Coverage for src / lilbee / cli / tui / widgets / grid_select.py: 100%
136 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +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
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 self.post_message(self.LeaveUp(self))
139 return
140 if self.highlighted is None:
141 self.highlighted = 0
142 else:
143 width, _height = grid_size
144 if self.highlighted >= width:
145 self.highlighted -= width
146 else:
147 self.post_message(self.LeaveUp(self))
149 def action_cursor_down(self) -> None:
150 if (grid_size := self.grid_size) is None:
151 self.post_message(self.LeaveDown(self))
152 return
153 if self.highlighted is None:
154 self.highlighted = 0
155 else:
156 width, _height = grid_size
157 if self.highlighted + width < len(self.children):
158 self.highlighted += width
159 else:
160 self.post_message(self.LeaveDown(self))
162 def action_cursor_left(self) -> None:
163 if self.highlighted is None:
164 self.highlighted = 0
165 else:
166 self.highlighted -= 1
168 def action_cursor_right(self) -> None:
169 if self.highlighted is None:
170 self.highlighted = 0
171 else:
172 self.highlighted += 1
174 def on_click(self, event: events.Click) -> None:
175 if event.widget is None:
176 return
177 highlighted_widget: Widget | None = None
178 if self.highlighted is not None:
179 with contextlib.suppress(IndexError):
180 highlighted_widget = self.children[self.highlighted]
181 for widget in event.widget.ancestors_with_self:
182 if widget in self.children:
183 if highlighted_widget is not None and highlighted_widget is widget:
184 self.action_select()
185 else:
186 self.highlighted = self.children.index(widget)
187 break
188 self.focus()
190 def action_select(self) -> None:
191 if self.highlighted is not None:
192 try:
193 widget = self.children[self.highlighted]
194 except IndexError:
195 pass
196 else:
197 self.post_message(self.Selected(self, widget))
199 def action_tab_next(self) -> None:
200 """Tab escapes the grid. Within-grid navigation uses arrow keys."""
201 self.post_message(self.LeaveDown(self))
203 def action_tab_previous(self) -> None:
204 """Shift+Tab escapes the grid. Within-grid navigation uses arrow keys."""
205 self.post_message(self.LeaveUp(self))