scustomtkinter 0.2.1__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.
- scustomtkinter/__init__.py +81 -0
- scustomtkinter/assets/images/error.png +0 -0
- scustomtkinter/assets/images/info.png +0 -0
- scustomtkinter/assets/images/warning.png +0 -0
- scustomtkinter/assets/sCTkThemes.json +1963 -0
- scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter/sctk_button_primary.py +366 -0
- scustomtkinter/sctk_button_secondary.py +354 -0
- scustomtkinter/sctk_button_tertiary.py +375 -0
- scustomtkinter/sctk_checkbox.py +270 -0
- scustomtkinter/sctk_combobox.py +155 -0
- scustomtkinter/sctk_core.py +15 -0
- scustomtkinter/sctk_dial.py +1472 -0
- scustomtkinter/sctk_dialog.py +701 -0
- scustomtkinter/sctk_dialog_toplevel.py +268 -0
- scustomtkinter/sctk_entry_primary.py +400 -0
- scustomtkinter/sctk_entry_secondary.py +342 -0
- scustomtkinter/sctk_file_explorer.py +879 -0
- scustomtkinter/sctk_frame.py +210 -0
- scustomtkinter/sctk_frame_labeled_primary.py +667 -0
- scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
- scustomtkinter/sctk_frequency_display.py +636 -0
- scustomtkinter/sctk_label_primary.py +283 -0
- scustomtkinter/sctk_label_secondary.py +275 -0
- scustomtkinter/sctk_label_tertiary.py +278 -0
- scustomtkinter/sctk_messagebox.py +275 -0
- scustomtkinter/sctk_notebook.py +1103 -0
- scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
- scustomtkinter/sctk_optionmenu_primary.py +378 -0
- scustomtkinter/sctk_optionmenu_secondary.py +346 -0
- scustomtkinter/sctk_path_chooser.py +516 -0
- scustomtkinter/sctk_progress_bar.py +274 -0
- scustomtkinter/sctk_radiobutton.py +360 -0
- scustomtkinter/sctk_scroll_mixin.py +546 -0
- scustomtkinter/sctk_scrollable_frame.py +795 -0
- scustomtkinter/sctk_scrollbar.py +313 -0
- scustomtkinter/sctk_segmentedbutton.py +424 -0
- scustomtkinter/sctk_selector.py +581 -0
- scustomtkinter/sctk_separator.py +308 -0
- scustomtkinter/sctk_slider.py +264 -0
- scustomtkinter/sctk_smeter.py +405 -0
- scustomtkinter/sctk_smeter_bar.py +557 -0
- scustomtkinter/sctk_spinbox.py +664 -0
- scustomtkinter/sctk_switch.py +463 -0
- scustomtkinter/sctk_tableview.py +869 -0
- scustomtkinter/sctk_tabview.py +409 -0
- scustomtkinter/sctk_textbox_primary.py +291 -0
- scustomtkinter/sctk_textbox_secondary.py +225 -0
- scustomtkinter/sctk_toplevel.py +153 -0
- scustomtkinter/themeable_widget.py +938 -0
- scustomtkinter-0.2.1.dist-info/METADATA +336 -0
- scustomtkinter-0.2.1.dist-info/RECORD +101 -0
- scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
- scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
- scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
- scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
- scustomtkinter_pygubu/__init__.py +1 -0
- scustomtkinter_pygubu/designer/__init__.py +0 -0
- scustomtkinter_pygubu/designer/plugin.py +1854 -0
- scustomtkinter_pygubu/designer/properties.py +247 -0
- scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
- scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
- scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkCorebo.py +120 -0
- scustomtkinter_pygubu/sCTkDialbo.py +371 -0
- scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
- scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
- scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
- scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
- scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
- scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
- scustomtkinter_pygubu/sCTkFramebo.py +46 -0
- scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
- scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
- scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
- scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
- scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
- scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
- scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
- scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
- scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
- scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
- scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
- scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
- scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
- scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
- scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
- scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
- scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
- scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
- scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
- scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
- scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
- scustomtkinter_pygubu/sCTkWidgetSetForPygubuDesigner.py +141 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
sCTkTableview
|
|
4
|
+
|
|
5
|
+
A theme-compliant custom data grid component wrapper.
|
|
6
|
+
Inherits cleanly from sCTkScrollableFrame and ThemeableWidget to manage
|
|
7
|
+
dense telemetry spreadsheets safely with full live theme repaint loops.
|
|
8
|
+
"""
|
|
9
|
+
import customtkinter as ctk
|
|
10
|
+
from .themeable_widget import ThemeableWidget, parse_list_property, defer, cancel_deferred
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Callable, Any, Literal
|
|
13
|
+
|
|
14
|
+
from .sctk_scrollable_frame import sCTkScrollableFrame
|
|
15
|
+
from .sctk_label_primary import sCTkLabelPrimary
|
|
16
|
+
from .sctk_label_secondary import sCTkLabelSecondary
|
|
17
|
+
from .sctk_entry_primary import sCTkEntryPrimary
|
|
18
|
+
from .sctk_optionmenu_primary import sCTkOptionMenuPrimary
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class sCTkTableview(sCTkScrollableFrame, ThemeableWidget):
|
|
23
|
+
def __init__(self, master: any, columns: Optional[Any] = None, width: int = 500, height: int = 300,
|
|
24
|
+
grid_mode: Literal["zebra", "grid", "none"] = "zebra", header_line_width: int = 2,
|
|
25
|
+
outline_width: float = 1.0, outline_radius: int = 4, state: Literal["normal", "disabled"] = "normal",
|
|
26
|
+
num_columns: int = 3, num_rows: int = 1, show_headers: Any = True,
|
|
27
|
+
cell_bg_color: Optional[Any] = None, cell_alt_bg_color: Optional[Any] = None,
|
|
28
|
+
editable_columns: Optional[Any] = None,
|
|
29
|
+
edit_trigger: Literal["double", "select"] = "double",
|
|
30
|
+
select_rows: Any = False, column_choices: Optional[dict] = None,
|
|
31
|
+
*args, **kwargs):
|
|
32
|
+
|
|
33
|
+
# 1. Run shared mixin logic first to parse master themes.json data maps safely
|
|
34
|
+
ThemeableWidget.__init__(self, kwargs)
|
|
35
|
+
|
|
36
|
+
# 2. Resolve theme profiles FIRST, before extracting individual
|
|
37
|
+
# values, so required keys can be validated before anything reads
|
|
38
|
+
# them.
|
|
39
|
+
self._switch_theme_profile = dict(self.final_kw)
|
|
40
|
+
# FIX: an earlier version read "disabled_map" out of
|
|
41
|
+
# self._switch_theme_profile (== dict(self.final_kw)), but
|
|
42
|
+
# ThemeableWidget.__init__ deliberately excludes "disabled_map" from
|
|
43
|
+
# final_kw -- this always evaluated to the empty-dict default, meaning
|
|
44
|
+
# EVERY disabled-state color lookup in _apply_state_and_theme_updates()
|
|
45
|
+
# silently fell back to its hardcoded literal instead of the real
|
|
46
|
+
# theme. Confirmed identical bug, same fix, as sCTkSwitch and
|
|
47
|
+
# sCTkSpinbox elsewhere in this project.
|
|
48
|
+
self._custom_disabled_map = dict(self._widget_disabled_map)
|
|
49
|
+
|
|
50
|
+
# FIX: an earlier version used .get(key, hardcoded_literal) for every
|
|
51
|
+
# color/font below, both here and in _apply_state_and_theme_updates(),
|
|
52
|
+
# silently substituting a guessed value whenever the real theme was
|
|
53
|
+
# incomplete rather than surfacing the gap. Matches the same hard-fail
|
|
54
|
+
# principle established for sCTkSwitch and the label family elsewhere
|
|
55
|
+
# in this project: required keys must be present in both the
|
|
56
|
+
# top-level theme block and disabled_map, or construction raises
|
|
57
|
+
# immediately with a clear message naming exactly what's missing.
|
|
58
|
+
#
|
|
59
|
+
# Colors need a disabled_map entry too, since they visually change
|
|
60
|
+
# when disabled. Fonts don't -- BUG, confirmed by direct testing: an
|
|
61
|
+
# earlier version of this validation incorrectly bundled
|
|
62
|
+
# header_font/cell_font into this same loop, requiring them in
|
|
63
|
+
# disabled_map too, even though no widget in this project uses a
|
|
64
|
+
# disabled-state font variant (fonts are always top-level-only).
|
|
65
|
+
# Split into two separate loops below to fix this.
|
|
66
|
+
for required_key in ("header_bg_color", "header_text_color",
|
|
67
|
+
"cell_text_color", "grid_line_color"):
|
|
68
|
+
if self._switch_theme_profile.get(required_key) is None:
|
|
69
|
+
raise KeyError(
|
|
70
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
|
|
71
|
+
f"at the top level of sCTkThemes.json."
|
|
72
|
+
)
|
|
73
|
+
if self._custom_disabled_map.get(required_key) is None:
|
|
74
|
+
raise KeyError(
|
|
75
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' in disabled_map."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Fonts only need to exist at the top level.
|
|
79
|
+
for required_key in ("header_font", "cell_font"):
|
|
80
|
+
if self._switch_theme_profile.get(required_key) is None:
|
|
81
|
+
raise KeyError(
|
|
82
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
|
|
83
|
+
f"at the top level of sCTkThemes.json."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# cell_bg_color/cell_alt_bg_color are different: they have a genuine
|
|
87
|
+
# constructor-kwarg override, so it's not automatically a theme gap
|
|
88
|
+
# if the theme itself doesn't define them -- only a problem if
|
|
89
|
+
# NEITHER the kwarg NOR the theme provides a value. disabled_map is
|
|
90
|
+
# still required either way, since there's no disabled-state kwarg
|
|
91
|
+
# override.
|
|
92
|
+
for ctor_key, ctor_val in (("cell_bg_color", cell_bg_color), ("cell_alt_bg_color", cell_alt_bg_color)):
|
|
93
|
+
if ctor_val is None and self._switch_theme_profile.get(ctor_key) is None:
|
|
94
|
+
raise KeyError(
|
|
95
|
+
f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}': '{ctor_key}' must be provided either as a "
|
|
96
|
+
f"constructor argument or in the theme's top-level block."
|
|
97
|
+
)
|
|
98
|
+
if self._custom_disabled_map.get(ctor_key) is None:
|
|
99
|
+
raise KeyError(f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{ctor_key}' in disabled_map.")
|
|
100
|
+
|
|
101
|
+
# 3. Extract theme-driven values -- safe to read directly via .get()
|
|
102
|
+
# now, without a fallback, since the validation above guarantees each
|
|
103
|
+
# key exists.
|
|
104
|
+
self._header_bg = self._switch_theme_profile.get("header_bg_color")
|
|
105
|
+
self._header_fg = self._switch_theme_profile.get("header_text_color")
|
|
106
|
+
self._header_font = self._switch_theme_profile.get("header_font")
|
|
107
|
+
|
|
108
|
+
self._cell_bg = cell_bg_color if cell_bg_color is not None else self._switch_theme_profile.get("cell_bg_color")
|
|
109
|
+
self._cell_alt_bg = cell_alt_bg_color if cell_alt_bg_color is not None else self._switch_theme_profile.get("cell_alt_bg_color")
|
|
110
|
+
self._cell_fg = self._switch_theme_profile.get("cell_text_color")
|
|
111
|
+
self._cell_font = self._switch_theme_profile.get("cell_font")
|
|
112
|
+
self._grid_line_color = self._switch_theme_profile.get("grid_line_color")
|
|
113
|
+
|
|
114
|
+
# OPTIONAL, unlike the colours above: a theme written before rows
|
|
115
|
+
# could be selected has no such key, and requiring it would break
|
|
116
|
+
# every one of them. Absent, the grid-line colour stands in -- still
|
|
117
|
+
# a theme value, never a hardcoded one.
|
|
118
|
+
#
|
|
119
|
+
# NOT the header colour, which this first used. In the library's own
|
|
120
|
+
# dark theme it differs from the rows by 6 (on a 0-765 scale), so the
|
|
121
|
+
# selected row was invisible. The grid-line colour stands clear of the
|
|
122
|
+
# rows in both modes. A theme should still set cell_selected_color:
|
|
123
|
+
# no existing colour was chosen to be a selection colour.
|
|
124
|
+
self._selected_bg = (self._switch_theme_profile.get("cell_selected_color")
|
|
125
|
+
or self._grid_line_color)
|
|
126
|
+
|
|
127
|
+
# FIX (related bug found while eliminating the fallback above): an
|
|
128
|
+
# earlier version of _apply_state_and_theme_updates() always reverted
|
|
129
|
+
# cell_bg_color/cell_alt_bg_color to the theme's value when
|
|
130
|
+
# transitioning back to "normal", silently discarding a constructor
|
|
131
|
+
# kwarg override (e.g. sCTkTableview(master, cell_bg_color="red"))
|
|
132
|
+
# after a disable/enable cycle. Caching the actually-resolved values
|
|
133
|
+
# here lets that method correctly restore whichever one this
|
|
134
|
+
# constructor call actually resolved to, not just the theme default.
|
|
135
|
+
self._resolved_normal_cell_bg = self._cell_bg
|
|
136
|
+
self._resolved_normal_cell_alt_bg = self._cell_alt_bg
|
|
137
|
+
|
|
138
|
+
# 3. Initialize specialized layout tracking fields
|
|
139
|
+
self._grid_mode = str(grid_mode).replace("'", "").replace('"', "").strip().lower()
|
|
140
|
+
self._header_line_width = int(header_line_width) if header_line_width is not None else 2
|
|
141
|
+
self._outline_width = float(outline_width) if outline_width else 1.0
|
|
142
|
+
self._outline_radius = int(outline_radius) if outline_radius else 4
|
|
143
|
+
self._state = state
|
|
144
|
+
|
|
145
|
+
self._num_columns = int(num_columns)
|
|
146
|
+
# COLUMNS SETS THE COLUMN COUNT, as the documentation always said. It
|
|
147
|
+
# did not: only the Designer's builder and configure() applied it, so
|
|
148
|
+
# a table built in code with five column names showed three -- the
|
|
149
|
+
# default count -- and silently dropped every column after the third,
|
|
150
|
+
# headers, cells and set_column_properties() alike. Worked out here,
|
|
151
|
+
# before the per-column widths below are sized from it.
|
|
152
|
+
if isinstance(columns, str):
|
|
153
|
+
columns = parse_list_property(columns)
|
|
154
|
+
if columns and isinstance(columns, (list, tuple)):
|
|
155
|
+
self._num_columns = len(columns)
|
|
156
|
+
self._num_rows = int(num_rows)
|
|
157
|
+
self._show_headers = str(show_headers).replace("'", "").replace('"', "").strip().lower() in ("true", "1", "yes")
|
|
158
|
+
|
|
159
|
+
# 🔑 FIXED LIFECYCLE REORDER: Pre-hydrate layout property variables completely
|
|
160
|
+
# BEFORE running super().__init__ to eliminate asynchronous AttributeError gaps!
|
|
161
|
+
self._column_widths = [120] * self._num_columns
|
|
162
|
+
self._column_anchors = ["center"] * self._num_columns
|
|
163
|
+
self._click_callback, self._edit_callback, self._validation_callback = None, None, None
|
|
164
|
+
|
|
165
|
+
# --- editing and selection --------------------------------------
|
|
166
|
+
# All three default to what the table always did -- every column
|
|
167
|
+
# editable, by double-click, and no row highlighted -- so an existing
|
|
168
|
+
# table is unchanged unless it asks for something else.
|
|
169
|
+
#
|
|
170
|
+
# editable_columns: None for all, else the column indices that may be
|
|
171
|
+
# edited. The rest are read-only, which a column such as a row
|
|
172
|
+
# number or a computed note needs.
|
|
173
|
+
# edit_trigger: "double" opens the editor on a double-click, as ever.
|
|
174
|
+
# "select" opens it on a click in the row that is ALREADY selected
|
|
175
|
+
# -- the spreadsheet and file-manager convention -- which frees the
|
|
176
|
+
# double-click to mean something else; see bind_activate_callback.
|
|
177
|
+
# select_rows: highlight the row last clicked. Implied by "select",
|
|
178
|
+
# which cannot work without a selection.
|
|
179
|
+
self._editable_columns = self._parse_columns(editable_columns)
|
|
180
|
+
self._edit_trigger = ("select" if str(edit_trigger).strip().lower() == "select"
|
|
181
|
+
else "double")
|
|
182
|
+
self._select_rows = (self._edit_trigger == "select"
|
|
183
|
+
or str(select_rows).strip().lower() in ("true", "1", "yes"))
|
|
184
|
+
self._selected_row = None
|
|
185
|
+
self._activate_callback = None
|
|
186
|
+
self._cell_editable_callback = None
|
|
187
|
+
# column index -> the values it may hold. Such a column is edited with
|
|
188
|
+
# a dropdown rather than typed: a fixed set of choices typed by hand
|
|
189
|
+
# invites typos, and each needs a rule for what counts as valid.
|
|
190
|
+
self._column_choices = {int(c): list(v) for c, v in (column_choices or {}).items()}
|
|
191
|
+
self._validation_with_row = False
|
|
192
|
+
self._editor = None
|
|
193
|
+
self._pending_edit = None
|
|
194
|
+
self._data_matrix, self._cell_widgets, self._header_widgets = [], [], []
|
|
195
|
+
|
|
196
|
+
self.columns_list = (list(columns) if (columns and isinstance(columns, (list, tuple)))
|
|
197
|
+
else [""] * self._num_columns)
|
|
198
|
+
|
|
199
|
+
# FIX: an earlier version temporarily overwrote self.__class__.__name__
|
|
200
|
+
# to "sCTkScrollableFrame" here, then restored it immediately after --
|
|
201
|
+
# a workaround for two problems that are now solved properly upstream:
|
|
202
|
+
# (1) sCTkScrollableFrame.__init__'s own internal ThemeableWidget.__init__
|
|
203
|
+
# call would otherwise run a second time on this instance, using
|
|
204
|
+
# self.__class__.__name__ (always "sCTkTableview", regardless of which
|
|
205
|
+
# class's code is executing) to look up the wrong theme block and
|
|
206
|
+
# overwrite this widget's correctly-built final_kw -- now prevented by
|
|
207
|
+
# ThemeableWidget's own run-once guard (see themeable_widget.py). (2)
|
|
208
|
+
# even with final_kw correctly preserved, sCTkScrollableFrame's own
|
|
209
|
+
# super().__init__() call would forward Tableview-specific keys like
|
|
210
|
+
# header_bg_color to the native CTkScrollableFrame constructor, which
|
|
211
|
+
# doesn't recognize them and has no **kwargs catch-all to fall back on
|
|
212
|
+
# -- now prevented by sCTkScrollableFrame's own whitelist filtering of
|
|
213
|
+
# its inbound kwargs before that call. Confirmed safe to call directly.
|
|
214
|
+
# The height asked for is a CEILING, not a suggestion: see load_dataset.
|
|
215
|
+
self._max_height = int(height)
|
|
216
|
+
super().__init__(master=master, width=width, height=height, *args)
|
|
217
|
+
|
|
218
|
+
super().configure(border_width=0, corner_radius=0, fg_color=self._cell_bg)
|
|
219
|
+
self.table_outline_frame = ctk.CTkFrame(self, fg_color=self._grid_line_color, border_width=self._outline_width, border_color=self._grid_line_color, corner_radius=self._outline_radius)
|
|
220
|
+
self.table_outline_frame.grid(row=0, column=0, sticky="nw", padx=1, pady=1)
|
|
221
|
+
|
|
222
|
+
self._create_header_bar()
|
|
223
|
+
self.load_dataset([[""] * self._num_columns for _ in range(self._num_rows)])
|
|
224
|
+
self.configure(state=state)
|
|
225
|
+
self._finalize_themeable_lifecycle()
|
|
226
|
+
|
|
227
|
+
def _create_header_bar(self):
|
|
228
|
+
for w in self._header_widgets:
|
|
229
|
+
try: w.destroy()
|
|
230
|
+
except Exception: pass
|
|
231
|
+
self._header_widgets = []
|
|
232
|
+
if hasattr(self, "header_separator") and self.header_separator:
|
|
233
|
+
try: self.header_separator.destroy()
|
|
234
|
+
except Exception: pass
|
|
235
|
+
self.header_separator = None
|
|
236
|
+
|
|
237
|
+
if not self._show_headers: return
|
|
238
|
+
is_none_mode = (self._grid_mode == "none")
|
|
239
|
+
gap_size, edge_size = (0 if is_none_mode else 1), (0 if is_none_mode else 2)
|
|
240
|
+
|
|
241
|
+
if self._header_line_width > 0:
|
|
242
|
+
self.header_separator = ctk.CTkFrame(self.table_outline_frame, height=self._header_line_width, fg_color=self._grid_line_color, corner_radius=0)
|
|
243
|
+
self.header_separator.grid(row=1, column=0, columnspan=self._num_columns, sticky="ew", padx=(edge_size, edge_size + gap_size), pady=(0, edge_size))
|
|
244
|
+
|
|
245
|
+
render_labels = list(self.columns_list)
|
|
246
|
+
if len(render_labels) < self._num_columns: render_labels += [""] * (self._num_columns - len(render_labels))
|
|
247
|
+
elif len(render_labels) > self._num_columns: render_labels = render_labels[:self._num_columns]
|
|
248
|
+
|
|
249
|
+
for col_idx, col_name in enumerate(render_labels):
|
|
250
|
+
w_limit = self._column_widths[col_idx] if col_idx < len(self._column_widths) else 120
|
|
251
|
+
header_cell = sCTkLabelPrimary(self.table_outline_frame, text=col_name, font=self._header_font, text_color=self._header_fg, fg_color=self._header_bg, corner_radius=0, height=28, width=w_limit)
|
|
252
|
+
left_pad, right_pad = (edge_size if col_idx == 0 else gap_size), (edge_size if col_idx == len(render_labels) - 1 else 0)
|
|
253
|
+
header_cell.grid(row=0, column=col_idx, sticky="ew", padx=(left_pad, right_pad), pady=(edge_size, 0))
|
|
254
|
+
self.table_outline_frame.grid_columnconfigure(col_idx, weight=0)
|
|
255
|
+
try: header_cell.lift()
|
|
256
|
+
except Exception: pass
|
|
257
|
+
self._header_widgets.append(header_cell)
|
|
258
|
+
def load_dataset(self, dataset: List[List[Any]]):
|
|
259
|
+
# An editor open over the old cells would be left floating over the
|
|
260
|
+
# new ones. Cancelled, not saved: the data it was editing is being
|
|
261
|
+
# replaced.
|
|
262
|
+
self._cancel_pending_edit()
|
|
263
|
+
self._close_editor(save=False)
|
|
264
|
+
for cell in [c for row in self._cell_widgets for c in row]: cell.destroy()
|
|
265
|
+
self._data_matrix, self._cell_widgets = [list(row) for row in dataset], []
|
|
266
|
+
super().configure(width=0, height=0)
|
|
267
|
+
|
|
268
|
+
row_offset = 1 if (self._show_headers and self._grid_mode == "none" and self._header_line_width == 0) else (2 if self._show_headers else 0)
|
|
269
|
+
is_none_mode = (self._grid_mode == "none")
|
|
270
|
+
gap_size, edge_size = (0 if is_none_mode else 1), (0 if is_none_mode else 2)
|
|
271
|
+
|
|
272
|
+
for r_idx, r_data in enumerate(self._data_matrix):
|
|
273
|
+
if len(r_data) < self._num_columns: r_data += [""] * (self._num_columns - len(r_data)); self._data_matrix[r_idx] = r_data
|
|
274
|
+
elif len(r_data) > self._num_columns: r_data = r_data[:self._num_columns]; self._data_matrix[r_idx] = r_data
|
|
275
|
+
|
|
276
|
+
current_row_bg = self._row_bg(r_idx)
|
|
277
|
+
r_cells = []
|
|
278
|
+
for c_idx in range(self._num_columns):
|
|
279
|
+
val = r_data[c_idx]
|
|
280
|
+
w_limit = self._column_widths[c_idx] if c_idx < len(self._column_widths) else 120
|
|
281
|
+
txt_anchor = self._column_anchors[c_idx] if c_idx < len(self._column_anchors) else "center"
|
|
282
|
+
display_val = " " + str(val) if txt_anchor == "w" else (str(val) + " " if txt_anchor == "e" else str(val))
|
|
283
|
+
|
|
284
|
+
cell_label = sCTkLabelSecondary(self.table_outline_frame, text=display_val, font=self._cell_font, text_color=self._cell_fg, width=w_limit, height=26, corner_radius=0, anchor=txt_anchor, fg_color="transparent")
|
|
285
|
+
cell_label.configure(fg_color=current_row_bg)
|
|
286
|
+
|
|
287
|
+
if self._state == "disabled": cell_label.configure(state="disabled")
|
|
288
|
+
|
|
289
|
+
top_pad, bot_pad = (edge_size if r_idx == 0 else gap_size), (edge_size if r_idx == len(self._data_matrix) - 1 else 0)
|
|
290
|
+
left_pad, right_pad = (edge_size if c_idx == 0 else gap_size), (edge_size if c_idx == self._num_columns - 1 else 0)
|
|
291
|
+
cell_label.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=(left_pad, right_pad), pady=(top_pad, bot_pad))
|
|
292
|
+
|
|
293
|
+
cell_label.bind("<Button-1>", lambda e, r=r_idx, c=c_idx: self._on_cell_click(r, c))
|
|
294
|
+
cell_label.bind("<Double-Button-1>", lambda e, r=r_idx, c=c_idx: self._on_cell_double(r, c))
|
|
295
|
+
r_cells.append(cell_label)
|
|
296
|
+
self._cell_widgets.append(r_cells)
|
|
297
|
+
|
|
298
|
+
# A selection beyond the new rows is dropped; one within them stays.
|
|
299
|
+
if self._selected_row is not None and self._selected_row >= len(self._data_matrix):
|
|
300
|
+
self._selected_row = None
|
|
301
|
+
|
|
302
|
+
for hw in self._header_widgets:
|
|
303
|
+
try: hw.lift()
|
|
304
|
+
except Exception: pass
|
|
305
|
+
if hasattr(self, "header_separator") and self.header_separator:
|
|
306
|
+
try: self.header_separator.lift()
|
|
307
|
+
except Exception: pass
|
|
308
|
+
|
|
309
|
+
self.update_idletasks()
|
|
310
|
+
# Fits its WIDTH to the columns, and its height to the rows -- but no
|
|
311
|
+
# taller than the height it was given. Beyond that it scrolls, which
|
|
312
|
+
# is what a scrolling frame is for. It used to fit the height to every
|
|
313
|
+
# row, so a long table asked for enough room never to scroll and
|
|
314
|
+
# pushed whatever else shared its parent out of the window.
|
|
315
|
+
super().configure(width=self.table_outline_frame.winfo_reqwidth() + 14,
|
|
316
|
+
height=min(self.table_outline_frame.winfo_reqheight() + 18,
|
|
317
|
+
self._max_height))
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Clicks
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
# How long a click on the selected row waits before opening the editor.
|
|
323
|
+
# A double-click begins with an ordinary click, so without this pause a
|
|
324
|
+
# double-click on the selected row would open the editor AND activate the
|
|
325
|
+
# row. The click waits; a double-click arriving in the meantime cancels
|
|
326
|
+
# it. The same pause file managers use before renaming.
|
|
327
|
+
EDIT_DELAY_MS = 500
|
|
328
|
+
|
|
329
|
+
# Where a dropdown editor's list opens, relative to the cell it edits:
|
|
330
|
+
# "below" -- just under the cell, leaving the value visible
|
|
331
|
+
# "over" -- starting at the cell's top edge, as a native menu does
|
|
332
|
+
# Settable per table, or for every table on the class.
|
|
333
|
+
CHOICE_LIST_POSITION = "below"
|
|
334
|
+
|
|
335
|
+
def _on_cell_click(self, r_idx: int, c_idx: int):
|
|
336
|
+
if self._state != "normal":
|
|
337
|
+
return
|
|
338
|
+
# A text editor closes itself when it loses focus; a dropdown does
|
|
339
|
+
# not, so a click on any cell closes one left open -- keeping nothing
|
|
340
|
+
# unless a value was chosen.
|
|
341
|
+
if self._editor and getattr(self._editor[0], "_is_choice", False):
|
|
342
|
+
self._close_editor(save=True)
|
|
343
|
+
was_selected = (self._selected_row == r_idx)
|
|
344
|
+
if self._select_rows:
|
|
345
|
+
self._set_selection(r_idx)
|
|
346
|
+
if self._click_callback:
|
|
347
|
+
self._click_callback(r_idx, self._data_matrix[r_idx])
|
|
348
|
+
if (self._edit_trigger == "select" and was_selected
|
|
349
|
+
and self._is_editable(r_idx, c_idx)):
|
|
350
|
+
self._cancel_pending_edit()
|
|
351
|
+
self._pending_edit = defer(
|
|
352
|
+
self, self.EDIT_DELAY_MS, lambda: self._begin_pending_edit(r_idx, c_idx))
|
|
353
|
+
|
|
354
|
+
def _on_cell_double(self, r_idx: int, c_idx: int):
|
|
355
|
+
if self._state != "normal":
|
|
356
|
+
return
|
|
357
|
+
self._cancel_pending_edit()
|
|
358
|
+
if self._edit_trigger == "double" and self._is_editable(r_idx, c_idx):
|
|
359
|
+
self._spawn_editor(r_idx, c_idx)
|
|
360
|
+
elif self._activate_callback:
|
|
361
|
+
self._activate_callback(r_idx, self._data_matrix[r_idx])
|
|
362
|
+
|
|
363
|
+
def _begin_pending_edit(self, r_idx: int, c_idx: int):
|
|
364
|
+
self._pending_edit = None
|
|
365
|
+
if self._state == "normal" and r_idx < len(self._data_matrix):
|
|
366
|
+
self._spawn_editor(r_idx, c_idx)
|
|
367
|
+
|
|
368
|
+
def _cancel_pending_edit(self):
|
|
369
|
+
if self._pending_edit is not None:
|
|
370
|
+
cancel_deferred(self, self._pending_edit)
|
|
371
|
+
self._pending_edit = None
|
|
372
|
+
|
|
373
|
+
def _is_editable(self, r_idx: int, c_idx: int) -> bool:
|
|
374
|
+
"""
|
|
375
|
+
The column must be editable, and -- if a cell check is bound -- so
|
|
376
|
+
must this particular cell. A cell that may not be edited never opens
|
|
377
|
+
an editor, rather than opening one and then throwing the typing away.
|
|
378
|
+
"""
|
|
379
|
+
if self._editable_columns is not None and c_idx not in self._editable_columns:
|
|
380
|
+
return False
|
|
381
|
+
if self._cell_editable_callback is not None:
|
|
382
|
+
return bool(self._cell_editable_callback(r_idx, c_idx))
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
def _parse_columns(value):
|
|
387
|
+
"""None for every column, else a set of column indices."""
|
|
388
|
+
if value is None:
|
|
389
|
+
return None
|
|
390
|
+
if isinstance(value, str):
|
|
391
|
+
value = parse_list_property(value) if value.strip() else []
|
|
392
|
+
return {int(v) for v in value}
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# Editing a cell
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
def _spawn_editor(self, r_idx: int, c_idx: int):
|
|
398
|
+
# One editor at a time. A second one is only opened by a deliberate
|
|
399
|
+
# edit elsewhere, so the first is kept, as a spreadsheet would.
|
|
400
|
+
self._close_editor(save=True)
|
|
401
|
+
row_offset = 1 if (self._show_headers and self._grid_mode == "none" and self._header_line_width == 0) else (
|
|
402
|
+
2 if self._show_headers else 0)
|
|
403
|
+
choices = self._column_choices.get(c_idx)
|
|
404
|
+
if choices:
|
|
405
|
+
self._spawn_choice_editor(r_idx, c_idx, choices, row_offset)
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
# sCTkEntryPrimary, not a bare CTkEntry, so the editor follows the
|
|
409
|
+
# theme like every other control in the library.
|
|
410
|
+
entry = sCTkEntryPrimary(self.table_outline_frame, font=self._cell_font,
|
|
411
|
+
width=self._column_widths[c_idx], height=24,
|
|
412
|
+
corner_radius=0)
|
|
413
|
+
entry.insert(0, str(self._data_matrix[r_idx][c_idx]))
|
|
414
|
+
entry.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=1, pady=1)
|
|
415
|
+
entry.focus_set()
|
|
416
|
+
entry.select_range(0, "end")
|
|
417
|
+
entry.bind("<Return>", lambda e: self._save_edit(r_idx, c_idx, entry))
|
|
418
|
+
entry.bind("<FocusOut>", lambda e: self._save_edit(r_idx, c_idx, entry))
|
|
419
|
+
# ESCAPE CANCELS. Without it, the only way out of an editor opened by
|
|
420
|
+
# mistake was to retype the old value -- Return and a click away both
|
|
421
|
+
# save.
|
|
422
|
+
entry.bind("<Escape>", lambda e: self._cancel_editor(entry))
|
|
423
|
+
self._editor = (entry, r_idx, c_idx)
|
|
424
|
+
|
|
425
|
+
def _spawn_choice_editor(self, r_idx, c_idx, choices, row_offset):
|
|
426
|
+
"""
|
|
427
|
+
A dropdown over the cell, open straight away.
|
|
428
|
+
|
|
429
|
+
ONLY A CHOICE IS KEPT. The menu has to show something when it opens,
|
|
430
|
+
and for a cell holding none of the choices -- empty, say -- that is
|
|
431
|
+
the first one. Closing it without choosing must not store that first
|
|
432
|
+
choice as though it had been picked, so the value is saved from the
|
|
433
|
+
menu's command, and nowhere else.
|
|
434
|
+
"""
|
|
435
|
+
current = str(self._data_matrix[r_idx][c_idx])
|
|
436
|
+
# The cell's font, for the box and its list alike, so the value does
|
|
437
|
+
# not jump in size the moment it becomes editable -- the option menu's
|
|
438
|
+
# own theme font was noticeably larger than the table's text.
|
|
439
|
+
menu = sCTkOptionMenuPrimary(self.table_outline_frame, values=list(choices),
|
|
440
|
+
width=self._column_widths[c_idx], height=24,
|
|
441
|
+
font=self._cell_font, dropdown_font=self._cell_font)
|
|
442
|
+
menu.set(current if current in choices else choices[0])
|
|
443
|
+
menu._is_choice = True
|
|
444
|
+
menu.configure(command=lambda value: self._choose(r_idx, c_idx, menu))
|
|
445
|
+
menu.grid(row=r_idx + row_offset, column=c_idx, sticky="ew", padx=1, pady=1)
|
|
446
|
+
menu.bind("<Escape>", lambda e: self._cancel_editor(menu))
|
|
447
|
+
self._editor = (menu, r_idx, c_idx)
|
|
448
|
+
# Open the list at once, so choosing takes one click, like typing.
|
|
449
|
+
defer(self, "idle", lambda: self._open_choice_list(menu, r_idx, c_idx))
|
|
450
|
+
|
|
451
|
+
def _open_choice_list(self, menu, r_idx, c_idx):
|
|
452
|
+
"""
|
|
453
|
+
Opens a dropdown editor's list, placed against its CELL.
|
|
454
|
+
|
|
455
|
+
CustomTkinter places the list from the menu widget's own position on
|
|
456
|
+
screen -- but this menu was only just created, and has not been laid
|
|
457
|
+
out yet, so that position is not known and the list could open well
|
|
458
|
+
away from the table. The cell under it has been on screen all along,
|
|
459
|
+
so its position is right from the start.
|
|
460
|
+
|
|
461
|
+
Uses CTkOptionMenu's own dropdown, which is private. If a later
|
|
462
|
+
version renames it, this falls back to the menu's own opener -- the
|
|
463
|
+
list then opens where CustomTkinter puts it -- or, failing that, the
|
|
464
|
+
menu simply opens on a click.
|
|
465
|
+
"""
|
|
466
|
+
if not menu.winfo_exists():
|
|
467
|
+
return
|
|
468
|
+
cell = self._cell_widgets[r_idx][c_idx]
|
|
469
|
+
cell.update_idletasks()
|
|
470
|
+
x = cell.winfo_rootx()
|
|
471
|
+
y = cell.winfo_rooty()
|
|
472
|
+
if str(self.CHOICE_LIST_POSITION).lower() != "over":
|
|
473
|
+
y += cell.winfo_height()
|
|
474
|
+
|
|
475
|
+
dropdown = getattr(menu, "_dropdown_menu", None)
|
|
476
|
+
if dropdown is not None and callable(getattr(dropdown, "open", None)):
|
|
477
|
+
dropdown.open(x, y)
|
|
478
|
+
return
|
|
479
|
+
opener = getattr(menu, "_open_dropdown_menu", None)
|
|
480
|
+
if callable(opener):
|
|
481
|
+
opener()
|
|
482
|
+
|
|
483
|
+
def _choose(self, r_idx, c_idx, menu):
|
|
484
|
+
"""A value was chosen from a dropdown editor: keep it."""
|
|
485
|
+
menu._chosen = True
|
|
486
|
+
self._save_edit(r_idx, c_idx, menu)
|
|
487
|
+
|
|
488
|
+
def _cancel_editor(self, entry):
|
|
489
|
+
entry._cancelled = True
|
|
490
|
+
if self._editor and self._editor[0] is entry:
|
|
491
|
+
self._editor = None
|
|
492
|
+
if entry.winfo_exists():
|
|
493
|
+
entry.destroy()
|
|
494
|
+
|
|
495
|
+
def _close_editor(self, save: bool):
|
|
496
|
+
"""Closes the open editor, if there is one, saving or not."""
|
|
497
|
+
if not self._editor:
|
|
498
|
+
return
|
|
499
|
+
entry, r_idx, c_idx = self._editor
|
|
500
|
+
# A dropdown is kept only if something was chosen from it.
|
|
501
|
+
if getattr(entry, "_is_choice", False) and not getattr(entry, "_chosen", False):
|
|
502
|
+
save = False
|
|
503
|
+
if save:
|
|
504
|
+
self._save_edit(r_idx, c_idx, entry)
|
|
505
|
+
else:
|
|
506
|
+
self._cancel_editor(entry)
|
|
507
|
+
|
|
508
|
+
def _save_edit(self, r_idx: int, c_idx: int, entry):
|
|
509
|
+
# Cancelled by Escape: the FocusOut that follows the editor's removal
|
|
510
|
+
# must not save what was cancelled.
|
|
511
|
+
if getattr(entry, "_cancelled", False) or not entry.winfo_exists(): return
|
|
512
|
+
val = entry.get()
|
|
513
|
+
entry._cancelled = True # nothing after this saves it twice
|
|
514
|
+
if self._editor and self._editor[0] is entry:
|
|
515
|
+
self._editor = None
|
|
516
|
+
entry.destroy()
|
|
517
|
+
if r_idx >= len(self._data_matrix): return
|
|
518
|
+
|
|
519
|
+
# VALIDATION. The callback may answer:
|
|
520
|
+
# a string -- accept, but store THIS instead (a value tidied up,
|
|
521
|
+
# such as a frequency typed "14.074" shown as
|
|
522
|
+
# "14.074.000")
|
|
523
|
+
# truthy -- accept as typed
|
|
524
|
+
# falsy -- reject; the cell keeps its old value
|
|
525
|
+
# A plain True/False callback behaves exactly as it always did.
|
|
526
|
+
if self._validation_callback:
|
|
527
|
+
verdict = (self._validation_callback(r_idx, c_idx, val) if self._validation_with_row
|
|
528
|
+
else self._validation_callback(c_idx, val))
|
|
529
|
+
if isinstance(verdict, str):
|
|
530
|
+
val = verdict
|
|
531
|
+
elif not verdict:
|
|
532
|
+
val = self._data_matrix[r_idx][c_idx]
|
|
533
|
+
|
|
534
|
+
# FIX: an earlier version compared self._data_matrix[r_idx][c_idx]
|
|
535
|
+
# against val AFTER the assignment below had already written val into
|
|
536
|
+
# that exact cell -- a tautology that was always true, so the edit
|
|
537
|
+
# callback fired on every save regardless of whether anything had
|
|
538
|
+
# actually changed. The previous value has to be captured BEFORE the
|
|
539
|
+
# write. A rejected edit reverts val to the old value above, so the
|
|
540
|
+
# callback correctly stays silent for it too.
|
|
541
|
+
old_val = self._data_matrix[r_idx][c_idx]
|
|
542
|
+
|
|
543
|
+
self._data_matrix[r_idx][c_idx] = val
|
|
544
|
+
self._cell_widgets[r_idx][c_idx].configure(
|
|
545
|
+
text=self._display(val, self._column_anchors[c_idx]))
|
|
546
|
+
if self._edit_callback and old_val != val: self._edit_callback(r_idx, c_idx, val)
|
|
547
|
+
|
|
548
|
+
@staticmethod
|
|
549
|
+
def _display(val, anchor):
|
|
550
|
+
"""A cell's text, padded away from the edge it is anchored to."""
|
|
551
|
+
return (" " + str(val) if anchor == "w"
|
|
552
|
+
else (str(val) + " " if anchor == "e" else str(val)))
|
|
553
|
+
|
|
554
|
+
# ------------------------------------------------------------------
|
|
555
|
+
# Selection
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
def _row_bg(self, r_idx: int):
|
|
558
|
+
if self._select_rows and r_idx == self._selected_row:
|
|
559
|
+
return self._selected_bg
|
|
560
|
+
return self._cell_alt_bg if (self._grid_mode == "zebra" and r_idx % 2 != 0) else self._cell_bg
|
|
561
|
+
|
|
562
|
+
def _paint_row(self, r_idx):
|
|
563
|
+
if r_idx is None or not 0 <= r_idx < len(self._cell_widgets):
|
|
564
|
+
return
|
|
565
|
+
bg = self._row_bg(r_idx)
|
|
566
|
+
for cell in self._cell_widgets[r_idx]:
|
|
567
|
+
cell.configure(fg_color=bg)
|
|
568
|
+
|
|
569
|
+
def _set_selection(self, r_idx):
|
|
570
|
+
previous, self._selected_row = self._selected_row, r_idx
|
|
571
|
+
if previous != r_idx:
|
|
572
|
+
self._paint_row(previous)
|
|
573
|
+
self._paint_row(r_idx)
|
|
574
|
+
|
|
575
|
+
def select_row(self, r_idx: Optional[int]):
|
|
576
|
+
"""
|
|
577
|
+
Selects a row from code -- or clears the selection, given None.
|
|
578
|
+
|
|
579
|
+
Does NOT call the selection callback: selecting from code is the
|
|
580
|
+
application saying what it already knows. The same rule as set() on
|
|
581
|
+
every other widget in the library.
|
|
582
|
+
"""
|
|
583
|
+
if r_idx is not None and not 0 <= r_idx < len(self._data_matrix):
|
|
584
|
+
r_idx = None
|
|
585
|
+
self._set_selection(r_idx)
|
|
586
|
+
|
|
587
|
+
def get_selected_row(self) -> Optional[int]:
|
|
588
|
+
"""The selected row's index, or None."""
|
|
589
|
+
return self._selected_row
|
|
590
|
+
|
|
591
|
+
def clear_selection(self):
|
|
592
|
+
self.select_row(None)
|
|
593
|
+
|
|
594
|
+
def set_row(self, r_idx: int, values: List[Any]):
|
|
595
|
+
"""
|
|
596
|
+
Replaces one row's values in place, without rebuilding the table.
|
|
597
|
+
|
|
598
|
+
load_dataset() destroys and recreates every cell. For a table that
|
|
599
|
+
changes often -- one row at a time, as data arrives -- that is a
|
|
600
|
+
great deal of work and a visible flicker; this updates only the text.
|
|
601
|
+
"""
|
|
602
|
+
if not 0 <= r_idx < len(self._data_matrix):
|
|
603
|
+
return
|
|
604
|
+
values = list(values)[:self._num_columns]
|
|
605
|
+
values += [""] * (self._num_columns - len(values))
|
|
606
|
+
self._data_matrix[r_idx] = values
|
|
607
|
+
for c_idx, val in enumerate(values):
|
|
608
|
+
anchor = self._column_anchors[c_idx] if c_idx < len(self._column_anchors) else "center"
|
|
609
|
+
self._cell_widgets[r_idx][c_idx].configure(text=self._display(val, anchor))
|
|
610
|
+
|
|
611
|
+
# Native constructor defaults, for the single-argument query below.
|
|
612
|
+
# Anything not listed reports its current value as its default.
|
|
613
|
+
_NATIVE_QUERY_DEFAULTS = {"width": 500, "height": 300}
|
|
614
|
+
|
|
615
|
+
def configure(self, require_redraw=False, **kwargs):
|
|
616
|
+
if require_redraw is not None and not kwargs and isinstance(require_redraw, str):
|
|
617
|
+
mapping = {
|
|
618
|
+
"state": ("state", "state", "state", "normal", str(getattr(self, "_state", "normal"))),
|
|
619
|
+
"grid_mode": ("grid_mode", "grid_mode", "grid_mode", "zebra",
|
|
620
|
+
str(getattr(self, "_grid_mode", "zebra"))),
|
|
621
|
+
"show_headers": ("show_headers", "show_headers", "show_headers", "True",
|
|
622
|
+
str(getattr(self, "_show_headers", True)))
|
|
623
|
+
}
|
|
624
|
+
if require_redraw in mapping: return mapping[require_redraw]
|
|
625
|
+
if require_redraw in ["num_columns", "num_rows", "header_line_width"]: return (require_redraw,
|
|
626
|
+
require_redraw,
|
|
627
|
+
require_redraw, "0",
|
|
628
|
+
str(getattr(self,
|
|
629
|
+
f"_{require_redraw}",
|
|
630
|
+
0)))
|
|
631
|
+
# Falls through to sCTkScrollableFrame.configure()'s own
|
|
632
|
+
# single-argument branch, which returns a Tkinter-style 5-tuple for
|
|
633
|
+
# any name it doesn't recognise rather than forwarding the string to
|
|
634
|
+
# native CTkScrollableFrame.configure() -- that signature takes no
|
|
635
|
+
# positional argument and would raise TypeError. Reached from Pygubu
|
|
636
|
+
# when a property is blanked in the inspector.
|
|
637
|
+
# FIX: this used to be `return super().configure(require_redraw)`.
|
|
638
|
+
#
|
|
639
|
+
# The native configure() is declared
|
|
640
|
+
# configure(self, require_redraw=False, **kwargs), so the property
|
|
641
|
+
# NAME was passed as require_redraw and the call returned None.
|
|
642
|
+
# Pygubu's _get_default_value() expects a five-element tuple, got
|
|
643
|
+
# None, and handed that None back to _set_property() -- where
|
|
644
|
+
# CustomTkinter raised "float() argument must be a string or a real
|
|
645
|
+
# number, not 'NoneType'".
|
|
646
|
+
#
|
|
647
|
+
# Reached whenever a field is blanked in the Designer inspector,
|
|
648
|
+
# which calls configure(name) to read the property's default.
|
|
649
|
+
#
|
|
650
|
+
# A proper Tkinter-style tuple is returned instead, built from
|
|
651
|
+
# cget() so the reported current value is real. Properties with no
|
|
652
|
+
# known native default report their current value as the default,
|
|
653
|
+
# making a blank a no-op rather than a jump to a guessed value.
|
|
654
|
+
try:
|
|
655
|
+
_current = self.cget(require_redraw)
|
|
656
|
+
except Exception:
|
|
657
|
+
_current = None
|
|
658
|
+
# FIX: the default came from _NATIVE_QUERY_DEFAULTS or, failing
|
|
659
|
+
# that, from cget() -- the CURRENT value. So clearing a themed
|
|
660
|
+
# property in the Designer reported whatever override was in place
|
|
661
|
+
# as its own default and put it straight back.
|
|
662
|
+
#
|
|
663
|
+
# The THEME's value is the right answer, taken from an untouched
|
|
664
|
+
# copy so a runtime override cannot corrupt it -- see
|
|
665
|
+
# ThemeableWidget._theme_default(). The native table still wins
|
|
666
|
+
# where it has an entry, for properties the theme says nothing
|
|
667
|
+
# about.
|
|
668
|
+
_default = self._NATIVE_QUERY_DEFAULTS.get(require_redraw)
|
|
669
|
+
if _default is None:
|
|
670
|
+
_default = self._theme_default(require_redraw)
|
|
671
|
+
if _default is None:
|
|
672
|
+
_default = _current
|
|
673
|
+
return (require_redraw, require_redraw, require_redraw,
|
|
674
|
+
self._query_value(_default, require_redraw),
|
|
675
|
+
self._query_value(_current, require_redraw))
|
|
676
|
+
|
|
677
|
+
if isinstance(require_redraw, dict): kwargs.update(require_redraw); require_redraw = False
|
|
678
|
+
|
|
679
|
+
# Runtime overrides have to reach the map a repaint reads, or the
|
|
680
|
+
# repaint puts the theme value straight back -- see
|
|
681
|
+
# ThemeableWidget._record_theme_overrides(). Placed after the dict form
|
|
682
|
+
# is merged, so a value passed that way is recorded too.
|
|
683
|
+
self._record_theme_overrides(kwargs)
|
|
684
|
+
|
|
685
|
+
rebuild_layout = False
|
|
686
|
+
|
|
687
|
+
# A new height is a new ceiling -- and is still passed on below.
|
|
688
|
+
if "height" in kwargs:
|
|
689
|
+
self._max_height = int(kwargs["height"])
|
|
690
|
+
|
|
691
|
+
if "editable_columns" in kwargs:
|
|
692
|
+
self._editable_columns = self._parse_columns(kwargs.pop("editable_columns"))
|
|
693
|
+
if "edit_trigger" in kwargs:
|
|
694
|
+
self._edit_trigger = ("select" if str(kwargs.pop("edit_trigger")).strip().lower()
|
|
695
|
+
== "select" else "double")
|
|
696
|
+
self._select_rows = self._select_rows or self._edit_trigger == "select"
|
|
697
|
+
if "select_rows" in kwargs:
|
|
698
|
+
self._select_rows = (self._edit_trigger == "select"
|
|
699
|
+
or str(kwargs.pop("select_rows")).strip().lower()
|
|
700
|
+
in ("true", "1", "yes"))
|
|
701
|
+
for r in range(len(self._cell_widgets)):
|
|
702
|
+
self._paint_row(r)
|
|
703
|
+
|
|
704
|
+
for k in ["cell_bg_color", "cell_alt_bg_color", "num_columns", "num_rows", "header_line_width", "grid_mode",
|
|
705
|
+
"show_headers", "outline_width", "outline_radius", "columns"]:
|
|
706
|
+
if k in kwargs:
|
|
707
|
+
v = kwargs.pop(k)
|
|
708
|
+
if k == "cell_bg_color":
|
|
709
|
+
self._cell_bg = v
|
|
710
|
+
elif k == "cell_alt_bg_color":
|
|
711
|
+
self._cell_alt_bg = v
|
|
712
|
+
elif k == "num_columns":
|
|
713
|
+
self._num_columns = int(v)
|
|
714
|
+
elif k == "num_rows":
|
|
715
|
+
self._num_rows = int(v)
|
|
716
|
+
elif k == "header_line_width":
|
|
717
|
+
self._header_line_width = int(v)
|
|
718
|
+
elif k == "grid_mode":
|
|
719
|
+
self._grid_mode = str(v).replace("'", "").replace('"', "").strip().lower()
|
|
720
|
+
elif k == "show_headers":
|
|
721
|
+
self._show_headers = v if isinstance(v, bool) else (str(v).lower() in ("true", "1", "yes"))
|
|
722
|
+
elif k == "outline_width":
|
|
723
|
+
self._outline_width = float(v); self.table_outline_frame.configure(border_width=self._outline_width)
|
|
724
|
+
elif k == "outline_radius":
|
|
725
|
+
self._outline_radius = int(v); self.table_outline_frame.configure(
|
|
726
|
+
corner_radius=self._outline_radius)
|
|
727
|
+
elif k == "columns":
|
|
728
|
+
# FIX: `columns` was accepted by __init__ and registered as
|
|
729
|
+
# a Pygubu Designer property, but configure() never popped
|
|
730
|
+
# it -- so editing the column list at runtime fell straight
|
|
731
|
+
# through to native CTkScrollableFrame.configure(), whose
|
|
732
|
+
# check_kwargs_empty() raised
|
|
733
|
+
# "['columns'] are not supported arguments". Construction
|
|
734
|
+
# worked; reconfiguration did not, which is exactly what
|
|
735
|
+
# the Designer does when the field is edited.
|
|
736
|
+
#
|
|
737
|
+
# Accepts a comma-separated string as well as a list, since
|
|
738
|
+
# __init__ does and Pygubu's property editor supplies a
|
|
739
|
+
# string.
|
|
740
|
+
parsed = parse_list_property(v)
|
|
741
|
+
self.columns_list = parsed if parsed else [""] * self._num_columns
|
|
742
|
+
# Column count follows the labels, matching how the Designer
|
|
743
|
+
# BO derives num_columns from the columns string.
|
|
744
|
+
self._num_columns = len(self.columns_list)
|
|
745
|
+
rebuild_layout = True
|
|
746
|
+
|
|
747
|
+
if rebuild_layout:
|
|
748
|
+
if len(self._column_widths) < self._num_columns:
|
|
749
|
+
self._column_widths += [120] * (self._num_columns - len(self._column_widths))
|
|
750
|
+
self._column_anchors += ["center"] * (self._num_columns - len(self._column_anchors))
|
|
751
|
+
self._create_header_bar()
|
|
752
|
+
self.load_dataset([[""] * self._num_columns for _ in range(max(self._num_rows, len(self._data_matrix)))])
|
|
753
|
+
|
|
754
|
+
self._apply_state_and_theme_updates(kwargs)
|
|
755
|
+
if kwargs: super().configure(**kwargs)
|
|
756
|
+
|
|
757
|
+
def _apply_state_and_theme_updates(self, kwargs):
|
|
758
|
+
if "state" not in kwargs: return
|
|
759
|
+
self._state = str(kwargs.pop("state")).lower()
|
|
760
|
+
dis_map = self._custom_disabled_map
|
|
761
|
+
normal_map = self._switch_theme_profile
|
|
762
|
+
is_disabled = self._state == "disabled"
|
|
763
|
+
|
|
764
|
+
# FIX: an earlier version repeated the same hardcoded fallback
|
|
765
|
+
# literals here as __init__ (see that method's comments) -- removed
|
|
766
|
+
# now that required keys are validated once, at construction. Also
|
|
767
|
+
# fixes a related bug: cell_bg_color/cell_alt_bg_color now correctly
|
|
768
|
+
# restore whatever this instance actually resolved to when
|
|
769
|
+
# constructed (theme value or constructor kwarg override), instead
|
|
770
|
+
# of always reverting to the theme's value and silently discarding a
|
|
771
|
+
# constructor override on every return to "normal".
|
|
772
|
+
self._header_bg = dis_map.get("header_bg_color") if is_disabled else normal_map.get("header_bg_color")
|
|
773
|
+
self._header_fg = dis_map.get("header_text_color") if is_disabled else normal_map.get("header_text_color")
|
|
774
|
+
self._cell_bg = dis_map.get("cell_bg_color") if is_disabled else self._resolved_normal_cell_bg
|
|
775
|
+
self._cell_alt_bg = dis_map.get("cell_alt_bg_color") if is_disabled else self._resolved_normal_cell_alt_bg
|
|
776
|
+
self._cell_fg = dis_map.get("cell_text_color") if is_disabled else normal_map.get("cell_text_color")
|
|
777
|
+
self._grid_line_color = dis_map.get("grid_line_color") if is_disabled else normal_map.get("grid_line_color")
|
|
778
|
+
# The selection colour follows the state too -- optional in both maps,
|
|
779
|
+
# falling back to the grid-line colour, as at construction.
|
|
780
|
+
self._selected_bg = ((dis_map if is_disabled else normal_map).get("cell_selected_color")
|
|
781
|
+
or self._grid_line_color)
|
|
782
|
+
|
|
783
|
+
if hasattr(self, "table_outline_frame") and self.table_outline_frame:
|
|
784
|
+
self.table_outline_frame.configure(fg_color=self._grid_line_color, border_color=self._grid_line_color)
|
|
785
|
+
|
|
786
|
+
for header_cell in self._header_widgets:
|
|
787
|
+
header_cell.configure(fg_color=self._header_bg, text_color=self._header_fg)
|
|
788
|
+
|
|
789
|
+
for r_idx, row in enumerate(self._cell_widgets):
|
|
790
|
+
row_bg = self._row_bg(r_idx)
|
|
791
|
+
for cell in row:
|
|
792
|
+
cell.configure(fg_color=row_bg, text_color=self._cell_fg, state=self._state)
|
|
793
|
+
|
|
794
|
+
# 🔑 THE CORE DESIGN PATTERN GATEWAY: Aligns fully with all other repository widgets!
|
|
795
|
+
def state(self, mode: str = None) -> str:
|
|
796
|
+
"""Unified state tracker gateway. Acts as getter if mode is None, otherwise configures state."""
|
|
797
|
+
if mode is None:
|
|
798
|
+
return str(getattr(self, "_state", "normal")).lower()
|
|
799
|
+
self.configure(state=mode)
|
|
800
|
+
return mode
|
|
801
|
+
|
|
802
|
+
# 🔑 API CONTINUITY PASS-THROUGHS
|
|
803
|
+
def get_state(self) -> str:
|
|
804
|
+
return self.state()
|
|
805
|
+
|
|
806
|
+
config = configure
|
|
807
|
+
|
|
808
|
+
def get_num_rows(self) -> int:
|
|
809
|
+
return len(self._cell_widgets) if self._cell_widgets else self._num_rows
|
|
810
|
+
|
|
811
|
+
def get_num_columns(self) -> int:
|
|
812
|
+
return len(self._cell_widgets) if (self._cell_widgets and self._cell_widgets) else self._num_columns
|
|
813
|
+
|
|
814
|
+
def set_column_properties(self, column_index: int, width: int, anchor: Literal["w", "center", "e"] = "center"):
|
|
815
|
+
if 0 <= column_index < len(self._column_widths):
|
|
816
|
+
self._column_widths[column_index], self._column_anchors[column_index] = width, anchor
|
|
817
|
+
if column_index < len(self._header_widgets) and self._show_headers:
|
|
818
|
+
txt = self.columns_list[column_index] if column_index < len(self.columns_list) else ""
|
|
819
|
+
self._header_widgets[column_index].configure(width=width, anchor=anchor,
|
|
820
|
+
text=" " + txt if anchor == "w" else (
|
|
821
|
+
txt + " " if anchor == "e" else txt))
|
|
822
|
+
|
|
823
|
+
def bind_selection_callback(self, callback: Callable):
|
|
824
|
+
self._click_callback = callback
|
|
825
|
+
|
|
826
|
+
def bind_edit_callback(self, callback: Callable):
|
|
827
|
+
self._edit_callback = callback
|
|
828
|
+
|
|
829
|
+
def set_column_choices(self, column_index: int, choices: Optional[List[str]]):
|
|
830
|
+
"""
|
|
831
|
+
Makes a column edited by choosing from a list rather than typing --
|
|
832
|
+
or, given None, typed again.
|
|
833
|
+
"""
|
|
834
|
+
if choices:
|
|
835
|
+
self._column_choices[int(column_index)] = list(choices)
|
|
836
|
+
else:
|
|
837
|
+
self._column_choices.pop(int(column_index), None)
|
|
838
|
+
|
|
839
|
+
def bind_cell_editable_callback(self, callback: Callable):
|
|
840
|
+
"""
|
|
841
|
+
callback(row_index, column_index) -> bool: whether one cell may be
|
|
842
|
+
edited, for the cases a column rule cannot express -- a column
|
|
843
|
+
editable in some rows and not others. Consulted after
|
|
844
|
+
editable_columns: a column left out there stays read-only whatever
|
|
845
|
+
this says. None removes the check.
|
|
846
|
+
"""
|
|
847
|
+
self._cell_editable_callback = callback
|
|
848
|
+
|
|
849
|
+
def bind_activate_callback(self, callback: Callable):
|
|
850
|
+
"""
|
|
851
|
+
callback(row_index, row_values), on a double-click that does not open
|
|
852
|
+
an editor -- every double-click when edit_trigger is "select", or one
|
|
853
|
+
on a read-only column otherwise. The natural "open this row" action.
|
|
854
|
+
"""
|
|
855
|
+
self._activate_callback = callback
|
|
856
|
+
|
|
857
|
+
def bind_validation_callback(self, callback: Callable, with_row: bool = False):
|
|
858
|
+
"""
|
|
859
|
+
callback(column_index, value) -- or, with with_row=True,
|
|
860
|
+
callback(row_index, column_index, value) -- checks an edit before it
|
|
861
|
+
is stored. Return a string to store that instead, anything truthy to
|
|
862
|
+
accept the edit as typed, or anything falsy to reject it.
|
|
863
|
+
|
|
864
|
+
with_row exists because some checks depend on the row: which rows
|
|
865
|
+
may carry a value at all, say. It is a separate switch rather than a
|
|
866
|
+
change of signature, so existing two-argument callbacks go on working.
|
|
867
|
+
"""
|
|
868
|
+
self._validation_callback = callback
|
|
869
|
+
self._validation_with_row = bool(with_row)
|