From 8e059df68a2b4f4001096813996b15fef21d92e2 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 8 Feb 2026 00:12:24 +0100 Subject: [PATCH] Fixed rule conflict management. Added User guide for formatting --- docs/DataGrid Formatting - User Guide.md | 1259 +++++++++++++++++ .../core/dsl/lark_to_simple_mode.py | 2 +- src/myfasthtml/core/formatting/dsl/grammar.py | 2 + .../core/formatting/dsl/transformer.py | 22 +- src/myfasthtml/core/formatting/engine.py | 96 +- tests/core/formatting/test_dsl_parser.py | 19 +- tests/core/formatting/test_engine.py | 69 + 7 files changed, 1452 insertions(+), 17 deletions(-) create mode 100644 docs/DataGrid Formatting - User Guide.md diff --git a/docs/DataGrid Formatting - User Guide.md b/docs/DataGrid Formatting - User Guide.md new file mode 100644 index 0000000..bda464d --- /dev/null +++ b/docs/DataGrid Formatting - User Guide.md @@ -0,0 +1,1259 @@ +# DataGrid Formatting - User Guide + +## Introduction + +The DataGrid Formatting system provides a powerful way to apply conditional styling and value formatting to your data grids. It allows you to create rich, readable data presentations with minimal code. + +**Key features:** + +- Apply styles (colors, fonts, decorations) based on cell values +- Format values for display (currencies, dates, percentages, text transformations) +- Create conditional rules that respond to data +- Reference other cells for cross-column/row comparisons +- Define formatting at multiple levels (cell, row, column, table, or globally) + +**Common use cases:** + +- Highlight negative values in financial reports +- Format currency and percentage values +- Color-code status indicators +- Display custom labels for enumerated values +- Apply different styles based on data thresholds +- Create visual hierarchies with conditional formatting + +## Quick Start + +Here's a simple example to highlight negative values in red: + +```python +column amount: + style("error") if value < 0 + format("EUR") +``` + +This creates a formatting rule that: +- Applies to all cells in the "amount" column +- Colors negative values with the "error" style (red background) +- Formats all values as Euro currency + +## Understanding Formatting + +### Formatting Levels + +Formatting can be applied at five different levels, each with its own specificity: + +| Level | Cells Targeted | Condition Evaluated On | Specificity | +|------------|-----------------------------------|------------------------|-------------| +| **Cell** | 1 specific cell | The cell value | Highest (1) | +| **Row** | All cells in the row | Each cell value | High (2) | +| **Column** | All cells in the column | Each cell value | Medium (3) | +| **Table** | All cells in a specific table | Each cell value | Low (4) | +| **Tables** | All cells in all tables (global) | Each cell value | Lowest (5) | + +**Visual hierarchy:** + +``` +Cell (amount, 5) ← Highest priority + ↓ +Row 5 ← Overridden by cell + ↓ +Column amount ← Overridden by row and cell + ↓ +Table "products" ← Overridden by column, row, and cell + ↓ +Tables (global) ← Lowest priority, applies everywhere +``` + +### How Rules Are Evaluated + +Rules are processed from **highest to lowest specificity**. When multiple rules match: + +1. **Cell-level rules** win over all others +2. **Row-level rules** win over column, table, and global rules +3. **Column-level rules** win over table and global rules +4. **Table-level rules** win over global rules +5. **Global rules** apply only if no other rules match + +### Conflict Resolution + +When multiple rules at the **same level** match a cell, style and formatter are resolved **independently**: + +**Style rules compete among themselves:** +1. **Specificity** = number of conditions in the rule (0 or 1) +2. **Higher specificity wins** +3. **At equal specificity, last rule wins** + +**Formatter rules compete among themselves:** +1. Same specificity logic as styles +2. Resolved independently from styles + +**Style and formatter can fuse:** +- If one rule provides the style and another provides the formatter, **both are applied** +- This allows separating concerns (e.g., unconditional formatting + conditional styling) + +**Example 1: Style competition** + +```python +column amount: + style(color="gray") # Specificity: 0 (no condition) + style("error") if value < 0 # Specificity: 1 + style("success", bold=True) if value > 0 # Specificity: 1 +``` + +- For `value = -10`: "error" style wins (specificity 1 > 0) +- For `value = 50`: "success" style wins (specificity 1 > 0) +- For `value = 0`: gray color applies (no other rules match) + +**Example 2: Style and formatter fusion** + +```python +column amount: + format("EUR") # Unconditional formatter + style("error") if value < 0 # Conditional style +``` + +- For `value = -100`: + - ✅ Style "error" applied (from conditional rule) + - ✅ Format "EUR" applied (from unconditional rule) + - Result: `-100,00 €` with red background + +- For `value = 100`: + - ❌ No style (condition not met) + - ✅ Format "EUR" applied + - Result: `100,00 €` with default style + +**Example 3: Multiple styles and formatters** + +```python +column amount: + style("neutral") # Unconditional style + format("EUR") # Unconditional formatter + style("error") if value < 0 # Conditional style (higher specificity) + format.number(precision=0, suffix=" €") if value < 0 # Conditional formatter (higher specificity) +``` + +- For `value = -5.67`: + - Style: "error" wins (specificity 1 > 0) + - Formatter: precision=0 wins (specificity 1 > 0) + - Result: `-6 €` with red background + +- For `value = 1234.56`: + - Style: "neutral" applies (no conditional matches) + - Formatter: "EUR" applies (no conditional matches) + - Result: `1 234,56 €` with neutral background + +### How Rules Work + +A formatting rule consists of three optional parts: + +``` +[style(...)] [format(...)] [if ] +``` + +**Rules:** + +- At least one of `style()` or `format()` must be present +- `condition` is optional +- If a condition is present, the rule applies only when the condition is met +- Multiple rules can be defined for the same scope + +**Examples:** + +```python +# Style only +style("error") + +# Format only +format("EUR") + +# Style + Format +style("error") format("EUR") + +# With condition +style("error") if value < 0 + +# All combined +style("error") format("EUR") if value < 0 +``` + +## Writing Formatting Rules + +### Basic Syntax + +A DSL document consists of **scopes**, each containing one or more **rules**: + +```python +: + + + ... + +: + + ... +``` + +**Scopes define which cells a rule applies to:** + +| Scope Syntax | Applies To | Example | +|--------------|------------|---------| +| `column :` | All cells in the column | `column amount:` | +| `column "":` | Column with spaces | `column "total amount":` | +| `row :` | All cells in the row | `row 0:` | +| `cell (, ):` | Single cell by coordinates | `cell (amount, 3):` | +| `cell :` | Single cell by ID | `cell tcell_grid1-3-2:` | +| `table "":` | All cells in a specific table | `table "products":` | +| `tables:` | All cells in all tables | `tables:` | + +**Indentation and comments:** + +```python +# This is a comment + +column amount: + # Rules must be indented under their scope + style("error") if value < 0 + format("EUR") + +# Column names with spaces need quotes +column "total amount": + style("neutral", bold=True) +``` + +### Styling Cells + +The `style()` function applies visual formatting to cells. + +**Syntax:** + +```python +style() +style(, ) +style() +``` + +**Available style presets (DaisyUI 5):** + +| Preset | Background | Text | Use Case | +|--------|------------|------|----------| +| `primary` | Primary theme color | Primary content | Main highlights | +| `secondary` | Secondary theme color | Secondary content | Secondary highlights | +| `accent` | Accent theme color | Accent content | Accent highlights | +| `neutral` | Neutral theme color | Neutral content | Headers, totals | +| `info` | Info (blue) | Info content | Information | +| `success` | Success (green) | Success content | Positive values | +| `warning` | Warning (yellow) | Warning content | Warnings | +| `error` | Error (red) | Error content | Negative values, errors | + +**Style properties:** + +| Property | Type | Description | Example | +|----------|------|-------------|---------| +| `background_color` | string | Background color | `"#ffeeee"`, `"red"`, `"var(--color-primary)"` | +| `color` | string | Text color | `"#cc0000"`, `"white"` | +| `bold` | boolean | Bold text | `True`, `False` | +| `italic` | boolean | Italic text | `True`, `False` | +| `underline` | boolean | Underlined text | `True`, `False` | +| `strikethrough` | boolean | Strikethrough text | `True`, `False` | +| `font_size` | string | Font size | `"12px"`, `"0.9em"` | + +**Examples:** + +```python +# Preset only +column status: + style("error") + +# Preset with overrides +column total: + style("neutral", bold=True) + +# Multiple properties +column name: + style("success", italic=True, underline=True) + +# No preset, direct properties +column notes: + style(color="red", bold=True) + style(background_color="#ffeeee", color="#cc0000") +``` + +### Formatting Values + +The `format()` function transforms cell values for display without changing the underlying data. + +**Using presets:** + +```python +# Preset only +format("EUR") + +# Preset with overrides +format("EUR", precision=3) +``` + +**Available formatter presets:** + +| Preset | Type | Description | Example Output | +|--------|------|-------------|----------------| +| `EUR` | number | Euro currency | 1 234,56 € | +| `USD` | number | US Dollar | $1,234.56 | +| `percentage` | number | Percentage (×100) | 45.2% | +| `short_date` | date | DD/MM/YYYY | 29/01/2026 | +| `iso_date` | date | YYYY-MM-DD | 2026-01-29 | +| `yes_no` | boolean | Yes/No | Yes | + +**Using explicit types:** + +When not using a preset, specify the type explicitly: + +```python +format.number(precision=2, suffix=" €", thousands_sep=" ") +format.date(format="%d/%m/%Y") +format.boolean(true_value="Oui", false_value="Non") +format.text(max_length=50, ellipsis="...") +format.enum(source={"draft": "Draft", "published": "Published"}) +format.constant(value="N/A") +``` + +#### Number Formatter + +Formats numbers, currencies, and percentages. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `prefix` | string | `""` | Text before value (e.g., "$") | +| `suffix` | string | `""` | Text after value (e.g., " €", "%") | +| `thousands_sep` | string | `""` | Thousands separator (e.g., ",", " ") | +| `decimal_sep` | string | `"."` | Decimal separator (e.g., ".", ",") | +| `precision` | int | `0` | Number of decimal places | +| `multiplier` | number | `1` | Multiply value before display (e.g., 100 for %) | + +**Examples:** + +```python +# Euro currency +column amount: + format.number(suffix=" €", thousands_sep=" ", decimal_sep=",", precision=2) + +# US Dollar +column price: + format.number(prefix="$", thousands_sep=",", precision=2) + +# Percentage +column rate: + format.number(suffix="%", multiplier=100, precision=1) + +# Large numbers with thousands separator +column population: + format.number(thousands_sep=",", precision=0) +``` + +#### Date Formatter + +Formats dates and datetimes using strftime patterns. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | string | `"%Y-%m-%d"` | strftime format pattern | + +**Common format patterns:** + +| Pattern | Description | Example | +|---------|-------------|---------| +| `"%Y-%m-%d"` | ISO format | 2026-01-29 | +| `"%d/%m/%Y"` | European | 29/01/2026 | +| `"%m/%d/%Y"` | US format | 01/29/2026 | +| `"%d %b %Y"` | Short month | 29 Jan 2026 | +| `"%d %B %Y"` | Full month | 29 January 2026 | +| `"%Y-%m-%d %H:%M"` | With time | 2026-01-29 14:30 | + +**Examples:** + +```python +# European format +column created_at: + format.date(format="%d/%m/%Y") + +# Full date with month name +column updated_at: + format.date(format="%d %B %Y") + +# ISO format with time +column timestamp: + format.date(format="%Y-%m-%d %H:%M:%S") +``` + +#### Boolean Formatter + +Formats boolean and binary values. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `true_value` | string | `"true"` | Display for true/1 | +| `false_value` | string | `"false"` | Display for false/0 | +| `null_value` | string | `""` | Display for null/None | + +**Examples:** + +```python +# Yes/No +column active: + format.boolean(true_value="Yes", false_value="No") + +# Checked/Unchecked +column completed: + format.boolean(true_value="✓", false_value="✗") + +# Custom labels +column status: + format.boolean(true_value="Enabled", false_value="Disabled", null_value="Unknown") +``` + +#### Text Formatter + +Formats and transforms text values. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `transform` | string | - | `"uppercase"`, `"lowercase"`, `"capitalize"` | +| `max_length` | int | - | Truncate if exceeded | +| `ellipsis` | string | `"..."` | Suffix when truncated | + +**Examples:** + +```python +# Uppercase +column code: + format.text(transform="uppercase") + +# Truncate long text +column description: + format.text(max_length=50, ellipsis="...") + +# Capitalize first letter +column name: + format.text(transform="capitalize") +``` + +#### Enum Formatter + +Maps values to display labels. Useful for status codes, categories, and dropdown lists. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | object | - | Mapping or datagrid reference | +| `default` | string | `""` | Label for unknown values | + +**Source types:** + +**Static mapping:** + +```python +column status: + format.enum( + source={ + "draft": "Draft", + "pending": "Pending Review", + "approved": "Approved", + "rejected": "Rejected" + }, + default="Unknown" + ) +``` + +**From another DataGrid:** + +```python +column category_id: + format.enum( + source={ + "type": "datagrid", + "value": "categories_grid", + "value_column": "id", + "display_column": "name" + } + ) +``` + +This looks up the `category_id` value in the "categories_grid", finds the matching row by "id", and displays the "name" column. + +#### Constant Formatter + +Displays a fixed value regardless of the cell's actual value. Useful for placeholder text or fixed labels. + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | string | - | The constant text to display | + +**Examples:** + +```python +# Display "N/A" for all cells +column placeholder: + format.constant(value="N/A") + +# Display fixed label +column type: + format.constant(value="Product") + +# Combined with condition +column optional_field: + format.constant(value="—") if value isempty +``` + +### Conditional Formatting + +Conditions determine when a rule applies. They use the syntax: + +```python +if +if +``` + +#### Comparison Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `==` | Equal | `value == 0` | +| `!=` | Not equal | `value != ""` | +| `<` | Less than | `value < 0` | +| `<=` | Less or equal | `value <= 100` | +| `>` | Greater than | `value > 1000` | +| `>=` | Greater or equal | `value >= 0` | + +**Examples:** + +```python +column amount: + style("error") if value < 0 + style("success") if value > 1000 + style("warning") if value == 0 +``` + +#### String Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `contains` | String contains | `value contains "error"` | +| `startswith` | String starts with | `value startswith "ERR"` | +| `endswith` | String ends with | `value endswith ".pdf"` | + +**Examples:** + +```python +column message: + style("error") if value contains "error" + style("info") if value startswith "INFO" + +column filename: + style("accent") if value endswith ".pdf" +``` + +**Note:** String comparisons are **case-insensitive by default**. See "Case Sensitivity" below for case-sensitive matching. + +#### List Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `in` | Value in list | `value in ["A", "B", "C"]` | +| `between` | Value in range | `value between 0 and 100` | + +**Examples:** + +```python +column status: + style("success") if value in ["approved", "completed"] + style("warning") if value in ["pending", "review"] + +column score: + style("error") if value between 0 and 30 + style("warning") if value between 31 and 70 + style("success") if value between 71 and 100 +``` + +#### Empty and NaN Checks + +| Operator | Description | Example | +|----------|-------------|---------| +| `isempty` | Is null or empty string | `value isempty` | +| `isnotempty` | Is not null or empty | `value isnotempty` | +| `isnan` | Is NaN (Not a Number) | `value isnan` | + +**Examples:** + +```python +column notes: + style("neutral") if value isempty + format.constant(value="—") if value isempty + +column calculation: + style("error") if value isnan + format.constant(value="Error") if value isnan + +column required_field: + style("warning") if value isempty +``` + +**Note:** `isempty` checks for `None` or empty string (`""`). `isnan` specifically checks for invalid float values (NaN). + +#### Cell References + +References allow comparing a cell's value with values from other cells. + +| Reference | Description | Example | +|-----------|-------------|---------| +| `value` | Current cell value | `value < 0` | +| `col.` | Value from another column (same row) | `value > col.budget` | +| `col.""` | Column with spaces | `value > col."max amount"` | +| `row.` | Value from another row (same column) | `value != row.0` | +| `cell.-` | Specific cell by coordinates | `value == cell.status-0` | + +**Examples:** + +```python +# Compare with another column +column actual: + style("error") if value > col.budget + style("warning") if value > col.budget * 0.9 + +# Compare with header row +column total: + style("neutral", bold=True) if value == row.0 + +# Compare with specific cell +column status: + style("success") if value == cell.status-0 +``` + +**Arithmetic in references:** + +You can perform simple arithmetic with column references: + +```python +column spending: + style("warning") if value > col.budget * 0.8 + style("error") if value > col.budget * 1.1 +``` + +#### Negation + +Use `not` to negate any condition: + +```python +column status: + style("error") if not value in ["valid", "approved"] + style("warning") if not value contains "OK" + +column amount: + style("success") if not value < 0 +``` + +#### Case Sensitivity + +String comparisons are **case-insensitive by default**. Use the `(case)` modifier for case-sensitive matching: + +```python +column code: + style("error") if value == "Error" (case) + style("warning") if value contains "WARN" (case) + +column status: + # This matches "approved", "APPROVED", "Approved" + style("success") if value == "approved" + + # This matches only "APPROVED" + style("info") if value == "APPROVED" (case) +``` + +### Complete Examples + +#### Example 1: Highlight Negative Values + +Simple financial data with negative values highlighted: + +```python +column amount: + style("error") if value < 0 + format("EUR") +``` + +**Result:** +- Negative values: red background, formatted as Euro +- Positive values: normal display, formatted as Euro + +#### Example 2: Status Indicators with Color Coding + +Map status codes to colors and labels: + +```python +column status: + format.enum( + source={ + "draft": "Draft", + "pending": "Pending Review", + "approved": "Approved", + "rejected": "Rejected" + }, + default="Unknown" + ) + style("neutral") if value == "draft" + style("warning") if value == "pending" + style("success") if value == "approved" + style("error") if value == "rejected" +``` + +**Result:** +- "draft" → "Draft" with neutral background +- "pending" → "Pending Review" with yellow background +- "approved" → "Approved" with green background +- "rejected" → "Rejected" with red background + +#### Example 3: Budget vs Actual with Thresholds + +Compare actual spending against budget with warning thresholds: + +```python +column actual: + format("EUR") + style("success") if value <= col.budget * 0.8 + style("warning") if value > col.budget * 0.8 + style("error") if value > col.budget + +column budget: + format("EUR") +``` + +**Result:** +- Actual ≤ 80% of budget: green background +- Actual > 80% but ≤ 100%: yellow background +- Actual > 100%: red background + +#### Example 4: Financial Report with Multiple Rules + +Complete financial report with headers, formatting, and conditional highlighting: + +```python +# Global styling for all tables +tables: + style(font_size="14px") + +# Header row +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 + +# 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 + +# Status column +column status: + format.enum( + source={ + "draft": "Draft", + "review": "In Review", + "approved": "Approved", + "rejected": "Rejected" + } + ) + style("neutral") if value == "draft" + style("info") if value == "review" + style("success") if value == "approved" + style("error") if value == "rejected" + +# Date column +column created_at: + format.date(format="%d %b %Y") + +# Highlight specific important cell +cell (amount, 10): + style("accent", bold=True) +``` + +**Result:** A fully formatted financial report with: +- Bold headers with neutral background +- Euro amounts with conditional coloring +- Progress percentages with traffic light colors +- Status labels with appropriate colors +- Formatted dates +- Highlighted total row + +## Using the DSL Editor + +### Editor Features + +The DSL editor provides a comfortable writing experience with: + +**Syntax highlighting:** +- Keywords (column, row, if, not) are highlighted +- Functions (style, format) are colored differently +- Strings, numbers, and operators have distinct colors + +**Line numbers:** +- Easy reference for error messages +- Quick navigation in large documents + +**Auto-indentation:** +- Automatic indentation after scope declarations +- Tab key for manual indentation +- Maintains Python-style indentation + +### Autocompletion + +The editor provides context-aware suggestions to help you write rules quickly and correctly. + +**How to trigger:** + +- **Ctrl+Space** - Manual trigger at any time +- **Automatic** - Triggers after typing `.`, `(`, `"`, or space in certain contexts + +**What can be autocompleted:** + +| Context | Suggestions | +|---------|-------------| +| Start of line | `column`, `row`, `cell`, `table`, `tables` | +| After `column ` | Column names from your DataGrid | +| After `row ` | Row indices (0, 1, 2, ...) | +| After `table ` | Table name from current DataGrid | +| Indented line | `style(`, `format(`, `format.` | +| Inside `style("` | Style presets (primary, error, success, ...) | +| After `style(` | Preset names and parameters (bold=, color=, ...) | +| Inside `format("` | Format presets (EUR, USD, percentage, ...) | +| After `format.` | Format types (number, date, boolean, text, enum, constant) | +| After `if ` | `value`, `col.`, `row.`, `not` | +| After operand | Operators (==, <, >, contains, in, ...) | +| After `col.` | Column names | +| After operator | `col.`, literal values, `True`, `False` | + +**Example workflow:** + +``` +User types │ Suggestions +───────────────┼──────────────────────────────────── +col │ column +column │ [column names from grid] +column amount: │ (new line, indent) + st │ style + style( │ "error", "warning", "success", ... + style("e │ "error" + style("error", │ bold=, italic=, color=, ... + style("error", bold=│ True, False + style("error") │ format(, format., if + style("error") if │ value, col., not + style("error") if value │ ==, !=, <, >, contains, in, ... + style("error") if value < │ [number input] +``` + +### Syntax Validation + +The editor provides real-time syntax checking as you type. + +**Error indicators:** + +``` +┌─────────────────────────────────────────────────────┐ +│ 1 column amount: │ +│ 2 style("error" if value < 0 │ +│ ▲▲▲▲▲▲▲▲▲▲▲▲▲ │ +│ ╰─ Missing closing parenthesis │ +│ 3 │ +└─────────────────────────────────────────────────────┘ +``` + +**Visual feedback:** + +- **Red underline** - Syntax error at that position +- **Gutter marker** - Error icon in the line number area +- **Tooltip** - Hover over the error for details + +**Common errors and fixes:** + +| Error | Cause | Fix | +|-------|-------|-----| +| `Expected ':'` | Missing colon after scope | Add `:` after scope name | +| `Expected indentation` | Rule not indented | Indent rule under scope | +| `Unexpected token` | Typo or invalid syntax | Check spelling, operators | +| `Missing closing quote` | Unclosed string | Add closing `"` or `'` | +| `Missing closing parenthesis` | Unclosed function call | Add closing `)` | +| `Unknown column` | Column doesn't exist | Check column name spelling | + +**Example - Before and after fixing:** + +**Before (error):** + +```python +column amount + style("error") if value < 0 +``` + +Error: `Expected ':' after column name` + +**After (fixed):** + +```python +column amount: + style("error") if value < 0 +``` + +## Managing Presets + +### Using Built-in Presets + +**Style presets (DaisyUI 5):** + +| Preset | Background | Text | Typical Use | +|--------|------------|------|-------------| +| `primary` | Primary theme color | Primary content | Main highlights, important data | +| `secondary` | Secondary theme color | Secondary content | Secondary highlights | +| `accent` | Accent theme color | Accent content | Accent highlights, special items | +| `neutral` | Neutral theme color | Neutral content | Headers, totals, summaries | +| `info` | Info (blue) | Info content | Information, notes | +| `success` | Success (green) | Success content | Positive values, completed items | +| `warning` | Warning (yellow) | Warning content | Warnings, thresholds exceeded | +| `error` | Error (red) | Error content | Negative values, errors, failures | + +**Formatter presets:** + +| Preset | Type | Configuration | Example Output | +|--------|------|---------------|----------------| +| `EUR` | number | suffix: " €", thousands_sep: " ", decimal_sep: ",", precision: 2 | 1 234,56 € | +| `USD` | number | prefix: "$", thousands_sep: ",", decimal_sep: ".", precision: 2 | $1,234.56 | +| `percentage` | number | suffix: "%", precision: 1, multiplier: 100 | 45.2% | +| `short_date` | date | format: "%d/%m/%Y" | 29/01/2026 | +| `iso_date` | date | format: "%Y-%m-%d" | 2026-01-29 | +| `yes_no` | boolean | true_value: "Yes", false_value: "No" | Yes | + +### Creating Custom Presets + +You can add your own presets to use across all DataGrids. + +**Adding a custom style preset:** + +```python +from myfasthtml.controls.DataGridsManager import DataGridsManager + +# Get the global manager +manager = DataGridsManager.get_instance() + +# Add custom style preset +manager.add_style_preset("highlight", { + "background_color": "yellow", + "color": "black", + "font_weight": "bold" +}) + +# Use in DSL +column important: + style("highlight") +``` + +**Adding a custom formatter preset:** + +```python +# Swiss Franc currency +manager.add_formatter_preset("CHF", { + "type": "number", + "prefix": "CHF ", + "thousands_sep": "'", + "decimal_sep": ".", + "precision": 2 +}) + +# Use in DSL +column price: + format("CHF") +``` + +**Adding multiple presets:** + +```python +# Custom style presets +manager.add_style_preset("urgent", { + "background_color": "#ff6b6b", + "color": "white", + "font_weight": "bold" +}) + +manager.add_style_preset("completed", { + "background_color": "#51cf66", + "color": "white", + "text_decoration": "line-through" +}) + +# Custom formatter presets +manager.add_formatter_preset("GBP", { + "type": "number", + "prefix": "£", + "thousands_sep": ",", + "precision": 2 +}) + +manager.add_formatter_preset("compact_date", { + "type": "date", + "format": "%d/%m/%y" +}) +``` + +**Removing presets:** + +```python +# Remove style preset +manager.remove_style_preset("highlight") + +# Remove formatter preset +manager.remove_formatter_preset("CHF") +``` + +## Reference + +### Style Properties + +Complete reference of all style properties: + +| Property | Type | Values | Description | +|----------|------|--------|-------------| +| `preset` | string | See Style Presets below | Preset name (applied first) | +| `background_color` | string | Hex, CSS name, or variable | Background color | +| `color` | string | Hex, CSS name, or variable | Text color | +| `bold` | boolean | `True`, `False` | Bold text | +| `italic` | boolean | `True`, `False` | Italic text | +| `underline` | boolean | `True`, `False` | Underlined text | +| `strikethrough` | boolean | `True`, `False` | Strikethrough text | +| `font_size` | string | CSS size value | Font size (e.g., "12px", "0.9em") | + +**Color values:** + +```python +# Hex colors +style(color="#cc0000") + +# CSS color names +style(color="red") + +# DaisyUI CSS variables +style(color="var(--color-primary)") +style(background_color="var(--color-base-200)") +``` + +**Combining properties:** + +```python +# Preset + overrides +style("error", bold=True, font_size="16px") + +# Multiple properties +style(background_color="#fffacd", color="#856404", bold=True, italic=True) +``` + +### Style Presets + +DaisyUI 5 color presets: + +| Preset | Background CSS Variable | Text CSS Variable | +|--------|------------------------|-------------------| +| `primary` | `var(--color-primary)` | `var(--color-primary-content)` | +| `secondary` | `var(--color-secondary)` | `var(--color-secondary-content)` | +| `accent` | `var(--color-accent)` | `var(--color-accent-content)` | +| `neutral` | `var(--color-neutral)` | `var(--color-neutral-content)` | +| `info` | `var(--color-info)` | `var(--color-info-content)` | +| `success` | `var(--color-success)` | `var(--color-success-content)` | +| `warning` | `var(--color-warning)` | `var(--color-warning-content)` | +| `error` | `var(--color-error)` | `var(--color-error-content)` | + +**Note:** All presets use `font_weight: "normal"`, `font_style: "normal"`, `text_decoration: "none"` by default. + +### Formatter Types + +#### Number Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `prefix` | string | `""` | Text before value | +| `suffix` | string | `""` | Text after value | +| `thousands_sep` | string | `""` | Thousands separator | +| `decimal_sep` | string | `"."` | Decimal separator | +| `precision` | int | `0` | Decimal places | +| `multiplier` | number | `1` | Multiply before display | + +#### Date Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | string | `"%Y-%m-%d"` | strftime pattern | + +#### Boolean Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `true_value` | string | `"true"` | Display for true/1 | +| `false_value` | string | `"false"` | Display for false/0 | +| `null_value` | string | `""` | Display for null/None | + +#### Text Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `transform` | string | - | `"uppercase"`, `"lowercase"`, `"capitalize"` | +| `max_length` | int | - | Truncate if exceeded | +| `ellipsis` | string | `"..."` | Suffix when truncated | + +#### Enum Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | object | - | Mapping or datagrid reference | +| `default` | string | `""` | Label for unknown values | + +#### Constant Formatter + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | string | - | The constant text to display | + +### Formatter Presets + +| Preset | Type | Configuration | +|--------|------|---------------| +| `EUR` | number | `suffix: " €", thousands_sep: " ", decimal_sep: ",", precision: 2` | +| `USD` | number | `prefix: "$", thousands_sep: ",", decimal_sep: ".", precision: 2` | +| `percentage` | number | `suffix: "%", precision: 1, multiplier: 100` | +| `short_date` | date | `format: "%d/%m/%Y"` | +| `iso_date` | date | `format: "%Y-%m-%d"` | +| `yes_no` | boolean | `true_value: "Yes", false_value: "No"` | + +### Operators Reference + +#### Comparison Operators + +| Operator | Description | Value Type | Example | +|----------|-------------|------------|---------| +| `==` | Equal | Any | `value == 0` | +| `!=` | Not equal | Any | `value != ""` | +| `<` | Less than | Number, Date | `value < 0` | +| `<=` | Less or equal | Number, Date | `value <= 100` | +| `>` | Greater than | Number, Date | `value > 1000` | +| `>=` | Greater or equal | Number, Date | `value >= 0` | + +#### String Operators + +| Operator | Description | Value Type | Example | +|----------|-------------|------------|---------| +| `contains` | String contains | String | `value contains "error"` | +| `startswith` | String starts with | String | `value startswith "ERR"` | +| `endswith` | String ends with | String | `value endswith ".pdf"` | + +**Note:** String operators are case-insensitive by default. Use `(case)` modifier for case-sensitive matching. + +#### List Operators + +| Operator | Description | Value Type | Example | +|----------|-------------|------------|---------| +| `in` | Value in list | List | `value in ["A", "B", "C"]` | +| `between` | Value in range | List [min, max] | `value between 0 and 100` | + +#### Unary Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `isempty` | Is null or empty string | `value isempty` | +| `isnotempty` | Is not null or empty | `value isnotempty` | +| `isnan` | Is NaN (Not a Number) | `value isnan` | + +### DSL Grammar (EBNF) + +Formal syntax reference for the DSL: + +```ebnf +// Top-level structure +program : scope+ + +// Scopes +scope : scope_header NEWLINE INDENT rule+ DEDENT +scope_header : column_scope | row_scope | cell_scope | table_scope | tables_scope +column_scope : "column" column_name ":" +row_scope : "row" INTEGER ":" +cell_scope : "cell" cell_ref ":" +table_scope : "table" QUOTED_STRING ":" +tables_scope : "tables" ":" +column_name : NAME | QUOTED_STRING +cell_ref : "(" column_name "," INTEGER ")" | CELL_ID + +// Rules +rule : (style_expr format_expr? | format_expr style_expr?) condition? NEWLINE +condition : "if" comparison + +// Comparisons +comparison : "not"? (binary_comp | unary_comp) case_modifier? +binary_comp : operand operator operand + | operand "in" list + | operand "between" operand "and" operand +unary_comp : operand ("isempty" | "isnotempty" | "isnan") +case_modifier : "(" "case" ")" + +// Operators +operator : "==" | "!=" | "<" | "<=" | ">" | ">=" + | "contains" | "startswith" | "endswith" + +// Operands +operand : value_ref | column_ref | row_ref | cell_ref_expr | literal | arithmetic +value_ref : "value" +column_ref : "col." (NAME | QUOTED_STRING) +row_ref : "row." INTEGER +cell_ref_expr : "cell." NAME "-" INTEGER +literal : STRING | NUMBER | BOOLEAN +arithmetic : operand ("*" | "/" | "+" | "-") operand +list : "[" (literal ("," literal)*)? "]" + +// Style expression +style_expr : "style" "(" style_args ")" +style_args : (QUOTED_STRING ("," style_kwargs)?) | style_kwargs +style_kwargs : style_kwarg ("," style_kwarg)* +style_kwarg : NAME "=" (QUOTED_STRING | BOOLEAN | NUMBER) + +// Format expression +format_expr : format_preset | format_typed +format_preset : "format" "(" QUOTED_STRING ("," format_kwargs)? ")" +format_typed : "format" "." FORMAT_TYPE "(" format_kwargs? ")" +format_kwargs : format_kwarg ("," format_kwarg)* +format_kwarg : NAME "=" (QUOTED_STRING | BOOLEAN | NUMBER | dict) +dict : "{" (dict_entry ("," dict_entry)*)? "}" +dict_entry : QUOTED_STRING ":" QUOTED_STRING + +// Tokens +FORMAT_TYPE : "number" | "date" | "boolean" | "text" | "enum" | "constant" +NAME : /[a-zA-Z_][a-zA-Z0-9_]*/ +QUOTED_STRING : /"[^"]*"/ | /'[^']*'/ +INTEGER : /[0-9]+/ +NUMBER : /[0-9]+(\.[0-9]+)?/ +BOOLEAN : "True" | "False" | "true" | "false" +CELL_ID : /tcell_[a-zA-Z0-9_-]+/ +NEWLINE : /\n/ +COMMENT : /#.*/ +``` + +--- + +**End of User Guide** + +For technical details about the implementation, see the developer documentation. diff --git a/src/myfasthtml/core/dsl/lark_to_simple_mode.py b/src/myfasthtml/core/dsl/lark_to_simple_mode.py index 100d4c1..bb4d352 100644 --- a/src/myfasthtml/core/dsl/lark_to_simple_mode.py +++ b/src/myfasthtml/core/dsl/lark_to_simple_mode.py @@ -203,7 +203,7 @@ def generate_formatting_dsl_mode() -> Dict[str, Any]: {"regex": r"#.*", "token": "comment"}, # Scope keywords - {"regex": r"\b(?:column|row|cell)\b", "token": "keyword"}, + {"regex": r"\b(?:column|row|cell|table|tables)\b", "token": "keyword"}, # Condition keywords {"regex": r"\b(?:if|not|and|or|in|between|case)\b", "token": "keyword"}, diff --git a/src/myfasthtml/core/formatting/dsl/grammar.py b/src/myfasthtml/core/formatting/dsl/grammar.py index 20f3290..59ac3f8 100644 --- a/src/myfasthtml/core/formatting/dsl/grammar.py +++ b/src/myfasthtml/core/formatting/dsl/grammar.py @@ -55,6 +55,7 @@ GRAMMAR = r""" unary_comparison: operand "isempty" -> isempty_comp | operand "isnotempty" -> isnotempty_comp + | operand "isnan" -> isnan_comp case_modifier: "(" "case" ")" @@ -122,6 +123,7 @@ GRAMMAR = r""" | "boolean" -> fmt_boolean | "text" -> fmt_text | "enum" -> fmt_enum + | "constant" -> fmt_constant // ==================== Keyword arguments ==================== diff --git a/src/myfasthtml/core/formatting/dsl/transformer.py b/src/myfasthtml/core/formatting/dsl/transformer.py index 81fd765..3fff84f 100644 --- a/src/myfasthtml/core/formatting/dsl/transformer.py +++ b/src/myfasthtml/core/formatting/dsl/transformer.py @@ -16,6 +16,7 @@ from ..dataclasses import ( BooleanFormatter, TextFormatter, EnumFormatter, + ConstantFormatter, ) @@ -96,7 +97,7 @@ class DSLTransformer(Transformer): if isinstance(item, Style): style_obj = item elif isinstance(item, (NumberFormatter, DateFormatter, BooleanFormatter, - TextFormatter, EnumFormatter)): + TextFormatter, EnumFormatter, ConstantFormatter)): formatter_obj = item elif isinstance(item, Condition): condition_obj = item @@ -166,9 +167,12 @@ class DSLTransformer(Transformer): def isempty_comp(self, items): return Condition(operator="isempty") - + def isnotempty_comp(self, items): return Condition(operator="isnotempty") + + def isnan_comp(self, items): + return Condition(operator="isnan") # ==================== Operators ==================== @@ -333,9 +337,12 @@ class DSLTransformer(Transformer): def fmt_text(self, items): return "text" - + def fmt_enum(self, items): return "enum" + + def fmt_constant(self, items): + return "constant" def _build_formatter(self, format_type: str, kwargs: dict): """Build the appropriate Formatter subclass.""" @@ -349,6 +356,8 @@ class DSLTransformer(Transformer): return TextFormatter(**self._filter_text_kwargs(kwargs)) elif format_type == "enum": return EnumFormatter(**self._filter_enum_kwargs(kwargs)) + elif format_type == "constant": + return ConstantFormatter(**self._filter_constant_kwargs(kwargs)) else: raise DSLValidationError(f"Unknown formatter type: {format_type}") @@ -376,7 +385,12 @@ class DSLTransformer(Transformer): """Filter kwargs for EnumFormatter.""" valid_keys = {"source", "default", "allow_empty", "empty_label", "order_by"} return {k: v for k, v in kwargs.items() if k in valid_keys} - + + def _filter_constant_kwargs(self, kwargs: dict) -> dict: + """Filter kwargs for ConstantFormatter.""" + valid_keys = {"value"} + return {k: v for k, v in kwargs.items() if k in valid_keys} + # ==================== Keyword arguments ==================== def kwargs(self, items): diff --git a/src/myfasthtml/core/formatting/engine.py b/src/myfasthtml/core/formatting/engine.py index 95e8ee6..95e805c 100644 --- a/src/myfasthtml/core/formatting/engine.py +++ b/src/myfasthtml/core/formatting/engine.py @@ -71,23 +71,22 @@ class FormattingEngine: if not matching_rules: return None, None - # Resolve conflicts to get the winning rule - winning_rule = self._resolve_conflicts(matching_rules) - - if winning_rule is None: - return None, None + # Resolve style and formatter independently + # This allows combining style from one rule and formatter from another + winning_style = self._resolve_style(matching_rules) + winning_formatter = self._resolve_formatter(matching_rules) # Apply style css_string = None - if winning_rule.style: - css_string = self._style_resolver.to_css_string(winning_rule.style) + if winning_style: + css_string = self._style_resolver.to_css_string(winning_style) if css_string == "": css_string = None # Apply formatter formatted_value = None - if winning_rule.formatter: - formatted_value = self._formatter_resolver.resolve(winning_rule.formatter, cell_value) + if winning_formatter: + formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value) return css_string, formatted_value @@ -116,10 +115,89 @@ class FormattingEngine: return matching + def _resolve_style(self, matching_rules: list[FormatRule]): + """ + Resolve style conflicts when multiple rules match. + + Resolution logic: + 1. Filter to rules that have a style + 2. Specificity = 1 if rule has condition, 0 otherwise + 3. Higher specificity wins + 4. At equal specificity, last rule wins + + Args: + matching_rules: List of rules that matched + + Returns: + The winning Style, or None if no rules have style + """ + # Filter to rules with style + style_rules = [rule for rule in matching_rules if rule.style is not None] + + if not style_rules: + return None + + if len(style_rules) == 1: + return style_rules[0].style + + # Calculate specificity for each rule + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in style_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1].style + + def _resolve_formatter(self, matching_rules: list[FormatRule]): + """ + Resolve formatter conflicts when multiple rules match. + + Resolution logic: + 1. Filter to rules that have a formatter + 2. Specificity = 1 if rule has condition, 0 otherwise + 3. Higher specificity wins + 4. At equal specificity, last rule wins + + Args: + matching_rules: List of rules that matched + + Returns: + The winning Formatter, or None if no rules have formatter + """ + # Filter to rules with formatter + formatter_rules = [rule for rule in matching_rules if rule.formatter is not None] + + if not formatter_rules: + return None + + if len(formatter_rules) == 1: + return formatter_rules[0].formatter + + # Calculate specificity for each rule + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in formatter_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1].formatter + def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None: """ Resolve conflicts when multiple rules match. + DEPRECATED: This method is kept for backward compatibility but is no longer used. + Use _resolve_style() and _resolve_formatter() instead. + Resolution logic: 1. Specificity = 1 if rule has condition, 0 otherwise 2. Higher specificity wins diff --git a/tests/core/formatting/test_dsl_parser.py b/tests/core/formatting/test_dsl_parser.py index e75dcb6..41e7e5e 100644 --- a/tests/core/formatting/test_dsl_parser.py +++ b/tests/core/formatting/test_dsl_parser.py @@ -11,6 +11,7 @@ from myfasthtml.core.formatting.dataclasses import ( BooleanFormatter, TextFormatter, EnumFormatter, + ConstantFormatter, ) from myfasthtml.core.formatting.dsl import ( parse_dsl, @@ -342,11 +343,23 @@ column status: format.enum(source={"draft": "Brouillon", "published": "Publie"}) """ rules = parse_dsl(dsl) - + formatter = rules[0].rule.formatter assert isinstance(formatter, EnumFormatter) assert formatter.source == {"draft": "Brouillon", "published": "Publie"} + def test_i_can_parse_format_constant(self): + """Test parsing format.constant with fixed value.""" + dsl = """ +column placeholder: + format.constant(value="N/A") +""" + rules = parse_dsl(dsl) + + formatter = rules[0].rule.formatter + assert isinstance(formatter, ConstantFormatter) + assert formatter.value == "N/A" + # ============================================================================= # Condition Tests @@ -379,9 +392,9 @@ column amount: assert condition is not None assert condition.operator == operator - @pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"]) + @pytest.mark.parametrize("unary_op", ["isempty", "isnotempty", "isnan"]) def test_i_can_parse_unary_conditions(self, unary_op): - """Test parsing unary conditions (isempty, isnotempty).""" + """Test parsing unary conditions (isempty, isnotempty, isnan).""" dsl = f""" column name: style("neutral") if value {unary_op} diff --git a/tests/core/formatting/test_engine.py b/tests/core/formatting/test_engine.py index 61316b1..81128cd 100644 --- a/tests/core/formatting/test_engine.py +++ b/tests/core/formatting/test_engine.py @@ -215,6 +215,75 @@ class TestConflictResolution: assert "color: red" in css + def test_style_and_formatter_fusion(self): + """ + Test that style and formatter can come from different rules. + + Scenario: + - Rule 1: format("EUR") - unconditional formatter + - Rule 2: style("secondary") if value > col.X - conditional style + + When condition is met: + - Style from Rule 2 (higher specificity for style) + - Formatter from Rule 1 (only rule with formatter) + - Both should be applied (fusion) + """ + engine = FormattingEngine() + rules = [ + FormatRule(formatter=NumberFormatter(precision=2, suffix=" €")), # unconditional + FormatRule( + condition=Condition(operator=">", value={"col": "budget"}), + style=Style(preset="secondary") # conditional + ), + ] + row_data = {"budget": 100} + + # Case 1: Condition met (value > budget) + css, formatted = engine.apply_format(rules, cell_value=150, row_data=row_data) + + assert css is not None + assert "var(--color-secondary)" in css # Style from Rule 2 + assert formatted == "150.00 €" # Formatter from Rule 1 + + # Case 2: Condition not met (value <= budget) + css, formatted = engine.apply_format(rules, cell_value=50, row_data=row_data) + + assert css is None # No style (Rule 2 doesn't match) + assert formatted == "50.00 €" # Formatter from Rule 1 still applies + + def test_multiple_styles_and_formatters_highest_specificity_wins(self): + """ + Test that style and formatter are resolved independently with specificity. + + Rules: + - Rule 1: style("neutral") - unconditional + - Rule 2: format("EUR") - unconditional + - Rule 3: style("error") if value < 0 - conditional style + - Rule 4: format.number(precision=0) if value < 0 - conditional formatter + + When value < 0: + - Style from Rule 3 (higher specificity) + - Formatter from Rule 4 (higher specificity) + """ + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(preset="neutral")), + FormatRule(formatter=NumberFormatter(precision=2, suffix=" €")), + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(preset="error") + ), + FormatRule( + condition=Condition(operator="<", value=0), + formatter=NumberFormatter(precision=0, suffix=" €") + ), + ] + + css, formatted = engine.apply_format(rules, cell_value=-5.67) + + assert "var(--color-error)" in css # Rule 3 wins for style + assert formatted == "-6 €" # Rule 4 wins for formatter (precision=0) + class TestWithRowData: def test_condition_with_column_reference(self):