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,938 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
ThemeableWidget
|
|
4
|
+
|
|
5
|
+
Shared __init__-time theme resolution and color-utility mixin for sCustomTkinter widgets.
|
|
6
|
+
|
|
7
|
+
Scope note: earlier versions of this class also defined configure()/cget()/_set_appearance_mode()
|
|
8
|
+
overrides, intended as shared runtime color-swapping logic used by every widget. Those methods are
|
|
9
|
+
removed here because they were structurally unreachable. Every sCTk* widget declares its native CTk
|
|
10
|
+
base class before ThemeableWidget (e.g. `class sCTkComboBox(ctk.CTkComboBox, ThemeableWidget)`), so
|
|
11
|
+
Python's MRO always resolves super().configure()/cget() calls to the native CTk widget -- and beneath
|
|
12
|
+
it, tkinter.Misc -- first. Both terminate the call chain without ever reaching ThemeableWidget. This
|
|
13
|
+
holds even for a hypothetical widget that defines neither configure() nor cget() itself, since
|
|
14
|
+
tkinter.Misc (an ancestor of every CTk widget) already implements both ahead of ThemeableWidget in
|
|
15
|
+
the MRO. Verified empirically against the real widget files during the docs/coding audit, not just
|
|
16
|
+
inferred from the class layout.
|
|
17
|
+
|
|
18
|
+
What this class DOES provide, and is genuinely used by every widget:
|
|
19
|
+
- __init__: resolves this widget's block from sCTkThemes.json, merges it with any keyword
|
|
20
|
+
overrides passed to the constructor, and builds self.final_kw plus the disabled/pressed/alarm/
|
|
21
|
+
readonly color maps (self._widget_disabled_map / _widget_pressed_map / _widget_alarm_map /
|
|
22
|
+
_widget_readonly_map).
|
|
23
|
+
- _resolve_color / _convert_lists_to_tuples / _sanitize_value: color-normalization helpers,
|
|
24
|
+
called directly (via self.) by each widget's own visual-state logic.
|
|
25
|
+
- _finalize_themeable_lifecycle: Pygubu lifecycle hook, called directly by each widget's __init__.
|
|
26
|
+
- apply_theme: convenience method that re-applies self.final_kw via self.configure(). Because this
|
|
27
|
+
calls self.configure() (not super().configure()), it correctly dispatches to whichever configure()
|
|
28
|
+
the actual widget instance defines -- unlike the removed methods, this one isn't dead code.
|
|
29
|
+
|
|
30
|
+
Each widget's own configure()/cget()/_set_appearance_mode() override is responsible for its own
|
|
31
|
+
runtime color-swapping, because each widget needs a different set of properties (and, for buttons,
|
|
32
|
+
a different state model -- normal/disabled/pressed/alarm) than any single shared method could know
|
|
33
|
+
about without hardcoding every widget's valid property list in one place. See the docs audit notes
|
|
34
|
+
for the reasoning behind not trying to re-centralize this.
|
|
35
|
+
|
|
36
|
+
RUN-ONCE GUARD: __init__ now sets self._themeable_widget_initialized = True on its first call per
|
|
37
|
+
instance, and returns immediately on any subsequent call. This matters specifically for widgets that
|
|
38
|
+
compose an already-themed sCTk widget as their own base class (e.g. sCTkSelector(sCTkFrame,
|
|
39
|
+
ThemeableWidget), sCTkTableview(sCTkScrollableFrame, ThemeableWidget)) -- without it, the base
|
|
40
|
+
widget's own internal ThemeableWidget.__init__ call (triggered when the composite widget's
|
|
41
|
+
super().__init__() cascades into it) would run a second time on the same instance, silently
|
|
42
|
+
overwriting self.final_kw. See __init__'s own docstring for the full reasoning.
|
|
43
|
+
"""
|
|
44
|
+
import os
|
|
45
|
+
import json
|
|
46
|
+
import tkinter as tk
|
|
47
|
+
import customtkinter as ctk
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# GLOBAL THEME REGISTRY POOL
|
|
51
|
+
GLOBAL_THEME_REGISTRY = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------------
|
|
55
|
+
# Deferred calls that survive their widget being destroyed
|
|
56
|
+
# ----------------------------------------------------------------------
|
|
57
|
+
def defer(widget, ms, callback):
|
|
58
|
+
"""
|
|
59
|
+
widget.after(ms, callback) -- or after_idle when ms is "idle" -- that is
|
|
60
|
+
safe if the widget is destroyed before it runs.
|
|
61
|
+
|
|
62
|
+
FIX (2026-11-11): closing a Designer preview printed
|
|
63
|
+
`invalid command name "...<lambda>"` for every widget with a call still
|
|
64
|
+
pending. tkinter registers a deferred callback as a Tcl command OF THE
|
|
65
|
+
WIDGET, and destroying the widget deletes that command -- so a check
|
|
66
|
+
inside the callback cannot help: there is no callback left to run. The
|
|
67
|
+
call is registered with the application's root instead, which outlives
|
|
68
|
+
the widget, and only reaches `callback` while the widget still exists.
|
|
69
|
+
|
|
70
|
+
Returns the id, for cancel_deferred(). Errors in `callback` are NOT
|
|
71
|
+
swallowed; only the widget's own disappearance is.
|
|
72
|
+
"""
|
|
73
|
+
root = widget._root()
|
|
74
|
+
|
|
75
|
+
def run():
|
|
76
|
+
try:
|
|
77
|
+
alive = widget.winfo_exists()
|
|
78
|
+
except tk.TclError:
|
|
79
|
+
return
|
|
80
|
+
if alive:
|
|
81
|
+
callback()
|
|
82
|
+
|
|
83
|
+
if ms == "idle":
|
|
84
|
+
return root.after_idle(run)
|
|
85
|
+
return root.after(ms, run)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cancel_deferred(widget, after_id):
|
|
89
|
+
"""Cancels a call made with defer(); harmless if it already ran or is None."""
|
|
90
|
+
if after_id is None:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
widget._root().after_cancel(after_id)
|
|
94
|
+
except (tk.TclError, ValueError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_initial_framework_themes():
|
|
99
|
+
"""Idempotent startup hook to pull themes.json safely without dropping imports."""
|
|
100
|
+
global GLOBAL_THEME_REGISTRY
|
|
101
|
+
if GLOBAL_THEME_REGISTRY:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
local_user_workspace = os.path.normpath(os.path.join(os.getcwd(), "sCTkThemes.json"))
|
|
105
|
+
|
|
106
|
+
# FIXED DIRECTORY COORDINATES: Look inside your actual bundled assets folder
|
|
107
|
+
current_script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
108
|
+
bundled_library_default = os.path.normpath(os.path.join(current_script_dir, "assets", "sCTkThemes.json"))
|
|
109
|
+
|
|
110
|
+
theme_target = local_user_workspace if os.path.exists(local_user_workspace) else bundled_library_default
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if os.path.exists(theme_target):
|
|
114
|
+
with open(theme_target, "r", encoding="utf-8") as file:
|
|
115
|
+
GLOBAL_THEME_REGISTRY = json.load(file)
|
|
116
|
+
else:
|
|
117
|
+
# Fallback empty profile dictionary so your widget protections don't crash if files vanish
|
|
118
|
+
GLOBAL_THEME_REGISTRY = {}
|
|
119
|
+
except Exception as err:
|
|
120
|
+
print(f"sCustomTkinter System Warning -> Could not parse theme layout tracking: {err}")
|
|
121
|
+
GLOBAL_THEME_REGISTRY = {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# NATIVE INITIALIZATION PASS: Safe, unblocked startup invocation
|
|
125
|
+
load_initial_framework_themes()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def default_i18n_translator(value):
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
def default_fo_callback(widget):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def default_image_loader(master, image_name: str, size=None):
|
|
135
|
+
if not image_name: return None
|
|
136
|
+
try:
|
|
137
|
+
if isinstance(image_name, ctk.CTkImage): return image_name
|
|
138
|
+
img_size = size if size else (24, 24)
|
|
139
|
+
from PIL import Image
|
|
140
|
+
pil_img = Image.open(image_name)
|
|
141
|
+
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=img_size)
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_list_property(value, default=None):
|
|
147
|
+
"""
|
|
148
|
+
Parse a list-valued widget property from any of the formats this library
|
|
149
|
+
and Pygubu Designer produce, returning a clean list of stripped strings.
|
|
150
|
+
|
|
151
|
+
WHY THIS EXISTS. Before this function, seven widgets each parsed their own
|
|
152
|
+
list property and no two agreed:
|
|
153
|
+
|
|
154
|
+
sCTkTableview.columns comma split, stripped
|
|
155
|
+
sCTkSelector.items ast.literal_eval only
|
|
156
|
+
sCTkDialSelector.labels literal_eval in __init__, but a plain
|
|
157
|
+
comma split with NO quote stripping in
|
|
158
|
+
configure() -- so the same value parsed
|
|
159
|
+
differently depending on when it was set
|
|
160
|
+
sCTkFileExplorer.filetypes three different implementations, in one file
|
|
161
|
+
sCTkPathChooser.filetypes literal_eval
|
|
162
|
+
sCTkSpinbox.values shlex.split when no comma is present
|
|
163
|
+
|
|
164
|
+
The user-visible consequence was that every widget wanted a different
|
|
165
|
+
format for the same kind of property, and the Designer's inspector gave no
|
|
166
|
+
hint which. Entering "AM, FM, LSB" into a dial produced labels with leading
|
|
167
|
+
spaces; the same string in a Tableview worked correctly.
|
|
168
|
+
|
|
169
|
+
Accepts, in order of preference:
|
|
170
|
+
- an actual list or tuple, returned with each element stripped
|
|
171
|
+
- a Python literal: "['A', 'B']" or "('A', 'B')"
|
|
172
|
+
- a bare comma-separated string: "A, B, C"
|
|
173
|
+
|
|
174
|
+
Empty input returns `default` (or an empty list), never None, so callers
|
|
175
|
+
can iterate the result unconditionally.
|
|
176
|
+
|
|
177
|
+
Deliberately does NOT accept space separation. sCTkSpinbox alone supported
|
|
178
|
+
it via shlex, which means "Meat Loaf" was two values there and one value
|
|
179
|
+
everywhere else -- surprising enough to be worth dropping rather than
|
|
180
|
+
spreading. Quote a value containing a comma if you need one.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
value: The raw property value, in any of the forms above.
|
|
184
|
+
default: Returned when value is empty or unparseable. Defaults to [].
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
A list of stripped strings.
|
|
188
|
+
"""
|
|
189
|
+
if default is None:
|
|
190
|
+
default = []
|
|
191
|
+
|
|
192
|
+
if value is None:
|
|
193
|
+
return list(default)
|
|
194
|
+
|
|
195
|
+
if isinstance(value, (list, tuple)):
|
|
196
|
+
cleaned = [str(item).strip() for item in value]
|
|
197
|
+
return [item for item in cleaned if item] or list(default)
|
|
198
|
+
|
|
199
|
+
text = str(value).strip()
|
|
200
|
+
if not text:
|
|
201
|
+
return list(default)
|
|
202
|
+
|
|
203
|
+
# Python literal form, as written by older .ui files and by the Selector's
|
|
204
|
+
# own inspector default of "['Item 1', 'Item 2']".
|
|
205
|
+
if text[0] in "[(":
|
|
206
|
+
try:
|
|
207
|
+
import ast
|
|
208
|
+
parsed = ast.literal_eval(text)
|
|
209
|
+
if isinstance(parsed, (list, tuple)):
|
|
210
|
+
cleaned = [str(item).strip() for item in parsed]
|
|
211
|
+
return [item for item in cleaned if item] or list(default)
|
|
212
|
+
except (ValueError, SyntaxError):
|
|
213
|
+
# Malformed literal -- fall through to the comma split below, which
|
|
214
|
+
# handles "[A, B]" (unquoted, not valid Python) correctly.
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Bare comma-separated form. Brackets and quotes are stripped so a
|
|
218
|
+
# half-formed literal still parses the way the user obviously intended.
|
|
219
|
+
text = text.strip("[]()")
|
|
220
|
+
return [item.strip().strip("\"'") for item in text.split(",")
|
|
221
|
+
if item.strip().strip("\"'")] or list(default)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_font_property(value, default=None):
|
|
225
|
+
"""
|
|
226
|
+
Parse a font from any of the forms a designer or a theme file produces,
|
|
227
|
+
returning the tuple CustomTkinter and Tk canvas items expect.
|
|
228
|
+
|
|
229
|
+
WHY THIS EXISTS. Pygubu's `fontentry` editor emits a Tk font
|
|
230
|
+
specification, not a tuple: a family, brace-wrapped when it contains a
|
|
231
|
+
space, then a size, then zero or more styles.
|
|
232
|
+
|
|
233
|
+
{Comic Sans MS} 14 bold
|
|
234
|
+
Arial 9 bold
|
|
235
|
+
|
|
236
|
+
The trap is what it emits for NO style selected:
|
|
237
|
+
|
|
238
|
+
{American Typewriter} 12 {}
|
|
239
|
+
|
|
240
|
+
"{}" is Tk's empty-list literal. Passed through as a style, Tk rejects it
|
|
241
|
+
outright when the tuple reaches a canvas item:
|
|
242
|
+
|
|
243
|
+
_tkinter.TclError: unknown font style ""
|
|
244
|
+
|
|
245
|
+
Two builder objects had their own copy of this parser and both got it
|
|
246
|
+
wrong the same way, which is why it lives here now -- the same argument
|
|
247
|
+
that consolidated seven list parsers into parse_list_property().
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
value: The raw font value: a tuple, a Tk font string, or None.
|
|
251
|
+
default: Returned when value is empty or unparseable. A caller with a
|
|
252
|
+
theme value to fall back on should pass it here.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A (family, size) or (family, size, style) tuple, or `default`.
|
|
256
|
+
"""
|
|
257
|
+
if not value:
|
|
258
|
+
return default
|
|
259
|
+
|
|
260
|
+
if isinstance(value, (tuple, list)):
|
|
261
|
+
if len(value) < 2:
|
|
262
|
+
return default
|
|
263
|
+
family, size = value[0], value[1]
|
|
264
|
+
styles = [str(s) for s in value[2:]
|
|
265
|
+
if s and str(s).strip() not in ("", "{}", "normal", "roman")]
|
|
266
|
+
else:
|
|
267
|
+
text = str(value).strip()
|
|
268
|
+
if not text:
|
|
269
|
+
return default
|
|
270
|
+
if text.startswith("{"):
|
|
271
|
+
end = text.find("}")
|
|
272
|
+
if end == -1:
|
|
273
|
+
return default
|
|
274
|
+
family, rest = text[1:end], text[end + 1:].split()
|
|
275
|
+
else:
|
|
276
|
+
parts = text.split()
|
|
277
|
+
family, rest = parts[0], parts[1:]
|
|
278
|
+
if not rest:
|
|
279
|
+
return default
|
|
280
|
+
size = rest[0]
|
|
281
|
+
styles = [s for s in rest[1:]
|
|
282
|
+
if s and s not in ("{}", "normal", "roman")]
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
size = int(size)
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
return default
|
|
288
|
+
|
|
289
|
+
joined = " ".join(styles).strip()
|
|
290
|
+
return (str(family), size, joined) if joined else (str(family), size)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ThemeableWidget:
|
|
294
|
+
def __init__(self, kwargs: dict):
|
|
295
|
+
"""
|
|
296
|
+
Shared base mixin that resolves global theme lookups via introspection,
|
|
297
|
+
extracts styling options, handles nested map sanitization, and filters
|
|
298
|
+
custom layout properties to prevent native framework validation failures.
|
|
299
|
+
|
|
300
|
+
RUN-ONCE GUARD: if a widget composes an already-themed sCTk widget as its
|
|
301
|
+
own base class (e.g. sCTkSelector(sCTkFrame, ThemeableWidget)), that base
|
|
302
|
+
widget's own __init__ calls ThemeableWidget.__init__ again internally when
|
|
303
|
+
super().__init__() cascades into it. Without a guard, this second call
|
|
304
|
+
would run in full -- looking up the theme block again via
|
|
305
|
+
self.__class__.__name__ (which is always the OUTERMOST class, e.g.
|
|
306
|
+
"sCTkSelector", regardless of which base class's code is currently
|
|
307
|
+
executing) and completely overwriting self.final_kw from scratch. For a
|
|
308
|
+
widget that forwards its full final_kw to super().__init__() (like
|
|
309
|
+
sCTkSelector), this second call happens to be a harmless no-op today,
|
|
310
|
+
since it's re-merging already-merged data onto itself -- but that's
|
|
311
|
+
incidental, not guaranteed, and fragile against future changes. For a
|
|
312
|
+
widget that only forwards a few explicit constructor arguments (like
|
|
313
|
+
sCTkTableview passing just master/width/height), the second call is
|
|
314
|
+
actively destructive: it replaces the correctly-built final_kw with a far
|
|
315
|
+
sparser one, silently discarding whatever the first call correctly
|
|
316
|
+
resolved. This guard makes ThemeableWidget.__init__ idempotent per
|
|
317
|
+
instance, the same way a C header's #ifndef/#define guard prevents a
|
|
318
|
+
header's contents from being processed twice -- the first call does the
|
|
319
|
+
real work and marks itself done; every subsequent call on the same
|
|
320
|
+
instance returns immediately.
|
|
321
|
+
"""
|
|
322
|
+
if getattr(self, "_themeable_widget_initialized", False):
|
|
323
|
+
return
|
|
324
|
+
self._themeable_widget_initialized = True
|
|
325
|
+
|
|
326
|
+
# 1. PYGUBU UTILITY PARAMETER INTERCEPTION & EXTRIPATION:
|
|
327
|
+
# We pop these completely out of the incoming kwargs pass before running any
|
|
328
|
+
# dictionary loops. This guarantees CustomTkinter never encounters them!
|
|
329
|
+
self.translator = kwargs.pop("translator", default_i18n_translator)
|
|
330
|
+
self.on_first_object_cb = kwargs.pop("on_first_object_cb", default_fo_callback)
|
|
331
|
+
self.image_loader = kwargs.pop("image_loader", default_image_loader)
|
|
332
|
+
self.data_pool = kwargs.pop("data_pool", None)
|
|
333
|
+
|
|
334
|
+
# _THEME_BLOCK_NAME lets a subclass declare which theme block it reads,
|
|
335
|
+
# instead of the lookup always using its own class name.
|
|
336
|
+
#
|
|
337
|
+
# Needed by the Pygubu Designer preview subclasses: sCTkTableviewForPreview,
|
|
338
|
+
# sCTkSelectorForPreview and friends exist only to expose internals so the
|
|
339
|
+
# Designer can select them, but their class names have no theme block --
|
|
340
|
+
# so the lookup found nothing, final_kw came back empty, and any widget
|
|
341
|
+
# with fail-loud key validation raised KeyError instead of rendering.
|
|
342
|
+
#
|
|
343
|
+
# Also useful outside the Designer: a subclass of sCTkButtonPrimary can
|
|
344
|
+
# now inherit its parent's theme rather than silently losing it.
|
|
345
|
+
class_name = getattr(self, "_THEME_BLOCK_NAME", None) or self.__class__.__name__
|
|
346
|
+
theme_defaults = GLOBAL_THEME_REGISTRY.get(class_name) or {}
|
|
347
|
+
|
|
348
|
+
if not isinstance(theme_defaults, dict):
|
|
349
|
+
raise KeyError(f"CRITICAL STYLING EXCEPTION: Registry corrupted for type: '{class_name}'...")
|
|
350
|
+
|
|
351
|
+
# Global unresolved null traffic check
|
|
352
|
+
for style_key, style_value in theme_defaults.items():
|
|
353
|
+
if isinstance(style_value, dict):
|
|
354
|
+
for sub_key, sub_value in style_value.items():
|
|
355
|
+
if sub_value is None:
|
|
356
|
+
raise ValueError(f"Null theme parameter encountered at ['{class_name}']['{style_key}']['{sub_key}']")
|
|
357
|
+
elif style_value is None:
|
|
358
|
+
raise ValueError(f"Null theme parameter encountered at ['{class_name}']['{style_key}']")
|
|
359
|
+
|
|
360
|
+
# Nested color map extractions
|
|
361
|
+
self._widget_disabled_map = ThemeableWidget._convert_lists_to_tuples(theme_defaults.get("disabled_map") or {})
|
|
362
|
+
self._widget_pressed_map = ThemeableWidget._convert_lists_to_tuples(theme_defaults.get("pressed_map") or {})
|
|
363
|
+
self._widget_alarm_map = ThemeableWidget._convert_lists_to_tuples(theme_defaults.get("alarm_map") or {})
|
|
364
|
+
# readonly_map: added to support a genuine three-state model (normal/
|
|
365
|
+
# readonly/disabled) on sCTkEntryPrimary/Secondary, for widgets like
|
|
366
|
+
# sCTkSpinbox that need ttk.Spinbox's real readonly semantics (arrows
|
|
367
|
+
# stay clickable, typing is blocked) rather than collapsing everything
|
|
368
|
+
# non-disabled into "normal". Added here, not as an Entry-specific
|
|
369
|
+
# hack, so any future widget can use the same pattern.
|
|
370
|
+
self._widget_readonly_map = ThemeableWidget._convert_lists_to_tuples(theme_defaults.get("readonly_map") or {})
|
|
371
|
+
|
|
372
|
+
forbidden_keys = {"disabled_map", "pressed_map", "alarm_map", "readonly_map"}
|
|
373
|
+
CUSTOM_VECTOR_KEYS = {
|
|
374
|
+
"dial_color", "shadow_color", "text_color", "pointer_color",
|
|
375
|
+
"pointer_glow_color", "disabled_text_color", "disabled_dial_color",
|
|
376
|
+
"disabled_dimple_glow", "diameter"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
self.final_kw = {}
|
|
380
|
+
is_vector_widget = any(v_key in theme_defaults for v_key in ["dial_color", "pointer_color", "diameter"])
|
|
381
|
+
|
|
382
|
+
active_vector_guards = set(CUSTOM_VECTOR_KEYS)
|
|
383
|
+
if not is_vector_widget:
|
|
384
|
+
active_vector_guards.discard("text_color")
|
|
385
|
+
|
|
386
|
+
for key, value in theme_defaults.items():
|
|
387
|
+
if key not in forbidden_keys and key not in active_vector_guards:
|
|
388
|
+
self.final_kw[key] = ThemeableWidget._sanitize_value(key, value)
|
|
389
|
+
|
|
390
|
+
for key, value in kwargs.items():
|
|
391
|
+
if value is not None and key not in forbidden_keys and key not in active_vector_guards:
|
|
392
|
+
self.final_kw[key] = ThemeableWidget._sanitize_value(key, value)
|
|
393
|
+
|
|
394
|
+
# 2. AUTOMATED STRING TRANSLATION PASSTHROUGH:
|
|
395
|
+
if "text" in self.final_kw:
|
|
396
|
+
self.final_kw["text"] = self.translator(self.final_kw["text"])
|
|
397
|
+
if "placeholder_text" in self.final_kw:
|
|
398
|
+
self.final_kw["placeholder_text"] = self.translator(self.final_kw["placeholder_text"])
|
|
399
|
+
if "label_text" in self.final_kw:
|
|
400
|
+
self.final_kw["label_text"] = self.translator(self.final_kw["label_text"])
|
|
401
|
+
|
|
402
|
+
# 3. MODERN HIGH-DPI ICON VECTOR UPGRADE PASS:
|
|
403
|
+
if "image" in self.final_kw and isinstance(self.final_kw["image"], str):
|
|
404
|
+
self.final_kw["image"] = self.image_loader(None, self.final_kw["image"])
|
|
405
|
+
|
|
406
|
+
# NOTE: self._state is set here for backward compatibility, but as of this audit
|
|
407
|
+
# no reviewed widget reads it correctly via a reachable code path (the one that
|
|
408
|
+
# did -- sCTkSegmentedButton -- was doing so by mistake; see its own fix notes).
|
|
409
|
+
# Left in place rather than removed until the remaining widgets are audited.
|
|
410
|
+
self._state = "normal"
|
|
411
|
+
|
|
412
|
+
def apply_theme(self):
|
|
413
|
+
"""
|
|
414
|
+
Re-applies self.final_kw via self.configure(). This calls self.configure(), not
|
|
415
|
+
super().configure(), so it correctly dispatches to whichever configure() the
|
|
416
|
+
actual widget instance defines -- this method is NOT dead code like the removed
|
|
417
|
+
configure()/cget() overrides were.
|
|
418
|
+
"""
|
|
419
|
+
if hasattr(self, "configure"):
|
|
420
|
+
self.configure(**self.final_kw)
|
|
421
|
+
|
|
422
|
+
def _finalize_themeable_lifecycle(self):
|
|
423
|
+
"""POST-COMPILATION LIFECYCLE HOOK PASS: Dispatches object triggers cleanly up to Pygubu."""
|
|
424
|
+
if hasattr(self, "on_first_object_cb") and self.on_first_object_cb:
|
|
425
|
+
try: self.on_first_object_cb(self)
|
|
426
|
+
except Exception: pass
|
|
427
|
+
|
|
428
|
+
@classmethod
|
|
429
|
+
def _convert_lists_to_tuples(cls, target_dict: dict) -> dict:
|
|
430
|
+
output = {}
|
|
431
|
+
for k, v in target_dict.items():
|
|
432
|
+
if isinstance(v, list) and len(v) == 2:
|
|
433
|
+
if "transparent" in v: output[k] = "transparent"
|
|
434
|
+
else: output[k] = tuple(v)
|
|
435
|
+
elif isinstance(v, str) and k in ("fg_color", "text_color", "border_color"):
|
|
436
|
+
if v == "transparent": output[k] = "transparent"
|
|
437
|
+
else: output[k] = (v, v)
|
|
438
|
+
else:
|
|
439
|
+
output[k] = v
|
|
440
|
+
return output
|
|
441
|
+
|
|
442
|
+
@classmethod
|
|
443
|
+
def _sanitize_value(cls, key, value):
|
|
444
|
+
if key in ("fg_color", "text_color", "border_color", "button_color", "button_hover_color"):
|
|
445
|
+
if isinstance(value, list) and len(value) == 2:
|
|
446
|
+
if "transparent" in value: return "transparent"
|
|
447
|
+
return tuple(value)
|
|
448
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
449
|
+
if "transparent" in value: return "transparent"
|
|
450
|
+
return value
|
|
451
|
+
if isinstance(value, str):
|
|
452
|
+
if value == "transparent": return "transparent"
|
|
453
|
+
return (value, value)
|
|
454
|
+
if isinstance(value, list):
|
|
455
|
+
return tuple(value)
|
|
456
|
+
return value
|
|
457
|
+
|
|
458
|
+
def _resolve_color(self, color_value):
|
|
459
|
+
if color_value == "transparent": return "transparent"
|
|
460
|
+
if isinstance(color_value, (tuple, list)):
|
|
461
|
+
current_mode = str(ctk.get_appearance_mode()).lower()
|
|
462
|
+
mode_idx = 1 if current_mode == "dark" else 0
|
|
463
|
+
return color_value[mode_idx]
|
|
464
|
+
return color_value
|
|
465
|
+
|
|
466
|
+
def _record_theme_overrides(self, kwargs):
|
|
467
|
+
"""
|
|
468
|
+
Records a runtime property change into the active theme map, so a later
|
|
469
|
+
repaint does not discard it.
|
|
470
|
+
|
|
471
|
+
THE PROBLEM THIS SOLVES is general to the library. Widgets repaint
|
|
472
|
+
themselves from _local_defaults -- a snapshot of the theme block taken
|
|
473
|
+
at construction -- whenever the state changes. Nothing wrote runtime
|
|
474
|
+
changes back into that snapshot, so:
|
|
475
|
+
|
|
476
|
+
widget.configure(fg_color="red") # widget turns red
|
|
477
|
+
widget.state("disabled")
|
|
478
|
+
widget.state("normal") # theme colour returns
|
|
479
|
+
|
|
480
|
+
In the Designer it was immediate rather than eventual, because
|
|
481
|
+
properties are applied in an order that sets `state` after the colours.
|
|
482
|
+
|
|
483
|
+
Only keys the theme ALREADY defines are updated. That is precisely the
|
|
484
|
+
set a repaint will reapply, so enumerating per-widget key lists is
|
|
485
|
+
unnecessary -- and it leaves native options the theme says nothing
|
|
486
|
+
about untouched.
|
|
487
|
+
|
|
488
|
+
THE CHANGE ALWAYS GOES TO THE NORMAL MAP, whatever state the widget is
|
|
489
|
+
in. That is not an oversight -- it follows from the Designer having ONE
|
|
490
|
+
field per property while this library keeps several maps.
|
|
491
|
+
|
|
492
|
+
An earlier version chose the map by current state, so that a colour set
|
|
493
|
+
on a disabled widget was visible immediately. It made a single field
|
|
494
|
+
mean different things at different moments:
|
|
495
|
+
|
|
496
|
+
normal: set fg_color green -> normal map green
|
|
497
|
+
disabled: clear fg_color -> DISABLED map reset, normal
|
|
498
|
+
map still green
|
|
499
|
+
normal: green returns
|
|
500
|
+
|
|
501
|
+
which reads as the clear having failed. One field, one meaning.
|
|
502
|
+
|
|
503
|
+
A property with its own separate field is the exception, and is handled
|
|
504
|
+
explicitly below -- text_color_disabled is the only one.
|
|
505
|
+
|
|
506
|
+
Call once near the top of a widget's configure(), before the values are
|
|
507
|
+
consumed or forwarded.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
kwargs: The keyword dict passed to configure(). Not modified.
|
|
511
|
+
"""
|
|
512
|
+
defaults = getattr(self, "_local_defaults", None)
|
|
513
|
+
if not defaults:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
# Snapshot the THEME before the first override lands on it.
|
|
517
|
+
#
|
|
518
|
+
# Recording overrides into _local_defaults makes a repaint keep them,
|
|
519
|
+
# which is the point -- but it also destroys the notion of a "default".
|
|
520
|
+
# The Designer reads the default from the same dict, so after setting
|
|
521
|
+
# fg_color yellow the reported default WAS yellow, and clearing the
|
|
522
|
+
# field restored the override instead of the theme.
|
|
523
|
+
#
|
|
524
|
+
# Taken lazily rather than in __init__ so widgets need no constructor
|
|
525
|
+
# change: the first override is the last moment the maps are still
|
|
526
|
+
# pristine.
|
|
527
|
+
self._snapshot_theme_defaults()
|
|
528
|
+
|
|
529
|
+
disabled_map = getattr(self, "_custom_disabled_map", None)
|
|
530
|
+
changed = False
|
|
531
|
+
|
|
532
|
+
for key, value in kwargs.items():
|
|
533
|
+
if value == "":
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# text_color_disabled and disabled_map's text_color are the SAME
|
|
537
|
+
# thing under two names.
|
|
538
|
+
#
|
|
539
|
+
# The first is CustomTkinter's native option; the second is this
|
|
540
|
+
# library's theme key, and a repaint feeds one into the other. So a
|
|
541
|
+
# value typed into the Designer's text_color_disabled field was not
|
|
542
|
+
# recorded here -- the theme block does not contain that name -- and
|
|
543
|
+
# the next repaint overwrote it from disabled_map. The field looked
|
|
544
|
+
# inert.
|
|
545
|
+
#
|
|
546
|
+
# Recording it against the theme key it feeds makes the native
|
|
547
|
+
# property live, and keeps a single source for the colour.
|
|
548
|
+
if key == "text_color_disabled":
|
|
549
|
+
if disabled_map is not None:
|
|
550
|
+
disabled_map["text_color"] = value
|
|
551
|
+
changed = True
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
if key in defaults:
|
|
555
|
+
defaults[key] = self._normalise_override(key, value)
|
|
556
|
+
changed = True
|
|
557
|
+
|
|
558
|
+
if changed:
|
|
559
|
+
self._repaint_after_override()
|
|
560
|
+
|
|
561
|
+
def _repaint_after_override(self):
|
|
562
|
+
"""
|
|
563
|
+
Re-runs the widget's own colour pass after a theme key changes.
|
|
564
|
+
|
|
565
|
+
DERIVED values are the reason. Several widgets compute one colour from
|
|
566
|
+
another -- sCTkOptionMenuSecondary sets button_color from fg_color so
|
|
567
|
+
the arrow blends into the control, and the segmented button derives its
|
|
568
|
+
unselected fill the same way. Those are worked out in the repaint
|
|
569
|
+
method, which runs on a state change and NOT on a plain configure().
|
|
570
|
+
|
|
571
|
+
So clearing fg_color in the Designer put the theme value back on the
|
|
572
|
+
native option and left the derived ones holding the override: the
|
|
573
|
+
background reverted while the arrow, or the segment fill, stayed red.
|
|
574
|
+
|
|
575
|
+
The repaint method is found by name because widgets do not agree on
|
|
576
|
+
one. Guarded against re-entry, since a repaint configures the widget
|
|
577
|
+
and would otherwise call back into here.
|
|
578
|
+
"""
|
|
579
|
+
if getattr(self, "_in_override_repaint", False):
|
|
580
|
+
return
|
|
581
|
+
# The list is in preference order, and covers every repaint entry
|
|
582
|
+
# point in the library. Canvas-drawing widgets are included because
|
|
583
|
+
# they have no colour-application method as such -- redrawing IS how
|
|
584
|
+
# they apply colours -- and without them an override was recorded but
|
|
585
|
+
# not shown until something else forced a redraw.
|
|
586
|
+
for name in ("_update_current_visual_state",
|
|
587
|
+
"_apply_custom_theme_colors",
|
|
588
|
+
"_apply_theme_colors",
|
|
589
|
+
"_draw_dial_base", # the dial family
|
|
590
|
+
"_draw_meter"): # sCTkSMeter, sCTkSMeterBar
|
|
591
|
+
method = getattr(self, name, None)
|
|
592
|
+
if method is None:
|
|
593
|
+
continue
|
|
594
|
+
self._in_override_repaint = True
|
|
595
|
+
try:
|
|
596
|
+
method()
|
|
597
|
+
except Exception:
|
|
598
|
+
# A widget not fully built yet. Losing one repaint is
|
|
599
|
+
# cosmetic; raising here would break the property edit.
|
|
600
|
+
pass
|
|
601
|
+
finally:
|
|
602
|
+
self._in_override_repaint = False
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
def _normalise_override(self, key, value):
|
|
606
|
+
"""
|
|
607
|
+
Puts a value from the Designer into the shape the theme maps hold.
|
|
608
|
+
|
|
609
|
+
Theme colours are stored as (light, dark) tuples, and widgets rely on
|
|
610
|
+
that: several call tuple() on them directly. The inspector supplies a
|
|
611
|
+
plain string, so storing it raw meant a widget did tuple("red") and
|
|
612
|
+
got ('r', 'e', 'd') -- which CustomTkinter rejected:
|
|
613
|
+
|
|
614
|
+
color ('r', 'e', 'd') must be string ('transparent' or
|
|
615
|
+
'color-name' or 'hex-color') or tuple of two strings
|
|
616
|
+
|
|
617
|
+
The shape is taken from the value ALREADY in the map rather than from
|
|
618
|
+
a list of key names. _convert_lists_to_tuples() special-cases only
|
|
619
|
+
fg_color, text_color and border_color, so a string given for
|
|
620
|
+
selected_color or hover_color would still have been stored raw and
|
|
621
|
+
exploded the same way -- and every widget names its colours
|
|
622
|
+
differently.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
key: The property name.
|
|
626
|
+
value: The raw value.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
The value in theme-map shape.
|
|
630
|
+
"""
|
|
631
|
+
if value == "transparent":
|
|
632
|
+
return "transparent"
|
|
633
|
+
|
|
634
|
+
if isinstance(value, (list, tuple)):
|
|
635
|
+
if len(value) == 2:
|
|
636
|
+
return "transparent" if "transparent" in value else tuple(value)
|
|
637
|
+
return value
|
|
638
|
+
|
|
639
|
+
if isinstance(value, str) and key not in self._LIST_PROPERTIES:
|
|
640
|
+
existing = (getattr(self, "_local_defaults", {}) or {}).get(key)
|
|
641
|
+
# A pair of STRINGS in the map means this key is a (light, dark)
|
|
642
|
+
# colour, so a single string has to become one.
|
|
643
|
+
#
|
|
644
|
+
# _LIST_PROPERTIES is excluded first: a two-item `values` list looks
|
|
645
|
+
# exactly like a colour pair, and would otherwise be stored as
|
|
646
|
+
# ('["A","B"]', '["A","B"]') -- the same collision that made
|
|
647
|
+
# _query_value() take the property name rather than guess.
|
|
648
|
+
if (isinstance(existing, (list, tuple)) and len(existing) == 2
|
|
649
|
+
and all(isinstance(x, str) for x in existing)):
|
|
650
|
+
return (value, value)
|
|
651
|
+
|
|
652
|
+
return value
|
|
653
|
+
|
|
654
|
+
def _snapshot_theme_defaults(self):
|
|
655
|
+
"""
|
|
656
|
+
Keeps an untouched copy of the theme maps, once, for reporting
|
|
657
|
+
defaults.
|
|
658
|
+
|
|
659
|
+
See _record_theme_overrides() for why this is needed and why it is
|
|
660
|
+
lazy.
|
|
661
|
+
"""
|
|
662
|
+
if getattr(self, "_pristine_defaults", None) is not None:
|
|
663
|
+
return
|
|
664
|
+
self._pristine_defaults = dict(getattr(self, "_local_defaults", {}) or {})
|
|
665
|
+
disabled_map = getattr(self, "_custom_disabled_map", None)
|
|
666
|
+
self._pristine_disabled = dict(disabled_map or {})
|
|
667
|
+
|
|
668
|
+
def _theme_default(self, key):
|
|
669
|
+
"""
|
|
670
|
+
The value the THEME gives a key, ignoring any runtime override.
|
|
671
|
+
|
|
672
|
+
Use this for the `default` slot of a configure() query, so clearing a
|
|
673
|
+
field in the Designer returns to the theme rather than to whatever was
|
|
674
|
+
last set.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
key: The property name.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
The theme's value, or None.
|
|
681
|
+
"""
|
|
682
|
+
pristine = getattr(self, "_pristine_defaults", None)
|
|
683
|
+
if pristine is None:
|
|
684
|
+
return (getattr(self, "_local_defaults", {}) or {}).get(key)
|
|
685
|
+
return pristine.get(key)
|
|
686
|
+
|
|
687
|
+
def _theme_disabled_default(self, key):
|
|
688
|
+
"""The theme's disabled_map value for a key, ignoring overrides."""
|
|
689
|
+
pristine = getattr(self, "_pristine_disabled", None)
|
|
690
|
+
if pristine is None:
|
|
691
|
+
return (getattr(self, "_custom_disabled_map", {}) or {}).get(key)
|
|
692
|
+
return pristine.get(key)
|
|
693
|
+
|
|
694
|
+
def _configure_query(self, pname, defaults=None):
|
|
695
|
+
"""
|
|
696
|
+
Tkinter-style property query for a name this widget doesn't handle
|
|
697
|
+
itself.
|
|
698
|
+
|
|
699
|
+
Pygubu calls configure(name) to read a property's default whenever a
|
|
700
|
+
field is blanked in the Designer inspector, and expects a five-element
|
|
701
|
+
tuple: (name, database_name, database_class, default, current).
|
|
702
|
+
|
|
703
|
+
Forwarding the name to a native configure() does NOT produce that.
|
|
704
|
+
CustomTkinter declares configure(self, require_redraw=False, **kwargs),
|
|
705
|
+
so the property NAME arrives as require_redraw and the call returns
|
|
706
|
+
None -- which pygubu then hands straight to _set_property():
|
|
707
|
+
|
|
708
|
+
Failed to set property 'height' ... float() argument must be a
|
|
709
|
+
string or a real number, not 'NoneType'
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
pname: The property being queried.
|
|
713
|
+
defaults: Optional {name: default} for properties whose native
|
|
714
|
+
default is known from THIS widget's own constructor. Anything
|
|
715
|
+
absent reports its current value as its default, which makes
|
|
716
|
+
blanking that field a no-op.
|
|
717
|
+
|
|
718
|
+
Only state a default you can point at. A guessed default is
|
|
719
|
+
worse than none, because the guess gets applied.
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
A Tkinter-style five-tuple.
|
|
723
|
+
"""
|
|
724
|
+
try:
|
|
725
|
+
current = self.cget(pname)
|
|
726
|
+
except Exception:
|
|
727
|
+
current = None
|
|
728
|
+
# Default resolution, in order:
|
|
729
|
+
# 1. an explicit default the caller declared
|
|
730
|
+
# 2. this widget's resolved THEME value
|
|
731
|
+
# 3. the current value
|
|
732
|
+
#
|
|
733
|
+
# Step 2 is what makes clearing a field in the Designer mean something.
|
|
734
|
+
# Without it the default was whatever the property already held, so
|
|
735
|
+
# blanking reported "the default is what you have" and nothing changed
|
|
736
|
+
# -- no error, but no revert either. For a themed widget the theme IS
|
|
737
|
+
# the default, and _local_defaults holds it already resolved.
|
|
738
|
+
#
|
|
739
|
+
# Step 3 remains for properties the theme says nothing about, where a
|
|
740
|
+
# no-op is still better than a guess.
|
|
741
|
+
if defaults and pname in defaults:
|
|
742
|
+
default = defaults[pname]
|
|
743
|
+
else:
|
|
744
|
+
# The PRISTINE theme value, not _local_defaults.
|
|
745
|
+
#
|
|
746
|
+
# _record_theme_overrides() writes runtime changes into
|
|
747
|
+
# _local_defaults so a repaint keeps them -- which means that dict
|
|
748
|
+
# no longer holds the theme's value. Reading the default from it
|
|
749
|
+
# made clearing a field in the Designer restore the last override
|
|
750
|
+
# instead of the theme. _theme_default() reads the untouched copy.
|
|
751
|
+
theme_value = self._theme_default(pname)
|
|
752
|
+
default = current if theme_value is None else theme_value
|
|
753
|
+
|
|
754
|
+
# A (light, dark) pair has to be resolved to ONE colour here.
|
|
755
|
+
#
|
|
756
|
+
# Everything in this library stores colours as pairs so CustomTkinter's
|
|
757
|
+
# appearance tracking can follow the mode. But pygubu takes whatever
|
|
758
|
+
# this returns and hands it back to configure(), where Tk sees the
|
|
759
|
+
# stringified tuple as a colour name:
|
|
760
|
+
#
|
|
761
|
+
# unknown color name "('#1A4375', '#2471A3')"
|
|
762
|
+
#
|
|
763
|
+
# _resolve_color() picks the half matching the current appearance mode,
|
|
764
|
+
# which is the value actually on screen -- and so the right answer to
|
|
765
|
+
# "what is this property now".
|
|
766
|
+
return (pname, pname, pname,
|
|
767
|
+
self._query_value(default, pname),
|
|
768
|
+
self._query_value(current, pname))
|
|
769
|
+
|
|
770
|
+
# Properties whose value is a LIST, not a colour pair. Both arrive as two
|
|
771
|
+
# strings when there are two options, and no test on the value can tell
|
|
772
|
+
# ["Apple", "Pear"] from ("white", "black") -- bare colour names look
|
|
773
|
+
# exactly like ordinary option text. The property NAME is the only
|
|
774
|
+
# reliable signal, so it is passed in rather than inferred.
|
|
775
|
+
_LIST_PROPERTIES = frozenset({"values", "labels", "columns", "items",
|
|
776
|
+
"filetypes"})
|
|
777
|
+
|
|
778
|
+
# Properties whose empty string is CONTENT, not a request to clear.
|
|
779
|
+
#
|
|
780
|
+
# Every configure() in this library drops `""`, and for theme values that
|
|
781
|
+
# is right: it is what the Designer sends to mean "fall back to the
|
|
782
|
+
# theme", and dropping it turns clearing an undefined property into a
|
|
783
|
+
# no-op rather than a `color is None` error (see _query_value).
|
|
784
|
+
#
|
|
785
|
+
# For these it is wrong. An empty string is an ordinary thing to ask for
|
|
786
|
+
# -- a label with nothing to say, an entry with no placeholder -- and
|
|
787
|
+
# dropping it left the OLD text in place. The call did nothing, silently,
|
|
788
|
+
# and the widget went on showing whatever it said last:
|
|
789
|
+
#
|
|
790
|
+
# label.configure(text="") # label still reads "vfo b 14.012.000"
|
|
791
|
+
#
|
|
792
|
+
# None of these is a theme key, so letting "" through cannot reach the
|
|
793
|
+
# theme maps: _record_theme_overrides only touches keys the theme block
|
|
794
|
+
# already defines.
|
|
795
|
+
#
|
|
796
|
+
# label_text is the caption of the labelled and scrollable frames --
|
|
797
|
+
# CTkScrollableFrame's own name for it.
|
|
798
|
+
_CONTENT_KEYS = frozenset({"text", "placeholder_text", "label_text"})
|
|
799
|
+
|
|
800
|
+
def _drop_cleared(self, kwargs):
|
|
801
|
+
"""
|
|
802
|
+
Removes theme values cleared to "" from kwargs, in place.
|
|
803
|
+
|
|
804
|
+
Content keys are kept, so `configure(text="")` really does blank the
|
|
805
|
+
text. Call this instead of filtering `if v == ""` by hand -- the
|
|
806
|
+
hand-written version was in every widget, and every copy dropped
|
|
807
|
+
content along with the theme values.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
kwargs: The keyword dict about to be forwarded to the native
|
|
811
|
+
widget. Modified in place.
|
|
812
|
+
"""
|
|
813
|
+
for key, value in list(kwargs.items()):
|
|
814
|
+
if value == "" and key not in self._CONTENT_KEYS:
|
|
815
|
+
kwargs.pop(key)
|
|
816
|
+
|
|
817
|
+
def _query_value(self, value, pname=None):
|
|
818
|
+
"""
|
|
819
|
+
Renders one property value in a form a configure() call can accept.
|
|
820
|
+
|
|
821
|
+
Colour pairs are resolved to the half in use; everything else is passed
|
|
822
|
+
through untouched. Deliberately NOT str() -- a number must stay a
|
|
823
|
+
number, or the widget receives "10" where it expects 10.
|
|
824
|
+
"""
|
|
825
|
+
# NOTHING becomes "", not None.
|
|
826
|
+
#
|
|
827
|
+
# A theme block need not define every property a widget's query
|
|
828
|
+
# branch lists -- sCTkButtonPrimary has no border_color at all,
|
|
829
|
+
# because it is a solid-fill button, and sCTkProgressBar the same.
|
|
830
|
+
# The default reported for such a key was None, and pygubu APPLIES
|
|
831
|
+
# the default when a field is cleared:
|
|
832
|
+
#
|
|
833
|
+
# Failed to set property 'border_color' ...
|
|
834
|
+
# Error: color is None, for transparency set color='transparent'
|
|
835
|
+
#
|
|
836
|
+
# An empty string is dropped by every configure() in this library --
|
|
837
|
+
# through _drop_cleared() -- so clearing an undefined property becomes
|
|
838
|
+
# the no-op it should always have been. Content keys such as `text`
|
|
839
|
+
# are the exception; see _CONTENT_KEYS.
|
|
840
|
+
if value is None:
|
|
841
|
+
return ""
|
|
842
|
+
|
|
843
|
+
# A list property is JSON, whatever its length. Checked FIRST, because
|
|
844
|
+
# a two-option list is indistinguishable from a colour pair by
|
|
845
|
+
# inspection alone.
|
|
846
|
+
if pname in self._LIST_PROPERTIES:
|
|
847
|
+
try:
|
|
848
|
+
import json
|
|
849
|
+
return json.dumps(list(value))
|
|
850
|
+
except (TypeError, ValueError):
|
|
851
|
+
return value
|
|
852
|
+
|
|
853
|
+
# A (light, dark) colour pair.
|
|
854
|
+
if (isinstance(value, (tuple, list)) and len(value) == 2
|
|
855
|
+
and all(isinstance(v, str) for v in value)):
|
|
856
|
+
try:
|
|
857
|
+
return self._resolve_color(value)
|
|
858
|
+
except Exception:
|
|
859
|
+
return value[0]
|
|
860
|
+
|
|
861
|
+
# A live CTkFont is not something a font property can be set FROM.
|
|
862
|
+
# cget("font") hands one back, and pygubu's font editor expects a Tk
|
|
863
|
+
# font specification it can parse:
|
|
864
|
+
#
|
|
865
|
+
# expected string or bytes-like object, got 'CTkFont'
|
|
866
|
+
#
|
|
867
|
+
# It is rendered as "{Family} size style" -- the same form the editor
|
|
868
|
+
# produces, so a value read out and written back round-trips.
|
|
869
|
+
# A font TUPLE -- ("Arial", 13) or ("Arial", 13, "bold") -- is how this
|
|
870
|
+
# library's theme file stores fonts, and it is what comes back once the
|
|
871
|
+
# theme is used as a property's default. pygubu parses font properties
|
|
872
|
+
# with tkfontstr_to_dict(), which wants a Tk font STRING:
|
|
873
|
+
#
|
|
874
|
+
# expected string or bytes-like object, got 'tuple'
|
|
875
|
+
#
|
|
876
|
+
# Distinguished from a colour pair above by its second element being a
|
|
877
|
+
# number rather than a string.
|
|
878
|
+
if (isinstance(value, (tuple, list)) and 2 <= len(value) <= 4
|
|
879
|
+
and isinstance(value[0], str)
|
|
880
|
+
and isinstance(value[1], (int, float))):
|
|
881
|
+
family = str(value[0])
|
|
882
|
+
parts = [f"{{{family}}}" if " " in family else family,
|
|
883
|
+
str(int(value[1]))]
|
|
884
|
+
parts.extend(str(extra) for extra in value[2:]
|
|
885
|
+
if extra and str(extra) not in ("normal", "roman"))
|
|
886
|
+
return " ".join(parts)
|
|
887
|
+
|
|
888
|
+
# A list reaching here is one whose property name was not supplied.
|
|
889
|
+
# Rendered as JSON anyway, since that is what every list property in
|
|
890
|
+
# this library expects -- pygubu parses them with a strict
|
|
891
|
+
# json.loads(), which raises TypeError on a Python list rather than
|
|
892
|
+
# rejecting it cleanly.
|
|
893
|
+
if isinstance(value, list):
|
|
894
|
+
try:
|
|
895
|
+
import json
|
|
896
|
+
return json.dumps(value)
|
|
897
|
+
except (TypeError, ValueError):
|
|
898
|
+
return value
|
|
899
|
+
|
|
900
|
+
# A Tcl_Obj is what cget() hands back for a variable property --
|
|
901
|
+
# textvariable, variable, listvariable. pygubu wants the variable's
|
|
902
|
+
# NAME, and str() on a Tcl_Obj gives exactly that ("PY_VAR0"). Passed
|
|
903
|
+
# through untouched it raised:
|
|
904
|
+
#
|
|
905
|
+
# argument of type '_tkinter.Tcl_Obj' is not a container or iterable
|
|
906
|
+
if type(value).__name__ == "Tcl_Obj":
|
|
907
|
+
return str(value)
|
|
908
|
+
|
|
909
|
+
# A Tk variable object, for the same reason: its name is what a
|
|
910
|
+
# configure() call accepts, not the object.
|
|
911
|
+
if value is not None and hasattr(value, "_name") \
|
|
912
|
+
and hasattr(value, "get") and hasattr(value, "set"):
|
|
913
|
+
return str(value)
|
|
914
|
+
|
|
915
|
+
if value is not None and type(value).__name__ == "CTkFont":
|
|
916
|
+
try:
|
|
917
|
+
family = value.cget("family")
|
|
918
|
+
size = value.cget("size")
|
|
919
|
+
parts = [f"{{{family}}}" if " " in str(family) else str(family),
|
|
920
|
+
str(size)]
|
|
921
|
+
for style in ("weight", "slant"):
|
|
922
|
+
try:
|
|
923
|
+
setting = value.cget(style)
|
|
924
|
+
except Exception:
|
|
925
|
+
continue
|
|
926
|
+
if setting and setting not in ("normal", "roman"):
|
|
927
|
+
parts.append(str(setting))
|
|
928
|
+
for style in ("underline", "overstrike"):
|
|
929
|
+
try:
|
|
930
|
+
if value.cget(style):
|
|
931
|
+
parts.append(style)
|
|
932
|
+
except Exception:
|
|
933
|
+
continue
|
|
934
|
+
return " ".join(parts)
|
|
935
|
+
except Exception:
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
return value
|