diff --git a/docs/DataGrid Formatting DSL.md b/docs/DataGrid Formatting DSL.md index 7c82ef6..3139164 100644 --- a/docs/DataGrid Formatting DSL.md +++ b/docs/DataGrid Formatting DSL.md @@ -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`) 4. ~~**Build CodeMirror extension**~~ Done (`DslEditor.py` + `initDslEditor()`) 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 - Engine ID: `DSLDefinition.get_id()` passed via `completionEngineId` in JS config - Trigger: Hybrid (Ctrl+Space + auto after `.` `(` `"` and space) diff --git a/docs/DataGrid Formatting.md b/docs/DataGrid Formatting.md index 528c28f..ee6ea85 100644 --- a/docs/DataGrid Formatting.md +++ b/docs/DataGrid Formatting.md @@ -17,7 +17,8 @@ | `row` parameter (column-level conditions) | :x: Not implemented | | | Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | | | **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` | | **Tests** | | `tests/core/formatting/` | | 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://...", ...}` - **Searchable enum**: For large option lists - **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 diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 7eaa30a..6d1f543 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -1000,7 +1000,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { for (const [combinationStr, config] of Object.entries(combinations)) { const sequence = parseCombination(combinationStr); - console.log("Parsing mouse combination", combinationStr, "=>", sequence); + //console.log("Parsing mouse combination", combinationStr, "=>", sequence); let currentNode = root; for (const actionSet of sequence) { @@ -1137,7 +1137,7 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) { const clickStart = performance.now(); 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 const snapshot = createSnapshot(event, 'click'); @@ -2358,7 +2358,7 @@ function initDslEditor(config) { 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"}`); } diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index eb874fb..57e23dd 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -24,7 +24,6 @@ from myfasthtml.controls.helpers import mk, icons 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.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.engine import FormattingEngine 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 # 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.bind_command("ToggleColumnsManager", 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, dsl=FormattingDSL(), save_state=self._settings.save_state, - _id="#formatting_editor") + _id="-formatting_editor") # other definitions self._mouse_support = { @@ -398,17 +397,6 @@ class DataGrid(MultipleInstance): 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)) if cell_id in self._state.cell_formats: @@ -816,7 +804,7 @@ class DataGrid(MultipleInstance): cls="flex items-center justify-between mb-2"), self._panel.set_main(self.mk_table_wrapper()), Script(f"initDataGrid('{self._id}');"), - Mouse(self, combinations=self._mouse_support), + Mouse(self, combinations=self._mouse_support, _id="-mouse"), id=self._id, cls="grid", style="height: 100%; grid-template-rows: auto 1fr;" diff --git a/src/myfasthtml/controls/DataGridFormattingEditor.py b/src/myfasthtml/controls/DataGridFormattingEditor.py index 39edebf..5af094a 100644 --- a/src/myfasthtml/controls/DataGridFormattingEditor.py +++ b/src/myfasthtml/controls/DataGridFormattingEditor.py @@ -1,7 +1,112 @@ +import logging +from collections import defaultdict + 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): - def on_dsl_change(self, dsl): - pass + def _find_column_by_name(self, name: str): + """ + 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") diff --git a/src/myfasthtml/controls/DslEditor.py b/src/myfasthtml/controls/DslEditor.py index e72d4d9..82e8258 100644 --- a/src/myfasthtml/controls/DslEditor.py +++ b/src/myfasthtml/controls/DslEditor.py @@ -62,11 +62,11 @@ class Commands(BaseCommands): self._owner, self._owner.toggle_auto_save).htmx(target=f"#as_{self._id}", trigger="click") - def on_content_changed(self): - return Command("OnContentChanged", - "On content changed", + def save_content(self): + return Command("SaveContent", + "Save content", self._owner, - self._owner.on_content_changed + self._owner.save_content ).htmx(target=None) @@ -112,15 +112,24 @@ class DslEditor(MultipleInstance): """Get the current editor content.""" return self._state.content - def update_content(self, content: str = "") -> None: + def update_content(self, content: str = ""): """Handler for content update from CodeMirror.""" self._state.content = content - if self._state.auto_save: - self.on_content_changed() 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): + logger.debug("toggle_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() def on_content_changed(self) -> None: @@ -182,7 +191,7 @@ class DslEditor(MultipleInstance): mk.button("Save", cls="btn btn-xs btn-primary", 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", id=f"as_{self._id}", ), diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index f385bf0..6d2e5a1 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -21,7 +21,9 @@ class InstancesDebugger(SingleInstance): return self._panel.set_main(vis_network) 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"}, "State": {"_name": "_state._name", "*": "_state"}, "Commands": {"*": "commands"}, diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index add738c..15566df 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -1,3 +1,4 @@ +import logging from dataclasses import dataclass 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_p2 import subtract20_regular +logger = logging.getLogger("Panel") class PanelIds: def __init__(self, owner): @@ -116,6 +118,7 @@ class Panel(MultipleInstance): return self._ids def update_side_width(self, side, width): + logger.debug(f"update_side_width {side=} {width=}") if side == "left": self._state.left_width = width else: