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