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 +4 -0
- projectum/anims.py +602 -0
- projectum/app.py +3247 -0
- projectum/assets/icon.svg +34 -0
- projectum/store.py +552 -0
- projectum/theme.py +1172 -0
- projectum/widgets.py +2053 -0
- projectum/youtube.py +96 -0
- projectum-1.6.0.dist-info/METADATA +201 -0
- projectum-1.6.0.dist-info/RECORD +14 -0
- projectum-1.6.0.dist-info/WHEEL +5 -0
- projectum-1.6.0.dist-info/entry_points.txt +2 -0
- projectum-1.6.0.dist-info/licenses/LICENSE +21 -0
- projectum-1.6.0.dist-info/top_level.txt +1 -0
projectum/__init__.py
ADDED
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
|