Files
MyFastHtml/src/myfasthtml/controls/Layout.py

367 lines
11 KiB
Python

"""
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()