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,291 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkTextboxPrimary
4
+
5
+ A theme-compliant, high-emphasis multi-line text area widget -- the more
6
+ prominent of the library's two textbox tiers (see also sCTkTextboxSecondary).
7
+ Inherits directly from ctk.CTkTextbox so CustomTkinter handles native
8
+ rendering and text editing; this class layers automatic light/dark theme
9
+ resolution and a distinct enabled/disabled visual state on top.
10
+
11
+ Base class order matters here: `class sCTkTextboxPrimary(ctk.CTkTextbox,
12
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
13
+ this file's own methods resolves to ctk.CTkTextbox -- and, beneath it,
14
+ tkinter.Misc -- never to ThemeableWidget. ThemeableWidget's own
15
+ configure()/cget()/_set_appearance_mode() overrides have been removed entirely
16
+ for this reason (see themeable_widget.py's docstring); this widget owns all of
17
+ its own runtime color-swapping logic.
18
+
19
+ Disabling uses CTk's native state="disabled", consistent with every other
20
+ widget in this library confirmed to correctly block interaction this way.
21
+ """
22
+ import os
23
+ import sys
24
+ import platform
25
+ from typing import Any, Optional
26
+ import tkinter as tk
27
+ import customtkinter as ctk
28
+ from .themeable_widget import ThemeableWidget
29
+
30
+
31
+ class sCTkTextboxPrimary(ctk.CTkTextbox, ThemeableWidget):
32
+ """Themeable, high-emphasis multi-line text area.
33
+
34
+ Adds to native ctk.CTkTextbox:
35
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
36
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
37
+ and just as importantly, what it no longer does).
38
+ - A distinct enabled/disabled visual state, using CTk's native
39
+ state="disabled".
40
+ - Pygubu Designer property introspection for `state`, `fg_color`,
41
+ `text_color`, `border_color`, `scrollbar_button_color`, and
42
+ `scrollbar_button_hover_color` via a single-argument configure() call.
43
+ - A transparency fallback: if fg_color resolves to "transparent" or
44
+ empty at construction, the widget copies its parent's fg_color
45
+ instead. Presumably a workaround for CTkTextbox not rendering true
46
+ transparency the way canvas-based widgets can; preserved as-is, not
47
+ independently re-verified as part of this project's audit.
48
+ - Manual scrollbar re-theming: CTkTextbox's internal scrollbar isn't
49
+ automatically covered by this widget's own theme keys, so
50
+ scrollbar_button_color/scrollbar_button_hover_color are pushed to it
51
+ directly whenever colors are recomputed.
52
+
53
+ Colors are passed through to configure() as raw (light, dark) tuples rather
54
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
55
+ tracking repaints them automatically on a light/dark switch -- the same
56
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
57
+ family. Not separately re-confirmed for this specific widget.
58
+
59
+ scrollbar_button_color and scrollbar_button_hover_color are required to be
60
+ present in whichever map is active for the scrollbar re-theming step below
61
+ -- if either is missing, this raises immediately rather than substituting
62
+ a hardcoded color. An earlier version used hardcoded hex fallbacks here,
63
+ several of which didn't even match the real theme file's actual values;
64
+ per this project's design, ThemeableWidget-based widgets are meant to fail
65
+ hard on incomplete theme data (see sCTkLabelPrimary/Secondary/Tertiary for
66
+ the precedent this follows).
67
+ """
68
+
69
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
70
+ """
71
+ Args:
72
+ master: Parent container.
73
+ **kw: `state` is pulled out explicitly below (applied after
74
+ construction, via self.state(), rather than passed to the
75
+ native constructor). Everything else is either a native
76
+ CTkTextbox argument or a theme-key override (see the
77
+ "sCTkTextboxPrimary" block in sCTkThemes.json).
78
+ """
79
+ # 1. Fire our shared theme logic. This resolves final_kw
80
+ # (construction-time properties) and the disabled color map. See
81
+ # ThemeableWidget.__init__ for what actually happens here.
82
+ ThemeableWidget.__init__(self, kw)
83
+
84
+ # 2. Store the resolved maps onto this instance. Note: unlike most
85
+ # other widgets in this project, these are NOT deep-copied (dict(...))
86
+ # here -- they reference the same dict objects final_kw/
87
+ # _widget_disabled_map point to. Preserved as-is from the original;
88
+ # this only matters if something elsewhere mutates those dicts after
89
+ # construction, which doesn't currently happen anywhere in this file.
90
+ self._local_defaults = self.final_kw
91
+ self._custom_disabled_map = self._widget_disabled_map
92
+
93
+ # Extract "state" from final_kw after ThemeableWidget's merge -- an
94
+ # explicit state= kwarg correctly overrides any "state" the theme
95
+ # JSON might define at the top level.
96
+ state_init = self.final_kw.pop("state", "normal")
97
+
98
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
99
+ super().__init__(master, **self.final_kw)
100
+
101
+ # Transparency fallback -- see this class's docstring.
102
+ try:
103
+ current_fg = super().cget("fg_color")
104
+ if current_fg in ("transparent", ""):
105
+ parent_bg = self.master.cget("fg_color")
106
+ if parent_bg not in ("transparent", ""):
107
+ super().configure(fg_color=parent_bg)
108
+ except Exception:
109
+ pass
110
+
111
+ # 4. Apply the requested initial state now that the native widget exists.
112
+ self._custom_current_state = "normal"
113
+ self.state(state_init)
114
+
115
+ # 5. Register lifecycle handshake hook, notifying Pygubu-style consumers
116
+ # that construction is complete.
117
+ self._finalize_themeable_lifecycle()
118
+
119
+ def _set_appearance_mode(self, mode_string: str) -> None:
120
+ """
121
+ Forwards CustomTkinter's internal light/dark mode change notification to
122
+ the native widget.
123
+
124
+ No longer manually re-triggers _update_current_visual_state(). That
125
+ method now passes raw (light, dark) tuples straight through to
126
+ configure() instead of pre-resolving to a single color, so CTk's own
127
+ appearance-mode tracking should repaint correctly on its own -- the
128
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
129
+ button family.
130
+
131
+ Args:
132
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
133
+ by CustomTkinter's internal appearance-mode change machinery.
134
+ """
135
+ if hasattr(super(), "_set_appearance_mode"):
136
+ try:
137
+ super()._set_appearance_mode(mode_string)
138
+ except Exception:
139
+ pass
140
+
141
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
142
+ """
143
+ Standard widget configuration, with Pygubu/positional-argument handling.
144
+
145
+ Args:
146
+ *args: At most one positional argument is meaningful:
147
+ - a dict: merged into kwargs and processed normally below.
148
+ - the literal string "state": returns a Tkinter-style
149
+ (name, name, name, default, current) tuple.
150
+ - one of "fg_color"/"text_color"/"border_color"/
151
+ "scrollbar_button_color"/"scrollbar_button_hover_color":
152
+ returns the same style of tuple. Note the returned value is
153
+ str(value), where value may itself be a (light, dark) tuple
154
+ rather than a single resolved color -- a known limitation
155
+ shared with the wider Pygubu-query investigation set aside
156
+ elsewhere in this project, not fixed here.
157
+ - anything else: forwarded directly to the native widget's
158
+ configure(), which does not support single-argument property
159
+ queries for arbitrary properties (same limitation).
160
+ **kwargs: Standard CTkTextbox configuration options, plus: passing
161
+ `state=...` updates self._custom_current_state and triggers a
162
+ repaint, rather than being forwarded as-is.
163
+
164
+ Returns:
165
+ The query tuple described above for the single-argument case, or
166
+ whatever super().configure() returns for the keyword-argument case
167
+ (typically None).
168
+ """
169
+ # args is always a tuple -- args[0] is the actual value passed, whether
170
+ # that's a string or a dict. An earlier version of this method compared
171
+ # the wrapped tuple directly (`pname = args`) and forwarded it unwrapped
172
+ # to super().configure(), which is not a valid call shape for the
173
+ # native widget. Don't reintroduce that.
174
+ if len(args) == 1:
175
+ if isinstance(args[0], dict):
176
+ kwargs = {**args[0], **kwargs}
177
+ else:
178
+ pname = args[0]
179
+ if pname == "state":
180
+ return ("state", "state", "state", "normal", str(self.state()))
181
+
182
+ if pname in ["fg_color", "text_color", "border_color", "scrollbar_button_color",
183
+ "scrollbar_button_hover_color"]:
184
+ val = self._custom_disabled_map.get(pname) if self._custom_current_state == "disabled" else self._local_defaults.get(pname)
185
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
186
+
187
+ return self._configure_query(pname)
188
+
189
+ # Runtime overrides have to reach the map a repaint reads, or the
190
+ # repaint puts the theme value straight back -- see
191
+ # ThemeableWidget._record_theme_overrides().
192
+ self._record_theme_overrides(kwargs)
193
+
194
+ if "state" in kwargs:
195
+ self._custom_current_state = str(kwargs.pop("state")).lower()
196
+ self._update_current_visual_state()
197
+
198
+ if kwargs:
199
+ return super().configure(**kwargs)
200
+ return None
201
+
202
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
203
+ # attribute -- it does not automatically track whichever configure() a
204
+ # subclass defines. Without this line, calling .config(...) on an instance
205
+ # would silently skip this entire override and land on the native widget's
206
+ # configure() directly, bypassing theming and state handling entirely.
207
+ config = configure
208
+
209
+ def get_state(self) -> str:
210
+ """Equivalent to calling state() with no argument."""
211
+ return self.state()
212
+
213
+ def state(self, state_string: Optional[str] = None) -> str:
214
+ """
215
+ Gets or sets the widget's enabled/disabled visual state.
216
+
217
+ Args:
218
+ state_string: If given, forwarded to configure(state=state_string).
219
+ If None, no change is made.
220
+
221
+ Returns:
222
+ The current state string (whatever was last set, or "normal" by
223
+ default).
224
+ """
225
+ if state_string is not None:
226
+ self.configure(state=state_string)
227
+ return getattr(self, "_custom_current_state", "normal")
228
+
229
+ def _update_current_visual_state(self) -> None:
230
+ """
231
+ Recomputes and applies this widget's colors from the theme file, based
232
+ on the current state, then sets the native interactive lock, then
233
+ re-themes the internal scrollbar.
234
+
235
+ Called on every state()/configure(state=...) change.
236
+
237
+ Passes raw (light, dark) tuples straight through to configure() instead
238
+ of resolving to a single color first, so CTk's native tracking can
239
+ handle appearance-mode repaints without help from _set_appearance_mode.
240
+
241
+ scrollbar_button_color and scrollbar_button_hover_color are required
242
+ to be present in whichever map is active -- see this class's
243
+ docstring.
244
+ """
245
+ is_disabled = getattr(self, "_custom_current_state", "normal") == "disabled"
246
+ target_map = self._custom_disabled_map if is_disabled else self._local_defaults
247
+
248
+ config_payload = {}
249
+ for key in ("fg_color", "border_color", "text_color", "scrollbar_button_color", "scrollbar_button_hover_color"):
250
+ val = target_map.get(key)
251
+ if val is not None:
252
+ config_payload[key] = val
253
+
254
+ if config_payload:
255
+ super().configure(**config_payload)
256
+
257
+ if is_disabled:
258
+ super().configure(state="disabled")
259
+ else:
260
+ super().configure(state="normal")
261
+
262
+ # Re-theme the internal scrollbar, which isn't automatically covered
263
+ # by the configure() call above.
264
+ if hasattr(self, "_scrollbar") and self._scrollbar:
265
+ try:
266
+ if target_map.get("scrollbar_button_color") is None:
267
+ raise KeyError(
268
+ f"'{self.__class__.__name__}' theme block is missing "
269
+ f"'scrollbar_button_color' in its "
270
+ f"{'disabled_map' if is_disabled else 'top-level'} section."
271
+ )
272
+ if is_disabled:
273
+ bar_color = target_map.get("scrollbar_button_color")
274
+ self._scrollbar.configure(button_color=bar_color, button_hover_color=bar_color)
275
+ else:
276
+ if target_map.get("scrollbar_button_hover_color") is None:
277
+ raise KeyError(
278
+ f"'{self.__class__.__name__}' theme block is missing "
279
+ f"'scrollbar_button_hover_color' at the top level."
280
+ )
281
+ self._scrollbar.configure(
282
+ button_color=target_map.get("scrollbar_button_color"),
283
+ button_hover_color=target_map.get("scrollbar_button_hover_color"),
284
+ )
285
+
286
+ if hasattr(self._scrollbar, "_draw"):
287
+ self._scrollbar._draw()
288
+ except KeyError:
289
+ raise
290
+ except Exception:
291
+ pass
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkTextboxSecondary
4
+
5
+ A theme-compliant, lower-emphasis multi-line text area widget (see also
6
+ sCTkTextboxPrimary). Inherits directly from ctk.CTkTextbox so CustomTkinter
7
+ handles native rendering and text editing; this class layers automatic
8
+ light/dark theme resolution and a distinct enabled/disabled visual state on
9
+ top.
10
+
11
+ Base class order matters here: `class sCTkTextboxSecondary(ctk.CTkTextbox,
12
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
13
+ this file's own methods resolves to ctk.CTkTextbox -- and, beneath it,
14
+ tkinter.Misc -- never to ThemeableWidget. ThemeableWidget's own
15
+ configure()/cget()/_set_appearance_mode() overrides have been removed entirely
16
+ for this reason (see themeable_widget.py's docstring); this widget owns all of
17
+ its own runtime color-swapping logic.
18
+
19
+ Disabling uses CTk's native state="disabled", consistent with every other
20
+ widget in this library confirmed to correctly block interaction this way.
21
+ """
22
+ import os
23
+ import sys
24
+ import platform
25
+ from typing import Any, Optional
26
+ import tkinter as tk
27
+ import customtkinter as ctk
28
+ from .themeable_widget import ThemeableWidget
29
+
30
+
31
+ class sCTkTextboxSecondary(ctk.CTkTextbox, ThemeableWidget):
32
+ """Themeable, lower-emphasis multi-line text area.
33
+
34
+ Adds to native ctk.CTkTextbox:
35
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
36
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
37
+ and just as importantly, what it no longer does).
38
+ - A distinct enabled/disabled visual state, using CTk's native
39
+ state="disabled".
40
+ - Pygubu Designer property introspection for `state`, `fg_color`,
41
+ `text_color`, `border_color`, `scrollbar_button_color`, and
42
+ `scrollbar_button_hover_color` via a single-argument configure() call.
43
+ - A transparency fallback: if fg_color resolves to "transparent" or
44
+ empty at construction, the widget copies its parent's fg_color
45
+ instead. Same rationale as sCTkTextboxPrimary's identical fallback.
46
+ - Manual scrollbar re-theming, same as sCTkTextboxPrimary.
47
+
48
+ Colors are passed through to configure() as raw (light, dark) tuples rather
49
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
50
+ tracking repaints them automatically on a light/dark switch -- the same
51
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
52
+ family. Not separately re-confirmed for this specific widget.
53
+
54
+ scrollbar_button_color and scrollbar_button_hover_color are required to be
55
+ present in whichever map is active for the scrollbar re-theming step below
56
+ -- if either is missing, this raises immediately rather than substituting
57
+ a hardcoded color. See sCTkTextboxPrimary's docstring for the full
58
+ rationale.
59
+ """
60
+
61
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
62
+ """
63
+ Args:
64
+ master: Parent container.
65
+ **kw: `state` is pulled out explicitly below. Everything else is
66
+ either a native CTkTextbox argument or a theme-key override
67
+ (see the "sCTkTextboxSecondary" block in sCTkThemes.json).
68
+ """
69
+ # 1. Fire our shared theme logic. This resolves final_kw
70
+ # (construction-time properties) and the disabled color map.
71
+ ThemeableWidget.__init__(self, kw)
72
+
73
+ # 2. Store the resolved maps onto this instance (not deep-copied --
74
+ # see sCTkTextboxPrimary's identical note).
75
+ self._local_defaults = self.final_kw
76
+ self._custom_disabled_map = self._widget_disabled_map
77
+
78
+ # Extract "state" from final_kw after ThemeableWidget's merge.
79
+ state_init = self.final_kw.pop("state", "normal")
80
+
81
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
82
+ super().__init__(master, **self.final_kw)
83
+
84
+ # Transparency fallback -- see this class's docstring.
85
+ try:
86
+ current_fg = super().cget("fg_color")
87
+ if current_fg in ("transparent", ""):
88
+ parent_bg = self.master.cget("fg_color")
89
+ if parent_bg not in ("transparent", ""):
90
+ super().configure(fg_color=parent_bg)
91
+ except Exception:
92
+ pass
93
+
94
+ # 4. Apply the requested initial state now that the native widget exists.
95
+ self._custom_current_state = "normal"
96
+ self.state(state_init)
97
+
98
+ # 5. Register lifecycle handshake hook, notifying Pygubu-style consumers
99
+ # that construction is complete.
100
+ self._finalize_themeable_lifecycle()
101
+
102
+ def _set_appearance_mode(self, mode_string: str) -> None:
103
+ """
104
+ Forwards CustomTkinter's internal light/dark mode change notification to
105
+ the native widget.
106
+
107
+ No longer manually re-triggers _update_current_visual_state() -- see
108
+ sCTkTextboxPrimary's identical method for the full reasoning.
109
+
110
+ Args:
111
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
112
+ by CustomTkinter's internal appearance-mode change machinery.
113
+ """
114
+ if hasattr(super(), "_set_appearance_mode"):
115
+ try:
116
+ super()._set_appearance_mode(mode_string)
117
+ except Exception:
118
+ pass
119
+
120
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
121
+ """
122
+ Standard widget configuration, with Pygubu/positional-argument handling.
123
+ See sCTkTextboxPrimary's identical method for full Args/Returns detail.
124
+ """
125
+ if len(args) == 1:
126
+ if isinstance(args[0], dict):
127
+ kwargs = {**args[0], **kwargs}
128
+ else:
129
+ pname = args[0]
130
+ if pname == "state":
131
+ return ("state", "state", "state", "normal", str(self.state()))
132
+
133
+ if pname in ["fg_color", "text_color", "border_color", "scrollbar_button_color",
134
+ "scrollbar_button_hover_color"]:
135
+ val = self._custom_disabled_map.get(pname) if self._custom_current_state == "disabled" else self._local_defaults.get(pname)
136
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
137
+
138
+ return self._configure_query(pname)
139
+
140
+ # Runtime overrides have to reach the map a repaint reads, or the
141
+ # repaint puts the theme value straight back -- see
142
+ # ThemeableWidget._record_theme_overrides().
143
+ self._record_theme_overrides(kwargs)
144
+
145
+ if "state" in kwargs:
146
+ self._custom_current_state = str(kwargs.pop("state")).lower()
147
+ self._update_current_visual_state()
148
+
149
+ if kwargs:
150
+ return super().configure(**kwargs)
151
+ return None
152
+
153
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
154
+ # attribute -- it does not automatically track whichever configure() a
155
+ # subclass defines. Without this line, calling .config(...) on an instance
156
+ # would silently skip this entire override and land on the native widget's
157
+ # configure() directly, bypassing theming and state handling entirely.
158
+ config = configure
159
+
160
+ def get_state(self) -> str:
161
+ """Equivalent to calling state() with no argument."""
162
+ return self.state()
163
+
164
+ def state(self, state_string: Optional[str] = None) -> str:
165
+ """
166
+ Gets or sets the widget's enabled/disabled visual state. See
167
+ sCTkTextboxPrimary's identical method for full Args/Returns detail.
168
+ """
169
+ if state_string is not None:
170
+ self.configure(state=state_string)
171
+ return getattr(self, "_custom_current_state", "normal")
172
+
173
+ def _update_current_visual_state(self) -> None:
174
+ """
175
+ Recomputes and applies this widget's colors from the theme file, based
176
+ on the current state, then sets the native interactive lock, then
177
+ re-themes the internal scrollbar. See sCTkTextboxPrimary's identical
178
+ method for the full reasoning, including the required-key checks for
179
+ scrollbar_button_color/scrollbar_button_hover_color.
180
+ """
181
+ is_disabled = getattr(self, "_custom_current_state", "normal") == "disabled"
182
+ target_map = self._custom_disabled_map if is_disabled else self._local_defaults
183
+
184
+ config_payload = {}
185
+ for key in ("fg_color", "border_color", "text_color", "scrollbar_button_color", "scrollbar_button_hover_color"):
186
+ val = target_map.get(key)
187
+ if val is not None:
188
+ config_payload[key] = val
189
+
190
+ if config_payload:
191
+ super().configure(**config_payload)
192
+
193
+ if is_disabled:
194
+ super().configure(state="disabled")
195
+ else:
196
+ super().configure(state="normal")
197
+
198
+ if hasattr(self, "_scrollbar") and self._scrollbar:
199
+ try:
200
+ if target_map.get("scrollbar_button_color") is None:
201
+ raise KeyError(
202
+ f"'{self.__class__.__name__}' theme block is missing "
203
+ f"'scrollbar_button_color' in its "
204
+ f"{'disabled_map' if is_disabled else 'top-level'} section."
205
+ )
206
+ if is_disabled:
207
+ bar_color = target_map.get("scrollbar_button_color")
208
+ self._scrollbar.configure(button_color=bar_color, button_hover_color=bar_color)
209
+ else:
210
+ if target_map.get("scrollbar_button_hover_color") is None:
211
+ raise KeyError(
212
+ f"'{self.__class__.__name__}' theme block is missing "
213
+ f"'scrollbar_button_hover_color' at the top level."
214
+ )
215
+ self._scrollbar.configure(
216
+ button_color=target_map.get("scrollbar_button_color"),
217
+ button_hover_color=target_map.get("scrollbar_button_hover_color"),
218
+ )
219
+
220
+ if hasattr(self._scrollbar, "_draw"):
221
+ self._scrollbar._draw()
222
+ except KeyError:
223
+ raise
224
+ except Exception:
225
+ pass
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkToplevel
4
+
5
+ A theme-compliant top-level window wrapper, for secondary windows, modal
6
+ dialogs, and popups. Inherits directly from ctk.CTkToplevel so CustomTkinter
7
+ handles native window management; this class layers automatic light/dark
8
+ theme resolution on top.
9
+
10
+ Base class order matters here: `class sCTkToplevel(ctk.CTkToplevel,
11
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
12
+ this file's own methods resolves to ctk.CTkToplevel -- and, beneath it,
13
+ tkinter.Misc -- never to ThemeableWidget. ThemeableWidget's own
14
+ configure()/cget()/_set_appearance_mode() overrides have been removed entirely
15
+ for this reason (see themeable_widget.py's docstring).
16
+
17
+ This is the simplest widget in this project's audit so far: no disabled
18
+ state, no state()/get_state() at all, and no per-state color-swapping logic.
19
+ A top-level window has no interactive "enabled/disabled" concept the way a
20
+ control does, and its theme block (see sCTkThemes.json) defines only
21
+ fg_color -- nothing else.
22
+ """
23
+ from typing import Any, Optional
24
+ import customtkinter as ctk
25
+ from .themeable_widget import ThemeableWidget
26
+
27
+
28
+ class sCTkToplevel(ctk.CTkToplevel, ThemeableWidget):
29
+ """Themeable top-level window.
30
+
31
+ Adds to native ctk.CTkToplevel:
32
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
33
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
34
+ and just as importantly, what it no longer does).
35
+ - Positional-dict support in configure(), e.g.
36
+ toplevel.configure({"fg_color": "red"}) -- merged into keyword
37
+ arguments and applied normally.
38
+
39
+ Single-argument property queries go to ThemeableWidget's shared
40
+ _configure_query(), like every other widget. This class ignored them
41
+ until recently, which mattered as soon as sCTkToplevel was registered
42
+ with the Designer: blanking a field there reads the property's default
43
+ first, and an unanswered query returns None, which pygubu then applies.
44
+
45
+ There is still no state()/get_state() and no per-state colour swapping.
46
+ A window has no enabled/disabled concept, and nothing in this class
47
+ reapplies colours after construction -- which is also why it needs no
48
+ override recording: with no repaint to revert them, a runtime change
49
+ simply stays.
50
+
51
+ WHITELIST GUARD: if a composite widget inherits sCTkToplevel as its own
52
+ base class and explicitly calls ThemeableWidget.__init__ itself before
53
+ calling super().__init__(), that composite's own final_kw could contain
54
+ keys native ctk.CTkToplevel knows nothing about. This matters MORE here
55
+ than for most widgets: CTkToplevel's own __init__ explicitly calls
56
+ check_kwargs_empty(kwargs, raise_error=True) after popping its known-valid
57
+ keys, confirmed directly against CustomTkinter's own source -- meaning ANY
58
+ unrecognized keyword reaching it is GUARANTEED to raise, not just
59
+ incidentally likely to. _NATIVE_CTKTOPLEVEL_KWARGS filters final_kw down
60
+ to only the keys the real native constructor accepts before that call.
61
+ This only matters for the Pattern-B composition scenario described above;
62
+ for direct construction of a plain sCTkToplevel, final_kw already only
63
+ contains this widget's own theme keys (just fg_color), so the filter is a
64
+ no-op.
65
+ """
66
+
67
+ # Confirmed directly against CustomTkinter's own ctk_toplevel.py source:
68
+ # CTkToplevel's own _valid_tk_toplevel_arguments set, plus fg_color as the
69
+ # one CTk-specific parameter defined separately in its __init__ signature.
70
+ # "master" is excluded here since it's always passed positionally, never
71
+ # part of the filtered kwargs dict.
72
+ _NATIVE_CTKTOPLEVEL_KWARGS = frozenset({
73
+ "bd", "borderwidth", "class", "container", "cursor", "height",
74
+ "highlightbackground", "highlightthickness", "menu", "relief",
75
+ "screen", "takefocus", "use", "visual", "width", "fg_color",
76
+ })
77
+
78
+ def __init__(self, master: Optional[Any] = None, **kwargs: Any) -> None:
79
+ """
80
+ Args:
81
+ master: Parent window.
82
+ **kwargs: Any native CTkToplevel argument, or an fg_color override
83
+ (see the "sCTkToplevel" block in sCTkThemes.json, which
84
+ currently defines only fg_color).
85
+ """
86
+ # 1. Fire our shared theme logic first. This resolves final_kw
87
+ # (construction-time properties). See ThemeableWidget.__init__ for
88
+ # what actually happens here.
89
+ ThemeableWidget.__init__(self, kwargs)
90
+ self._local_defaults = dict(self.final_kw)
91
+
92
+ # 2. Initialize CustomTkinter's native top-level window. Only forwards
93
+ # the subset of final_kw that native CTkToplevel actually accepts --
94
+ # see this class's docstring ("WHITELIST GUARD") for why this
95
+ # filtering exists.
96
+ native_kwargs = {k: v for k, v in self.final_kw.items() if k in self._NATIVE_CTKTOPLEVEL_KWARGS}
97
+ super().__init__(master, **native_kwargs)
98
+
99
+ # 3. Register lifecycle handshake hook, notifying Pygubu-style consumers
100
+ # that construction is complete.
101
+ self._finalize_themeable_lifecycle()
102
+
103
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
104
+ """
105
+ Standard widget configuration, with positional-dict support.
106
+
107
+ Args:
108
+ *args: At most one positional argument is meaningful:
109
+ - a dict: merged into kwargs and processed normally.
110
+ - a property name: returns a Tkinter-style
111
+ (name, name, name, default, current) tuple from the shared
112
+ query helper.
113
+ **kwargs: Standard CTkToplevel configuration options.
114
+
115
+ Returns:
116
+ The query tuple for the single-argument case, otherwise whatever
117
+ super().configure() returns.
118
+ """
119
+ # args is always a tuple, never a dict itself -- args[0] is the actual
120
+ # dict if one was passed. An earlier version checked
121
+ # `isinstance(args, dict)` directly on the tuple, which can never be
122
+ # true; that dict-merge path was unreachable. Fixed here to check
123
+ # args[0] instead.
124
+ if len(args) == 1:
125
+ if isinstance(args[0], dict):
126
+ kwargs = {**args[0], **kwargs}
127
+ else:
128
+ # The single-argument query, which this widget used to ignore.
129
+ #
130
+ # Pygubu calls configure(name) to read a property's default
131
+ # whenever a field is blanked in the inspector. Falling through
132
+ # to the native configure() does not answer that: CustomTkinter
133
+ # declares configure(self, require_redraw=False, **kwargs), so
134
+ # the property NAME arrives as require_redraw and the call
135
+ # returns None -- which pygubu then applies.
136
+ #
137
+ # _configure_query() is the shared helper every other widget
138
+ # uses; this one simply never called it.
139
+ return self._configure_query(args[0])
140
+
141
+ if kwargs:
142
+ # RETURNED, not discarded. The docstring noted this was preserved
143
+ # as-is from an earlier version; a caller reading the result got
144
+ # None whatever the native widget said.
145
+ return super().configure(**kwargs)
146
+ return None
147
+
148
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
149
+ # attribute -- it does not automatically track whichever configure() a
150
+ # subclass defines. Without this line, calling .config(...) on an instance
151
+ # would silently skip this entire override and land on the native widget's
152
+ # configure() directly.
153
+ config = configure