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