I can add and show tabs with lazy loading and content management

This commit is contained in:
2025-11-15 22:34:04 +01:00
parent 93f6da66a5
commit 9a76bd57ba
11 changed files with 364 additions and 137 deletions

View File

@@ -1,17 +1,39 @@
import logging
import uuid
from dataclasses import dataclass
from typing import Any
from fasthtml.common import Div, Span
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.VisNetwork import VisNetwork
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.core.instances_helper import InstancesHelper
from myfasthtml.icons.fluent_p1 import tabs24_regular
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
logger = logging.getLogger("TabsManager")
vis_nodes = [
{"id": 1, "label": "Node 1"},
{"id": 2, "label": "Node 2"},
{"id": 3, "label": "Node 3"},
{"id": 4, "label": "Node 4"},
{"id": 5, "label": "Node 5"}
]
vis_edges = [
{"from": 1, "to": 3},
{"from": 1, "to": 2},
{"from": 2, "to": 4},
{"from": 2, "to": 5},
{"from": 3, "to": 3}
]
@dataclass
class Boundaries:
@@ -37,7 +59,7 @@ class Commands(BaseCommands):
def show_tab(self, tab_id):
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")
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-content-controller", swap="outerHTML")
def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab",
@@ -45,9 +67,10 @@ class Commands(BaseCommands):
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}")
return (Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).
htmx(target=f"#{self._id}-content-controller"))
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
@@ -73,107 +96,23 @@ class TabsManager(MultipleInstance):
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()
logger.debug(f"TabsManager created with id: {self._id}")
logger.debug(f" tabs : {self._get_ordered_tabs()}")
logger.debug(f" active tab : {self._state.active_tab}")
def get_state(self):
return self._state
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 _get_ordered_tabs(self):
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
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
def _get_tab_content(self, tab_id):
if tab_id not in self._state.tabs:
return None
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
return None
return InstancesHelper.dynamic_get(self._session, tab_config["component_type"], tab_config["component_id"])
@staticmethod
def _get_tab_count():
@@ -182,10 +121,16 @@ class TabsManager(MultipleInstance):
return res
def on_new_tab(self, label: str, component: Any, auto_increment=False):
logger.debug(f"on_new_tab {label=}, {component=}, {auto_increment=}")
if auto_increment:
label = f"{label}_{self._get_tab_count()}"
self.add_tab(label, component)
return self
tab_id = self.add_tab(label, component)
component = component or VisNetwork(self._session, nodes=vis_nodes, edges=vis_edges)
return (
self._mk_tabs_controller(),
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
self._mk_tabs_header(True),
)
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
"""
@@ -199,13 +144,14 @@ class TabsManager(MultipleInstance):
Returns:
tab_id: The UUID of the tab (new or existing)
"""
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
# copy the state to avoid multiple database call
state = self._state.copy()
# Extract component ID if the component has a get_id() method
component_type, component_id = None, None
if isinstance(component, BaseInstance):
component_type = type(component).__name__
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_id = component.get_id()
# Check if a tab with the same component_type, component_id AND label already exists
@@ -249,11 +195,23 @@ class TabsManager(MultipleInstance):
return tab_id
def show_tab(self, tab_id):
logger.debug(f"show_tab {tab_id=}")
if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return None
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
self._state.active_tab = tab_id
return self
if tab_id not in self._state._tabs_content:
logger.debug(f" Content does not exist. Creating it.")
content = self._get_tab_content(tab_id)
tab_content = self._mk_tab_content(tab_id, content)
self._state._tabs_content[tab_id] = tab_content
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
else:
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
def close_tab(self, tab_id: str):
"""
@@ -265,6 +223,7 @@ class TabsManager(MultipleInstance):
Returns:
Self for chaining
"""
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs:
return self
@@ -309,7 +268,9 @@ class TabsManager(MultipleInstance):
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))
command=self.commands.add_tab(f"Untitled",
None,
True))
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
"""
@@ -345,15 +306,7 @@ class TabsManager(MultipleInstance):
data_manager_id=self._id
)
def _mk_tabs_header(self):
"""
Create the tabs header containing visible tab buttons and dropdown.
Returns:
Div element containing visible tabs and dropdown button
"""
visible_tabs, hidden_tabs = self._calculate_visible_tabs()
def _mk_tabs_header(self, oob=False):
# Create visible tab buttons
visible_tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
@@ -364,14 +317,28 @@ class TabsManager(MultipleInstance):
header_content = [*visible_tab_buttons]
return Div(
Div(
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
cls="mf-tabs-header-wrapper"
),
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
id=f"{self._id}-header-wrapper",
cls="mf-tabs-header-wrapper",
hx_swap_oob="true" if oob else None
),
def _mk_tabs_controller(self):
return Div(
Div(id=f"{self._id}-content-controller", data_active_tab=f"{self._state.active_tab}"),
Script(f'updateTabs("{self._id}-content-controller");'),
)
def _mk_tab_content(self):
def _mk_tab_content(self, tab_id: str, content):
is_active = tab_id == self._state.active_tab
return Div(
content if content else Div("No Content", cls="mf-empty-content"),
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
id=f"{self._id}-{tab_id}-content",
)
def _mk_tab_content_wrapper(self):
"""
Create the active tab content area.
@@ -385,9 +352,9 @@ class TabsManager(MultipleInstance):
content = component
return Div(
content if content else Div("No Content", cls="mf-empty-content"),
cls="mf-tab-content",
id=f"{self._id}-content"
self._mk_tab_content(self._state.active_tab, content),
cls="mf-tab-content-wrapper",
id=f"{self._id}-content-wrapper",
)
def _mk_show_tabs_menu(self):
@@ -405,6 +372,15 @@ class TabsManager(MultipleInstance):
cls="dropdown dropdown-end"
)
def _wrap_tab_content(self, tab_content):
return Div(
tab_content,
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
)
def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")
def render(self):
"""
Render the complete TabsManager component.
@@ -413,10 +389,11 @@ class TabsManager(MultipleInstance):
Div element containing tabs header, content area, and resize script
"""
return Div(
self._mk_tabs_controller(),
self._mk_tabs_header(),
self._mk_tab_content(),
self._mk_tab_content_wrapper(),
cls="mf-tabs-manager",
id=self._id
id=self._id,
)
def __ft__(self):