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,667 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkFrameLabeledPrimary
4
+
5
+ A theme-compliant labeled scrollable container panel -- the higher-emphasis
6
+ of the library's two labeled scrollable frame tiers (see also
7
+ sCTkFrameLabeledSecondary). Inherits directly from ctk.CTkScrollableFrame so
8
+ CustomTkinter handles native scrolling and layout; this class layers
9
+ automatic light/dark theme resolution, a visual-only "disabled" state, and
10
+ scrollbar-hiding on top.
11
+
12
+ Base class order matters here: `class sCTkFrameLabeledPrimary(
13
+ ctk.CTkScrollableFrame, ThemeableWidget)` puts the native CTk class first, so
14
+ every `super()` call in this file's own methods resolves to
15
+ ctk.CTkScrollableFrame -- and, beneath it, tkinter.Misc -- never to
16
+ ThemeableWidget. ThemeableWidget's own configure()/cget()/_set_appearance_mode()
17
+ overrides have been removed entirely for this reason (see
18
+ themeable_widget.py's docstring); this widget owns all of its own runtime
19
+ color-swapping logic.
20
+
21
+ "Disabled" here is purely visual -- there is no native way to lock a
22
+ CTkFrame-derived container's own interactivity the way a control can be
23
+ locked. Disabling this widget dims its own colors; it does NOT
24
+ automatically disable child widgets placed inside it -- that's the caller's
25
+ responsibility, exactly as demonstrated in this project's own test harness
26
+ for this widget (looping over the frame's children and calling
27
+ .configure(state=...) on each one individually).
28
+
29
+ Deliberately not scrollable: this widget uses ctk.CTkScrollableFrame purely
30
+ to access its native label_text/label_font/label_text_color feature (plain
31
+ CTkFrame has no built-in label option) -- not because scrolling is wanted.
32
+ The model here is ttk.LabelFrame, which doesn't scroll at all. The scrollbar
33
+ is suppressed by matching its colors to the frame background and collapsing
34
+ its width to 0, which removes the visible/interactive scrollbar entirely.
35
+ Confirmed by the maintainer: this is the intended design, not a gap.
36
+ SIZED TO ITS CONTENTS, for the same reason: a ttk.LabelFrame is as tall as
37
+ what is in it, while CTkScrollableFrame defaults to 200 points high whatever
38
+ it holds. Give it a `height` and that is honoured instead, with anything past
39
+ it clipped -- this panel does not scroll. See _begin_content_sizing().
40
+ """
41
+ import customtkinter as ctk
42
+ from typing import Any, Optional
43
+ from .themeable_widget import ThemeableWidget, defer
44
+
45
+
46
+ # How long to wait before measuring again when nothing has been drawn yet,
47
+ # and how many times to bother. A second in all: if the panel is not on
48
+ # screen by then it is hidden or destroyed, and its height does not matter.
49
+ _FIT_RETRY_MS = 50
50
+ _FIT_MAX_ATTEMPTS = 20
51
+
52
+ class sCTkFrameLabeledPrimary(ctk.CTkScrollableFrame, ThemeableWidget):
53
+ """Themeable, high-emphasis labeled scrollable container.
54
+
55
+ Adds to native ctk.CTkScrollableFrame:
56
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
57
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
58
+ and just as importantly, what it no longer does).
59
+ - A visual-only "disabled" state (see module docstring) -- dims border,
60
+ fill, and label text colors; does not lock interactivity, and does
61
+ not cascade to child widgets automatically.
62
+ - Pygubu Designer property introspection for `state`, `fg_color`,
63
+ `border_color`, and `label_text_color` via a single-argument
64
+ configure() call.
65
+ - Scrollbar hiding: the internal scrollbar's colors are forced to match
66
+ the frame's own background and its width collapsed to 0, making it
67
+ invisible while (presumably) leaving scrolling itself functional.
68
+ - winfo_children()/get_children()/get_all_children(): by default,
69
+ winfo_children() filters out children whose class name is exactly
70
+ "CTkLabel", "Label", "CTkFrame", or "Frame" -- internal furniture
71
+ CTkScrollableFrame creates for its own title row and canvas wrapper,
72
+ which would otherwise clutter a naive "what did I add to this frame"
73
+ query. get_children() is this filtered view; get_all_children()
74
+ returns the raw, unfiltered list.
75
+
76
+ KNOWN LIMITATION: the winfo_children() filter is a class-NAME string
77
+ check, not a "was this created internally" check. A plain,
78
+ un-themed customtkinter.CTkLabel or customtkinter.CTkFrame added
79
+ directly as a child (bypassing the sCTk-prefixed equivalents) would be
80
+ incorrectly filtered out of get_children() too, since its class name
81
+ matches the same strings being excluded. Themed sCTk widgets (e.g.
82
+ sCTkLabelPrimary) are unaffected, since their class names don't match.
83
+
84
+ Deliberately not scrollable -- unlike sCTkScrollableFrame, this class has
85
+ no scroll-binding code (no _toggle_scroll_bindings, no macOS touchpad
86
+ handling), despite both wrapping the same native CTkScrollableFrame. This
87
+ is intentional, confirmed by the maintainer: the model here is
88
+ ttk.LabelFrame, which never scrolls. CTkScrollableFrame is used purely
89
+ for its built-in label feature, and the internal scrollbar is
90
+ deliberately suppressed (see _hide_internal_scrollbars()) rather than
91
+ left functional.
92
+
93
+ Colors are passed through to configure() as raw (light, dark) tuples rather
94
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
95
+ tracking repaints them automatically on a light/dark switch -- the same
96
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
97
+ family. Not separately re-confirmed for this specific widget.
98
+
99
+ WHITELIST GUARD: if a composite widget inherits sCTkFrameLabeledPrimary as
100
+ its own base class and explicitly calls ThemeableWidget.__init__ itself
101
+ before calling super().__init__(), that composite's own final_kw could
102
+ contain keys native ctk.CTkScrollableFrame knows nothing about. Confirmed
103
+ directly against CustomTkinter's own source, CTkScrollableFrame.__init__
104
+ has NO **kwargs catch-all at all -- every parameter is explicitly named,
105
+ so ANY unrecognized keyword reaching it raises TypeError immediately.
106
+ _NATIVE_CTKSCROLLABLEFRAME_KWARGS filters final_kw down to only the keys
107
+ the real native constructor accepts before that call. This only matters
108
+ for the Pattern-B composition scenario described above; for direct
109
+ construction of a plain sCTkFrameLabeledPrimary, final_kw already only
110
+ contains this widget's own theme keys, so the filter is a no-op.
111
+ """
112
+
113
+ # Confirmed directly against CustomTkinter's own ctk_scrollable_frame.py
114
+ # source: every one of CTkScrollableFrame.__init__'s named parameters,
115
+ # excluding "master" (always passed positionally, never part of the
116
+ # filtered kwargs dict).
117
+ _NATIVE_CTKSCROLLABLEFRAME_KWARGS = frozenset({
118
+ "width", "height", "corner_radius", "border_width", "bg_color",
119
+ "fg_color", "border_color", "scrollbar_fg_color",
120
+ "scrollbar_button_color", "scrollbar_button_hover_color",
121
+ "label_fg_color", "label_text_color", "label_text", "label_font",
122
+ "label_anchor", "orientation",
123
+ })
124
+
125
+ def __init__(self, master: Optional[Any] = None, **kwargs: Any) -> None:
126
+ """
127
+ Args:
128
+ master: Parent container.
129
+ **kwargs: Any native CTkScrollableFrame argument (e.g. `label_text`),
130
+ or a theme-key override (see the "sCTkFrameLabeledPrimary"
131
+ block in sCTkThemes.json, including its disabled_map).
132
+ """
133
+ # 1. Fire our shared theme logic first. This resolves final_kw
134
+ # (construction-time properties) and the disabled color map. See
135
+ # ThemeableWidget.__init__ for what actually happens here.
136
+ # Noted BEFORE the theme logic runs, which merges the theme's own keys
137
+ # into the same dictionary: what matters is whether the CALLER asked
138
+ # for a height, not whether one ends up in final_kw.
139
+ asked_height = kwargs.get("height")
140
+
141
+ ThemeableWidget.__init__(self, kwargs)
142
+
143
+ # 2. Deep-copy the resolved map onto this instance, so later changes
144
+ # here never leak back into the shared theme registry.
145
+ self._local_defaults = dict(self.final_kw)
146
+ self._custom_disabled_map = dict(self._widget_disabled_map)
147
+
148
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
149
+ # 3. Initialize CustomTkinter natively. Only forwards the subset of
150
+ # final_kw that native CTkScrollableFrame actually accepts -- see this
151
+ # class's docstring ("WHITELIST GUARD") for why this filtering exists.
152
+ native_kwargs = {k: v for k, v in self.final_kw.items() if k in self._NATIVE_CTKSCROLLABLEFRAME_KWARGS}
153
+ super().__init__(master, **native_kwargs)
154
+
155
+ self._custom_current_state = "normal"
156
+
157
+ # 4. Hide the scrollbar visually on first construction.
158
+ self._hide_internal_scrollbars()
159
+
160
+ # 5. Register lifecycle handshake hook, notifying Pygubu-style consumers
161
+ # that construction is complete.
162
+ # Sized to its contents unless the caller said otherwise -- see
163
+ # _begin_content_sizing for why this is not the caller's job.
164
+ self._begin_content_sizing(asked_height)
165
+
166
+ self._finalize_themeable_lifecycle()
167
+
168
+ def _set_appearance_mode(self, mode_string: str) -> None:
169
+ """
170
+ Forwards CustomTkinter's internal light/dark mode change notification to
171
+ the native widget.
172
+
173
+ No longer manually re-triggers _update_current_visual_state(). That
174
+ method now passes raw (light, dark) tuples straight through to
175
+ configure() instead of pre-resolving to a single color, so CTk's own
176
+ appearance-mode tracking should repaint correctly on its own -- the
177
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
178
+ button family.
179
+
180
+ Args:
181
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
182
+ by CustomTkinter's internal appearance-mode change machinery.
183
+ """
184
+ if hasattr(super(), "_set_appearance_mode"):
185
+ try:
186
+ super()._set_appearance_mode(mode_string)
187
+ except Exception:
188
+ pass
189
+
190
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
191
+ """
192
+ Standard widget configuration, with Pygubu/positional-argument handling.
193
+
194
+ Args:
195
+ *args: At most one positional argument is meaningful:
196
+ - a dict: merged into kwargs and processed normally below.
197
+ - the literal string "state": returns a Tkinter-style
198
+ (name, name, name, default, current) tuple.
199
+ - one of "fg_color"/"border_color"/"label_text_color":
200
+ returns the same style of tuple, with `current` reflecting
201
+ the disabled or normal value as appropriate. Note the
202
+ returned value is str(value), where value may itself be a
203
+ (light, dark) tuple rather than a single resolved color --
204
+ a known limitation shared with the wider Pygubu-query
205
+ investigation set aside elsewhere in this project, not
206
+ fixed here.
207
+ - anything else: forwarded directly to the native widget's
208
+ configure(), which does not support single-argument property
209
+ queries for arbitrary properties (same limitation).
210
+ **kwargs: Standard CTkScrollableFrame configuration options, plus:
211
+ passing `state=...` routes through self.state() rather than
212
+ being forwarded as-is.
213
+
214
+ Returns:
215
+ The query tuple described above for the single-argument case, or
216
+ whatever super().configure() returns for the keyword-argument case.
217
+ """
218
+ # args is always a tuple -- args[0] is the actual value passed, whether
219
+ # that's a string or a dict. An earlier version of this method compared
220
+ # the wrapped tuple directly (`pname = args`) and forwarded it unwrapped
221
+ # to super().configure(), which is not a valid call shape for the
222
+ # native widget. Don't reintroduce that.
223
+ if len(args) == 1:
224
+ if isinstance(args[0], dict):
225
+ kwargs = {**args[0], **kwargs}
226
+ else:
227
+ pname = args[0]
228
+ if pname == "state":
229
+ return ("state", "state", "state", "normal", str(self.state()))
230
+ if pname in ["fg_color", "border_color", "label_text_color"]:
231
+ current_state = str(self.state()).lower()
232
+ val = self._custom_disabled_map.get(pname) if current_state == "disabled" else self._local_defaults.get(pname)
233
+ return (pname, pname, pname, self._query_value(self._theme_default(pname)), self._query_value(val))
234
+ return self._configure_query(pname)
235
+
236
+ # Runtime overrides have to reach the map a repaint reads, or the
237
+ # repaint puts the theme value straight back -- see
238
+ # ThemeableWidget._record_theme_overrides().
239
+ self._record_theme_overrides(kwargs)
240
+
241
+ if "state" in kwargs:
242
+ target_state = kwargs.pop("state")
243
+ self.state(target_state)
244
+
245
+ self._drop_cleared(kwargs)
246
+
247
+ if kwargs:
248
+ result = super().configure(**kwargs)
249
+ self._hide_internal_scrollbars()
250
+ # FIX (2026-11-11): the title row, its font or the border changed,
251
+ # so the chrome around the content did -- measure it again. It is
252
+ # measured once and kept (see fit_to_content), so without this a
253
+ # title given after the content (as in Pygubu Designer) left the
254
+ # panel a title-row short until the content next changed.
255
+ if any(k in kwargs for k in self._CHROME_OPTIONS):
256
+ self._refit_chrome()
257
+ return result
258
+ return None
259
+
260
+ # Options that change the room around the content -- see _refit_chrome.
261
+ _CHROME_OPTIONS = ("label_text", "label_font", "border_width", "corner_radius")
262
+
263
+ def _refit_chrome(self) -> None:
264
+ """Forgets the measured chrome and fits again, unless the height is fixed."""
265
+ if getattr(self, "_height_is_fixed", True):
266
+ return
267
+ self._fit_chrome = None
268
+ self._fitted_height = None
269
+ self._fit_attempts = 0
270
+ self._ask_to_fit()
271
+
272
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
273
+ # attribute -- it does not automatically track whichever configure() a
274
+ # subclass defines. Without this line, calling .config(...) on an instance
275
+ # would silently skip this entire override and land on the native widget's
276
+ # configure() directly, bypassing theming and state handling entirely.
277
+ config = configure
278
+
279
+
280
+ # ------------------------------------------------------------------
281
+ # Sizing: as tall as its contents, like the ttk.LabelFrame it models
282
+ # ------------------------------------------------------------------
283
+ def _begin_content_sizing(self, explicit_height: Any) -> None:
284
+ """
285
+ Makes the panel as tall as what is in it.
286
+
287
+ WHY THIS EXISTS. This widget is a labeled PANEL -- see the module
288
+ docstring -- but it is built on CTkScrollableFrame, which is sized for
289
+ scrolling: it defaults to 200 points high whatever it holds. A panel
290
+ that stands half empty, or clips its last row, is wrong in a way the
291
+ caller cannot easily put right, because the height has to go on a
292
+ frame the caller never sees.
293
+
294
+ THE THREE WIDGETS. CTkScrollableFrame is an outer frame, a canvas
295
+ inside it, and the inner frame the content goes into -- and the object
296
+ the caller holds is the innermost. So `self.master` is the CANVAS, and
297
+ a height set on the widget or on its master is recorded and then
298
+ overridden when the outer frame resizes to what the canvas asks for.
299
+ `_parent_frame` is the one on screen, and propagation off is what
300
+ makes a height set there stick.
301
+
302
+ An explicit height is honoured as given -- the caller has said what
303
+ they want, and content beyond it is clipped, since this panel does not
304
+ scroll. Without one, the height follows the content.
305
+ """
306
+ self._fitted_height = None
307
+ self._fit_chrome = None
308
+ self._fit_attempts = 0
309
+ self._fitting = False
310
+ self._fit_scheduled = False
311
+ self._height_is_fixed = explicit_height is not None
312
+ outer = getattr(self, "_parent_frame", None)
313
+ if outer is None: # not the structure expected
314
+ return
315
+
316
+ if self._height_is_fixed:
317
+ # Once Tk has worked out the width: _freeze_outer reads it before
318
+ # turning propagation off, and at construction it is not known.
319
+ # explicit_height is in CustomTkinter units already.
320
+ defer(self, "idle", lambda: self._freeze_outer(int(explicit_height), scaled=True))
321
+ return
322
+
323
+ # The content's size changing is the signal to measure again -- but
324
+ # NOT NOW: this fires during the layout that is still happening, and
325
+ # our own resize fires it too. _ask_to_fit defers it to one pass on
326
+ # the next tick.
327
+ self.bind("<Configure>", lambda event: self._ask_to_fit(), add="+")
328
+ self._ask_to_fit()
329
+
330
+ def _ask_to_fit(self) -> None:
331
+ """
332
+ Asks for ONE measuring pass shortly, however many times it is called.
333
+
334
+ Everything about this widget's own resizing arrives in bursts -- a
335
+ row added, the window resized, our own height write coming back as
336
+ another Configure -- and measuring inside any of them reads a
337
+ half-finished layout. Coalescing them into one pass afterwards is
338
+ what makes the answer stable.
339
+ """
340
+ # NOT WHILE MEASURING. Settling the layout and setting the height both
341
+ # raise Configure events of our own making; treating those as new
342
+ # requests means every pass schedules the next one, for ever -- a
343
+ # timer waking up every 50ms doing nothing, which writes no height
344
+ # and so would never have shown on screen.
345
+ if self._height_is_fixed or self._fit_scheduled or getattr(self, "_fitting", False):
346
+ return
347
+ self._fit_scheduled = True
348
+ try:
349
+ # FIX (2026-11-11): THE FIRST PASS AT IDLE, retries on the timer.
350
+ # Pygubu Designer builds a form, lets Tk settle (update_idletasks)
351
+ # and then measures the window once to size its design canvas; a
352
+ # first fit 50 ms later came after that, so the canvas kept the
353
+ # unfitted size and the next widget down was squashed. Idle work
354
+ # is done during that settling. Only the FIRST pass: an idle pass
355
+ # that queued another idle pass spun for ever -- see
356
+ # fit_to_content -- so a retry still waits on the timer.
357
+ if self._fit_attempts == 0:
358
+ defer(self, "idle", self._fit_now)
359
+ else:
360
+ defer(self, _FIT_RETRY_MS, self._fit_now)
361
+ except Exception:
362
+ self._fit_scheduled = False
363
+
364
+ def _fit_now(self) -> None:
365
+ self._fit_scheduled = False
366
+ self.fit_to_content()
367
+
368
+ def fit_to_content(self) -> None:
369
+ """
370
+ Sets the panel's height to what its contents come to.
371
+
372
+ Called automatically as the content changes. Public because content
373
+ added long after construction -- a row appended to a settings group,
374
+ say -- may not change the inner frame's own size in a way Tk reports,
375
+ and then the caller needs a way to say "measure again".
376
+
377
+ Does nothing when a height was given explicitly.
378
+
379
+ THE CHROME IS MEASURED ONCE, NOT CALCULATED. The panel needs room for
380
+ its contents PLUS the title bar, its padding and the border -- about
381
+ 66 points, not the ~34 a first version worked out from the label's own
382
+ height and the border width. That arithmetic looked right and drew
383
+ every panel one row short. The difference between the outer frame and
384
+ the canvas inside it IS that chrome, whatever it is made of.
385
+
386
+ Measured ONCE because measuring it again mid-resize reads a
387
+ half-updated canvas, gives a different answer, and sets the height
388
+ again -- which resizes the canvas, which arrives back here. That
389
+ recursed until Python stopped it.
390
+ """
391
+ if getattr(self, "_height_is_fixed", False) or getattr(self, "_fitting", False):
392
+ return
393
+ outer = getattr(self, "_parent_frame", None)
394
+ canvas = getattr(self, "_parent_canvas", None) or self.master
395
+ if outer is None or canvas is None:
396
+ return
397
+
398
+ self._fitting = True
399
+ try:
400
+ # SETTLE THE LAYOUT FIRST. Called by hand straight after a row is
401
+ # added -- which is what this method is public for -- Tk has not
402
+ # yet recomputed what the content asks for, so measuring now reads
403
+ # the height from before the row. Re-entry is guarded above, so
404
+ # letting Tk catch up here is safe.
405
+ try:
406
+ self.update_idletasks()
407
+ except Exception:
408
+ pass
409
+ content = self.winfo_reqheight()
410
+ if content <= 1: # not laid out yet
411
+ self._retry_fit()
412
+ return
413
+
414
+ if self._fit_chrome is None and not getattr(self, "_outer_frozen", False):
415
+ # FIX (2026-11-11): BEFORE THE HEIGHT IS FROZEN the outer frame
416
+ # still asks for exactly the canvas plus the chrome, so the
417
+ # requested sizes give the chrome without anything being drawn
418
+ # -- which the first pass, at idle, needs. Once frozen, the
419
+ # outer frame asks for what was set, so a re-measure (a new
420
+ # title) reads the drawn heights below instead.
421
+ outer_req = outer.winfo_reqheight()
422
+ canvas_req = canvas.winfo_reqheight()
423
+ if outer_req > 1 and canvas_req > 1 and outer_req > canvas_req:
424
+ self._fit_chrome = outer_req - canvas_req
425
+ if self._fit_chrome is None:
426
+ outer_height = outer.winfo_height()
427
+ canvas_height = canvas.winfo_height()
428
+ if outer_height <= 1 or canvas_height <= 1:
429
+ # NOTHING IS DRAWN YET, so the chrome cannot be measured.
430
+ # Try again shortly -- on a TIMER, not after_idle, and not
431
+ # for ever. after_idle here spun: it queues work for the
432
+ # next idle moment, and queueing it again from inside that
433
+ # work means Tk never becomes idle, so the window is never
434
+ # drawn, so the sizes are never known.
435
+ self._retry_fit()
436
+ return
437
+ self._fit_chrome = max(0, outer_height - canvas_height)
438
+
439
+ wanted = content + self._fit_chrome
440
+ if wanted != self._fitted_height:
441
+ self._fitted_height = wanted
442
+ self._freeze_outer(wanted)
443
+ except Exception:
444
+ pass
445
+ finally:
446
+ self._fitting = False
447
+
448
+ def _freeze_outer(self, height, scaled: bool = False) -> None:
449
+ """
450
+ Gives the outer frame a fixed height, keeping its width.
451
+
452
+ FIX (2026-11-11), the Designer's collapsed labelled frame: a height
453
+ only sticks with propagation off, and propagation off fixes BOTH
454
+ dimensions at the frame's own width/height options. CustomTkinter
455
+ creates the outer frame with width 0 -- it takes its size from the
456
+ canvas inside -- so turning propagation off left it 0 wide. Packed or
457
+ gridded to fill, as in an application, the parent's width hid that;
458
+ packed plainly, as in Pygubu Designer's preview, the panel collapsed
459
+ to a sliver with its content off to one side. So the width it had
460
+ while propagating is read first, ONCE (afterwards it would only read
461
+ back what was set), and set alongside the height.
462
+
463
+ `height` is in pixels as measured, unless `scaled` says it is already
464
+ in CustomTkinter units; CTkFrame.configure() applies the widget
465
+ scaling itself, so pixels are converted back first -- otherwise a
466
+ scaled display (Windows at 150 %) got every fitted height scaled
467
+ twice.
468
+ """
469
+ outer = getattr(self, "_parent_frame", None)
470
+ if outer is None:
471
+ return
472
+ try:
473
+ if not outer.winfo_exists():
474
+ return
475
+ unscale = getattr(outer, "_reverse_widget_scaling", None) or (lambda v: v)
476
+ if getattr(self, "_outer_width", None) is None:
477
+ try:
478
+ outer.update_idletasks()
479
+ except Exception:
480
+ pass
481
+ width_px = max(outer.winfo_reqwidth(), outer.winfo_width())
482
+ self._outer_width = unscale(width_px) if width_px > 1 else None
483
+ settings = {"height": height if scaled else unscale(height)}
484
+ if self._outer_width:
485
+ settings["width"] = self._outer_width
486
+ outer.configure(**settings)
487
+ outer.pack_propagate(False)
488
+ outer.grid_propagate(False)
489
+ self._outer_frozen = True
490
+ except Exception:
491
+ pass
492
+
493
+ def _retry_fit(self) -> None:
494
+ """Waits and measures again -- a limited number of times."""
495
+ self._fit_attempts += 1
496
+ if self._fit_attempts > _FIT_MAX_ATTEMPTS:
497
+ return # hidden, or destroyed
498
+ self._fit_scheduled = False
499
+ self._ask_to_fit()
500
+
501
+ def winfo_children(self, include_private: bool = False) -> list:
502
+ """
503
+ By default, filters out children whose exact class name is "CTkLabel",
504
+ "Label", "CTkFrame", or "Frame" -- internal furniture CTkScrollableFrame
505
+ creates for its own title row and canvas wrapper. See this class's
506
+ docstring for a known limitation of this approach.
507
+
508
+ Args:
509
+ include_private: If True, returns the raw, unfiltered list instead.
510
+
511
+ Returns:
512
+ A list of child widgets.
513
+ """
514
+ raw_children = super().winfo_children()
515
+ if include_private:
516
+ return raw_children
517
+
518
+ filtered_children = []
519
+ for child in raw_children:
520
+ if child.__class__.__name__ not in ["CTkLabel", "Label", "CTkFrame", "Frame"]:
521
+ filtered_children.append(child)
522
+ return filtered_children
523
+
524
+ def get_children(self) -> list:
525
+ """Equivalent to winfo_children(include_private=False)."""
526
+ return self.winfo_children(include_private=False)
527
+
528
+ def get_all_children(self) -> list:
529
+ """Equivalent to winfo_children(include_private=True)."""
530
+ return self.winfo_children(include_private=True)
531
+
532
+ def get_state(self) -> str:
533
+ """Equivalent to calling state() with no argument."""
534
+ return self.state()
535
+
536
+ def state(self, mode: Optional[str] = None) -> str:
537
+ """
538
+ Gets or sets the widget's visual "disabled" state. This is purely
539
+ cosmetic -- see module docstring for why there's no native
540
+ interactivity lock, and why child widgets are not automatically
541
+ disabled along with the frame.
542
+
543
+ Args:
544
+ mode: If None, returns the current state without changing
545
+ anything. Otherwise, only the literal string "disabled"
546
+ (case-insensitive) is treated as disabled; anything in
547
+ ("normal", "enabled", "active") is treated as enabled.
548
+
549
+ Returns:
550
+ The resulting state ("normal" or "disabled").
551
+ """
552
+ if mode is None:
553
+ return getattr(self, "_custom_current_state", "normal")
554
+
555
+ mode = mode.lower()
556
+ if mode in ("normal", "enabled", "active"):
557
+ self._custom_current_state = "normal"
558
+ self._update_current_visual_state()
559
+
560
+ elif mode == "disabled":
561
+ super_payload = {}
562
+ for key in ("fg_color", "border_color", "label_text_color"):
563
+ val = self._custom_disabled_map.get(key)
564
+ if val is not None:
565
+ super_payload[key] = val
566
+
567
+ if super_payload:
568
+ super().configure(**super_payload)
569
+
570
+ self._custom_current_state = "disabled"
571
+ self._hide_internal_scrollbars()
572
+ return self._custom_current_state
573
+
574
+ def _update_current_visual_state(self) -> None:
575
+ """
576
+ Recomputes and applies this widget's colors from the theme file, for
577
+ the normal (enabled) state.
578
+
579
+ Called after construction (indirectly) and on every state("normal")
580
+ call.
581
+
582
+ Passes raw (light, dark) tuples straight through to configure() instead
583
+ of resolving to a single color first, so CTk's native tracking can
584
+ handle appearance-mode repaints without help from _set_appearance_mode.
585
+ """
586
+ config_payload = {}
587
+ for key in ("fg_color", "border_color", "label_text_color", "border_width", "label_font"):
588
+ val = self._local_defaults.get(key)
589
+ if val is not None:
590
+ config_payload[key] = val
591
+
592
+ if config_payload:
593
+ super().configure(**config_payload)
594
+ self._hide_internal_scrollbars()
595
+
596
+ def _hide_internal_scrollbars(self) -> None:
597
+ """
598
+ Forces the internal scrollbar's colors to match the frame's own
599
+ background and collapses its width to 0, making it visually
600
+ invisible.
601
+
602
+ If fg_color resolves to the literal string "transparent" (e.g. if a
603
+ caller explicitly overrides it), falls back to a hardcoded neutral
604
+ pair rather than passing "transparent" through to the scrollbar,
605
+ since there's no single real background color to match in that case.
606
+ This fallback is not theme-driven and is a deliberate exception to
607
+ this project's general "no hardcoded colors" principle -- there's no
608
+ required theme key this could reasonably read from instead, since the
609
+ whole point is handling the case where the real background is
610
+ genuinely ambiguous (transparent).
611
+ """
612
+ try:
613
+ bg_color_raw = super().cget("fg_color")
614
+ resolved_bg = bg_color_raw
615
+
616
+ if bg_color_raw == "transparent":
617
+ current_mode = str(ctk.get_appearance_mode()).lower()
618
+ resolved_bg = "#2B2B2B" if current_mode == "dark" else "#DBDBDB"
619
+
620
+ if hasattr(self, "_scrollbar") and self._scrollbar is not None:
621
+ self._scrollbar.configure(
622
+ fg_color=resolved_bg,
623
+ button_color=resolved_bg,
624
+ button_hover_color=resolved_bg,
625
+ width=0
626
+ )
627
+ except Exception:
628
+ pass
629
+ self._clear_right_border()
630
+
631
+
632
+ def _clear_right_border(self) -> None:
633
+ """
634
+ FIX: keeps the right-hand border visible below the title.
635
+
636
+ CTkScrollableFrame grids its canvas with spacing on the LEFT only --
637
+ padx=(border_spacing, 0) -- and leaves the right to the scrollbar,
638
+ which it grids in the next column with no spacing at all, out to the
639
+ frame's outer edge. Hidden by width 0 and the background colour, the
640
+ scrollbar still filled that strip, and painted over the border from
641
+ the title down: every group's right edge stopped below its title bar,
642
+ the left edge whole, the bottom corner back again.
643
+
644
+ So the scrollbar comes out of the grid, and the canvas gets the same
645
+ spacing on the right as on the left. Called from
646
+ _hide_internal_scrollbars(), which runs after construction and after
647
+ every configure() -- the native configure re-grids both.
648
+ """
649
+ try:
650
+ if getattr(self, "_orientation", "vertical") != "vertical":
651
+ return
652
+ scrollbar = getattr(self, "_scrollbar", None)
653
+ if scrollbar is not None:
654
+ scrollbar.grid_remove()
655
+ frame = getattr(self, "_parent_frame", None)
656
+ canvas = getattr(self, "_parent_canvas", None)
657
+ if frame is None or canvas is None:
658
+ return
659
+ spacing = self._apply_widget_scaling(
660
+ frame.cget("corner_radius") + frame.cget("border_width"))
661
+ canvas.grid_configure(padx=(spacing, spacing))
662
+ except Exception:
663
+ pass
664
+
665
+ def get_container(self):
666
+ """Returns self. Provided for API symmetry with composite widgets that wrap a separate inner container."""
667
+ return self