Files
zyt/app/src/doctor_workstation/ui/motion.py
T
2026-09-07 12:30:42 +08:00

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