Added classes to support formatting
This commit is contained in:
1
src/myfasthtml/core/formatting/__init__.py
Normal file
1
src/myfasthtml/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Formatting module for DataGrid
|
||||
191
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
191
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from typing import Any
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import Condition
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""Evaluates conditions against cell values."""
|
||||
|
||||
def evaluate(self, condition: Condition, cell_value: Any, row_data: dict = None) -> bool:
|
||||
"""
|
||||
Evaluate a condition against a cell value.
|
||||
|
||||
Args:
|
||||
condition: The condition to evaluate
|
||||
cell_value: The value of the current cell
|
||||
row_data: Dict of {col_id: value} for column references (optional)
|
||||
|
||||
Returns:
|
||||
True if condition is met, False otherwise
|
||||
"""
|
||||
# If col parameter is set, use that column's value instead of cell_value
|
||||
if condition.col is not None:
|
||||
if row_data is None or condition.col not in row_data:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
cell_value = row_data[condition.col]
|
||||
|
||||
# Handle isempty/isnotempty first (they work with None)
|
||||
if condition.operator == "isempty":
|
||||
result = self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
if condition.operator == "isnotempty":
|
||||
result = not self._is_empty(cell_value)
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
# For all other operators, None cell_value returns False
|
||||
if cell_value is None:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Resolve the comparison value (might be a column reference)
|
||||
compare_value = self._resolve_value(condition.value, row_data)
|
||||
|
||||
# If reference resolved to None, return False
|
||||
if compare_value is None and isinstance(condition.value, dict):
|
||||
return self._apply_negate(False, condition.negate)
|
||||
|
||||
# Evaluate based on operator
|
||||
result = self._evaluate_operator(
|
||||
condition.operator,
|
||||
cell_value,
|
||||
compare_value,
|
||||
condition.case_sensitive
|
||||
)
|
||||
|
||||
return self._apply_negate(result, condition.negate)
|
||||
|
||||
def _resolve_value(self, value: Any, row_data: dict) -> Any:
|
||||
"""Resolve a value, handling column references."""
|
||||
if isinstance(value, dict) and "col" in value:
|
||||
if row_data is None:
|
||||
return None
|
||||
col_id = value["col"]
|
||||
return row_data.get(col_id)
|
||||
return value
|
||||
|
||||
def _is_empty(self, value: Any) -> bool:
|
||||
"""Check if a value is empty (None or empty string)."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and value == "":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _apply_negate(self, result: bool, negate: bool) -> bool:
|
||||
"""Apply negation if needed."""
|
||||
return not result if negate else result
|
||||
|
||||
def _evaluate_operator(
|
||||
self,
|
||||
operator: str,
|
||||
cell_value: Any,
|
||||
compare_value: Any,
|
||||
case_sensitive: bool
|
||||
) -> bool:
|
||||
"""Evaluate a specific operator."""
|
||||
try:
|
||||
if operator == "==":
|
||||
return self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "!=":
|
||||
return not self._equals(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "<":
|
||||
return self._less_than(cell_value, compare_value)
|
||||
elif operator == "<=":
|
||||
return self._less_than_or_equal(cell_value, compare_value)
|
||||
elif operator == ">":
|
||||
return self._greater_than(cell_value, compare_value)
|
||||
elif operator == ">=":
|
||||
return self._greater_than_or_equal(cell_value, compare_value)
|
||||
elif operator == "contains":
|
||||
return self._contains(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "startswith":
|
||||
return self._startswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "endswith":
|
||||
return self._endswith(cell_value, compare_value, case_sensitive)
|
||||
elif operator == "in":
|
||||
return self._in_list(cell_value, compare_value)
|
||||
elif operator == "between":
|
||||
return self._between(cell_value, compare_value)
|
||||
else:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
# Type mismatch or invalid operation
|
||||
return False
|
||||
|
||||
def _equals(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check equality, with optional case-insensitive string comparison."""
|
||||
if isinstance(cell_value, str) and isinstance(compare_value, str):
|
||||
if case_sensitive:
|
||||
return cell_value == compare_value
|
||||
return cell_value.lower() == compare_value.lower()
|
||||
return cell_value == compare_value
|
||||
|
||||
def _less_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value < compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
# Allow int/float comparison
|
||||
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||
return cell_value < compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value < compare_value
|
||||
|
||||
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value <= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||
return cell_value <= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value <= compare_value
|
||||
|
||||
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value > compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||
return cell_value > compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value > compare_value
|
||||
|
||||
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||
"""Check if cell_value >= compare_value."""
|
||||
if type(cell_value) != type(compare_value):
|
||||
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||
return cell_value >= compare_value
|
||||
raise TypeError("Type mismatch")
|
||||
return cell_value >= compare_value
|
||||
|
||||
def _contains(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value contains compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return compare_str in cell_str
|
||||
return compare_str.lower() in cell_str.lower()
|
||||
|
||||
def _startswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value starts with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.startswith(compare_str)
|
||||
return cell_str.lower().startswith(compare_str.lower())
|
||||
|
||||
def _endswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||
"""Check if cell_value ends with compare_value (string)."""
|
||||
cell_str = str(cell_value)
|
||||
compare_str = str(compare_value)
|
||||
if case_sensitive:
|
||||
return cell_str.endswith(compare_str)
|
||||
return cell_str.lower().endswith(compare_str.lower())
|
||||
|
||||
def _in_list(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is in the list."""
|
||||
if not isinstance(compare_value, list):
|
||||
raise TypeError("'in' operator requires a list")
|
||||
return cell_value in compare_value
|
||||
|
||||
def _between(self, cell_value: Any, compare_value: list) -> bool:
|
||||
"""Check if cell_value is between min and max (inclusive)."""
|
||||
if not isinstance(compare_value, list) or len(compare_value) != 2:
|
||||
raise TypeError("'between' operator requires a list of [min, max]")
|
||||
min_val, max_val = compare_value
|
||||
return min_val <= cell_value <= max_val
|
||||
165
src/myfasthtml/core/formatting/dataclasses.py
Normal file
165
src/myfasthtml/core/formatting/dataclasses.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
# === Condition ===
|
||||
|
||||
@dataclass
|
||||
class Condition:
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# === Style ===
|
||||
|
||||
@dataclass
|
||||
class Style:
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
# === Formatters ===
|
||||
|
||||
@dataclass
|
||||
class Formatter:
|
||||
"""Base class for all formatters."""
|
||||
preset: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberFormatter(Formatter):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateFormatter(Formatter):
|
||||
"""
|
||||
Formatter for dates and datetimes.
|
||||
|
||||
Attributes:
|
||||
format: strftime format pattern (default: "%Y-%m-%d")
|
||||
"""
|
||||
format: str = "%Y-%m-%d"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BooleanFormatter(Formatter):
|
||||
"""
|
||||
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 = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextFormatter(Formatter):
|
||||
"""
|
||||
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 = "..."
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnumFormatter(Formatter):
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
# === Format Rule ===
|
||||
|
||||
@dataclass
|
||||
class FormatRule:
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
152
src/myfasthtml/core/formatting/engine.py
Normal file
152
src/myfasthtml/core/formatting/engine.py
Normal file
@@ -0,0 +1,152 @@
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
"""
|
||||
Initialize the FormattingEngine.
|
||||
|
||||
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[str | None, str | None]:
|
||||
"""
|
||||
Apply format rules to a cell value.
|
||||
|
||||
Args:
|
||||
rules: List of FormatRule to evaluate
|
||||
cell_value: The cell value to format
|
||||
row_data: Dict of {col_id: value} for column references
|
||||
|
||||
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 conflicts to get the winning rule
|
||||
winning_rule = self._resolve_conflicts(matching_rules)
|
||||
|
||||
if winning_rule is None:
|
||||
return None, None
|
||||
|
||||
# Apply style
|
||||
css_string = None
|
||||
if winning_rule.style:
|
||||
css_string = self._style_resolver.to_css_string(winning_rule.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)
|
||||
|
||||
return css_string, 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.
|
||||
|
||||
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_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
|
||||
"""
|
||||
Resolve conflicts when multiple rules match.
|
||||
|
||||
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]
|
||||
339
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
339
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
@@ -0,0 +1,339 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import (
|
||||
Formatter,
|
||||
NumberFormatter,
|
||||
DateFormatter,
|
||||
BooleanFormatter,
|
||||
TextFormatter,
|
||||
EnumFormatter,
|
||||
)
|
||||
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS
|
||||
|
||||
|
||||
# Error indicator when formatting fails
|
||||
FORMAT_ERROR = "\u26a0" # ⚠
|
||||
|
||||
|
||||
class BaseFormatterResolver(ABC):
|
||||
"""Base class for all formatter resolvers."""
|
||||
|
||||
@abstractmethod
|
||||
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||
"""Format a value using the given formatter."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter:
|
||||
"""Apply preset values to create a fully configured formatter."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NumberFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for NumberFormatter."""
|
||||
|
||||
def resolve(self, formatter: NumberFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
# Convert to float and apply multiplier
|
||||
num = float(value) * formatter.multiplier
|
||||
|
||||
# Round to precision
|
||||
if formatter.precision > 0:
|
||||
num = round(num, formatter.precision)
|
||||
else:
|
||||
num = int(round(num))
|
||||
|
||||
# Format with decimal separator
|
||||
if formatter.precision > 0:
|
||||
int_part = int(num)
|
||||
dec_part = abs(num - int_part)
|
||||
dec_str = f"{dec_part:.{formatter.precision}f}"[2:] # Remove "0."
|
||||
else:
|
||||
int_part = int(num)
|
||||
dec_str = ""
|
||||
|
||||
# Format integer part with thousands separator
|
||||
int_str = self._format_with_thousands(abs(int_part), formatter.thousands_sep)
|
||||
|
||||
# Handle negative
|
||||
if num < 0:
|
||||
int_str = "-" + int_str
|
||||
|
||||
# Combine parts
|
||||
if dec_str:
|
||||
result = f"{int_str}{formatter.decimal_sep}{dec_str}"
|
||||
else:
|
||||
result = int_str
|
||||
|
||||
return f"{formatter.prefix}{result}{formatter.suffix}"
|
||||
|
||||
def _format_with_thousands(self, num: int, sep: str) -> str:
|
||||
"""Format an integer with thousands separator."""
|
||||
if not sep:
|
||||
return str(num)
|
||||
|
||||
result = ""
|
||||
s = str(num)
|
||||
for i, digit in enumerate(reversed(s)):
|
||||
if i > 0 and i % 3 == 0:
|
||||
result = sep + result
|
||||
result = digit + result
|
||||
return result
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> NumberFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, NumberFormatter):
|
||||
return NumberFormatter(
|
||||
preset=formatter.preset,
|
||||
prefix=formatter.prefix if formatter.prefix != "" else preset.get("prefix", ""),
|
||||
suffix=formatter.suffix if formatter.suffix != "" else preset.get("suffix", ""),
|
||||
thousands_sep=formatter.thousands_sep if formatter.thousands_sep != "" else preset.get("thousands_sep", ""),
|
||||
decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."),
|
||||
precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0),
|
||||
multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0),
|
||||
)
|
||||
else:
|
||||
return NumberFormatter(
|
||||
preset=formatter.preset,
|
||||
prefix=preset.get("prefix", ""),
|
||||
suffix=preset.get("suffix", ""),
|
||||
thousands_sep=preset.get("thousands_sep", ""),
|
||||
decimal_sep=preset.get("decimal_sep", "."),
|
||||
precision=preset.get("precision", 0),
|
||||
multiplier=preset.get("multiplier", 1.0),
|
||||
)
|
||||
|
||||
|
||||
class DateFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for DateFormatter."""
|
||||
|
||||
def resolve(self, formatter: DateFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime(formatter.format)
|
||||
elif isinstance(value, str):
|
||||
dt = datetime.fromisoformat(value)
|
||||
return dt.strftime(formatter.format)
|
||||
else:
|
||||
raise TypeError(f"Cannot format {type(value)} as date")
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> DateFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, DateFormatter):
|
||||
return DateFormatter(
|
||||
preset=formatter.preset,
|
||||
format=formatter.format if formatter.format != "%Y-%m-%d" else preset.get("format", "%Y-%m-%d"),
|
||||
)
|
||||
else:
|
||||
return DateFormatter(
|
||||
preset=formatter.preset,
|
||||
format=preset.get("format", "%Y-%m-%d"),
|
||||
)
|
||||
|
||||
|
||||
class BooleanFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for BooleanFormatter."""
|
||||
|
||||
def resolve(self, formatter: BooleanFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return formatter.null_value
|
||||
if value is True or value == 1:
|
||||
return formatter.true_value
|
||||
if value is False or value == 0:
|
||||
return formatter.false_value
|
||||
return formatter.null_value
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> BooleanFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, BooleanFormatter):
|
||||
return BooleanFormatter(
|
||||
preset=formatter.preset,
|
||||
true_value=formatter.true_value if formatter.true_value != "true" else preset.get("true_value", "true"),
|
||||
false_value=formatter.false_value if formatter.false_value != "false" else preset.get("false_value", "false"),
|
||||
null_value=formatter.null_value if formatter.null_value != "" else preset.get("null_value", ""),
|
||||
)
|
||||
else:
|
||||
return BooleanFormatter(
|
||||
preset=formatter.preset,
|
||||
true_value=preset.get("true_value", "true"),
|
||||
false_value=preset.get("false_value", "false"),
|
||||
null_value=preset.get("null_value", ""),
|
||||
)
|
||||
|
||||
|
||||
class TextFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for TextFormatter."""
|
||||
|
||||
def resolve(self, formatter: TextFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
text = str(value)
|
||||
|
||||
# Apply transformation
|
||||
if formatter.transform == "uppercase":
|
||||
text = text.upper()
|
||||
elif formatter.transform == "lowercase":
|
||||
text = text.lower()
|
||||
elif formatter.transform == "capitalize":
|
||||
text = text.capitalize()
|
||||
|
||||
# Apply truncation
|
||||
if formatter.max_length and len(text) > formatter.max_length:
|
||||
text = text[:formatter.max_length] + formatter.ellipsis
|
||||
|
||||
return text
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> TextFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, TextFormatter):
|
||||
return TextFormatter(
|
||||
preset=formatter.preset,
|
||||
transform=formatter.transform or preset.get("transform"),
|
||||
max_length=formatter.max_length or preset.get("max_length"),
|
||||
ellipsis=formatter.ellipsis if formatter.ellipsis != "..." else preset.get("ellipsis", "..."),
|
||||
)
|
||||
else:
|
||||
return TextFormatter(
|
||||
preset=formatter.preset,
|
||||
transform=preset.get("transform"),
|
||||
max_length=preset.get("max_length"),
|
||||
ellipsis=preset.get("ellipsis", "..."),
|
||||
)
|
||||
|
||||
|
||||
class EnumFormatterResolver(BaseFormatterResolver):
|
||||
"""Resolver for EnumFormatter."""
|
||||
|
||||
def __init__(self, lookup_resolver: Callable[[str, str, str], dict] = None):
|
||||
self.lookup_resolver = lookup_resolver
|
||||
|
||||
def resolve(self, formatter: EnumFormatter, value: Any) -> str:
|
||||
if value is None:
|
||||
return formatter.default
|
||||
|
||||
source = formatter.source
|
||||
if not source:
|
||||
return str(value)
|
||||
|
||||
source_type = source.get("type")
|
||||
|
||||
if source_type == "mapping":
|
||||
mapping = source.get("value", {})
|
||||
return mapping.get(value, formatter.default)
|
||||
|
||||
elif source_type == "datagrid":
|
||||
if not self.lookup_resolver:
|
||||
return str(value)
|
||||
|
||||
grid_id = source.get("value")
|
||||
value_col = source.get("value_column")
|
||||
display_col = source.get("display_column")
|
||||
|
||||
mapping = self.lookup_resolver(grid_id, value_col, display_col)
|
||||
return mapping.get(value, formatter.default)
|
||||
|
||||
return str(value)
|
||||
|
||||
def apply_preset(self, formatter: Formatter, presets: dict) -> EnumFormatter:
|
||||
preset = presets.get(formatter.preset, {})
|
||||
|
||||
if isinstance(formatter, EnumFormatter):
|
||||
return formatter
|
||||
else:
|
||||
return EnumFormatter(
|
||||
preset=formatter.preset,
|
||||
source=preset.get("source", {}),
|
||||
default=preset.get("default", ""),
|
||||
)
|
||||
|
||||
|
||||
class FormatterResolver:
|
||||
"""
|
||||
Main resolver that dispatches to the appropriate formatter resolver.
|
||||
|
||||
This is a facade that delegates formatting to specialized resolvers
|
||||
based on the formatter type.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formatter_presets: dict = None,
|
||||
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||
):
|
||||
self.formatter_presets = formatter_presets or DEFAULT_FORMATTER_PRESETS
|
||||
self.lookup_resolver = lookup_resolver
|
||||
|
||||
# Registry of resolvers by formatter type
|
||||
self._resolvers: dict[type, BaseFormatterResolver] = {
|
||||
NumberFormatter: NumberFormatterResolver(),
|
||||
DateFormatter: DateFormatterResolver(),
|
||||
BooleanFormatter: BooleanFormatterResolver(),
|
||||
TextFormatter: TextFormatterResolver(),
|
||||
EnumFormatter: EnumFormatterResolver(lookup_resolver),
|
||||
}
|
||||
|
||||
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||
"""
|
||||
Apply formatter to a value.
|
||||
|
||||
Args:
|
||||
formatter: The Formatter to apply
|
||||
value: The value to format
|
||||
|
||||
Returns:
|
||||
Formatted string for display, or FORMAT_ERROR on failure
|
||||
"""
|
||||
if formatter is None:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
try:
|
||||
# Get the appropriate resolver
|
||||
resolver = self._get_resolver(formatter)
|
||||
if resolver is None:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
# Apply preset if specified
|
||||
formatter = resolver.apply_preset(formatter, self.formatter_presets)
|
||||
|
||||
# Resolve the value
|
||||
return resolver.resolve(formatter, value)
|
||||
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return FORMAT_ERROR
|
||||
|
||||
def _get_resolver(self, formatter: Formatter) -> BaseFormatterResolver | None:
|
||||
"""Get the appropriate resolver for a formatter."""
|
||||
# Direct type match
|
||||
formatter_type = type(formatter)
|
||||
if formatter_type in self._resolvers:
|
||||
return self._resolvers[formatter_type]
|
||||
|
||||
# Base Formatter with preset - determine type from preset
|
||||
if formatter_type == Formatter and formatter.preset:
|
||||
preset = self.formatter_presets.get(formatter.preset, {})
|
||||
preset_type = preset.get("type")
|
||||
|
||||
type_mapping = {
|
||||
"number": NumberFormatter,
|
||||
"date": DateFormatter,
|
||||
"boolean": BooleanFormatter,
|
||||
"text": TextFormatter,
|
||||
"enum": EnumFormatter,
|
||||
}
|
||||
|
||||
target_type = type_mapping.get(preset_type)
|
||||
if target_type:
|
||||
return self._resolvers.get(target_type)
|
||||
|
||||
return None
|
||||
76
src/myfasthtml/core/formatting/presets.py
Normal file
76
src/myfasthtml/core/formatting/presets.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# === Style Presets (DaisyUI 5) ===
|
||||
# Keys use CSS property names (with hyphens)
|
||||
|
||||
DEFAULT_STYLE_PRESETS = {
|
||||
"primary": {
|
||||
"background-color": "var(--color-primary)",
|
||||
"color": "var(--color-primary-content)",
|
||||
},
|
||||
"secondary": {
|
||||
"background-color": "var(--color-secondary)",
|
||||
"color": "var(--color-secondary-content)",
|
||||
},
|
||||
"accent": {
|
||||
"background-color": "var(--color-accent)",
|
||||
"color": "var(--color-accent-content)",
|
||||
},
|
||||
"neutral": {
|
||||
"background-color": "var(--color-neutral)",
|
||||
"color": "var(--color-neutral-content)",
|
||||
},
|
||||
"info": {
|
||||
"background-color": "var(--color-info)",
|
||||
"color": "var(--color-info-content)",
|
||||
},
|
||||
"success": {
|
||||
"background-color": "var(--color-success)",
|
||||
"color": "var(--color-success-content)",
|
||||
},
|
||||
"warning": {
|
||||
"background-color": "var(--color-warning)",
|
||||
"color": "var(--color-warning-content)",
|
||||
},
|
||||
"error": {
|
||||
"background-color": "var(--color-error)",
|
||||
"color": "var(--color-error-content)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# === Formatter Presets ===
|
||||
|
||||
DEFAULT_FORMATTER_PRESETS = {
|
||||
"EUR": {
|
||||
"type": "number",
|
||||
"suffix": " €",
|
||||
"thousands_sep": " ",
|
||||
"decimal_sep": ",",
|
||||
"precision": 2,
|
||||
},
|
||||
"USD": {
|
||||
"type": "number",
|
||||
"prefix": "$",
|
||||
"thousands_sep": ",",
|
||||
"decimal_sep": ".",
|
||||
"precision": 2,
|
||||
},
|
||||
"percentage": {
|
||||
"type": "number",
|
||||
"suffix": "%",
|
||||
"precision": 1,
|
||||
"multiplier": 100,
|
||||
},
|
||||
"short_date": {
|
||||
"type": "date",
|
||||
"format": "%d/%m/%Y",
|
||||
},
|
||||
"iso_date": {
|
||||
"type": "date",
|
||||
"format": "%Y-%m-%d",
|
||||
},
|
||||
"yes_no": {
|
||||
"type": "boolean",
|
||||
"true_value": "Yes",
|
||||
"false_value": "No",
|
||||
},
|
||||
}
|
||||
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
@@ -0,0 +1,75 @@
|
||||
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",
|
||||
"color": "color",
|
||||
"font_weight": "font-weight",
|
||||
"font_style": "font-style",
|
||||
"font_size": "font-size",
|
||||
"text_decoration": "text-decoration",
|
||||
}
|
||||
|
||||
|
||||
class StyleResolver:
|
||||
"""Resolves styles by applying presets and explicit properties."""
|
||||
|
||||
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.
|
||||
|
||||
Logic:
|
||||
1. If preset is defined, load preset properties
|
||||
2. Override with explicit properties (non-None values)
|
||||
3. Convert Python names to CSS names
|
||||
|
||||
Args:
|
||||
style: The Style object to resolve
|
||||
|
||||
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
|
||||
|
||||
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()) + ";"
|
||||
Reference in New Issue
Block a user