From bbd2ce7f474cbf2a4053b7571a0f1d1ba225a713 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 18 Oct 2026 23:19:11 +0200 Subject: [PATCH] fix(ui): ignore layer column clicks while a row is being renamed Double-clicking a word inside a sketch's rename entry opened the sketch editor: the layer column's capture-phase click gesture treated the click as a row activation and switched the window to the sketch page, hiding the bottom panel that held the focused rename entry mid-event. GTK then spun in focus-handling assertion failures, hanging the application (issue #582). The column's click handler now returns early when the clicked row's in-place name editor is open, leaving the event to the entry. --- rayforge/ui_gtk/doceditor/group_row.py | 4 + rayforge/ui_gtk/doceditor/layer_column.py | 6 + rayforge/ui_gtk/doceditor/workpiece_row.py | 4 + .../doceditor/test_layer_column_rename.py | 120 ++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 tests/ui_gtk/doceditor/test_layer_column_rename.py diff --git a/rayforge/ui_gtk/doceditor/group_row.py b/rayforge/ui_gtk/doceditor/group_row.py index d3f3ee299..b6213bcc2 100644 --- a/rayforge/ui_gtk/doceditor/group_row.py +++ b/rayforge/ui_gtk/doceditor/group_row.py @@ -76,6 +76,10 @@ def _on_double_clicked(self, gesture, n_press, x, y): return self.start_rename() + def is_renaming(self) -> bool: + """Whether the row's in-place name editor is currently active.""" + return self._rename_entry is not None + def start_rename(self): """Starts in-place editing of the item name.""" if self._rename_entry is not None: diff --git a/rayforge/ui_gtk/doceditor/layer_column.py b/rayforge/ui_gtk/doceditor/layer_column.py index ccdf4813c..0545362fa 100644 --- a/rayforge/ui_gtk/doceditor/layer_column.py +++ b/rayforge/ui_gtk/doceditor/layer_column.py @@ -476,6 +476,12 @@ def _on_column_clicked(self, gesture, n_press, x, y): item = self._row_items.get(widget) if item is None: break + row_child = widget.get_child() + if ( + isinstance(row_child, (WorkpieceRow, GroupRow)) + and row_child.is_renaming() + ): + return if n_press == 1: if not self._handle_item_click(item, gesture): return diff --git a/rayforge/ui_gtk/doceditor/workpiece_row.py b/rayforge/ui_gtk/doceditor/workpiece_row.py index d4b9bb570..0e4e68eb6 100644 --- a/rayforge/ui_gtk/doceditor/workpiece_row.py +++ b/rayforge/ui_gtk/doceditor/workpiece_row.py @@ -109,6 +109,10 @@ def _on_double_clicked(self, gesture, n_press, x, y): return self.start_rename() + def is_renaming(self) -> bool: + """Whether the row's in-place name editor is currently active.""" + return self._rename_entry is not None + def start_rename(self): """Starts in-place editing of the item name.""" if self._rename_entry is not None: diff --git a/tests/ui_gtk/doceditor/test_layer_column_rename.py b/tests/ui_gtk/doceditor/test_layer_column_rename.py new file mode 100644 index 000000000..eaab51678 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_layer_column_rename.py @@ -0,0 +1,120 @@ +"""Regression tests for clicks during in-place rename (issue #582). + +The layer column's capture-phase click gesture must ignore clicks that +land on a row while its name editor is open. Previously, double-clicking +a word in the rename entry of a sketch workpiece opened the sketch +editor modal page, hiding the focused entry mid-event and hanging GTK's +focus handling. +""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from blinker import Signal +from gi.repository import Gtk + +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.layer_column import LayerColumn +from rayforge.ui_gtk.doceditor.workpiece_row import WorkpieceRow + +pytestmark = pytest.mark.ui + + +class _SketchAsset: + edit_item_action = "edit-sketch-item" + + +def _column_with_row(sketch_workpiece: WorkPiece): + """Builds a bare LayerColumn with one row, bypassing Gtk __init__.""" + column: Any = LayerColumn.__new__(LayerColumn) + column.layer = MagicMock() + column.doc = MagicMock() + column.doc.active_layer = column.layer + column._row_items = {} + column._ordered_items = [] + column._selected_uids = set() + column._selection_anchor = None + column._pressed_clicked_item = None + column.edit_item_requested = Signal() + column.select_items_requested = Signal() + + row = Gtk.ListBoxRow() + row_child = WorkpieceRow(sketch_workpiece, on_rename=lambda item, n: None) + row.set_child(row_child) + column._row_items[row] = sketch_workpiece + column._ordered_items.append(sketch_workpiece) + return column, row_child + + +def _pick_click_target(column, row_child): + """Makes the column's pick() resolve to a widget inside the row.""" + entry = Gtk.Entry() + row_child.append(entry) + column.pick = lambda x, y, flags: entry + + +@pytest.fixture +def sketch_workpiece(): + wp = WorkPiece("New Sketch") + wp.geometry_provider_uid = "sketch-uid" + return wp + + +@pytest.mark.ui +def test_double_click_in_rename_entry_is_ignored(sketch_workpiece): + column, row_child = _column_with_row(sketch_workpiece) + _pick_click_target(column, row_child) + row_child.start_rename() + assert row_child.is_renaming() + + emissions = [] + + def on_edit_requested(sender, **kw): + emissions.append(kw) + + column.edit_item_requested.connect(on_edit_requested, weak=False) + + column._on_column_clicked(MagicMock(), 2, 5.0, 5.0) + + assert emissions == [] + + +@pytest.mark.ui +def test_single_click_in_rename_entry_does_not_change_selection( + sketch_workpiece, +): + column, row_child = _column_with_row(sketch_workpiece) + _pick_click_target(column, row_child) + row_child.start_rename() + + emissions = [] + + def on_select_items(sender, **kw): + emissions.append(kw) + + column.select_items_requested.connect(on_select_items, weak=False) + + gesture = MagicMock() + gesture.get_current_event.return_value = None + column._on_column_clicked(gesture, 1, 5.0, 5.0) + + assert emissions == [] + + +@pytest.mark.ui +def test_double_click_outside_rename_opens_editor(sketch_workpiece): + column, row_child = _column_with_row(sketch_workpiece) + _pick_click_target(column, row_child) + column.doc.get_asset_by_uid = lambda uid: _SketchAsset() + + emissions = [] + + def on_edit_requested(sender, **kw): + emissions.append(kw["item"]) + + column.edit_item_requested.connect(on_edit_requested, weak=False) + + column._on_column_clicked(MagicMock(), 2, 5.0, 5.0) + + assert emissions == [sketch_workpiece]