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.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped) self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed class Commands(BaseCommands): def show_tab(self, tab_id): return Command(f"ShowTab", "Activate or show a specific tab", self._owner, self._owner.show_tab, args=[tab_id, True, False], key=f"{self._owner.get_full_id()}-ShowTab-{tab_id}", ).htmx(target=f"#{self._id}-controller", swap="outerHTML") def close_tab(self, tab_id): return Command(f"CloseTab", "Close a specific tab", self._owner, self._owner.close_tab, kwargs={"tab_id": tab_id}, ).htmx(target=f"#{self._id}-controller", swap="outerHTML") def add_tab(self, label: str, component: Any, auto_increment=False): return Command(f"AddTab", "Add a new tab", self._owner, self._owner.on_new_tab, args=[label, component, auto_increment], key="#{id-name-args}", ).htmx(target=f"#{self._id}-controller", swap="outerHTML") class TabsManager(MultipleInstance): def __init__(self, parent, _id=None): super().__init__(parent, _id=_id) self._tab_count = 0 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 _dynamic_get_content(self, tab_id): if tab_id not in self._state.tabs: return Div("Tab not found.") tab_config = self._state.tabs[tab_id] if tab_config["component"] is None: return Div("Tab content does not support serialization.") # 1. Try to get existing component instance res = InstancesManager.get(self._session, tab_config["component"][1], None) if res is not None: logger.debug(f"Component {tab_config['component'][1]} already exists") return res # 2. Get or create parent if tab_config["component_parent"] is None: logger.error(f"No parent defined for tab {tab_id}") return Div("Failed to retrieve tab content (no parent).") parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None) if parent is None: logger.error(f"Parent {tab_config['component_parent'][1]} not found for tab {tab_id}") return Div("Parent component not available") # 3. If parent supports create_tab_content, use it if hasattr(parent, 'create_tab_content'): try: logger.debug(f"Asking parent {tab_config['component_parent'][1]} to create tab content for {tab_id}") content = parent.create_tab_content(tab_id) # Store in cache self._state.ns_tabs_content[tab_id] = content return content except Exception as e: logger.error(f"Error while parent creating tab content: {e}") return Div("Failed to retrieve tab content (cannot create).") else: # Parent doesn't support create_tab_content, fallback to error logger.error(f"Parent {tab_config['component_parent'][1]} doesn't support create_tab_content") return Div("Failed to retrieve tab content (create tab not supported).") def _get_or_create_tab_content(self, tab_id): """ Get tab content from cache or create it. This method ensures content is always stored in raw form (not wrapped). Args: tab_id: ID of the tab Returns: Raw content component (not wrapped in Div) """ if tab_id not in self._state.ns_tabs_content: self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id) return self._state.ns_tabs_content[tab_id] def _get_tab_count(self): res = self._tab_count self._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.create_tab(label, component) return self.show_tab(tab_id, oob=False) def show_or_create_tab(self, tab_id, label, component, activate=True): logger.debug(f"show_or_create_tab {tab_id=}, {label=}, {component=}, {activate=}") if tab_id not in self._state.tabs: self._add_or_update_tab(tab_id, label, component, activate) return self.show_tab(tab_id, activate=activate, oob=True) def create_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. The tab is not yet sent to the client. 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}") tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4()) self._add_or_update_tab(tab_id, label, component, activate) return tab_id def show_tab(self, tab_id, activate: bool = True, oob=True, is_new=True): """ Send the tab to the client if needed. If the tab was already sent, just update the active tab. :param tab_id: :param activate: :param oob: default=True so other control will not care of the target :param is_new: is it a new tab or an existing one? :return: """ 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']}") if activate: self._state.active_tab = tab_id # Get or create content (always stored in raw form) content = self._get_or_create_tab_content(tab_id) if tab_id not in self._state.ns_tabs_sent_to_client: logger.debug(f" Content not in client memory. Sending it.") self._state.ns_tabs_sent_to_client.add(tab_id) tab_content = self._mk_tab_content(tab_id, content) return (self._mk_tabs_controller(oob), self._mk_tabs_header_wrapper(oob), self._wrap_tab_content(tab_content, is_new)) else: logger.debug(f" Content already in client memory. Just switch.") return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date def change_tab_content(self, tab_id, label, component, activate=True): logger.debug(f"switch_tab {label=}, component={component}, activate={activate}") if tab_id not in self._state.tabs: logger.error(f" Tab {tab_id} not found. Cannot change its content.") return None self._add_or_update_tab(tab_id, label, component, activate) self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client return self.show_tab(tab_id, activate=activate, oob=True, is_new=False) 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: tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping, or self if tab not found """ logger.debug(f"close_tab {tab_id=}") if tab_id not in self._state.tabs: logger.debug(f" Tab not found.") 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.ns_tabs_content: del state.ns_tabs_content[tab_id] # Remove from content sent if tab_id in state.ns_tabs_sent_to_client: state.ns_tabs_sent_to_client.remove(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()) content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete") return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove 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, oob=False): return Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}", hx_on__after_settle=f'updateTabs("{self._id}-controller");', hx_swap_oob="true" if oob else None, ) 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 ] return Div( Div(*visible_tab_buttons, 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): """ Create a single tab button with its label and close button. Args: tab_data: Dictionary containing tab information (label, component_type, etc.) 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) ) return Div( tab_label, close_btn, cls=f"mf-tab-button {'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: content = self._get_or_create_tab_content(self._state.active_tab) tab_content = self._mk_tab_content(self._state.active_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): if tab_id is None: return Div("No Content", cls="mf-empty-content mf-tab-content hidden") 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 ''}", 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, is_new=True): if is_new: return Div( tab_content, hx_swap_oob=f"beforeend:#{self._id}-content-wrapper" ) else: tab_content.attrs["hx-swap-oob"] = "outerHTML" return tab_content def _tab_already_exists(self, label, component): if not isinstance(component, BaseInstance): return None component_type = component.get_prefix() 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') == (component_type, 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 parent_type, parent_id = None, None if isinstance(component, BaseInstance): component_type = component.get_prefix() component_id = component.get_id() parent = component.get_parent() if parent: parent_type = parent.get_prefix() parent_id = parent.get_id() # Add tab metadata to state state.tabs[tab_id] = { 'id': tab_id, 'label': label, 'component': (component_type, component_id) if component_type else None, 'component_parent': (parent_type, parent_id) if parent_type else None } # Add tab to order list if tab_id not in state.tabs_order: state.tabs_order.append(tab_id) # Add the content state.ns_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(), Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs cls="mf-tabs-manager", id=self._id, ) def __ft__(self): return self.render()