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,1239 @@
1
+ """Canvas drawing helpers for CTkDataTable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import tkinter as tk
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal
11
+
12
+ from ._utils import parse_datetime
13
+ from .table_column import BadgeStyle, TableColumn
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ActionRegion:
18
+ """Hit-test rectangle for a Canvas-rendered interactive cell area."""
19
+
20
+ row_index: int
21
+ column_key: str
22
+ action_key: str
23
+ bounds: tuple[float, float, float, float]
24
+ kind: Literal["action", "link", "checkbox"] = "action"
25
+
26
+
27
+ class TableRenderer:
28
+ """Draw table headers, rows, and typed cells onto a tkinter Canvas."""
29
+
30
+ DEFAULT_CELL_PADDING_X = 12
31
+ DEFAULT_BADGE_PADDING_X = 10
32
+ DEFAULT_BUTTON_PADDING_X = 12
33
+ DEFAULT_CHECKBOX_RADIUS = 4.0
34
+ DEFAULT_ACTION_RADIUS = 5.0
35
+
36
+ def __init__(
37
+ self,
38
+ canvas: tk.Canvas,
39
+ font: Any,
40
+ header_font: Any,
41
+ color_resolver: Callable[[Any], str] | None = None,
42
+ ) -> None:
43
+ """Initialize the renderer with a target Canvas and fonts."""
44
+
45
+ self.canvas = canvas
46
+ self.font = font
47
+ self.header_font = header_font
48
+ self._resolve_color = color_resolver or (lambda color: str(color))
49
+ self.cell_padding_x: float = self.DEFAULT_CELL_PADDING_X
50
+ self.badge_padding_x: float = self.DEFAULT_BADGE_PADDING_X
51
+ self.button_padding_x: float = self.DEFAULT_BUTTON_PADDING_X
52
+ self.badge_radius: float | None = None
53
+ self.checkbox_radius: float = self.DEFAULT_CHECKBOX_RADIUS
54
+ self.progress_radius: float | None = None
55
+ self.pill_radius: float | None = None
56
+ self.action_radius: float = self.DEFAULT_ACTION_RADIUS
57
+ self._fit_text_cache: dict[tuple[int, str, float], str] = {}
58
+ self._fit_text_cache_limit = 2048
59
+
60
+ def configure_style(
61
+ self,
62
+ *,
63
+ cell_padding_x: float | None = None,
64
+ badge_padding_x: float | None = None,
65
+ button_padding_x: float | None = None,
66
+ badge_radius: float | None = None,
67
+ checkbox_radius: float | None = None,
68
+ progress_radius: float | None = None,
69
+ pill_radius: float | None = None,
70
+ action_radius: float | None = None,
71
+ ) -> None:
72
+ """Apply table-wide spacing and radius options."""
73
+
74
+ self.cell_padding_x = self._dimension(cell_padding_x, self.DEFAULT_CELL_PADDING_X)
75
+ self.badge_padding_x = self._dimension(badge_padding_x, self.DEFAULT_BADGE_PADDING_X)
76
+ self.button_padding_x = self._dimension(button_padding_x, self.DEFAULT_BUTTON_PADDING_X)
77
+ self.badge_radius = self._optional_dimension(badge_radius)
78
+ self.checkbox_radius = self._dimension(checkbox_radius, self.DEFAULT_CHECKBOX_RADIUS)
79
+ self.progress_radius = self._optional_dimension(progress_radius)
80
+ self.pill_radius = self._optional_dimension(pill_radius)
81
+ self.action_radius = self._dimension(action_radius, self.DEFAULT_ACTION_RADIUS)
82
+ self._fit_text_cache.clear()
83
+
84
+ def draw_surface(
85
+ self,
86
+ *,
87
+ canvas_width: int,
88
+ canvas_height: int,
89
+ radius: float,
90
+ colors: Mapping[str, str],
91
+ ) -> None:
92
+ """Draw the rounded table surface behind headers and rows."""
93
+
94
+ self.canvas.delete("table_surface")
95
+ if canvas_width <= 0 or canvas_height <= 0:
96
+ return
97
+
98
+ radius = self._bounded_radius(radius, canvas_width, canvas_height)
99
+ self._rounded_rectangle(
100
+ 0,
101
+ 0,
102
+ canvas_width,
103
+ canvas_height,
104
+ radius=radius,
105
+ fill=colors["surface_bg"],
106
+ outline="",
107
+ tags=("table_surface",),
108
+ )
109
+ self.canvas.tag_lower("table_surface")
110
+
111
+ def draw_chrome(
112
+ self,
113
+ *,
114
+ canvas_width: int,
115
+ canvas_height: int,
116
+ radius: float,
117
+ border_width: float,
118
+ colors: Mapping[str, str],
119
+ bottom_cap_height: float = 0,
120
+ ) -> None:
121
+ """Mask square canvas corners and draw the table border."""
122
+
123
+ self.canvas.delete("table_chrome")
124
+ if canvas_width <= 0 or canvas_height <= 0:
125
+ return
126
+
127
+ radius = self._bounded_radius(radius, canvas_width, canvas_height)
128
+ border_width = max(0.0, float(border_width))
129
+ bottom_cap_height = max(0.0, min(float(bottom_cap_height), canvas_height))
130
+
131
+ if bottom_cap_height > 0:
132
+ self.canvas.create_rectangle(
133
+ 0,
134
+ canvas_height - bottom_cap_height,
135
+ canvas_width,
136
+ canvas_height,
137
+ fill=colors["surface_bg"],
138
+ outline="",
139
+ tags=("table_chrome", "bottom_cap"),
140
+ )
141
+
142
+ if radius > 0:
143
+ self._draw_corner_masks(
144
+ canvas_width,
145
+ canvas_height,
146
+ radius,
147
+ background=colors["canvas_bg"],
148
+ tags=("table_chrome", "corner_mask"),
149
+ )
150
+
151
+ if border_width > 0:
152
+ inset = border_width / 2
153
+ self._rounded_rectangle(
154
+ inset,
155
+ inset,
156
+ canvas_width - inset,
157
+ canvas_height - inset,
158
+ radius=max(0.0, radius - inset),
159
+ fill="",
160
+ outline=colors["table_border"],
161
+ tags=("table_chrome", "table_border"),
162
+ width=border_width,
163
+ )
164
+ self.canvas.tag_raise("table_chrome")
165
+
166
+ def draw_header(
167
+ self,
168
+ columns: list[TableColumn],
169
+ *,
170
+ x_offset: float,
171
+ canvas_width: int,
172
+ header_height: int,
173
+ radius: float,
174
+ sort_key: str | None,
175
+ sort_ascending: bool,
176
+ filtered_column_keys: set[str] | None = None,
177
+ colors: Mapping[str, str],
178
+ ) -> None:
179
+ """Draw the fixed table header row."""
180
+
181
+ filtered_column_keys = filtered_column_keys or set()
182
+ self._draw_top_background(
183
+ 0,
184
+ 0,
185
+ canvas_width,
186
+ header_height,
187
+ radius=radius,
188
+ fill=colors["header_bg"],
189
+ tags=("header", "header_background"),
190
+ )
191
+
192
+ x = -x_offset
193
+ for column in columns:
194
+ left = x
195
+ right = x + column.width
196
+ x = right
197
+ if right < 0 or left > canvas_width:
198
+ continue
199
+
200
+ column_tag = _tag_value(column.key)
201
+ self.canvas.create_line(
202
+ right,
203
+ 8,
204
+ right,
205
+ header_height - 8,
206
+ fill=colors["header_divider"],
207
+ tags=("header", f"header_divider_{column_tag}"),
208
+ )
209
+
210
+ indicator_width = 16 if column.key == sort_key else 0
211
+ filter_width = 12 if column.key in filtered_column_keys else 0
212
+ max_text_width = max(0, column.width - (self.cell_padding_x * 2) - indicator_width)
213
+ max_text_width = max(0, max_text_width - filter_width)
214
+ text = self._fit_text(column.title, max_text_width, self.header_font)
215
+ text_x, anchor = self._text_position(left, column.width - indicator_width - filter_width, column.align)
216
+ self.canvas.create_text(
217
+ text_x,
218
+ header_height / 2,
219
+ text=text,
220
+ anchor=anchor,
221
+ fill=colors["header_text"],
222
+ font=self.header_font,
223
+ tags=("header", f"header_text_{column_tag}"),
224
+ )
225
+
226
+ if column.key == sort_key:
227
+ self._draw_sort_indicator(
228
+ right - self.cell_padding_x - 8,
229
+ header_height / 2,
230
+ ascending=sort_ascending,
231
+ color=colors["sort_indicator"],
232
+ tags=("header", f"sort_{column_tag}"),
233
+ )
234
+ if column.key in filtered_column_keys:
235
+ self.canvas.create_oval(
236
+ right - self.cell_padding_x - indicator_width - 8,
237
+ header_height / 2 - 4,
238
+ right - self.cell_padding_x - indicator_width,
239
+ header_height / 2 + 4,
240
+ fill=colors["filter_indicator"],
241
+ outline="",
242
+ tags=("header", f"filter_{column_tag}"),
243
+ )
244
+
245
+ self.canvas.create_line(
246
+ 0,
247
+ header_height - 1,
248
+ canvas_width,
249
+ header_height - 1,
250
+ fill=colors["divider"],
251
+ tags=("header", "header_bottom_divider"),
252
+ )
253
+
254
+ def draw_footer(
255
+ self,
256
+ columns: list[TableColumn],
257
+ summaries: Mapping[str, str],
258
+ *,
259
+ x_offset: float,
260
+ canvas_width: int,
261
+ footer_top: int,
262
+ footer_height: int,
263
+ radius: float,
264
+ colors: Mapping[str, str],
265
+ ) -> None:
266
+ """Draw a fixed summary footer row."""
267
+
268
+ self.canvas.delete("footer")
269
+ self._draw_bottom_background(
270
+ 0,
271
+ footer_top,
272
+ canvas_width,
273
+ footer_top + footer_height,
274
+ radius=radius,
275
+ fill=colors["footer_bg"],
276
+ tags=("footer", "footer_background"),
277
+ )
278
+ self.canvas.create_line(
279
+ 0,
280
+ footer_top,
281
+ canvas_width,
282
+ footer_top,
283
+ fill=colors["divider"],
284
+ tags=("footer", "footer_top_divider"),
285
+ )
286
+
287
+ x = -x_offset
288
+ for column in columns:
289
+ left = x
290
+ right = x + column.width
291
+ x = right
292
+ if right < 0 or left > canvas_width:
293
+ continue
294
+
295
+ column_tag = _tag_value(column.key)
296
+ self.canvas.create_line(
297
+ right,
298
+ footer_top + 8,
299
+ right,
300
+ footer_top + footer_height - 8,
301
+ fill=colors["header_divider"],
302
+ tags=("footer", f"footer_divider_{column_tag}"),
303
+ )
304
+ text = summaries.get(column.key, "")
305
+ if not text:
306
+ continue
307
+ fitted = self._fit_text(text, max(0, column.width - self.cell_padding_x * 2), self.header_font)
308
+ text_x, anchor = self._text_position(left, column.width, column.align)
309
+ self.canvas.create_text(
310
+ text_x,
311
+ footer_top + footer_height / 2,
312
+ text=fitted,
313
+ anchor=anchor,
314
+ fill=colors["footer_text"],
315
+ font=self.header_font,
316
+ tags=("footer", f"footer_text_{column_tag}"),
317
+ )
318
+
319
+ def draw_row(
320
+ self,
321
+ row_index: int,
322
+ row: Mapping[str, Any],
323
+ columns: list[TableColumn],
324
+ *,
325
+ y: float,
326
+ row_height: int,
327
+ x_offset: float,
328
+ canvas_width: int,
329
+ selected: bool,
330
+ hovered: bool,
331
+ row_style: Mapping[str, Any] | None = None,
332
+ cell_styles: Mapping[str, Mapping[str, Any]] | None = None,
333
+ colors: Mapping[str, str],
334
+ ) -> list[ActionRegion]:
335
+ """Draw a single visible table row and return any action hit regions."""
336
+
337
+ row_tag = f"row_{row_index}"
338
+ if selected and hovered:
339
+ row_bg = colors["selected_hover_bg"]
340
+ elif selected:
341
+ row_bg = colors["selected_bg"]
342
+ elif hovered:
343
+ row_bg = colors["hover_bg"]
344
+ elif row_style and row_style.get("fg_color") is not None:
345
+ row_bg = self._resolve_color(row_style["fg_color"])
346
+ elif row_index % 2:
347
+ row_bg = colors["row_alt_bg"]
348
+ else:
349
+ row_bg = colors["row_bg"]
350
+
351
+ if selected and hovered:
352
+ row_text_color = colors["selected_hover_text"]
353
+ elif selected:
354
+ row_text_color = colors["selected_text"]
355
+ elif hovered:
356
+ row_text_color = colors["hover_text"]
357
+ else:
358
+ row_text_color = None
359
+ if row_style and row_style.get("text_color") is not None:
360
+ row_text_color = self._resolve_color(row_style["text_color"])
361
+
362
+ self.canvas.create_rectangle(
363
+ 0,
364
+ y,
365
+ canvas_width,
366
+ y + row_height,
367
+ fill=row_bg,
368
+ outline="",
369
+ tags=("body", row_tag, "row_background"),
370
+ )
371
+
372
+ regions: list[ActionRegion] = []
373
+ x = -x_offset
374
+ for column in columns:
375
+ left = x
376
+ right = x + column.width
377
+ x = right
378
+ if right < 0 or left > canvas_width:
379
+ continue
380
+ regions.extend(
381
+ self._draw_cell(
382
+ row_index,
383
+ row,
384
+ column,
385
+ left=left,
386
+ y=y,
387
+ width=column.width,
388
+ height=row_height,
389
+ row_text_color=row_text_color,
390
+ cell_style=(cell_styles or {}).get(column.key),
391
+ colors=colors,
392
+ )
393
+ )
394
+
395
+ self.canvas.create_line(
396
+ 0,
397
+ y + row_height - 1,
398
+ canvas_width,
399
+ y + row_height - 1,
400
+ fill=colors["divider"],
401
+ tags=("body", row_tag, "row_divider"),
402
+ )
403
+ return regions
404
+
405
+ def measure_action_regions(
406
+ self,
407
+ row_index: int,
408
+ column: TableColumn,
409
+ *,
410
+ left: float,
411
+ y: float,
412
+ width: int,
413
+ height: int,
414
+ ) -> list[ActionRegion]:
415
+ """Return action button hit regions for a cell without drawing it."""
416
+
417
+ if column.type != "action" or not column.actions:
418
+ return []
419
+ return self._action_regions(row_index, column, left=left, y=y, width=width, height=height)
420
+
421
+ def _draw_cell(
422
+ self,
423
+ row_index: int,
424
+ row: Mapping[str, Any],
425
+ column: TableColumn,
426
+ *,
427
+ left: float,
428
+ y: float,
429
+ width: int,
430
+ height: int,
431
+ row_text_color: str | None,
432
+ cell_style: Mapping[str, Any] | None,
433
+ colors: Mapping[str, str],
434
+ ) -> list[ActionRegion]:
435
+ value = row.get(column.key)
436
+ column_tag = _tag_value(column.key)
437
+ tags = ("body", f"row_{row_index}", f"cell_{row_index}_{column_tag}")
438
+ if cell_style and cell_style.get("fg_color") is not None:
439
+ self.canvas.create_rectangle(
440
+ left,
441
+ y,
442
+ left + width,
443
+ y + height,
444
+ fill=self._resolve_color(cell_style["fg_color"]),
445
+ outline="",
446
+ tags=tags + ("cell_background",),
447
+ )
448
+ cell_text_color = row_text_color
449
+ if cell_style and cell_style.get("text_color") is not None:
450
+ cell_text_color = self._resolve_color(cell_style["text_color"])
451
+
452
+ if column.type == "badge":
453
+ self._draw_badge(value, row, column, left, y, width, height, colors, tags, text_color=cell_text_color)
454
+ return []
455
+ if column.type == "checkbox":
456
+ return self._draw_checkbox(row_index, column.key, bool(value), left, y, width, height, colors, tags)
457
+ if column.type == "progress":
458
+ self._draw_progress(value, column, left, y, width, height, colors, tags)
459
+ return []
460
+ if column.type == "action":
461
+ return self._draw_actions(row_index, column, left, y, width, height, colors, tags)
462
+ if column.type == "pill_list":
463
+ self._draw_pill_list(value, column, left, y, width, height, colors, tags, text_color=cell_text_color)
464
+ return []
465
+ if column.type == "link":
466
+ return self._draw_link(
467
+ row_index,
468
+ value,
469
+ row,
470
+ column,
471
+ left,
472
+ y,
473
+ width,
474
+ height,
475
+ colors,
476
+ tags,
477
+ text_color=cell_text_color,
478
+ )
479
+
480
+ text = self._format_value(value, row, column)
481
+ align = (
482
+ "right"
483
+ if column.type in {"number", "percentage", "currency"} and column.align == "left"
484
+ else column.align
485
+ )
486
+ max_width = max(0, width - (self.cell_padding_x * 2))
487
+ fitted = self._fit_text(text, max_width, self.font)
488
+ text_x, anchor = self._text_position(left, width, align)
489
+ self.canvas.create_text(
490
+ text_x,
491
+ y + height / 2,
492
+ text=fitted,
493
+ anchor=anchor,
494
+ fill=cell_text_color or colors["text"],
495
+ font=self.font,
496
+ tags=tags,
497
+ )
498
+ return []
499
+
500
+ def _draw_badge(
501
+ self,
502
+ value: Any,
503
+ row: Mapping[str, Any],
504
+ column: TableColumn,
505
+ left: float,
506
+ y: float,
507
+ width: int,
508
+ height: int,
509
+ colors: Mapping[str, str],
510
+ tags: tuple[str, ...],
511
+ *,
512
+ text_color: str | None = None,
513
+ ) -> None:
514
+ style = self._resolve_badge_style(value, row, column, colors)
515
+ text = self._fit_text(style.text, max(0, width - self.cell_padding_x * 2), self.font)
516
+ text_width = min(self.font.measure(text), max(0, width - self.cell_padding_x * 2))
517
+ badge_width = min(width - self.cell_padding_x * 2, text_width + self.badge_padding_x * 2)
518
+ badge_height = min(26, max(20, height - 14))
519
+
520
+ if column.align == "right":
521
+ badge_left = left + width - self.cell_padding_x - badge_width
522
+ elif column.align == "center":
523
+ badge_left = left + (width - badge_width) / 2
524
+ else:
525
+ badge_left = left + self.cell_padding_x
526
+
527
+ badge_top = y + (height - badge_height) / 2
528
+ self._rounded_rectangle(
529
+ badge_left,
530
+ badge_top,
531
+ badge_left + badge_width,
532
+ badge_top + badge_height,
533
+ radius=self._shape_radius(self.badge_radius, badge_height / 2, badge_width, badge_height),
534
+ fill=self._resolve_color(style.fill_color),
535
+ outline="",
536
+ tags=tags + (f"badge_{_tag_value(column.key)}",),
537
+ )
538
+ self.canvas.create_text(
539
+ badge_left + badge_width / 2,
540
+ y + height / 2,
541
+ text=text,
542
+ anchor="center",
543
+ fill=text_color
544
+ or (self._resolve_color(style.text_color) if style.text_color is not None else colors["badge_text"]),
545
+ font=self.font,
546
+ tags=tags + (f"badge_text_{_tag_value(column.key)}",),
547
+ )
548
+
549
+ def _draw_checkbox(
550
+ self,
551
+ row_index: int,
552
+ column_key: str,
553
+ checked: bool,
554
+ left: float,
555
+ y: float,
556
+ width: int,
557
+ height: int,
558
+ colors: Mapping[str, str],
559
+ tags: tuple[str, ...],
560
+ ) -> list[ActionRegion]:
561
+ box_size = min(18, max(14, height - 18))
562
+ box_left = left + (width - box_size) / 2
563
+ box_top = y + (height - box_size) / 2
564
+ fill = colors["checkbox_fill_checked"] if checked else colors["checkbox_fill"]
565
+ self._rounded_rectangle(
566
+ box_left,
567
+ box_top,
568
+ box_left + box_size,
569
+ box_top + box_size,
570
+ radius=self._shape_radius(self.checkbox_radius, self.DEFAULT_CHECKBOX_RADIUS, box_size, box_size),
571
+ fill=fill,
572
+ outline=colors["checkbox_border"],
573
+ tags=tags + ("checkbox",),
574
+ )
575
+ if checked:
576
+ check_points = [
577
+ box_left + box_size * 0.25,
578
+ box_top + box_size * 0.52,
579
+ box_left + box_size * 0.43,
580
+ box_top + box_size * 0.70,
581
+ box_left + box_size * 0.76,
582
+ box_top + box_size * 0.30,
583
+ ]
584
+ self.canvas.create_line(
585
+ check_points,
586
+ fill=colors["checkbox_check"],
587
+ width=2,
588
+ capstyle="round",
589
+ joinstyle="round",
590
+ tags=tags + ("checkbox_check",),
591
+ )
592
+ hit_padding = 4
593
+ return [
594
+ ActionRegion(
595
+ row_index=row_index,
596
+ column_key=column_key,
597
+ action_key="checkbox",
598
+ bounds=(
599
+ max(left, box_left - hit_padding),
600
+ max(y, box_top - hit_padding),
601
+ min(left + width, box_left + box_size + hit_padding),
602
+ min(y + height, box_top + box_size + hit_padding),
603
+ ),
604
+ kind="checkbox",
605
+ )
606
+ ]
607
+
608
+ def _draw_progress(
609
+ self,
610
+ value: Any,
611
+ column: TableColumn,
612
+ left: float,
613
+ y: float,
614
+ width: int,
615
+ height: int,
616
+ colors: Mapping[str, str],
617
+ tags: tuple[str, ...],
618
+ ) -> None:
619
+ number = self._float_value(value)
620
+ if number is None:
621
+ return
622
+
623
+ span = column.progress_max - column.progress_min
624
+ ratio = min(1.0, max(0.0, (number - column.progress_min) / span))
625
+ percent = ratio * 100
626
+ bar_width = max(0.0, width - self.cell_padding_x * 2)
627
+ if bar_width <= 0:
628
+ return
629
+
630
+ bar_height = min(18, max(12, height - 18))
631
+ bar_left = left + self.cell_padding_x
632
+ bar_top = y + (height - bar_height) / 2
633
+ track_color = (
634
+ self._resolve_color(column.progress_background_color)
635
+ if column.progress_background_color is not None
636
+ else colors["progress_bg"]
637
+ )
638
+ fill_color = (
639
+ self._resolve_color(column.progress_color) if column.progress_color is not None else colors["progress_fill"]
640
+ )
641
+ self._rounded_rectangle(
642
+ bar_left,
643
+ bar_top,
644
+ bar_left + bar_width,
645
+ bar_top + bar_height,
646
+ radius=self._shape_radius(self.progress_radius, bar_height / 2, bar_width, bar_height),
647
+ fill=track_color,
648
+ outline="",
649
+ tags=tags + ("progress_track",),
650
+ )
651
+ fill_width = bar_width * ratio
652
+ if fill_width > 0:
653
+ self._rounded_rectangle(
654
+ bar_left,
655
+ bar_top,
656
+ bar_left + fill_width,
657
+ bar_top + bar_height,
658
+ radius=self._shape_radius(self.progress_radius, bar_height / 2, fill_width, bar_height),
659
+ fill=fill_color,
660
+ outline="",
661
+ tags=tags + ("progress_fill",),
662
+ )
663
+
664
+ if not column.progress_show_text:
665
+ return
666
+ try:
667
+ text = column.progress_text_format.format(
668
+ value=number,
669
+ minimum=column.progress_min,
670
+ maximum=column.progress_max,
671
+ min=column.progress_min,
672
+ max=column.progress_max,
673
+ percent=percent,
674
+ ratio=ratio,
675
+ )
676
+ except (KeyError, IndexError, ValueError):
677
+ text = f"{percent:.0f}%"
678
+ fitted = self._fit_text(text, max(0, bar_width - 6), self.font)
679
+ self.canvas.create_text(
680
+ bar_left + bar_width / 2,
681
+ y + height / 2,
682
+ text=fitted,
683
+ anchor="center",
684
+ fill=colors["progress_text"],
685
+ font=self.font,
686
+ tags=tags + ("progress_text",),
687
+ )
688
+
689
+ def _draw_link(
690
+ self,
691
+ row_index: int,
692
+ value: Any,
693
+ row: Mapping[str, Any],
694
+ column: TableColumn,
695
+ left: float,
696
+ y: float,
697
+ width: int,
698
+ height: int,
699
+ colors: Mapping[str, str],
700
+ tags: tuple[str, ...],
701
+ *,
702
+ text_color: str | None = None,
703
+ ) -> list[ActionRegion]:
704
+ text = self._format_value(value, row, column)
705
+ if not text:
706
+ return []
707
+
708
+ max_width = max(0, width - self.cell_padding_x * 2)
709
+ fitted = self._fit_text(text, max_width, self.font)
710
+ if not fitted:
711
+ return []
712
+
713
+ text_x, anchor = self._text_position(left, width, column.align)
714
+ link_color = (
715
+ text_color
716
+ or (self._resolve_color(column.link_color) if column.link_color is not None else colors["link_text"])
717
+ )
718
+ self.canvas.create_text(
719
+ text_x,
720
+ y + height / 2,
721
+ text=fitted,
722
+ anchor=anchor,
723
+ fill=link_color,
724
+ font=self.font,
725
+ tags=tags + ("link_text",),
726
+ )
727
+
728
+ text_width = min(self.font.measure(fitted), max_width)
729
+ if anchor == "e":
730
+ text_left = text_x - text_width
731
+ text_right = text_x
732
+ elif anchor == "center":
733
+ text_left = text_x - text_width / 2
734
+ text_right = text_x + text_width / 2
735
+ else:
736
+ text_left = text_x
737
+ text_right = text_x + text_width
738
+
739
+ underline_y = y + height / 2 + 8
740
+ self.canvas.create_line(
741
+ text_left,
742
+ underline_y,
743
+ text_right,
744
+ underline_y,
745
+ fill=link_color,
746
+ tags=tags + ("link_underline",),
747
+ )
748
+ return [
749
+ ActionRegion(
750
+ row_index=row_index,
751
+ column_key=column.key,
752
+ action_key="link",
753
+ bounds=(max(left, text_left), y + 4, min(left + width, text_right), y + height - 4),
754
+ kind="link",
755
+ )
756
+ ]
757
+
758
+ def _draw_pill_list(
759
+ self,
760
+ value: Any,
761
+ column: TableColumn,
762
+ left: float,
763
+ y: float,
764
+ width: int,
765
+ height: int,
766
+ colors: Mapping[str, str],
767
+ tags: tuple[str, ...],
768
+ *,
769
+ text_color: str | None = None,
770
+ ) -> None:
771
+ values = self._pill_values(value)
772
+ if not values:
773
+ return
774
+
775
+ gap = 6
776
+ available = max(0.0, width - self.cell_padding_x * 2)
777
+ if available <= 0:
778
+ return
779
+ pill_height = min(24, max(18, height - 16))
780
+ pill_padding = 9
781
+
782
+ segments: list[tuple[str, float, str]] = []
783
+ for raw_text in values:
784
+ fitted = self._fit_text(raw_text, max(0, available - pill_padding * 2), self.font)
785
+ pill_width = min(available, self.font.measure(fitted) + pill_padding * 2)
786
+ needed_width = pill_width if not segments else gap + pill_width
787
+ if self._pill_total_width(segments, gap) + needed_width <= available:
788
+ fill_color = self._pill_fill_color(raw_text, column, colors)
789
+ segments.append((fitted, pill_width, fill_color))
790
+ else:
791
+ break
792
+
793
+ if not segments:
794
+ return
795
+
796
+ while len(segments) < len(values):
797
+ hidden_count = len(values) - len(segments)
798
+ more_text = f"+{hidden_count}"
799
+ more_width = min(available, self.font.measure(more_text) + pill_padding * 2)
800
+ if self._pill_total_width(segments, gap) + gap + more_width <= available:
801
+ segments.append((more_text, more_width, colors["pill_bg"]))
802
+ break
803
+ segments.pop()
804
+ if not segments:
805
+ segments.append((f"+{len(values)}", min(available, more_width), colors["pill_bg"]))
806
+ break
807
+
808
+ total_width = self._pill_total_width(segments, gap)
809
+ if column.align == "right":
810
+ cursor = left + width - self.cell_padding_x - total_width
811
+ elif column.align == "center":
812
+ cursor = left + (width - total_width) / 2
813
+ else:
814
+ cursor = left + self.cell_padding_x
815
+
816
+ pill_top = y + (height - pill_height) / 2
817
+ pill_text = text_color or (
818
+ self._resolve_color(column.pill_text_color) if column.pill_text_color is not None else colors["pill_text"]
819
+ )
820
+ for text, pill_width, fill_color in segments:
821
+ self._rounded_rectangle(
822
+ cursor,
823
+ pill_top,
824
+ cursor + pill_width,
825
+ pill_top + pill_height,
826
+ radius=self._shape_radius(self.pill_radius, pill_height / 2, pill_width, pill_height),
827
+ fill=fill_color,
828
+ outline="",
829
+ tags=tags + ("pill",),
830
+ )
831
+ self.canvas.create_text(
832
+ cursor + pill_width / 2,
833
+ y + height / 2,
834
+ text=text,
835
+ anchor="center",
836
+ fill=pill_text,
837
+ font=self.font,
838
+ tags=tags + ("pill_text",),
839
+ )
840
+ cursor += pill_width + gap
841
+
842
+ def _draw_actions(
843
+ self,
844
+ row_index: int,
845
+ column: TableColumn,
846
+ left: float,
847
+ y: float,
848
+ width: int,
849
+ height: int,
850
+ colors: Mapping[str, str],
851
+ tags: tuple[str, ...],
852
+ ) -> list[ActionRegion]:
853
+ regions = self._action_regions(row_index, column, left=left, y=y, width=width, height=height)
854
+ for region, action in zip(regions, column.actions, strict=True):
855
+ x1, y1, x2, y2 = region.bounds
856
+ button_tags = tags + ("action_button", f"action_{_tag_value(action.key)}")
857
+ action_fill = colors.get(f"action_fill_{action.key}", colors["action_bg"])
858
+ action_border = colors.get(f"action_border_{action.key}", colors["action_border"])
859
+ action_text = colors.get(f"action_text_{action.key}", colors["action_text"])
860
+ self._rounded_rectangle(
861
+ x1,
862
+ y1,
863
+ x2,
864
+ y2,
865
+ radius=self._shape_radius(self.action_radius, self.DEFAULT_ACTION_RADIUS, x2 - x1, y2 - y1),
866
+ fill=action_fill,
867
+ outline=action_border,
868
+ tags=button_tags,
869
+ )
870
+ fitted = self._fit_text(action.label, max(0, (x2 - x1) - self.button_padding_x), self.font)
871
+ self.canvas.create_text(
872
+ (x1 + x2) / 2,
873
+ (y1 + y2) / 2,
874
+ text=fitted,
875
+ anchor="center",
876
+ fill=action_text,
877
+ font=self.font,
878
+ tags=button_tags + ("action_text",),
879
+ )
880
+ return regions
881
+
882
+ def _action_regions(
883
+ self,
884
+ row_index: int,
885
+ column: TableColumn,
886
+ *,
887
+ left: float,
888
+ y: float,
889
+ width: int,
890
+ height: int,
891
+ ) -> list[ActionRegion]:
892
+ gap = 6
893
+ button_height = min(28, max(22, height - 12))
894
+ widths = [
895
+ action.width or max(48, self.font.measure(action.label) + self.button_padding_x * 2)
896
+ for action in column.actions
897
+ ]
898
+ total_width = sum(widths) + gap * max(0, len(widths) - 1)
899
+ if total_width > width - self.cell_padding_x * 2 and widths:
900
+ available = max(32, width - self.cell_padding_x * 2 - gap * max(0, len(widths) - 1))
901
+ equal_width = max(32, available / len(widths))
902
+ widths = [min(item_width, equal_width) for item_width in widths]
903
+ total_width = sum(widths) + gap * max(0, len(widths) - 1)
904
+
905
+ start_x = left + (width - total_width) / 2
906
+ top = y + (height - button_height) / 2
907
+ regions: list[ActionRegion] = []
908
+ cursor = start_x
909
+ for action, button_width in zip(column.actions, widths, strict=True):
910
+ regions.append(
911
+ ActionRegion(
912
+ row_index=row_index,
913
+ column_key=column.key,
914
+ action_key=action.key,
915
+ bounds=(cursor, top, cursor + button_width, top + button_height),
916
+ )
917
+ )
918
+ cursor += button_width + gap
919
+ return regions
920
+
921
+ def _format_value(self, value: Any, row: Mapping[str, Any], column: TableColumn) -> str:
922
+ if column.formatter is not None:
923
+ try:
924
+ return str(column.formatter(value, row))
925
+ except Exception as error:
926
+ raise RuntimeError(f"Formatter failed for column '{column.key}': {error}") from error
927
+ if value is None:
928
+ return ""
929
+ if column.type == "number":
930
+ return self._format_number(value, column)
931
+ if column.type == "percentage":
932
+ return self._format_percentage(value, column)
933
+ if column.type == "currency":
934
+ return self._format_currency(value, column)
935
+ if column.type == "date":
936
+ return self._format_date(value, column.date_format)
937
+ if column.type == "datetime":
938
+ return self._format_datetime(value, column.datetime_format)
939
+ return str(value)
940
+
941
+ def _format_number(self, value: Any, column: TableColumn) -> str:
942
+ if column.number_format is None:
943
+ return str(value)
944
+ if callable(column.number_format):
945
+ return str(column.number_format(value))
946
+ try:
947
+ number = float(value)
948
+ except (TypeError, ValueError):
949
+ return str(value)
950
+ return column.number_format.format(number)
951
+
952
+ def _format_percentage(self, value: Any, column: TableColumn) -> str:
953
+ number = self._float_value(value)
954
+ if number is None:
955
+ return str(value)
956
+ display_value = number * column.percentage_multiplier
957
+ try:
958
+ return column.percentage_format.format(
959
+ display_value,
960
+ value=display_value,
961
+ raw_value=number,
962
+ multiplier=column.percentage_multiplier,
963
+ )
964
+ except (KeyError, IndexError, ValueError):
965
+ return str(value)
966
+
967
+ def _format_currency(self, value: Any, column: TableColumn) -> str:
968
+ number = self._float_value(value)
969
+ if number is None:
970
+ return str(value)
971
+ template = column.currency_negative_format if number < 0 else column.currency_format
972
+ try:
973
+ return template.format(
974
+ symbol=column.currency_symbol,
975
+ value=abs(number),
976
+ signed_value=number,
977
+ )
978
+ except (KeyError, IndexError, ValueError):
979
+ return str(value)
980
+
981
+ def _float_value(self, value: Any) -> float | None:
982
+ try:
983
+ return float(str(value).replace(",", ""))
984
+ except (TypeError, ValueError):
985
+ return None
986
+
987
+ def _format_date(self, value: Any, date_format: str) -> str:
988
+ parsed = parse_datetime(value)
989
+ if parsed is None:
990
+ return str(value)
991
+ return parsed.date().strftime(date_format)
992
+
993
+ def _format_datetime(self, value: Any, datetime_format: str) -> str:
994
+ parsed = parse_datetime(value)
995
+ if parsed is None:
996
+ return str(value)
997
+ return parsed.strftime(datetime_format)
998
+
999
+ def _resolve_badge_style(
1000
+ self,
1001
+ value: Any,
1002
+ row: Mapping[str, Any],
1003
+ column: TableColumn,
1004
+ colors: Mapping[str, str],
1005
+ ) -> BadgeStyle:
1006
+ text = "" if value is None else str(value)
1007
+ if text in column.badge_colors:
1008
+ return BadgeStyle(text=text, fill_color=column.badge_colors[text], text_color=None)
1009
+ if column.badge_fallback_handler is not None:
1010
+ result = column.badge_fallback_handler(value, row, column)
1011
+ if isinstance(result, BadgeStyle):
1012
+ return result
1013
+ if result is not None:
1014
+ return BadgeStyle(text=text, fill_color=result, text_color=None)
1015
+ if column.badge_fallback_color is not None:
1016
+ return BadgeStyle(text=text, fill_color=column.badge_fallback_color, text_color=None)
1017
+ return BadgeStyle(text=text, fill_color=colors["badge_default_bg"], text_color=colors["badge_text"])
1018
+
1019
+ def _pill_values(self, value: Any) -> list[str]:
1020
+ if value is None:
1021
+ return []
1022
+ if isinstance(value, str):
1023
+ return [part.strip() for part in value.split(",") if part.strip()]
1024
+ if isinstance(value, (list, tuple, set, frozenset)):
1025
+ return [str(item) for item in value if str(item)]
1026
+ return [str(value)]
1027
+
1028
+ def _pill_fill_color(self, text: str, column: TableColumn, colors: Mapping[str, str]) -> str:
1029
+ if text in column.pill_colors:
1030
+ return self._resolve_color(column.pill_colors[text])
1031
+ if column.pill_fallback_color is not None:
1032
+ return self._resolve_color(column.pill_fallback_color)
1033
+ return colors["pill_bg"]
1034
+
1035
+ def _pill_total_width(self, segments: list[tuple[str, float, str]], gap: int) -> float:
1036
+ if not segments:
1037
+ return 0.0
1038
+ return sum(segment[1] for segment in segments) + gap * (len(segments) - 1)
1039
+
1040
+ def _text_position(self, left: float, width: float, align: str) -> tuple[float, Literal["e", "center", "w"]]:
1041
+ if align == "right":
1042
+ return left + width - self.cell_padding_x, "e"
1043
+ if align == "center":
1044
+ return left + width / 2, "center"
1045
+ return left + self.cell_padding_x, "w"
1046
+
1047
+ def _fit_text(self, text: str, max_width: float, font: Any) -> str:
1048
+ cache_key = (id(font), text, round(float(max_width), 2))
1049
+ cached = self._fit_text_cache.get(cache_key)
1050
+ if cached is not None:
1051
+ return cached
1052
+
1053
+ if max_width <= 0:
1054
+ return self._remember_fit_text(cache_key, "")
1055
+ if font.measure(text) <= max_width:
1056
+ return self._remember_fit_text(cache_key, text)
1057
+ suffix = "..."
1058
+ suffix_width = font.measure(suffix)
1059
+ if suffix_width > max_width:
1060
+ return self._remember_fit_text(cache_key, "")
1061
+ low = 0
1062
+ high = len(text)
1063
+ while low < high:
1064
+ mid = (low + high + 1) // 2
1065
+ if font.measure(text[:mid] + suffix) <= max_width:
1066
+ low = mid
1067
+ else:
1068
+ high = mid - 1
1069
+ return self._remember_fit_text(cache_key, text[:low] + suffix)
1070
+
1071
+ def _remember_fit_text(self, cache_key: tuple[int, str, float], value: str) -> str:
1072
+ if len(self._fit_text_cache) >= self._fit_text_cache_limit:
1073
+ self._fit_text_cache.clear()
1074
+ self._fit_text_cache[cache_key] = value
1075
+ return value
1076
+
1077
+ def _dimension(self, value: float | None, fallback: float) -> float:
1078
+ if value is None:
1079
+ return fallback
1080
+ return max(0.0, float(value))
1081
+
1082
+ def _optional_dimension(self, value: float | None) -> float | None:
1083
+ if value is None:
1084
+ return None
1085
+ return max(0.0, float(value))
1086
+
1087
+ def _shape_radius(self, configured: float | None, fallback: float, width: float, height: float) -> float:
1088
+ radius = fallback if configured is None else configured
1089
+ return self._bounded_radius(radius, max(0.0, width), max(0.0, height))
1090
+
1091
+ def _draw_sort_indicator(self, x: float, y: float, *, ascending: bool, color: str, tags: tuple[str, ...]) -> None:
1092
+ size = 8
1093
+ if ascending:
1094
+ points = (x, y - size / 2, x - size / 2, y + size / 2, x + size / 2, y + size / 2)
1095
+ else:
1096
+ points = (x, y + size / 2, x - size / 2, y - size / 2, x + size / 2, y - size / 2)
1097
+ self.canvas.create_polygon(points, fill=color, outline="", tags=tags)
1098
+
1099
+ def _bounded_radius(self, radius: float, width: float, height: float) -> float:
1100
+ return max(0.0, min(float(radius), width / 2, height / 2))
1101
+
1102
+ def _draw_top_background(
1103
+ self,
1104
+ x1: float,
1105
+ y1: float,
1106
+ x2: float,
1107
+ y2: float,
1108
+ *,
1109
+ radius: float,
1110
+ fill: str,
1111
+ tags: tuple[str, ...],
1112
+ ) -> None:
1113
+ height = max(0.0, y2 - y1)
1114
+ radius = self._bounded_radius(radius, x2 - x1, height)
1115
+ if radius <= 0:
1116
+ self.canvas.create_rectangle(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
1117
+ return
1118
+
1119
+ rounded_bottom = min(y2, y1 + radius * 2)
1120
+ self._rounded_rectangle(x1, y1, x2, rounded_bottom, radius=radius, fill=fill, outline="", tags=tags)
1121
+ self.canvas.create_rectangle(x1, y1 + radius, x2, y2, fill=fill, outline="", tags=tags)
1122
+
1123
+ def _draw_bottom_background(
1124
+ self,
1125
+ x1: float,
1126
+ y1: float,
1127
+ x2: float,
1128
+ y2: float,
1129
+ *,
1130
+ radius: float,
1131
+ fill: str,
1132
+ tags: tuple[str, ...],
1133
+ ) -> None:
1134
+ height = max(0.0, y2 - y1)
1135
+ radius = self._bounded_radius(radius, x2 - x1, height)
1136
+ if radius <= 0:
1137
+ self.canvas.create_rectangle(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
1138
+ return
1139
+
1140
+ rounded_top = max(y1, y2 - radius * 2)
1141
+ self.canvas.create_rectangle(x1, y1, x2, y2 - radius, fill=fill, outline="", tags=tags)
1142
+ self._rounded_rectangle(x1, rounded_top, x2, y2, radius=radius, fill=fill, outline="", tags=tags)
1143
+
1144
+ def _draw_corner_masks(
1145
+ self,
1146
+ width: float,
1147
+ height: float,
1148
+ radius: float,
1149
+ *,
1150
+ background: str,
1151
+ tags: tuple[str, ...],
1152
+ ) -> None:
1153
+ steps = max(4, min(16, math.ceil(radius / 2)))
1154
+ corner_specs = (
1155
+ ((0, 0), (0, 0), (radius, 0), (radius, radius), -90, -180),
1156
+ ((width, 0), (width, 0), (width, radius), (width - radius, radius), 0, -90),
1157
+ ((width, height), (width, height), (width, height - radius), (width - radius, height - radius), 0, 90),
1158
+ ((0, height), (0, height), (0, height - radius), (radius, height - radius), 180, 90),
1159
+ )
1160
+ for corner, first, second, center, start_angle, end_angle in corner_specs:
1161
+ points = [
1162
+ corner,
1163
+ first,
1164
+ second,
1165
+ *self._arc_points(center, radius, start_angle, end_angle, steps),
1166
+ corner,
1167
+ ]
1168
+ self.canvas.create_polygon(
1169
+ self._flatten_points(points),
1170
+ fill=background,
1171
+ outline="",
1172
+ tags=tags,
1173
+ )
1174
+
1175
+ def _arc_points(
1176
+ self,
1177
+ center: tuple[float, float],
1178
+ radius: float,
1179
+ start_angle: float,
1180
+ end_angle: float,
1181
+ steps: int,
1182
+ ) -> list[tuple[float, float]]:
1183
+ cx, cy = center
1184
+ return [
1185
+ (
1186
+ cx + radius * math.cos(math.radians(start_angle + (end_angle - start_angle) * index / steps)),
1187
+ cy + radius * math.sin(math.radians(start_angle + (end_angle - start_angle) * index / steps)),
1188
+ )
1189
+ for index in range(steps + 1)
1190
+ ]
1191
+
1192
+ def _flatten_points(self, points: list[tuple[float, float]]) -> tuple[float, ...]:
1193
+ return tuple(coord for point in points for coord in point)
1194
+
1195
+ def _rounded_rectangle(
1196
+ self,
1197
+ x1: float,
1198
+ y1: float,
1199
+ x2: float,
1200
+ y2: float,
1201
+ *,
1202
+ radius: float,
1203
+ fill: str,
1204
+ outline: str,
1205
+ tags: tuple[str, ...],
1206
+ width: float = 1,
1207
+ ) -> None:
1208
+ radius = max(0, min(radius, (x2 - x1) / 2, (y2 - y1) / 2))
1209
+ points = (
1210
+ x1 + radius,
1211
+ y1,
1212
+ x2 - radius,
1213
+ y1,
1214
+ x2,
1215
+ y1,
1216
+ x2,
1217
+ y1 + radius,
1218
+ x2,
1219
+ y2 - radius,
1220
+ x2,
1221
+ y2,
1222
+ x2 - radius,
1223
+ y2,
1224
+ x1 + radius,
1225
+ y2,
1226
+ x1,
1227
+ y2,
1228
+ x1,
1229
+ y2 - radius,
1230
+ x1,
1231
+ y1 + radius,
1232
+ x1,
1233
+ y1,
1234
+ )
1235
+ self.canvas.create_polygon(points, smooth=True, fill=fill, outline=outline, tags=tags, width=width)
1236
+
1237
+
1238
+ def _tag_value(value: str) -> str:
1239
+ return re.sub(r"[^a-zA-Z0-9_]", "_", value)