diff --git a/changes/3781.feature.rst b/changes/3781.feature.rst new file mode 100644 index 0000000000..c3deba5489 --- /dev/null +++ b/changes/3781.feature.rst @@ -0,0 +1 @@ +Table and Tree widgets on desktop platforms will invoke `on_activate` when the Enter key is pressed and a single row/node is selected. diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index c73c086638..acd72bde07 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -2,6 +2,8 @@ from travertino.size import at_least import toga +from toga.keys import Key +from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -125,9 +127,19 @@ def tableViewSelectionDidChange_(self, notification) -> None: @objc_method def onDoubleClick_(self, sender) -> None: clicked = self.interface.data[self.clickedRow] - self.interface.on_activate(row=clicked) + @objc_method + def performKeyEquivalent_(self, event) -> bool: + if self.impl.has_focus: + key = toga_key(event) + if key["key"] in {Key.ENTER, Key.NUMPAD_ENTER} and not key["modifiers"]: + row = self.interface._selection_single + if row is not None: + self.interface.on_activate(row=row) + return True + return False + class Table(Widget): def create(self): @@ -172,6 +184,13 @@ def create(self): # Add the layout constraints self.add_constraints() + @property + def has_focus(self): + return ( + self.native.window is not None + and self.native.window.firstResponder == self.native_table + ) + def change_source(self, source): self.native_table.reloadData() diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 663997cc28..1e68b9a978 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -2,6 +2,8 @@ from travertino.size import at_least import toga +from toga.keys import Key +from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -169,6 +171,17 @@ def onDoubleClick_(self, sender) -> None: node = self.itemAtRow(self.clickedRow).attrs["node"] self.interface.on_activate(node=node) + @objc_method + def performKeyEquivalent_(self, event) -> bool: + if self.impl.has_focus: + key = toga_key(event) + if key["key"] in {Key.ENTER, Key.NUMPAD_ENTER} and not key["modifiers"]: + node = self.interface._selection_single + if node is not None: + self.interface.on_activate(node=node) + return True + return False + class Tree(Widget): def create(self): @@ -216,6 +229,13 @@ def create(self): # Add the layout constraints self.add_constraints() + @property + def has_focus(self): + return ( + self.native.window is not None + and self.native.window.firstResponder == self.native_tree + ) + def change_source(self, source): self.native_tree.reloadData() diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 7bb972c939..304daf3a81 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -13,6 +13,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True + supports_keyboard_boundary_shortcuts = False supports_widgets = True def __init__(self, widget): @@ -184,3 +185,8 @@ async def activate_row(self, row_path): delay=0.1, clickCount=2, ) + + async def select_first_row_keyboard(self): + # Use the keyboard to ensure first row is selected. + await self.type_character("") + await self.type_character("") diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 2f6ecb1d73..6512825ea7 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -174,6 +174,22 @@ def selection(self) -> list[Row] | Row | None: else: return self.data[selection] + @property + def _selection_single(self) -> Row | None: + """The currently selected row of the table. + + Returns :any:`None` if no row is currently selected, or if multiple rows are + selected. + """ + selection = self.selection + if self.multiple_select: + if len(selection) == 1: + return selection[0] + else: + return None + else: + return selection + def scroll_to_top(self) -> None: """Scroll the view so that the top of the list (first row) is visible.""" self.scroll_to_row(0) diff --git a/core/src/toga/widgets/tree.py b/core/src/toga/widgets/tree.py index 536b8bcb7a..251eb7f556 100644 --- a/core/src/toga/widgets/tree.py +++ b/core/src/toga/widgets/tree.py @@ -166,6 +166,22 @@ def selection(self) -> list[Node] | Node | None: """ return self._impl.get_selection() + @property + def _selection_single(self) -> Node | None: + """The currently selected node of the tree. + + Returns :any:`None` if no node is currently selected, or if multiple nodes are + selected. + """ + selection = self.selection + if self.multiple_select: + if len(selection) == 1: + return selection[0] + else: + return None + else: + return selection + def expand(self, node: Node | None = None) -> None: """Expand the specified node of the tree. diff --git a/core/tests/widgets/test_table.py b/core/tests/widgets/test_table.py index dadc19137b..76ac0c75d9 100644 --- a/core/tests/widgets/test_table.py +++ b/core/tests/widgets/test_table.py @@ -219,6 +219,7 @@ def test_single_selection(table, on_select_handler): """The current selection can be retrieved.""" # Selection is initially empty assert table.selection is None + assert table._selection_single is None on_select_handler.assert_not_called() # Select an item @@ -227,6 +228,9 @@ def test_single_selection(table, on_select_handler): # Selection returns a single row assert table.selection == table.data[1] + # _selection_single returns a single row + assert table._selection_single == table.data[1] + # Selection handler was triggered on_select_handler.assert_called_once_with(table) @@ -241,14 +245,31 @@ def test_multiple_selection(source, on_select_handler): ) # Selection is initially empty assert table.selection == [] + assert table._selection_single is None on_select_handler.assert_not_called() # Select an item + table._impl.simulate_selection([2]) + + # Selection returns a list of rows + assert table.selection == [table.data[2]] + + # _selection_single a single item + assert table._selection_single is table.data[2] + + # Selection handler was triggered + on_select_handler.assert_called_once_with(table) + on_select_handler.reset_mock() + + # Select multiple items table._impl.simulate_selection([0, 2]) # Selection returns a list of rows assert table.selection == [table.data[0], table.data[2]] + # _selection_single returns None + assert table._selection_single is None + # Selection handler was triggered on_select_handler.assert_called_once_with(table) diff --git a/core/tests/widgets/test_tree.py b/core/tests/widgets/test_tree.py index 965d7e1a64..a0add3cde3 100644 --- a/core/tests/widgets/test_tree.py +++ b/core/tests/widgets/test_tree.py @@ -265,6 +265,7 @@ def test_single_selection(tree, on_select_handler): """The current selection can be retrieved.""" # Selection is initially empty assert tree.selection is None + assert tree._selection_single is None on_select_handler.assert_not_called() # Select an item @@ -273,6 +274,9 @@ def test_single_selection(tree, on_select_handler): # Selection returns a single row assert tree.selection == tree.data[0][1] + # _selection_single returns a single node + assert tree._selection_single == tree.data[0][1] + # Selection handler was triggered on_select_handler.assert_called_once_with(tree) @@ -287,14 +291,31 @@ def test_multiple_selection(source, on_select_handler): ) # Selection is initially empty assert tree.selection == [] + assert tree._selection_single is None on_select_handler.assert_not_called() # Select an item + tree._impl.simulate_selection([(1, 2, 1)]) + + # Selection returns a list of rows + assert tree.selection == [tree.data[1][2][1]] + + # _selection_single returns a single item + assert tree._selection_single == tree.data[1][2][1] + + # Selection handler was triggered + on_select_handler.assert_called_once_with(tree) + on_select_handler.reset_mock() + + # Select multiple items tree._impl.simulate_selection([(0, 1), (1, 2, 1)]) # Selection returns a list of rows assert tree.selection == [tree.data[0][1], tree.data[1][2][1]] + # _selection_single returns None + assert tree._selection_single is None + # Selection handler was triggered on_select_handler.assert_called_once_with(tree) diff --git a/gtk/tests_backend/widgets/tree.py b/gtk/tests_backend/widgets/tree.py index ec9afcac4c..799804234c 100644 --- a/gtk/tests_backend/widgets/tree.py +++ b/gtk/tests_backend/widgets/tree.py @@ -104,3 +104,6 @@ async def activate_row(self, row_path): Gtk.TreePath(row_path), self.native_tree.get_columns()[0], ) + + async def select_first_row_keyboard(self): + pytest.skip("test not implemented for this platform") diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index adf3b2b6e3..3da8423cc2 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -92,13 +92,14 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture -async def multiselect_widget(source, on_select_handler): +async def multiselect_widget(source, on_select_handler, on_activate_handler): skip_on_platforms("iOS") return toga.Table( ["A", "B", "C"], data=source, multiple_select=True, on_select=on_select_handler, + on_activate=on_activate_handler, style=Pack(flex=1), ) @@ -164,11 +165,15 @@ async def test_scroll(widget, probe): assert -100 < probe.scroll_position <= 0 -async def test_keyboard_navigation(widget, source, probe): +async def test_keyboard_navigation( + widget, source, probe, on_select_handler, on_activate_handler +): """The list can be navigated using a keyboard.""" widget.focus() await probe.select_first_row_keyboard() + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() await probe.redraw("First row selected") assert widget.selection == widget.data[0] @@ -176,12 +181,25 @@ async def test_keyboard_navigation(widget, source, probe): await probe.type_character("a") await probe.redraw("Letter pressed - second row selected") assert widget.selection == widget.data[1] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() await probe.type_character("") await probe.redraw("Down arrow pressed - third row selected") assert widget.selection == widget.data[2] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() await probe.type_character("a") await probe.redraw("Letter pressed - forth row selected") assert widget.selection == widget.data[3] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Activate row + assert widget.selection == source[3] + await probe.type_character("\n") + await probe.redraw("Return key pressed - forth row activated") + on_activate_handler.assert_called_once_with(widget, row=source[3]) + on_activate_handler.reset_mock() # Select the last item with the end key if supported then wrap around. if probe.supports_keyboard_boundary_shortcuts: @@ -267,6 +285,31 @@ async def test_activate( on_activate_handler.reset_mock() +async def test_activate_keyboard( + widget, + probe, + other, + other_probe, + source, + on_activate_handler, +): + """Rows are activated by keyboard""" + + widget.focus() + await probe.select_first_row_keyboard() + + assert probe.has_focus + await probe.type_character("\n") + await probe.redraw("Return key pressed - first row is activated") + on_activate_handler.assert_called_once_with(widget, row=source[0]) + on_activate_handler.reset_mock() + + other.focus() + assert not probe.has_focus + await probe.type_character("\n") + on_activate_handler.assert_not_called() + + async def test_multiselect( multiselect_widget, multiselect_probe, @@ -316,6 +359,7 @@ async def test_multiselect_keyboard_control( multiselect_probe, source, on_select_handler, + on_activate_handler, ): """Selection on a multiselect table can be controlled by keyboard. @@ -333,14 +377,23 @@ async def test_multiselect_keyboard_control( # A single row can be added to the selection await multiselect_probe.select_first_row_keyboard() await multiselect_probe.redraw("First row selected") + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() assert multiselect_widget.selection == [source[0]] + # Activate row + await multiselect_probe.type_character("\n") + await multiselect_probe.redraw("Return key pressed - a single row is activated") + on_activate_handler.assert_called_once_with(multiselect_widget, row=source[0]) + on_activate_handler.reset_mock() + + # The selection can be extended await multiselect_probe.type_character("", shift=True) await multiselect_probe.redraw( "Down arrow pressed - second row added to the selection" ) assert multiselect_widget.selection == [source[0], source[1]] - on_select_handler.assert_called_with(multiselect_widget) + on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() await multiselect_probe.type_character("", shift=True) @@ -348,7 +401,7 @@ async def test_multiselect_keyboard_control( "Down arrow pressed - third row added to the selection" ) assert multiselect_widget.selection == [source[0], source[1], source[2]] - on_select_handler.assert_called_with(multiselect_widget) + on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() await multiselect_probe.type_character("", shift=True) @@ -356,9 +409,18 @@ async def test_multiselect_keyboard_control( "Up arrow pressed - third row removed from the selection" ) assert multiselect_widget.selection == [source[0], source[1]] - on_select_handler.assert_called_with(multiselect_widget) + on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() + # Activate is not fired when multiple rows are selected + assert len(multiselect_widget.selection) > 1 + await multiselect_probe.type_character("\n") + await multiselect_probe.redraw( + "Return key pressed - multiple rows were selected - no activation" + ) + on_activate_handler.assert_not_called() + on_activate_handler.reset_mock() + class MyData: def __init__(self, text): diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index e8366c0c45..dd2f3a032a 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -139,7 +139,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture -def multiselect_widget(source, on_select_handler): +def multiselect_widget(source, on_select_handler, on_activate_handler): # Although Android *has* a table implementation, it needs to be rebuilt. skip_on_platforms("iOS", "android", "windows") return toga.Tree( @@ -147,6 +147,7 @@ def multiselect_widget(source, on_select_handler): data=source, multiple_select=True, on_select=on_select_handler, + on_activate=on_activate_handler, style=Pack(flex=1), ) @@ -474,6 +475,94 @@ async def test_activate( on_activate_handler.reset_mock() +async def test_activate_keyboard( + widget, + probe, + other, + other_probe, + source, + on_activate_handler, +): + """Rows are activated by keyboard""" + + widget.focus() + await probe.select_first_row_keyboard() + + assert probe.has_focus + await probe.type_character("\n") + await probe.redraw("Return key pressed - first row is activated") + on_activate_handler.assert_called_once_with(widget, node=source[0]) + on_activate_handler.reset_mock() + + other.focus() + assert not probe.has_focus + await probe.type_character("\n") + on_activate_handler.assert_not_called() + + +async def test_keyboard_navigation( + widget, source, probe, on_select_handler, on_activate_handler +): + """The list can be navigated using a keyboard.""" + widget.focus() + + await probe.select_first_row_keyboard() + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + await probe.redraw("First row selected") + assert widget.selection == widget.data[0] + + # Navigate down with letter, arrow, letter. + await probe.type_character("a") + await probe.redraw("Letter pressed - second row selected") + assert widget.selection == widget.data[1] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + await probe.type_character("") + await probe.redraw("Down arrow pressed - third row selected") + assert widget.selection == widget.data[2] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + await probe.type_character("a") + await probe.redraw("Letter pressed - forth row selected") + assert widget.selection == widget.data[3] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Activate row + assert widget.selection == source[3] + await probe.type_character("\n") + await probe.redraw("Return key pressed - forth row selected") + on_activate_handler.assert_called_once_with(widget, node=source[3]) + on_activate_handler.reset_mock() + + # Select the last item with the end key if supported then wrap around. + if probe.supports_keyboard_boundary_shortcuts: + await probe.type_character("") + await probe.redraw("Last row is selected") + assert widget.selection == widget.data[-1] + # Navigate by 1 item, wrapping around. + await probe.type_character("a") + await probe.redraw("Letter pressed - first row is selected") + else: + await probe.type_character("") + await probe.type_character("") + await probe.type_character("") + await probe.redraw("Up arrow pressed thrice - first row is selected") + assert widget.selection == widget.data[0] + + # Type a letter that no items start with to verify the selection doesn't change. + await probe.type_character("x") + await probe.redraw("Invalid letter pressed - first row is still selected") + assert widget.selection == widget.data[0] + + # clear the table and verify with an empty selection. + widget.data.clear() + await probe.type_character("a") + await probe.redraw("Letter pressed - no row selected") + assert not widget.selection + + async def test_multiselect( multiselect_widget, multiselect_probe, @@ -524,6 +613,72 @@ async def test_multiselect( assert len(multiselect_widget.selection) == 70 +async def test_multiselect_keyboard_control( + multiselect_widget, + multiselect_probe, + source, + on_select_handler, + on_activate_handler, +): + """Selection on a multiselect table can be controlled by keyboard. + + Keyboard navigation can produce different events to mouse navigation, + so we need to test keyboard selection independent of mouse selection. + """ + await multiselect_probe.redraw("No row is selected in multiselect table") + + # Initial selection is empty + assert multiselect_widget.selection == [] + on_select_handler.assert_not_called() + + multiselect_widget.focus() + + # A single row can be added to the selection + await multiselect_probe.select_first_row_keyboard() + await multiselect_probe.redraw("First row selected") + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + assert multiselect_widget.selection == [source[0]] + + # Activate row + await multiselect_probe.type_character("\n") + await multiselect_probe.redraw("Return key pressed - a single row is selected") + on_activate_handler.assert_called_once_with(multiselect_widget, node=source[0]) + on_activate_handler.reset_mock() + + # The selection can be extended + await multiselect_probe.type_character("", shift=True) + await multiselect_probe.redraw( + "Down arrow pressed - second row added to the selection" + ) + assert multiselect_widget.selection == [source[0], source[1]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + await multiselect_probe.type_character("", shift=True) + await multiselect_probe.redraw( + "Down arrow pressed - third row added to the selection" + ) + assert multiselect_widget.selection == [source[0], source[1], source[2]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + await multiselect_probe.type_character("", shift=True) + await multiselect_probe.redraw( + "Up arrow pressed - third row removed from the selection" + ) + assert multiselect_widget.selection == [source[0], source[1]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # Activate is not fired when multiple rows are selected + assert len(multiselect_widget.selection) > 1 + await multiselect_probe.type_character("\n") + await multiselect_probe.redraw("Return key pressed - multiple rows selected") + on_activate_handler.assert_not_called() + on_activate_handler.reset_mock() + + class MyData: def __init__(self, text): self.text = text diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 20aba42bc0..7c14db2001 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -72,6 +72,7 @@ def create(self): def add_action_events(self): self.native.MouseDoubleClick += WeakrefCallable(self.winforms_double_click) + self.native.KeyPress += WeakrefCallable(self.winforms_key_press) def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) @@ -180,6 +181,13 @@ def winforms_double_click(self, sender, e): # that isn't guaranteed by the documentation. pass + def winforms_key_press(self, sender, e): + if ord(e.KeyChar) == int(WinForms.Keys.Enter): + row = self.interface._selection_single + if row: + self.interface.on_activate(row=row) + e.Handled = True + def _create_column(self, heading, accessor): col = WinForms.ColumnHeader() col.Text = heading