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 ## 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:** **Key features:**
@@ -60,7 +62,7 @@ The formatting system is built in three layers, each with a specific responsibil
``` ```
| Layer | Module | Responsibility | | Layer | Module | Responsibility |
|-------|--------|----------------| |--------------------------|-----------------------------------|------------------------------------------------------|
| **1. Formatting Engine** | `core/formatting/` | Apply format rules to cell values, resolve conflicts | | **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 | | **2. DSL Parser** | `core/formatting/dsl/` | Parse text DSL into structured rules |
| **3. Autocompletion** | `core/formatting/dsl/completion/` | Provide intelligent suggestions while editing | | **3. Autocompletion** | `core/formatting/dsl/completion/` | Provide intelligent suggestions while editing |
@@ -99,7 +101,7 @@ User writes DSL in editor
### Module Responsibilities ### Module Responsibilities
| Module | Location | Purpose | | Module | Location | Purpose |
|--------|----------|---------| |------------------------------|------------------------------------------|---------------------------------------------|
| `FormattingEngine` | `core/formatting/engine.py` | Main facade for applying format rules | | `FormattingEngine` | `core/formatting/engine.py` | Main facade for applying format rules |
| `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate conditions (==, <, contains, etc.) | | `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate conditions (==, <, contains, etc.) |
| `StyleResolver` | `core/formatting/style_resolver.py` | Resolve styles to CSS strings | | `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 | | `FormattingCompletionEngine` | `core/formatting/dsl/completion/` | Autocompletion for DSL |
| `DataGridFormattingEditor` | `controls/DataGridFormattingEditor.py` | DSL editor integrated in DataGrid | | `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 ## Fundamental Concepts
@@ -189,7 +338,7 @@ Scopes define **where** a rule applies. Five scope levels provide hierarchical t
``` ```
| Scope | Targets | Specificity | DSL Syntax | | Scope | Targets | Specificity | DSL Syntax |
|-------|---------|-------------|------------| |------------|-----------------------------|-------------|-------------------------------------------|
| **Cell** | 1 specific cell | Highest (1) | `cell (column, row):` or `cell tcell_id:` | | **Cell** | 1 specific cell | Highest (1) | `cell (column, row):` or `cell tcell_id:` |
| **Row** | All cells in row | High (2) | `row index:` | | **Row** | All cells in row | High (2) | `row index:` |
| **Column** | All cells in column | Medium (3) | `column name:` | | **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):** **Style Presets (DaisyUI 5):**
| Preset | Background | Text | Use Case | | Preset | Background | Text | Use Case |
|--------|------------|------|----------| |-------------|--------------------------|----------------------------------|-------------------|
| `primary` | `var(--color-primary)` | `var(--color-primary-content)` | Important values | | `primary` | `var(--color-primary)` | `var(--color-primary-content)` | Important values |
| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` | Secondary info | | `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` | Secondary info |
| `accent` | `var(--color-accent)` | `var(--color-accent-content)` | Highlights | | `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:** **Formatter Presets:**
| Preset | Type | Output Example | Use Case | | Preset | Type | Output Example | Use Case |
|--------|------|----------------|----------| |--------------|---------|----------------|--------------------|
| `EUR` | number | `1 234,56 €` | Euro currency | | `EUR` | number | `1 234,56 €` | Euro currency |
| `USD` | number | `$1,234.56` | US Dollar | | `USD` | number | `$1,234.56` | US Dollar |
| `percentage` | number | `75.0%` | Percentages (×100) | | `percentage` | number | `75.0%` | Percentages (×100) |
@@ -263,6 +412,7 @@ manager.add_style_preset("badge", {
``` ```
When a preset with `__class__` is applied: When a preset with `__class__` is applied:
- The CSS classes are added to the element's `class` attribute - The CSS classes are added to the element's `class` attribute
- The CSS properties are applied as inline styles - The CSS properties are applied as inline styles
- This allows combining DaisyUI component classes with custom styling - This allows combining DaisyUI component classes with custom styling
@@ -281,16 +431,18 @@ manager.add_style_preset("status_approved", {
}) })
# Use in DSL # Use in DSL
column status: column
style("status_draft") if value == "draft" status:
style("status_approved") if value == "approved" style("status_draft") if value == "draft"
style("status_approved") if value == "approved"
``` ```
--- ---
## Layer 1: Formatting Engine ## 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 ### Dataclasses
@@ -310,10 +462,11 @@ class Condition:
**Supported operators:** **Supported operators:**
| Operator | Description | Value Type | Example | | Operator | Description | Value Type | Example |
|----------|-------------|------------|---------| |--------------|--------------------|--------------|--------------------------|
| `==` | Equal | scalar | `value == 0` | | `==` | Equal | scalar | `value == 0` |
| `!=` | Not equal | scalar | `value != ""` | | `!=` | Not equal | scalar | `value != ""` |
| `<` | Less than | number | `value < 0` | | `<` | Less than | number | `value < 0` |
| | | | |
| `<=` | Less or equal | number | `value <= 100` | | `<=` | Less or equal | number | `value <= 100` |
| `>` | Greater than | number | `value > 1000` | | `>` | Greater than | number | `value > 1000` |
| `>=` | Greater or equal | number | `value >= 0` | | `>=` | 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 ## 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 ### DSL Syntax Overview
```python ```python
# Column scope # Column scope
column amount: column
format("EUR") amount:
style("error") if value < 0 format("EUR")
style("success") if value > col.budget style("error") if value < 0
style("success") if value > col.budget
# Row scope # Row scope
row 0: row
style("neutral", bold=True) 0:
style("neutral", bold=True)
# Cell scope # Cell scope
cell (amount, 10): cell(amount, 10):
style("accent", bold=True) style("accent", bold=True)
# Table scope # Table scope
table "orders": table
style(font_size="14px") "orders":
style(font_size="14px")
# Global scope # Global scope
tables: tables:
style(color="#333") style(color="#333")
``` ```
**Structure:** **Structure:**
@@ -671,12 +828,14 @@ Target all cells in a column by name (matches `col_id` first, then `title`):
```python ```python
# Simple name # Simple name
column amount: column
style("error") if value < 0 amount:
style("error") if value < 0
# Name with spaces (quoted) # Name with spaces (quoted)
column "total amount": column
format("EUR") "total amount":
format("EUR")
``` ```
#### Row Scope #### Row Scope
@@ -685,12 +844,14 @@ Target all cells in a row by index (0-based):
```python ```python
# Header row # Header row
row 0: row
style("neutral", bold=True) 0:
style("neutral", bold=True)
# Specific row # Specific row
row 5: row
style("highlight") 5:
style("highlight")
``` ```
#### Cell Scope #### Cell Scope
@@ -699,15 +860,16 @@ Target a specific cell by coordinates or ID:
```python ```python
# By coordinates (column, row) # By coordinates (column, row)
cell (amount, 3): cell(amount, 3):
style("highlight") style("highlight")
cell ("total amount", 0): cell("total amount", 0):
style("neutral", bold=True) style("neutral", bold=True)
# By cell ID # By cell ID
cell tcell_grid1-3-2: cell
style(background_color="yellow") tcell_grid1 - 3 - 2:
style(background_color="yellow")
``` ```
#### Table Scope #### Table Scope
@@ -715,9 +877,10 @@ cell tcell_grid1-3-2:
Target all cells in a specific table (must match DataGrid `_settings.name`): Target all cells in a specific table (must match DataGrid `_settings.name`):
```python ```python
table "products": table
style("neutral") "products":
format.number(precision=2) style("neutral")
format.number(precision=2)
``` ```
#### Tables Scope #### Tables Scope
@@ -726,7 +889,7 @@ Global rules for all tables:
```python ```python
tables: tables:
style(font_size="14px", color="#333") style(font_size="14px", color="#333")
``` ```
### Rules Syntax ### Rules Syntax
@@ -757,7 +920,7 @@ style(background_color="#ffeeee", color="#cc0000")
**Parameters:** **Parameters:**
| Parameter | Type | Description | | Parameter | Type | Description |
|-----------|------|-------------| |--------------------|---------------------|-----------------------------|
| `preset` | string (positional) | Preset name | | `preset` | string (positional) | Preset name |
| `background_color` | string | Background color | | `background_color` | string | Background color |
| `color` | string | Text color | | `color` | string | Text color |
@@ -794,7 +957,7 @@ format.constant(value="N/A")
**Type-specific parameters:** **Type-specific parameters:**
| Type | Parameters | | Type | Parameters |
|------|------------| |------------|-------------------------------------------------------------------------------|
| `number` | `prefix`, `suffix`, `thousands_sep`, `decimal_sep`, `precision`, `multiplier` | | `number` | `prefix`, `suffix`, `thousands_sep`, `decimal_sep`, `precision`, `multiplier` |
| `date` | `format` (strftime pattern) | | `date` | `format` (strftime pattern) |
| `boolean` | `true_value`, `false_value`, `null_value` | | `boolean` | `true_value`, `false_value`, `null_value` |
@@ -805,14 +968,14 @@ format.constant(value="N/A")
#### Condition Expression #### Condition Expression
```python ```python
if <left> <operator> <right> if < left > < operator > < right >
if <operand> <unary_operator> if < operand > < unary_operator >
``` ```
**Operators:** **Operators:**
| Operator | Example | | Operator | Example |
|----------|---------| |-------------------------|----------------------------|
| `==`, `!=` | `value == 0` | | `==`, `!=` | `value == 0` |
| `<`, `<=`, `>`, `>=` | `value < 0` | | `<`, `<=`, `>`, `>=` | `value < 0` |
| `contains` | `value contains "error"` | | `contains` | `value contains "error"` |
@@ -827,7 +990,9 @@ if <operand> <unary_operator>
```python ```python
style("error") if not value in ["valid", "approved"] style("error") if not value in ["valid", "approved"]
style("warning") if not value contains "OK" style("warning") if not value
contains
"OK"
``` ```
**Case sensitivity:** **Case sensitivity:**
@@ -835,14 +1000,16 @@ style("warning") if not value contains "OK"
String comparisons are case-insensitive by default. Use `(case)` modifier: String comparisons are case-insensitive by default. Use `(case)` modifier:
```python ```python
style("error") if value == "Error" (case) style("error") if value == "Error"(case)
style("warning") if value contains "WARN" (case) style("warning") if value
contains
"WARN"(case)
``` ```
**References:** **References:**
| Reference | Description | Example | | Reference | Description | Example |
|-----------|-------------|---------| |----------------|---------------------------|----------------------------|
| `value` | Current cell value | `value < 0` | | `value` | Current cell value | `value < 0` |
| `col.<name>` | Another column (same row) | `value > col.budget` | | `col.<name>` | Another column (same row) | `value > col.budget` |
| `col."<name>"` | Column with spaces | `value > col."max amount"` | | `col."<name>"` | Column with spaces | `value > col."max amount"` |
@@ -852,29 +1019,33 @@ style("warning") if value contains "WARN" (case)
**Basic formatting:** **Basic formatting:**
```python ```python
column amount: column
format("EUR") amount:
style("error") if value < 0 format("EUR")
style("error") if value < 0
``` ```
**Cross-column comparison:** **Cross-column comparison:**
```python ```python
column actual: column
format("EUR") actual:
style("error") if value > col.budget format("EUR")
style("warning") if value > col.budget * 0.8 style("error") if value > col.budget
style("success") if value <= col.budget * 0.8 style("warning") if value > col.budget * 0.8
style("success") if value <= col.budget * 0.8
``` ```
**Multiple conditions:** **Multiple conditions:**
```python ```python
column status: column
style("success") if value == "approved" status:
style("warning") if value == "pending" style("success") if value == "approved"
style("error") if value == "rejected" style("warning") if value == "pending"
style("neutral") if value isempty style("error") if value == "rejected"
style("neutral") if value
isempty
``` ```
**Complex example:** **Complex example:**
@@ -882,39 +1053,46 @@ column status:
```python ```python
# Global defaults # Global defaults
tables: tables:
style(font_size="14px", color="#333") style(font_size="14px", color="#333")
# Table-specific # Table-specific
table "financial_report": table
format.number(precision=2) "financial_report":
format.number(precision=2)
# Header row # Header row
row 0: row
style("neutral", bold=True) 0:
style("neutral", bold=True)
# Amount column # Amount column
column amount: column
format.number(precision=2, suffix=" €", thousands_sep=" ") amount:
style("error") if value < 0 format.number(precision=2, suffix=" €", thousands_sep=" ")
style("success") if value > col.target style("error") if value < 0
style("success") if value > col.target
# Percentage column # Percentage column
column progress: column
format("percentage") progress:
style("error") if value < 0.5 format("percentage")
style("warning") if value between 0.5 and 0.8 style("error") if value < 0.5
style("success") if value > 0.8 style("warning") if value
between
0.5 and 0.8
style("success") if value > 0.8
# Status column # Status column
column status: column
format.enum(source={"draft": "Draft", "review": "In Review", "approved": "Approved"}) status:
style("neutral") if value == "draft" format.enum(source={"draft": "Draft", "review": "In Review", "approved": "Approved"})
style("info") if value == "review" style("neutral") if value == "draft"
style("success") if value == "approved" style("info") if value == "review"
style("success") if value == "approved"
# Highlight specific cell # Highlight specific cell
cell (amount, 10): cell(amount, 10):
style("accent", bold=True) style("accent", bold=True)
``` ```
### Parser and Transformer ### Parser and Transformer
@@ -957,6 +1135,7 @@ class ScopedRule:
``` ```
Each `ScopedRule` contains: Each `ScopedRule` contains:
- **scope**: Where the rule applies - **scope**: Where the rule applies
- **rule**: What formatting to apply (condition + style + formatter) - **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: Before suggesting completions, detect the current scope by scanning backwards:
```python ```python
column amount: # ← Scope: ColumnScope("amount") column
style("error") if | # ← Cursor here 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. 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: Based on context, generate appropriate suggestions:
| Context | Suggestions | | Context | Suggestions |
|---------|-------------| |------------------|---------------------------------------------------------|
| `SCOPE_KEYWORD` | `column`, `row`, `cell`, `table`, `tables` | | `SCOPE_KEYWORD` | `column`, `row`, `cell`, `table`, `tables` |
| `COLUMN_NAME` | Column names from DataGrid | | `COLUMN_NAME` | Column names from DataGrid |
| `STYLE_PRESET` | Style presets ("error", "success", etc.) | | `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: The engine recognizes ~30 distinct contexts:
| Category | Contexts | | Category | Contexts |
|----------|----------| |---------------|------------------------------------------------------------------------------------------------------------------------------------------|
| **Scope** | `SCOPE_KEYWORD`, `COLUMN_NAME`, `ROW_INDEX`, `CELL_START`, `CELL_COLUMN`, `CELL_ROW`, `TABLE_NAME`, `TABLES_SCOPE` | | **Scope** | `SCOPE_KEYWORD`, `COLUMN_NAME`, `ROW_INDEX`, `CELL_START`, `CELL_COLUMN`, `CELL_ROW`, `TABLE_NAME`, `TABLES_SCOPE` |
| **Rule** | `RULE_START`, `AFTER_STYLE_OR_FORMAT` | | **Rule** | `RULE_START`, `AFTER_STYLE_OR_FORMAT` |
| **Style** | `STYLE_ARGS`, `STYLE_PRESET`, `STYLE_PARAM` | | **Style** | `STYLE_ARGS`, `STYLE_PRESET`, `STYLE_PARAM` |
@@ -1102,7 +1282,8 @@ The engine recognizes ~30 distinct contexts:
## Generic DSL Infrastructure ## 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 ### Purpose
@@ -1118,6 +1299,7 @@ Abstract base class for defining a DSL:
```python ```python
from myfasthtml.core.dsl.base import DSLDefinition from myfasthtml.core.dsl.base import DSLDefinition
class MyDSL(DSLDefinition): class MyDSL(DSLDefinition):
name: str = "My Custom DSL" name: str = "My Custom DSL"
@@ -1143,6 +1325,7 @@ Abstract base class for completion engines:
```python ```python
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
class MyCompletionEngine(BaseCompletionEngine): class MyCompletionEngine(BaseCompletionEngine):
def detect_scope(self, text: str, current_line: int): def detect_scope(self, text: str, current_line: int):
"""Detect current scope from previous lines.""" """Detect current scope from previous lines."""
@@ -1173,6 +1356,7 @@ Protocol for providing domain-specific metadata:
```python ```python
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
class MyProvider(BaseMetadataProvider): class MyProvider(BaseMetadataProvider):
# Implement methods to provide metadata for suggestions # Implement methods to provide metadata for suggestions
... ...
@@ -1234,18 +1418,20 @@ The editor matches column names by `col_id` first, then `title`:
```python ```python
# DSL # DSL
column amount: # Matches col_id="amount" or title="amount" column
... amount: # Matches col_id="amount" or title="amount"
...
``` ```
**Cell ID resolution:** **Cell ID resolution:**
```python ```python
# By coordinates # By coordinates
cell (amount, 3): # Resolved to tcell_grid1-3-0 cell(amount, 3): # Resolved to tcell_grid1-3-0
# By ID # By ID
cell tcell_grid1-3-0: # Used directly cell
tcell_grid1 - 3 - 0: # Used directly
``` ```
### FormattingDSL ### FormattingDSL
@@ -1342,9 +1528,10 @@ manager.add_style_preset("highlighted", {
**Usage in DSL:** **Usage in DSL:**
```python ```python
column status: column
style("badge_primary") if value == "active" status:
style("highlighted") if value == "important" style("badge_primary") if value == "active"
style("highlighted") if value == "important"
``` ```
#### Add Custom Formatter Presets #### 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} # Build mapping {value: display}
return mapping return mapping
engine = FormattingEngine(lookup_resolver=my_lookup_resolver) 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.dataclasses import Formatter
from myfasthtml.core.formatting.formatter_resolver import BaseFormatterResolver from myfasthtml.core.formatting.formatter_resolver import BaseFormatterResolver
@dataclass @dataclass
class CustomFormatter(Formatter): class CustomFormatter(Formatter):
custom_param: str = None custom_param: str = None
class CustomFormatterResolver(BaseFormatterResolver): class CustomFormatterResolver(BaseFormatterResolver):
def resolve(self, formatter: CustomFormatter, value: Any) -> str: def resolve(self, formatter: CustomFormatter, value: Any) -> str:
# Custom formatting logic # Custom formatting logic
@@ -1397,6 +1587,7 @@ class CustomFormatterResolver(BaseFormatterResolver):
# Preset application logic # Preset application logic
return formatter return formatter
# Register in FormatterResolver # Register in FormatterResolver
FormatterResolver._resolvers[CustomFormatter] = CustomFormatterResolver() FormatterResolver._resolvers[CustomFormatter] = CustomFormatterResolver()
``` ```
@@ -1407,6 +1598,7 @@ FormatterResolver._resolvers[CustomFormatter] = CustomFormatterResolver()
from myfasthtml.core.dsl.base import DSLDefinition from myfasthtml.core.dsl.base import DSLDefinition
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
class MyDSL(DSLDefinition): class MyDSL(DSLDefinition):
name = "My DSL" name = "My DSL"
@@ -1417,6 +1609,7 @@ class MyDSL(DSLDefinition):
... ...
""" """
class MyCompletionEngine(BaseCompletionEngine): class MyCompletionEngine(BaseCompletionEngine):
def detect_scope(self, text, line): def detect_scope(self, text, line):
# Scope detection logic # Scope detection logic
@@ -1477,7 +1670,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Public APIs by Module ### Public APIs by Module
| Module | Public API | Description | | Module | Public API | Description |
|--------|------------|-------------| |-----------------------------------|---------------------------------------------------------------------|-------------------------------------|
| `core/formatting/` | `FormattingEngine` | Main engine for applying rules | | `core/formatting/` | `FormattingEngine` | Main engine for applying rules |
| `core/formatting/dataclasses.py` | `Condition`, `Style`, `Formatter`, `FormatRule` | Core data structures | | `core/formatting/dataclasses.py` | `Condition`, `Style`, `Formatter`, `FormatRule` | Core data structures |
| `core/formatting/presets.py` | `DEFAULT_STYLE_PRESETS`, `DEFAULT_FORMATTER_PRESETS` | Built-in presets | | `core/formatting/presets.py` | `DEFAULT_STYLE_PRESETS`, `DEFAULT_FORMATTER_PRESETS` | Built-in presets |
@@ -1492,7 +1685,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Key Classes and Responsibilities ### Key Classes and Responsibilities
| Class | Location | Responsibility | | Class | Location | Responsibility |
|-------|----------|----------------| |------------------------------|----------------------------------------------------------------|--------------------------------------|
| `FormattingEngine` | `core/formatting/engine.py` | Apply format rules to cell values | | `FormattingEngine` | `core/formatting/engine.py` | Apply format rules to cell values |
| `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate 13 operators + references | | `ConditionEvaluator` | `core/formatting/condition_evaluator.py` | Evaluate 13 operators + references |
| `StyleResolver` | `core/formatting/style_resolver.py` | Resolve styles to CSS | | `StyleResolver` | `core/formatting/style_resolver.py` | Resolve styles to CSS |
@@ -1506,7 +1699,7 @@ class MyCompletionEngine(BaseCompletionEngine):
### Known Limitations ### Known Limitations
| Limitation | Status | Impact | | Limitation | Status | Impact |
|------------|--------|--------| |---------------------------------------------|--------------------------|-------------------------------------------------------|
| `row` parameter for column-level conditions | Not implemented | Cannot reference specific row in column scope | | `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` | | AND/OR operators | Not implemented | Use multiple rules or `in`/`between` |
| Format chaining | Not implemented | Only one formatter per rule | | 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 4. FormattingEngine applies rules during rendering
5. Cells display with CSS styles + formatted values 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__") 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._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._formatting_provider = DatagridMetadataProvider(self._parent)
self._formatting_engine = FormattingEngine(
rule_presets_provider=lambda: self._formatting_provider.rule_presets
)
self._columns = None self._columns = None
self.commands = Commands(self) self.commands = Commands(self)
@@ -268,8 +271,7 @@ class DataGrid(MultipleInstance):
# self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed()) # self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting: if self._settings.enable_formatting:
provider = DatagridMetadataProvider(self._parent) completion_engine = FormattingCompletionEngine(self._formatting_provider, self.get_table_name())
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id()) editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL() dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self, self._formatting_editor = DataGridFormattingEditor(self,

View File

@@ -140,7 +140,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._query.bind_command("CancelQuery", self.commands.apply_filter()) self._query.bind_command("CancelQuery", self.commands.apply_filter())
# Add Menu # 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}, " logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}") 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 ( from myfasthtml.core.formatting.presets import (
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS, 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__) logger = logging.getLogger(__name__)
class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider): class DatagridMetadataProvider(UniqueInstance, BaseMetadataProvider):
"""Concrete session-scoped metadata provider for DataGrid DSL engines. """Concrete session-scoped metadata provider for DataGrid DSL engines.
Implements BaseMetadataProvider by delegating live data queries to 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. all_tables_formats: Global format rules applied to all tables.
""" """
def __init__(self, parent=None, session: Optional[dict] = None, def __init__(self, parent=None, session: Optional[dict] = None, _id: Optional[str] = None):
_id: Optional[str] = None):
super().__init__(parent, session, _id) super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy() self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy() self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()

View File

@@ -31,7 +31,8 @@ class FormattingEngine:
style_presets: dict = None, style_presets: dict = None,
formatter_presets: dict = None, formatter_presets: dict = None,
rule_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. Initialize the FormattingEngine.
@@ -41,11 +42,20 @@ class FormattingEngine:
formatter_presets: Custom formatter presets. If None, uses defaults. formatter_presets: Custom formatter presets. If None, uses defaults.
rule_presets: Named rule presets (list of FormatRule dicts). 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. 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._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets) self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver) 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 = 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( def apply_format(
self, self,
@@ -99,8 +109,8 @@ class FormattingEngine:
""" """
Replace any FormatRule that references a rule preset with the preset's rules. 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 A rule is a rule preset reference when its formatter or style has a preset name
that exists in rule_presets (and not in formatter_presets). that exists in rule_presets.
Args: Args:
rules: Original list of FormatRule rules: Original list of FormatRule
@@ -108,21 +118,25 @@ class FormattingEngine:
Returns: Returns:
Expanded list with preset references replaced by their FormatRules Expanded list with preset references replaced by their FormatRules
""" """
rule_presets = self._get_rule_presets()
expanded = [] expanded = []
for rule in rules: 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: if preset_name:
expanded.extend(self._rule_presets[preset_name].rules) expanded.extend(rule_presets[preset_name].rules)
else: else:
expanded.append(rule) expanded.append(rule)
return expanded return expanded
def _get_rule_preset_name(self, rule: FormatRule) -> str | None: def _get_rule_preset_name(self, rule: FormatRule, rule_presets: dict) -> str | None:
"""Return the preset name if the rule's formatter references a rule preset, else None.""" """Return the preset name if the rule references a rule preset via format() or style(), else None."""
if rule.formatter is None: if rule.formatter is not None:
return None
preset = getattr(rule.formatter, "preset", 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 preset
return None return None

View File

@@ -5,7 +5,7 @@ from typing import Optional, Literal
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BoundCommand, Command from myfasthtml.core.commands import BoundCommand, Command
from myfasthtml.core.constants import NO_DEFAULT_VALUE 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 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) 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) _id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
if VERBOSE_VERBOSE: 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 # Compute _id
_id = cls.compute_id(_id, parent) _id = cls.compute_id(_id, parent)
@@ -163,7 +169,7 @@ class BaseInstance:
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']): def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
if _id is None: if _id is None:
prefix = cls.compute_prefix() prefix = cls.compute_prefix()
if issubclass(cls, SingleInstance): if issubclass(cls, (SingleInstance, UniqueInstance)):
_id = prefix _id = prefix
else: else:
_id = f"{prefix}-{str(uuid.uuid4())}" _id = f"{prefix}-{str(uuid.uuid4())}"
@@ -174,6 +180,17 @@ class BaseInstance:
return _id 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): class SingleInstance(BaseInstance):
""" """
@@ -200,7 +217,7 @@ class UniqueInstance(BaseInstance):
_id: Optional[str] = None, _id: Optional[str] = None,
auto_register: bool = True, auto_register: bool = True,
on_init=None): 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: if on_init is not None:
on_init() on_init()

View File

@@ -350,3 +350,32 @@ class TestPresets:
assert "background-color: purple" in css.css assert "background-color: purple" in css.css
assert "color: yellow" 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"