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,274 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkProgressBar
4
+
5
+ A theme-compliant progress indicator bar widget. Inherits directly from
6
+ ctk.CTkProgressBar so CustomTkinter handles native rendering; this class
7
+ layers automatic light/dark theme resolution and a distinct "disabled" visual
8
+ state on top.
9
+
10
+ Base class order matters here: `class sCTkProgressBar(ctk.CTkProgressBar,
11
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
12
+ this file's own methods resolves to ctk.CTkProgressBar -- 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); this widget owns all of
16
+ its own runtime color-swapping logic.
17
+
18
+ Progress bars have no native interactivity to block (no click handling at
19
+ all), so "disabled" here is purely a visual "looks inactive" state -- it never
20
+ touches CTk's native state machinery, unlike interactive widgets.
21
+ """
22
+ import os
23
+ from typing import Any, Optional
24
+ import customtkinter as ctk
25
+ from .themeable_widget import ThemeableWidget
26
+
27
+
28
+ class sCTkProgressBar(ctk.CTkProgressBar, ThemeableWidget):
29
+ """Themeable progress bar.
30
+
31
+ Adds to native ctk.CTkProgressBar:
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
+ - A purely visual "disabled" state, tracked in self._custom_current_state.
36
+ Since progress bars have no click behavior to block, this only swaps
37
+ colors -- it never calls the native configure(state=...).
38
+ - Pygubu Designer property introspection for `state`, `fg_color`,
39
+ `progress_color`, and `border_color` via a single-argument configure()
40
+ call, plus a cget() override so querying "state" doesn't raise (native
41
+ CTkProgressBar has no real "state" option to cget in the first place).
42
+ - A bind() override that no-ops while running inside Pygubu Designer
43
+ (detected via the PYGUBU_DESIGNER_RUNNING environment variable) --
44
+ the same pattern used by sCTkRadioButton, presumably to avoid event
45
+ bindings misbehaving during design-time layout.
46
+
47
+ Colors are passed through to configure() as raw (light, dark) tuples rather
48
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
49
+ tracking repaints them automatically on a light/dark switch -- the same
50
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
51
+ family. Not separately re-confirmed for this specific widget.
52
+
53
+ There's no `border_color` anywhere in this widget's theme block (not even
54
+ at the top level), even though the repaint loop checks for one. This
55
+ style simply has no themed border; the lookup always resolves to nothing
56
+ and is skipped, the same situation as sCTkButtonPrimary's border_color.
57
+ """
58
+
59
+ def __init__(self, master: Optional[Any] = None, **kwargs: Any) -> None:
60
+ """
61
+ Args:
62
+ master: Parent container.
63
+ **kwargs: `state` is pulled out explicitly below (applied after
64
+ construction, via self.state(), rather than passed to the
65
+ native constructor). Everything else is either a native
66
+ CTkProgressBar argument or a theme-key override (see the
67
+ "sCTkProgressBar" block in sCTkThemes.json).
68
+ """
69
+ # 1. Capture the initial state before ThemeableWidget's own kwargs
70
+ # pass, so it doesn't get merged into final_kw and sent to the native
71
+ # constructor.
72
+ state_init = kwargs.pop("state", "normal")
73
+
74
+ # 2. Fire our shared theme logic. This resolves final_kw
75
+ # (construction-time properties) and the disabled color map. See
76
+ # ThemeableWidget.__init__ for what actually happens here.
77
+ ThemeableWidget.__init__(self, kwargs)
78
+
79
+ # 3. Deep-copy the resolved map onto this instance, so later changes
80
+ # here never leak back into the shared theme registry.
81
+ self._local_defaults = dict(self.final_kw)
82
+ self._custom_disabled_map = dict(self._widget_disabled_map)
83
+
84
+ # Remove any stray "state" key from final_kw -- already extracted
85
+ # above, but guards against it being reintroduced by a theme override.
86
+ self.final_kw.pop("state", None)
87
+
88
+ # 4. Initialize CustomTkinter natively with the clean final kwargs array.
89
+ super().__init__(master, **self.final_kw)
90
+
91
+ # 5. Apply the requested initial state now that the native widget exists.
92
+ self._custom_current_state = "normal"
93
+ self.state(state_init)
94
+
95
+ # 6. Register lifecycle handshake hook, notifying Pygubu-style consumers
96
+ # that construction is complete.
97
+ self._finalize_themeable_lifecycle()
98
+
99
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
100
+ """
101
+ Standard widget configuration, with Pygubu/positional-argument handling.
102
+
103
+ Args:
104
+ *args: At most one positional argument is meaningful:
105
+ - a dict: merged into kwargs and processed normally below.
106
+ - the literal string "state": returns a Tkinter-style
107
+ (name, name, name, default, current) tuple.
108
+ - one of "fg_color"/"progress_color"/"border_color": returns
109
+ the same style of tuple, with `current` reflecting the
110
+ disabled or normal value as appropriate. Note the returned
111
+ value is str(value), where value may itself be a
112
+ (light, dark) tuple rather than a single resolved color --
113
+ a known limitation shared with the wider Pygubu-query
114
+ investigation set aside elsewhere in this project, not
115
+ fixed here.
116
+ - anything else: forwarded directly to the native widget's
117
+ configure(), which does not support single-argument property
118
+ queries for arbitrary properties (same limitation).
119
+ **kwargs: Standard CTkProgressBar configuration options, plus:
120
+ passing `state=...` routes through self.state() rather than
121
+ being forwarded as-is.
122
+
123
+ Returns:
124
+ The query tuple described above for the single-argument case, or
125
+ None for the keyword-argument case.
126
+ """
127
+ # args is always a tuple -- args[0] is the actual value passed, whether
128
+ # that's a string or a dict. An earlier version of this method compared
129
+ # the wrapped tuple directly (`pname = args`); its fallback used *args
130
+ # unpacking so it didn't crash, but the query branches below never
131
+ # matched anything. Don't reintroduce the tuple comparison.
132
+ if len(args) == 1:
133
+ if isinstance(args[0], dict):
134
+ kwargs = {**args[0], **kwargs}
135
+ else:
136
+ pname = args[0]
137
+ if pname == "state":
138
+ return ("state", "state", "state", "normal", str(self.state()))
139
+
140
+ if pname in ["fg_color", "progress_color", "border_color"]:
141
+ current_state = str(self.state()).lower()
142
+ val = self._custom_disabled_map.get(pname) if current_state == "disabled" else self._local_defaults.get(pname)
143
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
144
+
145
+ return self._configure_query(pname)
146
+
147
+ # Runtime overrides have to reach the map a repaint reads, or the
148
+ # repaint puts the theme value straight back -- see
149
+ # ThemeableWidget._record_theme_overrides().
150
+ self._record_theme_overrides(kwargs)
151
+
152
+ if "state" in kwargs:
153
+ self.state(kwargs.pop("state"))
154
+
155
+ if kwargs:
156
+ super().configure(**kwargs)
157
+ self._update_current_visual_state()
158
+
159
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
160
+ # attribute -- it does not automatically track whichever configure() a
161
+ # subclass defines. An earlier version of this file was MISSING this line
162
+ # entirely: calling .config(...) on an instance silently skipped this
163
+ # entire override and landed on the native widget's configure() directly,
164
+ # bypassing theming and state handling completely -- confirmed as a
165
+ # critical bug on sCTkSegmentedButton earlier in this project's audit; the
166
+ # same fix applies here.
167
+ config = configure
168
+
169
+ def cget(self, attribute_name: str) -> Any:
170
+ """
171
+ Intercepts "state" queries so they route through state() instead of
172
+ being forwarded to the native widget, which has no real "state" option
173
+ to cget in the first place and would otherwise raise.
174
+
175
+ Args:
176
+ attribute_name: The property name being queried.
177
+
178
+ Returns:
179
+ self.state() if attribute_name is "state" (case-insensitive),
180
+ otherwise whatever the native CTkProgressBar.cget() returns.
181
+ """
182
+ if str(attribute_name).lower() == "state":
183
+ return self.state()
184
+ return super().cget(attribute_name)
185
+
186
+ def _set_appearance_mode(self, mode_string: str) -> None:
187
+ """
188
+ Forwards CustomTkinter's internal light/dark mode change notification to
189
+ the native widget.
190
+
191
+ No longer manually re-triggers _update_current_visual_state(). That
192
+ method now passes raw (light, dark) tuples straight through to
193
+ configure() instead of pre-resolving to a single color, so CTk's own
194
+ appearance-mode tracking should repaint correctly on its own -- the
195
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
196
+ button family.
197
+
198
+ Args:
199
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
200
+ by CustomTkinter's internal appearance-mode change machinery.
201
+ """
202
+ if hasattr(super(), "_set_appearance_mode"):
203
+ try:
204
+ super()._set_appearance_mode(mode_string)
205
+ except Exception:
206
+ pass
207
+
208
+ def get_state(self) -> str:
209
+ """Equivalent to calling state() with no argument."""
210
+ return self.state()
211
+
212
+ def state(self, mode: Optional[str] = None) -> str:
213
+ """
214
+ Gets or sets the widget's visual "disabled" state.
215
+
216
+ Unlike most widgets in this library, any string is accepted and
217
+ stored verbatim (lowercased) -- there's no validation against a fixed
218
+ set of recognized values, since progress bars don't have a real
219
+ native "state" concept to validate against in the first place. Only
220
+ the literal value "disabled" actually changes the rendered colors;
221
+ anything else is treated as "not disabled" by _update_current_visual_state().
222
+
223
+ Args:
224
+ mode: If None, returns the current state without changing anything.
225
+ Otherwise, stored directly (lowercased) as the new state.
226
+
227
+ Returns:
228
+ The resulting state string (lowercase).
229
+ """
230
+ if mode is None:
231
+ return str(getattr(self, "_custom_current_state", "normal")).lower()
232
+
233
+ target_state = mode.lower()
234
+ self._custom_current_state = target_state
235
+ self._update_current_visual_state()
236
+ return self._custom_current_state
237
+
238
+ def _update_current_visual_state(self) -> None:
239
+ """
240
+ Recomputes and applies this widget's colors from the theme file, based
241
+ on the current state.
242
+
243
+ Called after construction, on every state() change, and after any
244
+ keyword configure() call.
245
+
246
+ Passes raw (light, dark) tuples straight through to configure() instead
247
+ of resolving to a single color first, so CTk's native tracking can
248
+ handle appearance-mode repaints without help from _set_appearance_mode.
249
+ Every value here traces back to sCTkThemes.json; there are no
250
+ hardcoded colors in this method.
251
+ """
252
+ is_disabled = getattr(self, "_custom_current_state", "normal") == "disabled"
253
+ target_map = self._custom_disabled_map if is_disabled else self._local_defaults
254
+
255
+ config_payload = {}
256
+ for key in ("fg_color", "progress_color", "border_color", "border_width", "corner_radius"):
257
+ val = target_map.get(key)
258
+ if val is not None:
259
+ config_payload[key] = val
260
+
261
+ if config_payload:
262
+ super().configure(**config_payload)
263
+
264
+ def bind(self, sequence=None, command=None, add=None):
265
+ """
266
+ No-ops while running inside Pygubu Designer (detected via the
267
+ PYGUBU_DESIGNER_RUNNING environment variable), otherwise forwards to
268
+ the native widget's bind(). Presumably guards against event bindings
269
+ misbehaving during design-time layout, since progress bars have no
270
+ real interactive purpose for the Designer to preview anyway.
271
+ """
272
+ if "PYGUBU_DESIGNER_RUNNING" in os.environ:
273
+ return None
274
+ return super().bind(sequence, command, add)
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkRadioButton
4
+
5
+ A theme-compliant radio selection button widget. Inherits directly from
6
+ ctk.CTkRadioButton so CustomTkinter handles native rendering, mutual
7
+ exclusion via shared variables, and click behavior; this class layers
8
+ automatic light/dark theme resolution and a distinct enabled/disabled visual
9
+ state on top.
10
+
11
+ Base class order matters here: `class sCTkRadioButton(ctk.CTkRadioButton,
12
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
13
+ this file's own methods resolves to ctk.CTkRadioButton -- 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" -- an earlier version used the
20
+ same manual-unbind approach confirmed broken on the button family (clicks
21
+ still fired while "disabled" there), and additionally called
22
+ super().configure(state="normal") even while disabling, never actually
23
+ setting native state to "disabled" at all. Fixed to match the button family's
24
+ confirmed-correct approach; not independently re-tested on this specific
25
+ widget.
26
+
27
+ CONFIRMED by direct testing: configure(variable=...) and configure(value=...)
28
+ now route through the native widget's own super().configure(), rather than
29
+ writing directly to CTkRadioButton's private _variable/_value attributes as
30
+ an earlier version did. That private-attribute approach (see its own "CRASH
31
+ SHIELD PASS" comment, apparently written to work around a ValueError) was
32
+ tested directly and found to be a real, confirmed bug: rebinding a button to
33
+ a new group's variable left the OLD variable's trace active (so the button
34
+ stayed incorrectly coupled to its original group) and corrupted the new
35
+ variable's value during the rebind. The native super().configure() approach
36
+ does not raise, correctly tears down the old binding, and correctly
37
+ establishes real two-way mutual exclusion in the new group.
38
+ """
39
+ import os
40
+ from typing import Any, Callable, Optional
41
+ import tkinter as tk
42
+ import customtkinter as ctk
43
+ from .themeable_widget import ThemeableWidget
44
+
45
+
46
+ class sCTkRadioButton(ctk.CTkRadioButton, ThemeableWidget):
47
+ """Themeable radio selection button.
48
+
49
+ Adds to native ctk.CTkRadioButton:
50
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
51
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
52
+ and just as importantly, what it no longer does).
53
+ - A distinct enabled/disabled visual state, using CTk's native
54
+ state="disabled" (see module docstring for the fix history).
55
+ - Pygubu Designer property introspection for `state`, `fg_color`,
56
+ `border_color`, `text_color`, and `hover_color` via a single-argument
57
+ configure() call.
58
+ - A bind() override that no-ops while running inside Pygubu Designer
59
+ (detected via the PYGUBU_DESIGNER_RUNNING environment variable) --
60
+ the same pattern used by sCTkProgressBar.
61
+
62
+ Colors are passed through to configure() as raw (light, dark) tuples rather
63
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
64
+ tracking repaints them automatically on a light/dark switch -- the same
65
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
66
+ family. Not separately re-confirmed for this specific widget.
67
+
68
+ CONFIRMED by direct testing: rebinding variable/value via
69
+ super().configure() (the "proper" approach) works correctly -- it does
70
+ not raise, correctly tears down the old variable's trace (a button
71
+ rebound to a new group no longer responds to the old group's other
72
+ buttons), and correctly establishes real two-way mutual exclusion in the
73
+ new group. The private-attribute approach previously used here was
74
+ confirmed WORSE than just "might not work": it left the old variable's
75
+ trace active (so the rebound button stayed incorrectly coupled to its
76
+ original group) and corrupted the new variable's value during the
77
+ rebind. See configure() for the finalized implementation.
78
+
79
+ The theme's border_width_unchecked and border_width_checked control this
80
+ widget's border thickness based on whether it's the currently-selected
81
+ button in its group (thicker when checked, to show the filled dot) --
82
+ these are real, top-level-only theme keys (not in disabled_map), applied
83
+ once at construction via final_kw and left alone afterward, the same way
84
+ font or corner_radius are elsewhere in this library. The native widget
85
+ handles switching between checked/unchecked appearance internally; no
86
+ repaint-time logic is needed for these two keys. The repaint loop below
87
+ no longer checks for "border_width", "radiobutton_width", or
88
+ "radiobutton_height" -- an earlier version did, but none of those three
89
+ correspond to anything in the real theme file; they were removed rather
90
+ than renamed, since border_width_unchecked/checked don't need to be
91
+ re-applied on disable/enable in the first place.
92
+ """
93
+
94
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
95
+ """
96
+ Args:
97
+ master: Parent container.
98
+ **kw: `variable` (tkinter.Variable), `value` (any), and `command`
99
+ (callable) are read (not popped -- see below) and explicitly
100
+ re-applied to final_kw before construction. Everything else is
101
+ either a native CTkRadioButton argument or a theme-key
102
+ override (see the "sCTkRadioButton" block in sCTkThemes.json).
103
+ """
104
+ # 1. Capture references to pass to the native constructor. Uses .get()
105
+ # rather than .pop() -- these keys are read here AND still present in
106
+ # kw when it's passed to ThemeableWidget.__init__ below, which will
107
+ # also copy them into final_kw as ordinary (non-color) values. The
108
+ # explicit re-assignment in step 3 is what guarantees they survive
109
+ # regardless of whether ThemeableWidget's own merge logic keeps them.
110
+ variable = kw.get("variable", None)
111
+ value = kw.get("value", None)
112
+ command = kw.get("command", None)
113
+
114
+ # 2. Fire our shared theme logic. This resolves final_kw
115
+ # (construction-time properties) and the disabled color map. See
116
+ # ThemeableWidget.__init__ for what actually happens here.
117
+ # NO PLACEHOLDER TEXT. CustomTkinter defaults `text` to the class
118
+ # name, so a CTkRadioButton built without text reads "CTkRadioButton". Nothing wants
119
+ # that in a real interface, and the Designer made it worse: clearing
120
+ # the text field removes the property, the canvas rebuilds the widget
121
+ # from what is left, and the placeholder appeared in place of the
122
+ # blank that was asked for.
123
+ kw.setdefault("text", "")
124
+ ThemeableWidget.__init__(self, kw)
125
+
126
+ # 3. Deep-copy the resolved map onto this instance, so later changes
127
+ # here never leak back into the shared theme registry.
128
+ self._local_defaults = dict(self.final_kw)
129
+ self._custom_disabled_map = dict(self._widget_disabled_map)
130
+
131
+ # Remove any stray "state" key from final_kw -- prevents native
132
+ # constructor validation collisions.
133
+ self.final_kw.pop("state", None)
134
+
135
+ # Explicitly ensure these survive into the native constructor call,
136
+ # regardless of how ThemeableWidget's merge handled them.
137
+ if variable is not None: self.final_kw["variable"] = variable
138
+ if value is not None: self.final_kw["value"] = value
139
+ if command is not None: self.final_kw["command"] = command
140
+
141
+ # 4. Initialize CustomTkinter natively, letting it wire up its own
142
+ # StringVar/mutual-exclusion tracking bindings.
143
+ super().__init__(master, **self.final_kw)
144
+
145
+ self._custom_current_state = "normal"
146
+ self._update_current_visual_state()
147
+
148
+ # 5. Register lifecycle handshake hook, notifying Pygubu-style consumers
149
+ # that construction is complete.
150
+ self._finalize_themeable_lifecycle()
151
+
152
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
153
+ """
154
+ Standard widget configuration, with Pygubu/positional-argument handling.
155
+
156
+ Args:
157
+ *args: At most one positional argument is meaningful:
158
+ - a dict: merged into kwargs and processed normally below.
159
+ - the literal string "state": returns a Tkinter-style
160
+ (name, name, name, default, current) tuple.
161
+ - one of "fg_color"/"border_color"/"text_color"/"hover_color":
162
+ returns the same style of tuple, with `current` reflecting
163
+ the disabled or normal value as appropriate. Note the
164
+ returned value is str(value), where value may itself be a
165
+ (light, dark) tuple rather than a single resolved color --
166
+ a known limitation shared with the wider Pygubu-query
167
+ investigation set aside elsewhere in this project, not
168
+ fixed here.
169
+ - anything else: forwarded directly to the native widget's
170
+ configure(), which does not support single-argument property
171
+ queries for arbitrary properties (same limitation).
172
+ **kwargs: Standard CTkRadioButton configuration options, plus:
173
+ `variable`/`value` are each routed through super().configure()
174
+ individually -- confirmed correct for rebinding to a new
175
+ group after construction, see module docstring; `command`
176
+ and `state` are each routed individually too.
177
+
178
+ Returns:
179
+ The query tuple described above for the single-argument case, or
180
+ whatever super().configure() returns for the keyword-argument case
181
+ (typically None).
182
+ """
183
+ # args is always a tuple -- args[0] is the actual value passed, whether
184
+ # that's a string or a dict. An earlier version of this method compared
185
+ # the wrapped tuple directly (`pname = args`) and forwarded it unwrapped
186
+ # to super().configure(), which is not a valid call shape for the
187
+ # native widget. Don't reintroduce that.
188
+ if len(args) == 1:
189
+ if isinstance(args[0], dict):
190
+ kwargs = {**args[0], **kwargs}
191
+ else:
192
+ pname = args[0]
193
+ if pname == "state":
194
+ return ("state", "state", "state", "normal", str(self.state()))
195
+
196
+ if pname in ["fg_color", "border_color", "text_color", "hover_color"]:
197
+ current_state = str(self.state()).lower()
198
+ val = self._custom_disabled_map.get(pname) if current_state == "disabled" else self._local_defaults.get(pname)
199
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
200
+
201
+ return self._configure_query(pname)
202
+
203
+ # CONFIRMED by direct testing: routing variable/value through
204
+ # super().configure() (rather than writing directly to CTkRadioButton's
205
+ # private _variable/_value attributes) does not raise, and correctly
206
+ # rebinds mutual exclusion to the new group -- see class docstring
207
+ # for the full confirmation and what was wrong with the private-
208
+ # attribute approach this replaces.
209
+ if "variable" in kwargs:
210
+ super().configure(variable=kwargs.pop("variable"))
211
+ if "value" in kwargs:
212
+ super().configure(value=kwargs.pop("value"))
213
+ if "command" in kwargs:
214
+ super().configure(command=kwargs.pop("command"))
215
+
216
+ # Runtime overrides have to reach the map a repaint reads, or the
217
+ # repaint puts the theme value straight back -- see
218
+ # ThemeableWidget._record_theme_overrides().
219
+ self._record_theme_overrides(kwargs)
220
+
221
+ if "state" in kwargs:
222
+ self.state(kwargs.pop("state"))
223
+
224
+ self._drop_cleared(kwargs)
225
+
226
+ if kwargs:
227
+ return super().configure(**kwargs)
228
+ return None
229
+
230
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
231
+ # attribute -- it does not automatically track whichever configure() a
232
+ # subclass defines. Without this line, calling .config(...) on an instance
233
+ # would silently skip this entire override and land on the native widget's
234
+ # configure() directly, bypassing theming and state handling entirely.
235
+ config = configure
236
+
237
+ def _set_appearance_mode(self, mode_string: str) -> None:
238
+ """
239
+ Forwards CustomTkinter's internal light/dark mode change notification to
240
+ the native widget.
241
+
242
+ No longer manually re-triggers _update_current_visual_state(). That
243
+ method now passes raw (light, dark) tuples straight through to
244
+ configure() instead of pre-resolving to a single color, so CTk's own
245
+ appearance-mode tracking should repaint correctly on its own -- the
246
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
247
+ button family.
248
+
249
+ Args:
250
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
251
+ by CustomTkinter's internal appearance-mode change machinery.
252
+ """
253
+ if hasattr(super(), "_set_appearance_mode"):
254
+ try:
255
+ super()._set_appearance_mode(mode_string)
256
+ except Exception:
257
+ pass
258
+
259
+ def get_state(self) -> str:
260
+ """Equivalent to calling state() with no argument."""
261
+ return self.state()
262
+
263
+ def state(self, mode: Optional[str] = None) -> str:
264
+ """
265
+ Gets or sets the widget's enabled/disabled state.
266
+
267
+ Args:
268
+ mode: If None, returns the current state without changing anything.
269
+ Otherwise, only the literal string "disabled" (case-insensitive)
270
+ is treated as disabled; anything in ("normal", "enabled",
271
+ "active") is treated as enabled. Any other value matches
272
+ neither branch, though colors are still harmlessly re-applied.
273
+
274
+ Returns:
275
+ The resulting state ("normal" or "disabled", lowercase).
276
+ """
277
+ if mode is None:
278
+ return str(getattr(self, "_custom_current_state", "normal")).lower()
279
+
280
+ mode = mode.lower()
281
+ if mode in ("normal", "enabled", "active"):
282
+ self._custom_current_state = "normal"
283
+ super().configure(state="normal")
284
+ # Re-establish the mouse bindings that disabling below may have
285
+ # torn down.
286
+ if hasattr(self, "_create_bindings"):
287
+ try:
288
+ self._create_bindings()
289
+ except Exception:
290
+ pass
291
+ self._update_current_visual_state()
292
+
293
+ elif mode == "disabled":
294
+ self._custom_current_state = "disabled"
295
+ # Uses CTk's native state="disabled" -- confirmed necessary (not
296
+ # optional) on the button family, which originally used the same
297
+ # manual-unbind code this method used to have, AND (unlike this
298
+ # widget's original version) at least called state="normal" for
299
+ # both branches -- meaning native state was NEVER actually set to
300
+ # "disabled" at all before this fix. See module docstring.
301
+ super().configure(state="disabled")
302
+ self._update_current_visual_state()
303
+
304
+ return self._custom_current_state
305
+
306
+ def _update_current_visual_state(self) -> None:
307
+ """
308
+ Recomputes and applies this widget's colors from the theme file, based
309
+ on the current state.
310
+
311
+ Called after construction and on every state() change.
312
+
313
+ Passes raw (light, dark) tuples straight through to configure() instead
314
+ of resolving to a single color first, so CTk's native tracking can
315
+ handle appearance-mode repaints without help from _set_appearance_mode.
316
+ Every value here traces back to sCTkThemes.json; there are no
317
+ hardcoded colors in this method.
318
+
319
+ See this class's docstring regarding "border_width" /
320
+ "radiobutton_width" / "radiobutton_height" -- those keys have been
321
+ removed from this loop entirely; they never matched anything in the
322
+ real theme (see this class's docstring for what the actual keys are
323
+ and why they don't need repaint-time handling).
324
+ """
325
+ is_disabled = getattr(self, "_custom_current_state", "normal") == "disabled"
326
+ target_map = self._custom_disabled_map if is_disabled else self._local_defaults
327
+
328
+ config_payload = {}
329
+ for key in ("fg_color", "border_color", "hover_color", "font"):
330
+ val = target_map.get(key)
331
+ if val is not None:
332
+ config_payload[key] = val
333
+
334
+
335
+ # text_color is handled separately, because this widget sets CTk's own
336
+ # state="disabled" -- and the native draw code then uses its
337
+ # text_color_disabled option, overriding whatever text_color was set
338
+ # to. Swapping text_color out of disabled_map was therefore discarded
339
+ # at draw time and the theme key had no visible effect.
340
+ #
341
+ # Both options are set, so CTk chooses between them as designed.
342
+ _normal_text = self._local_defaults.get("text_color")
343
+ if _normal_text is not None:
344
+ config_payload["text_color"] = _normal_text
345
+ _disabled_text = self._custom_disabled_map.get("text_color")
346
+ if _disabled_text is not None:
347
+ config_payload["text_color_disabled"] = _disabled_text
348
+
349
+ if config_payload:
350
+ super().configure(**config_payload)
351
+
352
+ def bind(self, sequence=None, command=None, add=None):
353
+ """
354
+ No-ops while running inside Pygubu Designer (detected via the
355
+ PYGUBU_DESIGNER_RUNNING environment variable), otherwise forwards to
356
+ the native widget's bind(). Same pattern used by sCTkProgressBar.
357
+ """
358
+ if "PYGUBU_DESIGNER_RUNNING" in os.environ:
359
+ return None
360
+ return super().bind(sequence, command, add)