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

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DataGridFormattingManager — Visual Mockup</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; }
/* ---- Manager layout ---- */
.manager-root {
display: grid;
grid-template-columns: 280px 1fr;
grid-template-rows: auto 1fr;
height: 520px;
border: 1px solid oklch(var(--b3));
border-radius: 0.75rem;
overflow: hidden;
}
/* ---- Menu bar (Menu control pattern) ---- */
.mf-menu {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.6rem;
background: oklch(var(--b2));
border-bottom: 1px solid oklch(var(--b3));
}
/* Icon button — mirrors mk.icon() output */
.mf-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.4rem;
cursor: pointer;
color: oklch(var(--bc) / 0.7);
transition: background 0.1s, color 0.1s;
position: relative;
}
.mf-icon-btn:hover { background: oklch(var(--b3)); color: oklch(var(--bc)); }
.mf-icon-btn.danger:hover { background: color-mix(in oklab, oklch(var(--er)) 15%, transparent); color: oklch(var(--er)); }
/* Tooltip — mirrors mk.icon(tooltip=...) */
.mf-icon-btn::after {
content: attr(data-tip);
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: oklch(var(--n));
color: oklch(var(--nc));
font-size: 0.7rem;
white-space: nowrap;
padding: 0.2rem 0.5rem;
border-radius: 0.3rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
}
.mf-icon-btn:hover::after { opacity: 1; }
.menu-separator { width: 1px; height: 1.2rem; background: oklch(var(--b3)); margin: 0 0.15rem; }
/* ---- Preset list ---- */
.preset-list {
overflow-y: auto;
border-right: 1px solid oklch(var(--b3));
background: oklch(var(--b1));
}
.preset-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.55rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid oklch(var(--b2));
transition: background 0.1s;
}
.preset-item:hover { background: oklch(var(--b2)); }
.preset-item.active {
background: color-mix(in oklab, oklch(var(--p)) 10%, transparent);
border-left: 3px solid oklch(var(--p));
padding-left: calc(0.75rem - 3px);
}
.preset-name { font-size: 0.875rem; font-weight: 600; }
.preset-desc { font-size: 0.7rem; color: oklch(var(--bc) / 0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preset-badges { display: flex; gap: 0.25rem; margin-top: 0.2rem; }
/* ---- Editor panel ---- */
.editor-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: oklch(var(--b1));
}
.editor-meta {
padding: 0.6rem 1rem 0.5rem;
border-bottom: 1px solid oklch(var(--b3));
display: flex;
align-items: center;
gap: 0.75rem;
}
.dsl-area {
flex: 1;
padding: 0.75rem 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.dsl-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: oklch(var(--bc) / 0.4);
}
.dsl-editor {
flex: 1;
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
padding: 0.75rem;
border: 1px solid oklch(var(--b3));
border-radius: 0.5rem;
background: oklch(var(--b2));
color: oklch(var(--bc));
outline: none;
transition: border-color 0.15s;
}
.dsl-editor:focus { border-color: oklch(var(--p)); }
/* ---- Code block (Python def) ---- */
.token-keyword { color: oklch(var(--p)); font-weight: 700; }
.token-builtin { color: oklch(var(--s)); font-weight: 600; }
.token-string { color: oklch(var(--su)); }
.token-number { color: oklch(var(--a)); }
.token-comment { color: oklch(var(--bc) / 0.4); font-style: italic; }
</style>
</head>
<body class="bg-base-200 p-6">
<!-- Page header -->
<div class="mb-4">
<h1 class="text-xl font-bold">DataGridFormattingManager</h1>
<p class="text-sm text-base-content/50 mt-0.5">
Named rule presets combining formatters, styles and conditions.
Reference them with <code class="bg-base-300 px-1 rounded text-xs">format("preset_name")</code>
or <code class="bg-base-300 px-1 rounded text-xs">style("preset_name")</code>.
</p>
</div>
<!-- Manager control -->
<div class="manager-root shadow-md">
<!-- Menu bar — Menu(conf=MenuConf(fixed_items=["New","Save","Rename","Delete"]), save_state=False) -->
<div class="mf-menu">
<!-- fixed_items rendered as mk.icon(command, tooltip=command.description) -->
<button class="mf-icon-btn" data-tip="New preset" onclick="newPreset()">
<!-- Fluent: Add -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="mf-icon-btn" data-tip="Save preset" onclick="savePreset()">
<!-- Fluent: Save -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</button>
<button class="mf-icon-btn" data-tip="Rename preset" onclick="renamePreset()">
<!-- Fluent: Rename -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<div class="menu-separator"></div>
<button class="mf-icon-btn danger" data-tip="Delete preset" onclick="deletePreset()">
<!-- Fluent: Delete -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
<!-- Left: preset list -->
<div class="preset-list" id="preset-list"></div>
<!-- Right: editor -->
<div class="editor-panel">
<div class="editor-meta">
<div class="flex-1">
<div class="text-sm font-semibold" id="editor-title"></div>
<div class="text-xs text-base-content/45 mt-0.5" id="editor-desc">Select a preset to edit</div>
</div>
<div class="flex gap-1" id="editor-badges"></div>
</div>
<div class="dsl-area">
<div class="dsl-label">Rules — DSL</div>
<textarea class="dsl-editor" id="dsl-editor" spellcheck="false"
placeholder="No preset selected."></textarea>
</div>
</div>
</div>
<!-- DEFAULT_RULE_PRESETS example -->
<div class="mt-6 rounded-xl border border-base-300 bg-base-100 overflow-hidden shadow">
<div class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs font-bold uppercase tracking-wide text-base-content/50">
DEFAULT_RULE_PRESETS — core/formatting/presets.py
</div>
<pre class="p-4 text-xs font-mono overflow-x-auto leading-relaxed text-base-content"><span class="token-comment"># name + description + list of complete FormatRule descriptors.
# Appears in format() suggestions if at least one rule has a formatter.
# Appears in style() suggestions if at least one rule has a style.</span>
DEFAULT_RULE_PRESETS = {
<span class="token-string">"accounting"</span>: {
<span class="token-string">"description"</span>: <span class="token-string">"Negatives in parentheses (red), positives plain"</span>,
<span class="token-string">"rules"</span>: [
{
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&lt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
<span class="token-string">"prefix"</span>: <span class="token-string">"("</span>, <span class="token-string">"suffix"</span>: <span class="token-string">")"</span>,
<span class="token-string">"absolute"</span>: <span class="token-keyword">True</span>, <span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
},
{
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&gt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
<span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
},
],
},
<span class="token-string">"traffic_light"</span>: {
<span class="token-string">"description"</span>: <span class="token-string">"Red / yellow / green style based on sign"</span>,
<span class="token-string">"rules"</span>: [
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&lt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>}},
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"=="</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>}},
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&gt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"success"</span>}},
],
},
<span class="token-string">"budget_variance"</span>: {
<span class="token-string">"description"</span>: <span class="token-string">"% variance: negative=error, over 10%=warning, else plain"</span>,
<span class="token-string">"rules"</span>: [
{
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&lt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
},
{
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"&gt;"</span>, <span class="token-string">"value"</span>: <span class="token-number">0.1</span>},
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>},
},
{
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
},
],
},
}</pre>
</div>
<script>
const presets = [
{
id: "accounting", name: "accounting",
description: "Negatives in parentheses (red), positives plain",
hasFormatter: true, hasStyle: true,
dsl:
`format.number(precision=0, prefix="(", suffix=")", absolute=True, thousands_sep=" ") style("error") if value < 0
format.number(precision=0, thousands_sep=" ") if value > 0`,
},
{
id: "traffic_light", name: "traffic_light",
description: "Red / yellow / green style based on sign",
hasFormatter: false, hasStyle: true,
dsl:
`style("error") if value < 0
style("warning") if value == 0
style("success") if value > 0`,
},
{
id: "budget_variance", name: "budget_variance",
description: "% variance: negative=error, over 10%=warning, else plain",
hasFormatter: true, hasStyle: true,
dsl:
`format.number(precision=1, suffix="%", multiplier=100) style("error") if value < 0
format.number(precision=1, suffix="%", multiplier=100) style("warning") if value > 0.1
format.number(precision=1, suffix="%", multiplier=100)`,
},
];
let activeId = null;
function renderList() {
const list = document.getElementById("preset-list");
list.innerHTML = "";
presets.forEach(p => {
const item = document.createElement("div");
item.className = "preset-item" + (p.id === activeId ? " active" : "");
item.onclick = () => selectPreset(p.id);
const badges = [];
if (p.hasFormatter) badges.push(`<span class="badge badge-xs badge-secondary">Format</span>`);
if (p.hasStyle) badges.push(`<span class="badge badge-xs badge-primary">Style</span>`);
item.innerHTML = `
<div class="preset-name">${p.name}</div>
<div class="preset-desc">${p.description}</div>
<div class="preset-badges">${badges.join("")}</div>
`;
list.appendChild(item);
});
}
function selectPreset(id) {
activeId = id;
const p = presets.find(x => x.id === id);
document.getElementById("editor-title").textContent = p.name;
document.getElementById("editor-desc").textContent = p.description;
document.getElementById("dsl-editor").value = p.dsl;
const badges = [];
if (p.hasFormatter) badges.push(`<span class="badge badge-secondary">format()</span>`);
if (p.hasStyle) badges.push(`<span class="badge badge-primary">style()</span>`);
document.getElementById("editor-badges").innerHTML = badges.join("");
renderList();
}
function savePreset() {
if (!activeId) return;
const p = presets.find(x => x.id === activeId);
p.dsl = document.getElementById("dsl-editor").value;
p.hasFormatter = p.dsl.includes("format");
p.hasStyle = p.dsl.includes("style");
renderList();
showToast("Preset saved.");
}
function newPreset() {
const name = prompt("Preset name:");
if (!name?.trim()) return;
const id = name.trim().toLowerCase().replace(/\s+/g, "_");
if (presets.find(x => x.id === id)) { alert("Name already exists."); return; }
presets.push({ id, name: id, description: "New rule preset", hasFormatter: false, hasStyle: false, dsl: "" });
renderList();
selectPreset(id);
}
function renamePreset() {
if (!activeId) return;
const p = presets.find(x => x.id === activeId);
const name = prompt("New name:", p.name);
if (!name?.trim()) return;
p.name = name.trim();
p.id = p.name.toLowerCase().replace(/\s+/g, "_");
activeId = p.id;
renderList();
document.getElementById("editor-title").textContent = p.name;
}
function deletePreset() {
if (!activeId) return;
if (!confirm(`Delete "${activeId}"?`)) return;
presets.splice(presets.findIndex(x => x.id === activeId), 1);
activeId = null;
document.getElementById("editor-title").textContent = "—";
document.getElementById("editor-desc").textContent = "Select a preset to edit";
document.getElementById("dsl-editor").value = "";
document.getElementById("editor-badges").innerHTML = "";
renderList();
}
function showToast(msg) {
const t = document.createElement("div");
t.className = "toast toast-end toast-bottom z-50";
t.innerHTML = `<div class="alert alert-success text-sm py-2 px-4">${msg}</div>`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
renderList();
selectPreset("accounting");
</script>
</body>
</html>

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 = {

View File

@@ -0,0 +1,167 @@
"""Tests for DEFAULT_RULE_PRESETS expansion in FormattingEngine."""
import pytest
from myfasthtml.core.formatting.dataclasses import FormatRule, NumberFormatter, RulePreset
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.formatting.style_resolver import StyleContainer
# ============================================================
# Helpers
# ============================================================
def make_preset_rule(preset_name: str) -> list[FormatRule]:
"""Simulate what format("preset_name") produces from the DSL transformer."""
return [FormatRule(formatter=NumberFormatter(preset=preset_name))]
# ============================================================
# accounting preset
# ============================================================
def test_i_can_use_accounting_preset_with_negative_value():
"""Negative value → parentheses format, no style."""
engine = FormattingEngine()
rules = make_preset_rule("accounting")
css, formatted = engine.apply_format(rules, cell_value=-12500)
assert formatted == "(12 500)"
assert css is None
def test_i_can_use_accounting_preset_with_positive_value():
"""Positive value → plain number format, no style."""
engine = FormattingEngine()
rules = make_preset_rule("accounting")
css, formatted = engine.apply_format(rules, cell_value=8340)
assert formatted == "8 340"
assert css is None
def test_i_can_use_accounting_preset_with_zero():
"""Zero does not match < 0 or > 0 → no formatter, no style."""
engine = FormattingEngine()
rules = make_preset_rule("accounting")
css, formatted = engine.apply_format(rules, cell_value=0)
assert formatted is None
assert css is None
# ============================================================
# traffic_light preset
# ============================================================
def test_i_can_use_traffic_light_preset_with_negative_value():
"""Negative value → error style (red)."""
engine = FormattingEngine()
rules = make_preset_rule("traffic_light")
css, formatted = engine.apply_format(rules, cell_value=-1)
assert css is not None
assert css.cls == "mf-formatting-error"
assert formatted is None
def test_i_can_use_traffic_light_preset_with_zero():
"""Zero → warning style (yellow)."""
engine = FormattingEngine()
rules = make_preset_rule("traffic_light")
css, formatted = engine.apply_format(rules, cell_value=0)
assert css is not None
assert css.cls == "mf-formatting-warning"
def test_i_can_use_traffic_light_preset_with_positive_value():
"""Positive value → success style (green)."""
engine = FormattingEngine()
rules = make_preset_rule("traffic_light")
css, formatted = engine.apply_format(rules, cell_value=42)
assert css is not None
assert css.cls == "mf-formatting-success"
# ============================================================
# budget_variance preset
# ============================================================
def test_i_can_use_budget_variance_preset_with_negative_value():
"""Negative variance → percentage format + error style."""
engine = FormattingEngine()
rules = make_preset_rule("budget_variance")
css, formatted = engine.apply_format(rules, cell_value=-0.08)
assert formatted == "-8.0%"
assert css is not None
assert css.cls == "mf-formatting-error"
def test_i_can_use_budget_variance_preset_over_threshold():
"""Variance > 10% → percentage format + warning style."""
engine = FormattingEngine()
rules = make_preset_rule("budget_variance")
css, formatted = engine.apply_format(rules, cell_value=0.15)
assert formatted == "15.0%"
assert css is not None
assert css.cls == "mf-formatting-warning"
def test_i_can_use_budget_variance_preset_within_range():
"""Variance within 010% → percentage format, no special style."""
engine = FormattingEngine()
rules = make_preset_rule("budget_variance")
css, formatted = engine.apply_format(rules, cell_value=0.05)
assert formatted == "5.0%"
assert css is None
# ============================================================
# Unknown preset → no expansion, engine falls back gracefully
# ============================================================
def test_i_cannot_use_unknown_rule_preset():
"""Unknown preset name is not expanded — treated as plain formatter preset (no crash)."""
engine = FormattingEngine()
rules = make_preset_rule("nonexistent_preset")
# Should not raise; returns defaults (no special formatting)
css, formatted = engine.apply_format(rules, cell_value=42)
assert css is None
# ============================================================
# Custom rule_presets override
# ============================================================
def test_i_can_pass_custom_rule_presets():
"""Engine accepts custom rule_presets dict of RulePreset, overriding defaults."""
custom = {
"my_preset": RulePreset(
name="my_preset",
description="Custom test preset",
rules=[
FormatRule(formatter=NumberFormatter(precision=2, suffix=" pts")),
],
)
}
engine = FormattingEngine(rule_presets=custom)
rules = make_preset_rule("my_preset")
_, formatted = engine.apply_format(rules, cell_value=9.5)
assert formatted == "9.50 pts"