I can add multiple tabs

This commit is contained in:
2025-11-14 22:32:26 +01:00
parent 7ff8b3ea14
commit 93f6da66a5
9 changed files with 452 additions and 164 deletions

View File

@@ -28,24 +28,19 @@ app, rt = create_app(protect_routes=True,
def index(session):
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
layout.set_footer("Goodbye World")
for i in range(1000):
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
layout.right_drawer.append(Div(f"Left Drawer Item {i}"))
for i in range(50):
layout.left_drawer.add(Div(f"Left Drawer Item {i}"))
layout.right_drawer.add(Div(f"Left Drawer Item {i}"))
tabs_manager = TabsManager(session, _id="main")
btn = mk.button("Add Tab",
command=Command("AddTab",
"Add a new tab",
tabs_manager.on_new_tab, "Tabs", Div("Content")).
htmx(target=f"#{tabs_manager.get_id()}"))
btn_show_right_drawer = mk.button("show",
command=Command("ShowRightDrawer",
"Show Right Drawer",
layout.toggle_drawer, "right"),
id="btn_show_right_drawer_id")
layout.set_footer(btn_show_right_drawer)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.set_main(tabs_manager)
return layout

View File

@@ -1,3 +1,7 @@
:root {
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
}
.mf-icon-20 {
width: 20px;
min-width: 20px;
@@ -14,6 +18,22 @@
margin-bottom: 4px;
}
.mf-icon-24 {
width: 24px;
min-width: 24px;
height: 24px;
margin-top: auto;
margin-bottom: 4px;
}
.mf-icon-32 {
width: 32px;
min-width: 32px;
height: 32px;
margin-top: auto;
margin-bottom: 4px;
}
/*
* MF Layout Component - CSS Grid Layout
* Provides fixed header/footer, collapsible drawers, and scrollable main content
@@ -81,13 +101,14 @@
/* Left drawer */
.mf-layout-left-drawer {
grid-area: left-drawer;
border-right: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
border-right: 1px solid var(--color-border-primary);
}
/* Right drawer */
.mf-layout-right-drawer {
grid-area: right-drawer;
border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
border-left: 1px solid var(--color-border-primary);
}
/* Collapsed drawer states */
@@ -171,11 +192,7 @@
grid-template-columns: 1fr;
}
/**
* Layout Drawer Resizer Styles
*
* Styles for the resizable drawer borders with visual feedback
*/
/**
* Layout Drawer Resizer Styles
*
@@ -296,8 +313,21 @@
.mf-tabs-header {
display: flex;
gap: 0;
flex-shrink: 0;
flex-shrink: 1;
min-height: 25px;
overflow-x: hidden;
overflow-y: hidden;
white-space: nowrap;
}
.mf-tabs-header-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden; /* important */
}
/* Individual Tab Button using DaisyUI tab classes */
@@ -319,6 +349,7 @@
background-color: var(--color-base-100);
color: var(--color-base-content);
border-radius: .25rem;
border-bottom: 4px solid var(--color-primary);
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
}
@@ -353,6 +384,7 @@
overflow: auto;
background-color: var(--color-base-100);
padding: 1rem;
border-top: 1px solid var(--color-border-primary);
}
/* Empty Content State */

View File

@@ -151,7 +151,59 @@ function initLayoutResizer(layoutId) {
initResizers();
// Re-initialize after HTMX swaps within this layout
layoutElement.addEventListener('htmx:afterSwap', function(event) {
layoutElement.addEventListener('htmx:afterSwap', function (event) {
console.log('Layout swapped:', event.detail.target);
initResizers();
});
}
function initBoundaries(elementId, updateUrl) {
function updateBoundaries() {
const container = document.getElementById(elementId);
if (!container) {
console.warn("initBoundaries : element " + elementId + " is not found !");
return;
}
const rect = container.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
console.log("boundaries: ", rect)
// Send boundaries to server
htmx.ajax('POST', updateUrl, {
target: '#' + elementId,
swap: 'outerHTML',
values: {width: width, height: height}
});
}
// Debounce function
let resizeTimeout;
function debouncedUpdate() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateBoundaries, 250);
}
// Update on load
setTimeout(updateBoundaries, 100);
// Update on window resize
const container = document.getElementById(elementId);
container.addEventListener('resize', debouncedUpdate);
// Cleanup on element removal
if (container) {
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.removedNodes.forEach(function (node) {
if (node.id === elementId) {
window.removeEventListener('resize', debouncedUpdate);
}
});
});
});
observer.observe(container.parentNode, {childList: true});
}
}

View File

@@ -0,0 +1,62 @@
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance
class BoundariesState:
def __init__(self):
# persisted in DB
self.width: int = 0
self.height: int = 0
class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
class Boundaries(SingleInstance):
"""
Ask the boundaries of the given control
Keep the boundaries updated
"""
def __init__(self, session, owner, container_id: str = None, on_resize=None):
super().__init__(session, Ids.Boundaries)
self._owner = owner
self._container_id = container_id or owner.get_id()
self._on_resize = on_resize
self._commands = Commands(self)
self._state = BoundariesState()
self._get_boundaries_command = self._commands.update_boundaries()
@property
def width(self):
return self._state.width
@property
def height(self):
return self._state.height
def update_boundaries(self, width: int, height: int):
"""
Update the component boundaries.
Args:
width: Available width in pixels
height: Available height in pixels
"""
self._state.width = width
self._state.height = height
return self._on_resize() if self._on_resize else self._owner
def render(self):
return Script(f"initBoundaries('{self._container_id}', '{self._get_boundaries_command.url}');")
def __ft__(self):
return self.render()

View File

@@ -10,11 +10,13 @@ from typing import Literal
from fasthtml.common import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Boundaries import Boundaries
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.controls.helpers import mk, Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.core.utils import get_id
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
@@ -64,14 +66,20 @@ class Layout(SingleInstance):
right_drawer (bool): Whether to include a right drawer
"""
class DrawerContent:
def __init__(self, owner, side: Literal["left", "right"]):
class Content:
def __init__(self, owner):
self._owner = owner
self.side = side
self._content = []
self._ids = set()
def append(self, content):
def add(self, content):
content_id = get_id(content)
if content_id in self._ids:
return
self._content.append(content)
if content_id is not None:
self._ids.add(content_id)
def get_content(self):
return self._content
@@ -89,13 +97,16 @@ class Layout(SingleInstance):
self.app_name = app_name
# Content storage
self._header_content = None
self._footer_content = None
self._main_content = None
self._state = LayoutState(self)
self._boundaries = Boundaries(session, self)
self.commands = Commands(self)
self.left_drawer = self.DrawerContent(self, "left")
self.right_drawer = self.DrawerContent(self, "right")
self.left_drawer = self.Content(self)
self.right_drawer = self.Content(self)
self.header_left = self.Content(self)
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
def set_footer(self, content):
"""
@@ -159,8 +170,16 @@ class Layout(SingleInstance):
Header: FastHTML Header component
"""
return Header(
self._mk_left_drawer_icon(),
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
),
Div( # right
*self.header_right.get_content(),
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
cls="flex gap-1"
),
cls="mf-layout-header"
)
@@ -281,4 +300,4 @@ class Layout(SingleInstance):
Returns:
Div: The rendered layout
"""
return self.render()
return self.render()

View File

@@ -1,25 +1,24 @@
import uuid
from dataclasses import dataclass
from typing import Any
from fasthtml.common import Div, Button, Span
from fasthtml.common import Div, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
from myfasthtml.icons.fluent_p1 import tabs24_regular
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
# Structure de tabs:
# {
# "tab-uuid-1": {
# "label": "Users",
# "component_type": "UsersPanel",
# "component_id": "UsersPanel-abc123",
# },
# "tab-uuid-2": { ... }
# }
@dataclass
class Boundaries:
"""Store component boundaries"""
width: int = 1020
height: int = 782
class TabsManagerState(DbObject):
def __init__(self, owner):
@@ -36,21 +35,155 @@ class TabsManagerState(DbObject):
class Commands(BaseCommands):
def show_tab(self, tab_id):
return Command(f"{self._prefix}SowTab",
return Command(f"{self._prefix}ShowTab",
"Activate or show a specific tab",
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab",
"Close a specific tab",
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False):
return Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).htmx(target=f"#{self._id}")
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner.update_boundaries).htmx(target=None)
def search_tab(self, query: str):
return Command(f"{self._prefix}SearchTab",
"Search for a tab by name",
self._owner.search_tab, query).htmx(target=f"#{self._id}-dropdown-content")
class TabsManager(MultipleInstance):
# Constants for width calculation
TAB_CHAR_WIDTH = 8 # pixels per character
TAB_PADDING = 40 # padding + close button space
TAB_MIN_WIDTH = 80 # minimum tab width
DROPDOWN_BTN_WIDTH = 40 # width of the dropdown button
_tab_count = 0
def __init__(self, session, _id=None):
super().__init__(session, Ids.TabsManager, _id=_id)
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()
def get_state(self):
return self._state
def on_new_tab(self, label: str, component: Any):
def _estimate_tab_width(self, label: str) -> int:
"""
Estimate the width of a tab based on its label.
Args:
label: Tab label text
Returns:
Estimated width in pixels
"""
char_width = len(label) * self.TAB_CHAR_WIDTH
total_width = char_width + self.TAB_PADDING
return max(total_width, self.TAB_MIN_WIDTH)
def _calculate_visible_tabs(self) -> tuple[list[str], list[str]]:
"""
Calculate which tabs should be visible and which should be in dropdown.
Priority: active tab + adjacent tabs, then others.
Returns:
Tuple of (visible_tab_ids, hidden_tab_ids)
"""
if self._boundaries.width == 0:
# No width info yet, show all tabs
return self._state.tabs_order.copy(), []
available_width = self._boundaries.width - self.DROPDOWN_BTN_WIDTH
visible_tabs = []
hidden_tabs = []
# Find active tab index
active_index = -1
if self._state.active_tab in self._state.tabs_order:
active_index = self._state.tabs_order.index(self._state.active_tab)
# Calculate widths for all tabs
tab_widths = {}
for tab_id in self._state.tabs_order:
label = self._state.tabs[tab_id].get('label', 'Untitled')
tab_widths[tab_id] = self._estimate_tab_width(label)
# Strategy: Start with active tab, then add adjacent tabs alternating left/right
used_width = 0
added_indices = set()
# Always add active tab first if it exists
if active_index >= 0:
active_tab_id = self._state.tabs_order[active_index]
used_width += tab_widths[active_tab_id]
if used_width <= available_width:
visible_tabs.append(active_tab_id)
added_indices.add(active_index)
else:
# Even active tab doesn't fit, add to hidden
hidden_tabs.append(active_tab_id)
# Add adjacent tabs alternating left and right
left_offset = 1
right_offset = 1
while used_width < available_width and len(added_indices) < len(self._state.tabs_order):
added_this_round = False
# Try to add tab to the right
right_index = active_index + right_offset
if right_index < len(self._state.tabs_order) and right_index not in added_indices:
tab_id = self._state.tabs_order[right_index]
tab_width = tab_widths[tab_id]
if used_width + tab_width <= available_width:
visible_tabs.append(tab_id)
added_indices.add(right_index)
used_width += tab_width
added_this_round = True
right_offset += 1
# Try to add tab to the left
left_index = active_index - left_offset
if left_index >= 0 and left_index not in added_indices:
tab_id = self._state.tabs_order[left_index]
tab_width = tab_widths[tab_id]
if used_width + tab_width <= available_width:
visible_tabs.insert(0, tab_id) # Insert at beginning to maintain order
added_indices.add(left_index)
used_width += tab_width
added_this_round = True
left_offset += 1
# If we couldn't add any tab this round, we're done
if not added_this_round:
break
# All remaining tabs go to hidden
for i, tab_id in enumerate(self._state.tabs_order):
if i not in added_indices:
hidden_tabs.append(tab_id)
return visible_tabs, hidden_tabs
@staticmethod
def _get_tab_count():
res = TabsManager._tab_count
TabsManager._tab_count += 1
return res
def on_new_tab(self, label: str, component: Any, auto_increment=False):
if auto_increment:
label = f"{label}_{self._get_tab_count()}"
self.add_tab(label, component)
return self
@@ -122,44 +255,120 @@ class TabsManager(MultipleInstance):
self._state.active_tab = tab_id
return self
def _mk_tab_button(self, tab_id: str, tab_data: dict):
def close_tab(self, tab_id: str):
"""
Close a tab and remove it from the tabs manager.
Args:
tab_id: ID of the tab to close
Returns:
Self for chaining
"""
if tab_id not in self._state.tabs:
return self
# Copy state
state = self._state.copy()
# Remove from tabs and order
del state.tabs[tab_id]
state.tabs_order.remove(tab_id)
# Remove from content
if tab_id in state._tabs_content:
del state._tabs_content[tab_id]
# If closing active tab, activate another one
if state.active_tab == tab_id:
if state.tabs_order:
# Activate the first remaining tab
state.active_tab = state.tabs_order[0]
else:
state.active_tab = None
# Update state
self._state.update(state)
return self
def search_tab(self, query: str):
"""
Search tabs by name (for dropdown search).
Args:
query: Search query
Returns:
Dropdown content with filtered tabs
"""
# This will be implemented later for search functionality
pass
def add_tab_btn(self):
return mk.icon(tab_add24_regular,
id=f"{self._id}-add-tab-btn",
cls="mf-tab-btn",
command=self.commands.add_tab(f"Untitled", None, True))
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
"""
Create a single tab button with its label and close button.
Args:
tab_id: Unique identifier for the tab
tab_data: Dictionary containing tab information (label, component_type, etc.)
in_dropdown: Whether this tab is rendered in the dropdown menu
Returns:
Button element representing the tab
"""
is_active = tab_id == self._state.active_tab
return Button(
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
close_btn = mk.mk(
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
command=self.commands.close_tab(tab_id)
)
tab_label = mk.mk(
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"),
command=self.commands.show_tab(tab_id)
)
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
return Div(
tab_label,
close_btn,
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
data_tab_id=tab_id,
data_manager_id=self._id
)
def _mk_tabs_header(self):
"""
Create the tabs header containing all tab buttons.
Create the tabs header containing visible tab buttons and dropdown.
Returns:
Div element containing all tab buttons
Div element containing visible tabs and dropdown button
"""
tab_buttons = [
visible_tabs, hidden_tabs = self._calculate_visible_tabs()
# Create visible tab buttons
visible_tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
for tab_id in self._state.tabs_order
if tab_id in self._state.tabs
]
header_content = [*visible_tab_buttons]
return Div(
*tab_buttons,
cls="mf-tabs-header",
id=f"{self._id}-header"
Div(
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
cls="mf-tabs-header-wrapper"
),
)
def _mk_tab_content(self):
@@ -181,12 +390,27 @@ class TabsManager(MultipleInstance):
id=f"{self._id}-content"
)
def _mk_show_tabs_menu(self):
return Div(
mk.icon(tabs24_regular,
size="32",
tabindex="0",
role="button",
cls="btn btn-xs"),
Div(
Div("content"),
tabindex="-1",
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
),
cls="dropdown dropdown-end"
)
def render(self):
"""
Render the complete TabsManager component.
Returns:
Div element containing tabs header and content area
Div element containing tabs header, content area, and resize script
"""
return Div(
self._mk_tabs_header(),

View File

@@ -8,6 +8,7 @@ from myfasthtml.core.utils import merge_classes
class Ids:
# Please keep the alphabetical order
AuthProxy = "mf-auth-proxy"
Boundaries = "mf-boundaries"
DbManager = "mf-dbmanager"
Layout = "mf-layout"
TabsManager = "mf-tabs-manager"

View File

@@ -86,6 +86,10 @@ class BaseCommand:
return self
@property
def url(self):
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def __str__(self):
return f"Command({self.name})"

View File

@@ -223,116 +223,15 @@ def debug_session(session):
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
import inspect
from typing import Optional
def build_args_kwargs(
parameters,
values,
default_args: Optional[list] = None,
default_kwargs: Optional[dict] = None
):
"""
Build (args, kwargs) from a sequence or dict of inspect.Parameter and a dict of values.
- POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD fill `args`
- KEYWORD_ONLY fill `kwargs`
- VAR_POSITIONAL (*args) accepts list/tuple values
- VAR_KEYWORD (**kwargs) accepts dict values
- If not found, fallback to default_args / default_kwargs when provided
- Raises ValueError for missing required or unknown parameters
"""
if not isinstance(parameters, dict):
parameters = {p.name: p for p in parameters}
ordered_params = list(parameters.values())
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in ordered_params)
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ordered_params)
args = []
kwargs = {}
consumed_names = set()
default_args = default_args or []
default_kwargs = default_kwargs or {}
# 1 Handle positional parameters
positional_params = [
p for p in ordered_params
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
for i, p in enumerate(positional_params):
if p.name in values:
args.append(values[p.name])
consumed_names.add(p.name)
elif i < len(default_args):
args.append(default_args[i])
elif p.default is not inspect.Parameter.empty:
args.append(p.default)
else:
raise ValueError(f"Missing required positional argument: {p.name}")
# 2 Handle *args
for p in ordered_params:
if p.kind == inspect.Parameter.VAR_POSITIONAL:
if p.name in values:
val = values[p.name]
if not isinstance(val, (list, tuple)):
raise ValueError(f"*{p.name} must be a list or tuple, got {type(val).__name__}")
args.extend(val)
consumed_names.add(p.name)
elif len(default_args) > len(positional_params):
# Add any remaining default_args beyond fixed positionals
args.extend(default_args[len(positional_params):])
# 3 Handle keyword-only parameters
for p in ordered_params:
if p.kind == inspect.Parameter.KEYWORD_ONLY:
if p.name in values:
kwargs[p.name] = values[p.name]
consumed_names.add(p.name)
elif p.name in default_kwargs:
kwargs[p.name] = default_kwargs[p.name]
elif p.default is not inspect.Parameter.empty:
kwargs[p.name] = p.default
else:
raise ValueError(f"Missing required keyword-only argument: {p.name}")
# 4 Handle **kwargs
for p in ordered_params:
if p.kind == inspect.Parameter.VAR_KEYWORD:
if p.name in values:
val = values[p.name]
if not isinstance(val, dict):
raise ValueError(f"**{p.name} must be a dict, got {type(val).__name__}")
kwargs.update(val)
consumed_names.add(p.name)
# Merge any unmatched names if **kwargs exists
remaining = {
k: v for k, v in values.items()
if k not in consumed_names and k not in parameters
}
# Also merge default_kwargs not used yet
for k, v in default_kwargs.items():
if k not in kwargs:
kwargs[k] = v
kwargs.update(remaining)
break
# 5 Handle unknown / unexpected parameters (if no **kwargs)
if not has_var_keyword:
unexpected = [k for k in values if k not in consumed_names and k in parameters]
if unexpected:
raise ValueError(f"Unexpected parameters: {unexpected}")
extra = [k for k in values if k not in consumed_names and k not in parameters]
if extra:
raise ValueError(f"Unknown parameters: {extra}")
return args, kwargs
def get_id(obj):
if isinstance(obj, str):
return obj
elif hasattr(obj, "id"):
return obj.id
elif hasattr(obj, "get_id"):
return obj.get_id()
else:
return str(obj)
@utils_rt(Routes.Commands)