Added first controls
This commit is contained in:
5
src/myfasthtml/controls/BaseCommands.py
Normal file
5
src/myfasthtml/controls/BaseCommands.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class BaseCommands:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
self._prefix = owner.get_prefix()
|
||||
62
src/myfasthtml/controls/Boundaries.py
Normal file
62
src/myfasthtml/controls/Boundaries.py
Normal 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()
|
||||
39
src/myfasthtml/controls/CommandsDebugger.py
Normal file
39
src/myfasthtml/controls/CommandsDebugger.py
Normal 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()
|
||||
94
src/myfasthtml/controls/Dropdown.py
Normal file
94
src/myfasthtml/controls/Dropdown.py
Normal 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';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
98
src/myfasthtml/controls/FileUpload.py
Normal file
98
src/myfasthtml/controls/FileUpload.py
Normal 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()
|
||||
34
src/myfasthtml/controls/InstancesDebugger.py
Normal file
34
src/myfasthtml/controls/InstancesDebugger.py
Normal 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()
|
||||
23
src/myfasthtml/controls/Keyboard.py
Normal file
23
src/myfasthtml/controls/Keyboard.py
Normal 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()
|
||||
326
src/myfasthtml/controls/Layout.py
Normal file
326
src/myfasthtml/controls/Layout.py
Normal 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()
|
||||
23
src/myfasthtml/controls/Mouse.py
Normal file
23
src/myfasthtml/controls/Mouse.py
Normal 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()
|
||||
90
src/myfasthtml/controls/Search.py
Normal file
90
src/myfasthtml/controls/Search.py
Normal 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()
|
||||
404
src/myfasthtml/controls/TabsManager.py
Normal file
404
src/myfasthtml/controls/TabsManager.py
Normal 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()
|
||||
83
src/myfasthtml/controls/UserProfile.py
Normal file
83
src/myfasthtml/controls/UserProfile.py
Normal 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()
|
||||
100
src/myfasthtml/controls/VisNetwork.py
Normal file
100
src/myfasthtml/controls/VisNetwork.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user