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`)
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)

View File

@@ -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

View File

@@ -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"}`);
}

View File

@@ -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;"

View File

@@ -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")

View File

@@ -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}",
),

View File

@@ -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"},

View File

@@ -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: