Fixed command id collision. Added class support in style preset

This commit is contained in:
2026-02-08 19:50:10 +01:00
parent 3ec994d6df
commit d44e0a0c01
14 changed files with 623 additions and 3677 deletions

View File

@@ -47,15 +47,16 @@ class Command:
# In this situation,
# either there is no parameter (so one single instance of the command is enough)
# or the parameter is a kwargs (so the parameters are provided when the command is called)
if (key is None
and owner is not None
and args is None # args is not provided
):
key = f"{owner.get_full_id()}-{name}"
key = key.replace("#{args}", _compute_from_args())
key = key.replace("#{id}", owner.get_full_id())
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
if key is None:
if owner is not None and args is None: # args is not provided
key = f"{owner.get_full_id()}-{name}"
else:
key = f"{name}-{_compute_from_args()}"
else:
key = key.replace("#{args}", _compute_from_args())
if owner is not None:
key = key.replace("#{id}", owner.get_full_id())
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
return key
@@ -78,24 +79,17 @@ class Command:
self._bindings = []
self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self._key = key
# special management when kwargs are provided
# In this situation,
# either there is no parameter (so one single instance of the command is enough)
# or the parameter is a kwargs (so the parameters are provided when the command is called)
if (self._key is None
and self.owner is not None
and args is None # args is not provided
):
self._key = f"{owner.get_full_id()}-{name}"
self._key = self.process_key(key, self.name, self.owner, self.default_args, self.default_kwargs)
# register the command
if auto_register:
if self._key in CommandsManager.commands_by_key:
self.id = CommandsManager.commands_by_key[self._key].id
if self._key is not None:
if self._key in CommandsManager.commands_by_key:
self.id = CommandsManager.commands_by_key[self._key].id
else:
CommandsManager.register(self)
else:
CommandsManager.register(self)
logger.warning(f"Command {self.name} has no key, it will not be registered.")
def get_key(self):
return self._key

View File

@@ -105,13 +105,13 @@ class FormattingCompletionEngine(BaseCompletionEngine):
case Context.CELL_ROW:
return self._get_row_index_suggestions()
case Context.TABLE_NAME:
return self._get_table_name_suggestion()
case Context.TABLES_SCOPE:
return [Suggestion(":", "Define global rules for all tables", "syntax")]
# =================================================================
# Rule-level contexts
# =================================================================
@@ -236,13 +236,13 @@ class FormattingCompletionEngine(BaseCompletionEngine):
except Exception:
pass
return []
def _get_table_name_suggestion(self) -> list[Suggestion]:
"""Get table name suggestion (current table only)."""
if self.table_name:
return [Suggestion(f'"{self.table_name}"', f"Current table: {self.table_name}", "table")]
return []
def _get_style_preset_suggestions(self) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
@@ -337,7 +337,7 @@ class FormattingCompletionEngine(BaseCompletionEngine):
try:
# Use table_name from scope, or empty string as fallback
table_name = scope.table_name or ""
table_name = scope.table_name or self.table_name or ""
values = self.provider.list_column_values(table_name, scope.column_name)
suggestions = []
for value in values:

View File

@@ -3,228 +3,226 @@ 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.style_resolver import StyleResolver
from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer
class FormattingEngine:
"""
Main facade for the formatting system.
Combines:
- ConditionEvaluator: evaluates conditions
- StyleResolver: resolves styles to CSS
- FormatterResolver: formats values for display
- Conflict resolution: handles multiple matching rules
Usage:
engine = FormattingEngine()
rules = [
FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)),
FormatRule(formatter=NumberFormatter(preset="EUR")),
]
css, formatted = engine.apply_format(rules, cell_value=-5.0)
"""
def __init__(
self,
style_presets: dict = None,
formatter_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
):
"""
Main facade for the formatting system.
Initialize the FormattingEngine.
Combines:
- ConditionEvaluator: evaluates conditions
- StyleResolver: resolves styles to CSS
- FormatterResolver: formats values for display
- Conflict resolution: handles multiple matching rules
Usage:
engine = FormattingEngine()
rules = [
FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)),
FormatRule(formatter=NumberFormatter(preset="EUR")),
]
css, formatted = engine.apply_format(rules, cell_value=-5.0)
Args:
style_presets: Custom style presets. If None, uses defaults.
formatter_presets: Custom formatter presets. 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)
def apply_format(
self,
rules: list[FormatRule],
cell_value: Any,
row_data: dict = None
) -> tuple[StyleContainer | None, str | None]:
"""
Apply format rules to a cell value.
def __init__(
self,
style_presets: dict = None,
formatter_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
):
"""
Initialize the FormattingEngine.
Args:
rules: List of FormatRule to evaluate
cell_value: The cell value to format
row_data: Dict of {col_id: value} for column references
Args:
style_presets: Custom style presets. If None, uses defaults.
formatter_presets: Custom formatter presets. 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)
Returns:
Tuple of (css_string, formatted_value):
- css_string: CSS inline style string, or None if no style
- formatted_value: Formatted string, or None if no formatter
"""
if not rules:
return None, None
# Find all matching rules
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
if not matching_rules:
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
style = None
if winning_style:
style = self._style_resolver.to_style_container(winning_style)
# Apply formatter
formatted_value = None
if winning_formatter:
formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value)
return style, formatted_value
def _get_matching_rules(
self,
rules: list[FormatRule],
cell_value: Any,
row_data: dict = None
) -> list[FormatRule]:
"""
Get all rules that match the current cell.
def apply_format(
self,
rules: list[FormatRule],
cell_value: Any,
row_data: dict = None
) -> tuple[str | None, str | None]:
"""
Apply format rules to a cell value.
A rule matches if:
- It has no condition (unconditional)
- Its condition evaluates to True
"""
matching = []
for rule in rules:
if rule.condition is None:
# Unconditional rule always matches
matching.append(rule)
elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
# Conditional rule matches
matching.append(rule)
return matching
def _resolve_style(self, matching_rules: list[FormatRule]):
"""
Resolve style conflicts when multiple rules match.
Args:
rules: List of FormatRule to evaluate
cell_value: The cell value to format
row_data: Dict of {col_id: value} for column references
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
Returns:
Tuple of (css_string, formatted_value):
- css_string: CSS inline style string, or None if no style
- formatted_value: Formatted string, or None if no formatter
"""
if not rules:
return None, None
Args:
matching_rules: List of rules that matched
# Find all matching rules
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
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.
if not matching_rules:
return None, None
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
# 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)
Args:
matching_rules: List of rules that matched
# Apply style
css_string = None
if winning_style:
css_string = self._style_resolver.to_css_string(winning_style)
if css_string == "":
css_string = None
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.
# Apply formatter
formatted_value = None
if winning_formatter:
formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value)
DEPRECATED: This method is kept for backward compatibility but is no longer used.
Use _resolve_style() and _resolve_formatter() instead.
return css_string, formatted_value
Resolution logic:
1. Specificity = 1 if rule has condition, 0 otherwise
2. Higher specificity wins
3. At equal specificity, last rule wins entirely (no fusion)
def _get_matching_rules(
self,
rules: list[FormatRule],
cell_value: Any,
row_data: dict = None
) -> list[FormatRule]:
"""
Get all rules that match the current cell.
Args:
matching_rules: List of rules that matched
A rule matches if:
- It has no condition (unconditional)
- Its condition evaluates to True
"""
matching = []
for rule in rules:
if rule.condition is None:
# Unconditional rule always matches
matching.append(rule)
elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
# Conditional rule matches
matching.append(rule)
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
3. At equal specificity, last rule wins entirely (no fusion)
Args:
matching_rules: List of rules that matched
Returns:
The winning FormatRule, or None if no rules
"""
if not matching_rules:
return None
if len(matching_rules) == 1:
return matching_rules[0]
# Calculate specificity for each rule
# Specificity = 1 if has condition, 0 otherwise
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 matching_rules)
# Filter to rules with max specificity
top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity]
# Last rule wins among equal specificity
return top_rules[-1]
Returns:
The winning FormatRule, or None if no rules
"""
if not matching_rules:
return None
if len(matching_rules) == 1:
return matching_rules[0]
# Calculate specificity for each rule
# Specificity = 1 if has condition, 0 otherwise
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 matching_rules)
# Filter to rules with max specificity
top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity]
# Last rule wins among equal specificity
return top_rules[-1]

View File

@@ -3,40 +3,31 @@
DEFAULT_STYLE_PRESETS = {
"primary": {
"background-color": "var(--color-primary)",
"color": "var(--color-primary-content)",
"__class__": "mf-formatting-primary",
},
"secondary": {
"background-color": "var(--color-secondary)",
"color": "var(--color-secondary-content)",
"__class__": "mf-formatting-secondary",
},
"accent": {
"background-color": "var(--color-accent)",
"color": "var(--color-accent-content)",
"__class__": "mf-formatting-accent",
},
"neutral": {
"background-color": "var(--color-neutral)",
"color": "var(--color-neutral-content)",
"__class__": "mf-formatting-neutral",
},
"info": {
"background-color": "var(--color-info)",
"color": "var(--color-info-content)",
"__class__": "mf-formatting-info",
},
"success": {
"background-color": "var(--color-success)",
"color": "var(--color-success-content)",
"__class__": "mf-formatting-success",
},
"warning": {
"background-color": "var(--color-warning)",
"color": "var(--color-warning-content)",
"__class__": "mf-formatting-warning",
},
"error": {
"background-color": "var(--color-error)",
"color": "var(--color-error-content)",
"__class__": "mf-formatting-error",
},
}
# === Formatter Presets ===
DEFAULT_FORMATTER_PRESETS = {

View File

@@ -1,7 +1,8 @@
from dataclasses import dataclass
from myfasthtml.core.formatting.dataclasses import Style
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS
# Mapping from Python attribute names to CSS property names
PROPERTY_NAME_MAP = {
"background_color": "background-color",
@@ -13,63 +14,90 @@ PROPERTY_NAME_MAP = {
}
@dataclass
class StyleContainer:
cls: str | None = None
css: str = None
class StyleResolver:
"""Resolves styles by applying presets and explicit properties."""
"""Resolves styles by applying presets and explicit properties."""
def __init__(self, style_presets: dict = None):
"""
Initialize the StyleResolver.
def __init__(self, style_presets: dict = None):
"""
Initialize the StyleResolver.
Args:
style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS.
"""
self.style_presets = style_presets or DEFAULT_STYLE_PRESETS
def resolve(self, style: Style) -> dict:
"""
Resolve a Style to CSS properties dict.
Args:
style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS.
"""
self.style_presets = style_presets or DEFAULT_STYLE_PRESETS
Logic:
1. If preset is defined, load preset properties
2. Override with explicit properties (non-None values)
3. Convert Python names to CSS names
def resolve(self, style: Style) -> dict:
"""
Resolve a Style to CSS properties dict.
Args:
style: The Style object to resolve
Logic:
1. If preset is defined, load preset properties
2. Override with explicit properties (non-None values)
3. Convert Python names to CSS names
Returns:
Dict of CSS properties, e.g. {"background-color": "red", "color": "white"}
"""
if style is None:
return {}
result = {}
# Apply preset first
if style.preset and style.preset in self.style_presets:
preset_props = self.style_presets[style.preset]
for css_name, value in preset_props.items():
result[css_name] = value
# Override with explicit properties
for py_name, css_name in PROPERTY_NAME_MAP.items():
value = getattr(style, py_name, None)
if value is not None:
result[css_name] = value
return result
def to_css_string(self, style: Style) -> str:
"""
Resolve a Style to a CSS inline string.
Args:
style: The Style object to resolve
Args:
style: The Style object to resolve
Returns:
Dict of CSS properties, e.g. {"background-color": "red", "color": "white"}
"""
if style is None:
return {}
Returns:
CSS string, e.g. "background-color: red; color: white;"
"""
props = self.resolve(style)
if not props:
return ""
props.pop("__class__", "")
return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
def to_style_container(self, style: Style) -> StyleContainer:
"""
Resolve a Style to a class that contains the class name and the CSS inline string.
result = {}
Args:
style: The Style object to resolve
# Apply preset first
if style.preset and style.preset in self.style_presets:
preset_props = self.style_presets[style.preset]
for css_name, value in preset_props.items():
result[css_name] = value
# Override with explicit properties
for py_name, css_name in PROPERTY_NAME_MAP.items():
value = getattr(style, py_name, None)
if value is not None:
result[css_name] = value
return result
def to_css_string(self, style: Style) -> str:
"""
Resolve a Style to a CSS inline string.
Args:
style: The Style object to resolve
Returns:
CSS string, e.g. "background-color: red; color: white;"
"""
props = self.resolve(style)
if not props:
return ""
return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
Returns:
CSS string, e.g. "background-color: red; color: white;" and the class name
"""
props = self.resolve(style)
if not props:
return StyleContainer(None, "")
cls = props.pop("__class__", None)
css = "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
return StyleContainer(cls, css)