Added Rules preset (on top of format and style presets)

This commit is contained in:
2026-03-13 21:02:03 +01:00
parent 3105b72ac2
commit 3d1a391cba
7 changed files with 848 additions and 131 deletions

View File

@@ -6,164 +6,195 @@ from typing import Any
@dataclass
class Condition:
"""
Represents a condition for conditional formatting.
"""
Represents a condition for conditional formatting.
Attributes:
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
"contains", "startswith", "endswith", "in", "between",
"isempty", "isnotempty")
value: Value to compare against (literal, list, or {"col": "..."} for reference)
negate: If True, inverts the condition result
case_sensitive: If True, string comparisons are case-sensitive (default False)
col: Column ID for row-level conditions (evaluate this column instead of current cell)
row: Row index for column-level conditions (evaluate this row instead of current cell)
"""
operator: str
value: Any = None
negate: bool = False
case_sensitive: bool = False
col: str = None
row: int = None
Attributes:
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
"contains", "startswith", "endswith", "in", "between",
"isempty", "isnotempty")
value: Value to compare against (literal, list, or {"col": "..."} for reference)
negate: If True, inverts the condition result
case_sensitive: If True, string comparisons are case-sensitive (default False)
col: Column ID for row-level conditions (evaluate this column instead of current cell)
row: Row index for column-level conditions (evaluate this row instead of current cell)
"""
operator: str
value: Any = None
negate: bool = False
case_sensitive: bool = False
col: str = None
row: int = None
# === Style ===
@dataclass
class Style:
"""
Represents style properties for cell formatting.
"""
Represents style properties for cell formatting.
Attributes:
preset: Name of a style preset ("primary", "success", "error", etc.)
background_color: Background color (hex, CSS name, or CSS variable)
color: Text color
font_weight: "normal" or "bold"
font_style: "normal" or "italic"
font_size: Font size ("12px", "0.9em")
text_decoration: "none", "underline", or "line-through"
"""
preset: str = None
background_color: str = None
color: str = None
font_weight: str = None
font_style: str = None
font_size: str = None
text_decoration: str = None
Attributes:
preset: Name of a style preset ("primary", "success", "error", etc.)
background_color: Background color (hex, CSS name, or CSS variable)
color: Text color
font_weight: "normal" or "bold"
font_style: "normal" or "italic"
font_size: Font size ("12px", "0.9em")
text_decoration: "none", "underline", or "line-through"
"""
preset: str = None
background_color: str = None
color: str = None
font_weight: str = None
font_style: str = None
font_size: str = None
text_decoration: str = None
# === Formatters ===
@dataclass
class Formatter:
"""Base class for all formatters."""
preset: str = None
"""Base class for all formatters."""
preset: str = None
@dataclass
class NumberFormatter(Formatter):
"""
Formatter for numbers, currencies, and percentages.
"""
Formatter for numbers, currencies, and percentages.
Attributes:
prefix: Text before value (e.g., "$")
suffix: Text after value (e.g., " EUR")
thousands_sep: Thousands separator (e.g., ",", " ")
decimal_sep: Decimal separator (e.g., ".", ",")
precision: Number of decimal places
multiplier: Multiply value before display (e.g., 100 for percentage)
"""
prefix: str = ""
suffix: str = ""
thousands_sep: str = ""
decimal_sep: str = "."
precision: int = 0
multiplier: float = 1.0
absolute: bool = False
Attributes:
prefix: Text before value (e.g., "$")
suffix: Text after value (e.g., " EUR")
thousands_sep: Thousands separator (e.g., ",", " ")
decimal_sep: Decimal separator (e.g., ".", ",")
precision: Number of decimal places
multiplier: Multiply value before display (e.g., 100 for percentage)
"""
prefix: str = ""
suffix: str = ""
thousands_sep: str = ""
decimal_sep: str = "."
precision: int = 0
multiplier: float = 1.0
absolute: bool = False
@dataclass
class DateFormatter(Formatter):
"""
Formatter for dates and datetimes.
"""
Formatter for dates and datetimes.
Attributes:
format: strftime format pattern (default: "%Y-%m-%d")
"""
format: str = "%Y-%m-%d"
Attributes:
format: strftime format pattern (default: "%Y-%m-%d")
"""
format: str = "%Y-%m-%d"
@dataclass
class BooleanFormatter(Formatter):
"""
Formatter for boolean values.
"""
Formatter for boolean values.
Attributes:
true_value: Display string for True
false_value: Display string for False
null_value: Display string for None/null
"""
true_value: str = "true"
false_value: str = "false"
null_value: str = ""
Attributes:
true_value: Display string for True
false_value: Display string for False
null_value: Display string for None/null
"""
true_value: str = "true"
false_value: str = "false"
null_value: str = ""
@dataclass
class TextFormatter(Formatter):
"""
Formatter for text transformations.
"""
Formatter for text transformations.
Attributes:
transform: Text transformation ("uppercase", "lowercase", "capitalize")
max_length: Maximum length before truncation
ellipsis: Suffix when truncated (default: "...")
"""
transform: str = None
max_length: int = None
ellipsis: str = "..."
Attributes:
transform: Text transformation ("uppercase", "lowercase", "capitalize")
max_length: Maximum length before truncation
ellipsis: Suffix when truncated (default: "...")
"""
transform: str = None
max_length: int = None
ellipsis: str = "..."
@dataclass
class ConstantFormatter(Formatter):
value: str = None
value: str = None
@dataclass
class EnumFormatter(Formatter):
"""
Formatter for mapping values to display labels.
"""
Formatter for mapping values to display labels.
Attributes:
source: Data source dict with "type" and "value" keys
- {"type": "mapping", "value": {"key": "label", ...}}
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
default: Label for unknown values
allow_empty: Show empty option in Select dropdowns
empty_label: Label for empty option
order_by: Sort order ("source", "display", "value")
"""
source: dict = field(default_factory=dict)
default: str = ""
allow_empty: bool = True
empty_label: str = "-- Select --"
order_by: str = "source"
Attributes:
source: Data source dict with "type" and "value" keys
- {"type": "mapping", "value": {"key": "label", ...}}
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
default: Label for unknown values
allow_empty: Show empty option in Select dropdowns
empty_label: Label for empty option
order_by: Sort order ("source", "display", "value")
"""
source: dict = field(default_factory=dict)
default: str = ""
allow_empty: bool = True
empty_label: str = "-- Select --"
order_by: str = "source"
# === Format Rule ===
@dataclass
class FormatRule:
"""
A formatting rule combining condition, style, and formatter.
"""
A formatting rule combining condition, style, and formatter.
Rules:
- style and formatter can appear alone (unconditional formatting)
- condition cannot appear alone - must be paired with style and/or formatter
- If condition is present, style/formatter is applied only if condition is met
Rules:
- style and formatter can appear alone (unconditional formatting)
- condition cannot appear alone - must be paired with style and/or formatter
- If condition is present, style/formatter is applied only if condition is met
Attributes:
condition: Optional condition for conditional formatting
style: Optional style to apply
formatter: Optional formatter to apply
"""
condition: Condition = None
style: Style = None
formatter: Formatter = None
Attributes:
condition: Optional condition for conditional formatting
style: Optional style to apply
formatter: Optional formatter to apply
"""
condition: Condition = None
style: Style = None
formatter: Formatter = None
# === Rule Preset ===
@dataclass
class RulePreset:
"""
A named, reusable list of FormatRules.
Referenced in DSL as format("name") or style("name").
Appears in format() suggestions if at least one rule has a formatter.
Appears in style() suggestions if at least one rule has a style.
Attributes:
name: Unique identifier used in DSL (e.g. "accounting")
description: Human-readable description shown in the UI
rules: Ordered list of FormatRule to apply
"""
name: str
description: str
rules: list[FormatRule] = field(default_factory=list)
def has_formatter(self) -> bool:
"""Returns True if at least one rule defines a formatter."""
return any(r.formatter is not None for r in self.rules)
def has_style(self) -> bool:
"""Returns True if at least one rule defines a style."""
return any(r.style is not None for r in self.rules)

View File

@@ -322,28 +322,41 @@ class FormattingCompletionEngine(BaseCompletionEngine):
def _get_style_preset_suggestions(self) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
# Add rule presets that have at least one style rule
try:
for rule_preset in self.provider.list_rule_presets_for_style():
suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
except Exception:
pass
# Add provider custom style presets
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
# Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets (just the name, no quotes - we're inside quotes)
# Add default style presets (no quotes we're inside quotes)
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_style_preset_suggestions_quoted(self) -> list[Suggestion]:
"""Get style preset suggestions with quotes."""
suggestions = []
# Add provider presets if available
# Add rule presets that have at least one style rule
try:
for rule_preset in self.provider.list_rule_presets_for_style():
suggestions.append(Suggestion(f'"{rule_preset.name}"', rule_preset.description, "rule_preset"))
except Exception:
pass
# Add provider custom style presets
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
@@ -351,18 +364,25 @@ class FormattingCompletionEngine(BaseCompletionEngine):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
except Exception:
pass
# Add default presets with quotes
# Add default style presets with quotes
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
return suggestions
def _get_format_preset_suggestions(self) -> list[Suggestion]:
"""Get format preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
# Add rule presets that have at least one formatter rule
try:
for rule_preset in self.provider.list_rule_presets_for_format():
suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
except Exception:
pass
# Add provider custom formatter presets
try:
custom_presets = self.provider.list_format_presets()
for preset in custom_presets:
@@ -370,11 +390,11 @@ class FormattingCompletionEngine(BaseCompletionEngine):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets
# Add default formatter presets
for preset in presets.FORMAT_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_row_index_suggestions(self) -> list[Suggestion]:

View File

@@ -11,7 +11,10 @@ from typing import Any, Optional
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS
from myfasthtml.core.formatting.dataclasses import RulePreset
from myfasthtml.core.formatting.presets import (
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
)
from myfasthtml.core.instances import SingleInstance, InstancesManager
logger = logging.getLogger(__name__)
@@ -38,6 +41,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
self.rule_presets: dict[str, RulePreset] = DEFAULT_RULE_PRESETS.copy()
self.all_tables_formats: list = []
# ------------------------------------------------------------------
@@ -132,6 +136,14 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
"""Return the names of all registered formatter presets."""
return list(self.formatter_presets.keys())
def list_rule_presets_for_format(self) -> list[RulePreset]:
"""Return rule presets that have at least one rule with a formatter."""
return [p for p in self.rule_presets.values() if p.has_formatter()]
def list_rule_presets_for_style(self) -> list[RulePreset]:
"""Return rule presets that have at least one rule with a style."""
return [p for p in self.rule_presets.values() if p.has_style()]
def get_style_presets(self) -> dict:
"""Return the full style presets dict."""
return self.style_presets

View File

@@ -3,6 +3,7 @@ from typing import Any, Callable
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
from myfasthtml.core.formatting.dataclasses import FormatRule
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS
from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer
@@ -29,6 +30,7 @@ class FormattingEngine:
self,
style_presets: dict = None,
formatter_presets: dict = None,
rule_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
):
"""
@@ -37,11 +39,13 @@ class FormattingEngine:
Args:
style_presets: Custom style 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.
lookup_resolver: Function for resolving enum datagrid sources.
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
def apply_format(
self,
@@ -65,6 +69,9 @@ class FormattingEngine:
if not rules:
return None, None
# Expand rule preset references before evaluating
rules = self._expand_rule_presets(rules)
# Find all matching rules
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
@@ -88,6 +95,37 @@ class FormattingEngine:
return style, formatted_value
def _expand_rule_presets(self, rules: list[FormatRule]) -> list[FormatRule]:
"""
Replace any FormatRule that references a rule preset with the preset's rules.
A rule is a rule preset reference when its formatter has a preset name
that exists in rule_presets (and not in formatter_presets).
Args:
rules: Original list of FormatRule
Returns:
Expanded list with preset references replaced by their FormatRules
"""
expanded = []
for rule in rules:
preset_name = self._get_rule_preset_name(rule)
if preset_name:
expanded.extend(self._rule_presets[preset_name].rules)
else:
expanded.append(rule)
return expanded
def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
"""Return the preset name if the rule's formatter references a rule preset, else None."""
if rule.formatter is None:
return None
preset = getattr(rule.formatter, "preset", None)
if preset and preset in self._rule_presets:
return preset
return None
def _get_matching_rules(
self,
rules: list[FormatRule],

View File

@@ -1,3 +1,7 @@
from myfasthtml.core.formatting.dataclasses import (
Condition, Style, FormatRule, RulePreset,
NumberFormatter, )
# === Style Presets (DaisyUI 5) ===
# Keys use CSS property names (with hyphens)
@@ -22,6 +26,56 @@ DEFAULT_STYLE_PRESETS = {
"white": {"__class__": "mf-formatting-white", },
}
# === Rule Presets ===
# Each RulePreset = name + description + list of FormatRule objects.
# Referenced in DSL as: format("name") or style("name")
DEFAULT_RULE_PRESETS: dict[str, RulePreset] = {
"accounting": RulePreset(
name="accounting",
description="Negatives in parentheses, positives plain",
rules=[
FormatRule(
condition=Condition(operator="<", value=0),
formatter=NumberFormatter(precision=0, prefix="(", suffix=")",
absolute=True, thousands_sep=" "),
),
FormatRule(
condition=Condition(operator=">", value=0),
formatter=NumberFormatter(precision=0, thousands_sep=" "),
),
],
),
"traffic_light": RulePreset(
name="traffic_light",
description="Red / yellow / green style based on sign",
rules=[
FormatRule(condition=Condition(operator="<", value=0), style=Style(preset="error")),
FormatRule(condition=Condition(operator="==", value=0), style=Style(preset="warning")),
FormatRule(condition=Condition(operator=">", value=0), style=Style(preset="success")),
],
),
"budget_variance": RulePreset(
name="budget_variance",
description="% variance: negative=error, over 10%=warning, else plain",
rules=[
FormatRule(
condition=Condition(operator="<", value=0),
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
style=Style(preset="error"),
),
FormatRule(
condition=Condition(operator=">", value=0.1),
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
style=Style(preset="warning"),
),
FormatRule(
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
),
],
),
}
# === Formatter Presets ===
DEFAULT_FORMATTER_PRESETS = {