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¶
Scrollable, RAM-bounded gallery of pipeline figures. |
Functions¶
|
Return every text object attached to a Matplotlib figure. |
|
The text size the user set on THIS figure, or 0 for "no override". |
A snapshot of FigureQueues that still have a real owner. |
|
|
Style |
|
Rasterise page 0 of |
|
Remember a per-figure text size, or clear it with |
Module Contents¶
- class spacr.qt.widgets.figure_queue.FigureQueue(ram_cap: int = RAM_CAP, parent=None)[source]¶
Bases:
PySide6.QtWidgets.QWidgetScrollable, 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
QThreadmust 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.
- 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_pngis 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.
- closeEvent(event)[source]¶
Stop background rendering before going away.
- Parameters:
event – the Qt close event.
- 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
Resizeof 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
Noneonly 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_00003tells 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_titlea 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_sectionsreports it.- Returns:
how many figures were dropped.
0for 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_restorable(idx: int) bool[source]¶
Whether
idxcan be made editable again from its spill.- Parameters:
idx – zero-based figure index; true when it is live or its pickled spill file exists.
- 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.
- 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
figatidx, replacing whatever was there.For a redraw that cannot happen in place.
create_grouped_plotbuilds 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;
Noneswaps 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 –
Falseswitches the view to the raster at once;Trueallows 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_keybehind 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.
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
idxbecomes 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
figthrough 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.
- 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
figper the app theme and save it as a display-capped PNG — plus, in PDF mode, a genuinely vector.pdfbeside 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
savefigcalls inspacr.plot,spacr.submodulesand 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
.pdfis 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 tofigitself. 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.
Nonefollows 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_pathand return it as aQImage.The function touches no widget and builds no
QPixmap; the caller must create any pixmap on the GUI thread. It usesQPdfPageRendererinMultiThreadedmode 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 byQThread.quit()— which exits nested event loops too, soFigureQueue._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 threadedJobRunner.Returns
Noneon any failure. A missing file is the most likely one and is not an error:FigureQueuedeletes 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_pxpixels.
- 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.FigureSettingsDialogand read byrender_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
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