494 lines
16 KiB
Python
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()
|