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.
@@ -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