365 lines
12 KiB
Python
365 lines
12 KiB
Python
"""Motion tokens and helpers.
|
|
|
|
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
|
|
at all, so every state change was an instant cut: pages replaced each other
|
|
between one frame and the next, drawers appeared fully formed, toasts blinked
|
|
in and out. Nothing was slow - it just gave the eye no continuity to follow,
|
|
which is what reads as "not smooth" however fast the code underneath is.
|
|
|
|
Everything here is short. A workstation is used all day, so transitions are
|
|
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
|
|
measured in single-digit pixels. Anything longer starts costing the user time.
|
|
|
|
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
|
|
throughout. Two rules keep that safe:
|
|
|
|
* an animation must be owned, or PySide garbage-collects it mid-flight and the
|
|
widget freezes half-faded - :func:`_own` parks it on the target;
|
|
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
|
|
offscreen render path, which would make a table scroll badly for the rest of
|
|
the session - every fade here removes its effect when it finishes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from PySide6.QtCore import (
|
|
QAbstractAnimation,
|
|
QEasingCurve,
|
|
QEvent,
|
|
QObject,
|
|
QPoint,
|
|
QPropertyAnimation,
|
|
Qt,
|
|
QTimer,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QAbstractScrollArea,
|
|
QGraphicsOpacityEffect,
|
|
QStackedWidget,
|
|
QWidget,
|
|
)
|
|
|
|
#: Durations in milliseconds.
|
|
FAST = 110 # hover-scale feedback, small fades
|
|
BASE = 170 # the default: page and panel transitions
|
|
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
|
|
|
|
#: Entering elements decelerate; elements that move between two known places
|
|
#: ease in and out; large travel gets a longer tail so it never looks linear.
|
|
EASE_ENTER = QEasingCurve.Type.OutCubic
|
|
EASE_MOVE = QEasingCurve.Type.InOutCubic
|
|
EASE_TRAVEL = QEasingCurve.Type.OutQuint
|
|
|
|
#: How far an entering surface rises, in device-independent pixels. Kept small
|
|
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
|
|
RISE = 8
|
|
|
|
|
|
def reduced_motion() -> bool:
|
|
"""Whether animation should be skipped entirely.
|
|
|
|
Off by default under the offscreen platform so widget grabs in tests and in
|
|
the packaging smoke checks capture a settled frame rather than a frame from
|
|
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
|
|
"""
|
|
|
|
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
|
|
if override in {"off", "0", "false", "none", "reduce"}:
|
|
return True
|
|
if override in {"on", "1", "true", "full"}:
|
|
return False
|
|
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
|
|
|
|
|
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
|
"""Park an animation on its target so Python does not collect it early."""
|
|
|
|
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
|
previous = running.get(key)
|
|
if previous is not None:
|
|
previous.stop()
|
|
running[key] = animation
|
|
target._doctor_motion = running
|
|
return animation
|
|
|
|
|
|
def animate(
|
|
target: Any,
|
|
prop: bytes,
|
|
start: Any,
|
|
end: Any,
|
|
*,
|
|
duration: int = BASE,
|
|
easing: QEasingCurve.Type = EASE_ENTER,
|
|
key: str | None = None,
|
|
owner: QWidget | None = None,
|
|
on_finished: Callable[[], None] | None = None,
|
|
) -> QPropertyAnimation | None:
|
|
"""Animate one Qt property, or apply the end value outright if motion is off.
|
|
|
|
``owner`` keeps the animation alive independently of ``target``. Fades
|
|
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
|
|
ends, so parenting the animation to the effect would destroy the animation
|
|
from inside its own ``finished`` emission.
|
|
"""
|
|
|
|
if reduced_motion():
|
|
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
|
if on_finished is not None:
|
|
on_finished()
|
|
return None
|
|
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
|
|
animation.setDuration(duration)
|
|
animation.setEasingCurve(easing)
|
|
animation.setStartValue(start)
|
|
animation.setEndValue(end)
|
|
if on_finished is not None:
|
|
animation.finished.connect(on_finished)
|
|
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
|
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
|
return animation
|
|
|
|
|
|
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
|
effect = widget.graphicsEffect()
|
|
if not isinstance(effect, QGraphicsOpacityEffect):
|
|
effect = QGraphicsOpacityEffect(widget)
|
|
widget.setGraphicsEffect(effect)
|
|
effect.setEnabled(True)
|
|
return effect
|
|
|
|
|
|
def _drop_effect(widget: QWidget) -> None:
|
|
"""Detach the opacity effect once a fade is done.
|
|
|
|
Leaving it attached keeps the widget on Qt's offscreen composite path, which
|
|
is exactly the sort of quiet, permanent frame-rate tax this module exists to
|
|
avoid introducing.
|
|
|
|
The detach is deferred by one event-loop turn on purpose. ``finished`` is
|
|
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
|
|
the old effect immediately - tearing down the object graph underneath a
|
|
signal that is still being delivered.
|
|
"""
|
|
|
|
def detach() -> None:
|
|
try:
|
|
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
|
widget.setGraphicsEffect(None)
|
|
except RuntimeError: # the widget went away while the fade was running
|
|
pass
|
|
|
|
QTimer.singleShot(0, detach)
|
|
|
|
|
|
def fade_in(
|
|
widget: QWidget,
|
|
*,
|
|
duration: int = BASE,
|
|
start: float = 0.0,
|
|
easing: QEasingCurve.Type = EASE_ENTER,
|
|
) -> None:
|
|
"""Fade a widget up to full opacity, showing it first if needed."""
|
|
|
|
if reduced_motion():
|
|
widget.show()
|
|
return
|
|
effect = _opacity_effect(widget)
|
|
effect.setOpacity(start)
|
|
widget.show()
|
|
animate(
|
|
effect,
|
|
b"opacity",
|
|
start,
|
|
1.0,
|
|
duration=duration,
|
|
easing=easing,
|
|
key="fade",
|
|
owner=widget,
|
|
on_finished=lambda: _drop_effect(widget),
|
|
)
|
|
|
|
|
|
def fade_out(
|
|
widget: QWidget,
|
|
*,
|
|
duration: int = FAST,
|
|
hide: bool = True,
|
|
on_finished: Callable[[], None] | None = None,
|
|
) -> None:
|
|
"""Fade a widget down, optionally hiding it when the fade completes."""
|
|
|
|
if reduced_motion():
|
|
if hide:
|
|
widget.hide()
|
|
if on_finished is not None:
|
|
on_finished()
|
|
return
|
|
effect = _opacity_effect(widget)
|
|
|
|
def done() -> None:
|
|
if hide:
|
|
widget.hide()
|
|
_drop_effect(widget)
|
|
if on_finished is not None:
|
|
on_finished()
|
|
|
|
animate(
|
|
effect,
|
|
b"opacity",
|
|
float(effect.opacity()),
|
|
0.0,
|
|
duration=duration,
|
|
easing=EASE_MOVE,
|
|
key="fade",
|
|
owner=widget,
|
|
on_finished=done,
|
|
)
|
|
|
|
|
|
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
|
"""Fade a surface in while it settles upward by a few pixels.
|
|
|
|
The rise is what makes a swap read as one surface replacing another rather
|
|
than as a repaint; keeping it under ten pixels stops it becoming a gesture
|
|
the user has to wait out.
|
|
"""
|
|
|
|
if reduced_motion():
|
|
widget.show()
|
|
return
|
|
fade_in(widget, duration=duration)
|
|
if rise:
|
|
origin = widget.pos()
|
|
widget.move(origin + QPoint(0, rise))
|
|
animate(
|
|
widget,
|
|
b"pos",
|
|
widget.pos(),
|
|
origin,
|
|
duration=duration,
|
|
easing=EASE_ENTER,
|
|
key="enter",
|
|
)
|
|
|
|
|
|
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
|
|
"""Change the current page of a stack with a short cross-fade.
|
|
|
|
``QStackedWidget`` swaps pages between two frames with nothing in between,
|
|
which is the single most-seen transition in this product - it happens on
|
|
every sidebar click and on every list that toggles to its empty state.
|
|
"""
|
|
|
|
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
|
|
stack.setCurrentIndex(index)
|
|
return
|
|
stack.setCurrentIndex(index)
|
|
page = stack.currentWidget()
|
|
if page is None or reduced_motion():
|
|
return
|
|
enter(page, rise=rise)
|
|
|
|
|
|
# --- Smooth scrolling -----------------------------------------------------
|
|
|
|
#: One wheel notch travels this far, and takes this long to get there. Qt's
|
|
#: default is an instant jump of three lines per notch, which on a long clinical
|
|
#: record is the single jerkiest thing in the interface.
|
|
SCROLL_STEP = 120
|
|
SCROLL_MS = 190
|
|
|
|
|
|
class _SmoothScroller(QObject):
|
|
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
|
|
|
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
|
super().__init__(area)
|
|
self._bar = (
|
|
area.verticalScrollBar()
|
|
if orientation is Qt.Orientation.Vertical
|
|
else area.horizontalScrollBar()
|
|
)
|
|
self._target = self._bar.value()
|
|
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
|
self._animation.setEasingCurve(EASE_ENTER)
|
|
self._animation.setDuration(SCROLL_MS)
|
|
# Keyboard, programmatic and drag movements must not be fought over: when
|
|
# nothing is animating, the wheel target follows wherever the bar went.
|
|
self._bar.valueChanged.connect(self._sync_target)
|
|
area.viewport().installEventFilter(self)
|
|
|
|
def _sync_target(self, value: int) -> None:
|
|
if self._animation.state() != QAbstractAnimation.State.Running:
|
|
self._target = value
|
|
|
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
|
del watched
|
|
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
|
return False
|
|
delta = event.angleDelta().y() or event.angleDelta().x()
|
|
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
return False
|
|
lower, upper = self._bar.minimum(), self._bar.maximum()
|
|
# Re-clamp first: the range can shrink underneath a running animation
|
|
# when the content behind it reloads, which would otherwise leave the
|
|
# pending target past the end of the new content.
|
|
self._target = max(lower, min(upper, self._target))
|
|
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
|
target = max(lower, min(upper, target))
|
|
# At either end, hand the wheel back so an enclosing scroll area still
|
|
# gets it - swallowing it there is what makes nested panes feel stuck.
|
|
if target == self._target:
|
|
return False
|
|
self._target = target
|
|
self._animation.stop()
|
|
self._animation.setStartValue(self._bar.value())
|
|
self._animation.setEndValue(target)
|
|
self._animation.start()
|
|
return True
|
|
|
|
|
|
def install_smooth_scroll(
|
|
area: QAbstractScrollArea,
|
|
*,
|
|
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
|
) -> None:
|
|
"""Give a scroll area eased wheel scrolling."""
|
|
|
|
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
|
return
|
|
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
|
|
|
|
|
|
def press_feedback(widget: QWidget) -> None:
|
|
"""Mark a widget so the shared stylesheet can give it a pressed transform.
|
|
|
|
Qt has no CSS transitions, so the visual step itself lives in the palette's
|
|
pressed state; this only tags the widget as one that should get it.
|
|
"""
|
|
|
|
widget.setProperty("motionPress", True)
|
|
|
|
|
|
__all__ = [
|
|
"BASE",
|
|
"EASE_ENTER",
|
|
"EASE_MOVE",
|
|
"EASE_TRAVEL",
|
|
"FAST",
|
|
"RISE",
|
|
"SLOW",
|
|
"animate",
|
|
"enter",
|
|
"fade_in",
|
|
"fade_out",
|
|
"install_smooth_scroll",
|
|
"press_feedback",
|
|
"reduced_motion",
|
|
"switch_stack",
|
|
]
|