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,581 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkSelector
4
+
5
+ An advanced theme-compliant, scrollable option tree selector widget.
6
+ Pairs an optional high-contrast search field lane with a dynamic listing frame card
7
+ to manage multi-state checkbox row configurations natively.
8
+ """
9
+ import customtkinter as ctk
10
+ from .themeable_widget import ThemeableWidget, parse_list_property
11
+
12
+ from typing import Optional, Union, Tuple
13
+
14
+ from .sctk_frame import sCTkFrame
15
+ from .sctk_checkbox import sCTkCheckBox
16
+ from .sctk_entry_primary import sCTkEntryPrimary
17
+ from .sctk_scrollable_frame import sCTkScrollableFrame
18
+
19
+ class sCTkSelector(sCTkFrame, ThemeableWidget):
20
+ # The placeholder list a cleared `items` property falls back to.
21
+ #
22
+ # NOT the constructor's default -- sCTkSelector(parent) with no items is
23
+ # legitimately empty, and stays that way. This is what the Designer's
24
+ # inspector shows on a fresh widget, and what its builder object passes
25
+ # when the field is blank, so restoring it here keeps the design view,
26
+ # the preview and the generated code agreeing.
27
+ #
28
+ # Previously a cleared field reported "[]" as its default and every
29
+ # checkbox vanished, which looked like the widget had broken.
30
+ DEFAULT_ITEMS = ("Item 1", "Item 2")
31
+
32
+ def __init__(self, master, items: Optional[list[str]] = None, multiple_choices=True, searchBox=True, **kwargs):
33
+ # 1. SANITIZE RUNTIME ARGUMENTS: Strip unmanaged properties out immediately
34
+ state_init = kwargs.pop("state", "normal")
35
+ pack_prop_init = kwargs.pop("pack_propagate", None)
36
+
37
+ # 2. ENFORCE SYSTEM REGISTRY INTERACTION:
38
+ ThemeableWidget.__init__(self, kwargs)
39
+
40
+ # 🛠️ THE MUTATION SAFEGUARD DEEP COPY SHIELD:
41
+ self._local_defaults = dict(self.final_kw)
42
+ self._custom_disabled_map = dict(self._widget_disabled_map)
43
+
44
+ # FIX: required-key validation moved here, to construction time,
45
+ # rather than living inside _update_current_visual_state() (called
46
+ # only later, and repeatedly on every state change). Moved so that
47
+ # border_color -- now also passed to both sub-widget constructors
48
+ # below, to keep their normal-state border visually consistent with
49
+ # this widget's own theme -- is guaranteed to exist before anything
50
+ # tries to use it, rather than risking a confusing native error at
51
+ # construction if a key were missing.
52
+ for required_key in ("text_color", "checkbox_fg_color", "checkbox_hover_color", "border_color", "checkmark_color"):
53
+ if self._local_defaults.get(required_key) is None:
54
+ raise KeyError(
55
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' "
56
+ f"at the top level of sCTkThemes.json."
57
+ )
58
+ for required_key in ("text_color", "checkbox_fg_color", "border_color", "checkmark_color"):
59
+ if self._custom_disabled_map.get(required_key) is None:
60
+ raise KeyError(
61
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing '{required_key}' in disabled_map."
62
+ )
63
+
64
+ fg_color = self._local_defaults.get("fg_color", "transparent")
65
+ # FIX: an earlier version never coordinated border_color between this
66
+ # widget and its two internal sub-widgets (search_bar,
67
+ # checkboxes_frame) -- each independently used its own standalone
68
+ # theme's border_color, which could visibly mismatch (confirmed by
69
+ # direct testing: sCTkEntryPrimary and sCTkScrollableFrame's own
70
+ # default border colors differ in dark mode). Passed here, once, at
71
+ # construction, rather than forced on every _update_current_visual_state()
72
+ # call -- that would fight with sCTkEntryPrimary's own correct
73
+ # readonly/disabled border-color changes, undoing the whole point of
74
+ # its three-state model. This only establishes the shared NORMAL-state
75
+ # border; each sub-widget's own state-driven color changes afterward
76
+ # are left completely alone.
77
+ selector_border_color = self._local_defaults.get("border_color")
78
+
79
+ # 3. Call the parent sCTkFrame constructor safely
80
+ super().__init__(master, **self.final_kw)
81
+
82
+ self._state = "normal"
83
+ self.search_var = ctk.StringVar(self)
84
+ self.search_var.trace_add("write", self._search_modified)
85
+
86
+ self.search_bar = None
87
+ self._search_box_visible = bool(searchBox)
88
+
89
+ self.checkboxes_frame = sCTkScrollableFrame(self, fg_color=fg_color, border_color=selector_border_color)
90
+ self.checkboxes_frame.pack(expand=True, fill="both", side="bottom")
91
+
92
+ if hasattr(self.checkboxes_frame, "_parent_frame") and self.checkboxes_frame._parent_frame is not None:
93
+ self.checkboxes_frame._parent_frame.pack_propagate(False)
94
+
95
+ self.checkboxes = []
96
+ self.selected_indexes = []
97
+ self.multiple_choices = multiple_choices
98
+
99
+ if items is None:
100
+ items = []
101
+
102
+ # 4. Route variables into the configure parser loop for execution mapping
103
+ self.configure(
104
+ items=items,
105
+ multiple_choices=multiple_choices,
106
+ searchBox=self._search_box_visible,
107
+ pack_propagate=pack_prop_init,
108
+ state=state_init
109
+ )
110
+
111
+ # 🔑 REGISTER LIFECYCLE HANDSHAKE HOOK: Pushes notifications up to Pygubu systems cleanly.
112
+ self._finalize_themeable_lifecycle()
113
+
114
+ def _selection(self, index: int):
115
+ if index in self.selected_indexes:
116
+ self.selected_indexes.remove(index)
117
+ else:
118
+ if self.multiple_choices:
119
+ self.selected_indexes.append(index)
120
+ else:
121
+ if self.selected_indexes:
122
+ for i in self.selected_indexes:
123
+ if i < len(self.checkboxes):
124
+ self.checkboxes[i].deselect()
125
+ self.selected_indexes.clear()
126
+ self.selected_indexes.append(index)
127
+ else:
128
+ self.selected_indexes.append(index)
129
+
130
+ def _reset_scroll(self):
131
+ if hasattr(self.checkboxes_frame, "_parent_canvas") and self.checkboxes_frame._parent_canvas is not None:
132
+ self.checkboxes_frame._parent_canvas.yview_moveto(0)
133
+
134
+ def _search_modified(self, *args):
135
+ value = self.search_var.get()
136
+ row = 0
137
+ for x in range(len(self.checkboxes)):
138
+ if self.checkboxes[x].cget("text").startswith(value):
139
+ self.checkboxes[x].grid(row=row, column=0, padx=3, pady=3, sticky="w")
140
+ row += 1
141
+ else:
142
+ self.checkboxes[x].grid_forget()
143
+ self._reset_scroll()
144
+
145
+ def get_all_items(self) -> list:
146
+ return [checkbox.cget("text") for checkbox in self.checkboxes]
147
+ def configure(self, cnf=None, **kwargs):
148
+ if cnf is not None and not kwargs and isinstance(cnf, str):
149
+ pname = cnf
150
+ # width and height report this widget's OWN defaults.
151
+ #
152
+ # Neither is in the theme block, so the shared query fell back to
153
+ # the current value -- and pygubu uses that as the default when a
154
+ # field is cleared. Clearing a height of 100 therefore set it to
155
+ # 100 again, which looked like the clear doing nothing.
156
+ #
157
+ # 200x150 is what the constructor falls back to when no size is
158
+ # given; see the else branch in configure().
159
+ # The reported default is 0, meaning "no size asked for".
160
+ #
161
+ # Pygubu does not send an empty string when a field is cleared --
162
+ # it asks the widget for the default and sets THAT. Reporting 150
163
+ # therefore sent height=150, which is indistinguishable from the
164
+ # user typing 150, so the widget honoured it: propagate off, height
165
+ # exactly 150. A freshly placed selector sends no height at all,
166
+ # takes the no-size branch, and fits its contents instead -- so the
167
+ # same logical state gave two different sizes.
168
+ #
169
+ # Zero is treated as "not asked for" below, which makes clearing
170
+ # land in the same branch a fresh load does.
171
+ if pname == "width":
172
+ return ("width", "width", "Width", 0,
173
+ int(self.final_kw.get("width", 0) or 0))
174
+ if pname == "height":
175
+ return ("height", "height", "Height", 0,
176
+ int(self.final_kw.get("height", 0) or 0))
177
+
178
+ if pname == "state": return ("state", "state", "state", "normal", str(self.state()))
179
+ if pname == "multiple_choices": return ("multiple_choices", "multiple_choices", "multiple_choices", "True", str(self.multiple_choices))
180
+ if pname == "searchBox": return ("searchBox", "searchBox", "searchBox", "True", str(self._search_box_visible))
181
+ if pname == "items":
182
+ # FIX: the default slot used to be the literal "[]", so
183
+ # clearing the field in the Designer reported "the default is
184
+ # an empty list" and every checkbox vanished -- the widget did
185
+ # exactly what it was told, but the answer was wrong. The
186
+ # constructor's default is DEFAULT_ITEMS, which is also what
187
+ # the inspector shows on a fresh widget.
188
+ #
189
+ # Both slots go through _query_value with the property name,
190
+ # so a list is rendered as JSON -- the form pygubu parses and
191
+ # the form a user types. str() of a Python list is neither.
192
+ current_items = ([cb.cget("text") for cb in self.checkboxes]
193
+ if hasattr(self, "checkboxes") else [])
194
+ return ("items", "items", "items",
195
+ self._query_value(list(self.DEFAULT_ITEMS), "items"),
196
+ self._query_value(current_items, "items"))
197
+ if pname == "pack_propagate": return (pname, pname, pname, "None", str(getattr(self, "_pack_propagate_val", None)))
198
+ if pname in ["fg_color", "border_color", "text_color"]:
199
+ current_state = str(self.state()).lower()
200
+ val = self._custom_disabled_map.get(pname) if current_state == "disabled" else self._local_defaults.get(pname)
201
+ return (pname, pname, pname,
202
+ self._query_value(self._theme_default(pname)),
203
+ self._query_value(val))
204
+ # FIX: forwarding a property NAME to native configure() passes it
205
+ # as require_redraw and returns None, where pygubu expects a
206
+ # Tkinter-style five-tuple -- it then hands that None straight back
207
+ # to _set_property(). Reached whenever a field is blanked in the
208
+ # Designer inspector.
209
+ #
210
+ # This file uses the older (self, cnf=None, **kwargs) signature, so
211
+ # the query arrives as `cnf`. The batch pass that fixed this across
212
+ # the library matched on `pname` and `require_redraw` and missed it.
213
+ return self._configure_query(cnf)
214
+
215
+ if isinstance(cnf, dict): kwargs = cnf | kwargs
216
+
217
+ if "items" in kwargs:
218
+ items_val = kwargs.pop("items")
219
+ # An empty value means "use the placeholder list", matching the
220
+ # query above and what the builder object passes when the
221
+ # inspector field is blank.
222
+ if items_val == "" or items_val is None:
223
+ items_val = list(self.DEFAULT_ITEMS)
224
+ elif isinstance(items_val, str):
225
+ # Shared parser: accepts the Python-literal form this
226
+ # widget's inspector default uses AND the bare
227
+ # comma-separated form every other widget expects.
228
+ try: items_val = parse_list_property(items_val)
229
+ except Exception: items_val = []
230
+ if items_val is not None:
231
+ if len(set(items_val)) == len(items_val):
232
+ for checkbox in self.checkboxes: checkbox.destroy()
233
+ self.checkboxes.clear()
234
+ self.selected_indexes.clear()
235
+ for index in range(len(items_val)):
236
+ self.checkboxes.append(sCTkCheckBox(self.checkboxes_frame, text=items_val[index], command=lambda a=index: self._selection(a)))
237
+ self._search_modified()
238
+ else: raise ValueError("There is two times or more the same item in the given items list")
239
+
240
+ if "searchBox" in kwargs:
241
+ sb_val = kwargs.pop("searchBox")
242
+ if sb_val == "" or sb_val is None: sb_val = True
243
+ elif isinstance(sb_val, str): sb_val = str(sb_val).lower() in ['true', '1', 'yes']
244
+ self._search_box_visible = sb_val
245
+ if self._search_box_visible:
246
+ # FIX: search_bar's border_color now aligned with this
247
+ # widget's own theme at creation time, matching the same
248
+ # fix applied to checkboxes_frame in __init__ -- see that
249
+ # constructor call's comment for the full reasoning.
250
+ if not hasattr(self, "search_bar") or self.search_bar is None: self.search_bar = sCTkEntryPrimary(self, textvariable=self.search_var, border_color=self._local_defaults.get("border_color"))
251
+ self.search_bar.pack(anchor="n", fill="x")
252
+ if hasattr(self, "checkboxes_frame") and self.checkboxes_frame is not None:
253
+ self.checkboxes_frame.pack_forget()
254
+ self.checkboxes_frame.pack(expand=True, fill="both", side="bottom")
255
+ if self._state == "disabled" and self.search_bar is not None: self.search_bar.configure(state="readonly")
256
+ else:
257
+ if hasattr(self, "search_bar") and self.search_bar is not None: self.search_bar.pack_forget()
258
+
259
+ if "multiple_choices" in kwargs:
260
+ mult_val = kwargs.pop("multiple_choices")
261
+ if mult_val == "" or mult_val is None: mult_val = True
262
+ elif isinstance(mult_val, str): mult_val = str(mult_val).lower() in ['true', '1', 'yes']
263
+ self.multiple_choices = mult_val
264
+
265
+ # Runtime overrides have to reach the map a repaint reads, or the
266
+ # repaint puts the theme value straight back -- see
267
+ # ThemeableWidget._record_theme_overrides().
268
+ self._record_theme_overrides(kwargs)
269
+
270
+ if "state" in kwargs: self.state(kwargs.pop("state"))
271
+
272
+ # grid_propagate is deliberately GONE.
273
+ #
274
+ # pack_propagate() and grid_propagate() control whether a container
275
+ # resizes to fit its children, and which one applies depends on how
276
+ # THE CHILDREN are managed -- not on how this widget is managed by its
277
+ # own parent. This widget packs its children, so pack_propagate is the
278
+ # meaningful call and grid_propagate could never do anything.
279
+ #
280
+ # It was offered anyway, so setting it looked like a knob that did
281
+ # nothing, while pack_propagate appeared to work "regardless of
282
+ # geometry management" -- which is simply what it does. A property that
283
+ # provably cannot have an effect is worse than an absent one: someone
284
+ # sets it, sees nothing, and goes looking for a bug in their layout.
285
+ pack_prop_val = kwargs.pop("pack_propagate", None)
286
+ # Accepted and discarded, so existing code passing it does not raise.
287
+ kwargs.pop("grid_propagate", None)
288
+ if pack_prop_val is not None and pack_prop_val != "": setattr(self, "_pack_propagate_val", str(pack_prop_val).lower() in ['true', '1', 'yes'])
289
+
290
+ for k, v in list(kwargs.items()):
291
+ if k in self._local_defaults: self.final_kw[k] = kwargs.pop(k)
292
+
293
+ if "fg_color" in self.final_kw:
294
+ new_fg = self.final_kw.get("fg_color")
295
+ if hasattr(self, "checkboxes_frame"): self.checkboxes_frame.configure(fg_color=new_fg)
296
+
297
+ # A CLEARED width or height means "go back to the default size".
298
+ #
299
+ # The Designer sends an empty string, and the blank-value filter at the
300
+ # end of this method simply drops it -- so nothing told the widget to
301
+ # revert and the last size stayed on the frame. Clearing the field
302
+ # looked like it did nothing until some other change forced a repaint.
303
+ #
304
+ # Removing it from final_kw as well is what matters: the lookup below
305
+ # falls back to that dict, so a stale entry there would keep the old
306
+ # size alive.
307
+ # Clearing is decided PER KEY, not for both at once.
308
+ #
309
+ # The Designer sends every property when any one of them changes, so
310
+ # editing the width also delivers height=0. Treating that as "the size
311
+ # was cleared" turned propagate back on and pushed the default width
312
+ # over the 400 just asked for -- setting a width made the height
313
+ # collapse to its contents and the width not take at all.
314
+ #
315
+ # Blank or zero both mean "no size asked for" here; see the query
316
+ # branch above for why a cleared field arrives as 0 rather than "".
317
+ _SIZE_DEFAULTS = {"width": 200, "height": 150}
318
+ _cleared = set()
319
+ for _size_key in ("width", "height"):
320
+ if _size_key in kwargs and str(kwargs[_size_key]).strip() in ("", "0"):
321
+ kwargs.pop(_size_key)
322
+ self.final_kw.pop(_size_key, None)
323
+ _cleared.add(_size_key)
324
+ # Reset the frame's stored value too. It remembers whatever it
325
+ # was last told, and turning propagate on only masks that --
326
+ # the next explicit size would let the old number reappear.
327
+ kwargs[_size_key] = _SIZE_DEFAULTS[_size_key]
328
+
329
+ # FIX: these read final_kw only, and width/height never arrive there.
330
+ #
331
+ # The loop above moves a keyword into final_kw only if it is a key this
332
+ # widget's THEME block defines, and the sCTkSelector block has no width
333
+ # or height. So an explicit height stayed in kwargs, h_val came back 0,
334
+ # and the else branch below ran -- setting 200x150 and, worse, turning
335
+ # ON pack_propagate. The height was then applied to the frame and
336
+ # immediately overridden by the frame shrinking to fit its children.
337
+ #
338
+ # The symptom was a height property that appeared to do nothing, in the
339
+ # Designer and in generated code alike.
340
+ w_val = int(kwargs.get("width", self.final_kw.get("width", 0)) or 0)
341
+ h_val = int(kwargs.get("height", self.final_kw.get("height", 0)) or 0)
342
+
343
+ # And put the COERCED values back, because the Designer sends strings.
344
+ #
345
+ # h_val above is an int and is used for the propagate decision, but the
346
+ # raw kwargs entry -- '100', with quotes -- is what reaches
347
+ # CTkFrame.configure() at the end of this method, which wants a number.
348
+ # The height was computed correctly, used correctly, and then forwarded
349
+ # in a form the native widget could not act on.
350
+ if "width" in kwargs:
351
+ kwargs["width"] = w_val
352
+ if "height" in kwargs:
353
+ kwargs["height"] = h_val
354
+ # Propagate goes back on only when NEITHER size was asked for, which
355
+ # is the state a freshly placed selector is in.
356
+ _explicit = {k for k in ("width", "height")
357
+ if k not in _cleared and (k in kwargs or k in self.final_kw)}
358
+
359
+ if _explicit:
360
+ # An explicit size means propagate OFF, unless this very call says
361
+ # otherwise.
362
+ #
363
+ # FIX: this used to fall back to self._pack_propagate_val, which is
364
+ # set to True at construction when no size was given -- and then
365
+ # outlives that situation. So a height set later was computed
366
+ # correctly, applied correctly, and then ignored, because the frame
367
+ # was still sizing itself to its children. winfo_reqheight() stayed
368
+ # at the content height no matter what was asked for.
369
+ #
370
+ # The `False` default in the old getattr never applied: the
371
+ # attribute always exists by this point.
372
+ #
373
+ # Asking for a size and asking the widget to size itself to its
374
+ # contents are contradictory. The explicit number is the more
375
+ # specific instruction, so it wins.
376
+ use_pack_p = pack_prop_val if pack_prop_val is not None else False
377
+ else:
378
+ self.final_kw["width"] = 200
379
+ self.final_kw["height"] = 150
380
+ if _cleared:
381
+ # Reset the frame's stored size as well as the propagate flag.
382
+ #
383
+ # The frame remembers whatever it was last told. Turning
384
+ # propagate back on only MASKS that: the widget fits its
385
+ # contents and looks right, but the old number is still there,
386
+ # and the next explicit size turns propagate off and lets it
387
+ # reappear. Setting width 400, clearing it, then setting a
388
+ # height brought the 400 back.
389
+ #
390
+ # Safe to forward here because propagate is on in this branch,
391
+ # so these do not fight the content sizing -- which is why a
392
+ # fresh load and a cleared field still agree.
393
+ kwargs["width"] = 200
394
+ kwargs["height"] = 150
395
+ use_pack_p = pack_prop_val if pack_prop_val is not None else getattr(self, "_pack_propagate_val", True)
396
+
397
+ if isinstance(use_pack_p, str): use_pack_p = use_pack_p.lower() in ['true', '1', 'yes']
398
+ if use_pack_p is not None: self.pack_propagate(use_pack_p)
399
+
400
+ if hasattr(self, "checkboxes_frame") and hasattr(self.checkboxes_frame, "_parent_frame"):
401
+ # The INNER frame stays non-propagating, always.
402
+ #
403
+ # FIX: this used to follow use_pack_p, the outer frame's setting.
404
+ # Construction deliberately pins it to False (see __init__), which
405
+ # is what gives a fresh selector its full scroll area however few
406
+ # checkboxes it holds. Setting it True made the inner frame shrink
407
+ # to its contents, so clearing a height produced a selector smaller
408
+ # than a freshly placed one -- the same logical state, two sizes.
409
+ #
410
+ # The outer frame's propagate is the one that means anything to a
411
+ # caller: it decides whether an explicit height is honoured.
412
+ self.checkboxes_frame._parent_frame.pack_propagate(False)
413
+
414
+ self.final_kw.pop("pack_propagate", None)
415
+ self.final_kw.pop("grid_propagate", None) # harmless if it was passed
416
+ self.final_kw.pop("state", None)
417
+
418
+ self._drop_cleared(kwargs)
419
+ if kwargs: return super().configure(**kwargs)
420
+ return None
421
+
422
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
423
+ # attribute -- it does not automatically track whichever configure() a
424
+ # subclass defines. Without this line, calling .config(...) silently skips
425
+ # this entire override and lands on sCTkFrame's configure() instead,
426
+ # bypassing the items/searchBox/multiple_choices/state handling above.
427
+ # Confirmed as a critical bug on sCTkSegmentedButton earlier in this
428
+ # project's audit; this was the last widget in the library still missing
429
+ # the alias.
430
+ #
431
+ # Note this class uses the older Tkinter `(self, cnf=None, **kwargs)`
432
+ # signature rather than `*args`. That's correct here and not the source of
433
+ # the tuple-comparison bugs found elsewhere: cnf is a real parameter
434
+ # holding the value itself, so `isinstance(cnf, dict)` and `pname = cnf`
435
+ # both behave as intended.
436
+ config = configure
437
+
438
+ def _set_appearance_mode(self, mode_string: str):
439
+ if hasattr(super(), "_set_appearance_mode"):
440
+ try:
441
+ super()._set_appearance_mode(mode_string)
442
+ except Exception:
443
+ pass
444
+ self._update_current_visual_state()
445
+
446
+ def get_state(self) -> str:
447
+ return self.state()
448
+
449
+ def state(self, mode: str = None) -> str:
450
+ """Dedicated state manager controlling human inputs programmatically via canvas intercept shields."""
451
+ if mode is None: return str(getattr(self, "_state", "normal")).lower()
452
+ mode = mode.lower()
453
+ if mode in ("normal", "enabled", "active"):
454
+ self._state = "normal"
455
+ if hasattr(self, "search_bar") and self.search_bar is not None:
456
+ self.search_bar.configure(state="normal")
457
+ if hasattr(self, "checkboxes"):
458
+ for cb in self.checkboxes:
459
+ cb.configure(state="normal")
460
+ if hasattr(cb, "_create_bindings"):
461
+ try:
462
+ cb._create_bindings()
463
+ except Exception:
464
+ pass
465
+ elif mode == "disabled":
466
+ self._state = "disabled"
467
+ if hasattr(self, "search_bar") and self.search_bar is not None:
468
+ self.search_bar.configure(state="readonly")
469
+ if hasattr(self, "checkboxes"):
470
+ for cb in self.checkboxes:
471
+ # 🔑 HARD INTERCEPT UNBIND MATRIX: Paralyzes mouse clicking tracks completely
472
+ try:
473
+ if hasattr(cb, "_canvas") and cb._canvas:
474
+ cb._canvas.unbind("<Enter>")
475
+ cb._canvas.unbind("<Leave>")
476
+ cb._canvas.unbind("<Button-1>")
477
+ cb._canvas.unbind("<ButtonRelease>")
478
+ if hasattr(cb, "_text_label") and cb._text_label:
479
+ cb._text_label.unbind("<Enter>")
480
+ cb._text_label.unbind("<Leave>")
481
+ cb._text_label.unbind("<Button-1>")
482
+ cb._text_label.unbind("<ButtonRelease>")
483
+ except Exception:
484
+ pass
485
+ self._update_current_visual_state()
486
+ return self._state
487
+
488
+ def _update_current_visual_state(self):
489
+ """
490
+ Applies checkbox and search-bar colors based on the current state.
491
+
492
+ Required-key validation for the theme keys this method (and
493
+ __init__'s sub-widget construction) depends on happens once, in
494
+ __init__ -- not repeated here on every call.
495
+
496
+ FIX: an earlier version's disabled branch used 100% hardcoded
497
+ literals -- self._custom_disabled_map was set up in __init__ but
498
+ never actually consulted here, meaning a correctly-populated
499
+ disabled_map in sCTkThemes.json had zero effect on what users
500
+ actually saw. Now reads from self._custom_disabled_map like every
501
+ other widget in this project, with hard-fail validation for
502
+ required keys.
503
+
504
+ FIX: an earlier version derived the checkbox's fill/hover color from
505
+ this widget's OWN fg_color/hover_color theme keys -- the same keys
506
+ that control the surrounding frame's own background -- falling back
507
+ to a hardcoded accent color pair whenever fg_color was "transparent"
508
+ (a common, legitimate choice for frame-type widgets, not a theme
509
+ gap). Reusing fg_color for two different purposes doesn't work when
510
+ the frame is meant to be transparent. Now uses dedicated
511
+ "checkbox_fg_color"/"checkbox_hover_color" theme keys instead, with
512
+ hard-fail validation, rather than overloading fg_color or silently
513
+ substituting a hardcoded guess.
514
+
515
+ FIX: an earlier version also wrote cb._inner_fg_color and cb._hover
516
+ directly onto each checkbox instance, in both branches -- confirmed
517
+ against sctk_checkbox.py's actual source that neither attribute is
518
+ ever read by CheckBox's own code anywhere. These were writes to
519
+ private attributes CheckBox never defined, with no effect. Removed
520
+ entirely, along with the now-unused color computation that only ever
521
+ fed into the dead cb._inner_fg_color write (which itself included a
522
+ cross-widget reach into the checkbox's own private _local_defaults
523
+ for a theme key, "inner_fg_color", that isn't part of CheckBox's
524
+ documented theme key set at all).
525
+
526
+ Passes raw (light, dark) tuples straight through to each checkbox's
527
+ configure() instead of resolving to a single color first, matching
528
+ the tuple-based approach validated elsewhere in this project. An
529
+ earlier version resolved everything to a single string first, which
530
+ still worked correctly here specifically because _set_appearance_mode
531
+ already manually re-triggers this whole method on every light/dark
532
+ switch -- but that's inconsistent with the more robust pattern used
533
+ elsewhere, which doesn't depend on a manual re-trigger at all.
534
+ """
535
+ is_disabled = getattr(self, "_state", "normal") == "disabled"
536
+
537
+ if hasattr(self, "search_bar") and self.search_bar is not None:
538
+ self.search_bar._update_current_visual_state()
539
+
540
+ if not hasattr(self, "checkboxes"):
541
+ return
542
+
543
+ for cb in self.checkboxes:
544
+ if is_disabled:
545
+ cb.configure(state="disabled")
546
+ d_map = self._custom_disabled_map
547
+ # FIX: an earlier version never referenced self._custom_disabled_map
548
+ # here at all -- these four lines were 100% hardcoded literals
549
+ # with zero theme connection. hover_color intentionally reuses
550
+ # checkbox_fg_color, not a separate disabled hover key -- hover
551
+ # can't meaningfully trigger while disabled anyway, matching the
552
+ # same "no distinct disabled hover" convention used elsewhere
553
+ # in this project (e.g. sCTkSlider).
554
+ cb.configure(
555
+ text_color=d_map.get("text_color"),
556
+ fg_color=d_map.get("checkbox_fg_color"),
557
+ border_color=d_map.get("border_color"),
558
+ hover_color=d_map.get("checkbox_fg_color"),
559
+ checkmark_color=d_map.get("checkmark_color"),
560
+ )
561
+ else:
562
+ cb.configure(state="normal")
563
+ m = self._local_defaults
564
+ # FIX: an earlier version read fg_color/hover_color here --
565
+ # the SAME keys that control the surrounding frame's own
566
+ # background -- with a hardcoded accent-color fallback for
567
+ # whenever fg_color was "transparent" (the frame's own
568
+ # default). Now uses dedicated checkbox_fg_color/
569
+ # checkbox_hover_color keys instead, so the checkbox's accent
570
+ # color no longer depends on what the frame's background
571
+ # happens to be set to.
572
+ cb.configure(
573
+ text_color=m.get("text_color"),
574
+ fg_color=m.get("checkbox_fg_color"),
575
+ border_color=m.get("border_color"),
576
+ hover_color=m.get("checkbox_hover_color"),
577
+ checkmark_color=m.get("checkmark_color"),
578
+ )
579
+
580
+ # Force an explicit redrawing pass on the inner elements safely
581
+ if hasattr(cb, "_draw"): cb._draw()