projectum 1.6.0__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.
projectum/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Projectum — track every project in one place. Made by wleeaf."""
2
+
3
+ __version__ = "1.6.0"
4
+ __author__ = "wleeaf"
projectum/anims.py ADDED
@@ -0,0 +1,602 @@
1
+ """Animation helpers for Projectum.
2
+
3
+ Strategy
4
+ ========
5
+
6
+ PySide6's ``QGraphicsOpacityEffect`` is the obvious tool for fading a
7
+ QWidget, but it does not work on widget trees that contain custom-painted
8
+ children (anything with ``def paintEvent: p = QPainter(self)``). The
9
+ effect's offscreen-pixmap render fights the child paint events and produces
10
+ broken frames mid-fade — the classic symptom is the new page being invisible
11
+ for most of the animation and then snapping in at the end. We rule that out
12
+ by routing different transitions through different paths:
13
+
14
+ * ``cross_fade_stack`` — for ``QStackedWidget`` swaps. Snapshots the old
15
+ page into a ``QPixmap``, switches the stack, and overlays the pixmap as a
16
+ ``QLabel`` that fades out. The label has no custom-painted children, so
17
+ ``QGraphicsOpacityEffect`` on it works cleanly. The new page is shown at
18
+ full opacity from frame 1 — no render pipeline conflict.
19
+
20
+ * ``slide_in_height`` / ``slide_out_height`` — for sections that
21
+ appear/disappear within a vertical layout (e.g. the tag palette). Animates
22
+ ``maximumHeight``. Layout reflows as the value changes, giving a clean
23
+ expand/collapse with no opacity effect involved.
24
+
25
+ * ``collapse_list_item`` — animates a ``QListWidgetItem``'s ``sizeHint``
26
+ height down to 0 for a satisfying delete.
27
+
28
+ * ``fade_in`` / ``fade_out`` — kept for *simple* widgets only (QLineEdit,
29
+ QPushButton, QLabel). Uses ``QGraphicsOpacityEffect``. Safe when there
30
+ are no custom-painted descendants.
31
+
32
+ * ``fade_window`` / ``fade_window_close`` — for top-level frameless popups.
33
+ Uses ``windowOpacity`` (window-compositor path), which doesn't go through
34
+ the offscreen-pixmap render and is immune to the child-paintEvent issue.
35
+
36
+ * ``animate_progress`` — tweens ``QProgressBar.value`` via QVariantAnimation.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import Callable
42
+
43
+ import math
44
+ import os
45
+
46
+ from PySide6.QtCore import (
47
+ QEasingCurve, QElapsedTimer, QEvent, QObject, QPropertyAnimation, QSize, Qt,
48
+ QTimer, QVariantAnimation,
49
+ )
50
+ from PySide6.QtWidgets import (
51
+ QAbstractScrollArea, QApplication, QGraphicsOpacityEffect, QLabel,
52
+ QListWidget, QListWidgetItem, QProgressBar, QStackedWidget, QWidget,
53
+ )
54
+
55
+
56
+ # QWIDGETSIZE_MAX is the value Qt uses to mean "no maximum". Restored after
57
+ # a slide so the widget can resize naturally again.
58
+ _QWIDGETSIZE_MAX = 16777215
59
+
60
+
61
+ # ──────────────────────── shared internals ────────────────────────
62
+
63
+
64
+ def _effect_for(widget: QWidget) -> QGraphicsOpacityEffect:
65
+ eff = widget.graphicsEffect()
66
+ if isinstance(eff, QGraphicsOpacityEffect):
67
+ return eff
68
+ eff = QGraphicsOpacityEffect(widget)
69
+ eff.setOpacity(1.0)
70
+ widget.setGraphicsEffect(eff)
71
+ return eff
72
+
73
+
74
+ def _stop_anim(widget: QWidget) -> None:
75
+ anim = getattr(widget, "_anim", None)
76
+ if not isinstance(anim, QPropertyAnimation):
77
+ return
78
+ try:
79
+ if anim.state() != QPropertyAnimation.State.Stopped:
80
+ try:
81
+ anim.finished.disconnect()
82
+ except (TypeError, RuntimeError):
83
+ pass
84
+ anim.stop()
85
+ # The animation is parented to the widget, so simply dropping our
86
+ # reference wouldn't free it — without this, every fade/slide leaks a
87
+ # QPropertyAnimation as a permanent child of the widget.
88
+ anim.deleteLater()
89
+ except RuntimeError:
90
+ pass
91
+ widget._anim = None
92
+
93
+
94
+ # ──────────────────────── fades for SIMPLE widgets ────────────────────────
95
+
96
+
97
+ def fade_in(widget: QWidget, duration: int = 180) -> QPropertyAnimation:
98
+ """Fade a widget from 0 opacity up to 1.
99
+
100
+ Safe only for widgets whose subtree contains no custom paintEvents
101
+ (plain QLabel/QPushButton/QLineEdit etc). For complex widgets, use
102
+ :func:`cross_fade_stack` or :func:`slide_in_height`.
103
+ """
104
+ _stop_anim(widget)
105
+ eff = _effect_for(widget)
106
+ eff.setOpacity(0.0)
107
+ widget.update()
108
+ anim = QPropertyAnimation(eff, b"opacity", widget)
109
+ anim.setDuration(duration)
110
+ anim.setStartValue(0.0)
111
+ anim.setEndValue(1.0)
112
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
113
+ anim.valueChanged.connect(lambda _v, w=widget: w.update())
114
+ widget._anim = anim
115
+ anim.start()
116
+ return anim
117
+
118
+
119
+ def fade_out(
120
+ widget: QWidget,
121
+ duration: int = 180,
122
+ on_done: Callable[[], None] | None = None,
123
+ ) -> QPropertyAnimation:
124
+ """Fade a SIMPLE widget down to 0. Same custom-paint caveat as fade_in."""
125
+ _stop_anim(widget)
126
+ eff = _effect_for(widget)
127
+ anim = QPropertyAnimation(eff, b"opacity", widget)
128
+ anim.setDuration(duration)
129
+ anim.setStartValue(eff.opacity())
130
+ anim.setEndValue(0.0)
131
+ anim.setEasingCurve(QEasingCurve.Type.InCubic)
132
+ anim.valueChanged.connect(lambda _v, w=widget: w.update())
133
+ if on_done is not None:
134
+ anim.finished.connect(on_done)
135
+ widget._anim = anim
136
+ anim.start()
137
+ return anim
138
+
139
+
140
+ # ──────────────────────── cross-fade for complex stacks ────────────────────────
141
+
142
+
143
+ class _OverlayResizer(QObject):
144
+ """Keeps a cross-fade overlay matched to its stack's size during the fade.
145
+
146
+ The overlay is an absolutely-positioned child (not in a layout), so without
147
+ this it keeps its initial geometry and misaligns if the window resizes
148
+ mid-fade.
149
+ """
150
+
151
+ def __init__(self, overlay: QWidget, stack: QStackedWidget):
152
+ super().__init__(stack)
153
+ self._overlay = overlay
154
+
155
+ def eventFilter(self, obj, event):
156
+ if event.type() == QEvent.Type.Resize:
157
+ try:
158
+ self._overlay.setGeometry(0, 0, obj.width(), obj.height())
159
+ except RuntimeError:
160
+ pass
161
+ return False
162
+
163
+
164
+ def cross_fade_stack(
165
+ stack: QStackedWidget,
166
+ new_index: int,
167
+ duration: int = 160,
168
+ ) -> None:
169
+ """Crossfade ``stack`` to ``new_index`` via a pixmap-overlay snapshot.
170
+
171
+ The destination page becomes visible at full opacity immediately — only
172
+ the snapshot of the old page (a plain QLabel) is faded, so the painter
173
+ pipeline stays clean even when both pages contain custom-painted
174
+ children.
175
+ """
176
+ if stack.currentIndex() == new_index:
177
+ return
178
+ new_widget = stack.widget(new_index)
179
+ if new_widget is None:
180
+ stack.setCurrentIndex(new_index)
181
+ return
182
+
183
+ old_widget = stack.currentWidget()
184
+ if (
185
+ old_widget is None
186
+ or not old_widget.isVisible()
187
+ or stack.width() <= 0
188
+ or stack.height() <= 0
189
+ ):
190
+ stack.setCurrentIndex(new_index)
191
+ return
192
+
193
+ # Tear down any in-flight overlay from a previous rapid click —
194
+ # otherwise multiple QLabel snapshots stack and fade independently.
195
+ previous = getattr(stack, "_cross_fade_overlay", None)
196
+ if previous is not None:
197
+ try:
198
+ previous.deleteLater()
199
+ except RuntimeError:
200
+ pass
201
+
202
+ pixmap = old_widget.grab()
203
+ stack.setCurrentIndex(new_index)
204
+
205
+ overlay = QLabel(stack)
206
+ overlay.setPixmap(pixmap)
207
+ overlay.setGeometry(0, 0, stack.width(), stack.height())
208
+ overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
209
+ overlay.raise_()
210
+ overlay.show()
211
+ stack._cross_fade_overlay = overlay
212
+
213
+ # Track stack resizes so the snapshot stays aligned during the fade.
214
+ resizer = _OverlayResizer(overlay, stack)
215
+ stack.installEventFilter(resizer)
216
+
217
+ eff = QGraphicsOpacityEffect(overlay)
218
+ eff.setOpacity(1.0)
219
+ overlay.setGraphicsEffect(eff)
220
+
221
+ anim = QPropertyAnimation(eff, b"opacity", overlay)
222
+ anim.setDuration(duration)
223
+ anim.setStartValue(1.0)
224
+ anim.setEndValue(0.0)
225
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
226
+
227
+ def _cleanup():
228
+ try:
229
+ stack.removeEventFilter(resizer)
230
+ except RuntimeError:
231
+ pass
232
+ resizer.deleteLater()
233
+ if getattr(stack, "_cross_fade_overlay", None) is overlay:
234
+ stack._cross_fade_overlay = None
235
+ overlay.deleteLater()
236
+ anim.finished.connect(_cleanup)
237
+ overlay._anim = anim
238
+ anim.start()
239
+
240
+
241
+ def cross_fade_swap(widget: QWidget, apply_change, duration: int = 240) -> None:
242
+ """Snapshot ``widget``, run ``apply_change()``, then fade the snapshot out
243
+ over the freshly-rendered widget — a crossfade for in-place restyles (e.g.
244
+ a theme swap, where the stylesheet changes but the widget tree stays).
245
+
246
+ The snapshot also conveniently hides the relayout/rebuild that
247
+ ``apply_change`` may trigger underneath it.
248
+ """
249
+ if widget.width() <= 0 or widget.height() <= 0 or not widget.isVisible():
250
+ apply_change()
251
+ return
252
+ # Tear down any in-flight overlay so rapid theme changes don't stack.
253
+ previous = getattr(widget, "_cross_fade_overlay", None)
254
+ if previous is not None:
255
+ try:
256
+ previous.deleteLater()
257
+ except RuntimeError:
258
+ pass
259
+ widget._cross_fade_overlay = None
260
+
261
+ pixmap = widget.grab()
262
+ apply_change()
263
+
264
+ overlay = QLabel(widget)
265
+ overlay.setPixmap(pixmap)
266
+ overlay.setGeometry(0, 0, widget.width(), widget.height())
267
+ overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
268
+ overlay.raise_()
269
+ overlay.show()
270
+ widget._cross_fade_overlay = overlay
271
+
272
+ eff = QGraphicsOpacityEffect(overlay)
273
+ eff.setOpacity(1.0)
274
+ overlay.setGraphicsEffect(eff)
275
+ anim = QPropertyAnimation(eff, b"opacity", overlay)
276
+ anim.setDuration(duration)
277
+ anim.setStartValue(1.0)
278
+ anim.setEndValue(0.0)
279
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
280
+
281
+ def _cleanup():
282
+ if getattr(widget, "_cross_fade_overlay", None) is overlay:
283
+ widget._cross_fade_overlay = None
284
+ overlay.deleteLater()
285
+ anim.finished.connect(_cleanup)
286
+ overlay._anim = anim
287
+ anim.start()
288
+
289
+
290
+ # ──────────────────────── slide for height-collapsible sections ────────────────────────
291
+
292
+
293
+ def slide_in_height(widget: QWidget, duration: int = 200) -> QPropertyAnimation:
294
+ """Reveal ``widget`` by animating its maxHeight from 0 to its natural height.
295
+
296
+ No opacity effect involved, so custom-painted children render normally
297
+ throughout the animation.
298
+ """
299
+ _stop_anim(widget)
300
+ widget.setVisible(True)
301
+ # sizeHint can underreport when the widget hasn't been laid out; ensure
302
+ # the layout has run so target_h is meaningful.
303
+ widget.adjustSize()
304
+ target_h = max(widget.sizeHint().height(), widget.minimumSizeHint().height())
305
+ if target_h <= 0:
306
+ widget.setMaximumHeight(_QWIDGETSIZE_MAX)
307
+ return None # type: ignore[return-value]
308
+ widget.setMaximumHeight(0)
309
+ anim = QPropertyAnimation(widget, b"maximumHeight", widget)
310
+ anim.setDuration(duration)
311
+ anim.setStartValue(0)
312
+ anim.setEndValue(target_h)
313
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
314
+ anim.finished.connect(lambda: widget.setMaximumHeight(_QWIDGETSIZE_MAX))
315
+ widget._anim = anim
316
+ anim.start()
317
+ return anim
318
+
319
+
320
+ def slide_out_height(
321
+ widget: QWidget,
322
+ duration: int = 180,
323
+ on_done: Callable[[], None] | None = None,
324
+ ) -> QPropertyAnimation:
325
+ """Collapse ``widget`` by animating its maxHeight to 0, then hide."""
326
+ _stop_anim(widget)
327
+ current_h = widget.height()
328
+ if current_h <= 0:
329
+ widget.setVisible(False)
330
+ if on_done is not None:
331
+ on_done()
332
+ return None # type: ignore[return-value]
333
+ anim = QPropertyAnimation(widget, b"maximumHeight", widget)
334
+ anim.setDuration(duration)
335
+ anim.setStartValue(current_h)
336
+ anim.setEndValue(0)
337
+ anim.setEasingCurve(QEasingCurve.Type.InCubic)
338
+
339
+ def _finish():
340
+ widget.setVisible(False)
341
+ widget.setMaximumHeight(_QWIDGETSIZE_MAX)
342
+ if on_done is not None:
343
+ on_done()
344
+ anim.finished.connect(_finish)
345
+ widget._anim = anim
346
+ anim.start()
347
+ return anim
348
+
349
+
350
+ # ──────────────────────── list-item collapse for delete ────────────────────────
351
+
352
+
353
+ def collapse_list_item(
354
+ list_widget: QListWidget,
355
+ item: QListWidgetItem,
356
+ duration: int = 200,
357
+ on_done: Callable[[], None] | None = None,
358
+ ) -> QVariantAnimation | None:
359
+ """Animate a list item's height down to 0 (other items shift up smoothly)."""
360
+ start_h = item.sizeHint().height()
361
+ if start_h <= 0:
362
+ if on_done is not None:
363
+ on_done()
364
+ return None
365
+ anim = QVariantAnimation(list_widget)
366
+ anim.setDuration(duration)
367
+ anim.setStartValue(start_h)
368
+ anim.setEndValue(0)
369
+ anim.setEasingCurve(QEasingCurve.Type.InCubic)
370
+
371
+ def _tick(h, it=item):
372
+ it.setSizeHint(QSize(0, max(1, int(h))))
373
+ anim.valueChanged.connect(_tick)
374
+ if on_done is not None:
375
+ anim.finished.connect(on_done)
376
+ list_widget._collapse_anim = anim # keep alive — anims aren't strong-refed
377
+ anim.start()
378
+ return anim
379
+
380
+
381
+ # ──────────────────────── window-opacity for top-level popups ────────────────────────
382
+
383
+
384
+ def fade_window(window: QWidget, target: float, duration: int = 140) -> QPropertyAnimation:
385
+ """Animate ``setWindowOpacity`` — for frameless popups (Qt.Popup etc).
386
+
387
+ Uses the window-compositor path, not the offscreen-pixmap render, so it
388
+ is safe regardless of the popup's painted contents.
389
+ """
390
+ _stop_anim(window)
391
+ anim = QPropertyAnimation(window, b"windowOpacity", window)
392
+ anim.setDuration(duration)
393
+ anim.setStartValue(window.windowOpacity())
394
+ anim.setEndValue(target)
395
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
396
+ window._anim = anim
397
+ anim.start()
398
+ return anim
399
+
400
+
401
+ def fade_window_close(
402
+ window: QWidget,
403
+ duration: int = 120,
404
+ on_done: Callable[[], None] | None = None,
405
+ ) -> QPropertyAnimation:
406
+ """Fade a window's opacity to 0 and then close it (or run ``on_done``)."""
407
+ _stop_anim(window)
408
+ anim = QPropertyAnimation(window, b"windowOpacity", window)
409
+ anim.setDuration(duration)
410
+ anim.setStartValue(window.windowOpacity())
411
+ anim.setEndValue(0.0)
412
+ anim.setEasingCurve(QEasingCurve.Type.InCubic)
413
+
414
+ def _finish():
415
+ if on_done is not None:
416
+ on_done()
417
+ else:
418
+ window.close()
419
+ anim.finished.connect(_finish)
420
+ window._anim = anim
421
+ anim.start()
422
+ return anim
423
+
424
+
425
+ # ──────────────────────── progress bar ────────────────────────
426
+
427
+
428
+ def animate_progress(
429
+ bar: QProgressBar, target: int, duration: int = 280
430
+ ) -> QVariantAnimation | None:
431
+ """Smoothly tween a QProgressBar's value to ``target``."""
432
+ existing = getattr(bar, "_progress_anim", None)
433
+ if isinstance(existing, QVariantAnimation):
434
+ try:
435
+ if existing.state() != QVariantAnimation.State.Stopped:
436
+ existing.stop()
437
+ # Parented to the bar — free it so calls don't accumulate
438
+ # animations as permanent children.
439
+ existing.deleteLater()
440
+ except RuntimeError:
441
+ pass
442
+ bar._progress_anim = None
443
+ start = bar.value()
444
+ if start == target:
445
+ return None
446
+ anim = QVariantAnimation(bar)
447
+ anim.setDuration(duration)
448
+ anim.setStartValue(start)
449
+ anim.setEndValue(target)
450
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
451
+ anim.valueChanged.connect(lambda v: bar.setValue(int(v)))
452
+ bar._progress_anim = anim
453
+ anim.start()
454
+ return anim
455
+
456
+
457
+ # ──────────────────────── smooth wheel scrolling ────────────────────────
458
+
459
+
460
+ class SmoothScrollFilter(QObject):
461
+ """Glide a ``QAbstractScrollArea`` toward an accumulating target on the
462
+ mouse wheel.
463
+
464
+ Each wheel notch extends a single ``_target_value``; a frame timer eases
465
+ the scrollbar toward it with **frame-rate-independent** damping. Because
466
+ the motion is one continuous lerp (not a fresh ease-out per notch), fast
467
+ wheel spins glide smoothly instead of stuttering as each notch restarts a
468
+ deceleration from a standstill.
469
+
470
+ Trackpad / high-precision wheels (which carry a ``pixelDelta``) are left to
471
+ native pixel scrolling — already smooth, lower-latency, and momentum-aware.
472
+
473
+ Tunables: :attr:`PIXELS_PER_NOTCH` (distance per notch) and :attr:`TAU_MS`
474
+ (easing time constant — smaller is snappier, larger is glidier).
475
+ """
476
+
477
+ PIXELS_PER_NOTCH = 110
478
+ TAU_MS = 90.0
479
+
480
+ def __init__(self, target: QAbstractScrollArea):
481
+ super().__init__(target)
482
+ self._target = target
483
+ self._sb = target.verticalScrollBar()
484
+ self._target_value = float(self._sb.value())
485
+ self._driving = False # True while WE are setting the scrollbar value
486
+ self._clock = QElapsedTimer()
487
+ self._clock.start()
488
+ self._timer = QTimer(self)
489
+ # PreciseTimer (not the default CoarseTimer) so the cadence actually
490
+ # tracks the display refresh instead of being snapped to coarse ticks.
491
+ self._timer.setTimerType(Qt.TimerType.PreciseTimer)
492
+ # Interval is (re)computed from the live screen when a glide starts —
493
+ # see eventFilter. The value here is just a sane initial guess; reading
494
+ # the refresh rate now (in __init__, before the window is shown) tends
495
+ # to report the primary screen's 60 Hz even on a 120 Hz panel.
496
+ self._timer.setInterval(self._frame_interval_ms())
497
+ self._timer.timeout.connect(self._tick)
498
+ # Any scrollbar change WE didn't cause (keyboard, drag, programmatic,
499
+ # model reload) re-anchors the target, so the next notch composes
500
+ # against the real value rather than a stale one.
501
+ self._sb.valueChanged.connect(self._on_external_change)
502
+
503
+ def _frame_interval_ms(self) -> int:
504
+ # Escape hatch: if Qt misreports the panel's refresh rate (some Linux
505
+ # setups report 60 Hz for a 120 Hz display), PROJECTUM_SCROLL_FPS forces
506
+ # the cadence, e.g. PROJECTUM_SCROLL_FPS=120.
507
+ override = os.environ.get("PROJECTUM_SCROLL_FPS")
508
+ if override:
509
+ try:
510
+ fps = float(override)
511
+ if fps > 0:
512
+ return max(4, min(33, int(round(1000.0 / fps))))
513
+ except ValueError:
514
+ pass
515
+ # Prefer the screen the window is ACTUALLY shown on (accurate once the
516
+ # window is mapped), then the widget's screen, then the primary screen.
517
+ screen = None
518
+ win = self._target.window() if hasattr(self._target, "window") else None
519
+ handle = win.windowHandle() if win is not None else None
520
+ if handle is not None:
521
+ screen = handle.screen()
522
+ if screen is None and hasattr(self._target, "screen"):
523
+ screen = self._target.screen()
524
+ if screen is None:
525
+ app = QApplication.instance()
526
+ screen = app.primaryScreen() if app is not None else None
527
+ rate = screen.refreshRate() if screen is not None else 0.0
528
+ if rate <= 0:
529
+ rate = 60.0
530
+ return max(6, min(16, int(round(1000.0 / rate))))
531
+
532
+ @classmethod
533
+ def install(cls, view: QAbstractScrollArea) -> "SmoothScrollFilter":
534
+ f = cls(view)
535
+ view.viewport().installEventFilter(f)
536
+ return f
537
+
538
+ def _set_value(self, value: int) -> None:
539
+ # Guard so the resulting valueChanged isn't mistaken for an external
540
+ # change (which would void our target mid-glide).
541
+ self._driving = True
542
+ self._sb.setValue(value)
543
+ self._driving = False
544
+
545
+ def _on_external_change(self, v: int) -> None:
546
+ if not self._driving:
547
+ self._target_value = float(v)
548
+
549
+ def _tick(self) -> None:
550
+ sb = self._sb
551
+ # Re-clamp every frame: a rebuild may have shrunk the range mid-glide.
552
+ self._target_value = max(
553
+ sb.minimum(), min(sb.maximum(), self._target_value)
554
+ )
555
+ cur = sb.value()
556
+ diff = self._target_value - cur
557
+ if abs(diff) < 0.5:
558
+ self._set_value(int(round(self._target_value)))
559
+ self._timer.stop()
560
+ return
561
+ dt = self._clock.restart()
562
+ # Frame-rate-independent exponential damping toward the target.
563
+ alpha = 1.0 - math.exp(-dt / self.TAU_MS) if dt > 0 else 0.5
564
+ step = diff * alpha
565
+ if -1.0 < step < 1.0: # guarantee ≥1px progress so it never crawls
566
+ step = 1.0 if diff > 0 else -1.0
567
+ self._set_value(int(round(cur + step)))
568
+
569
+ def eventFilter(self, _obj, event):
570
+ if event.type() != QEvent.Type.Wheel:
571
+ return False
572
+ # Don't take over modifier-wheel (Ctrl = zoom in most apps).
573
+ if event.modifiers() != Qt.KeyboardModifier.NoModifier:
574
+ return False
575
+ # Trackpad / high-precision wheels → native pixel scrolling.
576
+ if not event.pixelDelta().isNull():
577
+ return False
578
+ delta_y = event.angleDelta().y()
579
+ if delta_y == 0:
580
+ return False
581
+
582
+ sb = self._sb
583
+ if not self._timer.isActive():
584
+ self._target_value = float(sb.value()) # re-anchor when idle
585
+ notches = delta_y / 120.0
586
+ new_target = max(
587
+ sb.minimum(),
588
+ min(sb.maximum(), self._target_value - notches * self.PIXELS_PER_NOTCH),
589
+ )
590
+ if int(round(new_target)) == sb.value() and not self._timer.isActive():
591
+ # Already at the bound — let the event propagate (e.g. a parent
592
+ # scroll area can take over).
593
+ return False
594
+ self._target_value = new_target
595
+ if not self._timer.isActive():
596
+ # Recompute the cadence from the screen the window is currently on
597
+ # (now that it's shown) so a 120 Hz panel actually gets ~8 ms ticks
598
+ # rather than the 60 Hz value read before the window existed.
599
+ self._timer.setInterval(self._frame_interval_ms())
600
+ self._clock.restart()
601
+ self._timer.start()
602
+ return True