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,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