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,375 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkButtonTertiary
4
+
5
+ A theme-compliant tertiary (outline-style) action button widget -- the
6
+ lowest-emphasis of the library's three button tiers (see also
7
+ sCTkButtonPrimary, sCTkButtonSecondary). Inherits directly from ctk.CTkButton
8
+ so CustomTkinter handles native rendering; this class layers automatic
9
+ light/dark theme resolution and a four-state visual model
10
+ (normal/disabled/pressed) on top. Unlike sCTkButtonPrimary, there is no
11
+ "alarm" state here.
12
+
13
+ Base class order matters here: `class sCTkButtonTertiary(ctk.CTkButton,
14
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
15
+ this file's own methods resolves to ctk.CTkButton -- and, beneath it,
16
+ tkinter.Misc -- never to ThemeableWidget. ThemeableWidget's own
17
+ configure()/cget()/_set_appearance_mode() overrides have been removed entirely
18
+ for this reason (see themeable_widget.py's docstring); this widget owns all of
19
+ its own runtime color-swapping logic.
20
+ """
21
+ from typing import Any, Optional
22
+ import customtkinter as ctk
23
+ from .themeable_widget import ThemeableWidget, defer
24
+
25
+
26
+ class sCTkButtonTertiary(ctk.CTkButton, ThemeableWidget):
27
+ """Themeable tertiary (outline-style) action button.
28
+
29
+ Adds to native ctk.CTkButton:
30
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
31
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
32
+ and just as importantly, what it no longer does).
33
+ - A three-state visual model: normal, disabled, and pressed. Precedence
34
+ when both could apply is disabled > pressed (see
35
+ _update_current_visual_state() and the guard clause in set_pressed()).
36
+ - Pygubu Designer property introspection for a handful of properties via
37
+ a single-argument configure() call.
38
+
39
+ This button style has a transparent fg_color by design (it's an outline
40
+ button: border + text, no filled background). The theme's disabled_map has
41
+ no fg_color or hover_color entries at all, which is deliberate -- omitting
42
+ them means those properties simply keep their current ("transparent")
43
+ value when disabled, rather than gaining a solid disabled-fill background
44
+ that a filled button would want but an outline button shouldn't.
45
+ Similarly, pressed_map has no hover_color entry; _update_current_visual_state()
46
+ explicitly falls back to the normal-state hover_color in that case (see
47
+ that method) rather than leaving it unset.
48
+
49
+ Colors are passed through to configure() as raw (light, dark) tuples rather
50
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
51
+ tracking repaints them automatically on a light/dark switch -- the same
52
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and sCTkButtonPrimary.
53
+
54
+ Disabling uses CTk's native state="disabled" rather than manually unbinding
55
+ mouse events -- an earlier version of this widget used the manual-unbind
56
+ approach (identical code to what sCTkButtonPrimary originally had), which
57
+ was directly tested and confirmed NOT to block clicks on that widget. The
58
+ fix is carried over here on the strength of both widgets sharing the same
59
+ underlying ctk.CTkButton click-handling; a confirmatory test on this
60
+ specific widget is still worthwhile.
61
+ """
62
+
63
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
64
+ """
65
+ Args:
66
+ master: Parent container.
67
+ **kw: Any native CTkButton argument, or a theme-key override (see
68
+ the "sCTkButtonTertiary" block in sCTkThemes.json, including
69
+ its disabled_map, pressed_map and alarm_map sub-blocks).
70
+ """
71
+ # 1. Fire our shared theme logic first. This resolves final_kw
72
+ # (construction-time properties) and the disabled/pressed color maps.
73
+ # See ThemeableWidget.__init__ for what actually happens here.
74
+ # NO PLACEHOLDER TEXT. CustomTkinter defaults `text` to the class
75
+ # name, so a CTkButton built without text reads "CTkButton". Nothing wants
76
+ # that in a real interface, and the Designer made it worse: clearing
77
+ # the text field removes the property, the canvas rebuilds the widget
78
+ # from what is left, and the placeholder appeared in place of the
79
+ # blank that was asked for.
80
+ kw.setdefault("text", "")
81
+ ThemeableWidget.__init__(self, kw)
82
+
83
+ # 2. Deep-copy each resolved map onto this instance, so later changes
84
+ # here never leak back into the shared theme registry.
85
+ self._local_defaults = dict(self.final_kw)
86
+ self._custom_disabled_map = dict(self._widget_disabled_map)
87
+ self._custom_pressed_map = dict(self._widget_pressed_map)
88
+ self._custom_alarm_map = dict(self._widget_alarm_map)
89
+
90
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
91
+ super().__init__(master, **self.final_kw)
92
+
93
+ self.is_pressed = False
94
+ self.is_alarm = False
95
+ self._custom_current_state = "normal"
96
+ self._update_current_visual_state()
97
+
98
+ # 4. Register lifecycle handshake hook, notifying Pygubu-style consumers
99
+ # that construction is complete.
100
+ self._finalize_themeable_lifecycle()
101
+
102
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
103
+ """
104
+ Standard widget configuration, with Pygubu/positional-argument handling.
105
+
106
+ Args:
107
+ *args: At most one positional argument is meaningful:
108
+ - a dict: merged into kwargs and processed normally below.
109
+ - the literal string "state": returns a Tkinter-style
110
+ (name, name, name, default, current) tuple.
111
+ - one of "fg_color"/"border_color"/"text_color"/"hover_color":
112
+ returns the same style of tuple, with `current` reflecting
113
+ whichever of disabled/alarm/pressed/normal is currently active.
114
+ Note the returned value is str(value), where value may
115
+ itself be a (light, dark) tuple rather than a single
116
+ resolved color -- a known limitation shared with the wider
117
+ Pygubu-query investigation set aside elsewhere in this
118
+ project, not fixed here.
119
+ - anything else: forwarded directly to the native widget's
120
+ configure(), which does not support single-argument property
121
+ queries for arbitrary properties (same limitation).
122
+ **kwargs: Standard CTkButton configuration options, plus: passing
123
+ `state=...` routes through self.state() rather than being
124
+ forwarded as-is.
125
+
126
+ Returns:
127
+ The query tuple described above for the single-argument case, or
128
+ whatever super().configure() returns for the keyword-argument case
129
+ (typically None).
130
+ """
131
+ # args is always a tuple -- args[0] is the actual value passed, whether
132
+ # that's a string or a dict. An earlier version of this method compared
133
+ # the wrapped tuple directly (`pname = args`) and forwarded it unwrapped
134
+ # to super().configure(), which is not a valid call shape for the
135
+ # native widget. Don't reintroduce that.
136
+ if len(args) == 1:
137
+ if isinstance(args[0], dict):
138
+ kwargs = {**args[0], **kwargs}
139
+ else:
140
+ pname = args[0]
141
+ if pname == "state":
142
+ return ("state", "state", "state", "normal", str(self.state()))
143
+
144
+ if pname in ["fg_color", "border_color", "text_color", "hover_color"]:
145
+ current_state = str(self.state()).lower()
146
+ if current_state == "disabled" and self._custom_disabled_map:
147
+ val = self._custom_disabled_map.get(pname)
148
+ elif getattr(self, "is_alarm", False) and self._custom_alarm_map:
149
+ val = self._custom_alarm_map.get(pname)
150
+ elif getattr(self, "is_pressed", False) and self._custom_pressed_map:
151
+ val = self._custom_pressed_map.get(pname)
152
+ else:
153
+ val = self._local_defaults.get(pname)
154
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
155
+
156
+ return self._configure_query(pname)
157
+
158
+ # Runtime overrides have to reach the map a repaint reads, or
159
+ # _update_current_visual_state() puts the theme value straight back --
160
+ # see ThemeableWidget._record_theme_overrides().
161
+ self._record_theme_overrides(kwargs)
162
+
163
+ if "state" in kwargs:
164
+ target_state = kwargs.pop("state")
165
+ self.state(target_state)
166
+
167
+ # Theme values cleared to "" are dropped; `text=""` is kept, so a
168
+ # button's caption can actually be blanked. See
169
+ # ThemeableWidget._CONTENT_KEYS.
170
+ self._drop_cleared(kwargs)
171
+ if kwargs:
172
+ return super().configure(**kwargs)
173
+ return None
174
+
175
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
176
+ # attribute -- it does not automatically track whichever configure() a
177
+ # subclass defines. Without this line, calling .config(...) on an instance
178
+ # would silently skip this entire override and land on the native widget's
179
+ # configure() directly, bypassing theming and state handling entirely.
180
+ config = configure
181
+
182
+ def _set_appearance_mode(self, mode_string: str) -> None:
183
+ """
184
+ Forwards CustomTkinter's internal light/dark mode change notification to
185
+ the native widget.
186
+
187
+ No longer manually re-triggers _update_current_visual_state(). That
188
+ method now passes raw (light, dark) tuples straight through to
189
+ configure() instead of pre-resolving to a single color, so CTk's own
190
+ appearance-mode tracking should repaint correctly on its own -- the same
191
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and
192
+ sCTkButtonPrimary.
193
+
194
+ Args:
195
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
196
+ by CustomTkinter's internal appearance-mode change machinery.
197
+ """
198
+ if hasattr(super(), "_set_appearance_mode"):
199
+ try:
200
+ super()._set_appearance_mode(mode_string)
201
+ except Exception:
202
+ pass
203
+
204
+ def get_state(self) -> str:
205
+ """Equivalent to calling state() with no argument."""
206
+ return self.state()
207
+
208
+ def state(self, mode: Optional[str] = None) -> str:
209
+ """
210
+ Gets or sets the widget's enabled/disabled state.
211
+
212
+ Args:
213
+ mode: If None, returns the current state without changing anything.
214
+ Otherwise, only the literal string "disabled" (case-insensitive)
215
+ is treated as disabled; anything in ("normal", "enabled",
216
+ "active") is treated as enabled. Any other value falls through
217
+ without matching either branch, leaving the state unchanged.
218
+
219
+ Returns:
220
+ The resulting state ("normal" or "disabled", lowercase).
221
+ """
222
+ if mode is None:
223
+ return str(getattr(self, "_custom_current_state", "normal")).lower()
224
+
225
+ mode = mode.lower()
226
+ if mode in ("normal", "enabled", "active"):
227
+ self._custom_current_state = "normal"
228
+ super().configure(state="normal", hover=True)
229
+ # Re-establish the mouse bindings that disabling below may have
230
+ # torn down.
231
+ if hasattr(self, "_create_bindings"):
232
+ try: self._create_bindings()
233
+ except Exception: pass
234
+ # Deferred via after_idle rather than called immediately: on
235
+ # sCTkButtonPrimary, CTk's own state="normal" transition was found
236
+ # to schedule its own internal repaint that can run AFTER an
237
+ # immediate call here and silently overwrite fresh colors back to
238
+ # something stale. Queuing our reapply via after_idle lets CTk's
239
+ # own pending repaint settle first, so ours wins the final paint.
240
+ defer(self, "idle", self._update_current_visual_state)
241
+
242
+ elif mode == "disabled":
243
+ self._custom_current_state = "disabled"
244
+ # Uses CTk's native state="disabled" -- confirmed necessary (not
245
+ # optional) on sCTkButtonPrimary, which originally used the same
246
+ # manual-unbind code this method used to have. That approach was
247
+ # confirmed broken: clicks still fired the button's command while
248
+ # "disabled", almost certainly because CTkButton binds its click
249
+ # handler at the canvas-ITEM level (tag_bind on the drawn
250
+ # rectangle/text), not the canvas-WIDGET level -- so
251
+ # canvas.unbind() never reached it. Don't reintroduce manual event
252
+ # unbinding here.
253
+ super().configure(state="disabled", hover=False)
254
+ defer(self, "idle", self._update_current_visual_state)
255
+
256
+ return self._custom_current_state
257
+
258
+ def set_pressed(self, pressed: bool) -> None:
259
+ """
260
+ Toggles the visual "pressed" state.
261
+
262
+ No-op while disabled or in the alarm state -- see the precedence
263
+ order on _update_current_visual_state().
264
+
265
+ Args:
266
+ pressed: True to show the pressed-state colors, False to return to
267
+ normal (assuming not disabled or in alarm).
268
+ """
269
+ if getattr(self, "_custom_current_state", "normal") == "disabled" or self.is_alarm:
270
+ return
271
+ self.is_pressed = pressed
272
+ self._update_current_visual_state()
273
+
274
+ def set_alarm_state(self, active: bool) -> None:
275
+ """
276
+ Forces the button into (or out of) a high-visibility alarm state.
277
+
278
+ No-op while disabled. Turning alarm on clears any current "pressed"
279
+ state, since alarm takes visual precedence -- see
280
+ _update_current_visual_state().
281
+
282
+ Args:
283
+ active: True to enter the alarm state, False to leave it.
284
+ """
285
+ if getattr(self, "_custom_current_state", "normal") == "disabled":
286
+ return
287
+ self.is_alarm = active
288
+ if self.is_alarm:
289
+ self.is_pressed = False
290
+ self._update_current_visual_state()
291
+
292
+ def _update_current_visual_state(self) -> None:
293
+ """
294
+ Recomputes and applies this widget's colors from the theme file, based
295
+ on the current state. Precedence: disabled > alarm > pressed > normal -- only
296
+ the first matching branch applies.
297
+
298
+ Called after construction, on every state()/set_pressed() change, and
299
+ (indirectly, via after_idle) after every state() transition.
300
+
301
+ Passes raw (light, dark) tuples straight through to configure() instead
302
+ of resolving to a single color first, so CTk's native tracking can
303
+ handle appearance-mode repaints without help from _set_appearance_mode.
304
+ Every value here traces back to sCTkThemes.json; there are no
305
+ hardcoded colors in this method.
306
+
307
+ The pressed branch explicitly falls back to the normal-state
308
+ hover_color if pressed_map doesn't define one -- it doesn't, by
309
+ design, for this widget's theme block. See this class's docstring.
310
+
311
+ Only the normal (final else) branch also reapplies border_width,
312
+ corner_radius, and font -- these don't vary between
313
+ disabled/alarm/pressed/normal, so there's no need to repeat them in every
314
+ branch; they're set once here and otherwise left alone.
315
+ """
316
+ if getattr(self, "_custom_current_state", "normal") == "disabled":
317
+ config_payload = {}
318
+ for key in ("fg_color", "hover_color", "border_color"):
319
+ val = self._custom_disabled_map.get(key)
320
+ if val is not None:
321
+ config_payload[key] = val
322
+
323
+ # disabled_map's text_color goes to the NATIVE text_color_disabled.
324
+ #
325
+ # CTkButton has its own text_color_disabled option and its draw
326
+ # code uses that whenever state is "disabled", overriding whatever
327
+ # text_color was set to. Assigning the disabled value to text_color
328
+ # here was therefore discarded at draw time and the theme key had
329
+ # no visible effect at all.
330
+ #
331
+ # text_color keeps the NORMAL value, so the two options do not
332
+ # fight and CTk chooses between them as it is designed to.
333
+ disabled_text = self._custom_disabled_map.get("text_color")
334
+ if disabled_text is not None:
335
+ config_payload["text_color_disabled"] = disabled_text
336
+ normal_text = self._local_defaults.get("text_color")
337
+ if normal_text is not None:
338
+ config_payload["text_color"] = normal_text
339
+
340
+ if config_payload:
341
+ super().configure(**config_payload)
342
+ return
343
+
344
+ if getattr(self, "is_alarm", False):
345
+ config_payload = {}
346
+ for key in ("fg_color", "hover_color", "border_color", "text_color"):
347
+ val = self._custom_alarm_map.get(key)
348
+ if val is not None:
349
+ config_payload[key] = val
350
+ config_payload["hover"] = False
351
+ super().configure(**config_payload)
352
+
353
+ elif getattr(self, "is_pressed", False):
354
+ config_payload = {}
355
+ for key in ("fg_color", "border_color", "hover_color", "text_color"):
356
+ val = self._custom_pressed_map.get(key)
357
+ if val is not None:
358
+ config_payload[key] = val
359
+
360
+ # pressed_map has no hover_color entry for this widget by design
361
+ # (see class docstring) -- fall back to the normal-state value
362
+ # rather than leaving hover_color unset while pressed.
363
+ config_payload.setdefault("hover_color", self._local_defaults.get("hover_color"))
364
+ config_payload["hover"] = False
365
+ super().configure(**config_payload)
366
+ else:
367
+ config_payload = {}
368
+ for key in ("fg_color", "hover_color", "border_color", "text_color", "border_width", "corner_radius", "font"):
369
+ val = self._local_defaults.get(key)
370
+ if val is not None:
371
+ config_payload[key] = val
372
+
373
+ config_payload["hover"] = True
374
+ if config_payload:
375
+ super().configure(**config_payload)
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkCheckBox
4
+
5
+ A theme-compliant binary selection checkbox widget. Inherits directly from
6
+ ctk.CTkCheckBox so CustomTkinter handles native rendering, click toggling, and
7
+ input locking; this class layers automatic light/dark theme resolution and a
8
+ distinct enabled/disabled visual state on top.
9
+
10
+ Base class order matters here: `class sCTkCheckBox(ctk.CTkCheckBox,
11
+ ThemeableWidget)` puts the native CTk class first, so every `super()` call in
12
+ this file's own methods resolves to ctk.CTkCheckBox -- 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
+ from typing import Any, Optional
19
+ import customtkinter as ctk
20
+ from .themeable_widget import ThemeableWidget, defer
21
+
22
+
23
+ class sCTkCheckBox(ctk.CTkCheckBox, ThemeableWidget):
24
+ """Themeable checkbox.
25
+
26
+ Adds to native ctk.CTkCheckBox:
27
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
28
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
29
+ and just as importantly, what it no longer does).
30
+ - A distinct enabled/disabled visual state, tracked in
31
+ self._custom_current_state and swapped via _update_current_visual_state().
32
+ - Pygubu Designer property introspection for a handful of properties via
33
+ a single-argument configure() call.
34
+
35
+ Every color this widget applies, including the checkmark itself, comes
36
+ from sCTkThemes.json -- checkmark_color was added as a real theme key
37
+ during this project's audit; previously the theme block didn't define it
38
+ at all, so the checkmark silently always used CTkCheckBox's native default
39
+ regardless of theme.
40
+
41
+ Colors are passed through to configure() as raw (light, dark) tuples rather
42
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
43
+ tracking should repaint them automatically on a light/dark switch -- the
44
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and
45
+ sCTkButtonPrimary. Not separately re-confirmed for this specific widget.
46
+
47
+ Unlike the button family, this widget already used CTk's native
48
+ state="disabled"/state="normal" rather than manual event unbinding, so its
49
+ click-blocking was never suspected to be broken the way the buttons' was.
50
+ The color-then-state call order was reversed during this audit (state is
51
+ now set first, colors reapplied second via after_idle) as a precaution
52
+ based on a race condition confirmed on sCTkButtonPrimary between CTk's own
53
+ state-change repaint and an immediately-following manual color update --
54
+ this specific reordering has not been independently confirmed necessary
55
+ for this widget, only carried over as a defensive measure.
56
+ """
57
+
58
+ def __init__(self, master: Optional[Any] = None, **kw: Any) -> None:
59
+ """
60
+ Args:
61
+ master: Parent container.
62
+ **kw: Any native CTkCheckBox argument, or a theme-key override
63
+ (see the "sCTkCheckBox" block in sCTkThemes.json, including its
64
+ disabled_map sub-block).
65
+ """
66
+ # 1. Fire our shared theme logic first. This resolves final_kw
67
+ # (construction-time properties) and the disabled color map. See
68
+ # ThemeableWidget.__init__ for what actually happens here.
69
+ # NO PLACEHOLDER TEXT. CustomTkinter defaults `text` to the class
70
+ # name, so a CTkCheckBox built without text reads "CTkCheckBox". Nothing wants
71
+ # that in a real interface, and the Designer made it worse: clearing
72
+ # the text field removes the property, the canvas rebuilds the widget
73
+ # from what is left, and the placeholder appeared in place of the
74
+ # blank that was asked for.
75
+ kw.setdefault("text", "")
76
+ ThemeableWidget.__init__(self, kw)
77
+
78
+ # 2. Deep-copy the resolved map onto this instance, so later changes
79
+ # here never leak back into the shared theme registry.
80
+ self._local_defaults = dict(self.final_kw)
81
+ self._custom_disabled_map = dict(self._widget_disabled_map)
82
+
83
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
84
+ super().__init__(master, **self.final_kw)
85
+ self._custom_current_state = "normal"
86
+
87
+ # 4. Register lifecycle handshake hook, notifying Pygubu-style consumers
88
+ # that construction is complete.
89
+ self._finalize_themeable_lifecycle()
90
+
91
+ def _set_appearance_mode(self, mode_string: str) -> None:
92
+ """
93
+ Forwards CustomTkinter's internal light/dark mode change notification to
94
+ the native widget.
95
+
96
+ No longer manually re-triggers _update_current_visual_state(). That
97
+ method now passes raw (light, dark) tuples straight through to
98
+ configure() instead of pre-resolving to a single color, so CTk's own
99
+ appearance-mode tracking should repaint correctly on its own -- the
100
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and
101
+ sCTkButtonPrimary.
102
+
103
+ Args:
104
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
105
+ by CustomTkinter's internal appearance-mode change machinery.
106
+ """
107
+ if hasattr(super(), "_set_appearance_mode"):
108
+ try:
109
+ super()._set_appearance_mode(mode_string)
110
+ except Exception:
111
+ pass
112
+
113
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
114
+ """
115
+ Standard widget configuration, with Pygubu/positional-argument handling.
116
+
117
+ Args:
118
+ *args: At most one positional argument is meaningful:
119
+ - a dict: merged into kwargs and processed normally below.
120
+ - the literal string "state": returns a Tkinter-style
121
+ (name, name, name, default, current) tuple.
122
+ - one of "fg_color"/"border_color"/"text_color"/"hover_color"/
123
+ "checkmark_color": returns the same style of tuple, with
124
+ `current` reflecting the disabled or normal value as
125
+ appropriate. Note the returned value is str(value), where
126
+ value may itself be a (light, dark) tuple rather than a
127
+ single resolved color -- a known limitation shared with the
128
+ wider Pygubu-query investigation set aside elsewhere in this
129
+ project, not fixed here.
130
+ - anything else: forwarded directly to the native widget's
131
+ configure(), which does not support single-argument property
132
+ queries for arbitrary properties (same limitation).
133
+ **kwargs: Standard CTkCheckBox configuration options, plus: passing
134
+ `state=...` routes through self.state() rather than being
135
+ forwarded as-is.
136
+
137
+ Returns:
138
+ The query tuple described above for the single-argument case, or
139
+ whatever super().configure() returns for the keyword-argument case
140
+ (typically None).
141
+ """
142
+ if len(args) == 1:
143
+ if isinstance(args[0], dict):
144
+ kwargs = {**args[0], **kwargs}
145
+ else:
146
+ pname = args[0]
147
+ if pname == "state":
148
+ return ("state", "state", "state", "normal", str(self.state()))
149
+
150
+ if pname in ["fg_color", "border_color", "text_color", "hover_color", "checkmark_color"]:
151
+ current_state = str(self.state()).lower()
152
+ val = self._custom_disabled_map.get(pname) if current_state == "disabled" else self._local_defaults.get(pname)
153
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
154
+
155
+ return self._configure_query(pname)
156
+
157
+ # Runtime overrides have to reach the map a repaint reads, or
158
+ # _apply_theme_colors() puts the theme value straight back -- see
159
+ # ThemeableWidget._record_theme_overrides().
160
+ self._record_theme_overrides(kwargs)
161
+
162
+ if "state" in kwargs:
163
+ self.state(kwargs.pop("state"))
164
+
165
+ if kwargs:
166
+ self._drop_cleared(kwargs)
167
+ if kwargs:
168
+ return super().configure(**kwargs)
169
+ return None
170
+
171
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
172
+ # attribute -- it does not automatically track whichever configure() a
173
+ # subclass defines. Without this line, calling .config(...) on an instance
174
+ # would silently skip this entire override and land on the native widget's
175
+ # configure() directly, bypassing theming and state handling entirely.
176
+ config = configure
177
+
178
+ def get_state(self) -> str:
179
+ """Equivalent to calling state() with no argument."""
180
+ return self.state()
181
+
182
+ def state(self, mode: Optional[str] = None) -> str:
183
+ """
184
+ Gets or sets the widget's enabled/disabled state.
185
+
186
+ Args:
187
+ mode: If None, returns the current state without changing anything.
188
+ Otherwise, only the literal string "disabled" (case-insensitive)
189
+ is treated as disabled; anything in ("normal", "enabled",
190
+ "active") is treated as enabled. Any other value falls through
191
+ without matching either branch, leaving the state unchanged.
192
+
193
+ Returns:
194
+ The resulting state ("normal" or "disabled", lowercase). An
195
+ earlier version of this method didn't return anything when setting
196
+ a mode (only the query branch returned a value) -- fixed here for
197
+ consistency with every other themed widget's state().
198
+ """
199
+ if mode is None:
200
+ return str(getattr(self, "_custom_current_state", "normal")).lower()
201
+
202
+ mode = mode.lower()
203
+ if mode in ("normal", "enabled", "active"):
204
+ self._custom_current_state = "normal"
205
+ elif mode == "disabled":
206
+ self._custom_current_state = "disabled"
207
+
208
+ self._update_current_visual_state()
209
+ return self._custom_current_state
210
+
211
+ def _update_current_visual_state(self) -> None:
212
+ """
213
+ Sets the native enabled/disabled flag, then recomputes and applies
214
+ this widget's colors from the theme file, deferred via after_idle.
215
+
216
+ Called after every state() change.
217
+
218
+ Native state is set FIRST, colors reapplied SECOND via after_idle --
219
+ this order is a precaution, not a confirmed fix for this widget
220
+ specifically. On sCTkButtonPrimary, an immediate color reapply
221
+ following a state change was found to lose a race against CTk's own
222
+ internal state-change repaint, leaving stale colors after repeated
223
+ disable/enable cycles. That fix is carried over here defensively;
224
+ see this class's docstring.
225
+
226
+ Passes raw (light, dark) tuples straight through to configure() instead
227
+ of resolving to a single color first, so CTk's native tracking can
228
+ handle appearance-mode repaints without help from _set_appearance_mode.
229
+ Every value here traces back to sCTkThemes.json; there are no
230
+ hardcoded colors in this method.
231
+ """
232
+ is_disabled = getattr(self, "_custom_current_state", "normal") == "disabled"
233
+
234
+ super().configure(state="disabled" if is_disabled else "normal")
235
+ defer(self, "idle", self._apply_theme_colors)
236
+
237
+ def _apply_theme_colors(self) -> None:
238
+ """
239
+ Applies fg_color, border_color, hover_color, text_color,
240
+ checkmark_color, border_width, and font from whichever theme map
241
+ matches the current state. Called only via after_idle from
242
+ _update_current_visual_state() -- see that method for why the
243
+ deferral exists.
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", "hover_color", "checkmark_color", "border_width", "font"):
250
+ val = target_map.get(key)
251
+ if val is not None:
252
+ config_payload[key] = val
253
+
254
+ # text_color is handled separately, because CTkCheckBox has its own
255
+ # text_color_disabled option and its draw code uses that whenever state
256
+ # is "disabled" -- overriding whatever text_color was set to. Swapping
257
+ # text_color out of disabled_map was therefore discarded at draw time
258
+ # and the theme key had no visible effect: setting it to yellow changed
259
+ # nothing.
260
+ #
261
+ # Both options are set, so CTk chooses between them as designed.
262
+ normal_text = self._local_defaults.get("text_color")
263
+ if normal_text is not None:
264
+ config_payload["text_color"] = normal_text
265
+ disabled_text = self._custom_disabled_map.get("text_color")
266
+ if disabled_text is not None:
267
+ config_payload["text_color_disabled"] = disabled_text
268
+
269
+ if config_payload:
270
+ super().configure(**config_payload)