diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index acbd91a..98cc4fd 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -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'{value_str}')
+ return NotStr(f'{value_str}')
index = value_str.lower().find(filter_keyword_lower)
if index < 0:
- return NotStr(f'{value_str}')
+ 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)
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'{row_index}')
- # 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):
"""
diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py
index 6c4f2b3..afbfa16 100644
--- a/src/myfasthtml/controls/DataGridsManager.py
+++ b/src/myfasthtml/controls/DataGridsManager.py
@@ -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()
diff --git a/src/myfasthtml/core/formatting/condition_evaluator.py b/src/myfasthtml/core/formatting/condition_evaluator.py
index 57f9772..1aee4f5 100644
--- a/src/myfasthtml/core/formatting/condition_evaluator.py
+++ b/src/myfasthtml/core/formatting/condition_evaluator.py
@@ -1,3 +1,4 @@
+from numbers import Number
from typing import Any
from myfasthtml.core.formatting.dataclasses import Condition
@@ -32,6 +33,10 @@ class ConditionEvaluator:
if condition.operator == "isnotempty":
result = not self._is_empty(cell_value)
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
if cell_value is None:
@@ -70,6 +75,10 @@ class ConditionEvaluator:
if isinstance(value, str) and value == "":
return True
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:
"""Apply negation if needed."""
@@ -124,7 +133,7 @@ class ConditionEvaluator:
"""Check if cell_value < compare_value."""
if type(cell_value) != type(compare_value):
# 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
raise TypeError("Type mismatch")
return cell_value < compare_value
@@ -132,7 +141,7 @@ class ConditionEvaluator:
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value <= 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
raise TypeError("Type mismatch")
return cell_value <= compare_value
@@ -140,7 +149,7 @@ class ConditionEvaluator:
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value > 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
raise TypeError("Type mismatch")
return cell_value > compare_value
@@ -148,7 +157,7 @@ class ConditionEvaluator:
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
"""Check if cell_value >= 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
raise TypeError("Type mismatch")
return cell_value >= compare_value
diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py
index d5899b1..0982f49 100644
--- a/src/myfasthtml/core/formatting/dataclasses.py
+++ b/src/myfasthtml/core/formatting/dataclasses.py
@@ -121,6 +121,9 @@ class TextFormatter(Formatter):
max_length: int = None
ellipsis: str = "..."
+@dataclass
+class ConstantFormatter(Formatter):
+ value: str = None
@dataclass
class EnumFormatter(Formatter):
diff --git a/src/myfasthtml/core/formatting/formatter_resolver.py b/src/myfasthtml/core/formatting/formatter_resolver.py
index bdf0774..f05d1e2 100644
--- a/src/myfasthtml/core/formatting/formatter_resolver.py
+++ b/src/myfasthtml/core/formatting/formatter_resolver.py
@@ -9,6 +9,7 @@ from myfasthtml.core.formatting.dataclasses import (
BooleanFormatter,
TextFormatter,
EnumFormatter,
+ ConstantFormatter,
)
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:
"""
Main resolver that dispatches to the appropriate formatter resolver.
@@ -281,6 +291,7 @@ class FormatterResolver:
BooleanFormatter: BooleanFormatterResolver(),
TextFormatter: TextFormatterResolver(),
EnumFormatter: EnumFormatterResolver(lookup_resolver),
+ ConstantFormatter: ConstantFormatterResolver()
}
def resolve(self, formatter: Formatter, value: Any) -> str: