spacr.qt.widgets.activity_spinner¶
The background-activity indicator: a braided ring that turns while spaCR is busy, and does not exist on the CPU when it is not.
Why this is not the GIF¶
spacr/resources/icons/loading_spinner.gif is the asset this widget was
asked to reuse, and reuse was rejected on three measurements, not on taste:
Size. 800x600, 144 frames, 682 KB on disk.
QMoviedecodes a frame to a 32-bitQImage, so one frame is 800 x 600 x 4 = 1.92 MB and the whole loop is 276 MB.QMovie.CacheAllreally does hold all of it;CacheNone(the default) trades that for re-decoding 1.92 MB, and rescaling it to 16 px, 33 times a second, forever.Transparency. Every pixel of every frame has alpha 255 and the background is
#000000. The GIF is not a sprite, it is a video of a spinner. Dropped beside “Clear console” it paints an opaque black square, which is invisible in the dark theme and a hole in the light one.Legibility. The motif is a 1-2 px white stroke on an 800 px canvas. Scaled to 16 px that stroke is 0.02 px wide: it does not survive, with or without smooth transformation.
Pre-scaling the frames once into QPixmap objects fixes the per-frame cost
but none of the other two, and it still costs a 276 MB decode pass at start-up
for a 16 px dot. So the motif is redrawn instead: the GIF’s character is a
thin ring with a double-helix braid travelling around it (see the frames –
the braid sweeps, the ring stays), and that is exactly what
ActivitySpinner.paintEvent() draws with two QPolygonF objects. It
costs microseconds, it takes its colours from the live palette so it themes
in both light and dark, and it is sharp at any size.
Measured cost¶
Reported the way spacr.qt.widgets.dna_rain reports its own
(0.53 ms a frame, 3.2 % of one core at 60 fps) – see
tests/qt/test_activity_spinner.py::test_spinner_frame_cost_is_negligible
for the harness that produces the number, and the module-level docstring
there for the figures.
Idle cost is zero, not “small”: _sync() stops the QTimer
outright when the registry goes quiet, so an idle spinner posts no timer
events, schedules no repaints and is hidden as well. There is no invisible
animation running behind an idle window.
What drives it¶
spacr.qt.bridge.registry() – the process-wide RunRegistry that
make_thread adds every job to. Not an ad-hoc “busy” flag each caller has
to remember to set and, more importantly, remember to clear: the registry is
the same state every screen’s active_jobs() is counting, and it is
maintained by make_thread itself, so a job that forgets to tell anyone it
started still turns the spinner on.
When it appears¶
Not immediately. Most of what goes through make_thread – reading a
measurement table, listing a plate, loading a settings file – is finished
inside a second, and an indicator that appears and vanishes in that time is
not information. It is a flicker at the edge of vision, and it teaches the
reader to stop looking at the one place the app says it is busy.
So the widget waits spacr.qt.preferences.get_spinner_delay() seconds
(default 2) before showing. The mechanism is a delay, not a prediction:
ActivitySpinner._sync() starts a single-shot timer the moment work
begins and the spinner becomes visible only if ActivitySpinner.is_busy()
is still true when that timer fires. A job that finishes at 1.9 s cancels
the timer on its way out and never puts anything on screen – there is no
estimate of duration anywhere in this file, and therefore nothing to be wrong
about.
The clock runs on the work, not on the job. A second job starting while the timer is pending does not restart it: the timer is armed on the idle-to-busy edge only, so two seconds of continuous background activity shows the spinner even if no single job lasted that long. Going idle disarms it, and the next burst of work starts a fresh two seconds.
Hiding is not delayed. The moment the registry goes quiet the widget hides and its animation timer stops – a spinner that lingered after the work finished would be saying something untrue.
Classes¶
A small braided ring, visible only while background work is running. |
Functions¶
|
Put an |
Module Contents¶
- class spacr.qt.widgets.activity_spinner.ActivitySpinner(parent: PySide6.QtWidgets.QWidget | None = None, diameter: int = 16, auto: bool = True, delay_ms: int | None = None)[source]¶
Bases:
PySide6.QtWidgets.QWidgetA small braided ring, visible only while background work is running.
It watches
spacr.qt.bridge.registry()and needs no cooperation from the code that starts the work:spinner = ActivitySpinner(parent) # hidden, no timer running
- Parameters:
parent – the usual Qt parent.
diameter – side length in pixels. 16 is the size that sits level with a push button’s text.
auto – watch the run registry.
Falseleaves the widget under manualset_busy()control, which is what the tests use to drive it without spawning threads.delay_ms – how long work has to run before this appears.
None– the default – readsspacr.qt.preferences.get_spinner_delay()at construction, which is the only place that preference is consulted: the widget is built per screen, so a change to it reaches the next screen opened without this file having to watch a settings key.
Create the spinner, hidden until work has been going long enough.
It is transparent for mouse events, so it cannot swallow a click meant for the button beside it. Whether the current stretch of work has earned the spinner is kept as its own flag rather than read back from
isVisible()– a widget can be visible for reasons that have nothing to do with this decision, and reading visibility would let any of them quietly cancel the delay.- Parameters:
parent – parent widget, or
None.diameter – size in pixels.
auto – follow the process-wide run registry rather than being driven by hand.
delay_ms – how long work must run before the spinner appears;
Noneuses the preference.
- hideEvent(event)[source]¶
Stop the timer whenever the widget leaves the screen.
Hiding covers the cases the registry cannot see: the module was switched away from, the window was minimised, the screen closed. A timer left running there is exactly the invisible spin this widget exists to avoid.
The pending appearance delay goes with it, for the same reason: a screen the user has left should not schedule itself back on.
- Parameters:
event – the hide event; passed on to the base class after the animation and delay timers are stopped.
- is_spinning() bool[source]¶
Whether the animation timer is actually running.
The assertion behind “idle costs zero”: when this is
Falsethe widget posts no events at all.
- is_waiting() bool[source]¶
True while work is running but the delay has not elapsed.
The state that makes this a delay rather than a guess: busy, not shown, not spinning, costing nothing but one pending timer.
- paintEvent(event)[source]¶
Draw the ring and the braid travelling around it.
Two
QPolygonFobjects ofBRAID_POINTSpoints each plus one ellipse. No pixmap, no cache to invalidate on a theme change, and nothing to scale.- Parameters:
event – the paint event; not read, the whole widget is redrawn.
- set_busy(busy: bool) None[source]¶
Force the spinner on or off, on top of whatever the registry says.
For work that does not go through
make_thread– a bareQThreadsubclass, aQRunnable– and for tests.- Parameters:
busy – true to force the spinner on, false to leave it to the registry; coerced to
bool.
- set_delay_ms(value: int) None[source]¶
Change the appearance delay. Applies from the next idle-to-busy edge; it never yanks a spinner that is already up off the screen.
- Parameters:
value – the appearance delay in milliseconds; converted to
int, and negative values become 0.
- showEvent(event)[source]¶
Resume only if there is still something to report — and only if the work had already earned the spinner before the screen went away.
Without the
_duehalf of that condition, coming back to a screen would restart the animation for work that started three milliseconds ago, which is precisely the flicker the delay exists to prevent.- Parameters:
event – the show event; passed on to the base class before the animation is resumed.
- spacr.qt.widgets.activity_spinner.attach_activity_spinner(screen: PySide6.QtWidgets.QWidget) ActivitySpinner | None[source]¶
Put an
ActivitySpinnerimmediately right of Clear console.Idempotent: calling it twice on the same screen returns the spinner that is already installed rather than adding a second one, so it is safe from a
showEvent.The button is found by attribute (
screen._btn_clear) and only then by text, because the text is translated –retranslate_widget_treeruns over every screen as it opens, and by the time a user in a non-English locale sees the row the string “Clear console” is not in the tree.- Parameters:
screen – any widget in the tree that owns the button – the
AppScreenitself, or a descendant of it.- Returns:
the spinner, or
Nonewhen this tree has no such button (Annotate, the Database Browser and every other non-AppScreensurface), which is not an error.