spacr.qt.widgets.figure_queue

Collect, navigate, and restyle figures emitted by a pipeline run.

The panel combines a thumbnail strip, forward/back controls, a position label, and a zoomable full-resolution view. Every arriving figure is written to a temporary PNG. The newest 100 full-resolution QPixmap objects remain in memory; older entries reload their PNG on demand, while thumbnails stay in memory. Clearing the queue or destroying its owner removes the temporary directory.

In PDF mode, the PNG preview appears immediately and a worker rasterizes the vector page at 2,200 pixels before replacing it. This keeps the GUI responsive during expensive vector rendering; the recorded nine-panel, 16-by-12-inch figure required about 815 ms to rasterize synchronously.

Classes

FigureQueue

Scrollable, RAM-bounded gallery of pipeline figures.

Functions

figure_text_items(fig)

Return every text object attached to a Matplotlib figure.

figure_text_size_override(→ int)

The text size the user set on THIS figure, or 0 for "no override".

live_figure_queues()

A snapshot of FigureQueues that still have a real owner.

render_figure_to_png(→ bool)

Style fig per the app theme and save it as a display-capped PNG —

render_pdf_to_image(pdf_path[, max_px, timeout_ms])

Rasterise page 0 of pdf_path and return it as a QImage.

set_figure_text_size_override(→ None)

Remember a per-figure text size, or clear it with 0.

Module Contents

class spacr.qt.widgets.figure_queue.FigureQueue(ram_cap: int = RAM_CAP, parent=None)[source]

Bases: PySide6.QtWidgets.QWidget

Scrollable, RAM-bounded gallery of pipeline figures.

Parameters:
  • ram_cap – how many bytes of figures the gallery may hold. Older figures are dropped to stay under it, which is what “RAM-bounded” above means – a pipeline emitting hundreds of figures must not grow without limit.

  • parent – parent widget.

Build the figure queue.

Parameters:
  • ram_cap – how many bytes of full-resolution pixmaps to keep in memory; older ones are evicted by least-recent use.

  • parent – parent widget, or None.

__del__()[source]

Best-effort cleanup if the widget is collected without being closed.

The canvas goes first, so its queued idle draw cannot run after Python has released the owning widget; the workers follow, because they read out of the directory about to be removed and a live QThread must not be left holding a runner whose last reference is being dropped; the temporary directory goes last. Every step is guarded – the C++ half may already be gone when Qt initiated the destruction.

active_jobs() → int[source]

How many crisp-render worker threads are still winding down.

add_figure(fig, prerendered_png: str | None = None) → int[source]

Render + append fig (a matplotlib Figure). Returns its index. Re-emitting the same figure object re-selects it instead of duplicating.

prerendered_png is a PNG the pipeline bridge already rendered in a WORKER thread — when supplied we just adopt it (a fast file move + a cheap QPixmap load) instead of doing the expensive savefig on the GUI thread, so the UI stays responsive while many figures stream in.

Parameters:

fig – the Matplotlib figure to append; recognised by object identity, so the same object is never queued twice.

all_pixmaps()[source]

Every figure the queue holds, in order, for the grid view.

Reads the PNG from disk for a figure whose pixmap has been evicted rather than promoting it in the RAM cache: building a grid is a bulk read of everything, and letting it reorder the cache would evict exactly the figures the user is currently looking at.

cache_budget_entries()[source]

Measured, timestamped entries for the process-wide RAM policy.

The selected item is the one a live canvas or zoom view is actively presenting and is pinned. While a preview/render worker is using Figures, every editable Figure is pinned; spilling one concurrently would close matplotlib state under that worker. Full-resolution pixmaps other than the selected one remain safe to reload from PNG.

clear() → None[source]

Drop everything and delete the temp dir.

closeEvent(event)[source]

Stop background rendering before going away.

Parameters:

event – the Qt close event.

count() → int[source]

How many figures are queued.

Returns:

the figure count.

drop_cache_budget_entry(record_key) → bool[source]

Evict one policy-selected entry, rechecking its live-use pin.

Parameters:

record_key – (kind, index) pair, where kind is "figure" (a live Figure, spilled to disk) or "pixmap" (a cached raster); the figure on screen, or a figure while a render is running, is never evicted.

dynamic_figures_enabled() → bool[source]

Whether an evicted figure reloads from its vector page on demand.

eventFilter(obj, event)[source]

Debounce the view’s resizes into one re-render.

Parameters:
  • obj – the watched object; only the figure view is acted on.

  • event – the filtered event; a Resize of the view restarts the resize timer, and every event is passed on to the base class.

figure_for(idx: int)[source]

The live Figure for idx, restoring it from spill if needed.

This is what a restyling menu asks for. A figure inside the live window is returned directly; one past it is unpickled, put back into the live set (so repeated edits do not re-read the disk) and the cap re-applied. Returns None only when the figure was never spillable.

Parameters:

idx – zero-based figure index in the queue.

figure_titles()[source]

A short name per figure, for the grid captions.

THE FIGURE’S OWN NAME FIRST. A caption reading fig_00003 tells a reader nothing – it is the temp file’s stem, which is an implementation detail of how the picture got to the screen. A figure that knows what it is says so: matplotlib’s own label, or the _spacr_title a caller attached.

READ FROM THE NAME RECORDED AT ARRIVAL, not off the live Figure. See _titles – asking the Figure meant the caption vanished the moment the figure was spilled past the live cap, which is every figure but the last twenty on a screen that has done a few runs. The live Figure is still consulted for a slot that has no recorded name, so a figure that acquired one after it arrived is not ignored.

The filename stays as the last fallback, for the pictures that arrive without a name at all.

forget_run(label: str) → int[source]

Remove one run’s figures and compact the queue indices.

Parameters:

label – the run’s section label, as run_sections reports it.

Returns:

how many figures were dropped. 0 for a label this queue does not hold, which is not a failure – a run that drew nothing is a run with nothing to forget.

Figure state is stored in several maps keyed by a dense integer index. Removing a middle section shifts all later figure indices and run boundaries so navigation does not encounter gaps.

has_live_figure(idx: int) → bool[source]

Whether idx’s Figure is in memory right now.

A query, not a use: it does not promote the entry, so asking whether something is live cannot change what gets evicted next.

Parameters:

idx – zero-based figure index in the queue.

is_busy() → bool[source]

True while a crisp vector-page render has not been delivered yet.

is_restorable(idx: int) → bool[source]

Whether idx can be made editable again from its spill.

Parameters:

idx – zero-based figure index; true when it is live or its pickled spill file exists.

live_figure_cap() → int[source]

How many recent figures this machine profile keeps editable.

live_figure_count() → int[source]

How many live Figures are currently retained.

mark_run(label: str = '') → int[source]

Record that a new run’s figures start at the next index.

Called when a run STARTS rather than when its first figure arrives, so a run that draws nothing still shows as a section that drew nothing – which is a fact worth seeing rather than a gap.

Returns:

the index the run starts at.

ram_resident() → int[source]

How many full-res pixmaps are currently held in RAM.

refresh_current_figure(preview: bool = False) → bool[source]

Re-rasterise the figure on screen after something restyled it.

Writes through _render_figure(), so the PNG (at the preference DPI) and its sibling vector page are both rewritten — the format the user asked for is the format the view and the export agree on.

Parameters:

preview – render the raster only, skipping the vector page. A full render writes the PNG and exports a PDF at the preference DPI, which is the better part of a second on a large figure. That is right once; it is ruinous while a control is moving, and doing it per change is what made the settings dialog hang. The vector page is rewritten when the dialog closes, so nothing stays stale.

Returns:

True when the view was updated.

refresh_figure(index: int, preview: bool = False) → bool[source]

Re-rasterise a figure that is NOT the one on screen.

Restyling from a grid tile has to redraw that tile. Without this the edit lands on the matplotlib object, the picture the grid is built from stays as it was, and the user sees a menu that appears to do nothing – so they do it again, and again.

The view is deliberately not touched: the whole point of editing from the grid is that the grid stays put.

Parameters:

index – zero-based figure index in the queue; the figure on screen is refreshed through refresh_current_figure().

replace_figure(idx: int, fig) → bool[source]

Put fig at idx, replacing whatever was there.

For a redraw that cannot happen in place. create_grouped_plot builds its own Figure – spacrGraph makes one and draws into it – so “show this data as a violin instead” produces a NEW object, and everything holding the old one (this queue, the grid tile, the thumbnail) has to be pointed at the new one together or the menu looks broken while the tile keeps the old picture.

Parameters:
  • idx – zero-based index of an existing figure in the queue; out of range swaps nothing.

  • fig – the new Matplotlib figure; None swaps nothing.

Returns:

whether the swap happened.

run_sections()[source]

[(label, start, count)] over the figures held.

Figures that arrived before any run was marked – a figure loaded from disk, or a queue used outside a pipeline – come back as one leading section rather than being dropped.

set_live_canvas_enabled(enabled: bool) → None[source]

Turn the live canvas off to force the raster pipeline.

The raster path is not legacy – a figure spilled past the live window or loaded from a PDF has no Figure to draw and can only be a picture. This makes that path reachable on demand, so the machinery that keeps it off the GUI thread stays under test.

Parameters:

enabled – False switches the view to the raster at once; True allows the live canvas for the next figure shown.

set_propagate_callback(callback) → None[source]

Register callback(dict) for the settings window’s Propagate.

The same seam the Mask live preview and the UMAP explorer use (SettingsWidgets.set_value_for_key behind an owner method), so a value tuned against a finished figure lands in the settings panel and is saved with the run instead of living in a dialog that is about to close. Optional: a queue built in a test has none, and the button says so rather than doing nothing.

Parameters:

callback – called with a dict of setting key to value when the figure settings window’s Propagate is pressed; None (or any non-callable) leaves Propagate with nothing to call.

show_figure_menu(position, idx: int | None = None, navigate: bool = True) → None[source]

Right-click menu for a figure, from the view, a thumbnail or a tile.

The panel had one button offering three controls – background, text colour, text size – and no context menu at all, so a figure could not be restyled by clicking on it.

Parameters:
  • position – global screen position at which Qt opens the context menu.

  • navigate – whether idx becomes the current figure first. The thumbnail strip wants that; the figure grid does not, because a grid is for comparing figures and jumping to one loses the comparison the user was making.

show_index(idx: int) → None[source]

Show one figure by position, ignoring an out-of-range index.

IGNORED RATHER THAN CLAMPED: an index past the end usually means the caller is out of step with the queue, and silently showing the last figure would hide that.

Parameters:

idx – the figure’s position, from 0.

show_live_canvas(fig) → bool[source]

Show fig through matplotlib itself. True if the canvas is up.

This is what makes a figure crisp: the canvas redraws from the Figure at the widget’s device resolution every time it changes size or zoom, so there is never a raster being stretched to fit. It is also what makes it fast – looking at a figure costs no render at all.

Parameters:

fig – the Matplotlib figure to embed in a Qt canvas with its navigation toolbar; the canvas already showing it is just redrawn.

show_next() → None[source]

Show the next figure.

show_prev() → None[source]

Show the previous figure.

spilled_count() → int[source]

How many figures have been evicted from RAM to disk-only.

spacr.qt.widgets.figure_queue.figure_text_items(fig)[source]

Return every text object attached to a Matplotlib figure.

Includes axis titles and labels, tick labels, annotations, legends and legend titles, the figure title, and text stored directly on the figure. The function has no Qt dependency and is safe to call from the figure rendering worker.

Parameters:

fig – Matplotlib figure to inspect.

Returns:

Text objects in traversal order, with duplicate objects removed.

spacr.qt.widgets.figure_queue.figure_text_size_override(fig) → int[source]

The text size the user set on THIS figure, or 0 for “no override”.

Parameters:

fig – a Matplotlib figure; the size is read from an attribute set by set_figure_text_size_override(), and a missing or unreadable value gives 0.

spacr.qt.widgets.figure_queue.live_figure_queues()[source]

A snapshot of FigureQueues that still have a real owner.

spacr.qt.widgets.figure_queue.render_figure_to_png(fig, png_path: str, *, for_print: bool = False, write_pdf: bool | None = None) → bool[source]

Style fig per the app theme and save it as a display-capped PNG — plus, in PDF mode, a genuinely vector .pdf beside it (_export_vector_pdf()). Pure matplotlib — no Qt — so it is SAFE TO CALL FROM A WORKER THREAD, which is how the pipeline bridge keeps the GUI responsive while lots of figures are produced.

Returns True once the PNG — the raster the GUI actually displays — is on disk. A failed sibling PDF does not turn that into False, and the asymmetry is deliberate rather than sloppy: the callers turn False into “no pixmap, no thumbnail”, so reporting a missing export that way would delete the figure from the gallery over a file nothing has asked for yet. It is logged at WARNING instead, and FigureQueue._request_pdf_refinement() notices the absent page, says so, and stops waiting for a render that will never arrive.

Note what the DPI preference does and does not reach. It sets the PNG’s resolution (subject to the display cap below) and the resolution of any raster inside the PDF. It does not reach the figures a pipeline saves to its own results directory: those go through savefig calls in spacr.plot, spacr.submodules and friends, which hard-code their own format and DPI and never consult preferences at all.

Parameters:
  • fig – the Matplotlib figure; it is restyled in place with the theme’s colours and text size before saving.

  • png_path – destination PNG path; in PDF mode the .pdf is written beside it with the same stem.

  • for_print – True for a graph the user is SAVING to a file. The files get the white print style (PRINT_BACKGROUND, PRINT_INK) whatever the screen theme, are written from a detached copy so the figure on screen keeps its colours, and the PNG is not display-capped. False (every gallery and canvas render) is unchanged: theme colours, applied to fig itself. Decision 2026-09-25: “saved graphs (PDF/PNG) get a WHITE PRINT STYLE (white background, dark text/axes/lines) whatever the screen theme”; a file is opened by a PDF reader, a printer or a journal, and white text on a transparent page disappears on every one of them.

  • write_pdf – whether to write the sibling PDF. None follows the figure-format preference; a save dialog passes the user’s choice.

spacr.qt.widgets.figure_queue.render_pdf_to_image(pdf_path: str, max_px: int = PDF_DISPLAY_MAX_PX, timeout_ms: int = 30000)[source]

Rasterise page 0 of pdf_path and return it as a QImage.

The function touches no widget and builds no QPixmap; the caller must create any pixmap on the GUI thread. It uses QPdfPageRenderer in MultiThreaded mode and waits in a nested event loop on the calling worker, allowing the GUI thread to remain responsive.

The wait is bounded twice over: by timeout_ms, and by QThread.quit() — which exits nested event loops too, so FigureQueue._shutdown_jobs() can still stop a render in flight.

Every QObject created here is unparented so its ownership does not cross thread-affinity boundaries.

Call this from a worker thread only. On the GUI thread the nested loop would re-enter the application’s own event loop and deliver user input in the middle of a render — reentrancy, not a freeze, but no better. FigureQueue._request_pdf_refinement() always submits it to a threaded JobRunner.

Returns None on any failure. A missing file is the most likely one and is not an error: FigureQueue deletes its temp directory when it closes, and a render already in flight is expected to survive that rather than raise on the worker thread.

Parameters:

pdf_path – path to the PDF; only its first page is rendered, scaled so its longer side is max_px pixels.

spacr.qt.widgets.figure_queue.set_figure_text_size_override(fig, size: int) → None[source]

Remember a per-figure text size, or clear it with 0.

Written by the per-figure control in spacr.qt.widgets.figure_settings.FigureSettingsDialog and read by render_figure_to_png(), which is the whole point: without it the next full render puts the global preference straight back over the user’s choice, which is issue #108’s “the font size has been returned to 10”.

Parameters:
  • fig – the Matplotlib figure to tag; a figure that refuses the attribute is logged and left alone.

  • size – text size in points; 0 (or a negative value) clears the override.

Nested helpers

FigureQueue._render_preview_async.work(_blob=blob, _target=target, _token=token, _idx=self._current, _face=facecolor)

Render one preview off the GUI thread.

Everything it needs is bound as a DEFAULT ARGUMENT rather than closed over, so a preview that starts while the user is scrolling renders the figure it was asked for rather than whichever one is current when it runs.

spacr/qt/widgets/figure_queue.py:1884

FigureQueue.forget_run._shift(mapping)

Reindex a mapping after a run is removed, keeping its type.

spacr/qt/widgets/figure_queue.py:1317

FigureQueue.show_figure_menu._redraw(preview=False, _i=index)

Told a toggle happened, or handed a whole new Figure.

“Show as” produces a NEW figure (see figure_settings._replot), so this doubles as the swap: anything that is not a bool is the replacement, and everything holding the old one – this queue, the grid tile, the thumbnail – is pointed at it together.

spacr/qt/widgets/figure_queue.py:1033

render_pdf_to_image._page_rendered(_page, _size, image, _options, _request_id)

Take the rendered page. Queued back onto THIS thread.

Which is why it is a handful of lines: it is the only Python holding the GIL while Qt’s render thread is working.

spacr/qt/widgets/figure_queue.py:523