Coverage for src/lilbee/cli/tui/widgets/grid_select.py: 100%
134 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""GridSelect: responsive grid with cursor navigation.
3Ported from toad (https://github.com/batrachianai/toad).
4Extends Textual's ItemGrid with keyboard cursor, highlight class, and messages.
5"""
7from __future__ import annotations
9import contextlib
10from dataclasses import dataclass
11from typing import ClassVar
13from textual import containers, events
14from textual.binding import Binding, BindingType
15from textual.layouts.grid import GridLayout
16from textual.message import Message
17from textual.reactive import reactive
18from textual.widget import Widget
21class GridSelect(containers.ItemGrid, can_focus=True):
22 """A responsive grid that supports arrow-key cursor navigation and selection."""
24 FOCUS_ON_CLICK = False
25 BINDINGS: ClassVar[list[BindingType]] = [
26 Binding("up", "cursor_up", "Up", show=False),
27 Binding("down", "cursor_down", "Down", show=False),
28 Binding("left", "cursor_left", "Left", show=False),
29 Binding("right", "cursor_right", "Right", show=False),
30 Binding("h", "cursor_left", "Left", show=False),
31 Binding("l", "cursor_right", "Right", show=False),
32 Binding("enter", "select", "Select", show=False),
33 Binding("tab", "tab_next", "Tab Next", show=False),
34 Binding("shift+tab", "tab_previous", "Tab Previous", show=False),
35 ]
37 highlighted: reactive[int | None] = reactive(None)
39 @dataclass
40 class Selected(Message):
41 grid_select: GridSelect
42 widget: Widget
44 @property
45 def control(self) -> Widget:
46 return self.grid_select
48 @dataclass
49 class Highlighted(Message):
50 grid_select: GridSelect
51 widget: Widget
53 @property
54 def control(self) -> Widget:
55 return self.grid_select
57 @dataclass
58 class LeaveUp(Message):
59 grid_select: GridSelect
61 @dataclass
62 class LeaveDown(Message):
63 grid_select: GridSelect
65 def __init__(
66 self,
67 *children: Widget,
68 name: str | None = None,
69 id: str | None = None,
70 classes: str | None = None,
71 min_column_width: int = 30,
72 max_column_width: int | None = None,
73 ) -> None:
74 super().__init__(
75 *children,
76 name=name,
77 id=id,
78 classes=classes,
79 min_column_width=min_column_width,
80 max_column_width=max_column_width,
81 )
83 @property
84 def grid_size(self) -> tuple[int, int] | None:
85 if not isinstance(self.layout, GridLayout):
86 return None
87 return self.layout.grid_size
89 def highlight_first(self) -> None:
90 self.highlighted = 0
92 def highlight_last(self) -> None:
93 if self.children:
94 self.highlighted = len(self.children) - 1
96 def on_focus(self) -> None:
97 if self.highlighted is None:
98 self.highlighted = 0
99 self.reveal_highlight()
101 def on_blur(self) -> None:
102 self.highlighted = None
104 def reveal_highlight(self) -> None:
105 if self.highlighted is None:
106 return
107 try:
108 widget = self.children[self.highlighted]
109 except IndexError:
110 return
111 if not self.screen.can_view_entire(widget):
112 self.screen.scroll_to_center(widget, origin_visible=True)
114 def watch_highlighted(self, old_highlighted: int | None, highlighted: int | None) -> None:
115 if old_highlighted is not None:
116 with contextlib.suppress(IndexError):
117 self.children[old_highlighted].remove_class("-highlight")
118 if highlighted is not None:
119 try:
120 widget = self.children[highlighted]
121 widget.add_class("-highlight")
122 self.post_message(self.Highlighted(self, widget))
123 except IndexError:
124 pass # children mutated between validate_highlighted and watch_highlighted
125 self.reveal_highlight()
127 def validate_highlighted(self, highlighted: int | None) -> int | None:
128 if highlighted is None:
129 return None
130 if not self.children:
131 return None
132 if highlighted < 0:
133 return 0
134 if highlighted >= len(self.children):
135 return len(self.children) - 1
136 return highlighted
138 def action_cursor_up(self) -> None:
139 if (grid_size := self.grid_size) is None:
140 # Layout not arranged yet (e.g. right after a remount): stay put
141 # rather than throwing focus out of the grid on the first keypress.
142 return
143 if self.highlighted is None:
144 self.highlighted = 0
145 else:
146 width, _height = grid_size
147 if self.highlighted >= width:
148 self.highlighted -= width
149 else:
150 self.post_message(self.LeaveUp(self))
152 def action_cursor_down(self) -> None:
153 if (grid_size := self.grid_size) is None:
154 # Same stay-put contract as action_cursor_up.
155 return
156 if self.highlighted is None:
157 self.highlighted = 0
158 else:
159 width, _height = grid_size
160 if self.highlighted + width < len(self.children):
161 self.highlighted += width
162 else:
163 self.post_message(self.LeaveDown(self))
165 def action_cursor_left(self) -> None:
166 if self.highlighted is None:
167 self.highlighted = 0
168 else:
169 self.highlighted -= 1
171 def action_cursor_right(self) -> None:
172 if self.highlighted is None:
173 self.highlighted = 0
174 else:
175 self.highlighted += 1
177 def on_click(self, event: events.Click) -> None:
178 if event.widget is None:
179 return
180 highlighted_widget: Widget | None = None
181 if self.highlighted is not None:
182 with contextlib.suppress(IndexError):
183 highlighted_widget = self.children[self.highlighted]
184 for widget in event.widget.ancestors_with_self:
185 if widget in self.children:
186 if highlighted_widget is not None and highlighted_widget is widget:
187 self.action_select()
188 else:
189 self.highlighted = self.children.index(widget)
190 break
191 self.focus()
193 def action_select(self) -> None:
194 if self.highlighted is not None:
195 try:
196 widget = self.children[self.highlighted]
197 except IndexError:
198 pass
199 else:
200 self.post_message(self.Selected(self, widget))
202 def action_tab_next(self) -> None:
203 """Tab escapes the grid. Within-grid navigation uses arrow keys."""
204 self.post_message(self.LeaveDown(self))
206 def action_tab_previous(self) -> None:
207 """Shift+Tab escapes the grid. Within-grid navigation uses arrow keys."""
208 self.post_message(self.LeaveUp(self))