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,1659 @@
1
+ """A virtualized Canvas-rendered data table for CustomTkinter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import queue
7
+ import threading
8
+ import tkinter as tk
9
+ import tkinter.font as tkfont
10
+ from bisect import bisect_left
11
+ from collections.abc import Callable, Iterable, Mapping, Sequence
12
+ from contextlib import suppress
13
+ from dataclasses import replace
14
+ from typing import Any, Literal, cast
15
+
16
+ import customtkinter as ctk
17
+
18
+ from .table_column import ColorValue, ColumnDefinition, TableAction, TableColumn, normalize_columns
19
+ from .table_events import TableRowEvent
20
+ from .table_model import ColumnFilter, RowData, TableModel
21
+ from .table_renderer import ActionRegion, TableRenderer
22
+ from .table_style import (
23
+ TABLE_STYLE_COLOR_MAP,
24
+ TableStyle,
25
+ TableStyleDefinition,
26
+ merge_table_style,
27
+ normalize_table_style,
28
+ )
29
+
30
+ SortCallback = Callable[[str, bool], None]
31
+ SearchCallback = Callable[[str], None]
32
+ StyleDefinition = Mapping[str, Any]
33
+ RowStyleCallback = Callable[[RowData], StyleDefinition | None]
34
+ CellStyleCallback = Callable[[RowData, str, Any], StyleDefinition | None]
35
+ SummaryDefinition = str | Callable[[list[RowData]], Any]
36
+ AsyncFetchCallback = Callable[[], Iterable[Any]]
37
+ AsyncSuccessCallback = Callable[[list[dict[str, Any]]], None]
38
+ AsyncErrorCallback = Callable[[BaseException], None]
39
+
40
+
41
+ class CTkDataTable(ctk.CTkFrame):
42
+ """Modern business data table rendered on a single tkinter Canvas."""
43
+
44
+ _SHIFT_MASK = 0x0001
45
+ _CONTROL_MASK = 0x0004
46
+
47
+ def __init__(
48
+ self,
49
+ master: Any,
50
+ columns: Sequence[ColumnDefinition],
51
+ data: Iterable[Any] | None = None,
52
+ *,
53
+ row_height: int = 42,
54
+ header_height: int = 44,
55
+ footer_height: int = 38,
56
+ font: Any | None = None,
57
+ header_font: Any | None = None,
58
+ horizontal_scroll: bool = False,
59
+ multi_select: bool = False,
60
+ searchable: bool = False,
61
+ search_delay_ms: int = 0,
62
+ resizable_columns: bool = False,
63
+ min_column_width: int = 48,
64
+ style: TableStyleDefinition | None = None,
65
+ enable_style_hooks: bool = False,
66
+ row_style: RowStyleCallback | None = None,
67
+ cell_style: CellStyleCallback | None = None,
68
+ context_menu: Sequence[TableAction | Mapping[str, Any] | str] | None = None,
69
+ on_context_action: Callable[[TableRowEvent], None] | None = None,
70
+ footer: bool = False,
71
+ summaries: Mapping[str, SummaryDefinition] | None = None,
72
+ empty_message: str = "No records to display",
73
+ loading_message: str = "Loading records...",
74
+ error_message: str = "Could not load records",
75
+ on_row_click: Callable[[TableRowEvent], None] | None = None,
76
+ on_row_double_click: Callable[[TableRowEvent], None] | None = None,
77
+ on_cell_click: Callable[[TableRowEvent], None] | None = None,
78
+ on_action_click: Callable[[TableRowEvent], None] | None = None,
79
+ on_link_click: Callable[[TableRowEvent], None] | None = None,
80
+ on_checkbox_toggle: Callable[[TableRowEvent], None] | None = None,
81
+ on_sort: SortCallback | None = None,
82
+ on_search: SearchCallback | None = None,
83
+ **kwargs: Any,
84
+ ) -> None:
85
+ """Create a CTkDataTable.
86
+
87
+ Columns are supplied as TableColumn instances or dictionaries. Row data
88
+ can be dictionaries or common query row objects.
89
+ """
90
+
91
+ table_style = normalize_table_style(style)
92
+ self._default_table_corner_radius = float(
93
+ kwargs.pop("corner_radius", table_style.corner_radius if table_style.corner_radius is not None else 12)
94
+ )
95
+ self._default_table_border_width = float(
96
+ kwargs.pop("border_width", table_style.border_width if table_style.border_width is not None else 1)
97
+ )
98
+ self._table_fg_color = kwargs.pop("fg_color", None)
99
+ self._table_border_color = kwargs.pop("border_color", None)
100
+ if table_style.surface_bg is not None:
101
+ self._table_fg_color = table_style.surface_bg
102
+ if table_style.border_color is not None:
103
+ self._table_border_color = table_style.border_color
104
+ kwargs.setdefault("corner_radius", 0)
105
+ kwargs.setdefault("border_width", 0)
106
+ kwargs.setdefault("fg_color", "transparent")
107
+ super().__init__(master, **kwargs)
108
+
109
+ if row_height < 28:
110
+ raise ValueError("row_height must be at least 28 pixels.")
111
+ if header_height < 32:
112
+ raise ValueError("header_height must be at least 32 pixels.")
113
+ if footer_height < 28:
114
+ raise ValueError("footer_height must be at least 28 pixels.")
115
+ if search_delay_ms < 0:
116
+ raise ValueError("search_delay_ms must not be negative.")
117
+ if min_column_width < 24:
118
+ raise ValueError("min_column_width must be at least 24 pixels.")
119
+ if not enable_style_hooks and (row_style is not None or cell_style is not None):
120
+ raise ValueError("Set enable_style_hooks=True before passing row_style or cell_style callbacks.")
121
+
122
+ self._columns = normalize_columns(columns)
123
+ self._model = TableModel(self._columns)
124
+ self._visible_columns_cache: list[TableColumn] = []
125
+ self._column_edges_cache: tuple[float, ...] = ()
126
+ self._column_edge_columns_cache: tuple[TableColumn, ...] = ()
127
+ self._total_table_width_cache = 0
128
+ self._row_height = row_height
129
+ self._header_height = header_height
130
+ self._footer_height = footer_height
131
+ self._horizontal_scroll_enabled = horizontal_scroll
132
+ self._multi_select = multi_select
133
+ self._searchable = searchable
134
+ self._search_delay_ms = search_delay_ms
135
+ self._pending_search_after: str | None = None
136
+ self._resizable_columns = resizable_columns
137
+ self._resize_hit_width = 5
138
+ self._resize_state: tuple[str, float, int] | None = None
139
+ self._min_column_width = min_column_width
140
+ self._enable_style_hooks = enable_style_hooks
141
+ self._row_style_callback = row_style
142
+ self._cell_style_callback = cell_style
143
+ self._context_actions = tuple(
144
+ TableAction.from_definition(action) for action in (context_menu or ())
145
+ )
146
+ self._context_action_callback = on_context_action
147
+ self._footer_enabled = footer
148
+ self._summaries = dict(summaries or {})
149
+ self._summary_cache: dict[str, str] | None = None
150
+ self._empty_message = empty_message
151
+ self._loading_message = loading_message
152
+ self._default_error_message = error_message
153
+ self._error_message: str | None = None
154
+ self._table_style = table_style
155
+ self._theme_colors_cache: tuple[tuple[str, ...], dict[str, str]] | None = None
156
+
157
+ self._row_click_callback = on_row_click
158
+ self._row_double_click_callback = on_row_double_click
159
+ self._cell_click_callback = on_cell_click
160
+ self._action_click_callback = on_action_click
161
+ self._link_click_callback = on_link_click
162
+ self._checkbox_toggle_callback = on_checkbox_toggle
163
+ self._sort_callback = on_sort
164
+ self._search_callback = on_search
165
+
166
+ self._loading = False
167
+ self._load_generation = 0
168
+
169
+ self._hovered_view_index: int | None = None
170
+ self._rendered_view_indices: set[int] = set()
171
+ self._action_regions: list[ActionRegion] = []
172
+ self._action_regions_by_row: dict[int, list[ActionRegion]] = {}
173
+
174
+ self._canvas_width = 1
175
+ self._canvas_height = 1
176
+ self._y_offset = 0.0
177
+ self._x_offset = 0.0
178
+
179
+ self._font = font if font is not None else self._make_font(size=13)
180
+ if header_font is not None:
181
+ self._header_font = header_font
182
+ elif font is not None:
183
+ self._header_font = font
184
+ else:
185
+ self._header_font = self._make_font(size=13, weight="bold")
186
+ self._refresh_column_cache()
187
+
188
+ self.grid_columnconfigure(0, weight=1)
189
+
190
+ canvas_row = 0
191
+ self._search_entry: ctk.CTkEntry | None = None
192
+ if searchable:
193
+ self.grid_rowconfigure(1, weight=1)
194
+ canvas_row = 1
195
+ self._search_entry = ctk.CTkEntry(self, placeholder_text="Search…")
196
+ search_entry = self._search_entry
197
+ assert search_entry is not None
198
+ search_entry.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=(8, 4))
199
+ search_entry.bind("<KeyRelease>", lambda _e: self._queue_search(search_entry.get()))
200
+ else:
201
+ self.grid_rowconfigure(0, weight=1)
202
+
203
+ self._table_canvas = tk.Canvas(self, bd=0, highlightthickness=0, takefocus=True)
204
+ self._table_canvas.grid(row=canvas_row, column=0, sticky="nsew")
205
+
206
+ self._vertical_scrollbar = ctk.CTkScrollbar(
207
+ self,
208
+ orientation="vertical",
209
+ command=self._handle_vertical_scrollbar,
210
+ )
211
+ self._vertical_scrollbar.grid(row=canvas_row, column=1, sticky="ns", padx=(0, 2), pady=2)
212
+
213
+ self._horizontal_scrollbar: ctk.CTkScrollbar | None = None
214
+ if self._horizontal_scroll_enabled:
215
+ self._horizontal_scrollbar = ctk.CTkScrollbar(
216
+ self,
217
+ orientation="horizontal",
218
+ command=self._handle_horizontal_scrollbar,
219
+ )
220
+ self._horizontal_scrollbar.grid(row=canvas_row + 1, column=0, sticky="ew", padx=2, pady=(0, 2))
221
+
222
+ self._renderer = TableRenderer(self._table_canvas, self._font, self._header_font, self._resolve_color)
223
+ self._apply_renderer_style()
224
+ self._apply_layout_insets()
225
+ self._bind_events()
226
+ self.set_data(data if data is not None else [])
227
+
228
+ def set_data(self, data: Iterable[Any]) -> None:
229
+ """Replace the dataset with row-like objects converted to dictionaries."""
230
+
231
+ self._error_message = None
232
+ self._model.set_data(data)
233
+ self._hovered_view_index = None
234
+ self._rebuild_view(preserve_scroll=False)
235
+
236
+ def get_data(self) -> list[dict[str, Any]]:
237
+ """Return a shallow copy of all source data rows."""
238
+
239
+ return self._model.get_data()
240
+
241
+ def get_columns(self) -> tuple[TableColumn, ...]:
242
+ """Return the current normalized column definitions."""
243
+
244
+ return self._columns
245
+
246
+ def set_columns(self, columns: Sequence[ColumnDefinition]) -> None:
247
+ """Replace column definitions while preserving compatible table state."""
248
+
249
+ self._columns = normalize_columns(columns)
250
+ self._model.set_columns(self._columns)
251
+ self._refresh_column_cache()
252
+ self._hovered_view_index = None
253
+ self._invalidate_summary_cache()
254
+ self._clamp_offsets()
255
+ self._redraw(full=True)
256
+
257
+ def set_column_width(self, column_key: str, width: int) -> None:
258
+ """Set a column width programmatically."""
259
+
260
+ self._set_column_width(column_key, width)
261
+
262
+ def get_column_width(self, column_key: str) -> int:
263
+ """Return the current width for a column key."""
264
+
265
+ return self._model.require_column(column_key).width
266
+
267
+ def refresh(self) -> None:
268
+ """Refresh the current rendered view without changing data or selection."""
269
+
270
+ self._clamp_offsets()
271
+ self._redraw(full=True)
272
+
273
+ def get_style(self) -> TableStyle:
274
+ """Return the current table-wide style options."""
275
+
276
+ return self._table_style
277
+
278
+ def set_style(self, style: TableStyleDefinition | None = None, **kwargs: Any) -> None:
279
+ """Replace table-wide style options and redraw the table.
280
+
281
+ Pass a TableStyle, a mapping, keyword options, or any combination of a
282
+ base style plus keyword overrides.
283
+ """
284
+
285
+ self._table_style = normalize_table_style(style, **kwargs)
286
+ self._after_style_changed()
287
+
288
+ def configure_style(self, style: TableStyleDefinition | None = None, **kwargs: Any) -> None:
289
+ """Merge table-wide style options into the current style and redraw."""
290
+
291
+ if style is None and not kwargs:
292
+ return
293
+ self._table_style = merge_table_style(self._table_style, style, **kwargs)
294
+ self._after_style_changed()
295
+
296
+ def clear(self) -> None:
297
+ """Clear all rows from the table."""
298
+
299
+ self.set_data([])
300
+
301
+ def get_selected_row(self) -> dict[str, Any] | None:
302
+ """Return the first selected row, or None when no row is selected."""
303
+
304
+ rows = self.get_selected_rows()
305
+ return rows[0] if rows else None
306
+
307
+ def get_selected_rows(self) -> list[dict[str, Any]]:
308
+ """Return selected rows as shallow copies."""
309
+
310
+ return self._model.get_selected_rows()
311
+
312
+ def get_selected_indices(self) -> list[int]:
313
+ """Return selected source-data indices in current view order."""
314
+
315
+ return self._model.get_selected_source_indices()
316
+
317
+ def get_selected_view_indices(self) -> list[int]:
318
+ """Return selected visible row indices in current view order."""
319
+
320
+ return self._model.get_selected_view_indices()
321
+
322
+ def get_row(self, index: int) -> dict[str, Any]:
323
+ """Return a shallow copy of one source row."""
324
+
325
+ return self._model.get_row(index)
326
+
327
+ def get_view_row(self, view_index: int) -> dict[str, Any]:
328
+ """Return a shallow copy of one row by current filtered/sorted view index."""
329
+
330
+ return dict(self._model.row_for_view_index(view_index))
331
+
332
+ def source_index_for_view_index(self, view_index: int) -> int:
333
+ """Return the source-data index for a current view index."""
334
+
335
+ return self._model.source_index_for_view_index(view_index)
336
+
337
+ def view_index_for_source_index(self, source_index: int) -> int | None:
338
+ """Return the current view index for a source-data index, if visible."""
339
+
340
+ return self._model.view_index_for_source_index(source_index)
341
+
342
+ def find_row_index(self, column_key: str, value: Any) -> int | None:
343
+ """Return the first source row index whose column value matches value."""
344
+
345
+ return self._model.find_source_index(column_key, value)
346
+
347
+ def sort_by(self, column_key: str, ascending: bool = True) -> None:
348
+ """Sort visible rows by a column key."""
349
+
350
+ self._model.sort_by(column_key, bool(ascending))
351
+ self._rebuild_view(preserve_scroll=False)
352
+ if self._sort_callback is not None:
353
+ self._sort_callback(column_key, bool(ascending))
354
+
355
+ def search(self, query: str) -> None:
356
+ """Filter rows by a case-insensitive query across all visible fields."""
357
+
358
+ self._model.search(query)
359
+ self._rebuild_view(preserve_scroll=False)
360
+ if self._search_callback is not None:
361
+ self._search_callback(self._model.filter_query)
362
+
363
+ def filter(self, query: str) -> None:
364
+ """Alias for :meth:`search` (backward compatibility)."""
365
+
366
+ self.search(query)
367
+
368
+ def set_column_filter(self, column_key: str, definition: ColumnFilter) -> None:
369
+ """Set a column filter and refresh the visible rows."""
370
+
371
+ self._model.set_column_filter(column_key, definition)
372
+ self._rebuild_view(preserve_scroll=False)
373
+
374
+ def clear_column_filter(self, column_key: str) -> None:
375
+ """Clear one column filter and refresh the visible rows."""
376
+
377
+ self._model.clear_column_filter(column_key)
378
+ self._rebuild_view(preserve_scroll=False)
379
+
380
+ def clear_column_filters(self) -> None:
381
+ """Clear all column filters and refresh the visible rows."""
382
+
383
+ self._model.clear_column_filters()
384
+ self._rebuild_view(preserve_scroll=False)
385
+
386
+ def get_column_filters(self) -> dict[str, ColumnFilter]:
387
+ """Return a shallow copy of active column filters."""
388
+
389
+ return self._model.column_filters
390
+
391
+ def add_row(self, row: Any) -> int:
392
+ """Append a row-like object and return its source index."""
393
+
394
+ source_index = self._model.add_row(row)
395
+ self._rebuild_view(preserve_scroll=True)
396
+ return source_index
397
+
398
+ def add_rows(self, rows: Iterable[Any]) -> list[int]:
399
+ """Append multiple rows in a single rebuild and return their source indices."""
400
+
401
+ source_indices = self._model.add_rows(rows)
402
+ self._rebuild_view(preserve_scroll=True)
403
+ return source_indices
404
+
405
+ def update_row(self, index: int, row: Any) -> None:
406
+ """Replace a source row by its integer index."""
407
+
408
+ self._model.update_row(index, row)
409
+ self._rebuild_view(preserve_scroll=True)
410
+
411
+ def update_view_row(self, view_index: int, row: Any) -> None:
412
+ """Replace a row by its current filtered/sorted view index."""
413
+
414
+ self.update_row(self._model.source_index_for_view_index(view_index), row)
415
+
416
+ def update_row_where(self, column_key: str, value: Any, new_row: Any) -> bool:
417
+ """Replace the first row where *column_key* equals *value*. Returns True if found."""
418
+
419
+ updated = self._model.update_row_where(column_key, value, new_row)
420
+ if updated:
421
+ self._rebuild_view(preserve_scroll=True)
422
+ return updated
423
+
424
+ def delete_row(self, index: int) -> None:
425
+ """Delete a source row by its integer index."""
426
+
427
+ self._model.delete_row(index)
428
+ self._rebuild_view(preserve_scroll=True)
429
+
430
+ def delete_view_row(self, view_index: int) -> None:
431
+ """Delete a row by its current filtered/sorted view index."""
432
+
433
+ self.delete_row(self._model.source_index_for_view_index(view_index))
434
+
435
+ def delete_row_where(self, column_key: str, value: Any) -> bool:
436
+ """Delete the first row where *column_key* equals *value*. Returns True if found."""
437
+
438
+ deleted = self._model.delete_row_by_key(column_key, value)
439
+ if deleted:
440
+ self._rebuild_view(preserve_scroll=True)
441
+ return deleted
442
+
443
+ def delete_row_by_key(self, column_key: str, value: Any) -> bool:
444
+ """Alias for :meth:`delete_row_where` (backward compatibility)."""
445
+
446
+ return self.delete_row_where(column_key, value)
447
+
448
+ def delete_selected_rows(self) -> int:
449
+ """Delete all selected source rows and return the number removed."""
450
+
451
+ count = self._model.delete_rows(self._model.get_selected_source_indices())
452
+ if count:
453
+ self._rebuild_view(preserve_scroll=True)
454
+ return count
455
+
456
+ def set_loading(self, state: bool) -> None:
457
+ """Show or hide the loading state overlay."""
458
+
459
+ self._loading = bool(state)
460
+ if self._loading:
461
+ self._error_message = None
462
+ self._redraw(full=True)
463
+
464
+ def set_error(self, message: str | None = None) -> None:
465
+ """Show an error state. Pass None to use the default loading error message."""
466
+
467
+ self._loading = False
468
+ self._error_message = message or self._default_error_message
469
+ self._redraw(full=True)
470
+
471
+ def clear_error(self) -> None:
472
+ """Hide the error state without changing table rows."""
473
+
474
+ self._error_message = None
475
+ self._redraw(full=True)
476
+
477
+ def load_async(
478
+ self,
479
+ fetch_rows: AsyncFetchCallback,
480
+ *,
481
+ on_success: AsyncSuccessCallback | None = None,
482
+ on_error: AsyncErrorCallback | None = None,
483
+ clear_on_error: bool = False,
484
+ ) -> threading.Thread:
485
+ """Run a row loader in a background thread and update the table on the Tk thread."""
486
+
487
+ self._load_generation += 1
488
+ generation = self._load_generation
489
+ self.set_loading(True)
490
+
491
+ result_queue: queue.Queue[tuple[str, list[Any] | Exception]] = queue.Queue(maxsize=1)
492
+
493
+ def run() -> None:
494
+ try:
495
+ rows = list(fetch_rows())
496
+ except Exception as error:
497
+ result_queue.put(("error", error))
498
+ return
499
+ result_queue.put(("success", rows))
500
+
501
+ thread = threading.Thread(target=run, daemon=True)
502
+ thread.start()
503
+ self.after(10, lambda: self._poll_async_result(generation, result_queue, on_success, on_error, clear_on_error))
504
+ return thread
505
+
506
+ def _bind_events(self) -> None:
507
+ self._table_canvas.bind("<Configure>", self._handle_configure)
508
+ self._table_canvas.bind("<MouseWheel>", self._handle_mousewheel)
509
+ self._table_canvas.bind("<Button-4>", self._handle_mousewheel)
510
+ self._table_canvas.bind("<Button-5>", self._handle_mousewheel)
511
+ self._table_canvas.bind("<Button-1>", self._handle_click)
512
+ self._table_canvas.bind("<B1-Motion>", self._handle_drag)
513
+ self._table_canvas.bind("<ButtonRelease-1>", self._handle_release)
514
+ self._table_canvas.bind("<Button-3>", self._handle_context_menu)
515
+ self._table_canvas.bind("<Control-Button-1>", self._handle_context_menu)
516
+ self._table_canvas.bind("<Double-Button-1>", self._handle_double_click)
517
+ self._table_canvas.bind("<Motion>", self._handle_motion)
518
+ self._table_canvas.bind("<Leave>", self._handle_leave)
519
+ self._table_canvas.bind("<KeyPress>", self._handle_keypress)
520
+
521
+ def _make_font(self, *, size: int, weight: Literal["normal", "bold"] = "normal") -> Any:
522
+ try:
523
+ return ctk.CTkFont(size=size, weight=weight)
524
+ except Exception:
525
+ font = tkfont.nametofont("TkDefaultFont").copy()
526
+ font.configure(size=size, weight=weight)
527
+ return font
528
+
529
+ def _handle_configure(self, event: tk.Event[Any]) -> None:
530
+ self._canvas_width = max(1, int(event.width))
531
+ self._canvas_height = max(1, int(event.height))
532
+ self._clamp_offsets()
533
+ self._redraw(full=True)
534
+
535
+ def _handle_vertical_scrollbar(self, *args: str) -> None:
536
+ if not args:
537
+ return
538
+ if args[0] == "moveto" and len(args) >= 2:
539
+ self._set_y_offset(float(args[1]) * self._total_body_height())
540
+ elif args[0] == "scroll" and len(args) >= 3:
541
+ amount = int(args[1])
542
+ unit = args[2]
543
+ step = self._row_height if unit == "units" else max(self._row_height, self._body_height())
544
+ self._set_y_offset(self._y_offset + amount * step)
545
+
546
+ def _handle_horizontal_scrollbar(self, *args: str) -> None:
547
+ if not args:
548
+ return
549
+ if args[0] == "moveto" and len(args) >= 2:
550
+ self._set_x_offset(float(args[1]) * self._total_table_width())
551
+ elif args[0] == "scroll" and len(args) >= 3:
552
+ amount = int(args[1])
553
+ unit = args[2]
554
+ step = 40 if unit == "units" else max(80, self._canvas_width)
555
+ self._set_x_offset(self._x_offset + amount * step)
556
+
557
+ def _handle_mousewheel(self, event: tk.Event[Any]) -> str:
558
+ if getattr(event, "num", None) == 4:
559
+ units: float = -1
560
+ elif getattr(event, "num", None) == 5:
561
+ units = 1
562
+ else:
563
+ delta = getattr(event, "delta", 0)
564
+ units = -delta / 120 if delta else 0
565
+ if units and abs(units) < 1:
566
+ units = -1 if delta > 0 else 1
567
+
568
+ if self._horizontal_scroll_enabled and getattr(event, "state", 0) & self._SHIFT_MASK:
569
+ self._set_x_offset(self._x_offset + units * 40)
570
+ else:
571
+ self._set_y_offset(self._y_offset + units * self._row_height)
572
+ return "break"
573
+
574
+ def _handle_click(self, event: tk.Event[Any]) -> str | None:
575
+ self._table_canvas.focus_set()
576
+ if event.y < self._header_height:
577
+ resize_column = self._resize_column_from_x(event.x)
578
+ if resize_column is not None:
579
+ self._resize_state = (resize_column.key, float(event.x), resize_column.width)
580
+ return "break"
581
+ column = self._column_from_x(event.x)
582
+ if column is not None and column.sortable:
583
+ ascending = True
584
+ if self._model.sort_state is not None and self._model.sort_state[0] == column.key:
585
+ ascending = not self._model.sort_state[1]
586
+ self.sort_by(column.key, ascending)
587
+ return "break"
588
+
589
+ if self._loading:
590
+ return "break"
591
+
592
+ view_index = self._row_index_from_y(event.y)
593
+ if view_index is None:
594
+ return None
595
+
596
+ action_region = self._hit_action(event.x, event.y)
597
+ row = self._row_for_view_index(view_index)
598
+ source_index = self._model.source_index_for_view_index(view_index)
599
+ if action_region is not None and action_region.kind == "action":
600
+ if self._action_click_callback is not None:
601
+ self._action_click_callback(
602
+ TableRowEvent(
603
+ row=dict(row),
604
+ source_index=source_index,
605
+ view_index=view_index,
606
+ column_key=action_region.column_key,
607
+ action_key=action_region.action_key,
608
+ )
609
+ )
610
+ return "break"
611
+ if action_region is not None and action_region.kind == "checkbox":
612
+ self._toggle_checkbox(view_index, action_region.column_key, event)
613
+ return "break"
614
+ if action_region is not None and action_region.kind == "link":
615
+ self._select_view_index(view_index, event)
616
+ if self._link_click_callback is not None:
617
+ self._link_click_callback(
618
+ TableRowEvent(
619
+ row=dict(row),
620
+ source_index=source_index,
621
+ view_index=view_index,
622
+ column_key=action_region.column_key,
623
+ action_key=action_region.action_key,
624
+ )
625
+ )
626
+ return "break"
627
+
628
+ column = self._column_from_x(event.x)
629
+ self._select_view_index(view_index, event)
630
+ if column is not None and self._cell_click_callback is not None:
631
+ self._cell_click_callback(
632
+ TableRowEvent(row=dict(row), source_index=source_index, view_index=view_index, column_key=column.key)
633
+ )
634
+ if self._row_click_callback is not None:
635
+ self._row_click_callback(TableRowEvent(row=dict(row), source_index=source_index, view_index=view_index))
636
+ return "break"
637
+
638
+ def _handle_drag(self, event: tk.Event[Any]) -> str | None:
639
+ if self._resize_state is None:
640
+ return None
641
+ column_key, start_x, start_width = self._resize_state
642
+ width = max(self._min_column_width, round(start_width + float(event.x) - start_x))
643
+ self._set_column_width(column_key, width)
644
+ return "break"
645
+
646
+ def _handle_release(self, _event: tk.Event[Any]) -> str | None:
647
+ if self._resize_state is None:
648
+ return None
649
+ self._resize_state = None
650
+ self._update_header_cursor(None)
651
+ return "break"
652
+
653
+ def _handle_context_menu(self, event: tk.Event[Any]) -> str | None:
654
+ if not self._context_actions or self._loading or self._error_message is not None:
655
+ return None
656
+ view_index = self._row_index_from_y(event.y)
657
+ if view_index is None:
658
+ return None
659
+ self._table_canvas.focus_set()
660
+ self._select_view_index(view_index, event)
661
+ row_event = self._event_for_view_index(view_index)
662
+
663
+ menu = tk.Menu(self._table_canvas, tearoff=0)
664
+ for action in self._context_actions:
665
+ menu.add_command(
666
+ label=action.label,
667
+ command=self._context_command(row_event, action.key),
668
+ )
669
+ try:
670
+ menu.tk_popup(event.x_root, event.y_root)
671
+ finally:
672
+ menu.grab_release()
673
+ return "break"
674
+
675
+ def _context_command(self, row_event: TableRowEvent, action_key: str) -> Callable[[], None]:
676
+ return lambda: self._invoke_context_action(row_event, action_key)
677
+
678
+ def _handle_double_click(self, event: tk.Event[Any]) -> str | None:
679
+ if event.y < self._header_height or self._loading:
680
+ return "break"
681
+ action_region = self._hit_action(event.x, event.y)
682
+ if action_region is not None and action_region.kind == "checkbox":
683
+ view_index = self._row_index_from_y(event.y)
684
+ if view_index is not None:
685
+ self._toggle_checkbox(view_index, action_region.column_key, event)
686
+ return "break"
687
+ if action_region is not None and (action_region.kind == "action" or self._link_click_callback is not None):
688
+ return "break"
689
+ view_index = self._row_index_from_y(event.y)
690
+ if view_index is None:
691
+ return None
692
+ if self._row_double_click_callback is not None:
693
+ self._row_double_click_callback(self._event_for_view_index(view_index))
694
+ return "break"
695
+
696
+ def _handle_keypress(self, event: tk.Event[Any]) -> str | None:
697
+ if self._loading or not self._model.view_indices:
698
+ return None
699
+
700
+ keysym = getattr(event, "keysym", "")
701
+ focused_view_index = self._model.focused_view_index()
702
+
703
+ page_size = max(1, self._body_height() // self._row_height)
704
+ last_index = len(self._model.view_indices) - 1
705
+ target_index: int | None = None
706
+
707
+ if focused_view_index is None:
708
+ if keysym in {"End", "Prior"}:
709
+ target_index = last_index
710
+ elif keysym in {"Up", "Down", "Next", "Home"}:
711
+ target_index = 0
712
+ else:
713
+ return None
714
+ elif keysym == "Up":
715
+ target_index = max(0, focused_view_index - 1)
716
+ elif keysym == "Down":
717
+ target_index = min(last_index, focused_view_index + 1)
718
+ elif keysym == "Prior":
719
+ target_index = max(0, focused_view_index - page_size)
720
+ elif keysym == "Next":
721
+ target_index = min(last_index, focused_view_index + page_size)
722
+ elif keysym == "Home":
723
+ target_index = 0
724
+ elif keysym == "End":
725
+ target_index = last_index
726
+ elif keysym in {"Return", "KP_Enter"}:
727
+ if self._row_double_click_callback is not None:
728
+ self._row_double_click_callback(self._event_for_view_index(focused_view_index))
729
+ return "break"
730
+ else:
731
+ return None
732
+
733
+ state = getattr(event, "state", 0)
734
+ changed = self._model.select_view_index(
735
+ target_index,
736
+ multi_select=self._multi_select,
737
+ shift=bool(state & self._SHIFT_MASK),
738
+ control=False,
739
+ )
740
+ self._scroll_view_index_into_view(target_index)
741
+ self._redraw_changed_sources(changed)
742
+ return "break"
743
+
744
+ def _event_for_view_index(
745
+ self,
746
+ view_index: int,
747
+ *,
748
+ column_key: str | None = None,
749
+ action_key: str | None = None,
750
+ ) -> TableRowEvent:
751
+ source_index = self._model.source_index_for_view_index(view_index)
752
+ return TableRowEvent(
753
+ row=dict(self._row_for_view_index(view_index)),
754
+ source_index=source_index,
755
+ view_index=view_index,
756
+ column_key=column_key,
757
+ action_key=action_key,
758
+ )
759
+
760
+ def _toggle_checkbox(self, view_index: int, column_key: str, event: tk.Event[Any]) -> None:
761
+ source_index = self._model.source_index_for_view_index(view_index)
762
+ row = dict(self._row_for_view_index(view_index))
763
+ row[column_key] = not bool(row.get(column_key))
764
+
765
+ self._select_view_index(view_index, event)
766
+ self._model.update_row(source_index, row)
767
+ current_view_index = self._model.view_index_for_source_index(source_index)
768
+ self._rebuild_view(preserve_scroll=True)
769
+
770
+ if self._checkbox_toggle_callback is not None:
771
+ self._checkbox_toggle_callback(
772
+ TableRowEvent(
773
+ row=dict(row),
774
+ source_index=source_index,
775
+ view_index=current_view_index if current_view_index is not None else view_index,
776
+ column_key=column_key,
777
+ action_key="checkbox",
778
+ )
779
+ )
780
+
781
+ def _handle_motion(self, event: tk.Event[Any]) -> None:
782
+ if self._resize_state is not None:
783
+ return
784
+ if event.y < self._header_height:
785
+ self._update_header_cursor(self._resize_column_from_x(event.x))
786
+ else:
787
+ action_region = None if self._loading else self._hit_action(event.x, event.y)
788
+ cursor = "hand2" if action_region is not None and action_region.kind == "checkbox" else ""
789
+ self._update_canvas_cursor(cursor)
790
+ view_index = None if self._loading else self._row_index_from_y(event.y)
791
+ if view_index == self._hovered_view_index:
792
+ return
793
+ old_index = self._hovered_view_index
794
+ self._hovered_view_index = view_index
795
+ if old_index is not None:
796
+ self._redraw_row(old_index)
797
+ if view_index is not None:
798
+ self._redraw_row(view_index)
799
+ self._refresh_action_regions()
800
+ self._table_canvas.tag_raise("header")
801
+
802
+ def _handle_leave(self, _event: tk.Event[Any]) -> None:
803
+ if self._resize_state is None:
804
+ self._update_header_cursor(None)
805
+ if self._hovered_view_index is None:
806
+ return
807
+ old_index = self._hovered_view_index
808
+ self._hovered_view_index = None
809
+ self._redraw_row(old_index)
810
+ self._refresh_action_regions()
811
+ self._table_canvas.tag_raise("header")
812
+
813
+ def _select_view_index(self, view_index: int, event: tk.Event[Any]) -> None:
814
+ state = getattr(event, "state", 0)
815
+ changed = self._model.select_view_index(
816
+ view_index,
817
+ multi_select=self._multi_select,
818
+ shift=bool(state & self._SHIFT_MASK),
819
+ control=bool(state & self._CONTROL_MASK),
820
+ )
821
+ self._redraw_changed_sources(changed)
822
+
823
+ def _set_column_width(self, column_key: str, width: int) -> None:
824
+ columns = tuple(
825
+ replace(column, width=max(self._min_column_width, int(width))) if column.key == column_key else column
826
+ for column in self._columns
827
+ )
828
+ if columns == self._columns:
829
+ return
830
+ self._columns = columns
831
+ self._model.set_columns(columns, rebuild=False, clear_search_cache=False)
832
+ self._refresh_column_cache()
833
+ self._clamp_offsets()
834
+ self._redraw(full=True)
835
+
836
+ def _resize_column_from_x(self, x: float) -> TableColumn | None:
837
+ if not self._resizable_columns:
838
+ return None
839
+ content_x = x + self._x_offset
840
+ edge_index = bisect_left(self._column_edges_cache, content_x)
841
+ for index in {edge_index - 1, edge_index}:
842
+ if (
843
+ 0 <= index < len(self._column_edges_cache)
844
+ and abs(content_x - self._column_edges_cache[index]) <= self._resize_hit_width
845
+ ):
846
+ return self._column_edge_columns_cache[index]
847
+ return None
848
+
849
+ def _update_header_cursor(self, resize_column: TableColumn | None) -> None:
850
+ cursor = "sb_h_double_arrow" if resize_column is not None else ""
851
+ self._update_canvas_cursor(cursor)
852
+
853
+ def _update_canvas_cursor(self, cursor: str) -> None:
854
+ try:
855
+ if self._table_canvas.cget("cursor") != cursor:
856
+ self._table_canvas.configure(cursor=cursor)
857
+ except tk.TclError:
858
+ return
859
+
860
+ def _invoke_context_action(self, row_event: TableRowEvent, action_key: str) -> None:
861
+ if self._context_action_callback is None:
862
+ return
863
+ self._context_action_callback(
864
+ TableRowEvent(
865
+ row=dict(row_event.row),
866
+ source_index=row_event.source_index,
867
+ view_index=row_event.view_index,
868
+ column_key=row_event.column_key,
869
+ action_key=action_key,
870
+ )
871
+ )
872
+
873
+ def _queue_search(self, query: str) -> None:
874
+ if self._pending_search_after is not None:
875
+ with suppress(tk.TclError):
876
+ self.after_cancel(self._pending_search_after)
877
+ self._pending_search_after = None
878
+ if self._search_delay_ms <= 0:
879
+ self.search(query)
880
+ return
881
+ self._pending_search_after = self.after(self._search_delay_ms, lambda: self._run_queued_search(query))
882
+
883
+ def _run_queued_search(self, query: str) -> None:
884
+ self._pending_search_after = None
885
+ self.search(query)
886
+
887
+ def _finish_async_success(
888
+ self,
889
+ generation: int,
890
+ rows: list[Any],
891
+ on_success: AsyncSuccessCallback | None,
892
+ ) -> None:
893
+ if generation != self._load_generation:
894
+ return
895
+ self._loading = False
896
+ self.set_data(rows)
897
+ if on_success is not None:
898
+ on_success(self.get_data())
899
+
900
+ def _poll_async_result(
901
+ self,
902
+ generation: int,
903
+ result_queue: queue.Queue[tuple[str, list[Any] | Exception]],
904
+ on_success: AsyncSuccessCallback | None,
905
+ on_error: AsyncErrorCallback | None,
906
+ clear_on_error: bool,
907
+ ) -> None:
908
+ if generation != self._load_generation:
909
+ return
910
+ try:
911
+ kind, payload = result_queue.get_nowait()
912
+ except queue.Empty:
913
+ self.after(
914
+ 10,
915
+ lambda: self._poll_async_result(generation, result_queue, on_success, on_error, clear_on_error),
916
+ )
917
+ return
918
+ if kind == "success":
919
+ self._finish_async_success(generation, cast(list[Any], payload), on_success)
920
+ else:
921
+ self._finish_async_error(generation, cast(Exception, payload), on_error, clear_on_error)
922
+
923
+ def _finish_async_error(
924
+ self,
925
+ generation: int,
926
+ error: Exception,
927
+ on_error: AsyncErrorCallback | None,
928
+ clear_on_error: bool,
929
+ ) -> None:
930
+ if generation != self._load_generation:
931
+ return
932
+ if clear_on_error:
933
+ self.clear()
934
+ self.set_error(str(error) or self._default_error_message)
935
+ if on_error is not None:
936
+ on_error(error)
937
+
938
+ def _style_for_row(self, row: RowData) -> StyleDefinition | None:
939
+ if self._row_style_callback is None:
940
+ return None
941
+ try:
942
+ style = self._row_style_callback(dict(row))
943
+ except Exception as error:
944
+ raise RuntimeError(f"row_style callback failed: {error}") from error
945
+ if style is not None and not isinstance(style, Mapping):
946
+ raise TypeError("row_style must return a mapping or None.")
947
+ return style
948
+
949
+ def _styles_for_cells(self, row: RowData) -> dict[str, StyleDefinition]:
950
+ if self._cell_style_callback is None:
951
+ return {}
952
+ styles: dict[str, StyleDefinition] = {}
953
+ row_copy = dict(row)
954
+ for column in self._visible_columns():
955
+ try:
956
+ style = self._cell_style_callback(row_copy, column.key, row.get(column.key))
957
+ except Exception as error:
958
+ raise RuntimeError(f"cell_style callback failed for column '{column.key}': {error}") from error
959
+ if style is not None:
960
+ if not isinstance(style, Mapping):
961
+ raise TypeError("cell_style must return a mapping or None.")
962
+ styles[column.key] = style
963
+ return styles
964
+
965
+ def _summary_values(self) -> dict[str, str]:
966
+ if not self._footer_enabled:
967
+ return {}
968
+ if self._summary_cache is None:
969
+ rows = [self._model.source_data[index] for index in self._model.view_indices]
970
+ self._summary_cache = {
971
+ column_key: self._summary_value(column_key, definition, rows)
972
+ for column_key, definition in self._summaries.items()
973
+ }
974
+ return self._summary_cache
975
+
976
+ def _summary_value(self, column_key: str, definition: SummaryDefinition, rows: list[RowData]) -> str:
977
+ if callable(definition):
978
+ try:
979
+ return str(definition([dict(row) for row in rows]))
980
+ except Exception as error:
981
+ raise RuntimeError(f"Summary callback failed for column '{column_key}': {error}") from error
982
+ name = str(definition).casefold()
983
+ values = [row.get(column_key) for row in rows]
984
+ if name == "count":
985
+ return str(len(rows))
986
+ numbers = self._numeric_values(values)
987
+ if name == "sum":
988
+ return self._format_summary_number(sum(numbers)) if numbers else ""
989
+ if name in {"avg", "average"}:
990
+ return self._format_summary_number(sum(numbers) / len(numbers)) if numbers else ""
991
+ if name == "min":
992
+ return self._format_summary_number(min(numbers)) if numbers else ""
993
+ if name == "max":
994
+ return self._format_summary_number(max(numbers)) if numbers else ""
995
+ return str(definition)
996
+
997
+ def _numeric_values(self, values: Iterable[Any]) -> list[float]:
998
+ numbers: list[float] = []
999
+ for value in values:
1000
+ if value is None or value == "":
1001
+ continue
1002
+ try:
1003
+ numbers.append(float(str(value).replace(",", "")))
1004
+ except (TypeError, ValueError):
1005
+ continue
1006
+ return numbers
1007
+
1008
+ def _format_summary_number(self, value: float) -> str:
1009
+ if value.is_integer():
1010
+ return str(int(value))
1011
+ return f"{value:,.2f}"
1012
+
1013
+ def _invalidate_summary_cache(self) -> None:
1014
+ self._summary_cache = None
1015
+
1016
+ def _redraw_changed_sources(self, source_indices: set[int]) -> None:
1017
+ for source_index in source_indices:
1018
+ changed_view_index = self._view_index_for_source_index(source_index)
1019
+ if changed_view_index is not None:
1020
+ self._redraw_row(changed_view_index)
1021
+
1022
+ def _rebuild_view(self, *, preserve_scroll: bool) -> None:
1023
+ old_y = self._y_offset
1024
+ self._invalidate_summary_cache()
1025
+
1026
+ if not preserve_scroll:
1027
+ self._y_offset = 0.0
1028
+ else:
1029
+ self._y_offset = old_y
1030
+
1031
+ self._clamp_offsets()
1032
+ self._redraw(full=True)
1033
+
1034
+ def _redraw(
1035
+ self,
1036
+ *,
1037
+ full: bool,
1038
+ previous_y_offset: float | None = None,
1039
+ redraw_fixed: bool = True,
1040
+ ) -> None:
1041
+ colors = self._theme_colors()
1042
+ self._table_canvas.configure(background=colors["canvas_bg"])
1043
+ self._update_scrollregion()
1044
+ self._sync_scrollbars()
1045
+ self._apply_action_colors(colors)
1046
+
1047
+ if full:
1048
+ self._table_canvas.delete("all")
1049
+ self._rendered_view_indices.clear()
1050
+ self._action_regions_by_row.clear()
1051
+ self._draw_table_surface(colors)
1052
+ self._draw_header(colors)
1053
+ else:
1054
+ self._table_canvas.delete("state")
1055
+ if previous_y_offset is not None:
1056
+ delta_y = previous_y_offset - self._y_offset
1057
+ for row_index in list(self._rendered_view_indices):
1058
+ self._table_canvas.move(f"row_{row_index}", 0, delta_y)
1059
+ self._move_action_regions(delta_y)
1060
+
1061
+ if self._loading or self._error_message is not None or not self._model.view_indices:
1062
+ self._table_canvas.delete("body")
1063
+ self._rendered_view_indices.clear()
1064
+ self._draw_state(colors)
1065
+ self._table_canvas.tag_raise("header")
1066
+ if redraw_fixed:
1067
+ self._draw_footer(colors)
1068
+ self._draw_table_chrome(colors)
1069
+ else:
1070
+ self._raise_fixed_layers()
1071
+ self._action_regions.clear()
1072
+ self._action_regions_by_row.clear()
1073
+ return
1074
+
1075
+ self._table_canvas.delete("state")
1076
+ self._draw_visible_rows(colors)
1077
+ self._refresh_action_regions()
1078
+ self._table_canvas.tag_raise("header")
1079
+ if redraw_fixed:
1080
+ self._draw_footer(colors)
1081
+ self._draw_table_chrome(colors)
1082
+ else:
1083
+ self._raise_fixed_layers()
1084
+
1085
+ def _draw_table_surface(self, colors: Mapping[str, str]) -> None:
1086
+ self._renderer.draw_surface(
1087
+ canvas_width=self._canvas_width,
1088
+ canvas_height=self._canvas_height,
1089
+ radius=self._table_corner_radius(),
1090
+ colors=colors,
1091
+ )
1092
+
1093
+ def _draw_table_chrome(self, colors: Mapping[str, str]) -> None:
1094
+ self._renderer.draw_chrome(
1095
+ canvas_width=self._canvas_width,
1096
+ canvas_height=self._canvas_height,
1097
+ radius=self._table_corner_radius(),
1098
+ border_width=self._table_border_width(),
1099
+ bottom_cap_height=self._bottom_cap_height(),
1100
+ colors=colors,
1101
+ )
1102
+
1103
+ def _draw_header(self, colors: Mapping[str, str]) -> None:
1104
+ sort_key = self._model.sort_state[0] if self._model.sort_state else None
1105
+ sort_ascending = self._model.sort_state[1] if self._model.sort_state else True
1106
+ self._renderer.draw_header(
1107
+ self._visible_columns(),
1108
+ x_offset=self._x_offset,
1109
+ canvas_width=self._canvas_width,
1110
+ header_height=self._header_height,
1111
+ radius=self._table_corner_radius(),
1112
+ sort_key=sort_key,
1113
+ sort_ascending=sort_ascending,
1114
+ filtered_column_keys=set(self._model.column_filters),
1115
+ colors=colors,
1116
+ )
1117
+
1118
+ def _draw_footer(self, colors: Mapping[str, str]) -> None:
1119
+ if not self._footer_enabled:
1120
+ self._table_canvas.delete("footer")
1121
+ return
1122
+ self._renderer.draw_footer(
1123
+ self._visible_columns(),
1124
+ self._summary_values(),
1125
+ x_offset=self._x_offset,
1126
+ canvas_width=self._canvas_width,
1127
+ footer_top=self._footer_top(),
1128
+ footer_height=self._footer_height,
1129
+ radius=self._table_corner_radius(),
1130
+ colors=colors,
1131
+ )
1132
+ self._table_canvas.tag_raise("footer")
1133
+
1134
+ def _raise_fixed_layers(self) -> None:
1135
+ self._table_canvas.tag_raise("header")
1136
+ if self._footer_enabled:
1137
+ self._table_canvas.tag_raise("footer")
1138
+ self._table_canvas.tag_raise("table_chrome")
1139
+
1140
+ def _draw_visible_rows(self, colors: Mapping[str, str]) -> None:
1141
+ visible_indices = set(self._visible_row_range())
1142
+ for row_index in list(self._rendered_view_indices):
1143
+ if row_index not in visible_indices:
1144
+ self._table_canvas.delete(f"row_{row_index}")
1145
+ self._rendered_view_indices.remove(row_index)
1146
+ self._action_regions_by_row.pop(row_index, None)
1147
+
1148
+ for row_index in sorted(visible_indices):
1149
+ if row_index not in self._rendered_view_indices:
1150
+ self._draw_row(row_index, colors)
1151
+
1152
+ def _redraw_row(self, row_index: int) -> None:
1153
+ if row_index < 0 or row_index >= len(self._model.view_indices):
1154
+ return
1155
+ if row_index not in self._visible_row_range():
1156
+ return
1157
+ colors = self._theme_colors()
1158
+ self._apply_action_colors(colors)
1159
+ self._table_canvas.delete(f"row_{row_index}")
1160
+ self._rendered_view_indices.discard(row_index)
1161
+ self._action_regions_by_row.pop(row_index, None)
1162
+ self._draw_row(row_index, colors)
1163
+ self._refresh_action_regions()
1164
+ self._table_canvas.tag_raise("header")
1165
+ self._raise_fixed_layers()
1166
+
1167
+ def _draw_row(self, row_index: int, colors: Mapping[str, str]) -> None:
1168
+ source_index = self._model.source_index_for_view_index(row_index)
1169
+ row = self._model.row_for_view_index(row_index)
1170
+ row_style = self._style_for_row(row) if self._enable_style_hooks else None
1171
+ cell_styles = self._styles_for_cells(row) if self._enable_style_hooks else None
1172
+ regions = self._renderer.draw_row(
1173
+ row_index,
1174
+ row,
1175
+ self._visible_columns(),
1176
+ y=self._row_y(row_index),
1177
+ row_height=self._row_height,
1178
+ x_offset=self._x_offset,
1179
+ canvas_width=self._canvas_width,
1180
+ selected=source_index in self._model.selected_source_indices,
1181
+ hovered=row_index == self._hovered_view_index,
1182
+ row_style=row_style,
1183
+ cell_styles=cell_styles,
1184
+ colors=colors,
1185
+ )
1186
+ self._action_regions_by_row[row_index] = regions
1187
+ self._rendered_view_indices.add(row_index)
1188
+
1189
+ def _draw_state(self, colors: Mapping[str, str]) -> None:
1190
+ message = self._loading_message if self._loading else self._empty_message
1191
+ if self._error_message is not None:
1192
+ message = self._error_message
1193
+ elif (
1194
+ not self._loading
1195
+ and (self._model.filter_query.strip() or self._model.column_filters)
1196
+ and self._model.source_data
1197
+ ):
1198
+ message = "No matching records"
1199
+
1200
+ body_top = self._header_height
1201
+ body_height = self._body_height()
1202
+ self._table_canvas.create_rectangle(
1203
+ 0,
1204
+ body_top,
1205
+ self._canvas_width,
1206
+ self._footer_top(),
1207
+ fill=colors["surface_bg"],
1208
+ outline="",
1209
+ tags=("state",),
1210
+ )
1211
+ center_y = body_top + body_height / 2
1212
+ if self._loading:
1213
+ indicator_width = min(160, max(80, self._canvas_width * 0.3))
1214
+ self._table_canvas.create_rectangle(
1215
+ (self._canvas_width - indicator_width) / 2,
1216
+ center_y + 20,
1217
+ (self._canvas_width + indicator_width) / 2,
1218
+ center_y + 24,
1219
+ fill=colors["loading_indicator"],
1220
+ outline="",
1221
+ tags=("state", "loading_indicator"),
1222
+ )
1223
+ self._table_canvas.create_text(
1224
+ self._canvas_width / 2,
1225
+ center_y,
1226
+ text=message,
1227
+ fill=colors["muted_text"],
1228
+ font=self._font,
1229
+ anchor="center",
1230
+ tags=("state", "state_text"),
1231
+ )
1232
+
1233
+ def _refresh_action_regions(self) -> None:
1234
+ self._action_regions = [
1235
+ region
1236
+ for row_index in self._rendered_view_indices
1237
+ for region in self._action_regions_by_row.get(row_index, [])
1238
+ ]
1239
+
1240
+ def _move_action_regions(self, delta_y: float) -> None:
1241
+ if not delta_y:
1242
+ return
1243
+ moved_by_row: dict[int, list[ActionRegion]] = {}
1244
+ for row_index, regions in self._action_regions_by_row.items():
1245
+ moved_regions: list[ActionRegion] = []
1246
+ for region in regions:
1247
+ x1, y1, x2, y2 = region.bounds
1248
+ moved_regions.append(
1249
+ ActionRegion(
1250
+ row_index=region.row_index,
1251
+ column_key=region.column_key,
1252
+ action_key=region.action_key,
1253
+ bounds=(x1, y1 + delta_y, x2, y2 + delta_y),
1254
+ kind=region.kind,
1255
+ )
1256
+ )
1257
+ moved_by_row[row_index] = moved_regions
1258
+ self._action_regions_by_row = moved_by_row
1259
+
1260
+ def _hit_action(self, x: float, y: float) -> ActionRegion | None:
1261
+ for region in self._action_regions:
1262
+ x1, y1, x2, y2 = region.bounds
1263
+ if x1 <= x <= x2 and y1 <= y <= y2:
1264
+ return region
1265
+ return None
1266
+
1267
+ def _visible_row_range(self) -> range:
1268
+ body_height = self._body_height()
1269
+ if body_height <= 0 or not self._model.view_indices:
1270
+ return range(0)
1271
+ start = max(0, int(self._y_offset // self._row_height))
1272
+ end = min(len(self._model.view_indices), int((self._y_offset + body_height) // self._row_height))
1273
+ return range(start, end)
1274
+
1275
+ def _row_y(self, row_index: int) -> float:
1276
+ return self._header_height + row_index * self._row_height - self._y_offset
1277
+
1278
+ def _scroll_view_index_into_view(self, view_index: int) -> None:
1279
+ row_top = view_index * self._row_height
1280
+ row_bottom = row_top + self._row_height
1281
+ viewport_top = self._y_offset
1282
+ viewport_bottom = self._y_offset + self._body_height()
1283
+
1284
+ if row_top < viewport_top:
1285
+ self._set_y_offset(float(row_top))
1286
+ elif row_bottom > viewport_bottom:
1287
+ self._set_y_offset(float(row_bottom - self._body_height()))
1288
+
1289
+ def _row_index_from_y(self, y: float) -> int | None:
1290
+ if y < self._header_height or y > self._footer_top():
1291
+ return None
1292
+ row_index = int((y - self._header_height + self._y_offset) // self._row_height)
1293
+ row_bottom = (row_index + 1) * self._row_height
1294
+ if row_bottom > self._y_offset + self._body_height():
1295
+ return None
1296
+ if 0 <= row_index < len(self._model.view_indices):
1297
+ return row_index
1298
+ return None
1299
+
1300
+ def _row_for_view_index(self, view_index: int) -> RowData:
1301
+ return self._model.row_for_view_index(view_index)
1302
+
1303
+ def _view_index_for_source_index(self, source_index: int) -> int | None:
1304
+ return self._model.view_index_for_source_index(source_index)
1305
+
1306
+ def _column_from_x(self, x: float) -> TableColumn | None:
1307
+ content_x = x + self._x_offset
1308
+ if content_x < 0:
1309
+ return None
1310
+ index = bisect_left(self._column_edges_cache, content_x)
1311
+ if 0 <= index < len(self._column_edge_columns_cache):
1312
+ return self._column_edge_columns_cache[index]
1313
+ return None
1314
+
1315
+ def _visible_columns(self) -> list[TableColumn]:
1316
+ return self._visible_columns_cache
1317
+
1318
+ def _refresh_column_cache(self) -> None:
1319
+ visible_columns = [column for column in self._columns if column.visible]
1320
+ edges: list[float] = []
1321
+ cursor = 0.0
1322
+ for column in visible_columns:
1323
+ cursor += column.width
1324
+ edges.append(cursor)
1325
+ self._visible_columns_cache = visible_columns
1326
+ self._column_edges_cache = tuple(edges)
1327
+ self._column_edge_columns_cache = tuple(visible_columns)
1328
+ self._total_table_width_cache = int(cursor)
1329
+
1330
+ def _set_y_offset(self, value: float) -> None:
1331
+ old_offset = self._y_offset
1332
+ self._y_offset = min(max(0.0, value), self._max_y_offset())
1333
+ if self._y_offset != old_offset:
1334
+ self._sync_scrollbars()
1335
+ self._redraw(full=False, previous_y_offset=old_offset, redraw_fixed=False)
1336
+
1337
+ def _set_x_offset(self, value: float) -> None:
1338
+ old_offset = self._x_offset
1339
+ self._x_offset = min(max(0.0, value), self._max_x_offset())
1340
+ if self._x_offset != old_offset:
1341
+ self._sync_scrollbars()
1342
+ self._redraw(full=True)
1343
+
1344
+ def _clamp_offsets(self) -> None:
1345
+ self._y_offset = min(max(0.0, self._y_offset), self._max_y_offset())
1346
+ self._x_offset = min(max(0.0, self._x_offset), self._max_x_offset())
1347
+
1348
+ def _update_scrollregion(self) -> None:
1349
+ self._table_canvas.configure(
1350
+ scrollregion=(
1351
+ 0,
1352
+ 0,
1353
+ max(self._canvas_width, self._total_table_width()),
1354
+ self._header_height + self._total_body_height() + self._active_footer_height(),
1355
+ )
1356
+ )
1357
+
1358
+ def _sync_scrollbars(self) -> None:
1359
+ total_body_height = self._total_body_height()
1360
+ body_height = self._body_height()
1361
+ if total_body_height <= body_height or total_body_height <= 0:
1362
+ self._vertical_scrollbar.set(0, 1)
1363
+ else:
1364
+ first = self._y_offset / total_body_height
1365
+ last = min(1.0, (self._y_offset + body_height) / total_body_height)
1366
+ self._vertical_scrollbar.set(first, last)
1367
+
1368
+ if self._horizontal_scrollbar is not None:
1369
+ total_width = self._total_table_width()
1370
+ if total_width <= self._canvas_width or total_width <= 0:
1371
+ self._horizontal_scrollbar.set(0, 1)
1372
+ else:
1373
+ first = self._x_offset / total_width
1374
+ last = min(1.0, (self._x_offset + self._canvas_width) / total_width)
1375
+ self._horizontal_scrollbar.set(first, last)
1376
+
1377
+ def _body_height(self) -> int:
1378
+ return max(0, self._footer_top() - self._header_height)
1379
+
1380
+ def _active_footer_height(self) -> int:
1381
+ return self._footer_height if self._footer_enabled else 0
1382
+
1383
+ def _footer_top(self) -> int:
1384
+ return max(self._header_height, self._canvas_height - self._active_footer_height() - self._bottom_cap_height())
1385
+
1386
+ def _total_body_height(self) -> int:
1387
+ return len(self._model.view_indices) * self._row_height
1388
+
1389
+ def _total_table_width(self) -> int:
1390
+ return self._total_table_width_cache
1391
+
1392
+ def _max_y_offset(self) -> float:
1393
+ return max(0.0, self._total_body_height() - self._body_height())
1394
+
1395
+ def _max_x_offset(self) -> float:
1396
+ if not self._horizontal_scroll_enabled:
1397
+ return 0.0
1398
+ return max(0.0, self._total_table_width() - self._canvas_width)
1399
+
1400
+ def _after_style_changed(self) -> None:
1401
+ self._theme_colors_cache = None
1402
+ self._apply_frame_style()
1403
+ self._apply_renderer_style()
1404
+ self._apply_layout_insets()
1405
+ self._redraw(full=True)
1406
+
1407
+ def _apply_layout_insets(self) -> None:
1408
+ if not hasattr(self, "_table_canvas"):
1409
+ return
1410
+ scrollbar_gap = self._scrollbar_gap()
1411
+ self._table_canvas.grid_configure(padx=0, pady=0)
1412
+ self._vertical_scrollbar.grid_configure(padx=(scrollbar_gap, 0), pady=4)
1413
+ if self._horizontal_scrollbar is not None:
1414
+ self._horizontal_scrollbar.grid_configure(padx=(0, scrollbar_gap), pady=(scrollbar_gap, 0))
1415
+
1416
+ def _scrollbar_gap(self) -> int:
1417
+ radius = self._table_corner_radius()
1418
+ if radius <= 0:
1419
+ return 0
1420
+ return max(4, min(8, math.ceil(radius / 2)))
1421
+
1422
+ def _bottom_cap_height(self) -> int:
1423
+ if self._footer_enabled:
1424
+ return 0
1425
+ radius = self._table_corner_radius()
1426
+ if radius <= 0:
1427
+ return 0
1428
+ return max(2, min(6, math.ceil(radius / 3)))
1429
+
1430
+ def _apply_frame_style(self) -> None:
1431
+ self.configure(corner_radius=0, border_width=0, fg_color="transparent")
1432
+
1433
+ def _apply_renderer_style(self) -> None:
1434
+ if not hasattr(self, "_renderer"):
1435
+ return
1436
+ self._renderer.configure_style(
1437
+ cell_padding_x=self._style_number("cell_padding_x"),
1438
+ badge_padding_x=self._style_number("badge_padding_x"),
1439
+ button_padding_x=self._style_number("button_padding_x"),
1440
+ badge_radius=self._style_number("badge_radius"),
1441
+ checkbox_radius=self._style_number("checkbox_radius"),
1442
+ progress_radius=self._style_number("progress_radius"),
1443
+ pill_radius=self._style_number("pill_radius"),
1444
+ action_radius=self._style_number("action_radius"),
1445
+ )
1446
+
1447
+ def _style_number(self, key: str) -> float | None:
1448
+ value = getattr(self._table_style, key)
1449
+ if value is None:
1450
+ return None
1451
+ return max(0.0, float(value))
1452
+
1453
+ def _theme_colors(self) -> dict[str, str]:
1454
+ signature = self._theme_colors_signature()
1455
+ if self._theme_colors_cache is not None and self._theme_colors_cache[0] == signature:
1456
+ return dict(self._theme_colors_cache[1])
1457
+
1458
+ frame_bg = self._table_surface_color(self._theme_frame_color())
1459
+ outside_bg = self._surrounding_color(frame_bg)
1460
+ text = self._theme_color("CTkLabel", "text_color", ("#1f2933", "#f4f6fa"))
1461
+ button_bg = self._theme_color("CTkButton", "fg_color", ("#3b82f6", "#2563eb"))
1462
+ button_hover = self._theme_color("CTkButton", "hover_color", ("#2563eb", "#1d4ed8"))
1463
+ button_text = self._theme_color("CTkButton", "text_color", ("#ffffff", "#ffffff"))
1464
+ entry_border = self._theme_color("CTkEntry", "border_color", ("#c5cbd6", "#3d4350"))
1465
+
1466
+ colors = {
1467
+ "canvas_bg": outside_bg,
1468
+ "surface_bg": self._blend(frame_bg, text, 0.02),
1469
+ "row_bg": self._blend(frame_bg, text, 0.02),
1470
+ "row_alt_bg": self._blend(frame_bg, text, 0.045),
1471
+ "header_bg": self._blend(frame_bg, text, 0.075),
1472
+ "footer_bg": self._blend(frame_bg, text, 0.065),
1473
+ "hover_bg": self._blend(frame_bg, button_bg, 0.12),
1474
+ "selected_bg": self._blend(frame_bg, button_bg, 0.28),
1475
+ "selected_hover_bg": self._blend(frame_bg, button_hover, 0.34),
1476
+ "text": text,
1477
+ "hover_text": text,
1478
+ "selected_text": text,
1479
+ "selected_hover_text": text,
1480
+ "muted_text": self._blend(frame_bg, text, 0.62),
1481
+ "header_text": text,
1482
+ "footer_text": text,
1483
+ "divider": self._blend(frame_bg, text, 0.13),
1484
+ "header_divider": self._blend(frame_bg, text, 0.10),
1485
+ "table_border": self._table_border_theme_color(self._blend(frame_bg, text, 0.16)),
1486
+ "sort_indicator": button_bg,
1487
+ "filter_indicator": button_bg,
1488
+ "badge_default_bg": self._blend(frame_bg, text, 0.18),
1489
+ "badge_text": text,
1490
+ "pill_bg": self._blend(frame_bg, button_bg, 0.14),
1491
+ "pill_text": text,
1492
+ "progress_bg": self._blend(frame_bg, text, 0.13),
1493
+ "progress_fill": button_bg,
1494
+ "progress_text": text,
1495
+ "link_text": button_bg,
1496
+ "checkbox_fill": frame_bg,
1497
+ "checkbox_fill_checked": button_bg,
1498
+ "checkbox_border": entry_border,
1499
+ "checkbox_check": button_text,
1500
+ "action_bg": self._blend(frame_bg, button_bg, 0.16),
1501
+ "action_border": self._blend(frame_bg, button_bg, 0.32),
1502
+ "action_text": text,
1503
+ "loading_indicator": button_bg,
1504
+ }
1505
+ for style_key, color_key in TABLE_STYLE_COLOR_MAP.items():
1506
+ value = getattr(self._table_style, style_key)
1507
+ if value is not None:
1508
+ colors[color_key] = self._resolve_color(value)
1509
+ self._theme_colors_cache = (signature, colors)
1510
+ return dict(colors)
1511
+
1512
+ def _theme_colors_signature(self) -> tuple[str, ...]:
1513
+ return (
1514
+ ctk.get_appearance_mode().lower(),
1515
+ repr(self._table_fg_color),
1516
+ repr(self._table_border_color),
1517
+ repr(self._default_table_corner_radius),
1518
+ repr(self._default_table_border_width),
1519
+ repr(self._raw_surrounding_options()),
1520
+ repr(ctk.ThemeManager.theme.get("CTkFrame", {}).get("fg_color")),
1521
+ repr(ctk.ThemeManager.theme.get("CTkLabel", {}).get("text_color")),
1522
+ repr(ctk.ThemeManager.theme.get("CTkButton", {}).get("fg_color")),
1523
+ repr(ctk.ThemeManager.theme.get("CTkButton", {}).get("hover_color")),
1524
+ repr(ctk.ThemeManager.theme.get("CTkButton", {}).get("text_color")),
1525
+ repr(ctk.ThemeManager.theme.get("CTkEntry", {}).get("border_color")),
1526
+ repr(self._table_style),
1527
+ )
1528
+
1529
+ def _raw_widget_option(self, option: str) -> Any:
1530
+ try:
1531
+ return self.cget(option)
1532
+ except Exception:
1533
+ return None
1534
+
1535
+ def _raw_surrounding_options(self) -> tuple[str, ...]:
1536
+ values: list[str] = []
1537
+ parent = self.master
1538
+ while parent is not None:
1539
+ widget_values: list[str] = []
1540
+ for option in ("fg_color", "bg", "background"):
1541
+ try:
1542
+ widget_values.append(repr(parent.cget(option)))
1543
+ except Exception:
1544
+ continue
1545
+ if widget_values:
1546
+ values.extend(widget_values)
1547
+ break
1548
+ parent = getattr(parent, "master", None)
1549
+ return tuple(values)
1550
+
1551
+ def _apply_action_colors(self, colors: dict[str, str]) -> None:
1552
+ for column in self._columns:
1553
+ for action in column.actions:
1554
+ if action.fg_color is not None:
1555
+ colors[f"action_fill_{action.key}"] = self._resolve_color(action.fg_color)
1556
+ if action.text_color is not None:
1557
+ colors[f"action_text_{action.key}"] = self._resolve_color(action.text_color)
1558
+ if action.border_color is not None:
1559
+ colors[f"action_border_{action.key}"] = self._resolve_color(action.border_color)
1560
+
1561
+ def _theme_color(self, widget: str, key: str, fallback: ColorValue) -> str:
1562
+ theme_value = ctk.ThemeManager.theme.get(widget, {}).get(key, fallback)
1563
+ return self._resolve_color(theme_value)
1564
+
1565
+ def _widget_color(self, option: str, fallback: ColorValue) -> str:
1566
+ try:
1567
+ value = self.cget(option)
1568
+ except Exception:
1569
+ value = fallback
1570
+ if value is None or value == "transparent":
1571
+ value = fallback
1572
+ return self._resolve_color(value)
1573
+
1574
+ def _table_surface_color(self, fallback: ColorValue) -> str:
1575
+ value = self._table_fg_color
1576
+ if value is None or value == "transparent":
1577
+ value = fallback
1578
+ return self._resolve_color(value)
1579
+
1580
+ def _theme_frame_color(self) -> ColorValue:
1581
+ theme_value = ctk.ThemeManager.theme.get("CTkFrame", {}).get("fg_color")
1582
+ if theme_value is None:
1583
+ return ("#f7f8fb", "#2b2b2b")
1584
+ return cast(ColorValue, theme_value)
1585
+
1586
+ def _table_border_theme_color(self, fallback: ColorValue) -> str:
1587
+ value = self._table_border_color
1588
+ if value is None or value == "transparent":
1589
+ value = fallback
1590
+ return self._resolve_color(value)
1591
+
1592
+ def _surrounding_color(self, fallback: ColorValue) -> str:
1593
+ parent = self.master
1594
+ while parent is not None:
1595
+ for option in ("fg_color", "bg", "background"):
1596
+ try:
1597
+ value = parent.cget(option)
1598
+ except Exception:
1599
+ continue
1600
+ if value is None or value == "transparent":
1601
+ continue
1602
+ return self._resolve_color(value)
1603
+ parent = getattr(parent, "master", None)
1604
+ return self._resolve_color(fallback)
1605
+
1606
+ def _table_corner_radius(self) -> float:
1607
+ if self._table_style.corner_radius is not None:
1608
+ return max(0.0, float(self._table_style.corner_radius))
1609
+ return max(0.0, self._default_table_corner_radius)
1610
+
1611
+ def _table_border_width(self) -> float:
1612
+ if self._table_style.border_width is not None:
1613
+ return max(0.0, float(self._table_style.border_width))
1614
+ return max(0.0, self._default_table_border_width)
1615
+
1616
+ def _widget_number(self, option: str, fallback: float) -> float:
1617
+ try:
1618
+ value = self.cget(option)
1619
+ except Exception:
1620
+ return fallback
1621
+ if isinstance(value, (tuple, list)):
1622
+ value = value[0] if value else fallback
1623
+ try:
1624
+ return max(0.0, float(value))
1625
+ except (TypeError, ValueError):
1626
+ return fallback
1627
+
1628
+ def _resolve_color(self, color: Any) -> str:
1629
+ try:
1630
+ apply_mode = self._apply_appearance_mode
1631
+ return str(apply_mode(color))
1632
+ except Exception:
1633
+ if isinstance(color, (tuple, list)):
1634
+ dark = ctk.get_appearance_mode().lower() == "dark"
1635
+ return str(color[1 if dark and len(color) > 1 else 0])
1636
+ return str(color)
1637
+
1638
+ def _blend(self, base: str, overlay: str, amount: float) -> str:
1639
+ base_rgb = self._color_to_rgb(base)
1640
+ overlay_rgb = self._color_to_rgb(overlay)
1641
+ amount = min(1.0, max(0.0, amount))
1642
+ mixed = tuple(round(base_rgb[index] * (1 - amount) + overlay_rgb[index] * amount) for index in range(3))
1643
+ return f"#{mixed[0]:02x}{mixed[1]:02x}{mixed[2]:02x}"
1644
+
1645
+ def _color_to_rgb(self, color: str) -> tuple[int, int, int]:
1646
+ try:
1647
+ red, green, blue = self.winfo_rgb(color)
1648
+ return (red // 256, green // 256, blue // 256)
1649
+ except tk.TclError:
1650
+ return (127, 127, 127)
1651
+
1652
+ def _set_appearance_mode(self, mode_string: str) -> None:
1653
+ try:
1654
+ super()._set_appearance_mode(mode_string)
1655
+ finally:
1656
+ if hasattr(self, "_theme_colors_cache"):
1657
+ self._theme_colors_cache = None
1658
+ if hasattr(self, "_table_canvas"):
1659
+ self._redraw(full=True)