Skip to content

Commit b84b693

Browse files
Merge pull request Textualize#3912 from Textualize/navigation
Fix keyboard navigation in option list (& selection list), radio set, list view
2 parents a382bdc + 268d765 commit b84b693

14 files changed

+618
-191
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Changed
11+
12+
- Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881
13+
814
## [0.48.1] - 2023-02-01
915

1016
### Fixed

src/textual/_widget_navigation.py

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""
2+
Utilities to move index-based selections backward/forward.
3+
4+
These utilities concern themselves with selections where not all options are available,
5+
otherwise it would be enough to increment/decrement the index and use the operator `%`
6+
to implement wrapping.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from functools import partial
12+
from itertools import count
13+
from typing import TYPE_CHECKING, Literal, Protocol, Sequence
14+
15+
from typing_extensions import TypeAlias, TypeVar
16+
17+
if TYPE_CHECKING:
18+
from .widget import Widget
19+
20+
21+
class Disableable(Protocol):
22+
"""Non-widgets that have an enabled/disabled status."""
23+
24+
disabled: bool
25+
26+
27+
Direction: TypeAlias = Literal[-1, 1]
28+
"""Valid values to determine navigation direction.
29+
30+
In a vertical setting, 1 points down and -1 points up.
31+
In a horizontal setting, 1 points right and -1 points left.
32+
"""
33+
_DisableableType = TypeVar("_DisableableType", bound=Disableable)
34+
_WidgetType = TypeVar("_WidgetType", bound="Widget")
35+
36+
37+
def get_directed_distance(
38+
index: int, start: int, direction: Direction, wrap_at: int
39+
) -> int:
40+
"""Computes the distance going from `start` to `index` in the given direction.
41+
42+
Starting at `start`, this is the number of steps you need to take in the given
43+
`direction` to reach `index`, assuming there is wrapping at 0 and `wrap_at`.
44+
This is also the smallest non-negative integer solution `d` to
45+
`(start + d * direction) % wrap_at == index`.
46+
47+
The diagram below illustrates the computation of `d1 = distance(2, 8, 1, 10)` and
48+
`d2 = distance(2, 8, -1, 10)`:
49+
50+
```
51+
start ────────────────────┐
52+
index ────────┐ │
53+
indices 0 1 2 3 4 5 6 7 8 9
54+
d1 2 3 4 0 1
55+
> > > > > (direction == 1)
56+
d2 6 5 4 3 2 1 0
57+
< < < < < < < (direction == -1)
58+
```
59+
60+
Args:
61+
index: The index that we want to reach.
62+
start: The starting point to consider when computing the distance.
63+
direction: The direction in which we want to compute the distance.
64+
wrap_at: Controls at what point wrapping around takes place.
65+
66+
Returns:
67+
The computed distance.
68+
"""
69+
return direction * (index - start) % wrap_at
70+
71+
72+
def find_first_enabled(
73+
candidates: Sequence[_DisableableType] | Sequence[_WidgetType],
74+
) -> int | None:
75+
"""Find the first enabled candidate in a sequence of possibly-disabled objects.
76+
77+
Args:
78+
candidates: The sequence of candidates to consider.
79+
80+
Returns:
81+
The first enabled candidate or `None` if none were available.
82+
"""
83+
return next(
84+
(index for index, candidate in enumerate(candidates) if not candidate.disabled),
85+
None,
86+
)
87+
88+
89+
def find_last_enabled(
90+
candidates: Sequence[_DisableableType] | Sequence[_WidgetType],
91+
) -> int | None:
92+
"""Find the last enabled candidate in a sequence of possibly-disabled objects.
93+
94+
Args:
95+
candidates: The sequence of candidates to consider.
96+
97+
Returns:
98+
The last enabled candidate or `None` if none were available.
99+
"""
100+
total_candidates = len(candidates)
101+
return next(
102+
(
103+
total_candidates - offset_from_end
104+
for offset_from_end, candidate in enumerate(reversed(candidates), start=1)
105+
if not candidate.disabled
106+
),
107+
None,
108+
)
109+
110+
111+
def find_next_enabled(
112+
candidates: Sequence[_DisableableType] | Sequence[_WidgetType],
113+
anchor: int | None,
114+
direction: Direction,
115+
with_anchor: bool = False,
116+
) -> int | None:
117+
"""Find the next enabled object if we're currently at the given anchor.
118+
119+
The definition of "next" depends on the given direction and this function will wrap
120+
around the ends of the sequence of object candidates.
121+
122+
Args:
123+
candidates: The sequence of object candidates to consider.
124+
anchor: The point of the sequence from which we'll start looking for the next
125+
enabled object.
126+
direction: The direction in which to traverse the candidates when looking for
127+
the next enabled candidate.
128+
with_anchor: Consider the anchor position as the first valid position instead of
129+
the last one.
130+
131+
Returns:
132+
The next enabled object. If none are available, return the anchor.
133+
"""
134+
135+
if anchor is None:
136+
if candidates:
137+
return (
138+
find_first_enabled(candidates)
139+
if direction == 1
140+
else find_last_enabled(candidates)
141+
)
142+
return None
143+
144+
start = anchor + direction if not with_anchor else anchor
145+
key_function = partial(
146+
get_directed_distance,
147+
start=start,
148+
direction=direction,
149+
wrap_at=len(candidates),
150+
)
151+
enabled_candidates = [
152+
index for index, candidate in enumerate(candidates) if not candidate.disabled
153+
]
154+
return min(enabled_candidates, key=key_function, default=anchor)
155+
156+
157+
def find_next_enabled_no_wrap(
158+
candidates: Sequence[_DisableableType] | Sequence[_WidgetType],
159+
anchor: int | None,
160+
direction: Direction,
161+
with_anchor: bool = False,
162+
) -> int | None:
163+
"""Find the next enabled object starting from the given anchor (without wrapping).
164+
165+
The meaning of "next" and "past" depend on the direction specified.
166+
167+
Args:
168+
candidates: The sequence of object candidates to consider.
169+
anchor: The point of the sequence from which we'll start looking for the next
170+
enabled object.
171+
direction: The direction in which to traverse the candidates when looking for
172+
the next enabled candidate.
173+
with_anchor: Whether to consider the anchor or not.
174+
175+
Returns:
176+
The next enabled object. If none are available, return None.
177+
"""
178+
179+
if anchor is None:
180+
if candidates:
181+
return (
182+
find_first_enabled(candidates)
183+
if direction == 1
184+
else find_last_enabled(candidates)
185+
)
186+
return None
187+
188+
start = anchor if with_anchor else anchor + direction
189+
counter = count(start, direction)
190+
valid_candidates = (
191+
candidates[start:] if direction == 1 else reversed(candidates[: start + 1])
192+
)
193+
194+
for idx, candidate in zip(counter, valid_candidates):
195+
if candidate.disabled:
196+
continue
197+
return idx
198+
return None

src/textual/types.py

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
UnusedParameter,
1313
WatchCallbackType,
1414
)
15+
from ._widget_navigation import Direction
1516
from .actions import ActionParseResult
1617
from .css.styles import RenderStyles
1718
from .widgets._directory_tree import DirEntry
@@ -32,6 +33,7 @@
3233
"CSSPathError",
3334
"CSSPathType",
3435
"DirEntry",
36+
"Direction",
3537
"DuplicateID",
3638
"EasingFunction",
3739
"IgnoreReturnCallbackType",

src/textual/widgets/_list_item.py

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class ListItem(Widget, can_focus=False):
2525
background: $panel-lighten-1;
2626
overflow: hidden hidden;
2727
}
28+
ListItem > :disabled {
29+
background: $panel-darken-1;
30+
}
2831
ListItem > Widget :hover {
2932
background: $boost;
3033
}

src/textual/widgets/_list_view.py

+48-33
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
from typing_extensions import TypeGuard
66

7-
from textual.await_remove import AwaitRemove
8-
from textual.binding import Binding, BindingType
9-
from textual.containers import VerticalScroll
10-
from textual.events import Mount
11-
from textual.geometry import clamp
12-
from textual.message import Message
13-
from textual.reactive import reactive
14-
from textual.widget import AwaitMount, Widget
15-
from textual.widgets._list_item import ListItem
7+
from .. import _widget_navigation
8+
from ..await_remove import AwaitRemove
9+
from ..binding import Binding, BindingType
10+
from ..containers import VerticalScroll
11+
from ..events import Mount
12+
from ..message import Message
13+
from ..reactive import reactive
14+
from ..widget import AwaitMount
15+
from ..widgets._list_item import ListItem
1616

1717

1818
class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
@@ -38,7 +38,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
3838
| down | Move the cursor down. |
3939
"""
4040

41-
index = reactive[Optional[int]](0, always_update=True)
41+
index = reactive[Optional[int]](0, always_update=True, init=False)
4242
"""The index of the currently highlighted item."""
4343

4444
class Highlighted(Message):
@@ -117,7 +117,12 @@ def __init__(
117117
super().__init__(
118118
*children, name=name, id=id, classes=classes, disabled=disabled
119119
)
120-
self._index = initial_index
120+
# Set the index to the given initial index, or the first available index after.
121+
self._index = _widget_navigation.find_next_enabled(
122+
self._nodes,
123+
anchor=initial_index - 1 if initial_index is not None else None,
124+
direction=1,
125+
)
121126

122127
def _on_mount(self, _: Mount) -> None:
123128
"""Ensure the ListView is fully-settled after mounting."""
@@ -142,17 +147,17 @@ def validate_index(self, index: int | None) -> int | None:
142147
Returns:
143148
The clamped index.
144149
"""
145-
if not self._nodes or index is None:
150+
if index is None or not self._nodes:
146151
return None
147-
return self._clamp_index(index)
152+
elif index < 0:
153+
return 0
154+
elif index >= len(self._nodes):
155+
return len(self._nodes) - 1
148156

149-
def _clamp_index(self, index: int) -> int:
150-
"""Clamp the index to a valid value given the current list of children"""
151-
last_index = max(len(self._nodes) - 1, 0)
152-
return clamp(index, 0, last_index)
157+
return index
153158

154159
def _is_valid_index(self, index: int | None) -> TypeGuard[int]:
155-
"""Return True if the current index is valid given the current list of children"""
160+
"""Determine whether the current index is valid into the list of children."""
156161
if index is None:
157162
return False
158163
return 0 <= index < len(self._nodes)
@@ -164,16 +169,14 @@ def watch_index(self, old_index: int | None, new_index: int | None) -> None:
164169
assert isinstance(old_child, ListItem)
165170
old_child.highlighted = False
166171

167-
new_child: Widget | None
168-
if self._is_valid_index(new_index):
172+
if self._is_valid_index(new_index) and not self._nodes[new_index].disabled:
169173
new_child = self._nodes[new_index]
170174
assert isinstance(new_child, ListItem)
171175
new_child.highlighted = True
176+
self._scroll_highlighted_region()
177+
self.post_message(self.Highlighted(self, new_child))
172178
else:
173-
new_child = None
174-
175-
self._scroll_highlighted_region()
176-
self.post_message(self.Highlighted(self, new_child))
179+
self.post_message(self.Highlighted(self, None))
177180

178181
def extend(self, items: Iterable[ListItem]) -> AwaitMount:
179182
"""Append multiple new ListItems to the end of the ListView.
@@ -222,19 +225,30 @@ def action_select_cursor(self) -> None:
222225

223226
def action_cursor_down(self) -> None:
224227
"""Highlight the next item in the list."""
225-
if self.index is None:
226-
self.index = 0
227-
return
228-
self.index += 1
228+
candidate = _widget_navigation.find_next_enabled(
229+
self._nodes,
230+
anchor=self.index,
231+
direction=1,
232+
)
233+
if self.index is not None and candidate is not None and candidate < self.index:
234+
return # Avoid wrapping around.
235+
236+
self.index = candidate
229237

230238
def action_cursor_up(self) -> None:
231239
"""Highlight the previous item in the list."""
232-
if self.index is None:
233-
self.index = 0
234-
return
235-
self.index -= 1
240+
candidate = _widget_navigation.find_next_enabled(
241+
self._nodes,
242+
anchor=self.index,
243+
direction=-1,
244+
)
245+
if self.index is not None and candidate is not None and candidate > self.index:
246+
return # Avoid wrapping around.
247+
248+
self.index = candidate
236249

237250
def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
251+
event.stop()
238252
self.focus()
239253
self.index = self._nodes.index(event.item)
240254
self.post_message(self.Selected(self, event.item))
@@ -244,5 +258,6 @@ def _scroll_highlighted_region(self) -> None:
244258
if self.highlighted_child is not None:
245259
self.scroll_to_widget(self.highlighted_child, animate=False)
246260

247-
def __len__(self):
261+
def __len__(self) -> int:
262+
"""Compute the length (in number of items) of the list view."""
248263
return len(self._nodes)

0 commit comments

Comments
 (0)