I can format columns

This commit is contained in:
2026-02-06 22:46:59 +01:00
parent 0620cb678b
commit db1e94f930
8 changed files with 141 additions and 31 deletions

View File

@@ -1403,6 +1403,8 @@ These warnings would be non-blocking (DSL still parses) but displayed with a dif
3. ~~**Translate lark grammar to Lezer**~~ Done (`lark_to_lezer.py`) 3. ~~**Translate lark grammar to Lezer**~~ Done (`lark_to_lezer.py`)
4. ~~**Build CodeMirror extension**~~ Done (`DslEditor.py` + `initDslEditor()`) 4. ~~**Build CodeMirror extension**~~ Done (`DslEditor.py` + `initDslEditor()`)
5. ~~**Integrate with DataGrid** - connect DSL output to formatting engine~~ Done 5. ~~**Integrate with DataGrid** - connect DSL output to formatting engine~~ Done
- `DataGridFormattingEditor.on_content_changed()` dispatches rules to column/row/cell formats
- `DataGrid.mk_body_cell_content()` applies formatting via `FormattingEngine`
6. ~~**Implement dynamic client-side completions**~~ Done 6. ~~**Implement dynamic client-side completions**~~ Done
- Engine ID: `DSLDefinition.get_id()` passed via `completionEngineId` in JS config - Engine ID: `DSLDefinition.get_id()` passed via `completionEngineId` in JS config
- Trigger: Hybrid (Ctrl+Space + auto after `.` `(` `"` and space) - Trigger: Hybrid (Ctrl+Space + auto after `.` `(` `"` and space)

View File

@@ -17,7 +17,8 @@
| `row` parameter (column-level conditions) | :x: Not implemented | | | `row` parameter (column-level conditions) | :x: Not implemented | |
| Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | | | Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | |
| **DataGrid Integration** | | | | **DataGrid Integration** | | |
| Integration in `mk_body_cell_content()` | :x: Not implemented | | | Integration in `mk_body_cell_content()` | :white_check_mark: Implemented | `DataGrid.py` |
| DataGridFormattingEditor | :white_check_mark: Implemented | `DataGridFormattingEditor.py` |
| DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` | | DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` |
| **Tests** | | `tests/core/formatting/` | | **Tests** | | `tests/core/formatting/` |
| test_condition_evaluator.py | :white_check_mark: ~45 test cases | | | test_condition_evaluator.py | :white_check_mark: ~45 test cases | |
@@ -537,4 +538,4 @@ All items below are :x: **not implemented**.
- **API source for enum**: `{"type": "api", "value": "https://...", ...}` - **API source for enum**: `{"type": "api", "value": "https://...", ...}`
- **Searchable enum**: For large option lists - **Searchable enum**: For large option lists
- **Formatter chaining**: Apply multiple formatters in sequence - **Formatter chaining**: Apply multiple formatters in sequence
- **DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()` - ~~**DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()`~~ Done

View File

@@ -1000,7 +1000,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
for (const [combinationStr, config] of Object.entries(combinations)) { for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr); const sequence = parseCombination(combinationStr);
console.log("Parsing mouse combination", combinationStr, "=>", sequence); //console.log("Parsing mouse combination", combinationStr, "=>", sequence);
let currentNode = root; let currentNode = root;
for (const actionSet of sequence) { for (const actionSet of sequence) {
@@ -1137,7 +1137,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
const clickStart = performance.now(); const clickStart = performance.now();
const elementCount = MouseRegistry.elements.size; const elementCount = MouseRegistry.elements.size;
console.warn(`🖱️ Click handler START: processing ${elementCount} registered elements`); //console.warn(`🖱️ Click handler START: processing ${elementCount} registered elements`);
// Create a snapshot of current mouse action with modifiers // Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'click'); const snapshot = createSnapshot(event, 'click');
@@ -2358,7 +2358,7 @@ function initDslEditor(config) {
setContent: (content) => editor.setValue(content) setContent: (content) => editor.setValue(content)
}; };
console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`); //console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`);
} }

View File

@@ -24,7 +24,6 @@ from myfasthtml.controls.helpers import mk, icons
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter
from myfasthtml.core.formatting.dsl.definition import FormattingDSL from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -181,7 +180,7 @@ class DataGrid(MultipleInstance):
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
# add Panel # add Panel
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="#panel") self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel")
self._panel.set_side_visible("right", False) # the right Panel always starts closed self._panel.set_side_visible("right", False) # the right Panel always starts closed
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right")) self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right")) self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
@@ -212,7 +211,7 @@ class DataGrid(MultipleInstance):
conf=editor_conf, conf=editor_conf,
dsl=FormattingDSL(), dsl=FormattingDSL(),
save_state=self._settings.save_state, save_state=self._settings.save_state,
_id="#formatting_editor") _id="-formatting_editor")
# other definitions # other definitions
self._mouse_support = { self._mouse_support = {
@@ -398,17 +397,6 @@ class DataGrid(MultipleInstance):
list[FormatRule] or None if no formatting defined list[FormatRule] or None if no formatting defined
""" """
# hack to test
if col_def.col_id == "age":
return [
FormatRule(style=Style(color="green")),
FormatRule(condition=Condition(operator=">", value=18), style=Style(color="red")),
]
return [
FormatRule(condition=Condition(operator="isnan"), formatter=ConstantFormatter(value="-")),
]
cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index)) cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index))
if cell_id in self._state.cell_formats: if cell_id in self._state.cell_formats:
@@ -816,7 +804,7 @@ class DataGrid(MultipleInstance):
cls="flex items-center justify-between mb-2"), cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table_wrapper()), self._panel.set_main(self.mk_table_wrapper()),
Script(f"initDataGrid('{self._id}');"), Script(f"initDataGrid('{self._id}');"),
Mouse(self, combinations=self._mouse_support), Mouse(self, combinations=self._mouse_support, _id="-mouse"),
id=self._id, id=self._id,
cls="grid", cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;" style="height: 100%; grid-template-rows: auto 1fr;"

View File

@@ -1,7 +1,112 @@
import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope
logger = logging.getLogger("DataGridFormattingEditor")
class DataGridFormattingEditor(DslEditor): class DataGridFormattingEditor(DslEditor):
def on_dsl_change(self, dsl): def _find_column_by_name(self, name: str):
pass """
Find a column by name, searching col_id first, then title.
Returns:
tuple (col_pos, col_def) if found, (None, None) otherwise
"""
# First pass: match by col_id
for col_pos, col_def in enumerate(self._parent.get_state().columns):
if col_def.col_id == name:
return col_pos, col_def
# Second pass: match by title
for col_pos, col_def in enumerate(self._parent.get_state().columns):
if col_def.title == name:
return col_pos, col_def
return None, None
def _get_cell_id(self, scope: CellScope):
"""
Get cell_id from CellScope.
If scope has cell_id, use it directly.
Otherwise, resolve coordinates (column, row) to cell_id.
Returns:
cell_id string or None if column not found
"""
if scope.cell_id:
return scope.cell_id
col_pos, _ = self._find_column_by_name(scope.column)
if col_pos is None:
logger.warning(f"Column '{scope.column}' not found for CellScope")
return None
return self._parent._get_element_id_from_pos("cell", (col_pos, scope.row))
def on_content_changed(self):
dsl = self.get_content()
# Step 1: Parse DSL
try:
scoped_rules = parse_dsl(dsl)
except DSLSyntaxError as e:
logger.debug(f"DSL syntax error, keeping old formatting: {e}")
return
# Step 2: Group rules by scope
columns_rules = defaultdict(list) # key = column name
rows_rules = defaultdict(list) # key = row index
cells_rules = defaultdict(list) # key = cell_id
for scoped_rule in scoped_rules:
scope = scoped_rule.scope
rule = scoped_rule.rule
if isinstance(scope, ColumnScope):
columns_rules[scope.column].append(rule)
elif isinstance(scope, RowScope):
rows_rules[scope.row].append(rule)
elif isinstance(scope, CellScope):
cell_id = self._get_cell_id(scope)
if cell_id:
cells_rules[cell_id].append(rule)
# Step 3: Copy state for atomic update
state = self._parent.get_state().copy()
# Step 4: Clear existing formats on the copy
for col in state.columns:
col.format = None
for row in state.rows:
row.format = None
state.cell_formats.clear()
# Step 5: Apply grouped rules on the copy
for column_name, rules in columns_rules.items():
col_pos, col_def = self._find_column_by_name(column_name)
if col_def:
# Find the column in the copied state
state.columns[col_pos].format = rules
else:
logger.warning(f"Column '{column_name}' not found, skipping rules")
for row_index, rules in rows_rules.items():
if row_index < len(state.rows):
state.rows[row_index].format = rules
else:
logger.warning(f"Row {row_index} out of range, skipping rules")
for cell_id, rules in cells_rules.items():
state.cell_formats[cell_id] = rules
# Step 6: Update state atomically
self._parent.get_state().update(state)
# Step 7: Refresh the DataGrid
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells")
return self._parent.render_partial("body")

View File

@@ -62,11 +62,11 @@ class Commands(BaseCommands):
self._owner, self._owner,
self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click") self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click")
def on_content_changed(self): def save_content(self):
return Command("OnContentChanged", return Command("SaveContent",
"On content changed", "Save content",
self._owner, self._owner,
self._owner.on_content_changed self._owner.save_content
).htmx(target=None) ).htmx(target=None)
@@ -112,15 +112,24 @@ class DslEditor(MultipleInstance):
"""Get the current editor content.""" """Get the current editor content."""
return self._state.content return self._state.content
def update_content(self, content: str = "") -> None: def update_content(self, content: str = ""):
"""Handler for content update from CodeMirror.""" """Handler for content update from CodeMirror."""
self._state.content = content self._state.content = content
if self._state.auto_save:
self.on_content_changed()
logger.debug(f"Content updated: {len(content)} chars") logger.debug(f"Content updated: {len(content)} chars")
if self._state.auto_save:
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
return None
def save_content(self):
logger.debug("save_content")
return None, self.on_content_changed() # on_content_changed must be second to benefit from oob swap
def toggle_auto_save(self): def toggle_auto_save(self):
logger.debug("toggle_auto_save")
self._state.auto_save = not self._state.auto_save self._state.auto_save = not self._state.auto_save
logger.debug(f" auto_save={self._state.auto_save}")
return self._mk_auto_save() return self._mk_auto_save()
def on_content_changed(self) -> None: def on_content_changed(self) -> None:
@@ -182,7 +191,7 @@ class DslEditor(MultipleInstance):
mk.button("Save", mk.button("Save",
cls="btn btn-xs btn-primary", cls="btn btn-xs btn-primary",
disabled="disabled" if self._state.auto_save else None, disabled="disabled" if self._state.auto_save else None,
command=self.commands.update_content()), command=self.commands.save_content()),
cls="flex justify-between items-center p-2", cls="flex justify-between items-center p-2",
id=f"as_{self._id}", id=f"as_{self._id}",
), ),

View File

@@ -21,7 +21,9 @@ class InstancesDebugger(SingleInstance):
return self._panel.set_main(vis_network) return self._panel.set_main(vis_network)
def on_network_event(self, event_data: dict): def on_network_event(self, event_data: dict):
session, instance_id = event_data["nodes"][0].split("#") parts = event_data["nodes"][0].split("#")
session = parts[0]
instance_id = "#".join(parts[1:])
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"}, properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
"State": {"_name": "_state._name", "*": "_state"}, "State": {"_name": "_state._name", "*": "_state"},
"Commands": {"*": "commands"}, "Commands": {"*": "commands"},

View File

@@ -1,3 +1,4 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, Optional from typing import Literal, Optional
@@ -12,6 +13,7 @@ from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import more_horizontal20_regular from myfasthtml.icons.fluent_p1 import more_horizontal20_regular
from myfasthtml.icons.fluent_p2 import subtract20_regular from myfasthtml.icons.fluent_p2 import subtract20_regular
logger = logging.getLogger("Panel")
class PanelIds: class PanelIds:
def __init__(self, owner): def __init__(self, owner):
@@ -116,6 +118,7 @@ class Panel(MultipleInstance):
return self._ids return self._ids
def update_side_width(self, side, width): def update_side_width(self, side, width):
logger.debug(f"update_side_width {side=} {width=}")
if side == "left": if side == "left":
self._state.left_width = width self._state.left_width = width
else: else: