Added first controls

This commit is contained in:
2025-11-26 20:53:12 +01:00
parent 459c89bae2
commit ce5328fe34
68 changed files with 37849 additions and 87048 deletions

View File

@@ -0,0 +1,5 @@
class BaseCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
self._prefix = owner.get_prefix()

View File

@@ -0,0 +1,62 @@
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance
class BoundariesState:
def __init__(self):
# persisted in DB
self.width: int = 0
self.height: int = 0
class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
class Boundaries(SingleInstance):
"""
Ask the boundaries of the given control
Keep the boundaries updated
"""
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id)
self._owner = owner
self._container_id = container_id or owner.get_id()
self._on_resize = on_resize
self._commands = Commands(self)
self._state = BoundariesState()
self._get_boundaries_command = self._commands.update_boundaries()
@property
def width(self):
return self._state.width
@property
def height(self):
return self._state.height
def update_boundaries(self, width: int, height: int):
"""
Update the component boundaries.
Args:
width: Available width in pixels
height: Available height in pixels
"""
self._state.width = width
self._state.height = height
return self._on_resize() if self._on_resize else self._owner
def render(self):
return Script(f"initBoundaries('{self._container_id}', '{self._get_boundaries_command.url}');")
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,39 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
commands = self._get_commands()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent(x))
)
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
return vis_network
@staticmethod
def get_command_parent(command):
if (ft := command.get_ft()) is None:
return None
if hasattr(ft, "get_id") and callable(ft.get_id):
return ft.get_id()
if hasattr(ft, "get_prefix") and callable(ft.get_prefix):
return ft.get_prefix()
if hasattr(ft, "attrs"):
return ft.attrs.get("id", None)
return None
def _get_commands(self):
return list(CommandsManager.commands.values())
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,94 @@
from fastcore.xml import FT
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
class Commands(BaseCommands):
def close(self):
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
def click(self):
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
class DropdownState:
def __init__(self):
self.opened = False
class Dropdown(MultipleInstance):
def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id)
self.button = Div(button) if not isinstance(button, FT) else button
self.content = content
self.commands = Commands(self)
self._state = DropdownState()
self._toggle_command = self.commands.toggle()
def toggle(self):
self._state.opened = not self._state.opened
return self._mk_content()
def close(self):
self._state.opened = False
return self._mk_content()
def on_click(self, combination, is_inside: bool):
if combination == "click":
self._state.opened = is_inside
return self._mk_content()
def _mk_content(self):
return Div(self.content,
cls=f"mf-dropdown {'is-visible' if self._state.opened else ''}",
id=f"{self._id}-content"),
def render(self):
return Div(
Div(
Div(self.button) if self.button else Div("None"),
self._mk_content(),
cls="mf-dropdown-wrapper"
),
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
Mouse(self, "-mouse").add("click", self.commands.click()),
id=self._id
)
def __ft__(self):
return self.render()
# document.addEventListener('htmx:afterSwap', function(event) {
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
#
# // Vérifie si c'est bien notre popup
# if (targetElement.classList.contains('mf-popup-container')) {
#
# // Trouver l'élément déclencheur HTMX (le bouton existant)
# // HTMX stocke l'élément déclencheur dans event.detail.elt
# const trigger = document.querySelector('#mon-bouton-existant');
#
# if (trigger) {
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
# const rect = trigger.getBoundingClientRect();
#
# // L'élément du popup à positionner
# const popup = targetElement;
#
# // Appliquer la position au conteneur du popup
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
#
# // Top: Juste en dessous de l'élément déclencheur
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
#
# // Left: Aligner avec le côté gauche de l'élément déclencheur
# popup.style.left = (rect.left + window.scrollX) + 'px';
# }
# }
# });

View File

@@ -0,0 +1,98 @@
import logging
from io import BytesIO
import pandas as pd
from fastapi import UploadFile
from fasthtml.components import *
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
logger = logging.getLogger("FileUpload")
class FileUploadState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
# persisted in DB
# must not be persisted in DB (prefix ns_ = no_saving_)
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
class Commands(BaseCommands):
def __init__(self, owner):
super().__init__(owner)
def upload_file(self):
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = FileUploadState(self)
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
def mk_sheet_selector(self):
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
[Option(
name,
selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names]
return Select(
*options,
name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2"
)
@staticmethod
def get_sheets_names(file_content):
try:
excel_file = pd.ExcelFile(BytesIO(file_content))
sheet_names = excel_file.sheet_names
except Exception as ex:
logger.error(f"get_sheets_names: {ex=}")
sheet_names = []
return sheet_names
def render(self):
return Div(
Div(
mk.mk(Input(type='file',
name='file',
id=f"fi_{self._id}", # fn stands for 'file name'
value=self._state.ns_file_name,
hx_preserve="true",
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
command=self.commands.upload_file()
),
self.mk_sheet_selector(),
cls="flex"
),
mk.dialog_buttons(),
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,34 @@
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def render(self):
s_name = InstancesManager.get_session_user_name
instances = self._get_instances()
nodes, edges = from_parent_child_list(
instances,
id_getter=lambda x: x.get_full_id(),
label_getter=lambda x: f"{x.get_id()}",
parent_getter=lambda x: x.get_full_parent_id()
)
for edge in edges:
edge["color"] = "green"
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
for node in nodes:
node["shape"] = "box"
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
return vis_network
def _get_instances(self):
return list(InstancesManager.instances.values())
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,23 @@
import json
from fasthtml.xtend import Script
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand):
self.combinations[sequence] = command
return self
def render(self):
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,326 @@
"""
Layout component for FastHTML applications.
This component provides a responsive layout with fixed header/footer,
optional collapsible left/right drawers, and a scrollable main content area.
"""
import logging
from typing import Literal
from fasthtml.common import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Boundaries import Boundaries
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.utils import get_id
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
self.left_drawer_width: int = 250
self.right_drawer_width: int = 250
class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"]):
"""
Create a command to update drawer width.
Args:
side: Which drawer to update ("left" or "right")
Returns:
Command: Command object for updating drawer width
"""
return Command(
f"UpdateDrawerWidth_{side}",
f"Update {side} drawer width",
self._owner.update_drawer_width,
side
)
class Layout(SingleInstance):
"""
A responsive layout component with header, footer, main content area,
and optional collapsible side drawers.
Attributes:
app_name (str): Name of the application
left_drawer (bool): Whether to include a left drawer
right_drawer (bool): Whether to include a right drawer
"""
class Content:
def __init__(self, owner):
self._owner = owner
self._content = {}
self._groups = []
self._ids = set()
def add_group(self, group, group_ft=None):
group_ft = group_ft or Div(group, cls="mf-layout-group")
if not group:
group_ft = None
self._groups.append((group, group_ft))
self._content[group] = []
def add(self, content, group=None):
content_id = get_id(content)
if content_id in self._ids:
return
if group not in self._content:
self.add_group(group)
self._content[group] = []
self._content[group].append(content)
if content_id is not None:
self._ids.add(content_id)
def get_content(self):
return self._content
def get_groups(self):
return self._groups
def __init__(self, parent, app_name, _id=None):
"""
Initialize the Layout component.
Args:
app_name (str): Name of the application
left_drawer (bool): Enable left drawer. Default is True.
right_drawer (bool): Enable right drawer. Default is True.
"""
super().__init__(parent, _id=_id)
self.app_name = app_name
# Content storage
self._main_content = None
self._state = LayoutState(self)
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
self.right_drawer = self.Content(self)
self.header_left = self.Content(self)
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
def set_footer(self, content):
"""
Set the footer content.
Args:
content: FastHTML component(s) or content for the footer
"""
self._footer_content = content
def set_main(self, content):
"""
Set the main content area.
Args:
content: FastHTML component(s) or content for the main area
"""
self._main_content = content
def toggle_drawer(self, side: Literal["left", "right"]):
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
if side == "left":
self._state.left_drawer_open = not self._state.left_drawer_open
return self._mk_left_drawer_icon(), self._mk_left_drawer()
elif side == "right":
self._state.right_drawer_open = not self._state.right_drawer_open
return self._mk_right_drawer_icon(), self._mk_right_drawer()
else:
raise ValueError("Invalid drawer side")
def update_drawer_width(self, side: Literal["left", "right"], width: int):
"""
Update the width of a drawer.
Args:
side: Which drawer to update ("left" or "right")
width: New width in pixels
Returns:
Div: Updated drawer component
"""
# Constrain width between min and max values
width = max(150, min(600, width))
logger.debug(f"Update drawer width: {side=}, {width=}")
if side == "left":
self._state.left_drawer_width = width
return self._mk_left_drawer()
elif side == "right":
self._state.right_drawer_width = width
return self._mk_right_drawer()
else:
raise ValueError("Invalid drawer side")
def _mk_header(self):
"""
Render the header component.
Returns:
Header: FastHTML Header component
"""
return Header(
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
),
Div( # right
*self.header_right.get_content()[None],
UserProfile(self),
cls="flex gap-1"
),
cls="mf-layout-header"
)
def _mk_footer(self):
"""
Render the footer component.
Returns:
Footer: FastHTML Footer component
"""
footer_content = self._footer_content if self._footer_content else ""
return Footer(
footer_content,
cls="mf-layout-footer footer sm:footer-horizontal"
)
def _mk_main(self):
"""
Render the main content area.
Returns:
Main: FastHTML Main component
"""
main_content = self._main_content if self._main_content else ""
return Main(
main_content,
cls="mf-layout-main"
)
def _mk_left_drawer(self):
"""
Render the left drawer if enabled.
Returns:
Div: FastHTML Div component for left drawer
"""
resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-right",
data_command_id=self.commands.update_drawer_width("left").id,
data_side="left"
)
# Wrap content in scrollable container
content_wrapper = Div(
*[
(
Div(cls="divider") if index > 0 else None,
group_ft,
*[item for item in self.left_drawer.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(self.left_drawer.get_groups())
],
cls="mf-layout-drawer-content"
)
return Div(
content_wrapper,
resizer,
id=f"{self._id}_ld",
cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}",
style=f"width: {self._state.left_drawer_width if self._state.left_drawer_open else 0}px;"
)
def _mk_right_drawer(self):
"""
Render the right drawer if enabled.
Returns:
Div: FastHTML Div component for right drawer
"""
resizer = Div(
cls="mf-layout-resizer mf-layout-resizer-left",
data_command_id=self.commands.update_drawer_width("right").id,
data_side="right"
)
# Wrap content in scrollable container
content_wrapper = Div(
*self.right_drawer.get_content(),
cls="mf-layout-drawer-content"
)
return Div(
resizer,
content_wrapper,
cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}",
id=f"{self._id}_rd",
style=f"width: {self._state.right_drawer_width if self._state.right_drawer_open else 0}px;"
)
def _mk_left_drawer_icon(self):
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
id=f"{self._id}_ldi",
command=self.commands.toggle_drawer("left"))
def _mk_right_drawer_icon(self):
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
id=f"{self._id}_rdi",
command=self.commands.toggle_drawer("right"))
def render(self):
"""
Render the complete layout.
Returns:
Div: Complete layout as FastHTML Div component
"""
# Wrap everything in a container div
return Div(
self._mk_header(),
self._mk_left_drawer(),
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
Script(f"initLayoutResizer('{self._id}');"),
id=self._id,
cls="mf-layout",
)
def __ft__(self):
"""
FastHTML magic method for rendering.
Returns:
Div: The rendered layout
"""
return self.render()

View File

@@ -0,0 +1,23 @@
import json
from fasthtml.xtend import Script
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.instances import MultipleInstance
class Mouse(MultipleInstance):
def __init__(self, parent, _id=None, combinations=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand):
self.combinations[sequence] = command
return self
def render(self):
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,90 @@
import logging
from typing import Callable, Any
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
logger = logging.getLogger("Search")
class Commands(BaseCommands):
def search(self):
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms",
swap="innerHTML"))
class Search(MultipleInstance):
def __init__(self,
parent: BaseInstance,
_id=None,
items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
template: Callable[[Any], Any] = None): # once filtered, what to render ?
"""
Represents a component for managing and filtering a list of items based on specific criteria.
This class initializes with a session, an optional identifier, a list of item names,
a callable for extracting a string value from items, and a template callable for rendering
the filtered items. It provides functionality to handle and organize item-based operations.
:param _id: Optional identifier for the component.
:param items: An optional list of names for the items to be filtered.
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
function that returns the item as is.
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
"""
super().__init__(parent, _id=_id)
self.items_names = items_names or ''
self.items = items or []
self.filtered = self.items.copy()
self.get_attr = get_attr or (lambda x: x)
self.template = template or Div
self.commands = Commands(self)
def set_items(self, items):
self.items = items
self.filtered = self.items.copy()
return self
def on_search(self, query):
logger.debug(f"on_search {query=}")
self.search(query)
return tuple(self._mk_search_results())
def search(self, query):
logger.debug(f"search {query=}")
if query is None or query.strip() == "":
self.filtered = self.items.copy()
else:
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
self.filtered = res_seq + res_fuzzy
return self.filtered
def _mk_search_results(self):
return [self.template(item) for item in self.filtered]
def render(self):
return Div(
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
command=self.commands.search()),
Div(
*self._mk_search_results(),
id=f"{self._id}-results",
cls="mf-search-results",
),
id=f"{self._id}",
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,404 @@
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
return InstancesManager.get(self._session, tab_config["component_id"])
@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 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 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()

View File

@@ -0,0 +1,83 @@
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import mk
from myfasthtml.core.AuthProxy import AuthProxy
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, RootInstance
from myfasthtml.core.utils import retrieve_user_info
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
class UserProfileState:
def __init__(self, owner):
self._owner = owner
self._session = owner.get_session()
self.theme = "light"
self.load()
def load(self):
user_info = retrieve_user_info(self._session)
user_settings = user_info.get("user_settings", {})
for k, v in user_settings.items():
if hasattr(self, k):
setattr(self, k, v)
def save(self):
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
auth_proxy = AuthProxy(RootInstance)
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
class Commands(BaseCommands):
def update_dark_mode(self):
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
class UserProfile(SingleInstance):
def __init__(self, parent=None, _id=None):
super().__init__(parent, _id=_id)
self._state = UserProfileState(self)
self._commands = Commands(self)
def update_dark_mode(self, client_response):
self._state.theme = client_response.get("theme", "light")
self._state.save()
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
def render(self):
user_info = retrieve_user_info(self._session)
return Div(
Div(user_info['username'],
tabindex="0",
role="button",
cls="btn btn-xs"),
Div(
Div(mk.icon(person_outline_sharp, cls="mr-1"), user_info['username'], cls="flex m-1"),
Div(mk.icon(alternate_email_filled, cls="mr-1"), user_info['email'], cls="flex m-1"),
Div(mk.icon(dark_mode_filled, cls="mr-1"), self.mk_dark_mode(), cls="flex m-1"),
Div(A("Logout", cls="btn btn-xs mr-1", href="/logout"), cls="flex justify-center items-center"),
tabindex="-1",
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
),
cls="dropdown dropdown-end"
)
def mk_dark_mode(self):
return Label(
mk.mk(Input(type="checkbox",
name='theme',
aria_label='Dark',
value="dark",
checked='true' if self._state.theme == 'dark' else None,
cls='theme-controller'),
command=self._commands.update_dark_mode()),
light_mode_filled,
dark_mode_filled,
cls="toggle text-base-content"
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,100 @@
import json
import logging
from fasthtml.components import Script, Div
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("VisNetwork")
class VisNetworkState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
# persisted in DB
self.nodes: list = []
self.edges: list = []
self.options: dict = {
"autoResize": True,
"interaction": {
"dragNodes": True,
"zoomView": True,
"dragView": True,
},
"physics": {"enabled": True}
}
class VisNetwork(MultipleInstance):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
super().__init__(parent, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
self._state = VisNetworkState(self)
self._update_state(nodes, edges, options)
def _update_state(self, nodes, edges, options):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
if not nodes and not edges and not options:
return
state = self._state.copy()
if nodes is not None:
state.nodes = nodes
if edges is not None:
state.edges = edges
if options is not None:
state.options = options
self._state.update(state)
def add_to_options(self, **kwargs):
logger.debug(f"add_to_options: {kwargs=}")
new_options = self._state.options.copy() | kwargs
self._update_state(None, None, new_options)
return self
def render(self):
# Serialize nodes and edges to JSON
# This preserves all properties (color, shape, size, etc.) that are present
js_nodes = ",\n ".join(
json.dumps(node) for node in self._state.nodes
)
js_edges = ",\n ".join(
json.dumps(edge) for edge in self._state.edges
)
# Convert Python options to JS
js_options = json.dumps(self._state.options, indent=2)
return (
Div(
id=self._id,
cls="mf-vis",
),
# 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

@@ -5,12 +5,33 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.utils import merge_classes
class Ids:
# Please keep the alphabetical order
Root = "mf-root"
UserSession = "mf-user_session"
class mk:
@staticmethod
def button(element, command: Command = None, binding: Binding = None, **kwargs):
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod
def dialog_buttons(ok_title: str = "OK",
cancel_title: str = "Cancel",
on_ok: Command = None,
on_cancel: Command = None,
cls=None):
return Div(
Div(
mk.button(ok_title, cls="btn btn-primary btn-sm mr-2", command=on_ok),
mk.button(cancel_title, cls="btn btn-ghost btn-sm", command=on_cancel),
cls="flex justify-end"
),
cls=merge_classes("flex justify-end w-full mt-1", cls)
)
@staticmethod
def icon(icon, size=20,
can_select=True,
@@ -27,6 +48,27 @@ class mk:
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def label(text: str,
icon=None,
size: str = "sm",
cls='',
command: Command = None,
binding: Binding = None,
**kwargs):
merged_cls = merge_classes("flex", cls, kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size}")
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def convert_size(size: str):
return (size.replace("xs", "16").
replace("sm", "20").
replace("md", "24").
replace("lg", "28").
replace("xl", "32"))
@staticmethod
def manage_command(ft, command: Command):
if command:
@@ -50,6 +92,6 @@ class mk:
@staticmethod
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
ft = mk.manage_command(ft, command) if command else ft
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
return ft