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,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)
|