Fixed FormattingRules not being applied

This commit is contained in:
2026-03-15 16:50:21 +01:00
parent feb9da50b2
commit 0c9c8bc7fa
7 changed files with 671 additions and 416 deletions

View File

@@ -2,7 +2,9 @@
## Introduction
The DataGrid Formatting System provides a comprehensive solution for applying conditional formatting and data transformation to DataGrid cells. It combines a powerful formatting engine with a user-friendly Domain Specific Language (DSL) and intelligent autocompletion.
The DataGrid Formatting System provides a comprehensive solution for applying conditional formatting and data
transformation to DataGrid cells. It combines a powerful formatting engine with a user-friendly Domain Specific
Language (DSL) and intelligent autocompletion.
**Key features:**
@@ -60,7 +62,7 @@ The formatting system is built in three layers, each with a specific responsibil
```
| Layer | Module | Responsibility |
|-------|--------|----------------|
|--------------------------|-----------------------------------|------------------------------------------------------|
| **1. Formatting Engine** | `core/formatting/` | Apply format rules to cell values, resolve conflicts |
| **2. DSL Parser** | `core/formatting/dsl/` | Parse text DSL into structured rules |
| **3. Autocompletion** | `core/formatting/dsl/completion/` | Provide intelligent suggestions while editing |
@@ -99,7 +101,7 @@ User writes DSL in editor
### Module Responsibilities
| Module | Location | Purpose |
|--------|----------|---------|
|------------------------------|------------------------------------------|---------------------------------------------|
| `FormattingEngine` | `core/formatting/engine.py` | Main facade for applying format rules |
| `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate conditions (==, <, contains, etc.) |
| `StyleResolver` | `core/formatting/style_resolver.py` | Resolve styles to CSS strings |
@@ -109,6 +111,153 @@ User writes DSL in editor
| `FormattingCompletionEngine` | `core/formatting/dsl/completion/` | Autocompletion for DSL |
| `DataGridFormattingEditor` | `controls/DataGridFormattingEditor.py` | DSL editor integrated in DataGrid |
### Component Interaction Diagrams
#### Rendering Pipeline
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ RENDERING PIPELINE │
└─────────────────────────────────────────────────────────────────────────────────┘
DataGrid._render_cell_content()
├─ _get_format_rules(col_pos, row_idx, col_def)
│ Priority: cell > row > column > table > tables
│ └─ dgm.get_state().all_tables_formats (DataGridsManager)
└─ FormattingEngine.apply_format(rules, cell_value, row_data)
├─ _get_rule_presets() ◄── lambda: self._formatting_provider.rule_presets
├─ _expand_rule_presets(rules)
│ style("traffic_light") or format("traffic_light")
│ → replaced by RulePreset.rules
├─ _get_matching_rules()
│ └─ ConditionEvaluator.evaluate(condition, value, row_data)
├─ _resolve_style(matching_rules)
│ └─ StyleResolver.to_style_container(style)
│ └─ DEFAULT_STYLE_PRESETS["primary"]
│ → {"__class__": "mf-formatting-primary"}
│ → StyleContainer(cls="mf-formatting-primary", css="")
└─ _resolve_formatter(matching_rules)
└─ FormatterResolver.resolve(formatter, value)
└─ NumberFormatterResolver / DateFormatterResolver / ...
```
#### DSL Editing Pipeline
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DSL EDITING PIPELINE │
└─────────────────────────────────────────────────────────────────────────────────┘
DataGridFormattingEditor.on_content_changed() (subclass of DslEditor)
├─ parse_dsl(text)
│ ├─ DSLParser.parse(text) → lark.Tree
│ └─ DSLTransformer.transform(tree) → list[ScopedRule]
│ ScopedRule = (scope, FormatRule)
│ scopes: ColumnScope | RowScope | CellScope | TableScope | TablesScope
└─ Dispatch by scope into DataGrid state:
ColumnScope → col_def.format
RowScope → DataGridRowUiState.format
CellScope → state.cell_formats[cell_id]
TableScope → state.table_format
TablesScope → DataGridsManager.all_tables_formats
```
#### Autocompletion Pipeline
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AUTOCOMPLETION PIPELINE │
└─────────────────────────────────────────────────────────────────────────────────┘
CodeMirror (JS) → DslEditor (update)
└─ FormattingCompletionEngine.get_completions(text, cursor)
├─ detect_scope(text, line)
│ → ColumnScope / TablesScope / ...
├─ detect_context(text, cursor, scope)
│ → inside style() call? format() call? kwarg? ...
├─ get_suggestions(context, scope, prefix)
│ │
│ └─ DatagridMetadataProvider ◄────── SHARED (UniqueInstance / session)
│ │
│ ├─ list_style_presets() → DEFAULT_STYLE_PRESETS keys
│ ├─ list_rule_presets_for_style() → RulePresets where has_style() == True
│ ├─ list_rule_presets_for_format() → RulePresets where has_formatter() == True
│ ├─ list_columns(table_name) → DataServicesManager
│ └─ list_column_values(...) → DataServicesManager
└─ _extract_used_params() → deduplicate already-written kwargs
```
#### Preset Management and Shared Provider
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PRESET MANAGEMENT AND SHARED PROVIDER │
└─────────────────────────────────────────────────────────────────────────────────┘
DataGridFormattingManager
├─ _editor: DslEditor (preset DSL editor)
├─ handle_save_preset()
│ └─ _parse_dsl_to_rules(dsl) ← parse + discard scope
│ → preset.rules = [FormatRule, ...]
└─ _sync_provider()
└─ DatagridMetadataProvider.rule_presets = {builtin + user presets}
│ UniqueInstance → same instance for all components in the session
┌───────┴────────────────────────────┐
│ │
DataGridFormattingManager DataGrid
DatagridMetadataProvider(self) DatagridMetadataProvider(self._parent)
│ │
└───────────────┬────────────────────┘
SAME INSTANCE (per session)
┌─────────┴──────────┐
│ │
completion engine FormattingEngine
(autocompletion) (cell rendering)
rule_presets_provider=lambda: provider.rule_presets
```
#### Data Structures
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DATA STRUCTURES │
└─────────────────────────────────────────────────────────────────────────────────┘
RulePreset(name, description, dsl, rules: list[FormatRule])
└─ FormatRule(condition?, style?, formatter?)
├─ Condition(operator, value, negate, case_sensitive)
├─ Style(preset?, color?, font_weight?, ...)
└─ Formatter (base)
├─ NumberFormatter(precision, prefix, suffix, ...)
├─ DateFormatter(format)
├─ BooleanFormatter(true_value, false_value)
├─ TextFormatter(transform, max_length)
├─ EnumFormatter(source, default)
└─ ConstantFormatter(value)
```
---
## Fundamental Concepts
@@ -189,7 +338,7 @@ Scopes define **where** a rule applies. Five scope levels provide hierarchical t
```
| Scope | Targets | Specificity | DSL Syntax |
|-------|---------|-------------|------------|
|------------|-----------------------------|-------------|-------------------------------------------|
| **Cell** | 1 specific cell | Highest (1) | `cell (column, row):` or `cell tcell_id:` |
| **Row** | All cells in row | High (2) | `row index:` |
| **Column** | All cells in column | Medium (3) | `column name:` |
@@ -211,7 +360,7 @@ Presets are named configurations that can be referenced by name instead of speci
**Style Presets (DaisyUI 5):**
| Preset | Background | Text | Use Case |
|--------|------------|------|----------|
|-------------|--------------------------|----------------------------------|-------------------|
| `primary` | `var(--color-primary)` | `var(--color-primary-content)` | Important values |
| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` | Secondary info |
| `accent` | `var(--color-accent)` | `var(--color-accent-content)` | Highlights |
@@ -224,7 +373,7 @@ Presets are named configurations that can be referenced by name instead of speci
**Formatter Presets:**
| Preset | Type | Output Example | Use Case |
|--------|------|----------------|----------|
|--------------|---------|----------------|--------------------|
| `EUR` | number | `1 234,56 €` | Euro currency |
| `USD` | number | `$1,234.56` | US Dollar |
| `percentage` | number | `75.0%` | Percentages (×100) |
@@ -263,6 +412,7 @@ manager.add_style_preset("badge", {
```
When a preset with `__class__` is applied:
- The CSS classes are added to the element's `class` attribute
- The CSS properties are applied as inline styles
- This allows combining DaisyUI component classes with custom styling
@@ -281,16 +431,18 @@ manager.add_style_preset("status_approved", {
})
# Use in DSL
column status:
style("status_draft") if value == "draft"
style("status_approved") if value == "approved"
column
status:
style("status_draft") if value == "draft"
style("status_approved") if value == "approved"
```
---
## Layer 1: Formatting Engine
The formatting engine (`core/formatting/`) is the foundation that applies formatting rules to cell values. It is **independent of the DSL** and can be used programmatically.
The formatting engine (`core/formatting/`) is the foundation that applies formatting rules to cell values. It is *
*independent of the DSL** and can be used programmatically.
### Dataclasses
@@ -310,10 +462,11 @@ class Condition:
**Supported operators:**
| Operator | Description | Value Type | Example |
|----------|-------------|------------|---------|
|--------------|--------------------|--------------|--------------------------|
| `==` | Equal | scalar | `value == 0` |
| `!=` | Not equal | scalar | `value != ""` |
| `<` | Less than | number | `value < 0` |
| | | | |
| `<=` | Less or equal | number | `value <= 100` |
| `>` | Greater than | number | `value > 1000` |
| `>=` | Greater or equal | number | `value >= 0` |
@@ -618,32 +771,36 @@ If formatting fails (e.g., non-numeric value for NumberFormatter), returns `"⚠
## Layer 2: DSL Parser
The DSL provides a human-readable text format for defining formatting rules. It is parsed into structured `ScopedRule` objects that can be applied by the formatting engine.
The DSL provides a human-readable text format for defining formatting rules. It is parsed into structured `ScopedRule`
objects that can be applied by the formatting engine.
### DSL Syntax Overview
```python
# Column scope
column amount:
format("EUR")
style("error") if value < 0
style("success") if value > col.budget
column
amount:
format("EUR")
style("error") if value < 0
style("success") if value > col.budget
# Row scope
row 0:
style("neutral", bold=True)
row
0:
style("neutral", bold=True)
# Cell scope
cell (amount, 10):
style("accent", bold=True)
cell(amount, 10):
style("accent", bold=True)
# Table scope
table "orders":
style(font_size="14px")
table
"orders":
style(font_size="14px")
# Global scope
tables:
style(color="#333")
style(color="#333")
```
**Structure:**
@@ -671,12 +828,14 @@ Target all cells in a column by name (matches `col_id` first, then `title`):
```python
# Simple name
column amount:
style("error") if value < 0
column
amount:
style("error") if value < 0
# Name with spaces (quoted)
column "total amount":
format("EUR")
column
"total amount":
format("EUR")
```
#### Row Scope
@@ -685,12 +844,14 @@ Target all cells in a row by index (0-based):
```python
# Header row
row 0:
style("neutral", bold=True)
row
0:
style("neutral", bold=True)
# Specific row
row 5:
style("highlight")
row
5:
style("highlight")
```
#### Cell Scope
@@ -699,15 +860,16 @@ Target a specific cell by coordinates or ID:
```python
# By coordinates (column, row)
cell (amount, 3):
style("highlight")
cell(amount, 3):
style("highlight")
cell ("total amount", 0):
style("neutral", bold=True)
cell("total amount", 0):
style("neutral", bold=True)
# By cell ID
cell tcell_grid1-3-2:
style(background_color="yellow")
cell
tcell_grid1 - 3 - 2:
style(background_color="yellow")
```
#### Table Scope
@@ -715,9 +877,10 @@ cell tcell_grid1-3-2:
Target all cells in a specific table (must match DataGrid `_settings.name`):
```python
table "products":
style("neutral")
format.number(precision=2)
table
"products":
style("neutral")
format.number(precision=2)
```
#### Tables Scope
@@ -726,7 +889,7 @@ Global rules for all tables:
```python
tables:
style(font_size="14px", color="#333")
style(font_size="14px", color="#333")
```
### Rules Syntax
@@ -757,7 +920,7 @@ style(background_color="#ffeeee", color="#cc0000")
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
|--------------------|---------------------|-----------------------------|
| `preset` | string (positional) | Preset name |
| `background_color` | string | Background color |
| `color` | string | Text color |
@@ -794,7 +957,7 @@ format.constant(value="N/A")
**Type-specific parameters:**
| Type | Parameters |
|------|------------|
|------------|-------------------------------------------------------------------------------|
| `number` | `prefix`, `suffix`, `thousands_sep`, `decimal_sep`, `precision`, `multiplier` |
| `date` | `format` (strftime pattern) |
| `boolean` | `true_value`, `false_value`, `null_value` |
@@ -805,14 +968,14 @@ format.constant(value="N/A")
#### Condition Expression
```python
if <left> <operator> <right>
if <operand> <unary_operator>
if < left > < operator > < right >
if < operand > < unary_operator >
```
**Operators:**
| Operator | Example |
|----------|---------|
|-------------------------|----------------------------|
| `==`, `!=` | `value == 0` |
| `<`, `<=`, `>`, `>=` | `value < 0` |
| `contains` | `value contains "error"` |
@@ -827,7 +990,9 @@ if <operand> <unary_operator>
```python
style("error") if not value in ["valid", "approved"]
style("warning") if not value contains "OK"
style("warning") if not value
contains
"OK"
```
**Case sensitivity:**
@@ -835,14 +1000,16 @@ style("warning") if not value contains "OK"
String comparisons are case-insensitive by default. Use `(case)` modifier:
```python
style("error") if value == "Error" (case)
style("warning") if value contains "WARN" (case)
style("error") if value == "Error"(case)
style("warning") if value
contains
"WARN"(case)
```
**References:**
| Reference | Description | Example |
|-----------|-------------|---------|
|----------------|---------------------------|----------------------------|
| `value` | Current cell value | `value < 0` |
| `col.<name>` | Another column (same row) | `value > col.budget` |
| `col."<name>"` | Column with spaces | `value > col."max amount"` |
@@ -852,29 +1019,33 @@ style("warning") if value contains "WARN" (case)
**Basic formatting:**
```python
column amount:
format("EUR")
style("error") if value < 0
column
amount:
format("EUR")
style("error") if value < 0
```
**Cross-column comparison:**
```python
column actual:
format("EUR")
style("error") if value > col.budget
style("warning") if value > col.budget * 0.8
style("success") if value <= col.budget * 0.8
column
actual:
format("EUR")
style("error") if value > col.budget
style("warning") if value > col.budget * 0.8
style("success") if value <= col.budget * 0.8
```
**Multiple conditions:**
```python
column status:
style("success") if value == "approved"
style("warning") if value == "pending"
style("error") if value == "rejected"
style("neutral") if value isempty
column
status:
style("success") if value == "approved"
style("warning") if value == "pending"
style("error") if value == "rejected"
style("neutral") if value
isempty
```
**Complex example:**
@@ -882,39 +1053,46 @@ column status:
```python
# Global defaults
tables:
style(font_size="14px", color="#333")
style(font_size="14px", color="#333")
# Table-specific
table "financial_report":
format.number(precision=2)
table
"financial_report":
format.number(precision=2)
# Header row
row 0:
style("neutral", bold=True)
row
0:
style("neutral", bold=True)
# Amount column
column amount:
format.number(precision=2, suffix=" €", thousands_sep=" ")
style("error") if value < 0
style("success") if value > col.target
column
amount:
format.number(precision=2, suffix=" €", thousands_sep=" ")
style("error") if value < 0
style("success") if value > col.target
# Percentage column
column progress:
format("percentage")
style("error") if value < 0.5
style("warning") if value between 0.5 and 0.8
style("success") if value > 0.8
column
progress:
format("percentage")
style("error") if value < 0.5
style("warning") if value
between
0.5 and 0.8
style("success") if value > 0.8
# Status column
column status:
format.enum(source={"draft": "Draft", "review": "In Review", "approved": "Approved"})
style("neutral") if value == "draft"
style("info") if value == "review"
style("success") if value == "approved"
column
status:
format.enum(source={"draft": "Draft", "review": "In Review", "approved": "Approved"})
style("neutral") if value == "draft"
style("info") if value == "review"
style("success") if value == "approved"
# Highlight specific cell
cell (amount, 10):
style("accent", bold=True)
cell(amount, 10):
style("accent", bold=True)
```
### Parser and Transformer
@@ -957,6 +1135,7 @@ class ScopedRule:
```
Each `ScopedRule` contains:
- **scope**: Where the rule applies
- **rule**: What formatting to apply (condition + style + formatter)
@@ -1001,8 +1180,9 @@ The autocompletion system provides intelligent, context-aware suggestions while
Before suggesting completions, detect the current scope by scanning backwards:
```python
column amount: # ← Scope: ColumnScope("amount")
style("error") if | # ← Cursor here
column
amount: # ← Scope: ColumnScope("amount")
style("error") if | # ← Cursor here
```
The engine knows we're in the `amount` column, so it can suggest column values from that column.
@@ -1033,7 +1213,7 @@ column amount:
Based on context, generate appropriate suggestions:
| Context | Suggestions |
|---------|-------------|
|------------------|---------------------------------------------------------|
| `SCOPE_KEYWORD` | `column`, `row`, `cell`, `table`, `tables` |
| `COLUMN_NAME` | Column names from DataGrid |
| `STYLE_PRESET` | Style presets ("error", "success", etc.) |
@@ -1090,7 +1270,7 @@ Implementations (like `DataGridsManager`) provide this interface to enable dynam
The engine recognizes ~30 distinct contexts:
| Category | Contexts |
|----------|----------|
|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
| **Scope** | `SCOPE_KEYWORD`, `COLUMN_NAME`, `ROW_INDEX`, `CELL_START`, `CELL_COLUMN`, `CELL_ROW`, `TABLE_NAME`, `TABLES_SCOPE` |
| **Rule** | `RULE_START`, `AFTER_STYLE_OR_FORMAT` |
| **Style** | `STYLE_ARGS`, `STYLE_PRESET`, `STYLE_PARAM` |
@@ -1102,7 +1282,8 @@ The engine recognizes ~30 distinct contexts:
## Generic DSL Infrastructure
The `core/dsl/` module provides a **reusable framework** for creating custom DSLs with CodeMirror integration and autocompletion.
The `core/dsl/` module provides a **reusable framework** for creating custom DSLs with CodeMirror integration and
autocompletion.
### Purpose
@@ -1118,6 +1299,7 @@ Abstract base class for defining a DSL:
```python
from myfasthtml.core.dsl.base import DSLDefinition
class MyDSL(DSLDefinition):
name: str = "My Custom DSL"
@@ -1143,6 +1325,7 @@ Abstract base class for completion engines:
```python
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
class MyCompletionEngine(BaseCompletionEngine):
def detect_scope(self, text: str, current_line: int):
"""Detect current scope from previous lines."""
@@ -1173,6 +1356,7 @@ Protocol for providing domain-specific metadata:
```python
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
class MyProvider(BaseMetadataProvider):
# Implement methods to provide metadata for suggestions
...
@@ -1234,18 +1418,20 @@ The editor matches column names by `col_id` first, then `title`:
```python
# DSL
column amount: # Matches col_id="amount" or title="amount"
...
column
amount: # Matches col_id="amount" or title="amount"
...
```
**Cell ID resolution:**
```python
# By coordinates
cell (amount, 3): # Resolved to tcell_grid1-3-0
cell(amount, 3): # Resolved to tcell_grid1-3-0
# By ID
cell tcell_grid1-3-0: # Used directly
cell
tcell_grid1 - 3 - 0: # Used directly
```
### FormattingDSL
@@ -1342,9 +1528,10 @@ manager.add_style_preset("highlighted", {
**Usage in DSL:**
```python
column status:
style("badge_primary") if value == "active"
style("highlighted") if value == "important"
column
status:
style("badge_primary") if value == "active"
style("highlighted") if value == "important"
```
#### Add Custom Formatter Presets
@@ -1375,6 +1562,7 @@ def my_lookup_resolver(grid_id: str, value_col: str, display_col: str) -> dict:
# Build mapping {value: display}
return mapping
engine = FormattingEngine(lookup_resolver=my_lookup_resolver)
```
@@ -1384,10 +1572,12 @@ engine = FormattingEngine(lookup_resolver=my_lookup_resolver)
from myfasthtml.core.formatting.dataclasses import Formatter
from myfasthtml.core.formatting.formatter_resolver import BaseFormatterResolver
@dataclass
class CustomFormatter(Formatter):
custom_param: str = None
class CustomFormatterResolver(BaseFormatterResolver):
def resolve(self, formatter: CustomFormatter, value: Any) -> str:
# Custom formatting logic
@@ -1397,6 +1587,7 @@ class CustomFormatterResolver(BaseFormatterResolver):
# Preset application logic
return formatter
# Register in FormatterResolver
FormatterResolver._resolvers[CustomFormatter] = CustomFormatterResolver()
```
@@ -1407,6 +1598,7 @@ FormatterResolver._resolvers[CustomFormatter] = CustomFormatterResolver()
from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
class MyDSL(DSLDefinition):
name = "My DSL"
@@ -1417,6 +1609,7 @@ class MyDSL(DSLDefinition):
...
"""
class MyCompletionEngine(BaseCompletionEngine):
def detect_scope(self, text, line):
# Scope detection logic
@@ -1477,7 +1670,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Public APIs by Module
| Module | Public API | Description |
|--------|------------|-------------|
|-----------------------------------|---------------------------------------------------------------------|-------------------------------------|
| `core/formatting/` | `FormattingEngine` | Main engine for applying rules |
| `core/formatting/dataclasses.py` | `Condition`, `Style`, `Formatter`, `FormatRule` | Core data structures |
| `core/formatting/presets.py` | `DEFAULT_STYLE_PRESETS`, `DEFAULT_FORMATTER_PRESETS` | Built-in presets |
@@ -1492,7 +1685,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Key Classes and Responsibilities
| Class | Location | Responsibility |
|-------|----------|----------------|
|------------------------------|----------------------------------------------------------------|--------------------------------------|
| `FormattingEngine` | `core/formatting/engine.py` | Apply format rules to cell values |
| `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate 13 operators + references |
| `StyleResolver` | `core/formatting/style_resolver.py` | Resolve styles to CSS |
@@ -1506,7 +1699,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Known Limitations
| Limitation | Status | Impact |
|------------|--------|--------|
|---------------------------------------------|--------------------------|-------------------------------------------------------|
| `row` parameter for column-level conditions | Not implemented | Cannot reference specific row in column scope |
| AND/OR operators | Not implemented | Use multiple rules or `in`/`between` |
| Format chaining | Not implemented | Only one formatter per rule |
@@ -1539,4 +1732,5 @@ The DataGrid Formatting System provides a comprehensive, three-layer architectur
4. FormattingEngine applies rules during rendering
5. Cells display with CSS styles + formatted values
For most use cases, the DSL provides sufficient expressiveness without requiring programmatic interaction with the formatting engine.
For most use cases, the DSL provides sufficient expressiveness without requiring programmatic interaction with the
formatting engine.

View File

@@ -220,7 +220,10 @@ class DataGrid(MultipleInstance):
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._formatting_provider = DatagridMetadataProvider(self._parent)
self._formatting_engine = FormattingEngine(
rule_presets_provider=lambda: self._formatting_provider.rule_presets
)
self._columns = None
self.commands = Commands(self)
@@ -268,8 +271,7 @@ class DataGrid(MultipleInstance):
# self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting:
provider = DatagridMetadataProvider(self._parent)
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
completion_engine = FormattingCompletionEngine(self._formatting_provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,

View File

@@ -140,7 +140,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._query.bind_command("CancelQuery", self.commands.apply_filter())
# Add Menu
self._menu = Menu(self, conf=MenuConf(["ResetView"]))
self._menu = Menu(self, conf=MenuConf(["ResetView"]), _id="-menu")
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")

View File

@@ -15,12 +15,12 @@ from myfasthtml.core.formatting.dataclasses import RulePreset
from myfasthtml.core.formatting.presets import (
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
)
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.instances import UniqueInstance, InstancesManager
logger = logging.getLogger(__name__)
class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
class DatagridMetadataProvider(UniqueInstance, BaseMetadataProvider):
"""Concrete session-scoped metadata provider for DataGrid DSL engines.
Implements BaseMetadataProvider by delegating live data queries to
@@ -36,8 +36,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
all_tables_formats: Global format rules applied to all tables.
"""
def __init__(self, parent=None, session: Optional[dict] = None,
_id: Optional[str] = None):
def __init__(self, parent=None, session: Optional[dict] = None, _id: Optional[str] = None):
super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()

View File

@@ -31,7 +31,8 @@ class FormattingEngine:
style_presets: dict = None,
formatter_presets: dict = None,
rule_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
lookup_resolver: Callable[[str, str, str], dict] = None,
rule_presets_provider: Callable[[], dict] = None,
):
"""
Initialize the FormattingEngine.
@@ -41,11 +42,20 @@ class FormattingEngine:
formatter_presets: Custom formatter presets. If None, uses defaults.
rule_presets: Named rule presets (list of FormatRule dicts). If None, uses defaults.
lookup_resolver: Function for resolving enum datagrid sources.
rule_presets_provider: Callable returning the current rule_presets dict.
When provided, takes precedence over rule_presets on every apply_format call.
Use this to keep the engine in sync with a shared provider.
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
self._rule_presets_provider = rule_presets_provider
def _get_rule_presets(self) -> dict:
if self._rule_presets_provider is not None:
return self._rule_presets_provider()
return self._rule_presets
def apply_format(
self,
@@ -99,8 +109,8 @@ class FormattingEngine:
"""
Replace any FormatRule that references a rule preset with the preset's rules.
A rule is a rule preset reference when its formatter has a preset name
that exists in rule_presets (and not in formatter_presets).
A rule is a rule preset reference when its formatter or style has a preset name
that exists in rule_presets.
Args:
rules: Original list of FormatRule
@@ -108,21 +118,25 @@ class FormattingEngine:
Returns:
Expanded list with preset references replaced by their FormatRules
"""
rule_presets = self._get_rule_presets()
expanded = []
for rule in rules:
preset_name = self._get_rule_preset_name(rule)
preset_name = self._get_rule_preset_name(rule, rule_presets)
if preset_name:
expanded.extend(self._rule_presets[preset_name].rules)
expanded.extend(rule_presets[preset_name].rules)
else:
expanded.append(rule)
return expanded
def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
"""Return the preset name if the rule's formatter references a rule preset, else None."""
if rule.formatter is None:
return None
def _get_rule_preset_name(self, rule: FormatRule, rule_presets: dict) -> str | None:
"""Return the preset name if the rule references a rule preset via format() or style(), else None."""
if rule.formatter is not None:
preset = getattr(rule.formatter, "preset", None)
if preset and preset in self._rule_presets:
if preset and preset in rule_presets:
return preset
if rule.style is not None:
preset = getattr(rule.style, "preset", None)
if preset and preset in rule_presets:
return preset
return None

View File

@@ -5,7 +5,7 @@ from typing import Optional, Literal
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BoundCommand, Command
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, debug_session
VERBOSE_VERBOSE = False
@@ -36,7 +36,13 @@ class BaseInstance:
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
if VERBOSE_VERBOSE:
logger.debug(f" parent={parent}, session={session}, _id={_id}")
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
# for UniqueInstance, the parent is always the ultimate root parent
if issubclass(cls, UniqueInstance):
parent = BaseInstance.get_ultimate_root_parent(parent)
if VERBOSE_VERBOSE:
logger.debug(f" UniqueInstance detected. parent is set to ultimate root {parent=}")
# Compute _id
_id = cls.compute_id(_id, parent)
@@ -163,7 +169,7 @@ class BaseInstance:
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
if _id is None:
prefix = cls.compute_prefix()
if issubclass(cls, SingleInstance):
if issubclass(cls, (SingleInstance, UniqueInstance)):
_id = prefix
else:
_id = f"{prefix}-{str(uuid.uuid4())}"
@@ -174,6 +180,17 @@ class BaseInstance:
return _id
@staticmethod
def get_ultimate_root_parent(instance):
if instance is None:
return None
parent = instance
while True:
if parent.get_parent() is None:
return parent
parent = parent.get_parent()
class SingleInstance(BaseInstance):
"""
@@ -200,7 +217,7 @@ class UniqueInstance(BaseInstance):
_id: Optional[str] = None,
auto_register: bool = True,
on_init=None):
super().__init__(parent, session, _id, auto_register)
super().__init__(BaseInstance.get_ultimate_root_parent(parent), session, _id, auto_register)
if on_init is not None:
on_init()

View File

@@ -350,3 +350,32 @@ class TestPresets:
assert "background-color: purple" in css.css
assert "color: yellow" in css.css
def test_i_can_expand_rule_preset_via_style(self):
"""A rule preset referenced via style() must be expanded like via format().
Why: style("traffic_light") should expand the traffic_light rule preset
(which has conditional style rules) instead of looking up "traffic_light"
as a style preset name (where it doesn't exist).
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=-5)
assert css is not None
assert css.cls == "mf-formatting-error"
def test_i_can_expand_rule_preset_via_style_with_no_match(self):
"""A rule preset via style() with a non-matching condition returns no style.
Why: traffic_light has style("error") only if value < 0.
A positive value should produce no style output.
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=10)
assert css is not None
assert css.cls == "mf-formatting-success"