I can add and show tabs with lazy loading and content management

This commit is contained in:
2025-11-15 22:34:04 +01:00
parent 93f6da66a5
commit 9a76bd57ba
11 changed files with 364 additions and 137 deletions

View File

@@ -1,7 +1,7 @@
import logging import logging.config
import yaml
from fasthtml import serve from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.Layout import Layout from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
@@ -10,15 +10,16 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.instances import InstancesManager from myfasthtml.core.instances import InstancesManager
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
logging.basicConfig( with open('logging.yaml', 'r') as f:
level=logging.DEBUG, # Set logging level to DEBUG config = yaml.safe_load(f)
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format # At the top of your script or module
) logging.config.dictConfig(config)
app, rt = create_app(protect_routes=True, app, rt = create_app(protect_routes=True,
mount_auth_app=True, mount_auth_app=True,
pico=False, pico=False,
vis=True,
title="MyFastHtml", title="MyFastHtml",
live=True, live=True,
base_url="http://localhost:5003") base_url="http://localhost:5003")
@@ -28,9 +29,6 @@ app, rt = create_app(protect_routes=True,
def index(session): def index(session):
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout") layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
layout.set_footer("Goodbye World") layout.set_footer("Goodbye World")
for i in range(50):
layout.left_drawer.add(Div(f"Left Drawer Item {i}"))
layout.right_drawer.add(Div(f"Left Drawer Item {i}"))
tabs_manager = TabsManager(session, _id="main") tabs_manager = TabsManager(session, _id="main")

54
src/logging.yaml Normal file
View File

@@ -0,0 +1,54 @@
version: 1
disable_existing_loggers: False
formatters:
default:
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: DEBUG
handlers: [console]
loggers:
# Explicit logger configuration (example)
multipart.multipart:
level: INFO
handlers: [console]
propagate: False
python_multipart.multipart:
level: INFO
handlers: [ console ]
propagate: False
httpcore:
level: ERROR
handlers: [ console ]
propagate: False
httpx:
level: INFO
handlers: [ console ]
propagate: False
watchfiles.main:
level: ERROR
handlers: [console]
propagate: False
dbengine.dbengine:
level: ERROR
handlers: [console]
propagate: False
passlib.registry:
level: ERROR
handlers: [console]
propagate: False
socket.socket:
level: ERROR
handlers: [ console ]
propagate: False

View File

@@ -380,6 +380,16 @@
/* Tab Content Area */ /* Tab Content Area */
.mf-tab-content { .mf-tab-content {
flex: 1;
overflow: auto;
height: 100%;
border: 2px solid black;
/*background-color: var(--color-base-100);*/
/*padding: 1rem;*/
/*border-top: 1px solid var(--color-border-primary);*/
}
.mf-tab-content-wrapper {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background-color: var(--color-base-100); background-color: var(--color-base-100);
@@ -396,3 +406,8 @@
@apply text-base-content/50; @apply text-base-content/50;
font-style: italic; font-style: italic;
} }
.mf-vis {
width: 100%;
height: 100%;
}

View File

@@ -207,3 +207,63 @@ function initBoundaries(elementId, updateUrl) {
observer.observe(container.parentNode, {childList: true}); observer.observe(container.parentNode, {childList: true});
} }
} }
/**
* Updates the tabs display by showing the active tab content and scrolling to make it visible.
* This function is called when switching between tabs to update both the content visibility
* and the tab button states.
*
* @param {string} controllerId - The ID of the tabs controller element (format: "{managerId}-content-controller")
*/
function updateTabs(controllerId) {
const controller = document.getElementById(controllerId);
if (!controller) {
console.warn(`Controller ${controllerId} not found`);
return;
}
const activeTabId = controller.dataset.activeTab;
if (!activeTabId) {
console.warn('No active tab ID found');
return;
}
// Extract manager ID from controller ID (remove '-content-controller' suffix)
const managerId = controllerId.replace('-content-controller', '');
// Hide all tab contents for this manager
const contentWrapper = document.getElementById(`${managerId}-content-wrapper`);
if (contentWrapper) {
contentWrapper.querySelectorAll('.mf-tab-content').forEach(content => {
content.classList.add('hidden');
});
// Show the active tab content
const activeContent = document.getElementById(`${managerId}-${activeTabId}-content`);
if (activeContent) {
activeContent.classList.remove('hidden');
}
}
// Update active tab button styling
const header = document.getElementById(`${managerId}-header`);
if (header) {
// Remove active class from all tabs
header.querySelectorAll('.mf-tab-button').forEach(btn => {
btn.classList.remove('mf-tab-active');
});
// Add active class to current tab
const activeButton = header.querySelector(`[data-tab-id="${activeTabId}"]`);
if (activeButton) {
activeButton.classList.add('mf-tab-active');
// Scroll to make active tab visible if needed
activeButton.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,39 @@
import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from fasthtml.common import Div, Span from fasthtml.common import Div, Span
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids, mk from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, BaseInstance from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.core.instances_helper import InstancesHelper
from myfasthtml.icons.fluent_p1 import tabs24_regular from myfasthtml.icons.fluent_p1 import tabs24_regular
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_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 @dataclass
class Boundaries: class Boundaries:
@@ -37,7 +59,7 @@ class Commands(BaseCommands):
def show_tab(self, tab_id): def show_tab(self, tab_id):
return Command(f"{self._prefix}ShowTab", return Command(f"{self._prefix}ShowTab",
"Activate or show a specific tab", "Activate or show a specific tab",
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML") self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-content-controller", swap="outerHTML")
def close_tab(self, tab_id): def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab", return Command(f"{self._prefix}CloseTab",
@@ -45,9 +67,10 @@ class Commands(BaseCommands):
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML") self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False): def add_tab(self, label: str, component: Any, auto_increment=False):
return Command(f"{self._prefix}AddTab", return (Command(f"{self._prefix}AddTab",
"Add a new tab", "Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).htmx(target=f"#{self._id}") self._owner.on_new_tab, label, component, auto_increment).
htmx(target=f"#{self._id}-content-controller"))
def update_boundaries(self): def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries", return Command(f"{self._prefix}UpdateBoundaries",
@@ -73,107 +96,23 @@ class TabsManager(MultipleInstance):
self._state = TabsManagerState(self) self._state = TabsManagerState(self)
self.commands = Commands(self) self.commands = Commands(self)
self._boundaries = Boundaries() self._boundaries = Boundaries()
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): def get_state(self):
return self._state return self._state
def _estimate_tab_width(self, label: str) -> int: def _get_ordered_tabs(self):
""" return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
Estimate the width of a tab based on its label.
Args: def _get_tab_content(self, tab_id):
label: Tab label text if tab_id not in self._state.tabs:
return None
Returns: tab_config = self._state.tabs[tab_id]
Estimated width in pixels if tab_config["component_type"] is None:
""" return None
char_width = len(label) * self.TAB_CHAR_WIDTH return InstancesHelper.dynamic_get(self._session, tab_config["component_type"], tab_config["component_id"])
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 @staticmethod
def _get_tab_count(): def _get_tab_count():
@@ -182,10 +121,16 @@ class TabsManager(MultipleInstance):
return res return res
def on_new_tab(self, label: str, component: Any, auto_increment=False): 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: if auto_increment:
label = f"{label}_{self._get_tab_count()}" label = f"{label}_{self._get_tab_count()}"
self.add_tab(label, component) tab_id = self.add_tab(label, component)
return self component = component or VisNetwork(self._session, nodes=vis_nodes, edges=vis_edges)
return (
self._mk_tabs_controller(),
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
self._mk_tabs_header(True),
)
def add_tab(self, label: str, component: Any, activate: bool = True) -> str: def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
""" """
@@ -199,13 +144,14 @@ class TabsManager(MultipleInstance):
Returns: Returns:
tab_id: The UUID of the tab (new or existing) 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 # copy the state to avoid multiple database call
state = self._state.copy() state = self._state.copy()
# Extract component ID if the component has a get_id() method # Extract component ID if the component has a get_id() method
component_type, component_id = None, None component_type, component_id = None, None
if isinstance(component, BaseInstance): if isinstance(component, BaseInstance):
component_type = type(component).__name__ component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_id = component.get_id() component_id = component.get_id()
# Check if a tab with the same component_type, component_id AND label already exists # Check if a tab with the same component_type, component_id AND label already exists
@@ -249,11 +195,23 @@ class TabsManager(MultipleInstance):
return tab_id return tab_id
def show_tab(self, tab_id): def show_tab(self, tab_id):
logger.debug(f"show_tab {tab_id=}")
if tab_id not in self._state.tabs: if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return None return None
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
self._state.active_tab = tab_id self._state.active_tab = tab_id
return self
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 close_tab(self, tab_id: str): def close_tab(self, tab_id: str):
""" """
@@ -265,6 +223,7 @@ class TabsManager(MultipleInstance):
Returns: Returns:
Self for chaining Self for chaining
""" """
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs: if tab_id not in self._state.tabs:
return self return self
@@ -309,7 +268,9 @@ class TabsManager(MultipleInstance):
return mk.icon(tab_add24_regular, return mk.icon(tab_add24_regular,
id=f"{self._id}-add-tab-btn", id=f"{self._id}-add-tab-btn",
cls="mf-tab-btn", cls="mf-tab-btn",
command=self.commands.add_tab(f"Untitled", None, True)) command=self.commands.add_tab(f"Untitled",
None,
True))
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False): def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
""" """
@@ -345,15 +306,7 @@ class TabsManager(MultipleInstance):
data_manager_id=self._id data_manager_id=self._id
) )
def _mk_tabs_header(self): def _mk_tabs_header(self, oob=False):
"""
Create the tabs header containing visible tab buttons and dropdown.
Returns:
Div element containing visible tabs and dropdown button
"""
visible_tabs, hidden_tabs = self._calculate_visible_tabs()
# Create visible tab buttons # Create visible tab buttons
visible_tab_buttons = [ visible_tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id]) self._mk_tab_button(tab_id, self._state.tabs[tab_id])
@@ -364,14 +317,28 @@ class TabsManager(MultipleInstance):
header_content = [*visible_tab_buttons] header_content = [*visible_tab_buttons]
return Div( return Div(
Div( Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"), self._mk_show_tabs_menu(),
self._mk_show_tabs_menu(), id=f"{self._id}-header-wrapper",
cls="mf-tabs-header-wrapper" cls="mf-tabs-header-wrapper",
), hx_swap_oob="true" if oob else None
),
def _mk_tabs_controller(self):
return Div(
Div(id=f"{self._id}-content-controller", data_active_tab=f"{self._state.active_tab}"),
Script(f'updateTabs("{self._id}-content-controller");'),
) )
def _mk_tab_content(self): 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_tab_content_wrapper(self):
""" """
Create the active tab content area. Create the active tab content area.
@@ -385,9 +352,9 @@ class TabsManager(MultipleInstance):
content = component content = component
return Div( return Div(
content if content else Div("No Content", cls="mf-empty-content"), self._mk_tab_content(self._state.active_tab, content),
cls="mf-tab-content", cls="mf-tab-content-wrapper",
id=f"{self._id}-content" id=f"{self._id}-content-wrapper",
) )
def _mk_show_tabs_menu(self): def _mk_show_tabs_menu(self):
@@ -405,6 +372,15 @@ class TabsManager(MultipleInstance):
cls="dropdown dropdown-end" 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 update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")
def render(self): def render(self):
""" """
Render the complete TabsManager component. Render the complete TabsManager component.
@@ -413,10 +389,11 @@ class TabsManager(MultipleInstance):
Div element containing tabs header, content area, and resize script Div element containing tabs header, content area, and resize script
""" """
return Div( return Div(
self._mk_tabs_controller(),
self._mk_tabs_header(), self._mk_tabs_header(),
self._mk_tab_content(), self._mk_tab_content_wrapper(),
cls="mf-tabs-manager", cls="mf-tabs-manager",
id=self._id id=self._id,
) )
def __ft__(self): def __ft__(self):

View File

@@ -0,0 +1,71 @@
import logging
from fasthtml.components import Script, Div
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("VisNetwork")
class VisNetwork(MultipleInstance):
def __init__(self, session, _id=None, nodes=None, edges=None, options=None):
super().__init__(session, Ids.VisNetwork, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
# Default values
self.nodes = nodes or []
self.edges = edges or []
self.options = options or {
"autoResize": True,
"interaction": {
"dragNodes": True,
"zoomView": True,
"dragView": True,
},
"physics": {"enabled": True}
}
def render(self):
# Prepare JS arrays (no JSON library needed)
js_nodes = ",\n ".join(
f'{{ id: {n["id"]}, label: "{n.get("label", "")}" }}'
for n in self.nodes
)
js_edges = ",\n ".join(
f'{{ from: {e["from"]}, to: {e["to"]} }}'
for e in self.edges
)
# Convert Python options to JS
import json
js_options = json.dumps(self.options, indent=2)
return (
Div(
id=self._id,
cls="mf-vis",
#style="width:100%; height:100%;", # Let parent control the layout
),
# The script initializing Vis.js
Script(f"""
(function() {{
const container = document.getElementById("{self._id}");
const nodes = new vis.DataSet([
{js_nodes}
]);
const edges = new vis.DataSet([
{js_edges}
]);
const data = {{
nodes: nodes,
edges: edges
}};
const options = {js_options};
const network = new vis.Network(container, data, options);
}})();
""")
)
def __ft__(self):
return self.render()

View File

@@ -13,6 +13,7 @@ class Ids:
Layout = "mf-layout" Layout = "mf-layout"
TabsManager = "mf-tabs-manager" TabsManager = "mf-tabs-manager"
UserProfile = "mf-user-profile" UserProfile = "mf-user-profile"
VisNetwork = "mf-vis-network"
class mk: class mk:

View File

@@ -159,7 +159,7 @@ class Command(BaseCommand):
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
for r in ret[1:]: for r in ret[1:]:
if hasattr(r, 'attrs'): if hasattr(r, 'attrs'):
r.attrs["hx-swap-oob"] = "true" r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
if not ret_from_bindings: if not ret_from_bindings:
return ret return ret

View File

@@ -0,0 +1,11 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids
class InstancesHelper:
@staticmethod
def dynamic_get(session, component_type: str, instance_id: str):
if component_type == Ids.VisNetwork:
return VisNetwork(session, _id=instance_id)
return None

View File

@@ -31,6 +31,7 @@ def get_asset_content(filename):
def create_app(daisyui: Optional[bool] = True, def create_app(daisyui: Optional[bool] = True,
vis: Optional[bool] = True,
protect_routes: Optional[bool] = True, protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False, mount_auth_app: Optional[bool] = False,
base_url: Optional[str] = None, base_url: Optional[str] = None,
@@ -63,6 +64,11 @@ def create_app(daisyui: Optional[bool] = True,
Script(src="/myfasthtml/tailwindcss-browser@4.js"), Script(src="/myfasthtml/tailwindcss-browser@4.js"),
] ]
if vis:
hdrs += [
Script(src="/myfasthtml/vis-network.min.js"),
]
beforeware = create_auth_beforeware() if protect_routes else None beforeware = create_auth_beforeware() if protect_routes else None
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs) app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)