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

1"""GridSelect: responsive grid with cursor navigation. 

2 

3Ported from toad (https://github.com/batrachianai/toad). 

4Extends Textual's ItemGrid with keyboard cursor, highlight class, and messages. 

5""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10from dataclasses import dataclass 

11from typing import ClassVar 

12 

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 

19 

20 

21class GridSelect(containers.ItemGrid, can_focus=True): 

22 """A responsive grid that supports arrow-key cursor navigation and selection.""" 

23 

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 ] 

36 

37 highlighted: reactive[int | None] = reactive(None) 

38 

39 @dataclass 

40 class Selected(Message): 

41 grid_select: GridSelect 

42 widget: Widget 

43 

44 @property 

45 def control(self) -> Widget: 

46 return self.grid_select 

47 

48 @dataclass 

49 class Highlighted(Message): 

50 grid_select: GridSelect 

51 widget: Widget 

52 

53 @property 

54 def control(self) -> Widget: 

55 return self.grid_select 

56 

57 @dataclass 

58 class LeaveUp(Message): 

59 grid_select: GridSelect 

60 

61 @dataclass 

62 class LeaveDown(Message): 

63 grid_select: GridSelect 

64 

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 ) 

82 

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 

88 

89 def highlight_first(self) -> None: 

90 self.highlighted = 0 

91 

92 def highlight_last(self) -> None: 

93 if self.children: 

94 self.highlighted = len(self.children) - 1 

95 

96 def on_focus(self) -> None: 

97 if self.highlighted is None: 

98 self.highlighted = 0 

99 self.reveal_highlight() 

100 

101 def on_blur(self) -> None: 

102 self.highlighted = None 

103 

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) 

113 

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() 

126 

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 

137 

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)) 

151 

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)) 

164 

165 def action_cursor_left(self) -> None: 

166 if self.highlighted is None: 

167 self.highlighted = 0 

168 else: 

169 self.highlighted -= 1 

170 

171 def action_cursor_right(self) -> None: 

172 if self.highlighted is None: 

173 self.highlighted = 0 

174 else: 

175 self.highlighted += 1 

176 

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() 

192 

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)) 

201 

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)) 

205 

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))