I can apply format rules

This commit is contained in:
2026-01-26 23:29:51 +01:00
parent 9abb9dddfe
commit 778e5ac69d
5 changed files with 144 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
import html import html
import logging import logging
import re import re
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
@@ -21,6 +22,8 @@ from myfasthtml.controls.helpers import mk, icons
from myfasthtml.core.commands import Command 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.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv from myfasthtml.core.optimized_ft import OptimizedDiv
from myfasthtml.core.utils import make_safe_id from myfasthtml.core.utils import make_safe_id
@@ -47,6 +50,12 @@ def _mk_bool_cached(_value):
)) ))
@dataclass
class DatagridConf:
namespace: Optional[str] = None
name: Optional[str] = None
class DatagridState(DbObject): class DatagridState(DbObject):
def __init__(self, owner, save_state): def __init__(self, owner, save_state):
with self.initializing(): with self.initializing():
@@ -66,14 +75,17 @@ class DatagridState(DbObject):
self.ne_df = None self.ne_df = None
self.ns_fast_access = None self.ns_fast_access = None
self.ns_row_data = None
self.ns_total_rows = None self.ns_total_rows = None
class DatagridSettings(DbObject): class DatagridSettings(DbObject):
def __init__(self, owner, save_state): def __init__(self, owner, save_state, name, namespace):
with self.initializing(): with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state) super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state)
self.save_state = save_state is True self.save_state = save_state is True
self.namespace: Optional[str] = namespace
self.name: Optional[str] = name
self.file_name: Optional[str] = None self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None self.selected_sheet_name: Optional[str] = None
self.header_visible: bool = True self.header_visible: bool = True
@@ -148,10 +160,12 @@ class Commands(BaseCommands):
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=None, _id=None): def __init__(self, parent, conf=None, save_state=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._settings = settings or DatagridSettings(self, save_state=save_state) name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
self._state = DatagridState(self, save_state=self._settings.save_state) self._state = DatagridState(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine()
self.commands = Commands(self) self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
@@ -188,6 +202,8 @@ class DataGrid(MultipleInstance):
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
} }
logger.debug(f"DataGrid '{self._get_full_name()}' with id='{self._id}' created.")
@property @property
def _df(self): def _df(self):
return self._state.ne_df return self._state.ne_df
@@ -262,6 +278,9 @@ class DataGrid(MultipleInstance):
self._state.selection.selected = pos self._state.selection.selected = pos
self._state.save() self._state.save()
def _get_full_name(self):
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
def init_from_dataframe(self, df, init_state=True): def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype): def _get_column_type(dtype):
@@ -310,6 +329,24 @@ class DataGrid(MultipleInstance):
res[ROW_INDEX_ID] = _df.index.to_numpy() res[ROW_INDEX_ID] = _df.index.to_numpy()
return res return res
def _init_row_data(_df):
"""
Generates a list of row data dictionaries for column references in formatting conditions.
Each dict contains {col_id: value} for a single row, used by FormattingEngine
to evaluate conditions that reference other columns (e.g., {"col": "budget"}).
Args:
_df (DataFrame): The input pandas DataFrame.
Returns:
list[dict]: A list where each element is a dict of column values for that row.
"""
if _df is None:
return []
return _df.to_dict(orient='records')
if df is not None: if df is not None:
self._state.ne_df = df self._state.ne_df = df
if init_state: if init_state:
@@ -317,10 +354,55 @@ class DataGrid(MultipleInstance):
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index] self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = _init_columns(df) # use df not self._df to keep the original title self._state.columns = _init_columns(df) # use df not self._df to keep the original title
self._state.ns_fast_access = _init_fast_access(self._df) self._state.ns_fast_access = _init_fast_access(self._df)
self._state.ns_row_data = _init_row_data(self._df)
self._state.ns_total_rows = len(self._df) if self._df is not None else 0 self._state.ns_total_rows = len(self._df) if self._df is not None else 0
return self return self
def _get_format_rules(self, col_pos, row_index, col_def):
"""
Get format rules for a cell, returning only the most specific level defined.
Priority (most specific wins):
1. Cell-level: self._state.cell_formats[cell_id]
2. Row-level: row_state.format (if row has specific state)
3. Column-level: col_def.format
Args:
col_pos: Column position index
row_index: Row index
col_def: DataGridColumnState for the column
Returns:
list[FormatRule] or None if no formatting defined
"""
# hack to test
if col_def.col_id == "age":
return [
FormatRule(style=Style(color="green")),
FormatRule(condition=Condition(operator=">", value=18), style=Style(color="red")),
]
return [
FormatRule(condition=Condition(operator="isnan"), formatter=ConstantFormatter(value="-")),
]
cell_id = self._get_element_id_from_pos("cell", (row_index, col_pos))
if cell_id in self._state.cell_formats:
return self._state.cell_formats[cell_id]
if row_index < len(self._state.rows):
row_state = self._state.rows[row_index]
if row_state.format:
return row_state.format
if col_def.format:
return col_def.format
return None
def set_column_width(self, col_id: str, width: str): def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS.""" """Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}") logger.debug(f"set_column_width: {col_id=} {width=}")
@@ -431,35 +513,35 @@ class DataGrid(MultipleInstance):
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
""" """
OPTIMIZED: Generate cell content with minimal object creation. Generate cell content with formatting and optional search highlighting.
- Uses plain strings instead of Label objects when possible
- Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups Processing order:
- Avoids html.escape when not necessary 1. Apply formatter (transforms value for display)
- Uses cached boolean HTML (_mk_bool_cached) 2. Apply style (CSS inline style)
3. Apply search highlighting (on top of formatted value)
""" """
def mk_highlighted_text(value_str, css_class): def mk_highlighted_text(value_str, css_class, style=None):
"""Return highlighted text as raw HTML string or tuple of Spans.""" """Return highlighted text as raw HTML string or tuple of Spans."""
style_attr = f' style="{style}"' if style else ''
if not filter_keyword_lower: if not filter_keyword_lower:
# OPTIMIZATION: Return plain HTML string instead of Label object return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
# Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size)
return NotStr(f'<span class="{css_class} truncate">{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">{value_str}</span>') return NotStr(f'<span class="{css_class} truncate"{style_attr}>{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
len_keyword = len(filter_keyword_lower) len_keyword = len(filter_keyword_lower)
res = [] res = []
if index > 0: if index > 0:
res.append(Span(value_str[:index], cls=f"{css_class}")) res.append(Span(value_str[:index], cls=f"{css_class}"))
res.append(Span(value_str[index:index + len_keyword], cls=f"dt2-highlight-1")) res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1"))
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}")) res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}"))
return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] return Span(*res, cls=f"{css_class} truncate", style=style) 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]
@@ -472,8 +554,16 @@ class DataGrid(MultipleInstance):
if column_type == ColumnType.RowIndex: if column_type == ColumnType.RowIndex:
return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>') return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>')
# Convert value to string # Get format rules and apply formatting
value_str = str(value) css_string = None
formatted_value = None
rules = self._get_format_rules(col_pos, row_index, col_def)
if rules:
row_data = self._state.ns_row_data[row_index] if row_index < len(self._state.ns_row_data) else None
css_string, formatted_value = self._formatting_engine.apply_format(rules, value, row_data)
# Use formatted value or convert to string
value_str = formatted_value if formatted_value is not None else 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):
@@ -481,9 +571,9 @@ class DataGrid(MultipleInstance):
# 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", css_string)
else: else:
return mk_highlighted_text(value_str, "dt2-cell-content-text") return mk_highlighted_text(value_str, "dt2-cell-content-text", css_string)
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
""" """

View File

@@ -6,7 +6,7 @@ import pandas as pd
from fasthtml.components import Div from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGrid import DataGrid from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.TreeView import TreeView, TreeNode
@@ -97,12 +97,15 @@ 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(BytesIO(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) # first time the Datagrid is created namespace = file_upload.get_file_basename()
name = file_upload.get_sheet_name()
dg_conf = DatagridConf(namespace=namespace, name=name)
dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
dg.init_from_dataframe(df) dg.init_from_dataframe(df)
document = DocumentDefinition( document = DocumentDefinition(
document_id=str(uuid.uuid4()), document_id=str(uuid.uuid4()),
namespace=file_upload.get_file_basename(), namespace=namespace,
name=file_upload.get_sheet_name(), name=name,
type="excel", type="excel",
tab_id=tab_id, tab_id=tab_id,
datagrid_id=dg.get_id() datagrid_id=dg.get_id()

View File

@@ -1,3 +1,4 @@
from numbers import Number
from typing import Any from typing import Any
from myfasthtml.core.formatting.dataclasses import Condition from myfasthtml.core.formatting.dataclasses import Condition
@@ -33,6 +34,10 @@ class ConditionEvaluator:
result = not self._is_empty(cell_value) result = not self._is_empty(cell_value)
return self._apply_negate(result, condition.negate) return self._apply_negate(result, condition.negate)
if condition.operator == "isnan":
result = self._is_nan(cell_value)
return self._apply_negate(result, condition.negate)
# For all other operators, None cell_value returns False # For all other operators, None cell_value returns False
if cell_value is None: if cell_value is None:
return self._apply_negate(False, condition.negate) return self._apply_negate(False, condition.negate)
@@ -71,6 +76,10 @@ class ConditionEvaluator:
return True return True
return False return False
def _is_nan(self, value: Any) -> bool:
"""Check if a value is NaN."""
return isinstance(value, float) and value != value
def _apply_negate(self, result: bool, negate: bool) -> bool: def _apply_negate(self, result: bool, negate: bool) -> bool:
"""Apply negation if needed.""" """Apply negation if needed."""
return not result if negate else result return not result if negate else result
@@ -124,7 +133,7 @@ class ConditionEvaluator:
"""Check if cell_value < compare_value.""" """Check if cell_value < compare_value."""
if type(cell_value) != type(compare_value): if type(cell_value) != type(compare_value):
# Allow int/float comparison # Allow int/float comparison
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): if isinstance(cell_value, Number) and isinstance(compare_value, Number):
return cell_value < compare_value return cell_value < compare_value
raise TypeError("Type mismatch") raise TypeError("Type mismatch")
return cell_value < compare_value return cell_value < compare_value
@@ -132,7 +141,7 @@ class ConditionEvaluator:
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool: def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value <= compare_value.""" """Check if cell_value <= compare_value."""
if type(cell_value) != type(compare_value): if type(cell_value) != type(compare_value):
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): if isinstance(cell_value, Number) and isinstance(compare_value, Number):
return cell_value <= compare_value return cell_value <= compare_value
raise TypeError("Type mismatch") raise TypeError("Type mismatch")
return cell_value <= compare_value return cell_value <= compare_value
@@ -140,7 +149,7 @@ class ConditionEvaluator:
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool: def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value > compare_value.""" """Check if cell_value > compare_value."""
if type(cell_value) != type(compare_value): if type(cell_value) != type(compare_value):
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): if isinstance(cell_value, Number) and isinstance(compare_value, Number):
return cell_value > compare_value return cell_value > compare_value
raise TypeError("Type mismatch") raise TypeError("Type mismatch")
return cell_value > compare_value return cell_value > compare_value
@@ -148,7 +157,7 @@ class ConditionEvaluator:
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool: def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value >= compare_value.""" """Check if cell_value >= compare_value."""
if type(cell_value) != type(compare_value): if type(cell_value) != type(compare_value):
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): if isinstance(cell_value, Number) and isinstance(compare_value, Number):
return cell_value >= compare_value return cell_value >= compare_value
raise TypeError("Type mismatch") raise TypeError("Type mismatch")
return cell_value >= compare_value return cell_value >= compare_value

View File

@@ -121,6 +121,9 @@ class TextFormatter(Formatter):
max_length: int = None max_length: int = None
ellipsis: str = "..." ellipsis: str = "..."
@dataclass
class ConstantFormatter(Formatter):
value: str = None
@dataclass @dataclass
class EnumFormatter(Formatter): class EnumFormatter(Formatter):

View File

@@ -9,6 +9,7 @@ from myfasthtml.core.formatting.dataclasses import (
BooleanFormatter, BooleanFormatter,
TextFormatter, TextFormatter,
EnumFormatter, EnumFormatter,
ConstantFormatter,
) )
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS
@@ -258,6 +259,15 @@ class EnumFormatterResolver(BaseFormatterResolver):
) )
class ConstantFormatterResolver(BaseFormatterResolver):
def resolve(self, formatter: ConstantFormatter, value: Any) -> str:
return formatter.value
def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter:
return formatter
class FormatterResolver: class FormatterResolver:
""" """
Main resolver that dispatches to the appropriate formatter resolver. Main resolver that dispatches to the appropriate formatter resolver.
@@ -281,6 +291,7 @@ class FormatterResolver:
BooleanFormatter: BooleanFormatterResolver(), BooleanFormatter: BooleanFormatterResolver(),
TextFormatter: TextFormatterResolver(), TextFormatter: TextFormatterResolver(),
EnumFormatter: EnumFormatterResolver(lookup_resolver), EnumFormatter: EnumFormatterResolver(lookup_resolver),
ConstantFormatter: ConstantFormatterResolver()
} }
def resolve(self, formatter: Formatter, value: Any) -> str: def resolve(self, formatter: Formatter, value: Any) -> str: