Coverage for src/lilbee/cli/tui/widgets/grid_select.py: 100%
134 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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("enter", "select", "Select", show=False),
31 Binding("tab", "tab_next", "Tab Next", show=False),
32 Binding("shift+tab", "tab_previous", "Tab Previous", show=False),
33 ]
35 highlighted: reactive[int | None] = reactive(None)
37 @dataclass
38 class Selected(Message):
39 grid_select: GridSelect
40 widget: Widget
42 @property
43 def control(self) -> Widget:
44 return self.grid_select
46 @dataclass
47 class Highlighted(Message):
48 grid_select: GridSelect
49 widget: Widget
51 @property
52 def control(self) -> Widget:
53 return self.grid_select
55 @dataclass
56 class LeaveUp(Message):
57 grid_select: GridSelect
59 @dataclass
60 class LeaveDown(Message):
61 grid_select: GridSelect
63 def __init__(
64 self,
65 *children: Widget,
66 name: str | None = None,
67 id: str | None = None,
68 classes: str | None = None,
69 min_column_width: int = 30,
70 max_column_width: int | None = None,
71 ) -> None:
72 super().__init__(
73 *children,
74 name=name,
75 id=id,
76 classes=classes,
77 min_column_width=min_column_width,
78 max_column_width=max_column_width,
79 )
81 @property
82 def grid_size(self) -> tuple[int, int] | None:
83 if not isinstance(self.layout, GridLayout):
84 return None
85 return self.layout.grid_size
87 def highlight_first(self) -> None:
88 self.highlighted = 0
90 def highlight_last(self) -> None:
91 if self.children:
92 self.highlighted = len(self.children) - 1
94 def on_focus(self) -> None:
95 if self.highlighted is None:
96 self.highlighted = 0
97 self.reveal_highlight()
99 def on_blur(self) -> None:
100 self.highlighted = None
102 def reveal_highlight(self) -> None:
103 if self.highlighted is None:
104 return
105 try:
106 widget = self.children[self.highlighted]
107 except IndexError:
108 return
109 if not self.screen.can_view_entire(widget):
110 self.screen.scroll_to_center(widget, origin_visible=True)
112 def watch_highlighted(self, old_highlighted: int | None, highlighted: int | None) -> None:
113 if old_highlighted is not None:
114 with contextlib.suppress(IndexError):
115 self.children[old_highlighted].remove_class("-highlight")
116 if highlighted is not None:
117 try:
118 widget = self.children[highlighted]
119 widget.add_class("-highlight")
120 self.post_message(self.Highlighted(self, widget))
121 except IndexError:
122 pass # children mutated between validate_highlighted and watch_highlighted
123 self.reveal_highlight()
125 def validate_highlighted(self, highlighted: int | None) -> int | None:
126 if highlighted is None:
127 return None
128 if not self.children:
129 return None
130 if highlighted < 0:
131 return 0
132 if highlighted >= len(self.children):
133 return len(self.children) - 1
134 return highlighted
136 def action_cursor_up(self) -> None:
137 if (grid_size := self.grid_size) is None:
138 # Layout not arranged yet (e.g. right after a remount): stay put
139 # rather than throwing focus out of the grid on the first keypress.
140 return
141 if self.highlighted is None:
142 self.highlighted = 0
143 else:
144 width, _height = grid_size
145 if self.highlighted >= width:
146 self.highlighted -= width
147 else:
148 self.post_message(self.LeaveUp(self))
150 def action_cursor_down(self) -> None:
151 if (grid_size := self.grid_size) is None:
152 # Same stay-put contract as action_cursor_up.
153 return
154 if self.highlighted is None:
155 self.highlighted = 0
156 else:
157 width, _height = grid_size
158 if self.highlighted + width < len(self.children):
159 self.highlighted += width
160 else:
161 self.post_message(self.LeaveDown(self))
163 def action_cursor_left(self) -> None:
164 if self.highlighted is None:
165 self.highlighted = 0
166 else:
167 self.highlighted -= 1
169 def action_cursor_right(self) -> None:
170 if self.highlighted is None:
171 self.highlighted = 0
172 else:
173 self.highlighted += 1
175 def on_click(self, event: events.Click) -> None:
176 if event.widget is None:
177 return
178 highlighted_widget: Widget | None = None
179 if self.highlighted is not None:
180 with contextlib.suppress(IndexError):
181 highlighted_widget = self.children[self.highlighted]
182 for widget in event.widget.ancestors_with_self:
183 if widget in self.children:
184 if highlighted_widget is not None and highlighted_widget is widget:
185 self.action_select()
186 else:
187 self.highlighted = self.children.index(widget)
188 break
189 self.focus()
191 def action_select(self) -> None:
192 if self.highlighted is not None:
193 try:
194 widget = self.children[self.highlighted]
195 except IndexError:
196 pass
197 else:
198 self.post_message(self.Selected(self, widget))
200 def action_tab_next(self) -> None:
201 """Tab escapes the grid. Within-grid navigation uses arrow keys."""
202 self.post_message(self.LeaveDown(self))
204 def action_tab_previous(self) -> None:
205 """Shift+Tab escapes the grid. Within-grid navigation uses arrow keys."""
206 self.post_message(self.LeaveUp(self))