Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3781.feature.rst
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We recently switched the documentation from RST to Markdown, so please rename this file to .md.

Original file line number Diff line number Diff line change
@@ -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.
21 changes: 20 additions & 1 deletion cocoa/src/toga_cocoa/widgets/table.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate code between Tree and Table should be merged.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()

Expand Down
20 changes: 20 additions & 0 deletions cocoa/src/toga_cocoa/widgets/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions cocoa/tests_backend/widgets/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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("<down>")
await self.type_character("<up>")
16 changes: 16 additions & 0 deletions core/src/toga/widgets/table.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Tree and Table implementations are duplicated, so please merge them into one. Don't worry about the type annotations, because we only use them for documentation of the public API.

Copy link
Contributor Author

@bruno-rino bruno-rino Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where should this code shared between table and tree live?
Is it OK to add an from toga.widgets.table import ... in tree.py?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if there isn't any more obvious place, then it's fine to put it in one file and import it from the other.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions core/src/toga/widgets/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions core/tests/widgets/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions core/tests/widgets/test_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions gtk/tests_backend/widgets/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading