From 5d6c02001eb854454abd3969267a54f16d6ff22a Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 11 Jan 2026 15:49:20 +0100 Subject: [PATCH] Implemented lazy loading --- src/myfasthtml/assets/myfasthtml.css | 2 +- src/myfasthtml/controls/DataGrid.py | 69 ++++++---- src/myfasthtml/controls/DataGridsManager.py | 3 +- src/myfasthtml/controls/datagrid_objects.py | 4 +- src/myfasthtml/core/commands.py | 26 ++-- src/myfasthtml/core/constants.py | 4 +- src/myfasthtml/core/instances.py | 17 +-- src/myfasthtml/core/optimized_ft.py | 142 +++++++++++--------- 8 files changed, 151 insertions(+), 116 deletions(-) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 6f515eb..3b54707 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -965,7 +965,7 @@ input:focus { flex-direction: column; border: 1px solid var(--color-border); border-radius: 10px; - overflow: hidden; + /* overflow: hidden; */ } .dt2-table:focus { diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index c33ed90..6dcc053 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -11,6 +11,7 @@ from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance @@ -25,14 +26,14 @@ _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']') @lru_cache(maxsize=2) def _mk_bool_cached(_value): - """ - OPTIMIZED: Cached boolean checkbox HTML generator. - Since there are only 2 possible values (True/False), this will only generate HTML twice. - """ - return NotStr(str( - Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False), - cls="dt2-cell-content-checkbox") - )) + """ + OPTIMIZED: Cached boolean checkbox HTML generator. + Since there are only 2 possible values (True/False), this will only generate HTML twice. + """ + return NotStr(str( + Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False), + cls="dt2-cell-content-checkbox") + )) class DatagridState(DbObject): @@ -41,7 +42,7 @@ class DatagridState(DbObject): with self.initializing(): self.sidebar_visible: bool = False self.selected_view: str = None - self.row_index: bool = False + self.row_index: bool = True self.columns: list[DataGridColumnState] = [] self.rows: list[DataGridRowState] = [] # only the rows that have a specific state self.headers: list[DataGridHeaderFooterConf] = [] @@ -70,7 +71,17 @@ class DatagridSettings(DbObject): class Commands(BaseCommands): - pass + def get_page(self, page_index: int): + return Command("GetPage", + "Get a specific page of data", + self._owner, + self._owner.mk_body_content_page, + kwargs={"page_index": page_index} + ).htmx(target=f"#tb_{self._id}", + swap="beforeend", + trigger=f"intersect root:#tb_{self._id} once", + auto_swap_oob=False + ) class DataGrid(MultipleInstance): @@ -175,18 +186,18 @@ class DataGrid(MultipleInstance): - Avoids html.escape when not necessary - Uses cached boolean HTML (_mk_bool_cached) """ - + def mk_highlighted_text(value_str, css_class): """Return highlighted text as raw HTML string or tuple of Spans.""" if not filter_keyword_lower: # OPTIMIZATION: Return plain HTML string instead of Label object # Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size) return NotStr(f'{value_str}') - + index = value_str.lower().find(filter_keyword_lower) if index < 0: return NotStr(f'{value_str}') - + # Has highlighting - need to use Span objects # Add "truncate text-sm" to match mk.label() behavior len_keyword = len(filter_keyword_lower) @@ -197,25 +208,25 @@ class DataGrid(MultipleInstance): if index + len_keyword < len(value_str): res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm")) return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] - + column_type = col_def.type value = self._state.ns_fast_access[col_def.col_id][row_index] - + # Boolean type - uses cached HTML (only 2 possible values) if column_type == ColumnType.Bool: return _mk_bool_cached(value) - + # RowIndex - simplest case, just return the number as plain HTML if column_type == ColumnType.RowIndex: return NotStr(f'{row_index}') - + # Convert value to string value_str = str(value) - + # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex) if _HTML_SPECIAL_CHARS_REGEX.search(value_str): value_str = html.escape(value_str) - + # Number or Text type if column_type == ColumnType.Number: return mk_highlighted_text(value_str, "dt2-cell-content-number") @@ -229,12 +240,12 @@ class DataGrid(MultipleInstance): """ if not col_def.usable: return None - + if not col_def.visible: return OptimizedDiv(cls="dt2-col-hidden") - + content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower) - + return OptimizedDiv(content, data_col=col_def.col_id, style=f"width:{col_def.width}px;", @@ -252,19 +263,19 @@ class DataGrid(MultipleInstance): last_row = df.index[end - 1] else: last_row = None - - # OPTIMIZATION: Extract filter keyword once (was being checked 10,000 times) + filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) filter_keyword_lower = filter_keyword.lower() if filter_keyword else None - + rows = [OptimizedDiv( *[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower) for col_pos, col_def in enumerate(self._state.columns)], cls="dt2-row", data_row=f"{row_index}", - _id=f"tr_{self._id}-{row_index}", + id=f"tr_{self._id}-{row_index}", + **self.commands.get_page(page_index + 1).get_htmx_params(escaped=True) if row_index == last_row else {} ) for row_index in df.index[start:end]] - + return rows def mk_body(self): @@ -290,7 +301,9 @@ class DataGrid(MultipleInstance): return Div( self.mk_headers(), self.mk_body(), - self.mk_footers() + self.mk_footers(), + cls="dt2-table", + id=f"t_{self._id}", ) def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index b444f89..e5f62d8 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -1,5 +1,6 @@ import uuid from dataclasses import dataclass +from io import BytesIO import pandas as pd from fasthtml.components import Div @@ -90,7 +91,7 @@ class DataGridsManager(MultipleInstance): def open_from_excel(self, tab_id, file_upload: FileUpload): excel_content = file_upload.get_content() - df = pd.read_excel(excel_content, file_upload.get_sheet_name()) + df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name()) dg = DataGrid(self._tabs_manager, save_state=True) dg.init_from_dataframe(df) document = DocumentDefinition( diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index 277bfa8..368e2bf 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType +from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType @dataclass @@ -17,7 +17,7 @@ class DataGridColumnState: type: ColumnType = ColumnType.Text visible: bool = True usable: bool = True - width: int = DEFAULT_COLUMN_WIDTH + width: int = DATAGRID_DEFAULT_COLUMN_WIDTH @dataclass diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index ff07149..be0daef 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -1,3 +1,4 @@ +import html import inspect import json import logging @@ -11,6 +12,7 @@ from myfasthtml.core.utils import flatten logger = logging.getLogger("Commands") +AUTO_SWAP_OOB = "__auto_swap_oob__" class Command: """ @@ -71,7 +73,7 @@ class Command: self.callback = callback self.default_args = args or [] self.default_kwargs = kwargs or {} - self._htmx_extra = {} + self._htmx_extra = {AUTO_SWAP_OOB: True} self._bindings = [] self._ft = None self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} @@ -97,7 +99,7 @@ class Command: def get_key(self): return self._key - def get_htmx_params(self): + def get_htmx_params(self, escaped=False): res = { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-swap": "outerHTML", @@ -115,10 +117,13 @@ class Command: # kwarg are given to the callback as values res["hx-vals"] |= self.default_kwargs + if escaped: + res["hx-vals"] = html.escape(json.dumps(res["hx-vals"])) + return res def execute(self, client_response: dict = None): - logger.debug(f"Executing command {self.name}") + logger.debug(f"Executing command {self.name} with arguments {client_response=}") with ObservableResultCollector(self._bindings) as collector: kwargs = self._create_kwargs(self.default_kwargs, client_response, @@ -135,15 +140,18 @@ class Command: all_ret = flatten(ret, ret_from_bound_commands, collector.results) # Set the hx-swap-oob attribute on all elements returned by the callback - for r in all_ret[1:]: - if (hasattr(r, 'attrs') - and "hx-swap-oob" not in r.attrs - and r.get("id", None) is not None): - r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") + if self._htmx_extra[AUTO_SWAP_OOB]: + for r in all_ret[1:]: + if (hasattr(r, 'attrs') + and "hx-swap-oob" not in r.attrs + and r.get("id", None) is not None): + r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") return all_ret[0] if len(all_ret) == 1 else all_ret - def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None): + def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True): + self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob + # Note that the default value is the same than in get_htmx_params() if target is None: self._htmx_extra["hx-swap"] = "none" diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index f87b623..be32113 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -1,14 +1,16 @@ from enum import Enum -DEFAULT_COLUMN_WIDTH = 100 +NO_DEFAULT_VALUE = object() ROUTE_ROOT = "/myfasthtml" # Datagrid ROW_INDEX_ID = "__row_index__" +DATAGRID_DEFAULT_COLUMN_WIDTH = 100 DATAGRID_PAGE_SIZE = 1000 FILTER_INPUT_CID = "__filter_input__" + class Routes: Commands = "/commands" Bindings = "/bindings" diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index ec8f766..760de6a 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -3,6 +3,7 @@ import uuid from typing import Optional from myfasthtml.controls.helpers import Ids +from myfasthtml.core.constants import NO_DEFAULT_VALUE from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal logger = logging.getLogger("InstancesManager") @@ -183,7 +184,7 @@ class InstancesManager: return instance @staticmethod - def get(session: dict, instance_id: str, default="**__no_default__**"): + def get(session: dict, instance_id: str, default=NO_DEFAULT_VALUE): """ Get or create an instance of the given type (from its id) :param session: @@ -196,9 +197,9 @@ class InstancesManager: key = (session_id, instance_id) return InstancesManager.instances[key] except KeyError: - if default != "**__non__**": - return default - raise + if default is NO_DEFAULT_VALUE: + raise + return default @staticmethod def get_by_type(session: dict, cls: type): @@ -211,19 +212,19 @@ class InstancesManager: @staticmethod def dynamic_get(session, component_parent: tuple, component: tuple): component_type, component_id = component - + # 1. Check if component already exists existing = InstancesManager.get(session, component_id, None) if existing is not None: logger.debug(f"Component {component_id} already exists, returning existing instance") return existing - + # 2. Component doesn't exist, create it parent_type, parent_id = component_parent - + # parent should always exist parent = InstancesManager.get(session, parent_id) - + real_component_type = snake_to_pascal(component_type.removeprefix("mf-")) component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}" cls = get_class(component_full_type) diff --git a/src/myfasthtml/core/optimized_ft.py b/src/myfasthtml/core/optimized_ft.py index 087f9fe..58142ef 100644 --- a/src/myfasthtml/core/optimized_ft.py +++ b/src/myfasthtml/core/optimized_ft.py @@ -9,74 +9,84 @@ from functools import lru_cache from fasthtml.common import NotStr +from myfasthtml.core.constants import NO_DEFAULT_VALUE + class OptimizedFt: - """Lightweight FastHTML-compatible element that generates HTML directly.""" - - ATTR_MAP = { - "cls": "class", - "_id": "id", - } - - def __init__(self, tag, *args, **kwargs): - self.tag = tag - self.children = args - self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None} - - @staticmethod - @lru_cache(maxsize=128) - def safe_attr(attr_name): - """Convert Python attribute names to HTML attribute names.""" - attr_name = attr_name.replace("hx_", "hx-") - attr_name = attr_name.replace("data_", "data-") - return OptimizedFt.ATTR_MAP.get(attr_name, attr_name) - - @staticmethod - def to_html_helper(item): - """Convert any item to HTML string.""" - if item is None: - return "" - elif isinstance(item, str): - return item - elif isinstance(item, (int, float, bool)): - return str(item) - elif isinstance(item, OptimizedFt): - return item.to_html() - elif isinstance(item, NotStr): - return str(item) - else: - raise Exception(f"Unsupported type: {type(item)}, {item=}") - - def to_html(self): - """Generate HTML string.""" - # Build attributes - attrs_list = [] - for k, v in self.attrs.items(): - if v is False: - continue # Skip False attributes - if v is True: - attrs_list.append(k) # Boolean attribute - else: - # No need to escape v since we control the values (width, IDs, etc.) - attrs_list.append(f'{k}="{v}"') - - attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else '' - - # Build children HTML - children_html = ''.join(self.to_html_helper(child) for child in self.children) - - return f'<{self.tag}{attrs_str}>{children_html}' - - def __ft__(self): - """FastHTML compatibility - returns NotStr to avoid double escaping.""" - return NotStr(self.to_html()) - - def __str__(self): - return self.to_html() + """Lightweight FastHTML-compatible element that generates HTML directly.""" + + ATTR_MAP = { + "cls": "class", + "_id": "id", + } + + def __init__(self, tag, *args, **kwargs): + self.tag = tag + self.children = args + self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None} + + @staticmethod + @lru_cache(maxsize=128) + def safe_attr(attr_name): + """Convert Python attribute names to HTML attribute names.""" + attr_name = attr_name.replace("hx_", "hx-") + attr_name = attr_name.replace("data_", "data-") + return OptimizedFt.ATTR_MAP.get(attr_name, attr_name) + + @staticmethod + def to_html_helper(item): + """Convert any item to HTML string.""" + if item is None: + return "" + elif isinstance(item, str): + return item + elif isinstance(item, (int, float, bool)): + return str(item) + elif isinstance(item, OptimizedFt): + return item.to_html() + elif isinstance(item, NotStr): + return str(item) + else: + raise Exception(f"Unsupported type: {type(item)}, {item=}") + + def to_html(self): + """Generate HTML string.""" + # Build attributes + attrs_list = [] + for k, v in self.attrs.items(): + if v is False: + continue # Skip False attributes + if v is True: + attrs_list.append(k) # Boolean attribute + else: + # No need to escape v since we control the values (width, IDs, etc.) + attrs_list.append(f'{k}="{v}"') + + attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else '' + + # Build children HTML + children_html = ''.join(self.to_html_helper(child) for child in self.children) + + return f'<{self.tag}{attrs_str}>{children_html}' + + def __ft__(self): + """FastHTML compatibility - returns NotStr to avoid double escaping.""" + return NotStr(self.to_html()) + + def __str__(self): + return self.to_html() + + def get(self, attr_name, default=NO_DEFAULT_VALUE): + try: + return self.attrs[self.safe_attr(attr_name)] + except KeyError: + if default is NO_DEFAULT_VALUE: + raise + return default class OptimizedDiv(OptimizedFt): - """Optimized Div element.""" - - def __init__(self, *args, **kwargs): - super().__init__("div", *args, **kwargs) + """Optimized Div element.""" + + def __init__(self, *args, **kwargs): + super().__init__("div", *args, **kwargs)