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,546 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""
|
|
3
|
+
ScrollBindingMixin
|
|
4
|
+
|
|
5
|
+
The single, shared implementation of cross-platform mouse wheel and macOS
|
|
6
|
+
trackpad scroll handling for this library.
|
|
7
|
+
|
|
8
|
+
WHY THIS EXISTS. This logic previously existed as three independent copies --
|
|
9
|
+
in sCTkScrollableFrame, in sCTkFileExplorer, and in sCTkScrollArea -- each
|
|
10
|
+
adapted by hand from the first. They drifted, as duplicated code does:
|
|
11
|
+
|
|
12
|
+
- The two's-complement sign correction disagreed. sCTkScrollableFrame used
|
|
13
|
+
`>= 0x8000`; sCTkScrollArea used `> 32768`. Those differ at exactly
|
|
14
|
+
32768, which one treats as +32768 and the other as -32768.
|
|
15
|
+
- sCTkScrollArea decoded the packed touchpad delta differently again,
|
|
16
|
+
reading event.delta_y when present and applying a 16-bit correction to
|
|
17
|
+
what may be a 32-bit packed value.
|
|
18
|
+
- Windows wheel scaling disagreed: `/120` unscaled in sCTkScrollableFrame,
|
|
19
|
+
`/120 * 2` in sCTkScrollArea.
|
|
20
|
+
- sCTkFileExplorer had no touchpad ACCUMULATOR at all -- it scrolled on
|
|
21
|
+
every raw touchpad event instead of gating on an accumulated threshold,
|
|
22
|
+
making trackpad scrolling markedly faster and coarser there.
|
|
23
|
+
- The nested-scrollable boundary guard existed in exactly one of the three.
|
|
24
|
+
|
|
25
|
+
Every fix had to be made three times, and none of them were. This module is
|
|
26
|
+
the one place that logic now lives.
|
|
27
|
+
|
|
28
|
+
CANONICAL SOURCE. The behavior here is sCTkScrollableFrame's implementation,
|
|
29
|
+
which is the maintainer-verified reference confirmed smooth in live testing
|
|
30
|
+
on macOS with both an Apple mouse and a trackpad. Where the three copies
|
|
31
|
+
disagreed, sCTkScrollableFrame's version wins. Adopting this mixin therefore
|
|
32
|
+
CHANGES BEHAVIOR in the other two hosts -- see the module's integration
|
|
33
|
+
notes and retest accordingly.
|
|
34
|
+
|
|
35
|
+
PLATFORM BEHAVIOR. Three genuinely different platform models are handled:
|
|
36
|
+
- Windows: <MouseWheel> with a /120-scaled delta.
|
|
37
|
+
- Linux: discrete <Button-4>/<Button-5> events, no continuous delta.
|
|
38
|
+
- macOS: its own <MouseWheel> scaling, PLUS a separate, higher-precision
|
|
39
|
+
<TouchpadScroll> synthetic event carrying a two-axis delta
|
|
40
|
+
packed into a single 32-bit integer.
|
|
41
|
+
|
|
42
|
+
HOST REQUIREMENTS. A host class must provide:
|
|
43
|
+
|
|
44
|
+
_scroll_target() -> the widget to call yview_scroll() on, or None if
|
|
45
|
+
scrolling isn't currently possible.
|
|
46
|
+
_scroll_layers() -> the ordered list of widgets to bind, deduplicated.
|
|
47
|
+
|
|
48
|
+
and may optionally override:
|
|
49
|
+
|
|
50
|
+
_scroll_permitted() -> False to install BLOCKING handlers instead of
|
|
51
|
+
scroll handlers (default True).
|
|
52
|
+
_scroll_drag_targets() -> widgets whose click-drag should also be
|
|
53
|
+
blocked when not permitted (default none).
|
|
54
|
+
|
|
55
|
+
Hosts must call _init_scroll_state() once, before any binding happens.
|
|
56
|
+
"""
|
|
57
|
+
import sys
|
|
58
|
+
from .themeable_widget import defer
|
|
59
|
+
import tkinter as tk
|
|
60
|
+
import platform
|
|
61
|
+
|
|
62
|
+
import customtkinter as ctk
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ScrollBindingMixin:
|
|
66
|
+
"""Shared scroll-binding engine. See module docstring for the contract."""
|
|
67
|
+
|
|
68
|
+
# Bound on every layer. <TouchpadScroll> is macOS-only and is skipped
|
|
69
|
+
# elsewhere; binding it on other platforms raises in Tk.
|
|
70
|
+
SCROLL_EVENTS = ("<MouseWheel>", "<TouchpadScroll>", "<Button-4>", "<Button-5>")
|
|
71
|
+
|
|
72
|
+
# Click-drag events blocked on scrollbar-like widgets when scrolling is
|
|
73
|
+
# not permitted. See _set_scroll_drag_blocked().
|
|
74
|
+
DRAG_EVENTS = ("<Button-1>", "<B1-Motion>", "<ButtonRelease-1>")
|
|
75
|
+
|
|
76
|
+
# macOS wheel events carry a much smaller delta than Windows' /120 steps,
|
|
77
|
+
# so they're amplified to give comparable travel per notch.
|
|
78
|
+
MAC_SCROLL_SENSITIVITY = 3
|
|
79
|
+
|
|
80
|
+
# Ceiling on units travelled per macOS wheel event.
|
|
81
|
+
#
|
|
82
|
+
# macOS reports WILDLY different delta magnitudes depending on hardware.
|
|
83
|
+
# An Apple Magic Mouse sends fine-grained values near 1; a conventional
|
|
84
|
+
# wheel mouse sends a large value per detent -- around 38 in live testing.
|
|
85
|
+
# Multiplying that by MAC_SCROLL_SENSITIVITY gave 114 units of travel from
|
|
86
|
+
# a single wheel click, which jumped a ~100-row list end to end.
|
|
87
|
+
#
|
|
88
|
+
# The amplification is still right for fine-grained hardware, so rather
|
|
89
|
+
# than dropping it, the result is clamped: small deltas scale normally,
|
|
90
|
+
# large ones saturate at a sane notch-sized step. Tune this if a wheel
|
|
91
|
+
# notch feels too short or too long; it is the only knob involved.
|
|
92
|
+
MAC_SCROLL_MAX_STEP = 3
|
|
93
|
+
|
|
94
|
+
# Trackpad events arrive far more frequently and with far finer deltas
|
|
95
|
+
# than wheel notches. Scrolling on each one is unusably fast, so deltas
|
|
96
|
+
# accumulate and only move the view once this threshold is crossed.
|
|
97
|
+
# Tuned empirically; do not change without live trackpad testing.
|
|
98
|
+
TOUCHPAD_ACCUMULATION_THRESHOLD = 12.0
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Host contract
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
def _scroll_target(self):
|
|
104
|
+
"""The widget to scroll. Hosts MUST override."""
|
|
105
|
+
raise NotImplementedError(
|
|
106
|
+
f"{type(self).__name__} uses ScrollBindingMixin but does not "
|
|
107
|
+
f"implement _scroll_target()."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _scroll_layers(self):
|
|
111
|
+
"""Ordered, deduplicated list of widgets to bind. Hosts MUST override."""
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
f"{type(self).__name__} uses ScrollBindingMixin but does not "
|
|
114
|
+
f"implement _scroll_layers()."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _scroll_permitted(self) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Whether scrolling should currently respond. Hosts that support a
|
|
120
|
+
disabled state override this; the default is always-on.
|
|
121
|
+
"""
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def _scroll_drag_targets(self):
|
|
125
|
+
"""
|
|
126
|
+
Widgets whose click-and-drag should also be blocked when scrolling
|
|
127
|
+
isn't permitted -- typically an internal scrollbar. Default: none.
|
|
128
|
+
"""
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Setup
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
def _init_scroll_state(self) -> None:
|
|
135
|
+
"""Initializes the touchpad accumulator. Call once, before binding."""
|
|
136
|
+
self._touchpad_accumulated_delta = 0.0
|
|
137
|
+
self._touchpad_last_direction = 0
|
|
138
|
+
self._scroll_rebind_pending = False
|
|
139
|
+
|
|
140
|
+
def _install_scroll_activation(self, extra_map_widget=None, content_widget=None) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Establishes automatic scroll activation and content-change rebinding.
|
|
143
|
+
|
|
144
|
+
Call once from the host's __init__, after the widget hierarchy exists.
|
|
145
|
+
Requires _init_scroll_state() to have run first.
|
|
146
|
+
|
|
147
|
+
FOUR MECHANISMS, each covering a gap the others don't. All are
|
|
148
|
+
idempotent -- _toggle_scroll_bindings() always tears down before
|
|
149
|
+
rebuilding -- so overlapping coverage costs nothing:
|
|
150
|
+
|
|
151
|
+
<Map> on self later remaps, e.g. pack_forget() then re-place
|
|
152
|
+
<Map> on extra widget the widget the geometry manager actually sees
|
|
153
|
+
after_idle() initial activation, independent of mapping
|
|
154
|
+
<Configure>, debounced content added AFTER activation
|
|
155
|
+
|
|
156
|
+
WHY <Map> ALONE ISN'T ENOUGH. CTkScrollableFrame is not the widget
|
|
157
|
+
that gets placed: it builds an internal _parent_frame plus a canvas,
|
|
158
|
+
inserts ITSELF into that canvas via create_window(), and overrides
|
|
159
|
+
pack()/grid()/place() to operate on _parent_frame. The widget is
|
|
160
|
+
therefore a canvas-window child and may never receive <Map> the way an
|
|
161
|
+
ordinarily-managed widget does. after_idle() is what actually
|
|
162
|
+
establishes bindings in practice -- it fires once Tk goes idle, after
|
|
163
|
+
setup code has run and placement has happened, with no dependence on
|
|
164
|
+
mapping semantics at all.
|
|
165
|
+
|
|
166
|
+
WHY THE CONTENT REBIND IS NEEDED. Activation happens once, at a moment
|
|
167
|
+
when the frame is usually still empty -- callers construct, place, and
|
|
168
|
+
THEN populate. Confirmed by live testing: an sCTkTableview bound at
|
|
169
|
+
activation time collected 16 layers (frame, canvas, header cells)
|
|
170
|
+
because load_dataset() hadn't run yet, and the 32 data cells created
|
|
171
|
+
afterwards were never bound, so it scrolled beside its rows but not
|
|
172
|
+
over them.
|
|
173
|
+
|
|
174
|
+
CRITICAL -- tk.Misc.bind, NOT self.bind. CustomTkinter overrides
|
|
175
|
+
CTkScrollableFrame.bind() to forward every binding to
|
|
176
|
+
self._parent_canvas instead of attaching it to the widget. An earlier
|
|
177
|
+
version used self.bind(), so bindings never landed on the frame and
|
|
178
|
+
scroll handling was silently never installed -- the widget only
|
|
179
|
+
appeared to scroll where native CTk's own global bind_all handler
|
|
180
|
+
happened to cover for it. Calling tk.Misc.bind unbound reaches the
|
|
181
|
+
real Tkinter implementation. This looks like a needless complication
|
|
182
|
+
and is not.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
extra_map_widget: An additional widget to watch for <Map>,
|
|
186
|
+
typically the host's internal _parent_frame -- the widget the
|
|
187
|
+
geometry manager actually sees. Optional.
|
|
188
|
+
content_widget: The widget whose <Configure> signals a content
|
|
189
|
+
change. Defaults to self, which is correct when content is
|
|
190
|
+
added directly to the host. Hosts that put content in a
|
|
191
|
+
separate inner frame MUST pass that frame instead: adding
|
|
192
|
+
rows to an inner frame doesn't resize the outer widget, so
|
|
193
|
+
<Configure> on self would never fire and the content rebind
|
|
194
|
+
would silently never happen.
|
|
195
|
+
"""
|
|
196
|
+
tk.Misc.bind(self, "<Map>", self._activate_scroll_bindings, add="+")
|
|
197
|
+
|
|
198
|
+
if extra_map_widget is not None:
|
|
199
|
+
try:
|
|
200
|
+
tk.Misc.bind(extra_map_widget, "<Map>", self._activate_scroll_bindings, add="+")
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
defer(self, "idle", self._activate_scroll_bindings)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
target = content_widget if content_widget is not None else self
|
|
210
|
+
try:
|
|
211
|
+
tk.Misc.bind(target, "<Configure>", self._schedule_scroll_rebind, add="+")
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def _activate_scroll_bindings(self, event=None) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Binds or blocks scroll handling according to the host's current
|
|
218
|
+
permitted state. The single entry point every activation path uses,
|
|
219
|
+
so none of them can drift apart.
|
|
220
|
+
|
|
221
|
+
Deliberately does NOT return early when scrolling isn't permitted: it
|
|
222
|
+
runs a full pass to install the BLOCKING handlers described in
|
|
223
|
+
_toggle_scroll_bindings(). That matters for the
|
|
224
|
+
construct-then-disable-then-place ordering, where the earlier pass had
|
|
225
|
+
no realized hierarchy to install onto.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
event: The Tkinter event, when called from a binding. Ignored.
|
|
229
|
+
"""
|
|
230
|
+
self._toggle_scroll_bindings(bind=self._scroll_permitted())
|
|
231
|
+
|
|
232
|
+
def _schedule_scroll_rebind(self, event=None) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Requests a rebind, coalescing bursts into a single pass.
|
|
235
|
+
|
|
236
|
+
Bound to <Configure>, which fires once per child added. The pending
|
|
237
|
+
flag means a burst of additions schedules exactly one rebind, run
|
|
238
|
+
after the burst finishes rather than during it -- so the rebind sees
|
|
239
|
+
the finished widget tree, not a partial one. Building a table fires
|
|
240
|
+
<Configure> once per cell; this makes that one rebind instead of 32.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
event: The Tkinter <Configure> event. Ignored.
|
|
244
|
+
"""
|
|
245
|
+
if getattr(self, "_scroll_rebind_pending", False):
|
|
246
|
+
return
|
|
247
|
+
self._scroll_rebind_pending = True
|
|
248
|
+
try:
|
|
249
|
+
defer(self, "idle", self._run_scheduled_scroll_rebind)
|
|
250
|
+
except Exception:
|
|
251
|
+
# Widget destroyed mid-flight; nothing to rebind.
|
|
252
|
+
self._scroll_rebind_pending = False
|
|
253
|
+
|
|
254
|
+
def _run_scheduled_scroll_rebind(self) -> None:
|
|
255
|
+
"""Executes a coalesced rebind. See _schedule_scroll_rebind()."""
|
|
256
|
+
self._scroll_rebind_pending = False
|
|
257
|
+
try:
|
|
258
|
+
if not self.winfo_exists():
|
|
259
|
+
return
|
|
260
|
+
except Exception:
|
|
261
|
+
return
|
|
262
|
+
self._activate_scroll_bindings(None)
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Layer collection
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
def _collect_scroll_descendants(self, widget, collected: list) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Recursively collects a widget and its descendants for binding.
|
|
270
|
+
|
|
271
|
+
Stops at any nested CTkScrollableFrame boundary (covering
|
|
272
|
+
sCTkScrollableFrame and anything built on it, such as sCTkSelector
|
|
273
|
+
and sCTkTableview). Without this, an inner scrollable frame placed
|
|
274
|
+
inside an outer one would have its canvas, scrollbar, and entire
|
|
275
|
+
content tree bound to the OUTER host's handler as well as its own --
|
|
276
|
+
and since bindings use add="+", both fire on the same event,
|
|
277
|
+
scrolling both at once. Native CustomTkinter guards the same boundary
|
|
278
|
+
in _check_if_valid_scroll.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
widget: Subtree root. Never skipped itself, even if it is a
|
|
282
|
+
scrollable frame -- the guard applies to descendants only, so
|
|
283
|
+
a scrollable host can still bind its own layers.
|
|
284
|
+
collected: Accumulator, mutated in place.
|
|
285
|
+
"""
|
|
286
|
+
if widget is not self and isinstance(widget, ctk.CTkScrollableFrame):
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# Widgets that consume scroll input themselves also end the walk.
|
|
290
|
+
#
|
|
291
|
+
# FIX: a dial inside an sCTkScrollableFrame was being bound to the
|
|
292
|
+
# FRAME's handler as well as its own. The frame's binding is installed
|
|
293
|
+
# on the dial's canvas with add="+" and fires first, so pointing at a
|
|
294
|
+
# VFO knob and turning the wheel scrolled the LIST instead of the knob.
|
|
295
|
+
# Confirmed at runtime, not only in the Designer.
|
|
296
|
+
#
|
|
297
|
+
# The CTkScrollableFrame check above cannot cover this: a dial is not a
|
|
298
|
+
# scrollable frame, it simply has its own claim on the wheel. Any widget
|
|
299
|
+
# with that property can set _CONSUMES_SCROLL = True to opt out.
|
|
300
|
+
if widget is not self and getattr(widget, "_CONSUMES_SCROLL", False):
|
|
301
|
+
return
|
|
302
|
+
if widget not in collected:
|
|
303
|
+
collected.append(widget)
|
|
304
|
+
try:
|
|
305
|
+
for child in widget.winfo_children():
|
|
306
|
+
self._collect_scroll_descendants(child, collected)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Binding
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
def _toggle_scroll_bindings(self, bind: bool = True) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Establishes or removes every scroll binding for this host.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
bind: True (re)establishes all bindings -- safe to call
|
|
319
|
+
repeatedly, since existing bindings are always removed first,
|
|
320
|
+
so nothing accumulates. False removes them and installs
|
|
321
|
+
BLOCKING handlers in their place.
|
|
322
|
+
|
|
323
|
+
On blocking: unbinding alone does NOT stop scrolling. Native
|
|
324
|
+
CTkScrollableFrame installs its own application-global
|
|
325
|
+
bind_all("<MouseWheel>") handler that survives any unbind() here, and
|
|
326
|
+
unbind_all() is not an option -- it would disable scrolling for every
|
|
327
|
+
other scrollable widget in the application. Instead a handler
|
|
328
|
+
returning "break" is installed on each layer. Tk dispatches bindings
|
|
329
|
+
by bindtag in order -- widget, class, toplevel, then "all" -- and
|
|
330
|
+
bind_all lands on that final tag, so a widget-level "break" halts the
|
|
331
|
+
chain before the global handler is ever reached. Confirmed by live
|
|
332
|
+
testing: with two independent scrollable frames side by side,
|
|
333
|
+
disabling one left the other scrolling normally with both a mouse
|
|
334
|
+
wheel and a trackpad.
|
|
335
|
+
"""
|
|
336
|
+
# Hosts may expose _USE_CUSTOM_SCROLL_BINDING as a kill switch for the
|
|
337
|
+
# custom binding system, falling back to whatever native CustomTkinter
|
|
338
|
+
# provides. Checked here rather than at the call sites so it can't be
|
|
339
|
+
# bypassed by reaching this method through a different entry point --
|
|
340
|
+
# the exact bug that made the toggle ineffective in an earlier
|
|
341
|
+
# sCTkScrollableFrame revision. Defaults to enabled for hosts that
|
|
342
|
+
# don't define it.
|
|
343
|
+
if bind and not getattr(self, "_USE_CUSTOM_SCROLL_BINDING", True):
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
layers = self._scroll_layers()
|
|
347
|
+
|
|
348
|
+
for layer in layers:
|
|
349
|
+
for event_str in self.SCROLL_EVENTS:
|
|
350
|
+
try:
|
|
351
|
+
layer.unbind(event_str)
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
# <TouchpadScroll> exists only on macOS.
|
|
356
|
+
if "Touchpad" in event_str and sys.platform != "darwin":
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
if bind:
|
|
360
|
+
handler = (self._process_mac_touchpad_scroll
|
|
361
|
+
if "Touchpad" in event_str
|
|
362
|
+
else self._process_scroll_wheel)
|
|
363
|
+
else:
|
|
364
|
+
handler = self._block_scroll_event
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
layer.bind(event_str, handler, add="+")
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
self._set_scroll_drag_blocked(not bind)
|
|
372
|
+
|
|
373
|
+
def _block_scroll_event(self, event=None) -> str:
|
|
374
|
+
"""
|
|
375
|
+
Swallows a scroll event so it never reaches native CTk's global
|
|
376
|
+
bind_all handler. See _toggle_scroll_bindings() for why unbinding
|
|
377
|
+
alone is insufficient.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
"break", halting Tk's bindtag chain for this event.
|
|
381
|
+
"""
|
|
382
|
+
return "break"
|
|
383
|
+
|
|
384
|
+
def _set_scroll_drag_blocked(self, blocked: bool) -> None:
|
|
385
|
+
"""
|
|
386
|
+
Blocks or restores click-and-drag on the host's _scroll_drag_targets().
|
|
387
|
+
|
|
388
|
+
Deliberately does NOT use unbind(). Tk's unbind() removes EVERY
|
|
389
|
+
binding for an event on a widget, so calling it on a scrollbar's
|
|
390
|
+
<Button-1> would destroy CustomTkinter's own drag handler
|
|
391
|
+
permanently, with no way to restore it. Binding a blocker with
|
|
392
|
+
add="+" doesn't work either: handlers on one widget fire in the order
|
|
393
|
+
added, and CustomTkinter's was added during its own construction, so
|
|
394
|
+
ours would run after the drag had already been handled -- returning
|
|
395
|
+
"break" at that point is too late.
|
|
396
|
+
|
|
397
|
+
Uses bindtags instead, the mechanism Tk provides for exactly this.
|
|
398
|
+
Inserting a private tag at the FRONT of a widget's tag list means the
|
|
399
|
+
blocker runs before any of CustomTkinter's own bindings, so "break"
|
|
400
|
+
stops them before they execute. Restoring is just removing the tag;
|
|
401
|
+
CustomTkinter's bindings are never touched.
|
|
402
|
+
|
|
403
|
+
The tag name embeds id(self), so blocking one host has no effect on
|
|
404
|
+
any other in the same application.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
blocked: True installs the block, False removes it.
|
|
408
|
+
"""
|
|
409
|
+
targets = []
|
|
410
|
+
for widget in self._scroll_drag_targets():
|
|
411
|
+
if widget is None:
|
|
412
|
+
continue
|
|
413
|
+
targets.append(widget)
|
|
414
|
+
# The click lands on a CTk composite's internal canvas, not its
|
|
415
|
+
# outer frame, so children need the tag too.
|
|
416
|
+
try:
|
|
417
|
+
targets.extend(widget.winfo_children())
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
if not targets:
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
tag = f"sCTkScrollDragBlock{id(self)}"
|
|
425
|
+
for event_str in self.DRAG_EVENTS:
|
|
426
|
+
try:
|
|
427
|
+
self.bind_class(tag, event_str, self._block_scroll_event)
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
for widget in targets:
|
|
432
|
+
try:
|
|
433
|
+
tags = list(widget.bindtags())
|
|
434
|
+
if blocked and tag not in tags:
|
|
435
|
+
widget.bindtags(tuple([tag] + tags))
|
|
436
|
+
elif not blocked and tag in tags:
|
|
437
|
+
widget.bindtags(tuple(t for t in tags if t != tag))
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
# Event processing
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _decode_mac_touchpad_delta(raw_delta):
|
|
446
|
+
"""
|
|
447
|
+
Decodes macOS's packed 32-bit <TouchpadScroll> delta into signed
|
|
448
|
+
16-bit X and Y components.
|
|
449
|
+
|
|
450
|
+
The event packs both axes into one integer: X in the high 16 bits, Y
|
|
451
|
+
in the low 16. Each is an UNSIGNED 16-bit field that must be
|
|
452
|
+
converted back to signed, or every upward scroll reads as a large
|
|
453
|
+
positive number instead of a negative one.
|
|
454
|
+
|
|
455
|
+
The `>= 0x8000` comparison is the canonical form. An earlier copy in
|
|
456
|
+
sCTkScrollArea used `> 32768`, which is off by one: 32768 is the
|
|
457
|
+
smallest negative value in a signed 16-bit field (-32768), and that
|
|
458
|
+
version treated it as +32768 instead.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
raw_delta: The event's raw packed delta.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
(delta_x, delta_y) as signed integers.
|
|
465
|
+
"""
|
|
466
|
+
raw = raw_delta & 0xFFFFFFFF
|
|
467
|
+
delta_x = (raw >> 16) & 0xFFFF
|
|
468
|
+
if delta_x >= 0x8000:
|
|
469
|
+
delta_x -= 0x10000
|
|
470
|
+
delta_y = raw & 0xFFFF
|
|
471
|
+
if delta_y >= 0x8000:
|
|
472
|
+
delta_y -= 0x10000
|
|
473
|
+
return delta_x, delta_y
|
|
474
|
+
|
|
475
|
+
def _process_mac_touchpad_scroll(self, event):
|
|
476
|
+
"""
|
|
477
|
+
Handles macOS <TouchpadScroll>, accumulating fine-grained deltas and
|
|
478
|
+
moving the view only once TOUCHPAD_ACCUMULATION_THRESHOLD is crossed.
|
|
479
|
+
|
|
480
|
+
The accumulator resets on a direction reversal, so a change of
|
|
481
|
+
direction responds immediately rather than having to first cancel out
|
|
482
|
+
whatever had built up in the opposite direction.
|
|
483
|
+
|
|
484
|
+
NOTE: sCTkFileExplorer's previous copy had no accumulator at all --
|
|
485
|
+
it scrolled on every raw event, making trackpad scrolling markedly
|
|
486
|
+
faster and coarser than everywhere else in the library. Adopting this
|
|
487
|
+
mixin brings it in line.
|
|
488
|
+
"""
|
|
489
|
+
target = self._scroll_target()
|
|
490
|
+
if target is None:
|
|
491
|
+
return
|
|
492
|
+
try:
|
|
493
|
+
_, delta_y = self._decode_mac_touchpad_delta(event.delta)
|
|
494
|
+
if delta_y == 0:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
direction = 1 if delta_y > 0 else -1
|
|
498
|
+
if direction != self._touchpad_last_direction and self._touchpad_last_direction != 0:
|
|
499
|
+
self._touchpad_accumulated_delta = 0.0
|
|
500
|
+
self._touchpad_last_direction = direction
|
|
501
|
+
|
|
502
|
+
self._touchpad_accumulated_delta += delta_y
|
|
503
|
+
|
|
504
|
+
if abs(self._touchpad_accumulated_delta) >= self.TOUCHPAD_ACCUMULATION_THRESHOLD:
|
|
505
|
+
scaled_scroll = -1 if self._touchpad_accumulated_delta > 0 else 1
|
|
506
|
+
target.yview_scroll(scaled_scroll, "units")
|
|
507
|
+
self._touchpad_accumulated_delta = 0.0
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
def _process_scroll_wheel(self, event):
|
|
512
|
+
"""
|
|
513
|
+
Handles standard mouse wheel events across all three platforms.
|
|
514
|
+
|
|
515
|
+
Windows scaling is `/120` unscaled -- the canonical form.
|
|
516
|
+
sCTkScrollArea's previous copy doubled it, scrolling twice as far per
|
|
517
|
+
notch as everywhere else in the library.
|
|
518
|
+
"""
|
|
519
|
+
target = self._scroll_target()
|
|
520
|
+
if target is None:
|
|
521
|
+
return
|
|
522
|
+
try:
|
|
523
|
+
sys_platform = platform.system()
|
|
524
|
+
if sys_platform == "Darwin":
|
|
525
|
+
delta = event.delta
|
|
526
|
+
scaled_scroll = (int(-self.MAC_SCROLL_SENSITIVITY * delta)
|
|
527
|
+
if abs(delta) >= 1
|
|
528
|
+
else (-self.MAC_SCROLL_SENSITIVITY if delta > 0
|
|
529
|
+
else self.MAC_SCROLL_SENSITIVITY))
|
|
530
|
+
# Saturate rather than trust the magnitude -- see
|
|
531
|
+
# MAC_SCROLL_MAX_STEP. A conventional wheel's per-detent delta
|
|
532
|
+
# is large enough that the amplification above would otherwise
|
|
533
|
+
# throw the view across the entire content in one click.
|
|
534
|
+
cap = self.MAC_SCROLL_MAX_STEP
|
|
535
|
+
scaled_scroll = max(-cap, min(cap, scaled_scroll))
|
|
536
|
+
target.yview_scroll(scaled_scroll, "units")
|
|
537
|
+
elif sys_platform == "Linux":
|
|
538
|
+
# Linux has no continuous delta -- discrete button events only.
|
|
539
|
+
if event.num == 4:
|
|
540
|
+
target.yview_scroll(-1, "units")
|
|
541
|
+
elif event.num == 5:
|
|
542
|
+
target.yview_scroll(1, "units")
|
|
543
|
+
else:
|
|
544
|
+
target.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|