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.components import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
@@ -10,15 +10,16 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.instances import InstancesManager
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
with open('logging.yaml', 'r') as f:
config = yaml.safe_load(f)
# At the top of your script or module
logging.config.dictConfig(config)
app, rt = create_app(protect_routes=True,
mount_auth_app=True,
pico=False,
vis=True,
title="MyFastHtml",
live=True,
base_url="http://localhost:5003")
@@ -28,9 +29,6 @@ app, rt = create_app(protect_routes=True,
def index(session):
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
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")

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 */
.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;
overflow: auto;
background-color: var(--color-base-100);
@@ -395,4 +405,9 @@
height: 100%;
@apply text-base-content/50;
font-style: italic;
}
.mf-vis {
width: 100%;
height: 100%;
}

View File

@@ -206,4 +206,64 @@ function initBoundaries(elementId, updateUrl) {
});
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
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.VisNetwork import VisNetwork
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.core.instances_helper import InstancesHelper
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:
@@ -37,7 +59,7 @@ 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}", swap="outerHTML")
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-content-controller", swap="outerHTML")
def close_tab(self, tab_id):
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")
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}")
return (Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).
htmx(target=f"#{self._id}-content-controller"))
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
@@ -73,107 +96,23 @@ class TabsManager(MultipleInstance):
self._state = TabsManagerState(self)
self.commands = Commands(self)
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):
return self._state
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 _get_ordered_tabs(self):
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
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
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
return InstancesHelper.dynamic_get(self._session, tab_config["component_type"], tab_config["component_id"])
@staticmethod
def _get_tab_count():
@@ -182,10 +121,16 @@ class TabsManager(MultipleInstance):
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()}"
self.add_tab(label, component)
return self
tab_id = self.add_tab(label, component)
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:
"""
@@ -199,13 +144,14 @@ class TabsManager(MultipleInstance):
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 = type(component).__name__
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
@@ -249,11 +195,23 @@ class TabsManager(MultipleInstance):
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
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):
"""
@@ -265,6 +223,7 @@ class TabsManager(MultipleInstance):
Returns:
Self for chaining
"""
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs:
return self
@@ -309,7 +268,9 @@ class TabsManager(MultipleInstance):
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))
command=self.commands.add_tab(f"Untitled",
None,
True))
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
)
def _mk_tabs_header(self):
"""
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()
def _mk_tabs_header(self, oob=False):
# Create visible tab buttons
visible_tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
@@ -364,14 +317,28 @@ class TabsManager(MultipleInstance):
header_content = [*visible_tab_buttons]
return Div(
Div(
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
cls="mf-tabs-header-wrapper"
),
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_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.
@@ -385,9 +352,9 @@ class TabsManager(MultipleInstance):
content = component
return Div(
content if content else Div("No Content", cls="mf-empty-content"),
cls="mf-tab-content",
id=f"{self._id}-content"
self._mk_tab_content(self._state.active_tab, content),
cls="mf-tab-content-wrapper",
id=f"{self._id}-content-wrapper",
)
def _mk_show_tabs_menu(self):
@@ -405,6 +372,15 @@ class TabsManager(MultipleInstance):
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):
"""
Render the complete TabsManager component.
@@ -413,10 +389,11 @@ class TabsManager(MultipleInstance):
Div element containing tabs header, content area, and resize script
"""
return Div(
self._mk_tabs_controller(),
self._mk_tabs_header(),
self._mk_tab_content(),
self._mk_tab_content_wrapper(),
cls="mf-tabs-manager",
id=self._id
id=self._id,
)
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"
TabsManager = "mf-tabs-manager"
UserProfile = "mf-user-profile"
VisNetwork = "mf-vis-network"
class mk:

View File

@@ -159,7 +159,7 @@ class Command(BaseCommand):
if isinstance(ret, (list, tuple)):
for r in ret[1:]:
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:
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,
vis: Optional[bool] = True,
protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False,
base_url: Optional[str] = None,
@@ -63,6 +64,11 @@ def create_app(daisyui: Optional[bool] = True,
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
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
@@ -96,7 +102,7 @@ def create_app(daisyui: Optional[bool] = True,
if mount_auth_app:
# Setup authentication routes
setup_auth_routes(app, rt, base_url=base_url)
# create the AuthProxy instance
AuthProxy(base_url) # using the auto register mechanism to expose it