I can add multiple tabs
This commit is contained in:
15
src/app.py
15
src/app.py
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
62
src/myfasthtml/controls/Boundaries.py
Normal file
62
src/myfasthtml/controls/Boundaries.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user