I can add multiple tabs
This commit is contained in:
@@ -1,25 +1,24 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import Div, Button, Span
|
||||
from fasthtml.common import Div, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
|
||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||
|
||||
|
||||
# Structure de tabs:
|
||||
# {
|
||||
# "tab-uuid-1": {
|
||||
# "label": "Users",
|
||||
# "component_type": "UsersPanel",
|
||||
# "component_id": "UsersPanel-abc123",
|
||||
# },
|
||||
# "tab-uuid-2": { ... }
|
||||
# }
|
||||
@dataclass
|
||||
class Boundaries:
|
||||
"""Store component boundaries"""
|
||||
width: int = 1020
|
||||
height: int = 782
|
||||
|
||||
|
||||
class TabsManagerState(DbObject):
|
||||
def __init__(self, owner):
|
||||
@@ -36,21 +35,155 @@ class TabsManagerState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}SowTab",
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
"Activate or show a specific tab",
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", 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}")
|
||||
|
||||
def update_boundaries(self):
|
||||
return Command(f"{self._prefix}UpdateBoundaries",
|
||||
"Update component boundaries",
|
||||
self._owner.update_boundaries).htmx(target=None)
|
||||
|
||||
def search_tab(self, query: str):
|
||||
return Command(f"{self._prefix}SearchTab",
|
||||
"Search for a tab by name",
|
||||
self._owner.search_tab, query).htmx(target=f"#{self._id}-dropdown-content")
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
# Constants for width calculation
|
||||
TAB_CHAR_WIDTH = 8 # pixels per character
|
||||
TAB_PADDING = 40 # padding + close button space
|
||||
TAB_MIN_WIDTH = 80 # minimum tab width
|
||||
DROPDOWN_BTN_WIDTH = 40 # width of the dropdown button
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, session, _id=None):
|
||||
super().__init__(session, Ids.TabsManager, _id=_id)
|
||||
self._state = TabsManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
self._boundaries = Boundaries()
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def on_new_tab(self, label: str, component: Any):
|
||||
def _estimate_tab_width(self, label: str) -> int:
|
||||
"""
|
||||
Estimate the width of a tab based on its label.
|
||||
|
||||
Args:
|
||||
label: Tab label text
|
||||
|
||||
Returns:
|
||||
Estimated width in pixels
|
||||
"""
|
||||
char_width = len(label) * self.TAB_CHAR_WIDTH
|
||||
total_width = char_width + self.TAB_PADDING
|
||||
return max(total_width, self.TAB_MIN_WIDTH)
|
||||
|
||||
def _calculate_visible_tabs(self) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Calculate which tabs should be visible and which should be in dropdown.
|
||||
Priority: active tab + adjacent tabs, then others.
|
||||
|
||||
Returns:
|
||||
Tuple of (visible_tab_ids, hidden_tab_ids)
|
||||
"""
|
||||
if self._boundaries.width == 0:
|
||||
# No width info yet, show all tabs
|
||||
return self._state.tabs_order.copy(), []
|
||||
|
||||
available_width = self._boundaries.width - self.DROPDOWN_BTN_WIDTH
|
||||
visible_tabs = []
|
||||
hidden_tabs = []
|
||||
|
||||
# Find active tab index
|
||||
active_index = -1
|
||||
if self._state.active_tab in self._state.tabs_order:
|
||||
active_index = self._state.tabs_order.index(self._state.active_tab)
|
||||
|
||||
# Calculate widths for all tabs
|
||||
tab_widths = {}
|
||||
for tab_id in self._state.tabs_order:
|
||||
label = self._state.tabs[tab_id].get('label', 'Untitled')
|
||||
tab_widths[tab_id] = self._estimate_tab_width(label)
|
||||
|
||||
# Strategy: Start with active tab, then add adjacent tabs alternating left/right
|
||||
used_width = 0
|
||||
added_indices = set()
|
||||
|
||||
# Always add active tab first if it exists
|
||||
if active_index >= 0:
|
||||
active_tab_id = self._state.tabs_order[active_index]
|
||||
used_width += tab_widths[active_tab_id]
|
||||
if used_width <= available_width:
|
||||
visible_tabs.append(active_tab_id)
|
||||
added_indices.add(active_index)
|
||||
else:
|
||||
# Even active tab doesn't fit, add to hidden
|
||||
hidden_tabs.append(active_tab_id)
|
||||
|
||||
# Add adjacent tabs alternating left and right
|
||||
left_offset = 1
|
||||
right_offset = 1
|
||||
|
||||
while used_width < available_width and len(added_indices) < len(self._state.tabs_order):
|
||||
added_this_round = False
|
||||
|
||||
# Try to add tab to the right
|
||||
right_index = active_index + right_offset
|
||||
if right_index < len(self._state.tabs_order) and right_index not in added_indices:
|
||||
tab_id = self._state.tabs_order[right_index]
|
||||
tab_width = tab_widths[tab_id]
|
||||
if used_width + tab_width <= available_width:
|
||||
visible_tabs.append(tab_id)
|
||||
added_indices.add(right_index)
|
||||
used_width += tab_width
|
||||
added_this_round = True
|
||||
right_offset += 1
|
||||
|
||||
# Try to add tab to the left
|
||||
left_index = active_index - left_offset
|
||||
if left_index >= 0 and left_index not in added_indices:
|
||||
tab_id = self._state.tabs_order[left_index]
|
||||
tab_width = tab_widths[tab_id]
|
||||
if used_width + tab_width <= available_width:
|
||||
visible_tabs.insert(0, tab_id) # Insert at beginning to maintain order
|
||||
added_indices.add(left_index)
|
||||
used_width += tab_width
|
||||
added_this_round = True
|
||||
left_offset += 1
|
||||
|
||||
# If we couldn't add any tab this round, we're done
|
||||
if not added_this_round:
|
||||
break
|
||||
|
||||
# All remaining tabs go to hidden
|
||||
for i, tab_id in enumerate(self._state.tabs_order):
|
||||
if i not in added_indices:
|
||||
hidden_tabs.append(tab_id)
|
||||
|
||||
return visible_tabs, hidden_tabs
|
||||
|
||||
@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):
|
||||
if auto_increment:
|
||||
label = f"{label}_{self._get_tab_count()}"
|
||||
self.add_tab(label, component)
|
||||
return self
|
||||
|
||||
@@ -122,44 +255,120 @@ class TabsManager(MultipleInstance):
|
||||
self._state.active_tab = tab_id
|
||||
return self
|
||||
|
||||
def _mk_tab_button(self, tab_id: str, tab_data: dict):
|
||||
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
|
||||
"""
|
||||
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)
|
||||
|
||||
return self
|
||||
|
||||
def search_tab(self, query: str):
|
||||
"""
|
||||
Search tabs by name (for dropdown search).
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
|
||||
Returns:
|
||||
Dropdown content with filtered tabs
|
||||
"""
|
||||
# This will be implemented later for search functionality
|
||||
pass
|
||||
|
||||
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_tab_button(self, tab_id: str, 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
|
||||
"""
|
||||
is_active = tab_id == self._state.active_tab
|
||||
|
||||
return Button(
|
||||
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
|
||||
close_btn = mk.mk(
|
||||
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
|
||||
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||
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_tabs_header(self):
|
||||
"""
|
||||
Create the tabs header containing all tab buttons.
|
||||
Create the tabs header containing visible tab buttons and dropdown.
|
||||
|
||||
Returns:
|
||||
Div element containing all tab buttons
|
||||
Div element containing visible tabs and dropdown button
|
||||
"""
|
||||
tab_buttons = [
|
||||
visible_tabs, hidden_tabs = self._calculate_visible_tabs()
|
||||
|
||||
# Create visible tab buttons
|
||||
visible_tab_buttons = [
|
||||
self._mk_tab_button(tab_id, 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(
|
||||
*tab_buttons,
|
||||
cls="mf-tabs-header",
|
||||
id=f"{self._id}-header"
|
||||
Div(
|
||||
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
self._mk_show_tabs_menu(),
|
||||
cls="mf-tabs-header-wrapper"
|
||||
),
|
||||
)
|
||||
|
||||
def _mk_tab_content(self):
|
||||
@@ -181,12 +390,27 @@ class TabsManager(MultipleInstance):
|
||||
id=f"{self._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(
|
||||
Div("content"),
|
||||
tabindex="-1",
|
||||
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
|
||||
),
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete TabsManager component.
|
||||
|
||||
Returns:
|
||||
Div element containing tabs header and content area
|
||||
Div element containing tabs header, content area, and resize script
|
||||
"""
|
||||
return Div(
|
||||
self._mk_tabs_header(),
|
||||
|
||||
Reference in New Issue
Block a user