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. QMovie decodes a frame to a 32-bit QImage, so one frame is 800 x 600 x 4 = 1.92 MB and the whole loop is 276 MB. QMovie.CacheAll really 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

ActivitySpinner

A small braided ring, visible only while background work is running.

Functions

attach_activity_spinner(→ Optional[ActivitySpinner])

Put an ActivitySpinner immediately right of Clear console.

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.QWidget

A 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. False leaves the widget under manual set_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 – reads spacr.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; None uses the preference.

delay_ms() → int[source]

How long work must run before this widget appears, in ms.

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_busy() → bool[source]

Whether the spinner considers spaCR to be working.

is_spinning() → bool[source]

Whether the animation timer is actually running.

The assertion behind “idle costs zero”: when this is False the 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 QPolygonF objects of BRAID_POINTS points 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 bare QThread subclass, a QRunnable – 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 _due half 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 ActivitySpinner immediately 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_tree runs 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 AppScreen itself, or a descendant of it.

Returns:

the spinner, or None when this tree has no such button (Annotate, the Database Browser and every other non-AppScreen surface), which is not an error.