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
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-19 20:21 +0100
1"""Data tables with selectable rows."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import TYPE_CHECKING, ClassVar, Iterable, Iterator
8from textual.binding import Binding
9from textual.coordinate import Coordinate
10from textual.widgets import DataTable
11from textual.widgets.data_table import CellDoesNotExist, RowKey
13if TYPE_CHECKING:
14 from textual.app import App
17@dataclass
18class Checkbox:
19 """A checkbox, added to rows to make them selectable."""
21 checked: bool = False
23 def __str__(self) -> str:
24 return "■" if self.checked else ""
26 def __rich__(self) -> str:
27 return "[b]■[/]" if self.checked else ""
29 def check(self) -> None:
30 """Uncheck the checkbox."""
31 self.checked = True
33 def uncheck(self) -> None:
34 """Uncheck the checkbox."""
35 self.checked = False
37 def toggle(self) -> bool:
38 """Toggle the checkbox."""
39 self.checked = not self.checked
40 return self.checked
43@dataclass
44class SelectableRow:
45 """A selectable row."""
47 table: SelectableRowsDataTable
48 key: RowKey
50 @property
51 def app(self) -> App:
52 """Textual application."""
53 return self.table.app
55 @property
56 def _data(self) -> list:
57 return self.table.get_row(self.key)
59 @property
60 def data(self) -> list:
61 """Row data (without checkbox)."""
62 return self._data[1:]
64 @property
65 def index(self) -> int:
66 """Row index."""
67 return self.table.get_row_index(self.key)
69 @property
70 def checkbox(self) -> Checkbox:
71 """Row checkbox."""
72 return self._data[0]
74 def select(self) -> None:
75 """Select this row."""
76 self.checkbox.check()
78 def unselect(self) -> None:
79 """Unselect this row."""
80 self.checkbox.uncheck()
82 def toggle_select(self) -> bool:
83 """Toggle-select this row."""
84 return self.checkbox.toggle()
86 @property
87 def selected(self) -> bool:
88 """Whether this row is selected."""
89 return self.checkbox.checked
91 def remove(self) -> None:
92 """Remove row from the table."""
93 self.table.remove_row(self.key)
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)
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)
110class SelectableRowsDataTable(DataTable):
111 """Data table with selectable rows."""
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 ]
122 # --------------------------------------------------
123 # Textual methods.
124 # --------------------------------------------------
125 def add_rows(self, rows: Iterable[Iterable]) -> list[RowKey]:
126 """Add rows.
128 Automatically insert a column with checkboxes in position 0.
129 """
130 return super().add_rows((Checkbox(), *row) for row in rows)
132 def clear(self, columns: bool = True) -> SelectableRowsDataTable: # noqa: FBT001,FBT002
133 """Clear rows and optionally columns.
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
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()
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()
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()
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()
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()
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()
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)
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)
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