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.
- scustomtkinter/__init__.py +81 -0
- scustomtkinter/assets/images/error.png +0 -0
- scustomtkinter/assets/images/info.png +0 -0
- scustomtkinter/assets/images/warning.png +0 -0
- scustomtkinter/assets/sCTkThemes.json +1963 -0
- scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter/sctk_button_primary.py +366 -0
- scustomtkinter/sctk_button_secondary.py +354 -0
- scustomtkinter/sctk_button_tertiary.py +375 -0
- scustomtkinter/sctk_checkbox.py +270 -0
- scustomtkinter/sctk_combobox.py +155 -0
- scustomtkinter/sctk_core.py +15 -0
- scustomtkinter/sctk_dial.py +1472 -0
- scustomtkinter/sctk_dialog.py +701 -0
- scustomtkinter/sctk_dialog_toplevel.py +268 -0
- scustomtkinter/sctk_entry_primary.py +400 -0
- scustomtkinter/sctk_entry_secondary.py +342 -0
- scustomtkinter/sctk_file_explorer.py +879 -0
- scustomtkinter/sctk_frame.py +210 -0
- scustomtkinter/sctk_frame_labeled_primary.py +667 -0
- scustomtkinter/sctk_frame_labeled_secondary.py +598 -0
- scustomtkinter/sctk_frequency_display.py +636 -0
- scustomtkinter/sctk_label_primary.py +283 -0
- scustomtkinter/sctk_label_secondary.py +275 -0
- scustomtkinter/sctk_label_tertiary.py +278 -0
- scustomtkinter/sctk_messagebox.py +275 -0
- scustomtkinter/sctk_notebook.py +1103 -0
- scustomtkinter/sctk_optionmenu_border_mixin.py +203 -0
- scustomtkinter/sctk_optionmenu_primary.py +378 -0
- scustomtkinter/sctk_optionmenu_secondary.py +346 -0
- scustomtkinter/sctk_path_chooser.py +516 -0
- scustomtkinter/sctk_progress_bar.py +274 -0
- scustomtkinter/sctk_radiobutton.py +360 -0
- scustomtkinter/sctk_scroll_mixin.py +546 -0
- scustomtkinter/sctk_scrollable_frame.py +795 -0
- scustomtkinter/sctk_scrollbar.py +313 -0
- scustomtkinter/sctk_segmentedbutton.py +424 -0
- scustomtkinter/sctk_selector.py +581 -0
- scustomtkinter/sctk_separator.py +308 -0
- scustomtkinter/sctk_slider.py +264 -0
- scustomtkinter/sctk_smeter.py +405 -0
- scustomtkinter/sctk_smeter_bar.py +557 -0
- scustomtkinter/sctk_spinbox.py +664 -0
- scustomtkinter/sctk_switch.py +463 -0
- scustomtkinter/sctk_tableview.py +869 -0
- scustomtkinter/sctk_tabview.py +409 -0
- scustomtkinter/sctk_textbox_primary.py +291 -0
- scustomtkinter/sctk_textbox_secondary.py +225 -0
- scustomtkinter/sctk_toplevel.py +153 -0
- scustomtkinter/themeable_widget.py +938 -0
- scustomtkinter-0.2.1.dist-info/METADATA +336 -0
- scustomtkinter-0.2.1.dist-info/RECORD +101 -0
- scustomtkinter-0.2.1.dist-info/WHEEL +5 -0
- scustomtkinter-0.2.1.dist-info/entry_points.txt +2 -0
- scustomtkinter-0.2.1.dist-info/licenses/LICENSE +21 -0
- scustomtkinter-0.2.1.dist-info/licenses/scustomtkinter/license_morecustomtkinterwidgets.txt +22 -0
- scustomtkinter-0.2.1.dist-info/top_level.txt +2 -0
- scustomtkinter_pygubu/__init__.py +1 -0
- scustomtkinter_pygubu/designer/__init__.py +0 -0
- scustomtkinter_pygubu/designer/plugin.py +1854 -0
- scustomtkinter_pygubu/designer/properties.py +247 -0
- scustomtkinter_pygubu/sCTkButtonPrimarybo.py +46 -0
- scustomtkinter_pygubu/sCTkButtonSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkButtonTertiarybo.py +44 -0
- scustomtkinter_pygubu/sCTkCheckBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkComboBoxbo.py +45 -0
- scustomtkinter_pygubu/sCTkCorebo.py +120 -0
- scustomtkinter_pygubu/sCTkDialbo.py +371 -0
- scustomtkinter_pygubu/sCTkDialogbo.py +397 -0
- scustomtkinter_pygubu/sCTkEntryPrimarybo.py +100 -0
- scustomtkinter_pygubu/sCTkEntrySecondarybo.py +98 -0
- scustomtkinter_pygubu/sCTkFileExplorerbo.py +189 -0
- scustomtkinter_pygubu/sCTkFrameLabeledPrimarybo.py +69 -0
- scustomtkinter_pygubu/sCTkFrameLabeledSecondarybo.py +71 -0
- scustomtkinter_pygubu/sCTkFramebo.py +46 -0
- scustomtkinter_pygubu/sCTkFrequencyDisplaybo.py +175 -0
- scustomtkinter_pygubu/sCTkLabelPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkLabelSecondarybo.py +44 -0
- scustomtkinter_pygubu/sCTkLabelTertiarybo.py +45 -0
- scustomtkinter_pygubu/sCTkNotebookbo.py +392 -0
- scustomtkinter_pygubu/sCTkOptionMenuPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkOptionMenuSecondarybo.py +39 -0
- scustomtkinter_pygubu/sCTkPathChooserbo.py +204 -0
- scustomtkinter_pygubu/sCTkProgressBarbo.py +45 -0
- scustomtkinter_pygubu/sCTkRadioButtonbo.py +45 -0
- scustomtkinter_pygubu/sCTkSMeterBarbo.py +153 -0
- scustomtkinter_pygubu/sCTkSMeterbo.py +101 -0
- scustomtkinter_pygubu/sCTkScrollableFramebo.py +66 -0
- scustomtkinter_pygubu/sCTkScrollbarbo.py +48 -0
- scustomtkinter_pygubu/sCTkSegmentedButtonbo.py +47 -0
- scustomtkinter_pygubu/sCTkSelectorbo.py +167 -0
- scustomtkinter_pygubu/sCTkSeparatorbo.py +141 -0
- scustomtkinter_pygubu/sCTkSliderbo.py +45 -0
- scustomtkinter_pygubu/sCTkSpinboxbo.py +200 -0
- scustomtkinter_pygubu/sCTkSwitchbo.py +45 -0
- scustomtkinter_pygubu/sCTkTableviewbo.py +188 -0
- scustomtkinter_pygubu/sCTkTabviewbo.py +226 -0
- scustomtkinter_pygubu/sCTkTextboxPrimarybo.py +45 -0
- scustomtkinter_pygubu/sCTkTextboxSecondarybo.py +45 -0
- scustomtkinter_pygubu/sCTkToplevelbo.py +55 -0
- 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
|