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 logging
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
@@ -21,6 +22,8 @@ from myfasthtml.controls.helpers import mk, icons
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.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv
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):
def __init__(self, owner, save_state):
with self.initializing():
@@ -66,14 +75,17 @@ class DatagridState(DbObject):
self.ne_df = None
self.ns_fast_access = None
self.ns_row_data = None
self.ns_total_rows = None
class DatagridSettings(DbObject):
def __init__(self, owner, save_state):
def __init__(self, owner, save_state, name, namespace):
with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state)
self.save_state = save_state is True
self.namespace: Optional[str] = namespace
self.name: Optional[str] = name
self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None
self.header_visible: bool = True
@@ -148,10 +160,12 @@ class Commands(BaseCommands):
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)
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._formatting_engine = FormattingEngine()
self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
@@ -187,6 +201,8 @@ class DataGrid(MultipleInstance):
"ctrl+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
def _df(self):
@@ -262,6 +278,9 @@ class DataGrid(MultipleInstance):
self._state.selection.selected = pos
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 _get_column_type(dtype):
@@ -310,6 +329,24 @@ class DataGrid(MultipleInstance):
res[ROW_INDEX_ID] = _df.index.to_numpy()
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:
self._state.ne_df = df
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.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_row_data = _init_row_data(self._df)
self._state.ns_total_rows = len(self._df) if self._df is not None else 0
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):
"""Update column width after resize. Called via Command from JS."""
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):
"""
OPTIMIZED: Generate cell content with minimal object creation.
- Uses plain strings instead of Label objects when possible
- Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups
- Avoids html.escape when not necessary
- Uses cached boolean HTML (_mk_bool_cached)
Generate cell content with formatting and optional search highlighting.
Processing order:
1. Apply formatter (transforms value for display)
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."""
style_attr = f' style="{style}"' if style else ''
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'<span class="{css_class} truncate">{value_str}</span>')
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
index = value_str.lower().find(filter_keyword_lower)
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
# Add "truncate text-sm" to match mk.label() behavior
len_keyword = len(filter_keyword_lower)
res = []
if index > 0:
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):
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
value = self._state.ns_fast_access[col_def.col_id][row_index]
@@ -472,8 +554,16 @@ class DataGrid(MultipleInstance):
if column_type == ColumnType.RowIndex:
return NotStr(f'<span class="dt2-cell-content-number truncate">{row_index}</span>')
# Convert value to string
value_str = str(value)
# Get format rules and apply formatting
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)
if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
@@ -481,9 +571,9 @@ class DataGrid(MultipleInstance):
# Number or Text type
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:
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):
"""

View File

@@ -6,7 +6,7 @@ import pandas as pd
from fasthtml.components import Div
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.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
@@ -97,12 +97,15 @@ class DataGridsManager(MultipleInstance):
def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content()
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)
document = DocumentDefinition(
document_id=str(uuid.uuid4()),
namespace=file_upload.get_file_basename(),
name=file_upload.get_sheet_name(),
namespace=namespace,
name=name,
type="excel",
tab_id=tab_id,
datagrid_id=dg.get_id()