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.Search import Search from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.controls.helpers import mk from myfasthtml.core.commands import Command from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager 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: """Store component boundaries""" width: int = 1020 height: int = 782 class TabsManagerState(DbObject): def __init__(self, owner): super().__init__(owner) with self.initializing(): # persisted in DB self.tabs: dict[str, Any] = {} self.tabs_order: list[str] = [] self.active_tab: str | None = None # must not be persisted in DB self._tabs_content: dict[str, Any] = {} 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}-controller", 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}-controller")) class TabsManager(MultipleInstance): _tab_count = 0 def __init__(self, parent, _id=None): super().__init__(parent, _id=_id) self._state = TabsManagerState(self) self.commands = Commands(self) self._boundaries = Boundaries() self._search = Search(self, items=self._get_tab_list(), get_attr=lambda x: x["label"], template=self._mk_tab_button, _id="-search") 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 _get_ordered_tabs(self): return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order} 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 try: return InstancesManager.get(self._session, tab_config["component_id"]) except Exception as e: logger.error(f"Error while retrieving tab content: {e}") return Div("Tab not found.") @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): logger.debug(f"on_new_tab {label=}, {component=}, {auto_increment=}") if auto_increment: label = f"{label}_{self._get_tab_count()}" component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges) tab_id = self._tab_already_exists(label, component) if tab_id: return self.show_tab(tab_id) tab_id = self.add_tab(label, component) return ( self._mk_tabs_controller(), self._wrap_tab_content(self._mk_tab_content(tab_id, component)), self._mk_tabs_header_wrapper(True), ) def add_tab(self, label: str, component: Any, activate: bool = True) -> str: """ Add a new tab or update an existing one with the same component type, ID and label. Args: label: Display label for the tab component: Component instance to display in the tab activate: Whether to activate the new/updated tab immediately (default: True) 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 = 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 existing_tab_id = self._tab_already_exists(label, component) if existing_tab_id: # Update existing tab (only the component instance in memory) tab_id = existing_tab_id state._tabs_content[tab_id] = component else: # Create new tab tab_id = str(uuid.uuid4()) # Add tab metadata to state state.tabs[tab_id] = { 'id': tab_id, 'label': label, 'component_type': component_type, 'component_id': component_id } # Add tab to order state.tabs_order.append(tab_id) # Store component in memory state._tabs_content[tab_id] = component # Activate tab if requested if activate: state.active_tab = tab_id # finally, update the state self._state.update(state) self._search.set_items(self._get_tab_list()) 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 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 switch_tab(self, tab_id, label, component, activate=True): logger.debug(f"switch_tab {label=}, component={component}, activate={activate}") self._add_or_update_tab(tab_id, label, component, activate) return self.show_tab(tab_id) # 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 """ logger.debug(f"close_tab {tab_id=}") 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) self._search.set_items(self._get_tab_list()) return self 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_tabs_controller(self): return Div( Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"), Script(f'updateTabs("{self._id}-controller");'), ) def _mk_tabs_header_wrapper(self, oob=False): # Create visible tab buttons visible_tab_buttons = [ self._mk_tab_button(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( 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_tab_button(self, 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 """ tab_id = tab_data["id"] is_active = tab_id == self._state.active_tab close_btn = mk.mk( Span(dismiss_circle16_regular, cls="mf-tab-close-btn"), 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_tab_content_wrapper(self): """ Create the active tab content area. Returns: Div element containing the active tab content or empty container """ if self._state.active_tab: active_tab = self._state.active_tab if active_tab in self._state._tabs_content: tab_content = self._state._tabs_content[active_tab] else: content = self._get_tab_content(active_tab) tab_content = self._mk_tab_content(active_tab, content) self._state._tabs_content[active_tab] = tab_content else: tab_content = self._mk_tab_content(None, None) return Div( tab_content, cls="mf-tab-content-wrapper", id=f"{self._id}-content-wrapper", ) 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_show_tabs_menu(self): return Div( mk.icon(tabs24_regular, size="32", tabindex="0", role="button", cls="btn btn-xs"), Div( self._search, tabindex="-1", cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl" ), 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 _tab_already_exists(self, label, component): if not isinstance(component, BaseInstance): return None component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__ component_id = component.get_id() if component_id is not None: for tab_id, tab_data in self._state.tabs.items(): if (tab_data.get('component_type') == component_type and tab_data.get('component_id') == component_id and tab_data.get('label') == label): return tab_id return None def _get_tab_list(self): return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs] def _add_or_update_tab(self, tab_id, label, component, activate): 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 = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__ component_id = component.get_id() # Add tab metadata to state state.tabs[tab_id] = { 'id': tab_id, 'label': label, 'component_type': component_type, 'component_id': component_id } # Add the content state._tabs_content[tab_id] = component # Activate tab if requested if activate: state.active_tab = tab_id # finally, update the state self._state.update(state) self._search.set_items(self._get_tab_list()) def update_boundaries(self): return Script(f"updateBoundaries('{self._id}');") def render(self): """ Render the complete TabsManager component. Returns: Div element containing tabs header, content area, and resize script """ return Div( self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), self._mk_tab_content_wrapper(), cls="mf-tabs-manager", id=self._id, ) def __ft__(self): return self.render()