Coverage for src/devboard/datatable.py: 53.85%

124 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-19 20:21 +0100

1"""Data tables with selectable rows.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import TYPE_CHECKING, ClassVar, Iterable, Iterator 

7 

8from textual.binding import Binding 

9from textual.coordinate import Coordinate 

10from textual.widgets import DataTable 

11from textual.widgets.data_table import CellDoesNotExist, RowKey 

12 

13if TYPE_CHECKING: 

14 from textual.app import App 

15 

16 

17@dataclass 

18class Checkbox: 

19 """A checkbox, added to rows to make them selectable.""" 

20 

21 checked: bool = False 

22 

23 def __str__(self) -> str: 

24 return "■" if self.checked else "" 

25 

26 def __rich__(self) -> str: 

27 return "[b]■[/]" if self.checked else "" 

28 

29 def check(self) -> None: 

30 """Uncheck the checkbox.""" 

31 self.checked = True 

32 

33 def uncheck(self) -> None: 

34 """Uncheck the checkbox.""" 

35 self.checked = False 

36 

37 def toggle(self) -> bool: 

38 """Toggle the checkbox.""" 

39 self.checked = not self.checked 

40 return self.checked 

41 

42 

43@dataclass 

44class SelectableRow: 

45 """A selectable row.""" 

46 

47 table: SelectableRowsDataTable 

48 key: RowKey 

49 

50 @property 

51 def app(self) -> App: 

52 """Textual application.""" 

53 return self.table.app 

54 

55 @property 

56 def _data(self) -> list: 

57 return self.table.get_row(self.key) 

58 

59 @property 

60 def data(self) -> list: 

61 """Row data (without checkbox).""" 

62 return self._data[1:] 

63 

64 @property 

65 def index(self) -> int: 

66 """Row index.""" 

67 return self.table.get_row_index(self.key) 

68 

69 @property 

70 def checkbox(self) -> Checkbox: 

71 """Row checkbox.""" 

72 return self._data[0] 

73 

74 def select(self) -> None: 

75 """Select this row.""" 

76 self.checkbox.check() 

77 

78 def unselect(self) -> None: 

79 """Unselect this row.""" 

80 self.checkbox.uncheck() 

81 

82 def toggle_select(self) -> bool: 

83 """Toggle-select this row.""" 

84 return self.checkbox.toggle() 

85 

86 @property 

87 def selected(self) -> bool: 

88 """Whether this row is selected.""" 

89 return self.checkbox.checked 

90 

91 def remove(self) -> None: 

92 """Remove row from the table.""" 

93 self.table.remove_row(self.key) 

94 

95 @property 

96 def previous(self) -> SelectableRow: 

97 """Previous row (up).""" 

98 new_coord = Coordinate(self.index - 1, 0) 

99 key = self.table.coordinate_to_cell_key(new_coord).row_key 

100 return self.__class__(table=self.table, key=key) 

101 

102 @property 

103 def next(self) -> SelectableRow: 

104 """Next row (down).""" 

105 new_coord = Coordinate(self.index + 1, 0) 

106 key = self.table.coordinate_to_cell_key(new_coord).row_key 

107 return self.__class__(table=self.table, key=key) 

108 

109 

110class SelectableRowsDataTable(DataTable): 

111 """Data table with selectable rows.""" 

112 

113 ROW = SelectableRow 

114 BINDINGS: ClassVar = [ 

115 Binding("space", "toggle_select_row", "Toggle select", show=False), 

116 Binding("ctrl+a, *", "toggle_select_all", "Toggle select all", show=False), 

117 Binding("exclamation_mark", "reverse_select", "Reverse select", show=False), 

118 Binding("shift+up", "toggle_select_up", "Expand select up", show=False), 

119 Binding("shift+down", "toggle_select_down", "Expand select down", show=False), 

120 ] 

121 

122 # -------------------------------------------------- 

123 # Textual methods. 

124 # -------------------------------------------------- 

125 def add_rows(self, rows: Iterable[Iterable]) -> list[RowKey]: 

126 """Add rows. 

127 

128 Automatically insert a column with checkboxes in position 0. 

129 """ 

130 return super().add_rows((Checkbox(), *row) for row in rows) 

131 

132 def clear(self, columns: bool = True) -> SelectableRowsDataTable: # noqa: FBT001,FBT002 

133 """Clear rows and optionally columns. 

134 

135 When clearing columns, automatically re-add a column for checkboxes. 

136 """ 

137 super().clear(columns) 

138 if columns: 138 ↛ 140line 138 didn't jump to line 140, because the condition on line 138 was never false

139 self.add_column("", key="checkbox") 

140 return self 

141 

142 # -------------------------------------------------- 

143 # Binding actions. 

144 # -------------------------------------------------- 

145 def action_toggle_select_row(self) -> None: 

146 """Toggle-select current row.""" 

147 try: 

148 row = self.current_row 

149 except CellDoesNotExist: 

150 return 

151 row.toggle_select() 

152 self.force_refresh() 

153 

154 def action_toggle_select_all(self) -> None: 

155 """Toggle-select all rows.""" 

156 rows = list(self.selectable_rows) 

157 if all(row.selected for row in rows): 

158 for row in rows: 

159 row.unselect() 

160 else: 

161 for row in rows: 

162 row.select() 

163 self.force_refresh() 

164 

165 def action_reverse_select(self) -> None: 

166 """Reverse selection.""" 

167 for row in self.selectable_rows: 

168 row.toggle_select() 

169 self.force_refresh() 

170 

171 def action_toggle_select_up(self) -> None: 

172 """Toggle selection up.""" 

173 try: 

174 row = self.current_row 

175 previous_row = row.previous 

176 except CellDoesNotExist: 

177 pass 

178 else: 

179 previous_row.toggle_select() 

180 self.move_cursor(row=previous_row.index) 

181 self.force_refresh() 

182 

183 def action_toggle_select_down(self) -> None: 

184 """Toggle selection down.""" 

185 try: 

186 row = self.current_row 

187 next_row = row.next 

188 except CellDoesNotExist: 

189 pass 

190 else: 

191 next_row.toggle_select() 

192 self.move_cursor(row=next_row.index) 

193 self.force_refresh() 

194 

195 # -------------------------------------------------- 

196 # Additional methods/properties. 

197 # -------------------------------------------------- 

198 def force_refresh(self) -> None: 

199 """Force refresh table.""" 

200 # HACK: Without such increment, the table is refreshed 

201 # only when focus changes to another column. 

202 self._update_count += 1 

203 self.refresh() 

204 

205 @property 

206 def current_row(self) -> SelectableRow: 

207 """Currently selected row.""" 

208 key = self.coordinate_to_cell_key(self.cursor_coordinate).row_key 

209 return self.ROW(table=self, key=key) 

210 

211 @property 

212 def selectable_rows(self) -> Iterator[SelectableRow]: 

213 """Rows, as selectable ones.""" 

214 for key in self.rows: 

215 yield self.ROW(table=self, key=key) 

216 

217 @property 

218 def selected_rows(self) -> Iterator[SelectableRow]: 

219 """Selected rows.""" 

220 for row in self.selectable_rows: 

221 if row.selected: 

222 yield row