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

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("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 ] 

34 

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

36 

37 @dataclass 

38 class Selected(Message): 

39 grid_select: GridSelect 

40 widget: Widget 

41 

42 @property 

43 def control(self) -> Widget: 

44 return self.grid_select 

45 

46 @dataclass 

47 class Highlighted(Message): 

48 grid_select: GridSelect 

49 widget: Widget 

50 

51 @property 

52 def control(self) -> Widget: 

53 return self.grid_select 

54 

55 @dataclass 

56 class LeaveUp(Message): 

57 grid_select: GridSelect 

58 

59 @dataclass 

60 class LeaveDown(Message): 

61 grid_select: GridSelect 

62 

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 ) 

80 

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 

86 

87 def highlight_first(self) -> None: 

88 self.highlighted = 0 

89 

90 def highlight_last(self) -> None: 

91 if self.children: 

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

93 

94 def on_focus(self) -> None: 

95 if self.highlighted is None: 

96 self.highlighted = 0 

97 self.reveal_highlight() 

98 

99 def on_blur(self) -> None: 

100 self.highlighted = None 

101 

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) 

111 

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

124 

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 

135 

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

148 

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

161 

162 def action_cursor_left(self) -> None: 

163 if self.highlighted is None: 

164 self.highlighted = 0 

165 else: 

166 self.highlighted -= 1 

167 

168 def action_cursor_right(self) -> None: 

169 if self.highlighted is None: 

170 self.highlighted = 0 

171 else: 

172 self.highlighted += 1 

173 

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

189 

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

198 

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

202 

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