""" 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_contract20_regular as left_drawer_contract from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand logger = logging.getLogger("LayoutControl") class LayoutState(DbObject): def __init__(self, owner, name=None): super().__init__(owner, name=name) 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"], width: int = None): """ Create a command to update drawer width. Args: side: Which drawer to update ("left" or "right") width: New width in pixels. Given by the HTMX request 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, "default_layout") 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_main(self, content): """ Set the main content area. Args: content: FastHTML component(s) or content for the main area """ self._main_content = content return self def toggle_drawer(self, side: Literal["left", "right"]): """ Toggle the state of a drawer (open or close) based on the specified side. This method also generates the corresponding icon and drawer elements for the selected side. :param side: The side of the drawer to toggle. Must be either "left" or "right". :type side: Literal["left", "right"] :return: A tuple containing the updated drawer icon and drawer elements for the specified side. :rtype: Tuple[Any, Any] :raises ValueError: If the provided `side` is not "left" or "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._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children, cls="flex gap-1", id=f"{self._id}_hl" ), Div( # right *self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children, UserProfile(self), cls="flex gap-1", id=f"{self._id}_hr" ), cls="mf-layout-header", ) def _mk_footer(self): """ Render the footer component. Returns: Footer: FastHTML Footer component """ return Footer( Div( # left *self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children, cls="flex gap-1", id=f"{self._id}_fl" ), Div( # right *self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children, cls="flex gap-1", id=f"{self._id}_fr" ), 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-resizer mf-resizer-left", 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-resizer mf-resizer-right", data_command_id=self.commands.update_drawer_width("right").id, data_side="right" ) # Wrap content in scrollable container content_wrapper = Div( *[ ( Div(cls="divider") if index > 0 else None, group_ft, *[item for item in self.right_drawer.get_content()[group_name]] ) for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups()) ], 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(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand, id=f"{self._id}_ldi", command=self.commands.toggle_drawer("left")) def _mk_right_drawer_icon(self): return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand, id=f"{self._id}_rdi", command=self.commands.toggle_drawer("right")) @staticmethod def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False): return Div( *[ ( Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None, group_ft if show_group_name else None, *[item for item in content.get_content()[group_name]] ) for index, (group_name, group_ft) in enumerate(content.get_groups()) ], cls="mf-layout-drawer-content" ) def render(self): """ Render the complete layout. Returns: Div: Complete layout as FastHTML Div component """ # Wrap everything in a container div return Div( Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips self._mk_header(), self._mk_left_drawer(), self._mk_main(), self._mk_right_drawer(), self._mk_footer(), Script(f"initLayout('{self._id}');"), id=self._id, cls="mf-layout", ) def __ft__(self): """ FastHTML magic method for rendering. Returns: Div: The rendered layout """ return self.render()