442 lines
14 KiB
Python
442 lines
14 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._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()
|