Source code for spacr.qt.widgets.figure_settings

"""Restyle and export live Matplotlib figures from the Qt interface.

The settings dialog builds its controls from the artists present in a figure,
so only applicable options are shown. It can update data-dependent properties,
such as axis scales, without rerunning the analysis. This module also manages
reusable graph-style files and optional data, statistics, and caption sidecars.
"""

from __future__ import annotations

import logging
import math
import os
from functools import partial
from json import JSONDecodeError
from typing import Callable, Optional

import numpy

LOG = logging.getLogger(__name__)

from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtGui import QAction, QColor
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QDoubleSpinBox,
    QFormLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMenu,
    QPushButton,
    QScrollArea,
    QSpinBox,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)

from .colour_picker import pick_colour
from ..i18n import tr

#: Axis scales offered by the figure settings dialog. ``symlog`` supports
#: signed values that cannot be represented on a standard logarithmic scale.
AXIS_SCALES = ("linear", "log", "symlog")

LEGEND_LOCATIONS = (
    "best", "upper right", "upper left", "lower left", "lower right",
    "right", "center left", "center right", "lower center", "upper center",
    "center",
)

LINE_STYLES = (("-", "Solid"), ("--", "Dashed"), ("-.", "Dash-dot"),
               (":", "Dotted"), ("None", "None"))


def _as_hex(colour, fallback: str = "#1f77b4") -> str:
    """Any matplotlib colour spec as ``#rrggbb``.

    matplotlib hands back whatever it stored: an RGBA tuple from
    ``patch.get_facecolor()``, a named colour, a float grey, or an ARRAY of
    RGBA rows from a collection. ``QColor`` accepts none of those, and passing
    a tuple raised ``TypeError: QVariant must be holding a QColor`` the moment
    a colour button was clicked -- so every colour control in this dialog was
    dead on arrival.
    """
    try:
        from matplotlib.colors import to_hex
        import numpy as np

        value = colour
        if isinstance(value, np.ndarray):
            value = value[0] if value.ndim > 1 and len(value) else value
        elif isinstance(value, (list, tuple)) and len(value) \
                and isinstance(value[0], (list, tuple, np.ndarray)):
            value = value[0]
        return to_hex(value, keep_alpha=False)
    except Exception:
        return fallback


def _colour_button(initial, on_pick: Callable[[str], None]) -> QPushButton:
    """A button showing a colour that opens a picker."""
    button = QPushButton()
    state = {"colour": _as_hex(initial)}

    def _paint():
        """Show the current colour on the button, as a swatch and as text."""
        colour = QColor(state["colour"])
        button.setText(state["colour"])
        if colour.isValid():
            button.setStyleSheet(
                f"background-color: {colour.name()}; "
                f"color: {'#000' if colour.lightness() > 127 else '#fff'};")

    def _choose():
        """Ask for a colour and keep it if the dialog returned one."""
        colour = pick_colour(button, state["colour"])
        if colour.isValid():
            state["colour"] = colour.name()
            _paint()
            on_pick(colour.name())

    button.clicked.connect(_choose)
    _paint()
    return button


def _series_of(axis):
    """Every restylable series on ``axis``, as ``(label, artist)`` pairs.

    Lines and collections (a scatter is a collection) are what a user means by
    "the data". Named series come first so a legend label is what they are
    picked by rather than an index.
    """
    series = []
    for index, line in enumerate(axis.lines):
        label = line.get_label()
        if not label or label.startswith("_"):
            label = f"line {index + 1}"
        series.append((label, line))
    for index, collection in enumerate(axis.collections):
        label = collection.get_label()
        if not label or label.startswith("_"):
            label = f"points {index + 1}"
        series.append((label, collection))
    return series


[docs] class FigureSettingsDialog(QDialog): """Edit the supported appearance settings of a live figure. Controls are created from the figure's current axes, artists, legends, and optional spaCR metadata. Changes are previewed after a short debounce; rejecting the dialog restores the opening state when it could be captured. """ #: Debounce interval in milliseconds between an edit and its preview. REDRAW_DELAY_MS = 60 def __init__(self, figure, parent=None, *, on_change: Optional[Callable] = None, propagate_callback: Optional[Callable] = None): """Build the figure settings dialog with a live preview. A pickled snapshot of the figure is taken so Cancel has something to go back to: live apply with no way out is a trap -- the user drags a spin box to see what it does and there is no longer an "as it was". The per-figure text-size override is kept separately, because Cancel restores the figure by copying axes out of the snapshot rather than by swapping the object, so that attribute would otherwise survive an undo of everything it applies to. The Statistics tab appears only for a figure that compares groups: one offering a t-test on a Q-Q plot would be an invitation to report a number that means nothing. The UMAP tab appears only for a figure carrying the embedding it was drawn from -- without it, "live" would mean re-running the reduction and every point would move. :param figure: the matplotlib figure to restyle. :param parent: parent widget, or ``None``. :param on_change: called to redraw; takes ``preview`` when it can. :param propagate_callback: writes the values into the owning module's settings panel. ``None`` disables Propagate and says why. """ super().__init__(parent) self.setWindowTitle("Figure settings") self._figure = figure self._on_change = on_change self._propagate_cb = propagate_callback self.resize(520, 640) self._snapshot = None try: import pickle self._snapshot = pickle.dumps(figure) except Exception: pass try: from .figure_queue import figure_text_size_override self._text_size_at_open = figure_text_size_override(figure) except Exception: self._text_size_at_open = 0 #: Whether a preview render is currently running. self._rendering = False #: Whether another preview is required after the current render. self._dirty = False self._redraw = QTimer(self) self._redraw.setSingleShot(True) self._redraw.timeout.connect(self._redraw_now) layout = QVBoxLayout(self) self.tabs = QTabWidget(self) layout.addWidget(self.tabs) self.tabs.addTab(self._scroll(self._figure_tab()), "Figure") if getattr(figure, "_spacr_groups", None): self.tabs.addTab(self._scroll(self._statistics_tab()), "Statistics") for index, axis in enumerate(figure.axes): name = axis.get_title() or f"Axes {index + 1}" self.tabs.addTab(self._scroll(self._axes_tab(axis)), name[:18]) self._umap_settings = None self._umap_payload = getattr(figure, "_spacr_umap_payload", None) self._umap_applied = {} if isinstance(self._umap_payload, dict): self._build_umap_tab() self._block_wheel_on_inputs() buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) self._propagate_btn = QPushButton("Propagate settings") if callable(propagate_callback): self._propagate_btn.setToolTip( "Write these values into the module's settings panel, so the " "next run starts from them and they are saved with it.") else: self._propagate_btn.setEnabled(False) self._propagate_btn.setToolTip( "Only available for a figure opened from a module that has a " "settings panel to write into.") self._propagate_btn.clicked.connect(self._propagate) buttons.addButton(self._propagate_btn, QDialogButtonBox.ActionRole) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _build_umap_tab(self) -> None: """Add every Image UMAP setting, live against this figure.""" try: from .umap_figure_settings import UmapFigureSettings except Exception: return values = dict(self._umap_payload.get("settings") or {}) self._umap_settings = UmapFigureSettings(values, self) self._umap_settings.settings_changed.connect(self._on_umap_changed) self.tabs.addTab(self._scroll(self._umap_settings), "Image UMAP") self._umap_applied = dict(self._umap_settings.values()) def _on_umap_changed(self, values: dict) -> None: """Push a changed Image UMAP setting at the figure, now. The embedding is read, never recomputed -- see :func:`spacr.qt.widgets.umap_figure_settings.redraw_umap_figure`. """ from .umap_figure_settings import apply_to_figure mode = apply_to_figure(self._figure, self._umap_payload, values, self._umap_applied) self._umap_applied = dict(values) if mode: self._changed()
[docs] def umap_values(self) -> dict: """Return the current Image UMAP figure settings. Returns ------- dict Current settings, or an empty dictionary when the figure has no Image UMAP controls. """ if self._umap_settings is None: return {} return self._umap_settings.values()
def _propagate(self) -> None: """Send the current values into the module's settings panel.""" if not callable(self._propagate_cb): return values = dict(self.umap_values()) try: self._propagate_cb(values) except Exception: pass
[docs] def reject(self): """Restore the opening figure state and close the dialog. Restoration is best-effort when the figure could not be serialized or an artist cannot be reconstructed. """ if self._snapshot is not None: try: import pickle restored = pickle.loads(self._snapshot) try: self._figure.clear() for axis in list(restored.axes): axis.remove() axis.set_figure(self._figure) self._figure.add_axes(axis) self._figure.patch.set_facecolor(restored.patch.get_facecolor()) self._figure.set_size_inches(*restored.get_size_inches()) self._changed() finally: from .figure_queue import _close_pyplot_figures _close_pyplot_figures((restored,)) except Exception: pass try: from .figure_queue import set_figure_text_size_override set_figure_text_size_override( self._figure, getattr(self, "_text_size_at_open", 0)) except Exception: pass super().reject()
#: Input widget types that receive wheel events only while focused. _WHEEL_STEALERS = (QSpinBox, QDoubleSpinBox, QComboBox) #: Maximum number of series that receive individual appearance controls. SERIES_DETAIL_LIMIT = 8 #: Palettes offered when an axes exceeds :attr:`SERIES_DETAIL_LIMIT`. PALETTES = ("tab10", "tab20", "Set1", "Set2", "Set3", "Dark2", "Paired", "Accent", "viridis", "plasma", "cividis", "coolwarm") def _add_series_rules(self, form, axis, series) -> None: """Add shared styling controls for axes with many series. A single palette, size, and opacity rule applies across the complete series set instead of presenting one control per mark. """ form.addRow(QLabel(f"— {len(series)} series —")) note = QLabel( "Too many series to style one by one, so these rules apply " "across all of them.") note.setWordWrap(True) form.addRow(note) palette = QComboBox() palette.addItem("Keep current colours", None) for name in self.PALETTES: palette.addItem(name, name) def apply_palette(*_): """Recolour every series from the chosen palette.""" name = palette.currentData() if not name: return import matplotlib as mpl colormap = mpl.colormaps[name] count = max(len(series), 1) for index, (_label, artist) in enumerate(series): colour = (colormap(index % colormap.N) if colormap.N <= 32 else colormap(index / max(count - 1, 1))) try: artist.set_color(colour) except Exception: pass self._changed() palette.currentIndexChanged.connect(apply_palette) form.addRow("Palette", palette) size = QDoubleSpinBox() size.setRange(1.0, 600.0) size.setValue(36.0) def apply_size(value): """Resize every series that has a size to set.""" for _label, artist in series: if hasattr(artist, "set_sizes"): artist.set_sizes([value]) elif hasattr(artist, "set_markersize"): artist.set_markersize(value ** 0.5) self._changed() size.valueChanged.connect(apply_size) form.addRow("Point size (all)", size) opacity = QDoubleSpinBox() opacity.setRange(0.05, 1.0) opacity.setSingleStep(0.05) opacity.setValue(1.0) def apply_opacity(value): """Set the alpha on every series.""" for _label, artist in series: artist.set_alpha(value) self._changed() opacity.valueChanged.connect(apply_opacity) form.addRow("Opacity (all)", opacity) edge = QDoubleSpinBox() edge.setRange(0.0, 5.0) edge.setSingleStep(0.1) edge.setValue(0.0) def apply_edge(value): """Set the edge width on every series that has one.""" for _label, artist in series: if hasattr(artist, "set_linewidth"): artist.set_linewidth(value) self._changed() edge.valueChanged.connect(apply_edge) form.addRow("Outline width (all)", edge) def _block_wheel_on_inputs(self) -> None: """Let inputs take the wheel only once they are deliberately focused. ``findChildren`` takes ONE type per call in PySide6, not a tuple, so this loops -- passing a tuple raises TypeError and the whole dialog fails to construct. """ for kind in self._WHEEL_STEALERS: for widget in self.findChildren(kind): widget.setFocusPolicy(Qt.StrongFocus) widget.installEventFilter(self)
[docs] def eventFilter(self, obj, event): # noqa: N802 - Qt name """Prevent unfocused inputs from consuming scroll-wheel events. Parameters ---------- obj : PySide6.QtCore.QObject Object receiving the event. event : PySide6.QtCore.QEvent Event being filtered. Returns ------- bool ``True`` when an unfocused input's wheel event was consumed; otherwise the result from the parent event filter. """ if (event.type() == QEvent.Wheel and isinstance(obj, self._WHEEL_STEALERS) and not obj.hasFocus()): event.ignore() return True return super().eventFilter(obj, event)
[docs] def closeEvent(self, event): # noqa: N802 - Qt name """Complete a full-quality redraw before closing the dialog. Parameters ---------- event : PySide6.QtGui.QCloseEvent Qt close event forwarded to the parent implementation. """ if self._redraw.isActive(): self._redraw.stop() self._redraw_now(preview=False) else: self._redraw_now(preview=False) super().closeEvent(event)
@staticmethod def _scroll(widget: QWidget) -> QScrollArea: """Wrap a tab page in a scroll area. :param widget: the page. :returns: the scroll area holding it. """ area = QScrollArea() area.setWidgetResizable(True) area.setWidget(widget) return area def _changed(self) -> None: """Ask for a redraw. Every control calls this. Live feedback rather than an OK button, because restyling is a judgement made by looking -- 'is this legend small enough yet' is not answerable from a dialog that only applies on close. But *immediate* feedback is what froze the app: a render rewrites the raster and the vector page, and a spin box emits a value per step. So the redraw is debounced, and the one that lands mid-edit is a cheap preview. """ self._redraw.start(self.REDRAW_DELAY_MS) def _redraw_now(self, preview: bool = True) -> None: """Redraw the figure, never letting renders stack. A preview blocks the GUI thread for about 150 ms, and Qt keeps delivering events during it -- spin-box auto-repeat, the wheel, this timer. Without the guard each one lands another render behind the current one, the queue grows faster than it drains, and the window stops responding. A request arriving mid-render only sets a flag, and one final redraw runs afterwards: the thread is always free between renders and the picture still ends up matching the controls. :param preview: render at preview quality rather than full. """ if self._on_change is None: return if self._rendering: self._dirty = True return self._rendering = True try: try: self._on_change(preview=preview) except TypeError: self._on_change() finally: self._rendering = False if self._dirty: self._dirty = False self._redraw.start(self.REDRAW_DELAY_MS) def _statistics_tab(self) -> QWidget: """Build the statistical-test controls and show the resolved choice. The tab is available only for figures that carry comparable groups. Automatic selection is the default and reports the chosen test and rationale; an explicit test remains available for design information the data alone cannot infer. """ from ...figures import stats as stats_module page = QWidget() form = QFormLayout(page) groups = dict(getattr(self._figure, "_spacr_groups", {}) or {}) self._stats_state = {"test": None, "alpha": 0.05, "correction": "fdr_bh", "unit": "coefficient"} form.addRow(QLabel(", ".join( f"{label} (n={len(values)})" for label, values in groups.items()))) test = QComboBox() test.addItem("automatic — chosen from the data", None) for name in ("Student's t", "Welch's t", "Mann-Whitney U", "one-way ANOVA", "Welch's ANOVA", "Kruskal-Wallis", "paired t", "Wilcoxon signed-rank"): test.addItem(name, name) test.setToolTip( "Automatic chooses a test from the group count, Levene's test " "for equal variance, and Shapiro-Wilk tests for normality. If an " "assumption check has too few values to run, that assumption is " "treated as unmet.") form.addRow("Test", test) alpha = QDoubleSpinBox() alpha.setDecimals(3) alpha.setRange(0.001, 0.5) alpha.setSingleStep(0.005) alpha.setValue(0.05) form.addRow("Alpha", alpha) correction = QComboBox() try: from ...multiple_testing import METHODS for key in METHODS: correction.addItem(key, key) correction.setCurrentText("fdr_bh") except Exception: correction.addItem("fdr_bh", "fdr_bh") correction.setToolTip( "Adjust p-values across all comparisons shown in this panel. " "Without correction, six independent tests at alpha 0.05 have " "about a 26% chance of at least one false positive.") form.addRow("Correct across pairs", correction) unit = QLineEdit("coefficient") unit.setToolTip( "Name the independent observational unit used by the test. Use " "wells rather than individual cells when wells are the replicates; " "treating correlated cells as independent can greatly overstate " "significance.") form.addRow("Unit of replication", unit) verdict = QLabel("") verdict.setWordWrap(True) from ..theme import font_px verdict.setStyleSheet( f"color: palette(mid); font-size: {font_px(11)}px;") form.addRow(verdict) def _recompute(): """Re-run the test with the current choices and redraw.""" self._stats_state.update( test=test.currentData(), alpha=float(alpha.value()), correction=correction.currentData() or "fdr_bh", unit=unit.text().strip() or "observation") lines = [] labels = list(groups) for index, left in enumerate(labels): for right in labels[index + 1:]: try: result = stats_module.compare( {left: groups[left], right: groups[right]}, unit=self._stats_state["unit"], force=self._stats_state["test"]) except ValueError as refusal: lines.append(f"{left} vs {right}: {refusal}") continue lines.append(f"{left} vs {right} — {result.sentence()}") verdict.setText("\n".join(lines) or "nothing to compare") for control in (test, correction): control.currentIndexChanged.connect(lambda *_: _recompute()) alpha.valueChanged.connect(lambda *_: _recompute()) unit.editingFinished.connect(_recompute) _recompute() self._stats_verdict = verdict return page def _figure_tab(self) -> QWidget: """Build the Figure page: size, DPI, background, and the two ink controls. The text-size control reaches *every* text object, including the ones a naive sweep misses -- annotations, the suptitle and the legend title -- which is what made "shrink all text" leave the largest label on the plot untouched and read as the font getting bigger. The size is remembered on the figure rather than written to the preference, because this dialog restyles one figure in front of the user and the setting for every figure is Preferences. Line ink and font ink are two controls rather than one, split by what a mark is rather than by which code draws it, so "dark axes, coloured labels" is expressible. :returns: the page widget. """ from .figure_queue import set_figure_text_size_override page = QWidget() form = QFormLayout(page) figure = self._figure def set_face(colour): """Set the figure's own background colour.""" figure.patch.set_facecolor(colour) self._changed() form.addRow("Background", _colour_button( figure.patch.get_facecolor(), set_face)) width = QDoubleSpinBox() width.setRange(1, 60) width.setDecimals(1) width.setValue(figure.get_figwidth()) height = QDoubleSpinBox() height.setRange(1, 60) height.setDecimals(1) height.setValue(figure.get_figheight()) def resize(*_): """Resize the figure to the width and height on screen.""" figure.set_size_inches(width.value(), height.value()) self._changed() width.valueChanged.connect(resize) height.valueChanged.connect(resize) form.addRow("Width (in)", width) form.addRow("Height (in)", height) dpi = QSpinBox() dpi.setRange(50, 1200) dpi.setValue(int(figure.get_dpi())) dpi.valueChanged.connect( lambda value: (figure.set_dpi(value), self._changed())) form.addRow("DPI", dpi) all_text = QSpinBox() all_text.setRange(2, 96) all_text.setValue(_current_text_size(figure)) def set_all_text(size): """Set one font size on every piece of text in the figure.""" for item in _every_text(figure): item.set_fontsize(size) set_figure_text_size_override(figure, size) self._changed() all_text.valueChanged.connect(set_all_text) all_text.setToolTip( "The size of every piece of text in this figure: the title, the " "axis labels, the tick labels, the legend and any annotation. It " "is remembered for this figure, so a redraw keeps it. The size " "every figure starts at is in Preferences → Figures.") form.addRow("All text size", all_text) def set_line_ink(colour): """Recolour every line in the figure.""" apply_line_colour(figure, colour) self._changed() def set_font_ink(colour): """Recolour every piece of text in the figure.""" apply_font_colour(figure, colour) self._changed() current_font_colour = "#000000" current_line_colour = "#000000" if figure.axes: try: current_font_colour = _as_hex( figure.axes[0].xaxis.label.get_color()) except Exception: pass try: spines = list(figure.axes[0].spines.values()) if spines: current_line_colour = _as_hex(spines[0].get_edgecolor()) except Exception: current_line_colour = current_font_colour form.addRow("Line colour", _colour_button(current_line_colour, set_line_ink)) form.addRow("Font colour", _colour_button(current_font_colour, set_font_ink)) suptitle = QLineEdit( figure._suptitle.get_text() if figure._suptitle else "") suptitle.editingFinished.connect( lambda: (figure.suptitle(suptitle.text()), self._changed())) form.addRow("Figure title", suptitle) return page def _axes_tab(self, axis) -> QWidget: """Build one axes page: its title, labels, scales, limits and ticks. :param axis: the axes this page edits. :returns: the page widget. """ page = QWidget() form = QFormLayout(page) title = QLineEdit(axis.get_title()) title.editingFinished.connect( lambda: (axis.set_title(title.text()), self._changed())) form.addRow("Title", title) for label, getter, setter in ( ("X label", axis.get_xlabel, axis.set_xlabel), ("Y label", axis.get_ylabel, axis.set_ylabel), ): edit = QLineEdit(getter()) edit.editingFinished.connect( lambda e=edit, s=setter: (s(e.text()), self._changed())) form.addRow(label, edit) for label, getter, setter in ( ("X scale", axis.get_xscale, axis.set_xscale), ("Y scale", axis.get_yscale, axis.set_yscale), ): combo = QComboBox() combo.addItems(AXIS_SCALES) current = getter() if current in AXIS_SCALES: combo.setCurrentText(current) combo.currentTextChanged.connect( lambda value, s=setter: (s(value), self._changed())) form.addRow(label, combo) for label, getter, setter in ( ("X limits", axis.get_xlim, axis.set_xlim), ("Y limits", axis.get_ylim, axis.set_ylim), ): low, high = (float(v) for v in getter()) span = abs(high - low) or 1.0 row = QWidget() row_layout = QHBoxLayout(row) row_layout.setContentsMargins(0, 0, 0, 0) boxes = [] for value in (low, high): box = QDoubleSpinBox() box.setRange(-1e12, 1e12) box.setDecimals(4) box.setSingleStep(span / 20.0) box.setValue(value) box.setKeyboardTracking(False) boxes.append(box) row_layout.addWidget(box) def apply_limits(*_, s=setter, b=boxes): """Apply one axis's limits, refusing a zero-width range. Equal bounds collapse the axis and matplotlib draws nothing, so the value is left alone rather than applied. """ lower, upper = b[0].value(), b[1].value() if lower == upper: return s(lower, upper) self._changed() for box in boxes: box.valueChanged.connect(apply_limits) form.addRow(label, row) auto = QPushButton("Autoscale to data") def do_autoscale(): """Recompute the limits from the data now on the axis.""" axis.relim() axis.autoscale() self._changed() auto.clicked.connect(do_autoscale) form.addRow("", auto) for label, getter, setter in ( ("Invert X", axis.xaxis_inverted, axis.invert_xaxis), ("Invert Y", axis.yaxis_inverted, axis.invert_yaxis), ): check = QCheckBox() check.setChecked(bool(getter())) check.toggled.connect( lambda _v, s=setter: (s(), self._changed())) form.addRow(label, check) grid = QCheckBox() grid.setChecked(any(line.get_visible() for line in axis.get_xgridlines())) grid_axis = QComboBox() grid_axis.addItems(("both", "x", "y")) grid_width = QDoubleSpinBox() grid_width.setRange(0.1, 6.0) grid_width.setSingleStep(0.1) grid_width.setValue(0.8) grid_colour = {"value": "#cccccc"} def apply_grid(*_): """Show or hide the grid, passing line properties ONLY when enabling. matplotlib warns "First parameter to grid() is false, but line properties are supplied" and then turns the grid ON regardless -- so passing them unconditionally made the checkbox unable to switch the grid off, which is the opposite of what it says. """ if grid.isChecked(): axis.grid(True, axis=grid_axis.currentText(), color=grid_colour["value"], linewidth=grid_width.value()) else: axis.grid(False, axis=grid_axis.currentText()) self._changed() grid.toggled.connect(apply_grid) grid_axis.currentTextChanged.connect(apply_grid) grid_width.valueChanged.connect(apply_grid) form.addRow("Grid", grid) form.addRow("Grid axis", grid_axis) form.addRow("Grid width", grid_width) form.addRow("Grid colour", _colour_button( grid_colour["value"], lambda c: (grid_colour.__setitem__("value", c), apply_grid()))) spine_width = QDoubleSpinBox() spine_width.setRange(0.0, 10.0) spine_width.setSingleStep(0.25) spine_width.setValue( next(iter(axis.spines.values())).get_linewidth() if axis.spines else 1.0) def set_spines(value): """Set every spine's width, or hide them all at zero.""" for spine in axis.spines.values(): spine.set_linewidth(value) self._changed() spine_width.valueChanged.connect(set_spines) form.addRow("Spine width", spine_width) hide_top_right = QCheckBox() hide_top_right.setChecked( not axis.spines["top"].get_visible() if "top" in axis.spines else False) def set_top_right(hidden): """Hide or show the top and right spines together.""" for name in ("top", "right"): if name in axis.spines: axis.spines[name].set_visible(not hidden) self._changed() hide_top_right.toggled.connect(set_top_right) form.addRow("Hide top/right", hide_top_right) tick_size = QSpinBox() tick_size.setRange(4, 40) labels = axis.get_xticklabels() tick_size.setValue(int(labels[0].get_fontsize()) if labels else 10) tick_size.valueChanged.connect( lambda value: (axis.tick_params(labelsize=value), self._changed())) form.addRow("Tick label size", tick_size) handles, _labels = axis.get_legend_handles_labels() if axis.get_legend() is not None or handles: legend_on = QCheckBox() legend_on.setChecked(axis.get_legend() is not None and axis.get_legend().get_visible()) legend_where = QComboBox() legend_where.addItems(LEGEND_LOCATIONS) legend_size = QSpinBox() legend_size.setRange(4, 32) legend_size.setValue(9) legend_cols = QSpinBox() legend_cols.setRange(1, 6) legend_frame = QCheckBox() legend_frame.setChecked(True) def apply_legend(*_): """Rebuild the legend from the current choices.""" existing = axis.get_legend() if not legend_on.isChecked(): if existing is not None: existing.set_visible(False) self._changed() return handles, _labels = axis.get_legend_handles_labels() if handles: axis.legend(loc=legend_where.currentText(), ncol=legend_cols.value(), frameon=legend_frame.isChecked(), prop={"size": legend_size.value()}) elif existing is not None: existing.set_visible(True) existing.set_frame_on(legend_frame.isChecked()) for text in existing.get_texts(): text.set_fontsize(legend_size.value()) self._changed() for control in (legend_on, legend_frame): control.toggled.connect(apply_legend) legend_where.currentTextChanged.connect(apply_legend) legend_size.valueChanged.connect(apply_legend) legend_cols.valueChanged.connect(apply_legend) form.addRow("Legend", legend_on) form.addRow("Legend position", legend_where) form.addRow("Legend text size", legend_size) form.addRow("Legend columns", legend_cols) form.addRow("Legend frame", legend_frame) series = _series_of(axis) if len(series) > self.SERIES_DETAIL_LIMIT: self._add_series_rules(form, axis, series) return page for label, artist in series: form.addRow(QLabel(f"— {label} —")) def set_colour(colour, a=artist): """Recolour one artist. The artist is bound as a default argument. Bound at definition rather than closed over: a loop variable closed over gives every callback the LAST artist, which is the classic way a row of per-artist controls all end up editing one of them. """ try: a.set_color(colour) except Exception: pass self._changed() try: current = artist.get_color() except Exception: current = "#1f77b4" form.addRow(" Colour", _colour_button(current, set_colour)) if hasattr(artist, "set_linewidth"): line_width = QDoubleSpinBox() line_width.setRange(0.0, 12.0) line_width.setSingleStep(0.25) try: raw = artist.get_linewidth() if hasattr(raw, "__len__") and not isinstance(raw, str): raw = raw[0] if len(raw) else 1.0 width_value = float(raw) except Exception: width_value = 1.0 line_width.setValue(width_value) line_width.valueChanged.connect( lambda value, a=artist: (a.set_linewidth(value), self._changed())) form.addRow(" Line width", line_width) if hasattr(artist, "set_linestyle"): style = QComboBox() for code, name in LINE_STYLES: style.addItem(name, code) style.currentIndexChanged.connect( lambda _i, a=artist, c=style: ( a.set_linestyle(c.currentData()), self._changed())) form.addRow(" Line style", style) if hasattr(artist, "set_markersize"): marker = QDoubleSpinBox() marker.setRange(0.0, 40.0) try: marker.setValue(float(artist.get_markersize())) except Exception: marker.setValue(6.0) marker.valueChanged.connect( lambda value, a=artist: (a.set_markersize(value), self._changed())) form.addRow(" Marker size", marker) elif hasattr(artist, "set_sizes"): point = QDoubleSpinBox() point.setRange(1.0, 600.0) point.setValue(36.0) point.valueChanged.connect( lambda value, a=artist: (a.set_sizes([value]), self._changed())) form.addRow(" Point size", point) alpha = QDoubleSpinBox() alpha.setRange(0.05, 1.0) alpha.setSingleStep(0.05) try: alpha.setValue(float(artist.get_alpha() or 1.0)) except Exception: alpha.setValue(1.0) alpha.valueChanged.connect( lambda value, a=artist: (a.set_alpha(value), self._changed())) form.addRow(" Opacity", alpha) return page
[docs] def figure_line_artists(figure) -> list: """Collect artists affected by the global line-colour control. Parameters ---------- figure : matplotlib.figure.Figure Figure whose artists should be collected. Returns ------- list Data and reference lines, axes spines, and legend sample lines. Notes ----- Gridlines are excluded. Tick marks are updated separately by :func:`apply_line_colour` because Matplotlib recreates them during draws. """ found = [] for axis in getattr(figure, "axes", ()): found += list(getattr(axis, "lines", ())) found += list(axis.spines.values()) legend = axis.get_legend() if legend is not None: found += list(legend.get_lines()) return found
[docs] def apply_line_colour(figure, colour) -> int: """Apply one colour to figure lines, spines, and tick marks. Line styles and dash patterns are preserved. Gridlines and text are not changed. Parameters ---------- figure : matplotlib.figure.Figure Figure to update. colour : Any Matplotlib colour specification. Returns ------- int Number of line, spine, and legend artists successfully updated. Tick marks are updated separately and are not included in this count. """ touched = 0 for artist in figure_line_artists(figure): try: if hasattr(artist, "set_edgecolor"): artist.set_edgecolor(colour) else: artist.set_color(colour) touched += 1 except Exception: continue for axis in getattr(figure, "axes", ()): try: axis.tick_params(color=colour, which="both") except Exception: continue return touched
[docs] def apply_font_colour(figure, colour) -> int: """Apply one colour to every text object in a figure. Parameters ---------- figure : matplotlib.figure.Figure Figure to update. colour : Any Matplotlib colour specification. Returns ------- int Number of text objects successfully updated. This includes titles, axes labels, tick labels, legends, and annotations. """ touched = 0 for item in _every_text(figure): try: item.set_color(colour) touched += 1 except Exception: continue for axis in getattr(figure, "axes", ()): try: axis.tick_params(labelcolor=colour, which="both") except Exception: continue return touched
[docs] def figure_follows_the_theme(figure) -> None: """Restore line and font colours from the active theme preferences. Parameters ---------- figure : matplotlib.figure.Figure Figure to update. If the preference store is unavailable, black is used for both line and font colours. """ try: from ..preferences import get_figure_colors, get_figure_line_colour _bg, font = get_figure_colors() line = get_figure_line_colour() except Exception: font = line = "#000000" apply_line_colour(figure, line) apply_font_colour(figure, font)
#: Schema identifier required in a spaCR graph-style JSON file. GRAPH_STYLE_FILE_KIND = "spacr_graph_style"
[docs] def graph_style_as_dict(general=None, per_graph=None) -> dict: """Serialize graph-style preference overrides to a dictionary. Parameters ---------- general : mapping, optional General style overrides. If ``None``, read the current preference. per_graph : mapping of str to mapping, optional Overrides keyed by graph type. If ``None``, read the current preference. Returns ------- dict Style data with ``spacr_style_kind``, ``general``, and ``per_graph`` keys. Per-graph values that are not mappings are omitted. Notes ----- Only preference overrides are stored. Theme-resolved colours and package defaults are not captured from the currently displayed figure. """ if general is None or per_graph is None: try: from ..preferences import (get_figure_style, get_figure_style_per_graph) if general is None: general = get_figure_style() if per_graph is None: per_graph = get_figure_style_per_graph() except Exception: general, per_graph = general or {}, per_graph or {} return { "spacr_style_kind": GRAPH_STYLE_FILE_KIND, "general": dict(general or {}), "per_graph": {str(kind): dict(values) for kind, values in (per_graph or {}).items() if isinstance(values, dict)}, }
[docs] def save_graph_style(path: str, general=None, per_graph=None) -> str: """Write graph-style preference overrides to a JSON file. Parameters ---------- path : str Destination path. An empty path cancels the operation. general : mapping, optional General style overrides. If ``None``, read the current preference. per_graph : mapping of str to mapping, optional Overrides keyed by graph type. If ``None``, read the current preference. Returns ------- str ``path`` after a successful write, or an empty string if the path is empty or the file cannot be written. Raises ------ TypeError If a supplied setting value cannot be serialized as JSON. """ import json if not path: return "" try: with open(path, "w", encoding="utf-8") as handle: json.dump(graph_style_as_dict(general, per_graph), handle, indent=2, sort_keys=True) except OSError as error: LOG.warning("could not save the graph style to %s: %s", path, error) return "" return path
[docs] def load_graph_style(path: str) -> tuple: """Read graph-style preference overrides from a JSON file. Parameters ---------- path : str Path to a graph-style file created by :func:`save_graph_style`. Returns ------- general : dict General style overrides. per_graph : dict Style overrides keyed by graph type. Raises ------ OSError If the file cannot be opened. json.JSONDecodeError If the file does not contain valid JSON. ValueError If the file is not identified by :data:`GRAPH_STYLE_FILE_KIND`. Notes ----- Unknown setting names are preserved for compatibility with files created by other spaCR versions. """ import json with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, dict) or \ data.get("spacr_style_kind") != GRAPH_STYLE_FILE_KIND: raise ValueError(f"{path} is not a spaCR graph style") general = data.get("general") per_graph = data.get("per_graph") return (dict(general) if isinstance(general, dict) else {}, {str(kind): dict(values) for kind, values in (per_graph or {}).items() if isinstance(values, dict)})
[docs] def apply_graph_style(general, per_graph) -> None: """Save graph-style overrides as the active preferences. Parameters ---------- general : mapping General style overrides. per_graph : mapping of str to mapping Style overrides keyed by graph type. Values that are not mappings are omitted. """ from ..preferences import set_figure_style, set_figure_style_per_graph set_figure_style(dict(general or {})) set_figure_style_per_graph({str(kind): dict(values) for kind, values in (per_graph or {}).items() if isinstance(values, dict)})
[docs] def add_graph_style_file_entries(menu, parent=None, *, on_change=None) -> None: """Add graph-style save and load actions to a menu. Parameters ---------- menu : PySide6.QtWidgets.QMenu Menu that receives the actions. parent : PySide6.QtWidgets.QWidget, optional Parent for file dialogs and actions. If ``None``, use ``menu``. on_change : callable, optional Callback invoked after a style is loaded. Callbacks may accept a ``preview`` keyword argument. """ from PySide6.QtWidgets import QFileDialog, QMessageBox owner = parent if parent is not None else menu def _save(): """Write the current graph style to a JSON file.""" path, _filter = QFileDialog.getSaveFileName( owner, "Save graph style", "graph_style.json", "spaCR graph style (*.json);;All files (*)") if path: save_graph_style(path) def _load(): """Read a graph style back and apply it.""" path, _filter = QFileDialog.getOpenFileName( owner, "Load graph style", "", "spaCR graph style (*.json);;All files (*)") if not path: return try: general, per_graph = load_graph_style(path) except (OSError, ValueError, JSONDecodeError) as error: QMessageBox.warning(owner, "Load graph style", str(error)) return apply_graph_style(general, per_graph) if callable(on_change): try: on_change(preview=True) except TypeError: on_change() save = QAction(tr("Save graph style…"), owner) save.setToolTip(tr( "Write the general and per-graph settings from Preferences to a " "file, so a lab's house style can be shared and re-applied.")) save.triggered.connect(_save) menu.addAction(save) load = QAction(tr("Load graph style…"), owner) load.setToolTip(tr( "Read a saved house style and make it this project's default, so " "every figure drawn from now on uses it.")) load.triggered.connect(_load) menu.addAction(load)
#: Grouped-plot types offered by the figure context menu, in display order. GROUPED_PLOT_TYPES = ( ("line", "Line"), ("bar", "Bar"), ("jitter_bar", "Jitter over bar"), ("jitter_box", "Jitter over box"), ("jitter", "Jitter"), ("box", "Box"), ("violin", "Violin"), ) #: Column names the derived frame uses. #: #: A figure that was not drawn by `create_grouped_plot` has no recipe and so #: no column names either. These are what the reconstructed frame calls its #: two columns, and they are what the axis labels are replaced by when the #: figure is redrawn -- so they are read by a user, not only by the drawer. DERIVED_GROUP = "group" DERIVED_VALUE = "value" def _tick_labels(axes) -> dict: """Map x position -> tick text, for an axis with categorical ticks.""" out = {} try: locations = list(axes.get_xticks()) texts = [t.get_text() for t in axes.get_xticklabels()] except Exception: # noqa: BLE001 return out for position, text in zip(locations, texts): text = str(text).strip() if text: out[round(float(position), 6)] = text return out def _named(labels, x): """The tick label at ``x``, or the number itself when there is none.""" key = round(float(x), 6) if key in labels: return labels[key] nearest = min(labels, key=lambda k: abs(k - key)) if labels else None if nearest is not None and abs(nearest - key) < 0.5: return labels[nearest] return f"{float(x):g}" def _pairs_from_axes(axes): """Every (group, value) pair an axes actually drew, or an empty list. Reads the ARTISTS rather than any data the caller kept, because for these figures nobody kept any: this is the path for a figure that arrived without a recipe. Three artist families cover what spaCR draws -- rectangles for bars, path collections for scatter and strip, and lines for series and for the markers matplotlib draws as lines. """ labels = _tick_labels(axes) pairs = [] try: from matplotlib.patches import Rectangle span = axes.get_xlim() width = abs(span[1] - span[0]) or 1.0 for patch in list(axes.patches): if not isinstance(patch, Rectangle): continue if patch.get_width() >= width * 0.98: continue height = patch.get_height() if height is None or not math.isfinite(float(height)): continue centre = patch.get_x() + patch.get_width() / 2.0 pairs.append((_named(labels, centre), float(height))) except Exception: # noqa: BLE001 pass try: for collection in list(axes.collections): offsets = collection.get_offsets() if offsets is None or len(offsets) == 0: continue for x, y in numpy.asarray(offsets, dtype=float): if math.isfinite(x) and math.isfinite(y): pairs.append((_named(labels, x), float(y))) except Exception: # noqa: BLE001 pass try: for line in list(axes.lines): data = line.get_xydata() if data is None or len(data) == 0: continue if len(data) <= 2 and (line.get_marker() in (None, "", "None")): continue for x, y in numpy.asarray(data, dtype=float): if math.isfinite(x) and math.isfinite(y): pairs.append((_named(labels, x), float(y))) except Exception: # noqa: BLE001 pass return pairs
[docs] def derive_replot_recipe(figure): """Derive a grouped-plot recipe from an existing matplotlib figure. This fallback supports figures without a ``_spacr_replot`` payload by reading plotted values from bars, scatter collections, and data lines on a single axes. Artist-derived data are exact for bar heights and point coordinates but are necessarily lossy for summary artists such as box or violin plots, which do not retain their source observations. Figures created by :func:`spacr.plot.create_grouped_plot` use their attached source frame instead of this fallback. :param figure: Matplotlib ``Figure`` containing exactly one axes. :returns: Recipe dictionary for :func:`spacr.plot.create_grouped_plot`, or ``None`` when sufficient plottable values cannot be recovered. """ try: import pandas except Exception: # noqa: BLE001 return None axes = [a for a in getattr(figure, "axes", []) if a is not None] if len(axes) != 1: return None pairs = _pairs_from_axes(axes[0]) if len(pairs) < 2: return None frame = pandas.DataFrame(pairs, columns=[DERIVED_GROUP, DERIVED_VALUE]) return { "df": frame, "grouping_column": DERIVED_GROUP, "data_column": DERIVED_VALUE, "graph_type": "", "summary_func": "mean", "order": None, "colors": None, "y_lim": None, "error_bar_type": "std", }
def _replot(figure, kind: str, on_change=None): """Redraw ``figure`` in place as ``kind``. Returns whether it changed. A NEW Figure, and that is not a choice: `create_grouped_plot` builds its own -- spacrGraph makes one and draws into it -- so there is nothing to draw "in place" onto. The caller is handed the new one through ``on_change`` and is responsible for putting it where the old one was; `FigureQueue.replace_figure` is what does that. Never raises: a plot type that cannot show this data is a menu entry that does nothing visible, not a crash in a right-click. :param on_change: called with the NEW figure when it is drawn. A callable taking no arguments is still accepted, for the toggles that only need telling that something moved. :returns: the new Figure, or ``None``. """ recipe = dict(getattr(figure, "_spacr_replot", None) or {}) if recipe.get("df") is None: return None try: from ...plot import create_grouped_plot recipe["graph_type"] = str(kind) drawn, _results = create_grouped_plot(save=False, **recipe) except Exception: # noqa: BLE001 LOG.debug("could not redraw the figure as %r", kind, exc_info=True) return None if drawn is None: return None drawn._spacr_replot = recipe if callable(on_change): try: on_change(drawn) except TypeError: try: on_change() except Exception: # noqa: BLE001 LOG.debug("redraw notification failed", exc_info=True) except Exception: # noqa: BLE001 LOG.debug("redraw notification failed", exc_info=True) return drawn def _which_types_fit(recipe) -> tuple: """``(fitting kinds, {kind: why not})`` for this figure's data. THROUGH `spacr.graph_types`, which is where the fitness table lives -- a second opinion here would let the menu offer a type the drawer cannot draw, which is the failure that table exists to prevent. An empty first element means "could not tell", and then EVERY type is offered: refusing them all because the shape could not be read would take a working menu away over a question nobody asked. """ try: from ...graph_types import offer, shape_of frame = recipe.get("df") if frame is None or not len(frame): return (), {} shape = shape_of(frame, str(recipe.get("grouping_column") or ""), str(recipe.get("data_column") or "")) rows = offer(frame, str(recipe.get("grouping_column") or ""), str(recipe.get("data_column") or "")) del shape fits, why = [], {} for kind, _caption, reason in rows: (why.__setitem__(kind, reason) if reason else fits.append(kind)) alias = {"bar_jitter": ("jitter_bar", "jitter_box")} for source, targets in alias.items(): if source in fits: fits.extend(targets) elif source in why: for target in targets: why.setdefault(target, why[source]) return tuple(fits), why except Exception: # noqa: BLE001 LOG.debug("could not work out which graph types fit", exc_info=True) return (), {} def _add_group_colours(menu, figure, recipe, on_change, parent) -> None: """Add persistent colour controls for the groups represented in a plot. Colours are stored on the redraw recipe so all marks in a group retain the selected colour when the graph is redrawn or retyped. """ from PySide6.QtWidgets import QMenu frame = recipe.get("df") column = str(recipe.get("grouping_column") or "") if frame is None or column not in getattr(frame, "columns", ()): return try: groups = [str(g) for g in frame[column].astype(str).unique()] except Exception: # noqa: BLE001 return if not groups: return colours = QMenu(tr("Group colours"), menu) colours.setToolTipsVisible(True) menu.addMenu(colours) def _recolour(group: str) -> None: """Pick a colour for one group and store it on the recipe.""" current = dict(recipe.get("colors") or {}) start = str(current.get(group, "#4C72B0")) chosen = pick_colour(parent, start, tr("Colour for {group}", group=group)) if not chosen.isValid(): return current[group] = chosen.name() recipe["colors"] = current figure._spacr_replot = recipe _replot(figure, str(recipe.get("graph_type") or "bar"), on_change) for group in groups[:24]: action = colours.addAction(f"{group}…") action.setToolTip( tr("Colour every mark belonging to {group}.", group=group)) action.triggered.connect( lambda _checked=False, g=group: _recolour(g)) if len(groups) > 24: note = colours.addAction( tr("({count} more groups not listed)", count=len(groups) - 24)) note.setEnabled(False) def _add_bundle_save(menu, figure, parent) -> None: """Add a Matplotlib action that exports the figure and its evidence bundle.""" from PySide6.QtWidgets import QFileDialog action = menu.addAction(tr("Save")) action.setToolTip(tr( "Writes a FOLDER: the figure as pdf and png, the rows it was drawn " "from, and the test that was run on them with its assumptions. A pdf " "on its own cannot be checked -- six months later the question is " "what the numbers were and whether the difference was tested, and a " "figure file answers neither.")) def _save() -> None: """Ask for a folder and write the whole bundle into it.""" folder = QFileDialog.getExistingDirectory( parent, "Save the graph, its data and its statistics") if not folder: return try: save_figure_bundle(figure, folder) except Exception: # noqa: BLE001 LOG.debug("could not write the figure bundle", exc_info=True) action.triggered.connect(_save)
[docs] def save_figure_bundle(figure, folder: str, name: str = "") -> str: """Export a Matplotlib figure with its source data and statistics. Group definitions come from the attached replot recipe so statistical comparisons match the displayed figure. When no recipe is available, the standard files are still written and the statistics artifact records that no comparison could be formed. :param figure: Matplotlib figure to export. :param folder: destination directory for the bundle. :param name: optional base name for generated files. :returns: path to the written bundle directory. """ from ...figures.bundle import save recipe = dict(getattr(figure, "_spacr_replot", None) or {}) frame = recipe.get("df") column = str(recipe.get("grouping_column") or "") value = str(recipe.get("data_column") or "") groups = None if frame is not None and column in getattr(frame, "columns", ()) \ and value in getattr(frame, "columns", ()): groups = {str(key): part[value].dropna().to_numpy() for key, part in frame.groupby(column, observed=True)} title = name or _figure_title(figure) or "graph" def _render(path: str) -> None: """Render one file of the bundle through the SHARED export path. Both formats go through `save_figure` rather than each drawing itself, so print colours, embedded fonts and raster DPI match every other figure the user keeps. """ from ...plot import save_figure extension = os.path.splitext(path)[1].lower().lstrip(".") save_figure(figure, path, fmt=extension, bbox_inches="tight", close=False) return save(folder, title, render=_render, data=frame, groups=groups, unit=str(recipe.get("unit") or "observation"), settings={k: v for k, v in recipe.items() if k != "df"})
def _figure_title(figure) -> str: """The figure's own title, for naming its folder.""" try: if figure._suptitle is not None: return str(figure._suptitle.get_text()) except Exception: # noqa: BLE001 pass for axis in getattr(figure, "axes", ()): text = str(axis.get_title() or "") if text: return text return ""
[docs] def build_figure_context_menu(parent, figure, *, on_change=None, open_settings=None) -> QMenu: """Build the context menu for a displayed figure. The menu provides direct legend, grid, scale, colour, and export actions. Figures with grouped-plot metadata can also be redrawn as another plot type. More detailed controls are delegated to ``open_settings``. Parameters ---------- parent : PySide6.QtWidgets.QWidget Parent for the returned menu and its dialogs. figure : matplotlib.figure.Figure or None Figure to edit. If ``None``, the menu contains a disabled status action. on_change : callable, optional Callback invoked after a direct edit. Callbacks may accept a ``preview`` keyword argument or a replacement figure. open_settings : callable, optional Callback invoked by the ``Figure settings`` action. Returns ------- PySide6.QtWidgets.QMenu Context menu owned by ``parent``. """ menu = QMenu(parent) owner = parent if parent is not None else menu if figure is None: action = QAction(tr("This figure can no longer be restyled"), owner) action.setEnabled(False) menu.addAction(action) return menu axes = list(figure.axes) from ...figures.bundle import _is_image_figure picture = _is_image_figure(figure) recipe = None if picture else getattr(figure, "_spacr_replot", None) if not picture and not (isinstance(recipe, dict) and recipe.get("df") is not None): derived = derive_replot_recipe(figure) if derived is not None: recipe = derived try: figure._spacr_replot = derived figure._spacr_replot_derived = True except Exception: # noqa: BLE001 pass if isinstance(recipe, dict) and recipe.get("df") is not None: show_as = QMenu(tr("Graph type"), menu) menu.addMenu(show_as) current = str(recipe.get("graph_type") or "") fits, why_not = _which_types_fit(recipe) for kind, label in GROUPED_PLOT_TYPES: action = show_as.addAction(label) action.setCheckable(True) action.setChecked(kind == current) reason = why_not.get(kind, "") if fits and kind not in fits: action.setEnabled(False) action.setToolTip(reason) else: action.triggered.connect( lambda _checked=False, k=kind: _replot(figure, k, on_change)) show_as.setToolTipsVisible(True) _add_group_colours(menu, figure, recipe, on_change, parent) _add_figure_tools(menu, figure, parent, on_change) if not picture: _add_bundle_save(menu, figure, parent) def _notify() -> None: """Redraw after a menu toggle, CHEAPLY. A context-menu toggle is the same kind of edit the settings dialog makes, and the dialog learned long ago to preview: a full-quality render rewrites the raster AND the vector page, measured at ~263 ms on an 823-point volcano, and the user is mid-gesture. Preview here too, and let the next full render -- a resize, an export, closing the settings dialog -- catch up. `on_change` may be a callable that predates preview rendering, so the keyword is offered and withdrawn rather than assumed. """ if not on_change: return try: on_change(preview=True) except TypeError: on_change() def _apply(func): """Run ``func`` against every axis in the figure.""" for axis in axes: func(axis) _notify() legend_present = any(a.get_legend() is not None for a in axes) legend_action = QAction(tr("Legend"), owner) legend_action.setCheckable(True) legend_action.setChecked( legend_present and all(a.get_legend().get_visible() for a in axes if a.get_legend() is not None)) def toggle_legend(checked): """Show or hide the legend on every axis.""" for axis in axes: existing = axis.get_legend() if existing is not None: existing.set_visible(checked) elif checked and axis.get_legend_handles_labels()[0]: axis.legend() _notify() legend_action.toggled.connect(toggle_legend) menu.addAction(legend_action) grid_action = QAction(tr("Grid"), owner) grid_action.setCheckable(True) grid_action.setChecked(any(line.get_visible() for axis in axes for line in axis.get_xgridlines())) grid_action.toggled.connect( lambda checked: _apply(lambda a: a.grid(checked))) menu.addAction(grid_action) grid_action.setVisible(not picture) scales = QMenu(tr("Axis scale"), menu) menu.addMenu(scales) scales.menuAction().setVisible(not picture) for name, setter in (("X", "set_xscale"), ("Y", "set_yscale")): submenu = QMenu(name, scales) scales.addMenu(submenu) for scale in AXIS_SCALES: action = QAction(tr(scale), owner) action.triggered.connect( lambda _checked=False, s=scale, m=setter: _apply(lambda a: getattr(a, m)(s))) submenu.addAction(action) menu.addSeparator() appearance = QMenu(tr("Appearance"), menu) menu.addMenu(appearance) def _pick_ink(title, apply_to): """Pick a colour and apply it through ``apply_to``.""" current = "#000000" try: if axes: current = _as_hex(axes[0].xaxis.label.get_color()) except Exception: pass chosen = pick_colour(parent, current, title) if chosen.isValid(): apply_to(figure, chosen.name()) _notify() line_action = QAction(tr("Line colour…"), owner) line_action.setToolTip(tr( "Every line in the figure, the axis spines and the tick marks " "included. The numbers beside the ticks are text and follow the " "font colour.")) line_action.triggered.connect( lambda: _pick_ink(tr("Line colour"), apply_line_colour)) appearance.addAction(line_action) font_action = QAction(tr("Font colour…"), owner) font_action.setToolTip(tr( "Every piece of text in the figure: the title, the axis labels, the " "tick labels, the legend and any annotation.")) font_action.triggered.connect( lambda: _pick_ink(tr("Font colour"), apply_font_colour)) appearance.addAction(font_action) theme_action = QAction(tr("Follow the theme (colours)"), owner) theme_action.setToolTip(tr( "Put both colours back to the app theme and the figure preferences.")) theme_action.triggered.connect( lambda: (figure_follows_the_theme(figure), _notify())) appearance.addAction(theme_action) menu.addSeparator() save = QAction(tr("Save figure as…"), owner) save.setToolTip(tr( "Write this figure to a file using its current plot styling and the " "configured export background, format and resolution.")) save.triggered.connect(lambda: save_figure_as(parent, figure)) menu.addAction(save) styled = QAction(tr("Save figure with a preview…"), owner) styled.setToolTip(tr( "Choose the ink, background, grid, size and resolution for the saved " "file, preview the result, then export it. The figure on screen is " "not changed.")) styled.triggered.connect(lambda: _open_styled_save(parent, figure)) menu.addAction(styled) add_graph_style_file_entries(menu, parent, on_change=on_change) settings = QAction(tr("Edit figure…"), owner) settings.setToolTip(tr( "Titles, axis labels, limits and scales, fonts, colours, legend, " "size and DPI, applied live.")) if open_settings is None: open_settings = partial(_open_editor, figure, parent, on_change) settings.triggered.connect(lambda: open_settings()) menu.addAction(settings) return menu
def _redraw_after(figure, on_change) -> None: """Tell the owner of ``figure`` that it changed, however it listens.""" if not callable(on_change): try: figure.canvas.draw_idle() except Exception: # noqa: BLE001 pass return try: on_change(preview=False) except TypeError: try: on_change() except Exception: # noqa: BLE001 LOG.debug("redraw notification failed", exc_info=True) except Exception: # noqa: BLE001 LOG.debug("redraw notification failed", exc_info=True) def _retype(figure, kind: str, on_change=None) -> bool: """Redraw ``figure`` in place as ``kind`` from the data it carries. Never raises: a kind the data cannot be drawn as leaves the figure as it was drawn last and logs why. :returns: whether the figure was redrawn. """ from ...figures.bundle import _draw, _figure_record, _register_figure_data frame, spec = _figure_record(figure) if frame is None: return False spec = dict(spec, kind=str(kind)) view_keys = ("xlim", "ylim", "xscale", "yscale") for key in view_keys: spec.pop(key, None) if spec.get("panels"): spec["panels"] = [{key: value for key, value in panel.items() if key not in view_keys} for panel in spec["panels"]] previous_drawn = getattr(figure, "_spacr_drawn_data", None) try: _draw(figure, frame, spec) except Exception: # noqa: BLE001 figure._spacr_drawn_data = previous_drawn LOG.debug("could not redraw the figure as %r", kind, exc_info=True) return False try: from ...figures.style import _apply_user_style _apply_user_style(figure, kind, force=True) except Exception: # noqa: BLE001 LOG.debug("could not apply the figure preferences", exc_info=True) drawn = getattr(figure, "_spacr_drawn_data", None) extra = {k: v for k, v in spec.items() if k not in ("x", "y", "hue", "kind")} _register_figure_data(figure, frame, x=spec.get("x", ""), y=spec.get("y", ""), hue=spec.get("hue", ""), kind=kind, **extra) if kind == "regression_panel": figure._spacr_drawn_data = drawn _redraw_after(figure, on_change) return True def _annotations_from(table) -> tuple: """``(note, brackets)`` to draw from a statistics table.""" from ...figures.stats import stars note, brackets = "", [] for _index, row in table.iterrows(): stage = str(row["test_stage"]) p_value = row["p_adjusted"] if not (isinstance(p_value, float) and math.isfinite(p_value)): p_value = row["p_value"] if not (isinstance(p_value, float) and math.isfinite(p_value)): continue if stage in ("omnibus", "correlation", "contingency") and not note: note = f"{row['test_name']}: p = {p_value:.3g}" if stage == "pairwise": if not note and " vs " in str(row["groups"]) and \ not str(row["correction"]): note = f"{row['test_name']}: p = {p_value:.3g}" label = stars(p_value) if label and " vs " in str(row["groups"]): left, right = str(row["groups"]).split(" vs ", 1) brackets.append({"pair": [left, right], "label": label}) return note, brackets class _StatisticsDialog(QDialog): """Choose, run and show the statistics for a figure's data. The test is chosen from the data by default; every choice can be overridden, and Apply stores it on the figure so the saved zip reports the same tests and the plot shows them. """ def __init__(self, figure, parent=None, *, on_change=None): """Build the controls and run the automatic choice once. :param figure: the figure whose registered data is tested. :param parent: parent widget. :param on_change: called after the annotations are drawn. """ super().__init__(parent) from PySide6.QtWidgets import QPlainTextEdit from ...figures.bundle import _figure_record, _recipe_frame from ...figures.stats import _OVERRIDES, _data_kind self.setObjectName("FigureStatisticsDialog") self.setWindowTitle(tr("Statistics")) self.resize(640, 480) self._figure = figure self._on_change = on_change self._frame, self._spec = _figure_record(figure) frame = self._frame x, y = str(self._spec.get("x") or ""), str(self._spec.get("y") or "") panels = self._spec.get("panels") or [] available = list(_OVERRIDES.get( _data_kind(frame, x, y) if frame is not None else "none", ())) excluded = {x, y} if panels: families = [] for panel in panels: options = dict(self._spec, **panel) data = _recipe_frame(frame, options) px, py = str(options.get("x") or ""), str(options.get("y") or "") families.append(_OVERRIDES.get(_data_kind(data, px, py), ())) excluded.update((px, py)) excluded.update((options.get("melt") or {}).get("columns", [])) available = [name for name in families[0] if all(name in family for family in families[1:])] saved = dict(self._spec.get("stats") or {}) layout = QVBoxLayout(self) form = QFormLayout() layout.addLayout(form) self.test = QComboBox() self.test.setObjectName("FigureStatisticsTest") self.test.addItem(tr("Automatic (chosen from the data)"), None) for name in available: self.test.addItem(name, name) index = self.test.findData(saved.get("test")) self.test.setCurrentIndex(max(index, 0)) form.addRow(tr("Test"), self.test) self.pair = QComboBox() self.pair.setObjectName("FigureStatisticsPair") self.pair.addItem(tr("(none)"), "") for column in (list(frame.columns) if frame is not None else []): if column not in excluded: self.pair.addItem(str(column), str(column)) self.pair.setCurrentIndex(max(self.pair.findData( saved.get("pair") or self._spec.get("pair") or ""), 0)) form.addRow(tr("Subject column"), self.pair) self.paired = QCheckBox(tr("Paired / repeated measures")) self.paired.setObjectName("FigureStatisticsPaired") self.paired.setChecked(bool(saved.get("paired") if saved.get("paired") is not None else self.pair.currentData())) form.addRow("", self.paired) self.correction = QComboBox() self.correction.setObjectName("FigureStatisticsCorrection") try: from ...multiple_testing import METHODS for key in METHODS: self.correction.addItem(key, key) except Exception: # noqa: BLE001 self.correction.addItem("fdr_bh", "fdr_bh") self.correction.setCurrentIndex(max(self.correction.findData( saved.get("correction") or "fdr_bh"), 0)) form.addRow(tr("Multiple-comparison correction"), self.correction) self.show_on_plot = QCheckBox(tr("Show on the plot")) self.show_on_plot.setObjectName("FigureStatisticsShow") self.show_on_plot.setChecked(bool(saved.get("show", True))) form.addRow("", self.show_on_plot) self.report = QPlainTextEdit() self.report.setObjectName("FigureStatisticsReport") self.report.setReadOnly(True) layout.addWidget(self.report) buttons = QDialogButtonBox( QDialogButtonBox.Apply | QDialogButtonBox.Close, self) buttons.button(QDialogButtonBox.Apply).clicked.connect(self._apply) buttons.rejected.connect(self.reject) layout.addWidget(buttons) for combo in (self.test, self.pair, self.correction): combo.currentIndexChanged.connect(lambda *_: self._recompute()) self.paired.toggled.connect(lambda *_: self._recompute()) self.table = None self._recompute() def _choices(self) -> dict: """The statistics choices as stored on the figure's spec.""" return {"test": self.test.currentData(), "paired": bool(self.paired.isChecked()), "pair": str(self.pair.currentData() or ""), "correction": str(self.correction.currentData() or "fdr_bh"), "show": bool(self.show_on_plot.isChecked())} def _recompute(self): """Run the tests with the current choices and show the table.""" from ...figures.stats import _auto_statistics, _statistics_text chosen = self._choices() if self._spec.get("panels"): from ...figures.bundle import _panel_statistics self.table, report = _panel_statistics( self._frame, self._spec, choices=chosen) self.report.setPlainText(report) return self.table self.table = _auto_statistics( self._frame, str(self._spec.get("x") or ""), str(self._spec.get("y") or ""), test=chosen["test"], paired=chosen["paired"], pair=chosen["pair"], correction=chosen["correction"], order=self._spec.get("order"), count=str(self._spec.get("count") or "")) self.report.setPlainText(_statistics_text(self.table)) return self.table def _apply(self) -> None: """Store the choices on the figure and draw the result on it.""" from ...figures.bundle import _annotate chosen = self._choices() spec = dict(self._spec) spec["stats"] = chosen if spec.get("panels"): panels = [] for index, panel in enumerate(spec["panels"]): part = self.table.loc[self.table["panel"] == index] note, brackets = _annotations_from(part) panels.append(dict(panel, stats=chosen, stats_note=note if chosen["show"] else "", annotations=brackets if chosen["show"] else [])) spec.update(panels=panels, stats_note="", annotations=[]) else: note, brackets = _annotations_from(self.table) spec["stats_note"] = note if chosen["show"] else "" spec["annotations"] = brackets if chosen["show"] else [] self._spec = spec try: self._figure._spacr_spec = spec if getattr(self._figure, "_spacr_data", None) is None: self._figure._spacr_data = self._frame axes = [a for a in self._figure.axes if a.get_label() != "<colorbar>"] if spec.get("panels"): for index, panel in enumerate(spec["panels"]): slot = panel.get("slot", index) if isinstance(slot, int) and 0 <= slot < len(axes): _annotate(axes[slot], dict(spec, **panel)) elif axes: _annotate(axes[0], spec) except Exception: # noqa: BLE001 LOG.debug("could not annotate the figure", exc_info=True) _redraw_after(self._figure, self._on_change) def _save_zip_dialog(parent, figure) -> str: """Ask where, then write the figure's zip. Returns the path or ``""``.""" from PySide6.QtWidgets import QFileDialog from ...figures.bundle import _save_zip title = _figure_title(figure) or "figure" path, _filter = QFileDialog.getSaveFileName( parent, tr("Save figure (zip)"), f"{title}.zip", tr("Zip archive (*.zip)")) if not path: return "" try: return _save_zip(figure, path, name=title) except Exception: # noqa: BLE001 LOG.debug("could not write the figure zip", exc_info=True) return "" def _add_figure_tools(menu, figure, parent, on_change=None) -> None: """Add Change graph type, Statistics and Save figure (zip) to ``menu``. The graph types offered are the ones the figure's data fits: categories against a measurement, two measurements, one distribution, a count table or a matrix. A figure with no data attached offers the two entries that need none of it. A picture (a micrograph or masks) gets only the zip of its image and metadata: it has no graph type to change and nothing to test. """ from ...figures.bundle import _figure_record, _is_image_figure, _kinds_for owner = parent if parent is not None else menu if _is_image_figure(figure): archive = QAction(tr("Save figure (zip)…"), owner) archive.setToolTip(tr( "One zip: the picture in the default formats, the arrays it " "shows as TIFF and its metadata as JSON.")) archive.triggered.connect(lambda: _save_zip_dialog(parent, figure)) menu.addAction(archive) return frame, spec = _figure_record(figure) kinds = _kinds_for(frame, spec) if frame is not None else () retype = QMenu(tr("Change graph type"), menu) retype.setObjectName("FigureChangeGraphType") menu.addMenu(retype) current = str(spec.get("kind") or "") for kind, caption in kinds: action = retype.addAction(tr(caption)) action.setCheckable(True) action.setChecked(kind == current) action.triggered.connect( lambda _checked=False, k=kind: _retype(figure, k, on_change)) if not kinds: empty = retype.addAction(tr("No data is attached to this figure")) empty.setEnabled(False) statistics = QAction(tr("Statistics…"), owner) statistics.setEnabled(frame is not None) statistics.triggered.connect( lambda: _StatisticsDialog(figure, parent, on_change=on_change).exec()) menu.addAction(statistics) archive = QAction(tr("Save figure (zip)…"), owner) archive.setToolTip(tr( "One zip: the image in the default formats, the data as CSV, every " "statistical test in one CSV with a text summary, the plotting " "recipe as JSON and a Python script that re-creates the figure.")) archive.triggered.connect(lambda: _save_zip_dialog(parent, figure)) menu.addAction(archive) class _FigureMenuFilter(QObject): """Opens the shared figure menu on a right-click on any figure canvas. Installed on each canvas rather than on the application, so the cost is paid only by canvases. A canvas whose owner set a custom context-menu policy keeps its own menu, which adds the shared entries itself. """ def eventFilter(self, obj, event): # noqa: N802 - Qt name """Show the menu for a context-menu event on a figure canvas.""" if event.type() != QEvent.ContextMenu: return False try: if obj.contextMenuPolicy() != Qt.DefaultContextMenu: return False _show_canvas_menu(obj, event.globalPos()) except RuntimeError: return False return True _MENU_FILTER = None def _open_editor(figure, parent, on_change=None) -> None: """Open the figure editor on ``figure``, redrawing through ``on_change``.""" FigureSettingsDialog( figure, parent, on_change=lambda **_k: _redraw_after(figure, on_change)).exec() def _canvas_changed(canvas, preview=False) -> None: """Redraw ``canvas``; a replacement figure is redrawn in place instead. The house "Graph type" entries hand back a NEW figure, which a canvas owned by a screen cannot swap in, so its recipe is drawn onto the figure the canvas already shows. """ figure = getattr(canvas, "figure", None) if preview is not None and not isinstance(preview, bool): recipe = getattr(preview, "_spacr_replot", None) or {} if figure is not None: figure._spacr_replot = recipe figure._spacr_data = None _retype(figure, _house_kind(recipe.get("graph_type")), None) try: canvas.draw_idle() except RuntimeError: pass def _house_kind(graph_type) -> str: """The drawing kind for a spaCR house graph type.""" from ...figures.bundle import _HOUSE_KINDS return _HOUSE_KINDS.get(str(graph_type or ""), "box_strip") def _show_canvas_menu(canvas, position): """Build and open the shared figure menu for ``canvas`` at ``position``.""" figure = getattr(canvas, "figure", None) menu = build_figure_context_menu( canvas, figure, on_change=partial(_canvas_changed, canvas), open_settings=partial( _open_editor, figure, canvas, lambda **_k: canvas.draw_idle())) _exec_menu(menu, position) return menu def _exec_menu(menu, position) -> None: """Show ``menu`` modally at ``position``.""" menu.exec(position) def _attach_figure_menu(canvas) -> None: """Give ``canvas`` the shared right-click figure menu. Idempotent.""" global _MENU_FILTER if getattr(canvas, "_spacr_figure_menu", False): return if _MENU_FILTER is None: _MENU_FILTER = _FigureMenuFilter() canvas.installEventFilter(_MENU_FILTER) canvas._spacr_figure_menu = True def _every_text(figure): """Every text object on ``figure``, including the ones easily missed. ONE IMPLEMENTATION, in :func:`spacr.qt.widgets.figure_queue.figure_text_items` -- which carries the measurement and the reason. It lives there and not here because the RENDER pass needs it too and that pass runs on a worker thread with no Qt, and because the second half of issue #108 was these two reaching different sets of text: the dialog resized all twenty-three objects and the render put the global preference back over twenty of them. A copy here would drift again. """ from .figure_queue import figure_text_items return figure_text_items(figure) def _current_text_size(figure, default: int = 10) -> int: """The size the control should OPEN at: what the figure actually uses. It opened at a hardcoded 10 whatever the figure was set to, which is the third symptom of issue #108 -- "when returning to the Figure settings button menu the font size has been returned to 10". It had never left 10; it had never read the figure at all. The MOST COMMON size, not the largest or the mean: a figure has many tick labels at the body size and one or two headings above it, so the mode is what "the font size of this figure" means to a reader. Ties go to the smaller, so a figure with equal counts opens at its body size. """ from collections import Counter sizes = [] for item in _every_text(figure): try: if str(item.get_text()).strip(): sizes.append(round(float(item.get_fontsize()))) except Exception: # noqa: BLE001 continue if not sizes: return default counts = Counter(sizes) best = max(counts.values()) return min(size for size, count in counts.items() if count == best) def _open_styled_save(parent, figure): """Open the style-preview-save dialog. Returns it, or ``None``. Kept on the parent so Python does not collect it the moment this returns -- the same reason every other window this application opens is held somewhere. """ if figure is None: return None try: from .save_figure_dialog import SaveFigureDialog dialog = SaveFigureDialog(figure, parent=parent) except Exception: # noqa: BLE001 LOG.debug("could not open the styled save", exc_info=True) return None dialog.show() if parent is not None: kept = getattr(parent, "_spacr_save_dialogs", None) if kept is None: kept = [] try: parent._spacr_save_dialogs = kept except Exception: # noqa: BLE001 return dialog kept.append(dialog) return dialog
[docs] def save_figure_as(parent, figure, path: str = "") -> str: """Save a figure using its current styling and export preferences. Parameters ---------- parent : PySide6.QtWidgets.QWidget or None Parent for the file chooser when ``path`` is empty. figure : matplotlib.figure.Figure or None Figure to save. ``None`` cancels the operation. path : str, optional Destination path. If empty, prompt for a PNG, PDF, or SVG path. Returns ------- str Path returned by the writer, or an empty string when saving is cancelled or fails. Notes ----- A recognized filename extension takes precedence over the default output format. Available data, statistics, and caption metadata are exported by :func:`export_sidecars` beside the requested path. """ if figure is None: return "" if not path: from PySide6.QtWidgets import QFileDialog path, _filter = QFileDialog.getSaveFileName( parent, "Save figure", "figure.png", "PNG image (*.png);;PDF document (*.pdf);;" "SVG image (*.svg);;All files (*)") if not path: return "" extension = os.path.splitext(path)[1].lower().lstrip(".") try: from ...plot import FIGURE_FORMATS, _checked_savefig, print_ready, save_figure except Exception: FIGURE_FORMATS, print_ready, save_figure = (), None, None _checked_savefig = None if save_figure is not None and extension in FIGURE_FORMATS: try: return str(save_figure(figure, path, fmt=extension, bbox_inches="tight", close=False)) except Exception as error: # noqa: BLE001 - report, do not raise LOG.info("could not save figure to %s: %s", path, error) return "" finally: export_sidecars(figure, path) try: from ..preferences import (figure_bg_is_transparent, get_figure_colors, get_figure_png_dpi) background, _foreground = get_figure_colors() dpi = get_figure_png_dpi() except Exception: background, dpi = "none", 200 def figure_bg_is_transparent(value): """Whether a stored ground value means "no background at all".""" return str(value).lower() in ("none", "transparent") from contextlib import nullcontext try: vector = extension in ("pdf", "svg", "eps") ink = print_ready(figure) if print_ready is not None else nullcontext() write = (_checked_savefig if _checked_savefig is not None else lambda target, where, **options: target.savefig( where, **options)) with ink: write( figure, path, bbox_inches="tight", facecolor=background, transparent=figure_bg_is_transparent(background), **({} if vector else {"dpi": dpi})) except Exception as error: # noqa: BLE001 - report, do not raise LOG.info("could not save figure to %s: %s", path, error) return "" export_sidecars(figure, path) return path
[docs] def export_sidecars(figure, path) -> list: """Export available figure data, statistics, and caption sidecars. Parameters ---------- figure : matplotlib.figure.Figure Figure carrying optional ``_spacr_data``, ``_spacr_groups``, or ``_spacr_caption`` metadata. path : path-like Figure output path. Sidecars use the same directory and basename. Returns ------- list of str Successfully written sidecar paths. Depending on available metadata, these may include ``<name>.csv``, ``<name>_stats.csv``, and ``<name>_legend.txt``. Notes ----- The data sidecar contains the rows attached to the rendered figure. The statistics sidecar contains all usable pairwise comparisons with multiple testing correction provided by spaCR's statistics table helper. Individual sidecar failures are logged and do not interrupt the remaining exports. """ written = [] base = os.path.splitext(os.fspath(path))[0] frame = getattr(figure, "_spacr_data", None) drawn = getattr(figure, "_spacr_drawn_data", None) if drawn is not None: from pandas import DataFrame if isinstance(drawn, DataFrame): frame = drawn if frame is not None: try: target = f"{base}.csv" frame.to_csv(target, index=False) written.append(target) except Exception as error: # noqa: BLE001 LOG.info("could not export the figure's data: %s", error) groups = getattr(figure, "_spacr_groups", None) if groups: try: from ...figures.stats import compare, table usable = {label: values for label, values in groups.items() if values is not None and len(values) >= 2} if len(usable) >= 2: labels = list(usable) comparisons = [] for index, left in enumerate(labels): for right in labels[index + 1:]: try: comparisons.append(compare( {left: usable[left], right: usable[right]}, unit="coefficient")) except ValueError: continue if comparisons: target = f"{base}_stats.csv" table(comparisons).to_csv(target, index=False) written.append(target) except Exception as error: # noqa: BLE001 LOG.info("could not export the figure's statistics: %s", error) caption = getattr(figure, "_spacr_caption", "") if caption: try: target = f"{base}_legend.txt" with open(target, "w") as handle: handle.write(caption + "\n") written.append(target) except Exception as error: # noqa: BLE001 LOG.info("could not export the figure's legend: %s", error) return written
__all__ = ["FigureSettingsDialog", "build_figure_context_menu", "AXIS_SCALES", "GRAPH_STYLE_FILE_KIND", "graph_style_as_dict", "save_graph_style", "load_graph_style", "apply_graph_style", "add_graph_style_file_entries", "export_sidecars", "save_figure_as"] _FALLBACK_CHOICES = { "palette": ("colorblind", "deep", "muted", "pastel", "bright", "dark"), "grid_style": tuple(style for style, _label in LINE_STYLES), "threshold_style": tuple(style for style, _label in LINE_STYLES), "reference_style": tuple(style for style, _label in LINE_STYLES), "format": ("pdf", "png", "svg"), "colormap": ("viridis", "plasma", "inferno", "magma", "cividis", "coolwarm", "RdBu_r"), "bins": ("auto", "sturges", "fd", "scott", "sqrt"), "error_bars": ("sem", "sd", "ci95", "none"), "aspect": ("equal", "auto"), "spines": ("all", "left_bottom", "none"), } #: Compatibility alias for the fallback style-choice mapping. STYLE_CHOICES = _FALLBACK_CHOICES
[docs] def style_choices_for(name: str) -> tuple: """Return the choices available for a style setting. Parameters ---------- name : str Style setting name. Returns ------- tuple Canonical choices from :mod:`spacr.figure_style`. If that module is unavailable, return the local fallback choices. An empty tuple denotes a free-form or unknown setting. """ try: from ...figure_style import style_choices return tuple(style_choices(name)) except Exception: return tuple(_FALLBACK_CHOICES.get(name, ()))
#: Matplotlib colour value used to store a transparent figure background. TRANSPARENT_STYLE_GROUND = "none" #: Style keys that support an explicit transparent value in the preferences. TRANSPARENT_CAPABLE = ("background",) def _looks_like_a_colour(value) -> bool: """Whether a value is a hex colour string. :param value: the value. :returns: ``True`` for a string starting with ``#`` -- deliberately shallow, because this only has to tell a colour from a number or a name, not validate one. """ return isinstance(value, str) and value.startswith("#") def _is_transparent_ground(value) -> bool: """Whether a stored style value means "no ground at all".""" return str(value).strip().lower() in ("none", "transparent", "") #: Style keys whose capitalised name is not what the setting is called. #: #: `aspect` is the case this exists for. Capitalised it reads "Aspect", #: which a reader takes for the aspect RATIO -- a number tying one y unit #: to n x units, which is a statement about the data and is a different #: setting living under Axes. This one offers "equal" and "auto", which is #: matplotlib's axes aspect: whether one y unit is drawn the same length as #: one x unit. That is a statement about the DATA, not about the panel's #: proportions -- those are the separate Page shape row -- so it is called #: what the graph's own right-click menu calls the same control. Labelling #: it "Graph shape" left two shape-sounding rows, no axis-lock row, and a #: row whose caption and whose own explanation disagreed. _STYLE_LABELS = { "aspect": "Lock axis scales", "ci_level": "CI level", "error_capsize": "Error-bar cap size", } def _style_tip(name: str) -> str: """The tooltip for one figure-style row, or an empty string.""" tips = { "legend_size": tr("Text size of legend entries and legend titles, " "in points. Default 9."), "colormap": tr("Colour map for heat maps, images and density " "plots. viridis and cividis stay readable with " "colour-blindness and in greyscale. Default viridis."), "figure_width": tr("Width of a new figure in inches. The page shape " "sets the height, or Figure height does when the " "page shape is custom. Default 6.4."), "figure_height": tr("Height of a new figure in inches, used when the " "page shape is custom. Default 4.8."), "despine_offset": tr("Moves the axis lines this many points away " "from the data, as seaborn's despine does. 0 " "leaves them touching. Default 0."), "also_save": tr("Writes a second copy of every saved figure in this " "format beside the first, for example a PNG next to " "a PDF. Default none."), "vector_text": tr("Keeps text editable in PDF and SVG files instead " "of turning it into outlines. Default on."), "error_bars": tr("What error bars show on bar graphs: standard error " "(sem), standard deviation (sd), a confidence " "interval at the CI level (ci), a 95% interval " "(ci95) or nothing. Default sem."), "ci_level": tr("Confidence level, in percent, of the ci error bars. " "Default 95."), "error_capsize": tr("Width of the caps at the ends of error bars, in " "points. Default 5."), "jitter_width": tr("How far overlaid points spread sideways, as a " "fraction of one category's width. Default 0.4."), "point_alpha": tr("Opacity of overlaid points, from 0 (invisible) " "to 1 (solid). Default 0.6."), "point_overlay": tr("Draws the individual points over bar and box " "graphs. Default on."), } return tips.get(str(name), "")
[docs] def style_setting_label(name: str) -> str: """Convert a style setting name to a display label. Parameters ---------- name : str Underscore-delimited style key, such as ``'grid_colour'``. Returns ------- str Capitalized, space-delimited label, such as ``'Grid colour'``. """ return _STYLE_LABELS.get( str(name), str(name).replace("_", " ").strip().capitalize())
[docs] class FigureStylePreferences(QWidget): """Edit general and graph-specific figure-style preferences. General settings apply to every figure. Each graph type can override only the settings it needs, and the panel stores differences from package defaults rather than a fully resolved style. """ def __init__(self, general=None, per_graph=None, parent=None): """Build the figure-style preference page. :param general: the saved general style values; missing keys fall back to the shipped defaults. :param per_graph: saved per-graph-kind overrides, keyed by kind. :param parent: parent widget, or ``None``. """ super().__init__(parent) from ...figure_style import (GENERAL_DEFAULTS, GRAPH_DEFAULTS, GRAPH_KINDS) self._general_defaults = dict(GENERAL_DEFAULTS) self._graph_defaults = {kind: dict(values) for kind, values in GRAPH_DEFAULTS.items()} self._kinds = tuple(GRAPH_KINDS) self._general = dict(general or {}) self._per_graph = {str(kind): dict(values) for kind, values in (per_graph or {}).items() if isinstance(values, dict)} column = QVBoxLayout(self) column.setContentsMargins(0, 0, 0, 0) heading = QLabel( "Applies to every figure. A graph type below can override any " "of it.") heading.setWordWrap(True) column.addWidget(heading) general_form = QFormLayout() general_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) column.addLayout(general_form) #: General controls mapped to their getter, setter, and default value. self._general_controls = {} for name, default in self._general_defaults.items(): value = self._general.get(name, default) widget, getter, setter = self._control(name, value) if _style_tip(name): widget.setToolTip(_style_tip(name)) self._general_controls[name] = (getter, setter, default) general_form.addRow(style_setting_label(name), widget) column.addSpacing(8) picker_row = QHBoxLayout() picker_row.addWidget(QLabel("Graph type")) self._kind_box = QComboBox() self._kind_box.setToolTip( "Settings for one kind of graph, laid over the general ones " "above. Only what you change here is stored, so a graph type you " "have not touched follows the general settings.") for kind in self._kinds: self._kind_box.addItem(style_setting_label(kind), kind) picker_row.addWidget(self._kind_box, 1) column.addLayout(picker_row) #: Persistent control page for each graph type. self._pages = QTabWidget() self._pages.tabBar().setVisible(False) #: Per-graph controls mapped to their getter, setter, and default value. self._kind_controls = {} for kind in self._kinds: page = QWidget() form = QFormLayout(page) form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) controls = {} stored = self._per_graph.get(kind, {}) for name, default in self._graph_defaults.get(kind, {}).items(): value = stored.get(name, default) widget, getter, setter = self._control(name, value) if _style_tip(name): widget.setToolTip(_style_tip(name)) controls[name] = (getter, setter, default) form.addRow(style_setting_label(name), widget) self._kind_controls[kind] = controls self._pages.addTab(page, kind) column.addWidget(self._pages) self._kind_box.currentIndexChanged.connect(self._pages.setCurrentIndex) file_row = QHBoxLayout() save_button = QPushButton("Save style…") save_button.setToolTip( "Write these settings to a file, so a lab's house style can be " "shared and re-applied without setting every control again.") save_button.clicked.connect(self._save_to_file) load_button = QPushButton("Load style…") load_button.setToolTip( "Read a saved house style into these controls. Press Save to " "make it this project's default.") load_button.clicked.connect(self._load_from_file) file_row.addWidget(save_button) file_row.addWidget(load_button) file_row.addStretch(1) column.addLayout(file_row) from ..screens.settings_model import retarget_field_tooltips retarget_field_tooltips(self) def _save_to_file(self) -> None: """Write WHAT IS ON SCREEN, not what is stored. A panel with unsaved edits that saved the store instead would write a file the user can see does not match the controls in front of them. """ from PySide6.QtWidgets import QFileDialog path, _filter = QFileDialog.getSaveFileName( self, "Save graph style", "graph_style.json", "spaCR graph style (*.json);;All files (*)") if path: general, per_graph = self.values() save_graph_style(path, general, per_graph) def _load_from_file(self) -> None: """Read a house style INTO THE CONTROLS, not into the store. So the user sees what they are about to accept and can still press Cancel -- a load that wrote straight through would be the one action in this dialog that Cancel could not undo. """ from PySide6.QtWidgets import QFileDialog, QMessageBox path, _filter = QFileDialog.getOpenFileName( self, "Load graph style", "", "spaCR graph style (*.json);;All files (*)") if not path: return try: general, per_graph = load_graph_style(path) except (OSError, ValueError, JSONDecodeError) as error: QMessageBox.warning(self, "Load graph style", str(error)) return self.apply_values(general, per_graph)
[docs] def apply_values(self, general=None, per_graph=None) -> None: """Load style overrides into the preference controls. Parameters ---------- general : mapping, optional General style overrides. per_graph : mapping of str to mapping, optional Style overrides keyed by graph type. Notes ----- A control omitted from the supplied mappings is reset to its package default rather than retaining its previous value. """ general = dict(general or {}) per_graph = {str(kind): dict(values) for kind, values in (per_graph or {}).items() if isinstance(values, dict)} for name, (_getter, setter, default) in self._general_controls.items(): setter(general.get(name, default)) for kind, controls in self._kind_controls.items(): stored = per_graph.get(kind, {}) for name, (_getter, setter, default) in controls.items(): setter(stored.get(name, default))
def _control(self, name: str, value): """Build a widget, getter, and setter from one style value. Runtime values determine the control type because annotations may be strings or absent. Returning the setter beside the getter ensures reset-to-default behavior uses the same control contract. """ choices = style_choices_for(name) if name in TRANSPARENT_CAPABLE and (_looks_like_a_colour(value) or _is_transparent_ground(value)): return self._ground_control(name, value) if isinstance(value, bool): box = QCheckBox() box.setChecked(bool(value)) return box, box.isChecked, lambda v: box.setChecked(bool(v)) if choices: combo = QComboBox() for option in choices: combo.addItem(str(option), option) index = combo.findData(value) if index < 0: combo.addItem(f"{value} (not offered)", value) index = combo.count() - 1 combo.setCurrentIndex(index) def _set_combo(v, box=combo): """Select the entry whose data is ``v``, if the box has one.""" found = box.findData(v) if found >= 0: box.setCurrentIndex(found) return combo, combo.currentData, _set_combo if _looks_like_a_colour(value): holder = {"value": str(value)} button = _colour_button( str(value), lambda chosen: holder.__setitem__("value", chosen)) def _set_colour(v, b=button, h=holder): """Store a colour and repaint the swatch that shows it.""" h["value"] = str(v) b.setText(str(v)) colour = QColor(str(v)) if colour.isValid(): ink = "#000" if colour.lightness() > 127 else "#fff" b.setStyleSheet(f"background-color: {colour.name()}; " f"color: {ink};") return button, lambda h=holder: h["value"], _set_colour if isinstance(value, float): spin = QDoubleSpinBox() spin.setDecimals(2) spin.setRange(0.0, 1000.0) spin.setSingleStep(0.1) spin.setValue(float(value)) return spin, spin.value, lambda v: spin.setValue(float(v)) if isinstance(value, int): spin = QSpinBox() spin.setRange(0, 10000) spin.setValue(int(value)) return spin, spin.value, lambda v: spin.setValue(int(v)) line = QLineEdit(str(value)) return line, line.text, lambda v: line.setText(str(v)) def _ground_control(self, name: str, value): """A colour button with a "Transparent" box beside it. GREYED, NOT REMOVED (INVARIANTS 6): ticking Transparent disables the colour button rather than hiding it, so the colour the user had is still on screen and is still there when they untick. A control that vanishes takes its value with it. """ row = QWidget() layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) transparent = _is_transparent_ground(value) holder = {"value": (self._general_defaults.get(name, "#FFFFFF") if transparent else str(value))} button = _colour_button( holder["value"], lambda chosen: holder.__setitem__("value", chosen)) box = QCheckBox("Transparent") box.setToolTip( "Remove the figure and axes background so the underlying page or " "slide shows through. Check text, axes and data colours against " "the destination background before exporting.") box.setChecked(transparent) layout.addWidget(button, 1) layout.addWidget(box) def _paint_button(colour: str) -> None: """Show the ground colour on the button, as swatch and text.""" button.setText(str(colour)) qcolour = QColor(str(colour)) if qcolour.isValid(): ink = "#000" if qcolour.lightness() > 127 else "#fff" button.setStyleSheet(f"background-color: {qcolour.name()}; " f"color: {ink};") def _sync(*_): """Grey the colour button while transparent is ticked.""" button.setEnabled(not box.isChecked()) box.toggled.connect(_sync) _sync() def _get(): """The ground: the transparent sentinel, or the chosen colour.""" return (TRANSPARENT_STYLE_GROUND if box.isChecked() else holder["value"]) def _set(new_value): """Apply a ground, ticking transparent when that is what it means.""" if _is_transparent_ground(new_value): box.setChecked(True) else: box.setChecked(False) holder["value"] = str(new_value) _paint_button(str(new_value)) _sync() return row, _get, _set
[docs] def values(self) -> tuple: """Return style settings that differ from package defaults. Returns ------- general : dict General style overrides. per_graph : dict Non-default style settings keyed by graph type. Graph types with no overrides are omitted. """ general = {} for name, (getter, _setter, default) in self._general_controls.items(): value = getter() if not _same_setting(value, default): general[name] = value per_graph = {} for kind, controls in self._kind_controls.items(): changed = {} for name, (getter, _setter, default) in controls.items(): value = getter() if not _same_setting(value, default): changed[name] = value if changed: per_graph[kind] = changed return general, per_graph
[docs] def reset(self) -> None: """Reset every style control to its package default.""" for controls in [self._general_controls] + \ list(self._kind_controls.values()): for _name, (_getter, setter, default) in controls.items(): setter(default)
[docs] def select_kind(self, kind: str) -> None: """Display the preference page for one graph type. Parameters ---------- kind : str Graph type from :data:`spacr.figure_style.GRAPH_KINDS`. Unknown values leave the current page unchanged. """ index = self._kind_box.findData(str(kind)) if index >= 0: self._kind_box.setCurrentIndex(index)
def _same_setting(value, default) -> bool: """Whether a control still holds its default. Numbers are compared with a tolerance, because a QDoubleSpinBox with two decimals cannot hold 0.6 exactly and a panel that stored `grid_width: 0.6000000000000001` would mark every user as having overridden a setting they never touched. """ if isinstance(default, bool) or isinstance(value, bool): return bool(value) == bool(default) if isinstance(default, (int, float)) and isinstance(value, (int, float)): return abs(float(value) - float(default)) < 1e-6 return str(value) == str(default)