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,795 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ sCTkScrollableFrame
4
+
5
+ A theme-compliant scrollable viewport container frame. Inherits directly from
6
+ ctk.CTkScrollableFrame so CustomTkinter handles native scrolling and layout;
7
+ this class layers automatic light/dark theme resolution and carefully-tuned
8
+ cross-platform mouse wheel / macOS trackpad scroll handling on top.
9
+
10
+ Base class order matters here: `class sCTkScrollableFrame(
11
+ ctk.CTkScrollableFrame, ThemeableWidget)` puts the native CTk class first, so
12
+ every `super()` call in this file's own methods resolves to
13
+ ctk.CTkScrollableFrame -- and, beneath it, tkinter.Misc -- never to
14
+ ThemeableWidget. ThemeableWidget's own configure()/cget()/_set_appearance_mode()
15
+ overrides have been removed entirely for this reason (see
16
+ themeable_widget.py's docstring); this widget owns all of its own runtime
17
+ color-swapping logic.
18
+
19
+ Unlike sCTkFrame (which has no disabled concept at all), this class DOES
20
+ have a disabled state, via configure(state="disabled")/get_state(). That's
21
+ justified here where it isn't for a plain frame: this widget owns real
22
+ behavior to disable, not just colors. Disabling dims the border and the
23
+ scrollbar and stops all scrolling -- wheel, trackpad, and scrollbar drag.
24
+
25
+ It does NOT cascade to child widgets. Disabling children placed inside this
26
+ frame is entirely the caller's responsibility, exactly as with the labeled
27
+ frame variants -- loop over get_children() and configure(state=...) each one.
28
+
29
+ state and scroll_enabled are independent axes; see _scroll_effective(). A
30
+ frame explicitly set non-scrolling stays non-scrolling across a
31
+ disable/enable round trip rather than being switched on by the state change.
32
+
33
+ SCROLL BINDINGS ARE AUTOMATIC. __init__ binds <Map>, so scroll handling
34
+ activates by itself the first time the widget becomes visible via
35
+ pack()/grid()/place(), and again on any later remap. No separate activation
36
+ call is needed.
37
+
38
+ <Map> is used rather than binding directly in __init__ because the binding
39
+ logic inspects the widget's actual parent hierarchy (via winfo_parent()),
40
+ which isn't fully realized at construction time, before the widget has been
41
+ placed into a layout. <Map> is the earliest point at which it is.
42
+
43
+ To ship a frame that doesn't respond to scroll input, pass
44
+ scroll_enabled=False to the constructor, or call disable_scroll() -- either
45
+ one suppresses the automatic activation rather than being silently
46
+ overridden by it. See disable_scroll()'s docstring for the bulk-update
47
+ workflow this supports, and note that it governs scroll INPUT only; the
48
+ scrollbar itself stays visible, but is made inert -- it can't be dragged
49
+ while scrolling is disabled.
50
+
51
+ THE SCROLL-HANDLING METHODS BELOW (_toggle_scroll_bindings,
52
+ _process_mac_touchpad_scroll, _process_scroll_wheel,
53
+ _decode_mac_touchpad_delta) ARE UNCHANGED FROM THE ORIGINAL, VERIFIED BY THE
54
+ MAINTAINER TO BE WORKING, HARD-WON CODE. They implement cross-platform mouse
55
+ wheel handling (Windows delta scaling, Linux Button-4/Button-5 discrete
56
+ events, macOS Darwin-specific delta scaling) plus raw bit-level decoding of
57
+ macOS's high-precision trackpad gesture event (a packed 32-bit delta split
58
+ into signed 16-bit X/Y components). None of this logic was touched during
59
+ this project's audit; only unrelated bugs elsewhere in this file (argument
60
+ handling, dead code, color-tuple resolution) were fixed. Do not modify the
61
+ scroll-handling methods without extensive real-device testing across all
62
+ three platforms.
63
+ """
64
+ import sys
65
+ import platform
66
+ import tkinter as tk
67
+ from typing import Any, Optional
68
+ import customtkinter as ctk
69
+ from .themeable_widget import ThemeableWidget
70
+ from .sctk_scroll_mixin import ScrollBindingMixin
71
+
72
+ class sCTkScrollableFrame(ctk.CTkScrollableFrame, ScrollBindingMixin, ThemeableWidget):
73
+ """Themeable scrollable viewport container.
74
+
75
+ RESOLVED INVESTIGATION -- see _USE_CUSTOM_SCROLL_BINDING below. This
76
+ class inherits directly from ctk.CTkScrollableFrame, so calling
77
+ super().__init__() already triggers native CTk's own scroll-binding
78
+ setup (a global bind_all("<MouseWheel>", ...) combined with a
79
+ _check_if_valid_scroll() walk-up-the-hierarchy check, confirmed directly
80
+ against CustomTkinter's own source). It was suspected this file's own
81
+ additional custom scroll-binding system might be entirely redundant with
82
+ -- and actively competing against -- that already-running native system.
83
+ Confirmed by direct testing NOT to be the case: with this file's custom
84
+ system fully disabled, an external mouse's wheel kept working correctly
85
+ (proving native's inherited handling genuinely works), but the trackpad
86
+ produced zero response at all (not degraded -- completely dead). Trackpad
87
+ gestures on the tested system generate ONLY <TouchpadScroll> events,
88
+ never <MouseWheel> -- these are disjoint event channels for disjoint
89
+ physical inputs, not two systems racing for the same events. This file's
90
+ <TouchpadScroll> handling is the sole channel for trackpad scrolling, not
91
+ a redundant addition alongside native CTk. The real, confirmed bug was
92
+ magnitude loss in _process_mac_touchpad_scroll's scaling -- see that
93
+ method's own docstring.
94
+
95
+ Adds to native ctk.CTkScrollableFrame:
96
+ - Automatic light/dark theme resolution from sCTkThemes.json (via
97
+ ThemeableWidget.__init__ -- see that class's docstring for what it does,
98
+ and just as importantly, what it no longer does).
99
+ - Pygubu Designer property introspection for `fg_color`,
100
+ `label_fg_color`, `scrollbar_button_color`, `border_color`, `state`,
101
+ and `scroll_enabled` via a single-argument configure() call. For the
102
+ color keys the returned tuple's `default` and `current` positions are
103
+ identical; for `state` and `scroll_enabled` they can differ, since
104
+ those two carry live runtime values.
105
+ - A disabled state (see module docstring): dims border and scrollbar
106
+ and stops all scroll input. Does not cascade to children.
107
+ - Manual scrollbar re-theming: `scrollbar_button_color`/
108
+ `scrollbar_button_hover_color` are pushed to the internal scrollbar
109
+ directly, since they aren't automatically covered by a single
110
+ configure() call.
111
+ - winfo_children()/get_children()/get_all_children(): by default,
112
+ winfo_children() filters out children whose class name is exactly
113
+ "CTkScrollbar", "CTkCanvas", or "Canvas" -- internal furniture this
114
+ widget creates for its own scrolling machinery. get_children() is
115
+ this filtered view; get_all_children() returns the raw list. Same
116
+ class-name-based limitation as sCTkFrameLabeledPrimary's identical
117
+ filtering approach: a plain, un-themed widget matching one of these
118
+ exact class names, added directly as a child, would be incorrectly
119
+ filtered out too.
120
+ - Cross-platform scroll wheel and macOS trackpad handling -- see module
121
+ docstring. Wired up automatically on <Map>; no activation call needed.
122
+ - A `scroll_enabled` property (default True), settable at construction
123
+ or at runtime via configure(scroll_enabled=...), enable_scroll(), and
124
+ disable_scroll(), and readable via cget("scroll_enabled"). Governs
125
+ scroll INPUT handling only -- it does not hide the scrollbar.
126
+
127
+ Colors are passed through to configure() as raw (light, dark) tuples rather
128
+ than pre-resolved to a single value, so CustomTkinter's own appearance-mode
129
+ tracking repaints them automatically on a light/dark switch -- the same
130
+ approach validated on sCTkComboBox, sCTkSegmentedButton, and the button
131
+ family. Not separately re-confirmed for this specific widget.
132
+
133
+ WHITELIST GUARD: if a composite widget inherits sCTkScrollableFrame as its
134
+ own base class (e.g. sCTkTableview(sCTkScrollableFrame, ThemeableWidget))
135
+ and explicitly calls ThemeableWidget.__init__ itself before calling
136
+ super().__init__(), that composite's own final_kw -- built from ITS theme
137
+ block -- could contain keys native ctk.CTkScrollableFrame knows nothing
138
+ about. This matters MORE here than for any other container in this
139
+ project: confirmed directly against CustomTkinter's own source,
140
+ CTkScrollableFrame.__init__ has NO **kwargs catch-all at all -- every
141
+ parameter is explicitly named, so ANY unrecognized keyword reaching it
142
+ raises TypeError immediately, at the level of Python's own argument
143
+ binding, before any custom validation even runs.
144
+ _NATIVE_CTKSCROLLABLEFRAME_KWARGS filters final_kw down to only the keys
145
+ the real native constructor accepts before that call. This only matters
146
+ for the Pattern-B composition scenario described above; for direct
147
+ construction of a plain sCTkScrollableFrame, final_kw already only
148
+ contains this widget's own theme keys, so the filter is a no-op.
149
+ """
150
+
151
+ # Confirmed directly against CustomTkinter's own ctk_scrollable_frame.py
152
+ # source: every one of CTkScrollableFrame.__init__'s named parameters,
153
+ # excluding "master" (always passed positionally, never part of the
154
+ # filtered kwargs dict).
155
+ _NATIVE_CTKSCROLLABLEFRAME_KWARGS = frozenset({
156
+ "width", "height", "corner_radius", "border_width", "bg_color",
157
+ "fg_color", "border_color", "scrollbar_fg_color",
158
+ "scrollbar_button_color", "scrollbar_button_hover_color",
159
+ "label_fg_color", "label_text_color", "label_text", "label_font",
160
+ "label_anchor", "orientation",
161
+ })
162
+
163
+ # EXPERIMENTAL TOGGLE, under active investigation -- see class docstring.
164
+ # True (default): preserves current behavior -- calling
165
+ # _finalize_split_bindings() sets up this file's own custom scroll
166
+ # system (_toggle_scroll_bindings/_process_scroll_wheel/
167
+ # _process_mac_touchpad_scroll), IN ADDITION to whatever native
168
+ # ctk.CTkScrollableFrame already set up automatically via inheritance.
169
+ # False: _finalize_split_bindings()/_toggle_scroll_bindings() become
170
+ # no-ops. Scrolling relies ENTIRELY on whatever native CTkScrollableFrame
171
+ # already provides automatically via super().__init__() -- no custom
172
+ # binding of any kind. Untested as of this writing; flip this to False
173
+ # to find out whether the reported erratic touchpad behavior disappears
174
+ # when the custom system isn't also running alongside native's own.
175
+ _USE_CUSTOM_SCROLL_BINDING = True
176
+
177
+ # Theme keys that _update_current_visual_state() re-pushes on every
178
+ # repaint. configure() records these into _local_defaults so a runtime
179
+ # override survives the repaint instead of being reverted by it -- see
180
+ # configure()'s docstring.
181
+ _THEME_TRACKED_KEYS = frozenset({
182
+ "fg_color",
183
+ "border_color",
184
+ "label_fg_color",
185
+ "scrollbar_button_color",
186
+ "scrollbar_button_hover_color",
187
+ })
188
+
189
+ def __init__(self, master: Optional[Any] = None, **kwargs: Any) -> None:
190
+ """
191
+ Args:
192
+ master: Parent container.
193
+ **kwargs: Any native CTkScrollableFrame argument (e.g. `label_text`),
194
+ or a theme-key override (see the "sCTkScrollableFrame" block
195
+ in sCTkThemes.json). Additionally accepts `scroll_enabled`
196
+ (bool, default True) -- pass False to construct a frame that
197
+ doesn't respond to scroll input until enable_scroll() is
198
+ called.
199
+ """
200
+ # 1. Fire our shared theme logic first. This resolves final_kw
201
+ # (construction-time properties). See ThemeableWidget.__init__ for
202
+ # what actually happens here.
203
+ ThemeableWidget.__init__(self, kwargs)
204
+
205
+ # 2. Store the resolved kwargs onto this instance, so later changes
206
+ # here never leak back into the shared theme registry.
207
+ self._local_defaults = dict(self.final_kw)
208
+
209
+ # 2a. Capture the disabled-state color map. Read from
210
+ # self._widget_disabled_map, NOT from final_kw: ThemeableWidget.__init__
211
+ # deliberately excludes "disabled_map" from final_kw, so reading it
212
+ # there yields an empty dict and every disabled lookup silently falls
213
+ # back -- the confirmed bug pattern fixed in sCTkSwitch, sCTkSpinbox,
214
+ # and sCTkTableview elsewhere in this project.
215
+ # hasattr-guarded for the same reason as _state below: sCTkTableview
216
+ # builds this from the same source before calling super().__init__().
217
+ if not hasattr(self, "_custom_disabled_map"):
218
+ self._custom_disabled_map = dict(self._widget_disabled_map)
219
+
220
+ # 2b. Hard-fail on theme gaps rather than substituting a guessed
221
+ # color, matching the principle established across this project.
222
+ # Only the keys that actually change when disabled are required in
223
+ # disabled_map; fg_color deliberately is NOT among them (the content
224
+ # background stays put when disabled -- only the border and the now-
225
+ # inert scrollbar dim).
226
+ #
227
+ # SCOPED TO DIRECT CONSTRUCTION ONLY. A subclass that inherits this
228
+ # class (e.g. sCTkTableview) reaches here with self.final_kw built
229
+ # from ITS OWN theme block, not this one -- ThemeableWidget's
230
+ # run-once guard means the parent's __init__ never rebuilds it. So
231
+ # validating this widget's theme keys against a subclass's theme
232
+ # block would demand scrollbar colors from, say, the sCTkTableview
233
+ # block, and raise KeyError on every construction. Subclasses own
234
+ # their own theme contract and validate it themselves (sCTkTableview
235
+ # does exactly this); this check is for the concrete class only.
236
+ if type(self) is sCTkScrollableFrame:
237
+ for required_key in ("border_color", "scrollbar_button_color",
238
+ "scrollbar_button_hover_color"):
239
+ if self._local_defaults.get(required_key) is None:
240
+ raise KeyError(
241
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing "
242
+ f"'{required_key}' at the top level of sCTkThemes.json."
243
+ )
244
+ if self._custom_disabled_map.get(required_key) is None:
245
+ raise KeyError(
246
+ f"'{(getattr(self, '_THEME_BLOCK_NAME', None) or self.__class__.__name__)}' theme block is missing "
247
+ f"'{required_key}' in disabled_map."
248
+ )
249
+
250
+ # 2c. State and scroll intent. Both must exist before step 4's
251
+ # _update_current_visual_state() call, which reads them.
252
+ #
253
+ # These are two INDEPENDENT axes, deliberately not collapsed into one.
254
+ # _state is the user-facing enabled/disabled presentation;
255
+ # _scroll_enabled is the developer's own intent about whether this
256
+ # frame should scroll at all. Effective scrolling is the AND of the
257
+ # two -- see _scroll_effective(). Because state changes never write
258
+ # to _scroll_enabled, a frame that was constructed or configured
259
+ # non-scrolling stays non-scrolling after a disable/enable round
260
+ # trip, instead of being silently switched on by the state change.
261
+ # A subclass may legitimately establish either of these before calling
262
+ # super().__init__() -- sCTkTableview sets self._state from its own
263
+ # constructor argument, and would otherwise have it silently reset to
264
+ # the default here. hasattr-guarded so the subclass's value wins.
265
+ if not hasattr(self, "_state"):
266
+ self._state = str(self._local_defaults.get("state", "normal"))
267
+ if not hasattr(self, "_scroll_enabled"):
268
+ self._scroll_enabled = bool(self._local_defaults.get("scroll_enabled", True))
269
+
270
+ # 3. Initialize CustomTkinter natively with the clean final kwargs array.
271
+ # 3. Initialize CustomTkinter natively. Only forwards the subset of
272
+ # final_kw that native CTkScrollableFrame actually accepts -- see this
273
+ # class's docstring ("WHITELIST GUARD") for why this filtering exists,
274
+ # and why it's especially important for this specific native class.
275
+ native_kwargs = {k: v for k, v in self.final_kw.items() if k in self._NATIVE_CTKSCROLLABLEFRAME_KWARGS}
276
+ super().__init__(master, **native_kwargs)
277
+
278
+ # 4. Apply initial theming.
279
+ self._update_current_visual_state()
280
+
281
+ # Touchpad accumulator state, owned by ScrollBindingMixin. Must run
282
+ # before any binding happens.
283
+ self._init_scroll_state()
284
+
285
+ # 5. Scroll activation. Bindings come up automatically -- no caller
286
+ # action required. An earlier version needed a manual
287
+ # _finalize_split_bindings() call after placement; a scrollable frame
288
+ # that doesn't scroll by default is a confusing thing to ship, given
289
+ # what the widget's own name promises.
290
+ #
291
+ # _state and _scroll_enabled were both established in step 2c, since
292
+ # step 4 above needs them.
293
+ #
294
+ # ScrollBindingMixin installs all four activation mechanisms -- <Map>
295
+ # on this widget, <Map> on _parent_frame, after_idle(), and the
296
+ # debounced <Configure> content rebind -- and documents why each is
297
+ # needed. _parent_frame is passed because it is the widget the
298
+ # geometry manager actually sees; this widget itself is a canvas-window
299
+ # child and may never receive <Map> at all.
300
+ self._install_scroll_activation(
301
+ extra_map_widget=getattr(self, "_parent_frame", None))
302
+
303
+ # ------------------------------------------------------------------
304
+ # ScrollBindingMixin contract
305
+ # ------------------------------------------------------------------
306
+ def _scroll_target(self):
307
+ """
308
+ The widget to scroll: this frame's parent canvas, resolved through the
309
+ Tk widget path rather than a CustomTkinter private attribute.
310
+
311
+ Returns:
312
+ The canvas, or None if the hierarchy isn't realized yet.
313
+ """
314
+ try:
315
+ parent_widget = self.nametowidget(self.winfo_parent())
316
+ if parent_widget and parent_widget.__class__.__name__ == "Canvas":
317
+ return parent_widget
318
+ except Exception:
319
+ pass
320
+ return None
321
+
322
+ def _scroll_layers(self):
323
+ """
324
+ Every widget that should respond to a scroll event over this frame:
325
+ the frame itself, its parent canvas and that canvas's own parent, the
326
+ internal scrollbar, and the full content tree.
327
+
328
+ The content walk stops at nested CTkScrollableFrame boundaries -- see
329
+ ScrollBindingMixin._collect_scroll_descendants().
330
+
331
+ Returns:
332
+ An ordered, deduplicated list of widgets.
333
+ """
334
+ layers = [self]
335
+
336
+ canvas = self._scroll_target()
337
+ if canvas is not None:
338
+ layers.append(canvas)
339
+ try:
340
+ grandparent = self.nametowidget(canvas.winfo_parent())
341
+ if grandparent:
342
+ layers.append(grandparent)
343
+ except Exception:
344
+ pass
345
+
346
+ # The scrollbar is a SIBLING of the canvas, not a descendant of this
347
+ # frame, so the content walk below would never reach it -- it has to
348
+ # be added explicitly or the wheel does nothing while the pointer is
349
+ # over the scrollbar itself.
350
+ if getattr(self, "_scrollbar", None) is not None:
351
+ self._collect_scroll_descendants(self._scrollbar, layers)
352
+
353
+ self._collect_scroll_descendants(self, layers)
354
+ return layers
355
+
356
+ def _scroll_permitted(self) -> bool:
357
+ """The AND of scroll_enabled and state. See _scroll_effective()."""
358
+ return self._scroll_effective()
359
+
360
+ def _scroll_drag_targets(self):
361
+ """The internal scrollbar, whose dragging is blocked when disabled."""
362
+ bar = getattr(self, "_scrollbar", None)
363
+ return [bar] if bar is not None else []
364
+
365
+
366
+ def _scroll_effective(self) -> bool:
367
+ """
368
+ Whether this widget should actually respond to scroll input right now.
369
+
370
+ The AND of the two independent axes described in __init__ step 2c:
371
+
372
+ scroll_enabled | state | scrolls?
373
+ ---------------+-----------+---------
374
+ True | normal | yes
375
+ True | disabled | no
376
+ False | normal | no
377
+ False | disabled | no
378
+
379
+ Every place that binds or unbinds routes through here rather than
380
+ reading either flag directly, so the two can't drift apart.
381
+
382
+ Returns:
383
+ True only if scrolling is both wanted and permitted.
384
+ """
385
+ return self._scroll_enabled and self._state == "normal"
386
+
387
+ def is_scrolling(self) -> bool:
388
+ """
389
+ Whether this widget currently responds to scroll input.
390
+
391
+ Distinct from cget("scroll_enabled"), which reports stored INTENT.
392
+ A frame with scroll_enabled=True that has been disabled via
393
+ configure(state="disabled") reports True from cget and False from
394
+ here -- that difference is the point: the intent survives the state
395
+ change so it can be restored when the state goes back to normal.
396
+
397
+ Returns:
398
+ The live effective scroll state.
399
+ """
400
+ return self._scroll_effective()
401
+
402
+ def get_state(self) -> str:
403
+ """
404
+ Returns the current state, "normal" or "disabled". Mirrors the same
405
+ accessor on sCTkFrameLabeledPrimary/Secondary.
406
+ """
407
+ return self._state
408
+
409
+ def enable_scroll(self) -> None:
410
+ """
411
+ Turns scroll handling back on, rebinding every layer immediately.
412
+
413
+ Public convenience wrapper over configure(scroll_enabled=True); both
414
+ are equivalent and go through the same code path. Safe to call
415
+ repeatedly -- bindings are always torn down before being rebuilt, so
416
+ nothing accumulates.
417
+ """
418
+ self.configure(scroll_enabled=True)
419
+
420
+ def disable_scroll(self) -> None:
421
+ """
422
+ Turns scroll handling off, removing every scroll binding this widget
423
+ knows about. The widget stops responding to wheel and trackpad input
424
+ until enable_scroll() is called.
425
+
426
+ Public convenience wrapper over configure(scroll_enabled=False); both
427
+ are equivalent and go through the same code path.
428
+
429
+ This also suppresses the automatic <Map>-triggered binding: calling
430
+ this before the widget is first placed means scroll stays off through
431
+ pack()/grid()/place() rather than being silently re-enabled. That
432
+ makes the following a supported pattern for bulk content updates,
433
+ where rebinding on every widget added would be wasted work:
434
+
435
+ frame = sCTkScrollableFrame(master)
436
+ frame.disable_scroll()
437
+ frame.pack(fill="both", expand=True)
438
+ for item in many_items:
439
+ SomeWidget(frame, text=item).pack()
440
+ frame.enable_scroll()
441
+
442
+ Passing scroll_enabled=False to the constructor achieves the same
443
+ starting state without the separate call.
444
+
445
+ Blocks native CTk's own global mouse-wheel handling as well as this
446
+ file's custom bindings, and blocks click-and-drag on the internal
447
+ scrollbar -- see _toggle_scroll_bindings(bind=False) and
448
+ ScrollBindingMixin._set_scroll_drag_blocked() for the two mechanisms.
449
+
450
+ NOTE: the scrollbar remains VISIBLE, just inert -- it can't be
451
+ dragged, but it isn't hidden. CustomTkinter's scrollbar has no
452
+ disabled state to lock (confirmed by direct investigation earlier in
453
+ this project's audit), so there's no greyed-out appearance to switch
454
+ to either. Hiding it entirely is a separate concern, handled
455
+ elsewhere in this project by color-matching and zero width.
456
+ """
457
+ self.configure(scroll_enabled=False)
458
+
459
+ def _set_appearance_mode(self, mode_string: str) -> None:
460
+ """
461
+ Forwards CustomTkinter's internal light/dark mode change notification to
462
+ the native widget.
463
+
464
+ No longer manually re-triggers _update_current_visual_state(). That
465
+ method now passes raw (light, dark) tuples straight through to
466
+ configure() instead of pre-resolving to a single color, so CTk's own
467
+ appearance-mode tracking should repaint correctly on its own -- the
468
+ same approach validated on sCTkComboBox, sCTkSegmentedButton, and the
469
+ button family.
470
+
471
+ Args:
472
+ mode_string: The new appearance mode ("Light" or "Dark"), as passed
473
+ by CustomTkinter's internal appearance-mode change machinery.
474
+ """
475
+ if hasattr(super(), "_set_appearance_mode"):
476
+ try:
477
+ super()._set_appearance_mode(mode_string)
478
+ except Exception:
479
+ pass
480
+
481
+ def configure(self, *args: Any, **kwargs: Any) -> Any:
482
+ """
483
+ Standard widget configuration, with Pygubu/positional-argument handling.
484
+
485
+ Args:
486
+ *args: At most one positional argument is meaningful:
487
+ - a dict: merged into kwargs and processed normally below.
488
+ - one of "fg_color"/"label_fg_color"/"scrollbar_button_color"/
489
+ "border_color": returns a Tkinter-style
490
+ (name, name, name, default, current) tuple. `default` and
491
+ `current` are always identical, since this widget has no
492
+ disabled state to vary them.
493
+ - anything else: forwarded directly to the native widget's
494
+ configure(), which does not support single-argument property
495
+ queries for arbitrary properties -- a known limitation
496
+ shared with the wider Pygubu-query investigation set aside
497
+ elsewhere in this project.
498
+ **kwargs: Standard CTkScrollableFrame configuration options.
499
+
500
+ Returns:
501
+ The query tuple described above for the single-argument case, or
502
+ None for the keyword-argument case (this method doesn't return
503
+ super().configure()'s result in that branch).
504
+ """
505
+ # args is always a tuple -- args[0] is the actual value passed, whether
506
+ # that's a string or a dict. An earlier version of this method compared
507
+ # the wrapped tuple directly (`pname = args`), so the query branches
508
+ # below never matched anything. Don't reintroduce that.
509
+ if len(args) == 1:
510
+ if isinstance(args[0], dict):
511
+ kwargs = {**args[0], **kwargs}
512
+ else:
513
+ pname = args[0]
514
+ if pname in ["fg_color", "label_fg_color", "label_text_color",
515
+ "scrollbar_button_color", "border_color"]:
516
+ # FIX: the default came straight from the theme with no
517
+ # fallback, so a key the block does not define reported
518
+ # None -- and pygubu handed that None back to the widget:
519
+ #
520
+ # color is None, for transparency set color='transparent'
521
+ #
522
+ # Falling back to the current value makes clearing such a
523
+ # field a no-op rather than an error. label_text_color is
524
+ # in the list because this widget applies it; it was
525
+ # missing, so clearing it fell through to a branch that did
526
+ # not know about it and returned None.
527
+ theme_value = self._theme_default(pname)
528
+ current = self._local_defaults.get(pname)
529
+ if theme_value is None:
530
+ theme_value = current
531
+ return (pname, pname, pname,
532
+ self._query_value(theme_value),
533
+ self._query_value(current))
534
+ if pname == "state":
535
+ return (pname, pname, pname,
536
+ str(self._local_defaults.get("state", "normal")),
537
+ str(self._state))
538
+ if pname == "scroll_enabled":
539
+ # Unlike the color properties above, this one reports live
540
+ # state in the `current` position: `default` is the value
541
+ # the widget was constructed with, `current` is whatever
542
+ # enable_scroll()/disable_scroll() last set.
543
+ return (pname, pname, pname,
544
+ str(self._local_defaults.get("scroll_enabled", True)),
545
+ str(self._scroll_enabled))
546
+ # FIX: do NOT forward pname to native configure(). Unlike
547
+ # CTkFrame -- whose signature is configure(require_redraw=False,
548
+ # **kwargs) and so silently swallows a positional --
549
+ # CTkScrollableFrame.configure() is declared configure(self,
550
+ # **kwargs) and accepts NO positional argument at all. Passing
551
+ # one raised "CTkScrollableFrame.configure() takes 1 positional
552
+ # argument but 2 were given".
553
+ #
554
+ # This surfaced through Pygubu Designer: blanking a property in
555
+ # the inspector makes it call widget.configure(pname) to
556
+ # discover that property's default value, which reaches this
557
+ # branch for any name not handled above.
558
+ #
559
+ # A Tkinter-style 5-tuple is returned instead, built from
560
+ # cget() so the reported value is real. Returning None is not an
561
+ # option -- callers index into the tuple.
562
+ try:
563
+ current = self.cget(pname)
564
+ except Exception:
565
+ current = None
566
+ return (pname, pname, pname, current, current)
567
+
568
+ # scroll_enabled is this library's own property, not a native CTk one.
569
+ # It MUST be popped before the super() call below: CTkScrollableFrame
570
+ # .configure() raises on any keyword it doesn't recognize, exactly as
571
+ # its __init__ does (see the WHITELIST GUARD note in this class's
572
+ # docstring).
573
+ #
574
+ # Deliberately does NOT write back into self._local_defaults -- that
575
+ # dict holds construction-time defaults, and overwriting it here would
576
+ # make the single-argument query above report the current value in its
577
+ # `default` slot, collapsing the distinction between the two.
578
+ if "scroll_enabled" in kwargs:
579
+ new_state = bool(kwargs.pop("scroll_enabled"))
580
+ self._scroll_enabled = new_state
581
+ # Apply immediately rather than waiting for the next <Map>:
582
+ # enable_scroll() on an already-visible widget has to take effect
583
+ # now, and disable_scroll() has to tear bindings down now.
584
+ # _toggle_scroll_bindings() is idempotent either way. Routed
585
+ # through _scroll_effective() so enable_scroll() on a widget
586
+ # that's currently state="disabled" records the intent without
587
+ # actually re-enabling scrolling behind the disabled presentation.
588
+ self._toggle_scroll_bindings(bind=self._scroll_effective())
589
+
590
+ # state is likewise this library's own property, not a native CTk one,
591
+ # and must be popped before the super() call for the same reason.
592
+ # Runtime overrides have to reach the map a repaint reads, or the
593
+ # repaint puts the theme value straight back -- see
594
+ # ThemeableWidget._record_theme_overrides().
595
+ self._record_theme_overrides(kwargs)
596
+
597
+ if "state" in kwargs:
598
+ self._state = str(kwargs.pop("state"))
599
+ self._toggle_scroll_bindings(bind=self._scroll_effective())
600
+ self._update_current_visual_state()
601
+
602
+ # FIX: record theme overrides BEFORE the repaint below.
603
+ #
604
+ # _update_current_visual_state() re-pushes every tracked color from
605
+ # self._local_defaults. Since configure() never wrote to that dict,
606
+ # the sequence was: super().configure(fg_color="red") turns the widget
607
+ # red, then the repaint immediately overwrites it with the theme value
608
+ # -- so runtime color overrides silently did nothing.
609
+ #
610
+ # The repaint isn't wrong to exist (appearance-mode switches and state
611
+ # changes both need it); it was wrong to treat _local_defaults as
612
+ # authoritative when configure() had just introduced a newer value it
613
+ # never recorded. Writing the override in first makes the repaint
614
+ # reproduce it rather than revert it, and makes the override survive
615
+ # later appearance-mode and state changes -- matching CustomTkinter's
616
+ # own semantics, where configure(fg_color=...) sticks.
617
+ #
618
+ # Note this stores exactly what the caller passed: a single color
619
+ # replaces the theme's (light, dark) tuple for that key, so appearance
620
+ # tracking for it is intentionally given up. That's what asking for
621
+ # one specific color means.
622
+ #
623
+ # disabled_map still wins while disabled -- an override here sets the
624
+ # NORMAL-state color. See _update_current_visual_state()'s themed().
625
+ # The one widget that had NO empty-string rule at all.
626
+ #
627
+ # Every other configure() in the library drops a cleared theme value;
628
+ # this one forwarded it -- to the native widget, and into
629
+ # _local_defaults below, where the next repaint reapplied it. A
630
+ # Designer clear on a colour therefore reached CTkScrollableFrame as
631
+ # fg_color="". Called BEFORE the loop so a cleared value is never
632
+ # stored. label_text="" is kept, as a caption rather than a colour --
633
+ # see ThemeableWidget._CONTENT_KEYS.
634
+ self._drop_cleared(kwargs)
635
+
636
+ for key in self._THEME_TRACKED_KEYS:
637
+ if key in kwargs:
638
+ self._local_defaults[key] = kwargs[key]
639
+
640
+ # Re-checked after the pops above: configure(scroll_enabled=...) or
641
+ # configure(state=...) on its own leaves kwargs empty, and there's no
642
+ # reason to call through to the native widget for it.
643
+ if kwargs:
644
+ super().configure(**kwargs)
645
+ self._update_current_visual_state()
646
+
647
+ # Tkinter/CTk convention binds .config to .configure as a SEPARATE class
648
+ # attribute -- it does not automatically track whichever configure() a
649
+ # subclass defines. An earlier version of this file was MISSING this line
650
+ # entirely: calling .config(...) on an instance silently skipped this
651
+ # entire override and landed on the native widget's configure() directly,
652
+ # bypassing theming completely -- confirmed as a critical bug on
653
+ # sCTkSegmentedButton earlier in this project's audit; the same fix
654
+ # applies here.
655
+ config = configure
656
+
657
+ def cget(self, attribute_name: str) -> Any:
658
+ """
659
+ Standard property accessor, extended to know about `scroll_enabled`.
660
+
661
+ Native CTkScrollableFrame.cget() raises on any attribute it doesn't
662
+ recognize, so this library's own property has to be intercepted here
663
+ before delegating; everything else passes straight through unchanged.
664
+
665
+ Args:
666
+ attribute_name: The property to read.
667
+
668
+ Returns:
669
+ For "scroll_enabled", the live bool -- not the construction-time
670
+ default. For anything else, whatever the native widget returns.
671
+ """
672
+ if attribute_name == "scroll_enabled":
673
+ return self._scroll_enabled
674
+ if attribute_name == "state":
675
+ return self._state
676
+ return super().cget(attribute_name)
677
+
678
+ def _update_current_visual_state(self) -> None:
679
+ """
680
+ Recomputes and applies this widget's colors from the theme file.
681
+
682
+ Called after construction and on every keyword configure() call.
683
+
684
+ Passes raw (light, dark) tuples straight through to configure() instead
685
+ of resolving to a single color first, so CTk's native tracking can
686
+ handle appearance-mode repaints without help from _set_appearance_mode.
687
+ Every value here traces back to sCTkThemes.json; there are no
688
+ hardcoded colors in this method. The .get() calls carry no fallback
689
+ because __init__ already hard-failed on any missing key.
690
+ """
691
+ is_disabled = self._state == "disabled"
692
+
693
+ def themed(key: str) -> Any:
694
+ """Disabled-state value when disabled and one exists, else normal."""
695
+ if is_disabled and self._custom_disabled_map.get(key) is not None:
696
+ return self._custom_disabled_map[key]
697
+ return self._local_defaults.get(key)
698
+
699
+ config_payload = {}
700
+ # label_text_color included: this widget can carry a label, and a
701
+ # disabled panel whose heading stays at full contrast reads as enabled.
702
+ # The two labelled-frame variants already dim theirs; this one did not,
703
+ # so its disabled_map.label_text_color had no effect.
704
+ for key in ("fg_color", "border_color", "label_fg_color",
705
+ "label_text_color"):
706
+ val = themed(key)
707
+ if val is not None:
708
+ config_payload[key] = val
709
+
710
+ if config_payload:
711
+ super().configure(**config_payload)
712
+
713
+ # Re-theme the internal scrollbar, which isn't automatically covered
714
+ # by the configure() call above.
715
+ if hasattr(self, "_scrollbar") and self._scrollbar:
716
+ try:
717
+ normal_bar = themed("scrollbar_button_color")
718
+ # When disabled the scrollbar is inert (drag is blocked), so
719
+ # it must not light up on hover either -- a bright hover would
720
+ # falsely advertise it as draggable. Read from disabled_map
721
+ # like any other dimmed color; themed() falls back to the
722
+ # normal hover color automatically when not disabled.
723
+ normal_hover = themed("scrollbar_button_hover_color")
724
+ self._scrollbar.configure(button_color=normal_bar, button_hover_color=normal_hover)
725
+
726
+ if hasattr(self._scrollbar, "_draw"):
727
+ self._scrollbar._draw()
728
+ except Exception:
729
+ pass
730
+
731
+ # =========================================================================
732
+ # SCROLL-HANDLING METHODS BELOW: UNCHANGED FROM THE ORIGINAL. See module
733
+ # docstring. Do not modify without extensive real-device testing.
734
+ # =========================================================================
735
+
736
+
737
+ def _finalize_split_bindings(self):
738
+ """
739
+ Standard layout binding connection pass [1.1].
740
+
741
+ RETAINED FOR COMPATIBILITY. Calling this after placing the widget was
742
+ once mandatory; it no longer is, since __init__ binds <Map> and
743
+ activates scrolling automatically. Existing callers that still invoke
744
+ it are harmless -- the underlying toggle is idempotent -- but new code
745
+ shouldn't need it. Composite widgets in this project that rebuild
746
+ their content dynamically (sCTkFileExplorer, sCTkTableview) do still
747
+ call it deliberately, to bind rows created after the initial <Map>.
748
+
749
+ EXPERIMENTAL: the authoritative _USE_CUSTOM_SCROLL_BINDING gate now
750
+ lives inside _toggle_scroll_bindings() itself (see that method's
751
+ docstring for why), so it's enforced regardless of entry point. The
752
+ check here is just an early-exit optimization, not the real gate.
753
+ """
754
+ if not self._USE_CUSTOM_SCROLL_BINDING:
755
+ return
756
+ # Routed through _scroll_effective() rather than forcing bind=True:
757
+ # composite widgets in this project call this after rebuilding their
758
+ # content, and a rebuild must not silently re-enable scrolling on a
759
+ # frame that is disabled or was explicitly set non-scrolling.
760
+ self._toggle_scroll_bindings(bind=self._scroll_effective())
761
+
762
+ # =========================================================================
763
+ # END UNCHANGED SCROLL-HANDLING METHODS
764
+ # =========================================================================
765
+
766
+ def winfo_children(self, include_private: bool = False) -> list:
767
+ """
768
+ By default, filters out children whose exact class name is
769
+ "CTkScrollbar", "CTkCanvas", or "Canvas" -- internal furniture this
770
+ widget creates for its own scrolling machinery. See this class's
771
+ docstring for a known limitation of this approach.
772
+
773
+ Args:
774
+ include_private: If True, returns the raw, unfiltered list instead.
775
+
776
+ Returns:
777
+ A list of child widgets.
778
+ """
779
+ raw_children = super().winfo_children()
780
+ if include_private:
781
+ return raw_children
782
+
783
+ filtered_children = []
784
+ for child in raw_children:
785
+ if child.__class__.__name__ not in ["CTkScrollbar", "CTkCanvas", "Canvas"]:
786
+ filtered_children.append(child)
787
+ return filtered_children
788
+
789
+ def get_children(self) -> list:
790
+ """Equivalent to winfo_children(include_private=False)."""
791
+ return self.winfo_children(include_private=False)
792
+
793
+ def get_all_children(self) -> list:
794
+ """Equivalent to winfo_children(include_private=True)."""
795
+ return self.winfo_children(include_private=True)