Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a handler for ListView.SearchForVirtualItem in winforms backend for keyboard navigation in tables and detailed lists #2956

Merged
merged 11 commits into from
Nov 20, 2024
3 changes: 3 additions & 0 deletions android/tests_backend/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ def typeface(self):
@property
def text_size(self):
return self._row_view(0).getChildAt(0).getTextSize()

async def acquire_keyboard_focus(self):
pytest.skip("test not implemented for this platform")
1 change: 1 addition & 0 deletions changes/2956.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Multi-letter keyboard navigation in Tables and DetailedLists with the winforms backend is now functional.
2 changes: 2 additions & 0 deletions cocoa/tests_backend/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ async def type_character(self, char, modifierFlags=0):
key_code = {
"<backspace>": 51,
"<esc>": 53,
"<down>": 125,
"<up>": 126,
" ": 49,
"\n": 36,
"a": 0,
Expand Down
9 changes: 9 additions & 0 deletions cocoa/tests_backend/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class TableProbe(SimpleProbe):
native_class = NSScrollView
supports_icons = 2 # All columns
supports_keyboard_shortcuts = True
supports_keyboard_boundary_shortcuts = False
supports_widgets = True

def __init__(self, widget):
Expand Down Expand Up @@ -144,3 +145,11 @@ async def activate_row(self, row):
delay=0.1,
clickCount=2,
)

async def acquire_keyboard_focus(self):
self.native_table.window.makeFirstResponder(
self.native_table
) # switch to widget.focus() when possible (#2972).
# Insure first row is selected.
await self.type_character("<down>")
await self.type_character("<up>")
3 changes: 3 additions & 0 deletions gtk/tests_backend/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ async def activate_row(self, row):
Gtk.TreePath(row),
self.native_table.get_columns()[0],
)

async def acquire_keyboard_focus(self):
pytest.skip("test not implemented for this platform")
44 changes: 44 additions & 0 deletions testbed/tests/widgets/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,50 @@ async def test_scroll(widget, probe):
assert -100 < probe.scroll_position <= 0


async def test_keyboard_navigation(widget, source, probe):
"""The list can be navigated using a keyboard."""
await probe.acquire_keyboard_focus()
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]
await probe.type_character("<down>")
await probe.redraw("Down arrow pressed - third row selected")
assert widget.selection == widget.data[2]
await probe.type_character("a")
await probe.redraw("Letter pressed - forth row selected")
assert widget.selection == widget.data[3]

# Select the last item with the end key if supported then wrap around.
if probe.supports_keyboard_boundary_shortcuts:
await probe.type_character("<end>")
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("<up>")
await probe.type_character("<up>")
await probe.type_character("<up>")
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_select(widget, probe, source, on_select_handler):
"""Rows can be selected"""
# Initial selection is empty
Expand Down
70 changes: 58 additions & 12 deletions winforms/src/toga_winforms/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def create(self):
self.native.CacheVirtualItems += WeakrefCallable(
self.winforms_cache_virtual_items
)
self.native.SearchForVirtualItem += WeakrefCallable(
self.winforms_search_for_virtual_item
)
self.native.VirtualItemsSelectionRangeChanged += WeakrefCallable(
self.winforms_item_selection_changed
)
Expand Down Expand Up @@ -108,6 +111,49 @@ def winforms_cache_virtual_items(self, sender, e):
for i in range(new_length):
self._cache.append(self._new_item(i + self._first_item))

def winforms_search_for_virtual_item(self, sender, e):
if (
not e.IsTextSearch or not self._accessors or not self._data
): # pragma: no cover
# If this list is empty, or has no columns, or it's an unsupported search
# type, there's no search to be done. These situation are difficult to
# trigger in CI; they're here as a safety catch.
return
find_previous = e.Direction in [
WinForms.SearchDirectionHint.Up,
WinForms.SearchDirectionHint.Left,
]
i = e.StartIndex
found_item = False
while True:
# It is possible for e.StartIndex to be received out-of-range if the user
# performs keyboard navigation at its edge, so check before accessing data
if i < 0: # pragma: no cover
# This could happen if this event is fired searching backwards,
# however this should not happen in Toga's use of it.
# i = len(self._data) - 1
raise NotImplementedError("backwards search unsupported")
elif i >= len(self._data):
i = 0
if (
self._item_text(self._data[i], self._accessors[0])[
: len(e.Text)
].lower()
== e.Text.lower()
):
found_item = True
break
if find_previous: # pragma: no cover
# Toga does not currently need backwards searching functionality.
# i -= 1
raise NotImplementedError("backwards search unsupported")
else:
i += 1
if i == e.StartIndex:
break
if found_item:
e.Index = i

def winforms_item_selection_changed(self, sender, e):
self.interface.on_select()

Expand Down Expand Up @@ -156,19 +202,8 @@ def icon(attr):

return None if icon is None else icon._impl

def text(attr):
val = getattr(item, attr, None)
if isinstance(val, toga.Widget):
warn("Winforms does not support the use of widgets in cells")
val = None
if isinstance(val, tuple):
val = val[1]
if val is None:
val = self.interface.missing_value
return str(val)

lvi = WinForms.ListViewItem(
[text(attr) for attr in self._accessors],
[self._item_text(item, attr) for attr in self._accessors],
)

# If the table has accessors, populate the icons for the table.
Expand All @@ -181,6 +216,17 @@ def text(attr):

return lvi

def _item_text(self, item, attr):
val = getattr(item, attr, None)
if isinstance(val, toga.Widget):
warn("Winforms does not support the use of widgets in cells")
val = None
if isinstance(val, tuple):
val = val[1]
if val is None:
val = self.interface.missing_value
return str(val)

def _image_index(self, icon):
images = self.native.SmallImageList.Images
key = str(icon.path)
Expand Down
2 changes: 1 addition & 1 deletion winforms/tests_backend/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

KEY_CODES = {
f"<{name}>": f"{{{name.upper()}}}"
for name in ["esc", "up", "down", "left", "right"]
for name in ["esc", "up", "down", "left", "right", "home", "end"]
}
KEY_CODES.update(
{
Expand Down
7 changes: 7 additions & 0 deletions winforms/tests_backend/widgets/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class TableProbe(SimpleProbe):
background_supports_alpha = False
supports_icons = 1 # First column only
supports_keyboard_shortcuts = False
supports_keyboard_boundary_shortcuts = True
supports_widgets = False

@property
Expand Down Expand Up @@ -99,3 +100,9 @@ async def activate_row(self, row):
delta=0,
)
)

async def acquire_keyboard_focus(self):
await self.type_character(
"\t"
) # switch to widget.focus() when possible (#2972)
await self.type_character(" ") # select first row