Files
MyFastHtml/src/myfasthtml/controls/TabsManager.py

494 lines
16 KiB
Python

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()