330 lines
9.1 KiB
Python
330 lines
9.1 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_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)
|
|
self._footer_content = None
|
|
|
|
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
|
|
return self
|
|
|
|
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-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(
|
|
*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"initResizer('{self._id}');"),
|
|
id=self._id,
|
|
cls="mf-layout",
|
|
)
|
|
|
|
def __ft__(self):
|
|
"""
|
|
FastHTML magic method for rendering.
|
|
|
|
Returns:
|
|
Div: The rendered layout
|
|
"""
|
|
return self.render()
|