CTkDataTable 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CTkDataTable/__init__.py +17 -0
- CTkDataTable/_utils.py +58 -0
- CTkDataTable/ctk_data_table.py +1659 -0
- CTkDataTable/examples/__init__.py +1 -0
- CTkDataTable/examples/basic_table.py +208 -0
- CTkDataTable/py.typed +1 -0
- CTkDataTable/table_column.py +446 -0
- CTkDataTable/table_events.py +18 -0
- CTkDataTable/table_model.py +603 -0
- CTkDataTable/table_renderer.py +1239 -0
- CTkDataTable/table_style.py +210 -0
- ctkdatatable-0.1.0.dist-info/METADATA +681 -0
- ctkdatatable-0.1.0.dist-info/RECORD +16 -0
- ctkdatatable-0.1.0.dist-info/WHEEL +5 -0
- ctkdatatable-0.1.0.dist-info/licenses/LICENSE +21 -0
- ctkdatatable-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Non-visual data model for CTkDataTable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_left
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._utils import normalize_row, normalize_rows, parse_datetime
|
|
11
|
+
from .table_column import TableColumn
|
|
12
|
+
|
|
13
|
+
RowData = dict[str, Any]
|
|
14
|
+
ColumnFilter = Mapping[str, Any] | Callable[[Any, RowData], bool]
|
|
15
|
+
CompiledColumnFilter = Callable[[RowData], bool]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TableModel:
|
|
19
|
+
"""Own row data, sorting, filtering, and selection without tkinter state."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, columns: Sequence[TableColumn], data: Iterable[Any] = ()) -> None:
|
|
22
|
+
self._columns = tuple(columns)
|
|
23
|
+
self._source_data: list[RowData] = []
|
|
24
|
+
self._view_indices: list[int] = []
|
|
25
|
+
self._view_index_by_source: dict[int, int] = {}
|
|
26
|
+
self._sort_state: tuple[str, bool] | None = None
|
|
27
|
+
self._filter_query = ""
|
|
28
|
+
self._column_filters: dict[str, ColumnFilter] = {}
|
|
29
|
+
self._compiled_column_filters: dict[str, CompiledColumnFilter] = {}
|
|
30
|
+
self._search_text_cache: dict[int, str] = {}
|
|
31
|
+
self._selected_source_indices: set[int] = set()
|
|
32
|
+
self._selection_anchor_source_index: int | None = None
|
|
33
|
+
self.set_data(data)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def source_data(self) -> list[RowData]:
|
|
37
|
+
"""Return the internal source rows for render-time reads."""
|
|
38
|
+
|
|
39
|
+
return self._source_data
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def view_indices(self) -> list[int]:
|
|
43
|
+
"""Return source indices in their current filtered and sorted order."""
|
|
44
|
+
|
|
45
|
+
return self._view_indices
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def sort_state(self) -> tuple[str, bool] | None:
|
|
49
|
+
"""Return the active sort key and direction."""
|
|
50
|
+
|
|
51
|
+
return self._sort_state
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def filter_query(self) -> str:
|
|
55
|
+
"""Return the active filter query."""
|
|
56
|
+
|
|
57
|
+
return self._filter_query
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def column_filters(self) -> dict[str, ColumnFilter]:
|
|
61
|
+
"""Return a shallow copy of active column filters."""
|
|
62
|
+
|
|
63
|
+
return dict(self._column_filters)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def selected_source_indices(self) -> frozenset[int]:
|
|
67
|
+
"""Return selected source-row indices."""
|
|
68
|
+
|
|
69
|
+
return frozenset(self._selected_source_indices)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def selection_anchor_source_index(self) -> int | None:
|
|
73
|
+
"""Return the source index used as the current range-selection anchor."""
|
|
74
|
+
|
|
75
|
+
return self._selection_anchor_source_index
|
|
76
|
+
|
|
77
|
+
def set_data(self, data: Iterable[Any]) -> None:
|
|
78
|
+
"""Replace all rows and clear selection."""
|
|
79
|
+
|
|
80
|
+
self._source_data = normalize_rows(data)
|
|
81
|
+
self._search_text_cache.clear()
|
|
82
|
+
self.clear_selection()
|
|
83
|
+
self._rebuild_view()
|
|
84
|
+
|
|
85
|
+
def set_columns(
|
|
86
|
+
self,
|
|
87
|
+
columns: Sequence[TableColumn],
|
|
88
|
+
*,
|
|
89
|
+
rebuild: bool = True,
|
|
90
|
+
clear_search_cache: bool = True,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Replace column definitions while preserving rows and compatible state."""
|
|
93
|
+
|
|
94
|
+
self._columns = tuple(columns)
|
|
95
|
+
column_keys = {column.key for column in self._columns}
|
|
96
|
+
if self._sort_state is not None and self._sort_state[0] not in column_keys:
|
|
97
|
+
self._sort_state = None
|
|
98
|
+
self._column_filters = {
|
|
99
|
+
column_key: definition
|
|
100
|
+
for column_key, definition in self._column_filters.items()
|
|
101
|
+
if column_key in column_keys
|
|
102
|
+
}
|
|
103
|
+
self._compiled_column_filters = {
|
|
104
|
+
column_key: self._compile_column_filter(column_key, definition)
|
|
105
|
+
for column_key, definition in self._column_filters.items()
|
|
106
|
+
}
|
|
107
|
+
if clear_search_cache:
|
|
108
|
+
self._search_text_cache.clear()
|
|
109
|
+
if rebuild:
|
|
110
|
+
self._rebuild_view()
|
|
111
|
+
self.prune_selection_to_view()
|
|
112
|
+
|
|
113
|
+
def get_data(self) -> list[RowData]:
|
|
114
|
+
"""Return shallow copies of all source rows."""
|
|
115
|
+
|
|
116
|
+
return [dict(row) for row in self._source_data]
|
|
117
|
+
|
|
118
|
+
def get_visible_rows(self) -> list[RowData]:
|
|
119
|
+
"""Return shallow copies of rows in the current filtered and sorted view."""
|
|
120
|
+
|
|
121
|
+
return [dict(self._source_data[index]) for index in self._view_indices]
|
|
122
|
+
|
|
123
|
+
def clear(self) -> None:
|
|
124
|
+
"""Remove all rows."""
|
|
125
|
+
|
|
126
|
+
self.set_data([])
|
|
127
|
+
|
|
128
|
+
def sort_by(self, column_key: str, ascending: bool = True) -> None:
|
|
129
|
+
"""Sort visible rows by a column key."""
|
|
130
|
+
|
|
131
|
+
self.require_column(column_key)
|
|
132
|
+
self._sort_state = (column_key, bool(ascending))
|
|
133
|
+
self._rebuild_view()
|
|
134
|
+
|
|
135
|
+
def search(self, query: str) -> set[int]:
|
|
136
|
+
"""Filter rows across visible, non-action columns and return selection changes."""
|
|
137
|
+
|
|
138
|
+
self._filter_query = str(query)
|
|
139
|
+
self._rebuild_view()
|
|
140
|
+
return self.prune_selection_to_view()
|
|
141
|
+
|
|
142
|
+
def set_column_filter(self, column_key: str, definition: ColumnFilter) -> set[int]:
|
|
143
|
+
"""Set a filter for one column and return changed selection indices."""
|
|
144
|
+
|
|
145
|
+
self.require_column(column_key)
|
|
146
|
+
if not callable(definition) and not isinstance(definition, Mapping):
|
|
147
|
+
raise TypeError("Column filter definitions must be mappings or callables.")
|
|
148
|
+
compiled_filter = self._compile_column_filter(column_key, definition)
|
|
149
|
+
self._column_filters[column_key] = definition
|
|
150
|
+
self._compiled_column_filters[column_key] = compiled_filter
|
|
151
|
+
self._rebuild_view()
|
|
152
|
+
return self.prune_selection_to_view()
|
|
153
|
+
|
|
154
|
+
def clear_column_filter(self, column_key: str) -> set[int]:
|
|
155
|
+
"""Clear one column filter and return changed selection indices."""
|
|
156
|
+
|
|
157
|
+
self.require_column(column_key)
|
|
158
|
+
self._column_filters.pop(column_key, None)
|
|
159
|
+
self._compiled_column_filters.pop(column_key, None)
|
|
160
|
+
self._rebuild_view()
|
|
161
|
+
return self.prune_selection_to_view()
|
|
162
|
+
|
|
163
|
+
def clear_column_filters(self) -> set[int]:
|
|
164
|
+
"""Clear all column filters and return changed selection indices."""
|
|
165
|
+
|
|
166
|
+
self._column_filters.clear()
|
|
167
|
+
self._compiled_column_filters.clear()
|
|
168
|
+
self._rebuild_view()
|
|
169
|
+
return self.prune_selection_to_view()
|
|
170
|
+
|
|
171
|
+
def filter(self, query: str) -> set[int]:
|
|
172
|
+
"""Alias for :meth:`search`."""
|
|
173
|
+
|
|
174
|
+
return self.search(query)
|
|
175
|
+
|
|
176
|
+
def add_row(self, row: Any) -> int:
|
|
177
|
+
"""Append one row and return its source index."""
|
|
178
|
+
|
|
179
|
+
self._source_data.append(normalize_row(row))
|
|
180
|
+
self._rebuild_view()
|
|
181
|
+
return len(self._source_data) - 1
|
|
182
|
+
|
|
183
|
+
def add_rows(self, rows: Iterable[Any]) -> list[int]:
|
|
184
|
+
"""Append multiple rows in a single rebuild and return their source indices."""
|
|
185
|
+
|
|
186
|
+
start = len(self._source_data)
|
|
187
|
+
self._source_data.extend(normalize_row(row) for row in rows)
|
|
188
|
+
self._rebuild_view()
|
|
189
|
+
return list(range(start, len(self._source_data)))
|
|
190
|
+
|
|
191
|
+
def update_row(self, index: int, row: Any) -> None:
|
|
192
|
+
"""Replace a source row."""
|
|
193
|
+
|
|
194
|
+
self.validate_source_index(index)
|
|
195
|
+
self._source_data[index] = normalize_row(row)
|
|
196
|
+
self._search_text_cache.pop(index, None)
|
|
197
|
+
self._rebuild_view()
|
|
198
|
+
self._drop_invalid_selection()
|
|
199
|
+
|
|
200
|
+
def delete_row(self, index: int) -> None:
|
|
201
|
+
"""Delete a row by source-data index and shift selection."""
|
|
202
|
+
|
|
203
|
+
self.validate_source_index(index)
|
|
204
|
+
del self._source_data[index]
|
|
205
|
+
|
|
206
|
+
shifted_selection: set[int] = set()
|
|
207
|
+
for selected_index in self._selected_source_indices:
|
|
208
|
+
if selected_index == index:
|
|
209
|
+
continue
|
|
210
|
+
shifted_selection.add(selected_index - 1 if selected_index > index else selected_index)
|
|
211
|
+
self._selected_source_indices = shifted_selection
|
|
212
|
+
|
|
213
|
+
if self._selection_anchor_source_index == index:
|
|
214
|
+
self._selection_anchor_source_index = None
|
|
215
|
+
elif self._selection_anchor_source_index is not None and self._selection_anchor_source_index > index:
|
|
216
|
+
self._selection_anchor_source_index -= 1
|
|
217
|
+
|
|
218
|
+
self._search_text_cache.clear()
|
|
219
|
+
self._rebuild_view()
|
|
220
|
+
self._drop_invalid_selection()
|
|
221
|
+
|
|
222
|
+
def delete_rows(self, indices: Iterable[int]) -> int:
|
|
223
|
+
"""Delete multiple source rows in a single rebuild and return the number removed."""
|
|
224
|
+
|
|
225
|
+
unique_indices = sorted(set(indices))
|
|
226
|
+
if not unique_indices:
|
|
227
|
+
return 0
|
|
228
|
+
for index in unique_indices:
|
|
229
|
+
self.validate_source_index(index)
|
|
230
|
+
indices_set = set(unique_indices)
|
|
231
|
+
|
|
232
|
+
for index in reversed(unique_indices):
|
|
233
|
+
del self._source_data[index]
|
|
234
|
+
|
|
235
|
+
self._search_text_cache.clear()
|
|
236
|
+
new_selection: set[int] = set()
|
|
237
|
+
for si in self._selected_source_indices:
|
|
238
|
+
if si in indices_set:
|
|
239
|
+
continue
|
|
240
|
+
shift = bisect_left(unique_indices, si)
|
|
241
|
+
new_selection.add(si - shift)
|
|
242
|
+
self._selected_source_indices = new_selection
|
|
243
|
+
|
|
244
|
+
if self._selection_anchor_source_index in indices_set:
|
|
245
|
+
self._selection_anchor_source_index = None
|
|
246
|
+
elif self._selection_anchor_source_index is not None:
|
|
247
|
+
shift = bisect_left(unique_indices, self._selection_anchor_source_index)
|
|
248
|
+
self._selection_anchor_source_index -= shift
|
|
249
|
+
|
|
250
|
+
self._rebuild_view()
|
|
251
|
+
self._drop_invalid_selection()
|
|
252
|
+
return len(unique_indices)
|
|
253
|
+
|
|
254
|
+
def delete_row_by_key(self, column_key: str, value: Any) -> bool:
|
|
255
|
+
"""Delete the first row whose column value matches value."""
|
|
256
|
+
|
|
257
|
+
index = self.find_source_index(column_key, value)
|
|
258
|
+
if index is None:
|
|
259
|
+
return False
|
|
260
|
+
self.delete_row(index)
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def delete_row_where(self, column_key: str, value: Any) -> bool:
|
|
264
|
+
"""Alias for :meth:`delete_row_by_key`."""
|
|
265
|
+
|
|
266
|
+
return self.delete_row_by_key(column_key, value)
|
|
267
|
+
|
|
268
|
+
def update_row_where(self, column_key: str, value: Any, new_row: Any) -> bool:
|
|
269
|
+
"""Update the first row whose column matches value. Returns True if found."""
|
|
270
|
+
|
|
271
|
+
index = self.find_source_index(column_key, value)
|
|
272
|
+
if index is None:
|
|
273
|
+
return False
|
|
274
|
+
self.update_row(index, new_row)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def find_source_index(self, column_key: str, value: Any) -> int | None:
|
|
278
|
+
"""Return the first source index whose column value matches value."""
|
|
279
|
+
|
|
280
|
+
self.require_column(column_key)
|
|
281
|
+
for index, row in enumerate(self._source_data):
|
|
282
|
+
if row.get(column_key) == value:
|
|
283
|
+
return index
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def get_row(self, source_index: int) -> RowData:
|
|
287
|
+
"""Return a shallow copy of one source row."""
|
|
288
|
+
|
|
289
|
+
self.validate_source_index(source_index)
|
|
290
|
+
return dict(self._source_data[source_index])
|
|
291
|
+
|
|
292
|
+
def row_for_view_index(self, view_index: int) -> RowData:
|
|
293
|
+
"""Return the internal row for a view index."""
|
|
294
|
+
|
|
295
|
+
self.validate_view_index(view_index)
|
|
296
|
+
return self._source_data[self._view_indices[view_index]]
|
|
297
|
+
|
|
298
|
+
def source_index_for_view_index(self, view_index: int) -> int:
|
|
299
|
+
"""Return source index for a view index."""
|
|
300
|
+
|
|
301
|
+
self.validate_view_index(view_index)
|
|
302
|
+
return self._view_indices[view_index]
|
|
303
|
+
|
|
304
|
+
def view_index_for_source_index(self, source_index: int) -> int | None:
|
|
305
|
+
"""Return visible view index for a source index, if present."""
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
return self._view_index_by_source[source_index]
|
|
309
|
+
except KeyError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def get_selected_rows(self, *, visible_only: bool = False) -> list[RowData]:
|
|
313
|
+
"""Return selected rows as shallow copies."""
|
|
314
|
+
|
|
315
|
+
return [dict(self._source_data[index]) for index in self.get_selected_source_indices(visible_only=visible_only)]
|
|
316
|
+
|
|
317
|
+
def get_selected_source_indices(self, *, visible_only: bool = False) -> list[int]:
|
|
318
|
+
"""Return selected source indices in current view order."""
|
|
319
|
+
|
|
320
|
+
ordered_indices = [index for index in self._view_indices if index in self._selected_source_indices]
|
|
321
|
+
if visible_only:
|
|
322
|
+
return ordered_indices
|
|
323
|
+
hidden_selected = sorted(self._selected_source_indices.difference(ordered_indices))
|
|
324
|
+
return [index for index in ordered_indices + hidden_selected if 0 <= index < len(self._source_data)]
|
|
325
|
+
|
|
326
|
+
def get_selected_view_indices(self) -> list[int]:
|
|
327
|
+
"""Return selected rows as current view indices."""
|
|
328
|
+
|
|
329
|
+
return [
|
|
330
|
+
view_index
|
|
331
|
+
for view_index, source_index in enumerate(self._view_indices)
|
|
332
|
+
if source_index in self._selected_source_indices
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
def clear_selection(self) -> None:
|
|
336
|
+
"""Clear selected rows and range anchor."""
|
|
337
|
+
|
|
338
|
+
self._selected_source_indices.clear()
|
|
339
|
+
self._selection_anchor_source_index = None
|
|
340
|
+
|
|
341
|
+
def prune_selection_to_view(self) -> set[int]:
|
|
342
|
+
"""Remove selections that are no longer visible and return changed indices."""
|
|
343
|
+
|
|
344
|
+
old_selection = set(self._selected_source_indices)
|
|
345
|
+
visible = set(self._view_indices)
|
|
346
|
+
self._selected_source_indices.intersection_update(visible)
|
|
347
|
+
if self._selection_anchor_source_index not in self._selected_source_indices:
|
|
348
|
+
self._selection_anchor_source_index = None
|
|
349
|
+
return old_selection.symmetric_difference(self._selected_source_indices)
|
|
350
|
+
|
|
351
|
+
def select_view_index(
|
|
352
|
+
self,
|
|
353
|
+
view_index: int,
|
|
354
|
+
*,
|
|
355
|
+
multi_select: bool = False,
|
|
356
|
+
shift: bool = False,
|
|
357
|
+
control: bool = False,
|
|
358
|
+
) -> set[int]:
|
|
359
|
+
"""Select a visible row and return changed source indices."""
|
|
360
|
+
|
|
361
|
+
self.validate_view_index(view_index)
|
|
362
|
+
source_index = self._view_indices[view_index]
|
|
363
|
+
old_selection = set(self._selected_source_indices)
|
|
364
|
+
anchor_view_index = (
|
|
365
|
+
self._view_index_by_source.get(self._selection_anchor_source_index)
|
|
366
|
+
if self._selection_anchor_source_index is not None
|
|
367
|
+
else None
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if multi_select and shift and anchor_view_index is not None:
|
|
371
|
+
start = min(anchor_view_index, view_index)
|
|
372
|
+
end = max(anchor_view_index, view_index)
|
|
373
|
+
self._selected_source_indices = set(self._view_indices[start : end + 1])
|
|
374
|
+
elif multi_select and control:
|
|
375
|
+
if source_index in self._selected_source_indices:
|
|
376
|
+
self._selected_source_indices.remove(source_index)
|
|
377
|
+
else:
|
|
378
|
+
self._selected_source_indices.add(source_index)
|
|
379
|
+
self._selection_anchor_source_index = source_index
|
|
380
|
+
else:
|
|
381
|
+
self._selected_source_indices = {source_index}
|
|
382
|
+
self._selection_anchor_source_index = source_index
|
|
383
|
+
|
|
384
|
+
return old_selection.symmetric_difference(self._selected_source_indices)
|
|
385
|
+
|
|
386
|
+
def focused_view_index(self) -> int | None:
|
|
387
|
+
"""Return the best current view index for keyboard navigation."""
|
|
388
|
+
|
|
389
|
+
if self._selection_anchor_source_index is not None:
|
|
390
|
+
view_index = self.view_index_for_source_index(self._selection_anchor_source_index)
|
|
391
|
+
if view_index is not None:
|
|
392
|
+
return view_index
|
|
393
|
+
|
|
394
|
+
selected = self.get_selected_source_indices(visible_only=True)
|
|
395
|
+
if selected:
|
|
396
|
+
return self.view_index_for_source_index(selected[0])
|
|
397
|
+
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def visible_columns(self) -> list[TableColumn]:
|
|
401
|
+
"""Return columns currently included in the view."""
|
|
402
|
+
|
|
403
|
+
return [column for column in self._columns if column.visible]
|
|
404
|
+
|
|
405
|
+
def require_column(self, column_key: str) -> TableColumn:
|
|
406
|
+
"""Return a column or raise a clear error."""
|
|
407
|
+
|
|
408
|
+
for column in self._columns:
|
|
409
|
+
if column.key == column_key:
|
|
410
|
+
return column
|
|
411
|
+
raise KeyError(f"Unknown column key '{column_key}'.")
|
|
412
|
+
|
|
413
|
+
def validate_source_index(self, index: int) -> None:
|
|
414
|
+
"""Raise when source index is outside the data list."""
|
|
415
|
+
|
|
416
|
+
if index < 0 or index >= len(self._source_data):
|
|
417
|
+
raise IndexError(f"Row index {index} is out of range.")
|
|
418
|
+
|
|
419
|
+
def validate_view_index(self, index: int) -> None:
|
|
420
|
+
"""Raise when view index is outside the current filtered view."""
|
|
421
|
+
|
|
422
|
+
if index < 0 or index >= len(self._view_indices):
|
|
423
|
+
raise IndexError(f"View row index {index} is out of range.")
|
|
424
|
+
|
|
425
|
+
def _rebuild_view(self) -> None:
|
|
426
|
+
search_query = self._filter_query.strip().casefold()
|
|
427
|
+
if search_query or self._compiled_column_filters:
|
|
428
|
+
self._view_indices = [
|
|
429
|
+
index
|
|
430
|
+
for index, row in enumerate(self._source_data)
|
|
431
|
+
if self._row_matches_search(index, row, search_query) and self._row_matches_column_filters(row)
|
|
432
|
+
]
|
|
433
|
+
else:
|
|
434
|
+
self._view_indices = list(range(len(self._source_data)))
|
|
435
|
+
|
|
436
|
+
if self._sort_state is not None:
|
|
437
|
+
column_key, ascending = self._sort_state
|
|
438
|
+
self._view_indices = self._sorted_indices(self._view_indices, column_key, ascending)
|
|
439
|
+
|
|
440
|
+
self._view_index_by_source = {
|
|
441
|
+
source_index: view_index for view_index, source_index in enumerate(self._view_indices)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
def _row_matches_search(self, source_index: int, row: RowData, normalized_query: str) -> bool:
|
|
445
|
+
if not normalized_query:
|
|
446
|
+
return True
|
|
447
|
+
text = self._search_text_cache.get(source_index)
|
|
448
|
+
if text is None:
|
|
449
|
+
text = self._search_text(row)
|
|
450
|
+
self._search_text_cache[source_index] = text
|
|
451
|
+
return normalized_query in text
|
|
452
|
+
|
|
453
|
+
def _search_text(self, row: RowData) -> str:
|
|
454
|
+
values: list[str] = []
|
|
455
|
+
for column in self.visible_columns():
|
|
456
|
+
if column.type == "action":
|
|
457
|
+
continue
|
|
458
|
+
value = row.get(column.key)
|
|
459
|
+
if value is not None:
|
|
460
|
+
values.append(str(value).casefold())
|
|
461
|
+
return "\n".join(values)
|
|
462
|
+
|
|
463
|
+
def _row_matches_column_filters(self, row: RowData) -> bool:
|
|
464
|
+
for column_key, predicate in self._compiled_column_filters.items():
|
|
465
|
+
try:
|
|
466
|
+
if not predicate(row):
|
|
467
|
+
return False
|
|
468
|
+
except Exception as error:
|
|
469
|
+
raise RuntimeError(f"Column filter for '{column_key}' failed: {error}") from error
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
def _compile_column_filter(self, column_key: str, definition: ColumnFilter) -> CompiledColumnFilter:
|
|
473
|
+
if callable(definition):
|
|
474
|
+
def callable_filter(row: RowData) -> bool:
|
|
475
|
+
return bool(definition(row.get(column_key), row))
|
|
476
|
+
|
|
477
|
+
return callable_filter
|
|
478
|
+
|
|
479
|
+
filter_type = str(definition.get("type", "contains")).casefold()
|
|
480
|
+
if filter_type == "contains":
|
|
481
|
+
needle = str(definition.get("value", "")).casefold()
|
|
482
|
+
|
|
483
|
+
def contains_filter(row: RowData) -> bool:
|
|
484
|
+
return needle in str(row.get(column_key) or "").casefold()
|
|
485
|
+
|
|
486
|
+
return contains_filter
|
|
487
|
+
if filter_type == "equals":
|
|
488
|
+
expected = definition.get("value")
|
|
489
|
+
|
|
490
|
+
def equals_filter(row: RowData) -> bool:
|
|
491
|
+
return row.get(column_key) == expected
|
|
492
|
+
|
|
493
|
+
return equals_filter
|
|
494
|
+
if filter_type == "not_equals":
|
|
495
|
+
expected = definition.get("value")
|
|
496
|
+
|
|
497
|
+
def not_equals_filter(row: RowData) -> bool:
|
|
498
|
+
return row.get(column_key) != expected
|
|
499
|
+
|
|
500
|
+
return not_equals_filter
|
|
501
|
+
if filter_type == "in":
|
|
502
|
+
values = definition.get("values", ())
|
|
503
|
+
if isinstance(values, str):
|
|
504
|
+
raise TypeError(f"Column filter for '{column_key}' type 'in' requires a non-string iterable 'values'.")
|
|
505
|
+
try:
|
|
506
|
+
value_set = set(values)
|
|
507
|
+
except TypeError as error:
|
|
508
|
+
raise TypeError(f"Column filter for '{column_key}' type 'in' requires an iterable 'values'.") from error
|
|
509
|
+
|
|
510
|
+
def in_filter(row: RowData) -> bool:
|
|
511
|
+
return row.get(column_key) in value_set
|
|
512
|
+
|
|
513
|
+
return in_filter
|
|
514
|
+
if filter_type == "bool":
|
|
515
|
+
expected = bool(definition.get("value"))
|
|
516
|
+
|
|
517
|
+
def bool_filter(row: RowData) -> bool:
|
|
518
|
+
return bool(row.get(column_key)) is expected
|
|
519
|
+
|
|
520
|
+
return bool_filter
|
|
521
|
+
if filter_type == "range":
|
|
522
|
+
min_number = self._optional_float_bound(definition.get("min"), column_key, "min")
|
|
523
|
+
max_number = self._optional_float_bound(definition.get("max"), column_key, "max")
|
|
524
|
+
|
|
525
|
+
def range_filter(row: RowData) -> bool:
|
|
526
|
+
return self._value_in_number_range(row.get(column_key), min_number, max_number)
|
|
527
|
+
|
|
528
|
+
return range_filter
|
|
529
|
+
if filter_type == "date_range":
|
|
530
|
+
min_date = self._optional_datetime_bound(definition.get("min"), column_key, "min")
|
|
531
|
+
max_date = self._optional_datetime_bound(definition.get("max"), column_key, "max")
|
|
532
|
+
|
|
533
|
+
def date_range_filter(row: RowData) -> bool:
|
|
534
|
+
return self._value_in_date_range(row.get(column_key), min_date, max_date)
|
|
535
|
+
|
|
536
|
+
return date_range_filter
|
|
537
|
+
raise ValueError(f"Column filter type '{filter_type}' is not supported.")
|
|
538
|
+
|
|
539
|
+
def _optional_float_bound(self, value: Any, column_key: str, bound_name: str) -> float | None:
|
|
540
|
+
if value is None:
|
|
541
|
+
return None
|
|
542
|
+
try:
|
|
543
|
+
return float(str(value).replace(",", ""))
|
|
544
|
+
except (TypeError, ValueError) as error:
|
|
545
|
+
message = f"Column filter for '{column_key}' has invalid {bound_name} range value {value!r}."
|
|
546
|
+
raise ValueError(message) from error
|
|
547
|
+
|
|
548
|
+
def _optional_datetime_bound(self, value: Any, column_key: str, bound_name: str) -> datetime | None:
|
|
549
|
+
if value is None:
|
|
550
|
+
return None
|
|
551
|
+
parsed = parse_datetime(value)
|
|
552
|
+
if parsed is None:
|
|
553
|
+
raise ValueError(f"Column filter for '{column_key}' has invalid {bound_name} date value {value!r}.")
|
|
554
|
+
return parsed
|
|
555
|
+
|
|
556
|
+
def _value_in_number_range(self, value: Any, minimum: float | None, maximum: float | None) -> bool:
|
|
557
|
+
try:
|
|
558
|
+
number = float(str(value).replace(",", ""))
|
|
559
|
+
except (TypeError, ValueError):
|
|
560
|
+
return False
|
|
561
|
+
if minimum is not None and number < minimum:
|
|
562
|
+
return False
|
|
563
|
+
return not (maximum is not None and number > maximum)
|
|
564
|
+
|
|
565
|
+
def _value_in_date_range(self, value: Any, minimum: datetime | None, maximum: datetime | None) -> bool:
|
|
566
|
+
parsed = parse_datetime(value)
|
|
567
|
+
if parsed is None:
|
|
568
|
+
return False
|
|
569
|
+
if minimum is not None and parsed < minimum:
|
|
570
|
+
return False
|
|
571
|
+
return not (maximum is not None and parsed > maximum)
|
|
572
|
+
|
|
573
|
+
def _sorted_indices(self, indices: list[int], column_key: str, ascending: bool) -> list[int]:
|
|
574
|
+
column = self.require_column(column_key)
|
|
575
|
+
decorated = [(self._sort_value(self._source_data[index].get(column_key), column), index) for index in indices]
|
|
576
|
+
sortable = [(sort_key, index) for sort_key, index in decorated if sort_key is not None]
|
|
577
|
+
missing = [index for sort_key, index in decorated if sort_key is None]
|
|
578
|
+
|
|
579
|
+
sortable.sort(key=lambda item: item[0], reverse=not ascending)
|
|
580
|
+
return [index for _sort_key, index in sortable] + missing
|
|
581
|
+
|
|
582
|
+
def _sort_value(self, value: Any, column: TableColumn) -> tuple[int, Any] | None:
|
|
583
|
+
if value is None or value == "":
|
|
584
|
+
return None
|
|
585
|
+
if column.type in {"number", "percentage", "currency", "progress"}:
|
|
586
|
+
try:
|
|
587
|
+
return (0, float(str(value).replace(",", "")))
|
|
588
|
+
except (TypeError, ValueError):
|
|
589
|
+
return None
|
|
590
|
+
if column.type in {"date", "datetime"}:
|
|
591
|
+
parsed = parse_datetime(value)
|
|
592
|
+
if parsed is not None:
|
|
593
|
+
return (0, parsed.timestamp())
|
|
594
|
+
return None
|
|
595
|
+
if column.type == "checkbox":
|
|
596
|
+
return (0, bool(value))
|
|
597
|
+
return (0, str(value).casefold())
|
|
598
|
+
|
|
599
|
+
def _drop_invalid_selection(self) -> None:
|
|
600
|
+
valid_indices = set(range(len(self._source_data)))
|
|
601
|
+
self._selected_source_indices.intersection_update(valid_indices)
|
|
602
|
+
if self._selection_anchor_source_index not in self._selected_source_indices:
|
|
603
|
+
self._selection_anchor_source_index = None
|