I can add and show tabs with lazy loading and content management
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user