From ef9f269a49009f6ae1690284f1d5f2176daf226d Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 16 Mar 2026 21:16:21 +0100 Subject: [PATCH] I can edit a cell --- .claude/commands/developer-control.md | 2 +- .claude/skills/developer-control/SKILL.md | 13 +- src/myfasthtml/assets/core/mouse.js | 46 ++++++- src/myfasthtml/assets/datagrid/datagrid.js | 2 +- src/myfasthtml/controls/DataGrid.py | 134 +++++++++++++++++++-- src/myfasthtml/controls/Mouse.py | 3 +- src/myfasthtml/core/commands.py | 2 +- tests/controls/test_datagrid.py | 4 +- 8 files changed, 182 insertions(+), 24 deletions(-) diff --git a/.claude/commands/developer-control.md b/.claude/commands/developer-control.md index f40b114..9189427 100644 --- a/.claude/commands/developer-control.md +++ b/.claude/commands/developer-control.md @@ -205,7 +205,7 @@ def render(self): return Div( self._mk_content(), Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), - Mouse(self, _id="-mouse").add("click", self.commands.on_click()), + Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()), id=self._id ) ``` diff --git a/.claude/skills/developer-control/SKILL.md b/.claude/skills/developer-control/SKILL.md index a5de159..a666cec 100644 --- a/.claude/skills/developer-control/SKILL.md +++ b/.claude/skills/developer-control/SKILL.md @@ -209,13 +209,14 @@ For interactive controls, compose `Keyboard` and `Mouse`: from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Mouse import Mouse + def render(self): - return Div( - self._mk_content(), - Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), - Mouse(self, _id="-mouse").add("click", self.commands.on_click()), - id=self._id - ) + return Div( + self._mk_content(), + Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), + Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()), + id=self._id + ) ``` --- diff --git a/src/myfasthtml/assets/core/mouse.js b/src/myfasthtml/assets/core/mouse.js index b7ef8d5..6718cdd 100644 --- a/src/myfasthtml/assets/core/mouse.js +++ b/src/myfasthtml/assets/core/mouse.js @@ -14,6 +14,7 @@ pendingMatches: [], // Array of matches waiting for timeout sequenceTimeout: 500, // 500ms timeout for sequences clickHandler: null, + dblclickHandler: null, // Handler reference for dblclick contextmenuHandler: null, mousedownState: null, // Active drag state (only after movement detected) suppressNextClick: false, // Prevents click from firing after mousedown>mouseup @@ -35,7 +36,9 @@ // Handle aliases const aliasMap = { - 'rclick': 'right_click' + 'rclick': 'right_click', + 'double_click': 'dblclick', + 'dclick': 'dblclick' }; return aliasMap[normalized] || normalized; @@ -563,6 +566,43 @@ } } + /** + * Handle dblclick events (triggers for all registered elements). + * Uses a fresh single-step history so it never conflicts with click sequences. + * @param {MouseEvent} event - The dblclick event + */ + function handleDblClick(event) { + const snapshot = createSnapshot(event, 'dblclick'); + const dblclickHistory = [snapshot]; + + const currentMatches = []; + + for (const [elementId, data] of MouseRegistry.elements) { + const element = document.getElementById(elementId); + if (!element) continue; + + const isInside = element.contains(event.target); + const currentNode = traverseTree(data.tree, dblclickHistory); + if (!currentNode || !currentNode.config) continue; + + currentMatches.push({ + elementId: elementId, + config: currentNode.config, + combinationStr: currentNode.combinationStr, + isInside: isInside + }); + } + + const anyMatchInside = currentMatches.some(m => m.isInside); + if (anyMatchInside && !isInInputContext()) { + event.preventDefault(); + } + + for (const match of currentMatches) { + triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event); + } + } + /** * Clean up mousedown state and safety timeout */ @@ -989,11 +1029,13 @@ if (!MouseRegistry.listenerAttached) { // Store handler references for proper removal MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click'); + MouseRegistry.dblclickHandler = (e) => handleDblClick(e); MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click'); MouseRegistry.mousedownHandler = (e) => handleMouseDown(e); MouseRegistry.mouseupHandler = (e) => handleMouseUp(e); document.addEventListener('click', MouseRegistry.clickHandler); + document.addEventListener('dblclick', MouseRegistry.dblclickHandler); document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.addEventListener('mousedown', MouseRegistry.mousedownHandler); document.addEventListener('mouseup', MouseRegistry.mouseupHandler); @@ -1007,6 +1049,7 @@ function detachGlobalListener() { if (MouseRegistry.listenerAttached) { document.removeEventListener('click', MouseRegistry.clickHandler); + document.removeEventListener('dblclick', MouseRegistry.dblclickHandler); document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler); document.removeEventListener('mousedown', MouseRegistry.mousedownHandler); document.removeEventListener('mouseup', MouseRegistry.mouseupHandler); @@ -1014,6 +1057,7 @@ // Clean up handler references MouseRegistry.clickHandler = null; + MouseRegistry.dblclickHandler = null; MouseRegistry.contextmenuHandler = null; MouseRegistry.mousedownHandler = null; MouseRegistry.mouseupHandler = null; diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index 922e27e..e04c5a4 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -3,7 +3,7 @@ function initDataGrid(gridId) { initDataGridMouseOver(gridId); makeDatagridColumnsResizable(gridId); makeDatagridColumnsMovable(gridId); - updateDatagridSelection(gridId) + updateDatagridSelection(gridId); } /** diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 9b99d3a..732c9b4 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -1,7 +1,6 @@ import html import logging import re -from dataclasses import dataclass from functools import lru_cache from typing import Optional @@ -18,9 +17,7 @@ from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Query import Query, QUERY_FILTER -from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \ - DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \ - DataGridRowSelectionColumnState +from myfasthtml.controls.datagrid_objects import * from myfasthtml.controls.helpers import mk from myfasthtml.core.commands import Command from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID @@ -156,7 +153,7 @@ class Commands(BaseCommands): return Command("OnClick", "Click on the table", self._owner, - self._owner.on_click + self._owner.handle_on_click ).htmx(target=f"#tsm_{self._id}") def on_key_pressed(self): @@ -212,6 +209,21 @@ class Commands(BaseCommands): self._owner, self._owner.on_column_changed ) + + def start_edition(self): + return Command("StartEdition", + "Enter cell edit mode", + self._owner, + self._owner.handle_start_edition + ).htmx(target=f"#tsm_{self._id}") + + def save_edition(self): + return Command("SaveEdition", + "Save cell edition", + self._owner, + self._owner.handle_save_edition + ).htmx(target=f"#tsm_{self._id}", + trigger="blur, keydown[key=='Enter']") class DataGrid(MultipleInstance): @@ -293,10 +305,12 @@ class DataGrid(MultipleInstance): "click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, "ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, + "dblclick": {"command": self.commands.start_edition(), "hx_vals": "js:getCellId()"}, } self._key_support = { "esc": {"command": self.commands.on_key_pressed(), "require_inside": False}, + "enter": {"command": self.commands.on_key_pressed(), "require_inside": True}, } logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.") @@ -451,6 +465,26 @@ class DataGrid(MultipleInstance): if self._settings.enable_edition: self._columns.insert(0, DataGridRowSelectionColumnState()) + def _enter_edition(self, pos): + col_pos, row_index = pos + col_def = self._columns[col_pos] + if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula): + return self.render_partial() + self._state.edition.under_edition = pos + self._state.save() + return self.render_partial("cell", pos=pos) + + def _convert_edition_value(self, value_str, col_type): + if col_type == ColumnType.Number: + try: + return float(value_str) if '.' in value_str else int(value_str) + except (ValueError, TypeError): + return value_str + elif col_type == ColumnType.Bool: + return value_str.lower() in ('true', '1', 'yes') + else: + return value_str + def add_new_column(self, col_def: DataGridColumnState) -> None: """Add a new column, delegating data mutation to DataService. @@ -571,14 +605,20 @@ class DataGrid(MultipleInstance): self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() return self.render_partial("body") - def on_click(self, combination, is_inside, cell_id): + def handle_on_click(self, combination, is_inside, cell_id): logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}") if is_inside and cell_id: self._state.selection.extra_selected.clear() - if cell_id.startswith("tcell_"): - pos = self._get_pos_from_element_id(cell_id) - self._update_current_position(pos) + pos = self._get_pos_from_element_id(cell_id) + + if (self._settings.enable_edition and + pos is not None and + pos == self._state.selection.selected and + self._state.edition.under_edition is None): + return self._enter_edition(pos) + + self._update_current_position(pos) return self.render_partial() @@ -605,6 +645,11 @@ class DataGrid(MultipleInstance): if combination == "esc": self._update_current_position(None) self._state.selection.extra_selected.clear() + elif (combination == "enter" and + self._settings.enable_edition and + self._state.selection.selected and + self._state.edition.under_edition is None): + return self._enter_edition(self._state.selection.selected) return self.render_partial() @@ -674,6 +719,30 @@ class DataGrid(MultipleInstance): self._panel.set_title(side="right", title="Formatting") self._panel.set_right(self._formatting_editor) + def handle_start_edition(self, cell_id): + logger.debug(f"handle_start_edition: {cell_id=}") + if not self._settings.enable_edition: + return self.render_partial() + if self._state.edition.under_edition is not None: + return self.render_partial() + pos = self._get_pos_from_element_id(cell_id) + if pos is None: + return self.render_partial() + self._update_current_position(pos) + return self._enter_edition(pos) + + def handle_save_edition(self, value): + logger.debug(f"handle_save_edition: {value=}") + if self._state.edition.under_edition is None: + return self.render_partial() + col_pos, row_index = self._state.edition.under_edition + col_def = self._columns[col_pos] + typed_value = self._convert_edition_value(value, col_def.type) + self._data_service.set_data(col_def.col_id, row_index, typed_value) + self._state.edition.under_edition = None + self._state.save() + return self.render_partial("cell", pos=(col_pos, row_index)) + def handle_set_column_width(self, col_id: str, width: str): """Update column width after resize. Called via Command from JS.""" logger.debug(f"set_column_width: {col_id=} {width=}") @@ -724,6 +793,31 @@ class DataGrid(MultipleInstance): def get_data_service_id_from_data_grid_id(datagrid_id): return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1) + def _mk_edition_cell(self, col_pos, row_index, col_def: DataGridColumnState, is_last): + col_array = self._fast_access.get(col_def.col_id) + value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None + value_str = str(value) if not is_null(value) else "" + + save_cmd = self.commands.save_edition() + input_elem = mk.mk( + Input(value=value_str, name="value", autofocus=True, cls="dt2-cell-input"), + command=save_cmd + ) + + return OptimizedDiv( + input_elem, + id=self._get_element_id_from_pos("cell", (col_pos, row_index)), + cls=merge_classes("dt2-cell dt2-cell-edition", "dt2-last-cell" if is_last else None), + style=f"width:{col_def.width}px;" + ) + + def _mk_cell_oob(self, col_pos, row_index): + col_def = self._columns[col_pos] + filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) + filter_keyword_lower = filter_keyword.lower() if filter_keyword else None + is_last = col_pos == len(self._columns) - 1 + return self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last) + def mk_headers(self): resize_cmd = self.commands.set_column_width() move_cmd = self.commands.move_column() @@ -867,6 +961,10 @@ class DataGrid(MultipleInstance): if col_def.type == ColumnType.RowSelection_: return OptimizedDiv(cls="dt2-row-selection") + if (self._settings.enable_edition and + self._state.edition.under_edition == (col_pos, row_index)): + return self._mk_edition_cell(col_pos, row_index, col_def, is_last) + col_array = self._fast_access.get(col_def.col_id) value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower) @@ -1105,7 +1203,7 @@ class DataGrid(MultipleInstance): :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header) :return: """ - res = [] + res = [self.mk_selection_manager()] extra_attr = { "hx-on::after-settle": f"initDataGrid('{self._id}');", @@ -1133,7 +1231,21 @@ class DataGrid(MultipleInstance): header.attrs.update(header_extra_attr) return header - res.append(self.mk_selection_manager()) + else: + col_pos, row_index = None, None + + if (cell_id := kwargs.get("cell_id")) is not None: + col_pos, row_index = self._get_pos_from_element_id(cell_id) + elif (pos := kwargs.get("pos")) is not None: + col_pos, row_index = pos + + if col_pos is not None and row_index is not None: + col_def = self._columns[col_pos] + filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) + filter_keyword_lower = filter_keyword.lower() if filter_keyword else None + is_last_col = col_pos == len(self._columns) - 1 + cell = self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last_col) + res.append(cell) return tuple(res) diff --git a/src/myfasthtml/controls/Mouse.py b/src/myfasthtml/controls/Mouse.py index 17c364f..c2372c3 100644 --- a/src/myfasthtml/controls/Mouse.py +++ b/src/myfasthtml/controls/Mouse.py @@ -36,7 +36,8 @@ class Mouse(MultipleInstance): VALID_ACTIONS = { 'click', 'right_click', 'rclick', - 'mousedown>mouseup', 'rmousedown>mouseup' + 'mousedown>mouseup', 'rmousedown>mouseup', + 'dblclick', 'double_click', 'dclick' } VALID_MODIFIERS = {'ctrl', 'shift', 'alt'} def __init__(self, parent, _id=None, combinations=None): diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 26e2a9e..4c663be 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -174,7 +174,7 @@ class Command: # Set the hx-swap-oob attribute on all elements returned by the callback if self._htmx_extra[AUTO_SWAP_OOB]: for index, r in enumerate(all_ret[1:]): - if hasattr(r, "__ft__"): + if not hasattr(r, 'attrs') and hasattr(r, "__ft__"): r = r.__ft__() all_ret[index + 1] = r if (hasattr(r, 'attrs') diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py index e9a1917..5b80ee1 100644 --- a/tests/controls/test_datagrid.py +++ b/tests/controls/test_datagrid.py @@ -318,7 +318,7 @@ class TestDataGridBehaviour: dg = datagrid dg._state.selection.selected = (1, 2) - dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2") + dg.handle_on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2") assert dg._state.selection.selected == (1, 2) @@ -332,7 +332,7 @@ class TestDataGridBehaviour: dg = datagrid cell_id = f"tcell_{dg._id}-2-5" - dg.on_click("click", is_inside=True, cell_id=cell_id) + dg.handle_on_click("click", is_inside=True, cell_id=cell_id) assert dg._state.selection.selected == (2, 5)