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