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):
|
def index(session):
|
||||||
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
|
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
|
||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
for i in range(1000):
|
for i in range(50):
|
||||||
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
layout.left_drawer.add(Div(f"Left Drawer Item {i}"))
|
||||||
layout.right_drawer.append(Div(f"Left Drawer Item {i}"))
|
layout.right_drawer.add(Div(f"Left Drawer Item {i}"))
|
||||||
|
|
||||||
tabs_manager = TabsManager(session, _id="main")
|
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",
|
btn_show_right_drawer = mk.button("show",
|
||||||
command=Command("ShowRightDrawer",
|
command=Command("ShowRightDrawer",
|
||||||
"Show Right Drawer",
|
"Show Right Drawer",
|
||||||
layout.toggle_drawer, "right"),
|
layout.toggle_drawer, "right"),
|
||||||
id="btn_show_right_drawer_id")
|
id="btn_show_right_drawer_id")
|
||||||
|
layout.header_left.add(tabs_manager.add_tab_btn())
|
||||||
layout.set_footer(btn_show_right_drawer)
|
layout.header_right.add(btn_show_right_drawer)
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
:root {
|
||||||
|
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||||
|
}
|
||||||
|
|
||||||
.mf-icon-20 {
|
.mf-icon-20 {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
@@ -14,6 +18,22 @@
|
|||||||
margin-bottom: 4px;
|
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
|
* MF Layout Component - CSS Grid Layout
|
||||||
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
||||||
@@ -81,13 +101,14 @@
|
|||||||
/* Left drawer */
|
/* Left drawer */
|
||||||
.mf-layout-left-drawer {
|
.mf-layout-left-drawer {
|
||||||
grid-area: 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 */
|
/* Right drawer */
|
||||||
.mf-layout-right-drawer {
|
.mf-layout-right-drawer {
|
||||||
grid-area: 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 */
|
/* Collapsed drawer states */
|
||||||
@@ -171,11 +192,7 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout Drawer Resizer Styles
|
|
||||||
*
|
|
||||||
* Styles for the resizable drawer borders with visual feedback
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Layout Drawer Resizer Styles
|
* Layout Drawer Resizer Styles
|
||||||
*
|
*
|
||||||
@@ -296,8 +313,21 @@
|
|||||||
.mf-tabs-header {
|
.mf-tabs-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 1;
|
||||||
min-height: 25px;
|
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 */
|
/* Individual Tab Button using DaisyUI tab classes */
|
||||||
@@ -319,6 +349,7 @@
|
|||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
border-radius: .25rem;
|
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);
|
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;
|
overflow: auto;
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty Content State */
|
/* Empty Content State */
|
||||||
|
|||||||
@@ -151,7 +151,59 @@ function initLayoutResizer(layoutId) {
|
|||||||
initResizers();
|
initResizers();
|
||||||
|
|
||||||
// Re-initialize after HTMX swaps within this layout
|
// 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();
|
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 fasthtml.common import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.Boundaries import Boundaries
|
||||||
from myfasthtml.controls.UserProfile import UserProfile
|
from myfasthtml.controls.UserProfile import UserProfile
|
||||||
from myfasthtml.controls.helpers import mk, Ids
|
from myfasthtml.controls.helpers import mk, Ids
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
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 import panel_left_expand20_regular as left_drawer_icon
|
||||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
||||||
|
|
||||||
@@ -64,15 +66,21 @@ class Layout(SingleInstance):
|
|||||||
right_drawer (bool): Whether to include a right drawer
|
right_drawer (bool): Whether to include a right drawer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class DrawerContent:
|
class Content:
|
||||||
def __init__(self, owner, side: Literal["left", "right"]):
|
def __init__(self, owner):
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self.side = side
|
|
||||||
self._content = []
|
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)
|
self._content.append(content)
|
||||||
|
|
||||||
|
if content_id is not None:
|
||||||
|
self._ids.add(content_id)
|
||||||
|
|
||||||
def get_content(self):
|
def get_content(self):
|
||||||
return self._content
|
return self._content
|
||||||
|
|
||||||
@@ -89,13 +97,16 @@ class Layout(SingleInstance):
|
|||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
# Content storage
|
# Content storage
|
||||||
self._header_content = None
|
|
||||||
self._footer_content = None
|
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(self)
|
self._state = LayoutState(self)
|
||||||
|
self._boundaries = Boundaries(session, self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.DrawerContent(self, "left")
|
self.left_drawer = self.Content(self)
|
||||||
self.right_drawer = self.DrawerContent(self, "right")
|
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):
|
def set_footer(self, content):
|
||||||
"""
|
"""
|
||||||
@@ -159,8 +170,16 @@ class Layout(SingleInstance):
|
|||||||
Header: FastHTML Header component
|
Header: FastHTML Header component
|
||||||
"""
|
"""
|
||||||
return Header(
|
return Header(
|
||||||
self._mk_left_drawer_icon(),
|
Div( # left
|
||||||
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
|
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"
|
cls="mf-layout-header"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
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.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
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:
|
@dataclass
|
||||||
# {
|
class Boundaries:
|
||||||
# "tab-uuid-1": {
|
"""Store component boundaries"""
|
||||||
# "label": "Users",
|
width: int = 1020
|
||||||
# "component_type": "UsersPanel",
|
height: int = 782
|
||||||
# "component_id": "UsersPanel-abc123",
|
|
||||||
# },
|
|
||||||
# "tab-uuid-2": { ... }
|
|
||||||
# }
|
|
||||||
|
|
||||||
class TabsManagerState(DbObject):
|
class TabsManagerState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
@@ -36,21 +35,155 @@ class TabsManagerState(DbObject):
|
|||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
def show_tab(self, tab_id):
|
def show_tab(self, tab_id):
|
||||||
return Command(f"{self._prefix}SowTab",
|
return Command(f"{self._prefix}ShowTab",
|
||||||
"Activate or show a specific tab",
|
"Activate or show a specific tab",
|
||||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
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):
|
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):
|
def __init__(self, session, _id=None):
|
||||||
super().__init__(session, Ids.TabsManager, _id=_id)
|
super().__init__(session, Ids.TabsManager, _id=_id)
|
||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
|
self._boundaries = Boundaries()
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
return self._state
|
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)
|
self.add_tab(label, component)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -122,44 +255,120 @@ class TabsManager(MultipleInstance):
|
|||||||
self._state.active_tab = tab_id
|
self._state.active_tab = tab_id
|
||||||
return self
|
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.
|
Create a single tab button with its label and close button.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tab_id: Unique identifier for the tab
|
tab_id: Unique identifier for the tab
|
||||||
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
||||||
|
in_dropdown: Whether this tab is rendered in the dropdown menu
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Button element representing the tab
|
Button element representing the tab
|
||||||
"""
|
"""
|
||||||
is_active = tab_id == self._state.active_tab
|
is_active = tab_id == self._state.active_tab
|
||||||
|
|
||||||
return Button(
|
close_btn = mk.mk(
|
||||||
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
|
|
||||||
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
|
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_tab_id=tab_id,
|
||||||
data_manager_id=self._id
|
data_manager_id=self._id
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_tabs_header(self):
|
def _mk_tabs_header(self):
|
||||||
"""
|
"""
|
||||||
Create the tabs header containing all tab buttons.
|
Create the tabs header containing visible tab buttons and dropdown.
|
||||||
|
|
||||||
Returns:
|
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])
|
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
||||||
for tab_id in self._state.tabs_order
|
for tab_id in self._state.tabs_order
|
||||||
if tab_id in self._state.tabs
|
if tab_id in self._state.tabs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
header_content = [*visible_tab_buttons]
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
*tab_buttons,
|
Div(
|
||||||
cls="mf-tabs-header",
|
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||||
id=f"{self._id}-header"
|
self._mk_show_tabs_menu(),
|
||||||
|
cls="mf-tabs-header-wrapper"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_tab_content(self):
|
def _mk_tab_content(self):
|
||||||
@@ -181,12 +390,27 @@ class TabsManager(MultipleInstance):
|
|||||||
id=f"{self._id}-content"
|
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):
|
def render(self):
|
||||||
"""
|
"""
|
||||||
Render the complete TabsManager component.
|
Render the complete TabsManager component.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div element containing tabs header and content area
|
Div element containing tabs header, content area, and resize script
|
||||||
"""
|
"""
|
||||||
return Div(
|
return Div(
|
||||||
self._mk_tabs_header(),
|
self._mk_tabs_header(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
class Ids:
|
class Ids:
|
||||||
# Please keep the alphabetical order
|
# Please keep the alphabetical order
|
||||||
AuthProxy = "mf-auth-proxy"
|
AuthProxy = "mf-auth-proxy"
|
||||||
|
Boundaries = "mf-boundaries"
|
||||||
DbManager = "mf-dbmanager"
|
DbManager = "mf-dbmanager"
|
||||||
Layout = "mf-layout"
|
Layout = "mf-layout"
|
||||||
TabsManager = "mf-tabs-manager"
|
TabsManager = "mf-tabs-manager"
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class BaseCommand:
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Command({self.name})"
|
return f"Command({self.name})"
|
||||||
|
|
||||||
|
|||||||
@@ -223,116 +223,15 @@ def debug_session(session):
|
|||||||
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
||||||
|
|
||||||
|
|
||||||
import inspect
|
def get_id(obj):
|
||||||
from typing import Optional
|
if isinstance(obj, str):
|
||||||
|
return obj
|
||||||
|
elif hasattr(obj, "id"):
|
||||||
def build_args_kwargs(
|
return obj.id
|
||||||
parameters,
|
elif hasattr(obj, "get_id"):
|
||||||
values,
|
return obj.get_id()
|
||||||
default_args: Optional[list] = None,
|
else:
|
||||||
default_kwargs: Optional[dict] = None
|
return str(obj)
|
||||||
):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
|
|||||||
Reference in New Issue
Block a user