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 @@
1
+ """Example applications for CTkDataTable."""
@@ -0,0 +1,208 @@
1
+ """Standalone CTkDataTable demo showing the core Phase 1 features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Mapping
7
+ from datetime import date, datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import customtkinter as ctk
12
+
13
+ try:
14
+ from CTkDataTable import BadgeStyle, Column, CTkDataTable, TableColumn
15
+ except ModuleNotFoundError as error:
16
+ if error.name != "CTkDataTable":
17
+ raise
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
19
+ from CTkDataTable import BadgeStyle, Column, CTkDataTable, TableColumn
20
+
21
+
22
+ def unknown_badge(value: Any, _row: Mapping[str, Any], _column: TableColumn) -> BadgeStyle:
23
+ """Return a fallback badge style for unmapped values."""
24
+
25
+ return BadgeStyle(text=str(value), fill_color=("#64748b", "#475569"), text_color=("#ffffff", "#ffffff"))
26
+
27
+
28
+ def highlight_overdue(row: dict[str, Any]) -> dict[str, Any] | None:
29
+ """Highlight overdue rows when style hooks are enabled."""
30
+
31
+ if row.get("status") == "Overdue":
32
+ return {"fg_color": ("#fff7ed", "#431407")}
33
+ return None
34
+
35
+
36
+ def style_amount(_row: dict[str, Any], column_key: str, value: Any) -> dict[str, Any] | None:
37
+ """Tint large amount cells when style hooks are enabled."""
38
+
39
+ if column_key == "amount" and isinstance(value, (int, float)) and value >= 9000:
40
+ return {"text_color": ("#b45309", "#fbbf24")}
41
+ return None
42
+
43
+
44
+ def main() -> None:
45
+ """Run the basic table demo."""
46
+
47
+ ctk.set_appearance_mode("System")
48
+ ctk.set_default_color_theme("blue")
49
+
50
+ app = ctk.CTk()
51
+ app.title("CTkDataTable Basic Demo")
52
+ app.geometry("1120x640")
53
+ app.grid_columnconfigure(0, weight=1)
54
+ app.grid_rowconfigure(0, weight=1)
55
+ status = ctk.CTkLabel(app, text="Ready", anchor="w")
56
+ status.grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 8))
57
+
58
+ today = date.today()
59
+ columns = [
60
+ Column("name").title("Customer").width(180),
61
+ Column("amount").title("Amount").width(120).currency(),
62
+ Column("completion").title("Complete").width(140).progress(),
63
+ Column("due").title("Due Date").width(130).date(),
64
+ Column("updated").title("Updated").width(170).datetime(),
65
+ Column("status").title("Status").width(130).badge(
66
+ colors={"Open": "#2ecc71", "Closed": "#e74c3c", "Overdue": "#e67e22"},
67
+ fallback_handler=unknown_badge,
68
+ ),
69
+ Column("priority").title("Priority").width(110).badge(
70
+ colors={"High": "#ef4444", "Medium": "#f59e0b", "Low": "#22c55e"},
71
+ fallback_color="#64748b",
72
+ ),
73
+ Column("tags").title("Tags").width(190).pill_list(
74
+ colors={"Renewal": "#0ea5e9", "Finance": "#8b5cf6", "Risk": "#ef4444", "Ops": "#22c55e"},
75
+ fallback_color="#64748b",
76
+ text_color="#ffffff",
77
+ ),
78
+ Column("approved").title("Approved").width(100).checkbox(),
79
+ Column("profile").title("Profile").width(130).link(),
80
+ Column("actions").title("Actions").width(170).action(
81
+ buttons=[{"key": "view", "label": "View"}, {"key": "archive", "label": "Archive"}],
82
+ ),
83
+ ]
84
+ rows = [
85
+ {
86
+ "name": "Northwind Components",
87
+ "amount": 12640.50,
88
+ "completion": 72,
89
+ "due": today + timedelta(days=3),
90
+ "updated": datetime.now() - timedelta(hours=2),
91
+ "status": "Open",
92
+ "priority": "High",
93
+ "tags": ["Renewal", "Finance"],
94
+ "approved": False,
95
+ "profile": "Open profile",
96
+ },
97
+ {
98
+ "name": "Meridian Foods",
99
+ "amount": 7320,
100
+ "completion": 43,
101
+ "due": today - timedelta(days=1),
102
+ "updated": datetime.now() - timedelta(days=1, hours=3),
103
+ "status": "Overdue",
104
+ "priority": "Medium",
105
+ "tags": ["Risk", "Supplier"],
106
+ "approved": True,
107
+ "profile": "Open profile",
108
+ },
109
+ {
110
+ "name": "Blue Ridge Logistics",
111
+ "amount": 1895.99,
112
+ "completion": 100,
113
+ "due": today + timedelta(days=11),
114
+ "updated": datetime.now() - timedelta(minutes=35),
115
+ "status": "Closed",
116
+ "priority": "Low",
117
+ "tags": ["Ops", "Complete"],
118
+ "approved": True,
119
+ "profile": "Open profile",
120
+ },
121
+ {
122
+ "name": "Aster Digital",
123
+ "amount": 9842.75,
124
+ "completion": 58,
125
+ "due": today + timedelta(days=6),
126
+ "updated": datetime.now() - timedelta(days=2),
127
+ "status": "Waiting",
128
+ "priority": "Medium",
129
+ "tags": "Renewal, Ops",
130
+ "approved": False,
131
+ "profile": "Open profile",
132
+ },
133
+ ]
134
+
135
+ for index in range(800):
136
+ rows.append(
137
+ {
138
+ "name": f"Demo Account {index + 1:03d}",
139
+ "amount": 1200 + index * 37.5,
140
+ "completion": (index * 7) % 101,
141
+ "due": today + timedelta(days=index % 21),
142
+ "updated": datetime.now() - timedelta(hours=index),
143
+ "status": ["Open", "Closed", "Overdue", "Waiting"][index % 4],
144
+ "priority": ["Low", "Medium", "High"][index % 3],
145
+ "tags": [["Ops"], ["Finance", "Renewal"], ["Risk"], ["Support", "Review"]][index % 4],
146
+ "approved": index % 2 == 0,
147
+ "profile": "Open profile",
148
+ }
149
+ )
150
+
151
+ def show_status(message: str) -> None:
152
+ status.configure(text=message)
153
+
154
+ def show_row_status(event: Any) -> None:
155
+ show_status(f"Selected row: {event.row.get('name')}")
156
+
157
+ def show_cell_status(event: Any) -> None:
158
+ value = event.row.get(event.column_key or "")
159
+ show_status(f"Selected cell: {event.column_key} = {value}")
160
+
161
+ def show_sort_status(key: str, ascending: bool) -> None:
162
+ direction = "ascending" if ascending else "descending"
163
+ show_status(f"Sorted {key} {direction}")
164
+
165
+ def show_search_status(query: str) -> None:
166
+ show_status(f"Search query: {query}")
167
+
168
+ def show_action_status(event: Any) -> None:
169
+ show_status(f"Action {event.action_key}: {event.row.get('name')}")
170
+
171
+ def show_link_status(event: Any) -> None:
172
+ show_status(f"Link {event.column_key}: {event.row.get('name')}")
173
+
174
+ table = CTkDataTable(
175
+ app,
176
+ columns=columns,
177
+ data=rows,
178
+ horizontal_scroll=False,
179
+ multi_select=False,
180
+ searchable=False,
181
+ search_delay_ms=120,
182
+ resizable_columns=True,
183
+ enable_style_hooks=True,
184
+ row_style=highlight_overdue,
185
+ cell_style=style_amount,
186
+ footer=False,
187
+ summaries={"name": lambda visible_rows: f"{len(visible_rows)} rows", "amount": "sum"},
188
+ on_row_click=show_row_status,
189
+ on_cell_click=show_cell_status,
190
+ on_sort=show_sort_status,
191
+ on_search=show_search_status,
192
+ on_action_click=show_action_status,
193
+ on_link_click=show_link_status,
194
+ )
195
+ table.grid(row=0, column=0, sticky="nsew", padx=14, pady=14)
196
+
197
+ def toggle_mode() -> None:
198
+ ctk.set_appearance_mode("Dark" if ctk.get_appearance_mode() == "Light" else "Light")
199
+ table.refresh()
200
+
201
+ mode_btn = ctk.CTkButton(app, text="Toggle Dark/Light", command=toggle_mode)
202
+ mode_btn.grid(row=2, column=0, pady=(0, 10))
203
+
204
+ app.mainloop()
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
CTkDataTable/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,446 @@
1
+ """Column definitions for the CTkDataTable widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, TypeAlias, cast
8
+
9
+ ColumnAlign = Literal["left", "center", "right"]
10
+ ColumnType = Literal[
11
+ "text",
12
+ "number",
13
+ "percentage",
14
+ "currency",
15
+ "date",
16
+ "datetime",
17
+ "badge",
18
+ "checkbox",
19
+ "action",
20
+ "progress",
21
+ "link",
22
+ "pill_list",
23
+ ]
24
+ ColorValue = str | tuple[str, str]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class BadgeStyle:
29
+ """Resolved display information for a badge cell."""
30
+
31
+ text: str
32
+ fill_color: ColorValue
33
+ text_color: ColorValue | None = None
34
+
35
+
36
+ BadgeFallbackResult = BadgeStyle | ColorValue | None
37
+ BadgeFallbackHandler = Callable[[Any, Mapping[str, Any], "TableColumn"], BadgeFallbackResult]
38
+ CellFormatter = Callable[[Any, Mapping[str, Any]], str]
39
+ NumberFormatter = str | Callable[[Any], str]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TableAction:
44
+ """Definition for a Canvas-rendered action button inside an action cell."""
45
+
46
+ key: str
47
+ label: str
48
+ width: int | None = None
49
+ fg_color: ColorValue | None = None
50
+ text_color: ColorValue | None = None
51
+ border_color: ColorValue | None = None
52
+
53
+ @classmethod
54
+ def from_definition(cls, definition: TableAction | Mapping[str, Any] | str) -> TableAction:
55
+ """Create a table action from an existing action or mapping definition."""
56
+
57
+ if isinstance(definition, cls):
58
+ return definition
59
+ if isinstance(definition, str):
60
+ return cls(key=definition, label=definition.title())
61
+ if not isinstance(definition, Mapping):
62
+ raise TypeError("Action definitions must be TableAction objects, mappings, or strings.")
63
+ if "key" not in definition:
64
+ raise ValueError("Action definitions require a 'key'.")
65
+
66
+ key = str(definition["key"])
67
+ label = str(definition.get("label", key.title()))
68
+ width_value = definition.get("width")
69
+ width = int(width_value) if width_value is not None else None
70
+ if width is not None and width <= 0:
71
+ raise ValueError(f"Action '{key}' width must be greater than zero.")
72
+
73
+ return cls(
74
+ key=key,
75
+ label=label,
76
+ width=width,
77
+ fg_color=definition.get("fg_color"),
78
+ text_color=definition.get("text_color"),
79
+ border_color=definition.get("border_color"),
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class TableColumn:
85
+ """Typed configuration for a CTkDataTable column."""
86
+
87
+ key: str
88
+ title: str
89
+ width: int
90
+ align: ColumnAlign = "left"
91
+ visible: bool = True
92
+ sortable: bool = True
93
+ type: ColumnType = "text"
94
+ badge_colors: Mapping[str, ColorValue] = field(default_factory=dict)
95
+ badge_fallback_color: ColorValue | None = None
96
+ badge_fallback_handler: BadgeFallbackHandler | None = None
97
+ pill_colors: Mapping[str, ColorValue] = field(default_factory=dict)
98
+ pill_fallback_color: ColorValue | None = None
99
+ pill_text_color: ColorValue | None = None
100
+ actions: tuple[TableAction, ...] = ()
101
+ formatter: CellFormatter | None = None
102
+ number_format: NumberFormatter | None = None
103
+ percentage_format: str = "{value:.0f}%"
104
+ percentage_multiplier: float = 1.0
105
+ currency_symbol: str = "$"
106
+ currency_format: str = "{symbol}{value:,.2f}"
107
+ currency_negative_format: str = "-{symbol}{value:,.2f}"
108
+ date_format: str = "%Y-%m-%d"
109
+ datetime_format: str = "%Y-%m-%d %H:%M"
110
+ progress_min: float = 0.0
111
+ progress_max: float = 100.0
112
+ progress_color: ColorValue | None = None
113
+ progress_background_color: ColorValue | None = None
114
+ progress_show_text: bool = True
115
+ progress_text_format: str = "{percent:.0f}%"
116
+ link_color: ColorValue | None = None
117
+ metadata: Mapping[str, Any] = field(default_factory=dict)
118
+
119
+ @classmethod
120
+ def from_definition(cls, definition: TableColumn | Mapping[str, Any]) -> TableColumn:
121
+ """Create a column from an existing TableColumn or a dictionary definition."""
122
+
123
+ if isinstance(definition, cls):
124
+ return definition
125
+ if not isinstance(definition, Mapping):
126
+ raise TypeError("Column definitions must be TableColumn objects or mappings.")
127
+ if "key" not in definition:
128
+ raise ValueError("Column definitions require a 'key'.")
129
+
130
+ column_type = _validate_type(str(definition.get("type", "text")))
131
+ align = _validate_align(str(definition.get("align", _default_align(column_type))))
132
+ key = str(definition["key"])
133
+ width = int(definition.get("width", 140))
134
+ if width <= 0:
135
+ raise ValueError(f"Column '{key}' width must be greater than zero.")
136
+
137
+ raw_actions = definition.get("actions", ())
138
+ actions = tuple(TableAction.from_definition(action) for action in _as_sequence(raw_actions))
139
+
140
+ badge_colors = definition.get("badge_colors", {})
141
+ if not isinstance(badge_colors, Mapping):
142
+ raise TypeError(f"Column '{key}' badge_colors must be a mapping.")
143
+ pill_colors = definition.get("pill_colors", {})
144
+ if not isinstance(pill_colors, Mapping):
145
+ raise TypeError(f"Column '{key}' pill_colors must be a mapping.")
146
+ fallback_color = definition.get("badge_fallback_color")
147
+ fallback_handler = definition.get("badge_fallback_handler")
148
+ if fallback_handler is not None and not callable(fallback_handler):
149
+ raise TypeError(f"Column '{key}' badge fallback handler must be callable.")
150
+ formatter = definition.get("formatter")
151
+ if formatter is not None and not callable(formatter):
152
+ raise TypeError(f"Column '{key}' formatter must be callable.")
153
+ number_format = definition.get("number_format")
154
+ if number_format is not None and not isinstance(number_format, str) and not callable(number_format):
155
+ raise TypeError(f"Column '{key}' number_format must be a format string or callable.")
156
+ percentage_multiplier = float(definition.get("percentage_multiplier", 1.0))
157
+ if percentage_multiplier <= 0:
158
+ raise ValueError(f"Column '{key}' percentage_multiplier must be greater than zero.")
159
+ progress_min = float(definition.get("progress_min", 0.0))
160
+ progress_max = float(definition.get("progress_max", 100.0))
161
+ if progress_max <= progress_min:
162
+ raise ValueError(f"Column '{key}' progress_max must be greater than progress_min.")
163
+ metadata = definition.get("metadata", {})
164
+ if not isinstance(metadata, Mapping):
165
+ raise TypeError(f"Column '{key}' metadata must be a mapping.")
166
+
167
+ return cls(
168
+ key=key,
169
+ title=str(definition.get("title", key.replace("_", " ").title())),
170
+ width=width,
171
+ align=align,
172
+ visible=bool(definition.get("visible", True)),
173
+ sortable=bool(definition.get("sortable", True)),
174
+ type=column_type,
175
+ badge_colors={str(value): color for value, color in dict(badge_colors).items()},
176
+ badge_fallback_color=fallback_color,
177
+ badge_fallback_handler=fallback_handler,
178
+ pill_colors={str(value): color for value, color in dict(pill_colors).items()},
179
+ pill_fallback_color=definition.get("pill_fallback_color"),
180
+ pill_text_color=definition.get("pill_text_color"),
181
+ actions=actions,
182
+ formatter=formatter,
183
+ number_format=number_format,
184
+ percentage_format=str(definition.get("percentage_format", "{value:.0f}%")),
185
+ percentage_multiplier=percentage_multiplier,
186
+ currency_symbol=str(definition.get("currency_symbol", "$")),
187
+ currency_format=str(definition.get("currency_format", "{symbol}{value:,.2f}")),
188
+ currency_negative_format=str(definition.get("currency_negative_format", "-{symbol}{value:,.2f}")),
189
+ date_format=str(definition.get("date_format", "%Y-%m-%d")),
190
+ datetime_format=str(definition.get("datetime_format", "%Y-%m-%d %H:%M")),
191
+ progress_min=progress_min,
192
+ progress_max=progress_max,
193
+ progress_color=definition.get("progress_color"),
194
+ progress_background_color=definition.get("progress_background_color"),
195
+ progress_show_text=bool(definition.get("progress_show_text", True)),
196
+ progress_text_format=str(definition.get("progress_text_format", "{percent:.0f}%")),
197
+ link_color=definition.get("link_color"),
198
+ metadata=dict(metadata),
199
+ )
200
+
201
+
202
+ class Column(Mapping):
203
+ """Fluent builder for a :class:`TableColumn`.
204
+
205
+ Usage::
206
+
207
+ Column("status").title("Status").width(130).badge(
208
+ colors={"Open": "#2ecc71", "Closed": "#e74c3c"},
209
+ fallback_color="#64748b",
210
+ )
211
+ Column("amount").title("Amount").width(120).number(format="${:,.2f}")
212
+ Column("actions").title("Actions").width(170).action(
213
+ buttons=[{"key": "view", "label": "View"}, {"key": "edit", "label": "Edit"}],
214
+ )
215
+ """
216
+
217
+ def __init__(self, key: str) -> None:
218
+ self._data: dict[str, Any] = {"key": key, "type": "text"}
219
+
220
+ def __getitem__(self, key: str) -> Any:
221
+ return self._data[key]
222
+
223
+ def __iter__(self) -> Any:
224
+ return iter(self._data)
225
+
226
+ def __len__(self) -> int:
227
+ return len(self._data)
228
+
229
+ def title(self, title: str) -> Column:
230
+ """Set the column header label."""
231
+ self._data["title"] = title
232
+ return self
233
+
234
+ def width(self, pixels: int) -> Column:
235
+ """Set the column width in pixels."""
236
+ self._data["width"] = pixels
237
+ return self
238
+
239
+ def align(self, align: ColumnAlign) -> Column:
240
+ """Set text alignment: 'left', 'center', or 'right'."""
241
+ self._data["align"] = align
242
+ return self
243
+
244
+ def hide(self) -> Column:
245
+ """Mark this column as hidden."""
246
+ self._data["visible"] = False
247
+ return self
248
+
249
+ def no_sort(self) -> Column:
250
+ """Disable sorting for this column."""
251
+ self._data["sortable"] = False
252
+ return self
253
+
254
+ def fmt(self, func: CellFormatter) -> Column:
255
+ """Set a cell formatter callable ``(value, row) -> str``."""
256
+ self._data["formatter"] = func
257
+ return self
258
+
259
+ def metadata(self, **kwargs: Any) -> Column:
260
+ """Attach arbitrary metadata to this column."""
261
+ self._data["metadata"] = kwargs
262
+ return self
263
+
264
+ def text(self) -> Column:
265
+ """Plain text column (default)."""
266
+ self._data["type"] = "text"
267
+ return self
268
+
269
+ def number(self, format: NumberFormatter | None = None) -> Column:
270
+ """Numeric column with optional format string or callable."""
271
+ self._data["type"] = "number"
272
+ if format is not None:
273
+ self._data["number_format"] = format
274
+ return self
275
+
276
+ def percentage(self, *, format: str = "{value:.0f}%", multiplier: float = 1.0) -> Column:
277
+ """Percentage column with configurable display formatting."""
278
+ self._data["type"] = "percentage"
279
+ self._data["percentage_format"] = format
280
+ self._data["percentage_multiplier"] = multiplier
281
+ return self
282
+
283
+ def currency(
284
+ self,
285
+ *,
286
+ symbol: str = "$",
287
+ format: str = "{symbol}{value:,.2f}",
288
+ negative_format: str = "-{symbol}{value:,.2f}",
289
+ ) -> Column:
290
+ """Currency column with configurable positive and negative formatting."""
291
+ self._data["type"] = "currency"
292
+ self._data["currency_symbol"] = symbol
293
+ self._data["currency_format"] = format
294
+ self._data["currency_negative_format"] = negative_format
295
+ return self
296
+
297
+ def date(self, fmt: str = "%Y-%m-%d") -> Column:
298
+ """Date column with optional strftime format."""
299
+ self._data["type"] = "date"
300
+ self._data["date_format"] = fmt
301
+ return self
302
+
303
+ def datetime(self, fmt: str = "%Y-%m-%d %H:%M") -> Column:
304
+ """Datetime column with optional strftime format."""
305
+ self._data["type"] = "datetime"
306
+ self._data["datetime_format"] = fmt
307
+ return self
308
+
309
+ def badge(
310
+ self,
311
+ colors: Mapping[str, ColorValue] | None = None,
312
+ fallback_color: ColorValue | None = None,
313
+ fallback_handler: BadgeFallbackHandler | None = None,
314
+ ) -> Column:
315
+ """Badge column mapping values to colours."""
316
+ self._data["type"] = "badge"
317
+ if colors is not None:
318
+ self._data["badge_colors"] = dict(colors)
319
+ if fallback_color is not None:
320
+ self._data["badge_fallback_color"] = fallback_color
321
+ if fallback_handler is not None:
322
+ self._data["badge_fallback_handler"] = fallback_handler
323
+ return self
324
+
325
+ def pill_list(
326
+ self,
327
+ colors: Mapping[str, ColorValue] | None = None,
328
+ fallback_color: ColorValue | None = None,
329
+ text_color: ColorValue | None = None,
330
+ ) -> Column:
331
+ """Pill-list column for compact tag displays."""
332
+ self._data["type"] = "pill_list"
333
+ if colors is not None:
334
+ self._data["pill_colors"] = dict(colors)
335
+ if fallback_color is not None:
336
+ self._data["pill_fallback_color"] = fallback_color
337
+ if text_color is not None:
338
+ self._data["pill_text_color"] = text_color
339
+ return self
340
+
341
+ def checkbox(self) -> Column:
342
+ """Boolean checkbox column."""
343
+ self._data["type"] = "checkbox"
344
+ return self
345
+
346
+ def progress(
347
+ self,
348
+ *,
349
+ minimum: float = 0.0,
350
+ maximum: float = 100.0,
351
+ color: ColorValue | None = None,
352
+ background_color: ColorValue | None = None,
353
+ show_text: bool = True,
354
+ text_format: str = "{percent:.0f}%",
355
+ ) -> Column:
356
+ """Progress-bar column for numeric completion values."""
357
+ self._data["type"] = "progress"
358
+ self._data["progress_min"] = minimum
359
+ self._data["progress_max"] = maximum
360
+ self._data["progress_show_text"] = show_text
361
+ self._data["progress_text_format"] = text_format
362
+ if color is not None:
363
+ self._data["progress_color"] = color
364
+ if background_color is not None:
365
+ self._data["progress_background_color"] = background_color
366
+ return self
367
+
368
+ def link(self, color: ColorValue | None = None) -> Column:
369
+ """Link-style clickable text column."""
370
+ self._data["type"] = "link"
371
+ if color is not None:
372
+ self._data["link_color"] = color
373
+ return self
374
+
375
+ def action(
376
+ self,
377
+ buttons: Sequence[Any],
378
+ *,
379
+ sortable: bool = False,
380
+ ) -> Column:
381
+ """Action-button column. Pass a list of button definitions."""
382
+ self._data["type"] = "action"
383
+ self._data["actions"] = buttons
384
+ self._data["sortable"] = sortable
385
+ return self
386
+
387
+
388
+ ColumnDefinition: TypeAlias = TableColumn | Column | Mapping[str, Any]
389
+
390
+
391
+ def normalize_columns(columns: Sequence[ColumnDefinition]) -> tuple[TableColumn, ...]:
392
+ """Normalize a sequence of user supplied column definitions."""
393
+
394
+ normalized = tuple(TableColumn.from_definition(column) for column in columns)
395
+ seen: set[str] = set()
396
+ for column in normalized:
397
+ if column.key in seen:
398
+ raise ValueError(f"Duplicate column key '{column.key}'.")
399
+ seen.add(column.key)
400
+ return normalized
401
+
402
+
403
+ def _as_sequence(value: Any) -> Iterable[Any]:
404
+ if value is None:
405
+ return ()
406
+ if isinstance(value, Mapping):
407
+ return (value,)
408
+ if isinstance(value, str):
409
+ return ({"key": value, "label": value.title()},)
410
+ if not isinstance(value, Iterable):
411
+ raise TypeError("Action definitions must be an action mapping, string, or iterable of actions.")
412
+ return value
413
+
414
+
415
+ def _default_align(column_type: ColumnType) -> ColumnAlign:
416
+ if column_type in {"number", "percentage", "currency"}:
417
+ return "right"
418
+ if column_type in {"checkbox", "action", "progress"}:
419
+ return "center"
420
+ return "left"
421
+
422
+
423
+ def _validate_align(value: str) -> ColumnAlign:
424
+ if value not in {"left", "center", "right"}:
425
+ raise ValueError("Column align must be 'left', 'center', or 'right'.")
426
+ return cast(ColumnAlign, value)
427
+
428
+
429
+ def _validate_type(value: str) -> ColumnType:
430
+ valid_types = {
431
+ "text",
432
+ "number",
433
+ "percentage",
434
+ "currency",
435
+ "date",
436
+ "datetime",
437
+ "badge",
438
+ "checkbox",
439
+ "action",
440
+ "progress",
441
+ "link",
442
+ "pill_list",
443
+ }
444
+ if value not in valid_types:
445
+ raise ValueError(f"Column type '{value}' is not supported.")
446
+ return cast(ColumnType, value)
@@ -0,0 +1,18 @@
1
+ """Public event payloads for CTkDataTable callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TableRowEvent:
11
+ """Detailed row event payload with source and view indices."""
12
+
13
+ row: dict[str, Any]
14
+ source_index: int
15
+ view_index: int
16
+ column_key: str | None = None
17
+ action_key: str | None = None
18
+