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.
Files changed (101) hide show
  1. scustomtkinter/__init__.py +81 -0
  2. scustomtkinter/assets/images/error.png +0 -0
  3. scustomtkinter/assets/images/info.png +0 -0
  4. scustomtkinter/assets/images/warning.png +0 -0
  5. scustomtkinter/assets/sCTkThemes.json +1963 -0
  6. scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  7. scustomtkinter/sctk_button_primary.py +366 -0
  8. scustomtkinter/sctk_button_secondary.py +354 -0
  9. scustomtkinter/sctk_button_tertiary.py +375 -0
  10. scustomtkinter/sctk_checkbox.py +270 -0
  11. scustomtkinter/sctk_combobox.py +155 -0
  12. scustomtkinter/sctk_core.py +15 -0
  13. scustomtkinter/sctk_dial.py +1472 -0
  14. scustomtkinter/sctk_dialog.py +701 -0
  15. scustomtkinter/sctk_dialog_toplevel.py +268 -0
  16. scustomtkinter/sctk_entry_primary.py +400 -0
  17. scustomtkinter/sctk_entry_secondary.py +342 -0
  18. scustomtkinter/sctk_file_explorer.py +879 -0
  19. scustomtkinter/sctk_frame.py +210 -0
  20. scustomtkinter/sctk_frame_labeled_primary.py +667 -0
  21. scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
  22. scustomtkinter/sctk_frequency_display.py +636 -0
  23. scustomtkinter/sctk_label_primary.py +283 -0
  24. scustomtkinter/sctk_label_secondary.py +275 -0
  25. scustomtkinter/sctk_label_tertiary.py +278 -0
  26. scustomtkinter/sctk_messagebox.py +275 -0
  27. scustomtkinter/sctk_notebook.py +1103 -0
  28. scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
  29. scustomtkinter/sctk_optionmenu_primary.py +378 -0
  30. scustomtkinter/sctk_optionmenu_secondary.py +346 -0
  31. scustomtkinter/sctk_path_chooser.py +516 -0
  32. scustomtkinter/sctk_progress_bar.py +274 -0
  33. scustomtkinter/sctk_radiobutton.py +360 -0
  34. scustomtkinter/sctk_scroll_mixin.py +546 -0
  35. scustomtkinter/sctk_scrollable_frame.py +795 -0
  36. scustomtkinter/sctk_scrollbar.py +313 -0
  37. scustomtkinter/sctk_segmentedbutton.py +424 -0
  38. scustomtkinter/sctk_selector.py +581 -0
  39. scustomtkinter/sctk_separator.py +308 -0
  40. scustomtkinter/sctk_slider.py +264 -0
  41. scustomtkinter/sctk_smeter.py +405 -0
  42. scustomtkinter/sctk_smeter_bar.py +557 -0
  43. scustomtkinter/sctk_spinbox.py +664 -0
  44. scustomtkinter/sctk_switch.py +463 -0
  45. scustomtkinter/sctk_tableview.py +869 -0
  46. scustomtkinter/sctk_tabview.py +409 -0
  47. scustomtkinter/sctk_textbox_primary.py +291 -0
  48. scustomtkinter/sctk_textbox_secondary.py +225 -0
  49. scustomtkinter/sctk_toplevel.py +153 -0
  50. scustomtkinter/themeable_widget.py +938 -0
  51. scustomtkinter-0.2.1.dist-info/METADATA +336 -0
  52. scustomtkinter-0.2.1.dist-info/RECORD +101 -0
  53. scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
  54. scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
  55. scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
  56. scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
  57. scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
  58. scustomtkinter_pygubu/__init__.py +1 -0
  59. scustomtkinter_pygubu/designer/__init__.py +0 -0
  60. scustomtkinter_pygubu/designer/plugin.py +1854 -0
  61. scustomtkinter_pygubu/designer/properties.py +247 -0
  62. scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
  63. scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
  64. scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
  65. scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
  66. scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
  67. scustomtkinter_pygubu/sCTkCorebo.py +120 -0
  68. scustomtkinter_pygubu/sCTkDialbo.py +371 -0
  69. scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
  70. scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
  71. scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
  72. scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
  73. scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
  74. scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
  75. scustomtkinter_pygubu/sCTkFramebo.py +46 -0
  76. scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
  77. scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
  78. scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
  79. scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
  80. scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
  81. scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
  82. scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
  83. scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
  84. scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
  85. scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
  86. scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
  87. scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
  88. scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
  89. scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
  90. scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
  91. scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
  92. scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
  93. scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
  94. scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
  95. scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
  96. scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
  97. scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
  98. scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
  99. scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
  100. scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
  101. 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