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 ""
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)