"""Settings templates: named, reusable and shareable bundles of settings.
The historical Recipe API, JSON format and storage paths remain compatible.
A lab often uses several sets of settings, each tied to a preparation, such as
"Toxo PVM, 40×". A settings CSV saved in a folder has no name, no record of its
module and no record of the spaCR version that wrote it.
A recipe records all four:
* it has a **name** the user chose;
* it knows its **module**, so applying a Mask recipe to Measure is refused
rather than writing keys that do not apply there;
* it records the **spaCR version** it was captured with, and applying it under
a different one says so and lists the recipe's settings that the module no
longer has;
* it is **one file**, so sharing it is sending a file.
Storage is ``~/.spacr/recipes/<module>/<slug>.json``, honouring
:data:`RECIPE_DIR_ENV` — the same shape :func:`spacr.macro.macros_dir` uses,
so a lab that redirects one redirects both.
The file format is plain JSON::
{
"spacr_recipe": 1,
"name": "Toxo PVM, 40x",
"app_key": "mask",
"spacr_version": "1.3.6",
"created": "2026-08-03T22:41:07",
"notes": "",
"settings": {"cell_channel": 1, ...}
}
``spacr_recipe`` is a format version, not the spaCR version: the two change for
unrelated reasons, so a reader accepts any file whose format version it can
read.
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from PySide6.QtCore import QObject, Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from .widgets.flow import FlowLayout
from .widgets.sortable_table import _reuse_list_items
from .i18n import tr
from ..logging_util import _spacr_home
from ..run_journal import _atomic_write_text
LOG = logging.getLogger("spacr.qt.recipes")
#: The button this module hangs on the settings-search strip.
RECIPE_BUTTON_NAME = "SettingsRecipeButton"
def _recipe_button_qss(palette: dict, opacity=None) -> str:
"""Make the Recipes button float on the page like the one beside it.
A named ``QToolButton`` with no rule of its own takes the blanket
``QWidget {{ background-color: bg }}`` — the WINDOW colour, which is
near-black and is not a surface, so no page-opacity setting reaches
it. It sat as a black rectangle on a strip that is meant to be type on
the page.
Deliberately the same shape as ``QToolButton#SettingsSearchDisclosure``
beside it: transparent body, hairline border, accent on hover. The two
are peers on one row and any difference between them reads as a
mistake.
"""
return f"""
QToolButton#{RECIPE_BUTTON_NAME} {{
background: transparent;
color: {palette["fg_dim"]};
border: 1px solid {palette["border_soft"]};
border-radius: 6px;
padding: 3px 10px;
}}
QToolButton#{RECIPE_BUTTON_NAME}:hover {{
color: {palette["fg"]};
border-color: {palette["accent"]};
}}
"""
try:
from .theme import register_widget_qss as _register_widget_qss
_register_widget_qss(RECIPE_BUTTON_NAME, _recipe_button_qss, replace=True)
except Exception:
LOG.debug("could not register the recipe-button QSS", exc_info=True)
#: Override for the recipe folder, mirroring ``SPACR_MACRO_DIR``.
RECIPE_DIR_ENV = "SPACR_RECIPE_DIR"
#: On-disk format version. Bumped only when a reader would need to behave
#: differently, which has not happened yet.
FORMAT_VERSION = 1
#: The Help-menu label. Kept verbatim — ``spacr/qt/i18n.py`` keys its
#: catalog on the English string.
MENU_ACTION_TEXT = "Settings templates…"
_SLUG_RE = re.compile(r"[^a-z0-9]+")
[docs]
def spacr_version() -> str:
"""The running spaCR version, or ``"unknown"`` if it cannot be read."""
try:
from spacr.version import get_version
return str(get_version())
except Exception:
return "unknown"
[docs]
def recipes_dir(app_key: Optional[str] = None) -> str:
"""The folder holding recipes, optionally for one module.
``~/.spacr/recipes`` (or :data:`RECIPE_DIR_ENV`), with one subfolder per
module so a listing is already scoped and a user can hand over a whole
module's worth by copying one directory. Created on first use.
:param app_key: restrict to one module's folder.
"""
override = os.environ.get(RECIPE_DIR_ENV, "").strip()
root = (os.path.abspath(os.path.expanduser(override)) if override
else os.path.join(str(_spacr_home()), "recipes"))
if app_key:
root = os.path.join(root, _slug(app_key) or "unknown")
os.makedirs(root, exist_ok=True)
return root
def _slug(text: str) -> str:
"""A filesystem-safe stem for a recipe name.
Lower-cased, non-alphanumerics collapsed to hyphens. The display name is
stored inside the file, so the slug only has to be unique and typeable —
it is never what the user reads.
"""
return _SLUG_RE.sub("-", str(text or "").lower()).strip("-")
@dataclass
[docs]
class Recipe:
"""One named settings bundle.
:param name: display name chosen by the user; its slug becomes the file
stem when the recipe is saved.
:param app_key: key of the module the settings belong to; it picks the
recipe's subfolder and is checked against the screen on apply.
:param settings: setting key to value mapping written into the screen.
:param spacr_version: spaCR version the recipe was saved with; filled
in on save when empty.
:param created: ISO-8601 timestamp of creation, to the second; filled
in on save when empty and used to sort listings newest first.
:param notes: free-text notes stored with the recipe.
:param path: file the recipe was loaded from or saved to; not written
into the file itself.
"""
name: str
app_key: str
settings: Dict[str, Any] = field(default_factory=dict)
spacr_version: str = ""
created: str = ""
notes: str = ""
path: str = ""
[docs]
def to_json(self) -> Dict[str, Any]:
"""The on-disk mapping. ``path`` is where it lives, not part of it."""
return {
"spacr_recipe": FORMAT_VERSION,
"name": self.name,
"app_key": self.app_key,
"spacr_version": self.spacr_version or spacr_version(),
"created": self.created or _dt.datetime.now().isoformat(
timespec="seconds"),
"notes": self.notes,
"settings": dict(self.settings),
}
@classmethod
[docs]
def from_json(cls, data: Dict[str, Any], path: str = "") -> "Recipe":
"""Build a recipe from a parsed file.
:param data: the parsed JSON mapping; it must carry an integer
``spacr_recipe`` format version no newer than this build reads
and a ``settings`` dict. Missing ``name`` falls back to
``"Untitled"``.
:raises ValueError: when the mapping is not a recipe at all, or is a
format version this build does not understand. Both raise an
explicit error, so an arbitrary JSON file such as a
``package.json`` is not applied as settings.
"""
if not isinstance(data, dict) or "spacr_recipe" not in data:
raise ValueError(tr("not a spaCR settings template"))
version = data.get("spacr_recipe")
if not isinstance(version, int) or version > FORMAT_VERSION:
raise ValueError(
tr("Template format {version} is newer than this spaCR "
"understands (it reads up to {supported}).",
version=repr(version), supported=FORMAT_VERSION))
settings = data.get("settings")
if not isinstance(settings, dict):
raise ValueError(tr("template has no settings"))
return cls(
name=str(data.get("name") or "Untitled"),
app_key=str(data.get("app_key") or ""),
settings=dict(settings),
spacr_version=str(data.get("spacr_version") or "unknown"),
created=str(data.get("created") or ""),
notes=str(data.get("notes") or ""),
path=path,
)
[docs]
def save_recipe(recipe: Recipe, directory: Optional[str] = None) -> str:
"""Write ``recipe`` and return its path.
:param recipe: the bundle. Its ``spacr_version`` and ``created`` are filled
in here when empty, so a caller does not have to set them.
:param directory: override the destination (used by export).
"""
recipe.spacr_version = recipe.spacr_version or spacr_version()
recipe.created = recipe.created or _dt.datetime.now().isoformat(
timespec="seconds")
root = directory or recipes_dir(recipe.app_key)
os.makedirs(root, exist_ok=True)
path = os.path.join(root, f"{_slug(recipe.name) or 'recipe'}.json")
text = json.dumps(recipe.to_json(), indent=2, sort_keys=True, default=str)
_atomic_write_text(Path(path), text)
recipe.path = path
return path
[docs]
def load_recipe(path: str) -> Recipe:
"""Read one recipe file.
:param path: path to a recipe JSON file; it is recorded on the returned
recipe.
:raises ValueError: on anything that is not a readable recipe.
"""
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except json.JSONDecodeError as exc:
raise ValueError(f"{os.path.basename(path)} is not valid JSON: {exc}")
return Recipe.from_json(data, path=path)
[docs]
def list_recipes(app_key: Optional[str] = None) -> List[Recipe]:
"""Every readable recipe, newest first.
Unreadable files are logged and skipped rather than raising: one
corrupt file must not make the whole list unopenable.
:param app_key: restrict to one module.
"""
out: List[Recipe] = []
root = recipes_dir(app_key)
for entry in sorted(os.listdir(root)):
if not entry.endswith(".json"):
continue
path = os.path.join(root, entry)
if not os.path.isfile(path):
continue
try:
out.append(load_recipe(path))
except Exception:
LOG.debug("skipping unreadable recipe %s", path, exc_info=True)
out.sort(key=lambda r: r.created, reverse=True)
return out
[docs]
def delete_recipe(recipe: Recipe) -> bool:
"""Remove a recipe's file. ``True`` when something was removed.
:param recipe: the recipe whose ``path`` is deleted; an empty or missing
path removes nothing.
"""
if not recipe.path or not os.path.isfile(recipe.path):
return False
os.remove(recipe.path)
return True
def _rename_recipe(recipe: Recipe, new_name: str) -> str:
"""Give a saved recipe a new name and move its file to match.
The new file is written before the old one is removed, so a failure part
way leaves the recipe under its old name rather than under neither.
:param recipe: the saved recipe; its ``name`` and ``path`` are updated.
:param new_name: the name to give it; surrounding spaces are dropped.
:returns: the recipe's new path.
:raises ValueError: when the name is empty or another template of this
module already uses it.
"""
name = str(new_name or "").strip()
if not name:
raise ValueError(tr("A template needs a name."))
root = os.path.dirname(recipe.path) if recipe.path \
else recipes_dir(recipe.app_key)
target = os.path.join(root, f"{_slug(name) or 'recipe'}.json")
old = recipe.path
same_file = bool(old) and os.path.abspath(old) == os.path.abspath(target)
if os.path.exists(target) and not same_file:
raise ValueError(
tr("A template called {name} already exists.", name=repr(name)))
old_name = recipe.name
recipe.name = name
try:
save_recipe(recipe, directory=root)
except Exception:
recipe.name = old_name
raise
if old and not same_file and os.path.isfile(old):
os.remove(old)
return recipe.path
def _recipe_from_settings_csv(path: str, app_key: str, screen=None) -> Recipe:
"""Turn a settings CSV, as written next to every run, into a recipe.
The CSV is read by the screen's own settings-CSV reader when it has one,
so every header spelling the "Import settings…" button accepts is
accepted here too; the recipe is named after the file.
:param path: the settings CSV.
:param app_key: the module the recipe is filed under.
:param screen: the module screen whose ``_load_settings_csv`` reads the
file; without one the two-column ``Key,Value`` reader is used.
:raises ValueError: when the file holds no settings.
"""
reader = getattr(screen, "_load_settings_csv", None)
if callable(reader):
settings = reader(path)
else:
from spacr.utils import load_settings
settings = load_settings(path, setting_key="Key", setting_value="Value")
if not settings:
raise ValueError(tr("That file holds no settings."))
stem = os.path.splitext(os.path.basename(path))[0]
return Recipe(name=stem or "Untitled", app_key=str(app_key or ""),
settings=dict(settings))
[docs]
def version_note(recipe: Recipe, current: Optional[str] = None) -> str:
"""What to tell the user about the version gap, or ``""`` if there is none.
Returns a sentence that names both versions, not a boolean, so the user can
judge the gap.
:param recipe: the bundle about to be applied.
:param current: override the running version (tests).
"""
now = current if current is not None else spacr_version()
made = recipe.spacr_version or "unknown"
if made == now:
return ""
return tr("This template was saved with spaCR {made}; you are running "
"{now}. Settings that changed meaning or name in between are "
"applied as written.", made=made, now=now)
[docs]
def compatibility_note(recipe: Recipe, model) -> str:
"""Describe the recipe's settings that this module does not have.
The version gap says *that* something may have moved; this says *what*.
Reported as a count plus the first four names, so a recipe that differs in
many keys gives a short note. The result is empty when there is nothing to
report.
:param recipe: the bundle.
:param model: the screen's ``SettingsWidgets``.
"""
known = (set(getattr(model, "_widgets", {}) or {})
| set(getattr(model, "_defaults", {}) or {}))
if not known:
return ""
unknown = sorted(set(recipe.settings) - known)
if not unknown:
return ""
shown = ", ".join(unknown[:4])
more = tr(" and {count} more", count=len(unknown) - 4) if len(unknown) > 4 else ""
return tr("{count} setting(s) in this template are not in this "
"module any more and will be ignored: {shown}{more}.",
count=len(unknown), shown=shown, more=more)
[docs]
def apply_recipe(recipe: Recipe, screen) -> int:
"""Write a recipe's settings into ``screen``. Returns how many landed.
Delegates to ``AppScreen.apply_settings_dict``, which is the same path the
settings-CSV import takes — so a recipe reaches the same widgets an
imported CSV reaches.
:raises ValueError: when the recipe belongs to a different module. It is
refused rather than partially applied: the keys that happen to overlap
between two modules are exactly the generic ones (``src``, ``verbose``,
``n_jobs``), so a cross-module apply would write only those and still
report success.
:param recipe: the recipe to apply; its settings are copied, not
mutated.
:param screen: the module screen to write into; its ``app_key`` must
match the recipe's (when both are set) and it must provide
``apply_settings_dict``.
"""
app_key = str(getattr(screen, "app_key", "") or "")
if recipe.app_key and app_key and recipe.app_key != app_key:
raise ValueError(
tr("This template is for the {source} module, not {target}.",
source=repr(recipe.app_key), target=repr(app_key)))
apply_dict = getattr(screen, "apply_settings_dict", None)
if not callable(apply_dict):
raise ValueError("this screen cannot take settings")
return int(apply_dict(dict(recipe.settings)) or 0)
[docs]
def capture_recipe(screen, name: str, notes: str = "") -> Recipe:
"""Build a recipe from a screen's current settings.
Uses ``SettingsWidgets.collect``, so what is captured is exactly what a Run
would use — including the defaults the user never touched. Storing every
value means the recipe reproduces the same settings after a default
changes.
:param screen: the module screen to capture; it must have a settings
model (``_settings_model``) or :class:`ValueError` is raised, and
its ``app_key`` is stored on the recipe.
:param name: display name for the recipe; empty gives ``"Untitled"``.
"""
model = getattr(screen, "_settings_model", None)
if model is None:
raise ValueError("this screen has no settings to capture")
return Recipe(
name=str(name or "Untitled"),
app_key=str(getattr(screen, "app_key", "") or ""),
settings=dict(model.collect()),
notes=str(notes or ""),
)
[docs]
class RecipeDialog(QDialog):
"""List, apply, share and delete the recipes for one module.
:param screen: the module screen whose recipes these are. Its `app_key`
is what the list is filtered by, and it becomes the dialog's parent
when none is given.
:param parent: parent widget. Defaults to ``screen``.
"""
def __init__(self, screen, parent: Optional[QWidget] = None):
"""Build the recipe dialog for one module screen.
:param screen: the module screen whose settings recipes are saved and
applied; its ``app_key`` scopes which recipes are listed.
:param parent: parent widget; defaults to ``screen``.
"""
super().__init__(parent or screen)
self._screen = screen
self._app_key = str(getattr(screen, "app_key", "") or "")
self._confirmation_runner: Callable[[QMessageBox], Any] = (
lambda box: box.exec())
self.setWindowTitle(tr("Settings templates — {module}", module=self._app_key or tr("module")))
from .preferences import scaled_px
self.setMinimumWidth(scaled_px(520))
self.setObjectName("RecipeDialog")
column = QVBoxLayout(self)
column.setSpacing(8)
self._intro = QLabel(
tr("A template is this module's settings under a name you chose. "
"Save one when a plate is set up the way you want it; apply it "
"next time instead of retyping."), self)
self._intro.setWordWrap(True)
column.addWidget(self._intro)
self._list = QListWidget(self)
self._list.setObjectName("RecipeList")
self._list.currentRowChanged.connect(self._on_selection_changed)
column.addWidget(self._list, 1)
self._detail = QLabel("", self)
self._detail.setObjectName("RecipeDetail")
self._detail.setTextFormat(Qt.PlainText)
self._detail.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self._detail.setWordWrap(True)
column.addWidget(self._detail)
row = FlowLayout(spacing=6)
self._btn_save = QPushButton("Save current settings…", self)
self._btn_save.clicked.connect(self._on_save)
row.addWidget(self._btn_save)
self._btn_import = QPushButton("Import…", self)
self._btn_import.clicked.connect(self._on_import)
row.addWidget(self._btn_import)
self._btn_export = QPushButton("Share…", self)
self._btn_export.clicked.connect(self._on_export)
row.addWidget(self._btn_export)
self._btn_rename = QPushButton(tr("Rename…"), self)
self._btn_rename.setObjectName("RecipeRenameButton")
self._btn_rename.clicked.connect(self._on_rename)
row.addWidget(self._btn_rename)
self._btn_delete = QPushButton("Delete", self)
self._btn_delete.clicked.connect(self._on_delete)
row.addWidget(self._btn_delete)
self._btn_apply = QPushButton("Apply", self)
self._btn_apply.setDefault(True)
self._btn_apply.clicked.connect(self._on_apply)
row.addWidget(self._btn_apply)
column.addLayout(row)
self.reload()
[docs]
def recipes(self) -> List[Recipe]:
"""The recipes currently listed."""
return list(self._recipes)
[docs]
def reload(self) -> None:
"""Re-read the module's recipe folder and repopulate the list."""
self._recipes = list_recipes(self._app_key)
rows = _reuse_list_items(self._list, len(self._recipes))
for item, recipe in zip(rows, self._recipes):
item.setText(
f"{recipe.name} · spaCR {recipe.spacr_version}")
item.setData(Qt.UserRole, recipe.path)
if self._recipes:
self._list.setCurrentRow(0)
else:
self._detail.setText(
tr("No templates yet for this module. Set the settings up the way "
"you want them, then use “Save current settings…”."))
self._refresh_buttons()
[docs]
def selected(self) -> Optional[Recipe]:
"""The highlighted recipe, or ``None``."""
row = self._list.currentRow()
if 0 <= row < len(self._recipes):
return self._recipes[row]
return None
[docs]
def detail_text(self) -> str:
"""The line under the list. Public so tests read what users read."""
return self._detail.text()
[docs]
def set_confirmation_runner(
self, runner: Callable[[QMessageBox], Any]) -> None:
"""Replace how the version/compatibility confirmation is run.
The default enters the real modal Apply/Cancel loop. Tests inject a
runner that inspects the fully configured message box and returns an
answer without blocking; a host can use the same seam for a custom
presentation.
:param runner: callable given the configured Apply/Cancel
``QMessageBox``; the recipe is applied only when it returns
``QMessageBox.Apply``.
"""
self._confirmation_runner = runner
def _on_selection_changed(self, _row: int) -> None:
"""Describe the selected recipe, including anything that will not carry.
The description states the setting count and date, then any note about
the spaCR version it was written under and any settings this module no
longer has -- both before the user applies it rather than after. The
full saved file path is visible and can be copied.
:param _row: the newly selected row; the recipe is re-read from the
list, so it is not used.
"""
recipe = self.selected()
if recipe is None:
self._detail.setText("")
self._refresh_buttons()
return
parts = [f"{len(recipe.settings)} settings, saved {recipe.created}."]
note = version_note(recipe)
if note:
parts.append(note)
model = getattr(self._screen, "_settings_model", None)
if model is not None:
gap = compatibility_note(recipe, model)
if gap:
parts.append(gap)
if recipe.notes:
parts.append(recipe.notes)
detail = " ".join(parts)
if recipe.path:
detail += "\n" + tr("Saved to: {path}",
path=os.path.abspath(recipe.path))
self._detail.setText(detail)
self._refresh_buttons()
def _on_save(self) -> None:
"""Ask for a name and save the screen's current settings under it.
A failure is reported in a dialog rather than raised: a recipe that
cannot be written is a normal condition, not a crash. The saved
template is selected so its full file path is immediately visible.
"""
name, ok = QInputDialog.getText(
self, tr("Save template"),
tr("Name for this template, for example “Toxo PVM, 40×”:"))
if not ok or not str(name).strip():
return
try:
recipe = capture_recipe(self._screen, str(name).strip())
saved_path = os.path.abspath(save_recipe(recipe))
except Exception as exc:
QMessageBox.warning(self, tr("Could not save template"), str(exc))
return
self.reload()
for row, saved in enumerate(self._recipes):
if os.path.abspath(saved.path) == saved_path:
self._list.setCurrentRow(row)
break
def _on_apply(self) -> None:
"""Apply the selected recipe to the screen, confirming first if it may not fit.
A recipe from a different spaCR version, or one naming settings this
module no longer has, is applied only after the user says so -- with the
reason on screen, so the choice is informed rather than a warning to
click through.
"""
recipe = self.selected()
if recipe is None:
return
note = version_note(recipe)
model = getattr(self._screen, "_settings_model", None)
gap = compatibility_note(recipe, model) if model is not None else ""
if note or gap:
box = QMessageBox(self)
box.setIcon(QMessageBox.Information)
box.setWindowTitle(tr("Template from a different spaCR"))
box.setText(" ".join(part for part in (note, gap) if part))
box.setInformativeText("Apply it anyway?")
box.setStandardButtons(QMessageBox.Apply | QMessageBox.Cancel)
if self._confirmation_runner(box) != QMessageBox.Apply:
return
try:
applied = apply_recipe(recipe, self._screen)
except Exception as exc:
QMessageBox.warning(self, tr("Could not apply template"), str(exc))
return
self._detail.setText(
f"Applied “{recipe.name}” — {applied} settings written.")
def _on_export(self) -> None:
"""Write the selected recipe to a JSON file for sharing."""
recipe = self.selected()
if recipe is None:
return
path, _filter = QFileDialog.getSaveFileName(
self, tr("Share template"), f"{_slug(recipe.name)}.json",
tr("spaCR settings template (*.json)"))
if not path:
return
try:
text = json.dumps(recipe.to_json(), indent=2, sort_keys=True, default=str)
Path(path).parent.stat()
_atomic_write_text(Path(path), text)
except Exception as exc:
QMessageBox.warning(self, "Could not write the file", str(exc))
def _on_import(self) -> None:
"""Load a recipe from a JSON file or a settings CSV into this module.
A recipe belonging to another module is refused: applying it here would
write settings this screen does not have. One that names no module is
adopted by this one. A settings CSV, the file every run writes beside
its results, becomes a template named after the file.
"""
path, _filter = QFileDialog.getOpenFileName(
self, tr("Import template"), "",
tr("spaCR settings template (*.json);;Settings CSV (*.csv);;"
"All files (*)"))
if not path:
return
try:
if str(path).lower().endswith(".csv"):
recipe = _recipe_from_settings_csv(
path, self._app_key, self._screen)
else:
recipe = load_recipe(path)
if recipe.app_key and self._app_key and \
recipe.app_key != self._app_key:
raise ValueError(
tr("That template is for the {module} module.", module=repr(recipe.app_key)))
recipe.app_key = recipe.app_key or self._app_key
save_recipe(recipe)
except Exception as exc:
QMessageBox.warning(self, tr("Could not import template"), str(exc))
return
self.reload()
def _on_rename(self) -> None:
"""Ask for a new name for the selected recipe and rename it."""
recipe = self.selected()
if recipe is None:
return
name, ok = QInputDialog.getText(
self, tr("Rename template"), tr("New name for this template:"),
text=recipe.name)
if not ok or not str(name).strip() or str(name).strip() == recipe.name:
return
try:
_rename_recipe(recipe, str(name))
except Exception as exc:
QMessageBox.warning(self, tr("Could not rename template"), str(exc))
return
self.reload()
def _on_delete(self) -> None:
"""Delete the selected recipe."""
recipe = self.selected()
if recipe is None:
return
try:
delete_recipe(recipe)
except Exception as exc:
QMessageBox.warning(self, tr("Could not delete template"), str(exc))
return
self.reload()
def _refresh_buttons(self) -> None:
"""Enable Apply, Export, Rename and Delete only while a recipe is selected."""
has = self.selected() is not None
for button in (self._btn_apply, self._btn_export, self._btn_rename,
self._btn_delete):
button.setEnabled(has)
[docs]
def open_recipes(screen, parent: Optional[QWidget] = None) -> RecipeDialog:
"""Open the recipe dialog for ``screen``.
:param screen: the module screen whose recipes are listed and applied,
passed to :class:`RecipeDialog`.
"""
dialog = RecipeDialog(screen, parent=parent)
dialog.show()
return dialog
[docs]
def install(screen) -> Optional[QToolButton]:
"""Add a Templates button to ``screen``'s settings search strip.
The strip is where a settings bundle belongs — directly above the settings
it bundles — and it already exists, so no new toolbar is added. Returns
``None`` when the screen has no strip (a bespoke screen), or when one is
already installed.
:param screen: the module screen; its ``_settings_search`` strip must
provide ``add_trailing_widget``. If the screen already has a
``_recipe_button``, that button is returned unchanged.
"""
if getattr(screen, "_recipe_button", None) is not None:
return screen._recipe_button
bar = getattr(screen, "_settings_search", None)
if bar is None or not hasattr(bar, "add_trailing_widget"):
return None
button = QToolButton(bar)
button.setObjectName(RECIPE_BUTTON_NAME)
caption = "Templates"
button.setProperty("_spacr_i18n_text", caption)
button.setText(tr(caption))
button.setCursor(Qt.PointingHandCursor)
hint = ("Save these settings under a name, reuse a saved one, or share "
"it as a file.")
button.setProperty("_spacr_i18n_tooltip", hint)
button.setToolTip(tr(hint))
handler = _RecipeButtonHandler(screen, button)
button.clicked.connect(handler.on_clicked)
button._spacr_recipe_handler = handler
bar.add_trailing_widget(button)
screen._recipe_button = button
return button
class _RecipeButtonHandler:
"""Bound-method target for the Recipes button.
A plain object rather than a lambda so the connection holds a reference
to something that is not the screen's closure environment; the button
owns it, and it dies with the button.
:param screen: the screen whose recipes are opened.
:param button: the button this is connected to. Held for its
``window()``, which is what the dialog is parented to -- so the
dialog follows the real window even when the screen is reparented.
It is ALSO what owns this handler, per the note above.
"""
def __init__(self, screen, button: QToolButton):
"""Hold the screen and the button that owns this handler."""
self._screen = screen
self._button = button
def on_clicked(self, _checked: bool = False) -> None:
"""Open the recipe dialog for this handler's screen."""
open_recipes(self._screen, parent=self._button.window())
def _find_menu(window: QMainWindow, title: str) -> Optional[QMenu]:
"""The window's menu-bar menu titled ``title``, ignoring ``&``.
``findChildren`` rather than walking ``menuBar().actions()`` and calling
``QAction.menu()`` — see the note on
:func:`spacr.qt.widgets.feature_dictionary._find_menu`; the obvious
reading hands back a QMenu wrapper that dies with the QAction wrapper it
came off, and keeping the owners alive segfaults instead.
"""
try:
bar = window.menuBar()
if bar is None:
return None
menus = bar.findChildren(QMenu)
except Exception:
return None
for menu in menus:
try:
if menu.title().replace("&", "") == title:
return menu
except RuntimeError:
continue
return None
class _RecipeMenuHandler:
"""Bound-method target for the Help-menu entry."""
def __init__(self, window: QMainWindow):
"""Bind the Help-menu entry to whichever module is on screen.
:param window: the main window; its stack is read at trigger time
rather than here, so one entry serves every module.
NOT A QOBJECT and so NOT PARENTED: a bound method connected to a
signal does not keep its object alive, which is why the caller
stashes this handler on the action it connected.
"""
self._window = window
def on_triggered(self, _checked: bool = False) -> None:
"""Open recipes for whichever module is on screen."""
try:
screen = self._window._stack.currentWidget()
except Exception:
screen = None
if screen is None or getattr(screen, "_settings_model", None) is None:
QMessageBox.information(
self._window, tr("Settings templates"),
tr("Open a module with a settings panel first — a template is a "
"bundle of one module's settings."))
return
open_recipes(screen, parent=self._window)
[docs]
def install_help_action(window: QMainWindow) -> Optional[QAction]:
"""Add **Settings templates…** to the window's Help menu.
Returns the action, or ``None`` when there is no Help menu or one is
already installed. The command palette mirrors menu actions, so recipes are
also reachable from Ctrl+K.
:param window: the main window; its menu-bar menu titled "Help" (``&``
ignored) receives the action, before the first separator.
"""
menu = _find_menu(window, "Help")
if menu is None:
return None
for act in menu.actions():
if act.property("_spacr_i18n_text") == MENU_ACTION_TEXT or act.text() == tr(MENU_ACTION_TEXT):
return None
action = QAction(tr(MENU_ACTION_TEXT), window)
action.setProperty("_spacr_i18n_text", MENU_ACTION_TEXT)
from .menus import set_menu_role
set_menu_role(action, "none")
action.setStatusTip(
"Save the current module's settings under a name, reuse a saved "
"bundle, or share one as a file.")
handler = _RecipeMenuHandler(window)
action.triggered.connect(handler.on_triggered)
action._spacr_recipe_handler = handler
before = None
for act in menu.actions():
if act.isSeparator():
before = act
break
if before is not None:
menu.insertAction(before, action)
else:
menu.addAction(action)
return action
class _StackWatcher(QObject):
"""Adds the Recipes button to each settings screen as it is shown.
A ``QObject`` parented to the window so the connection dies with the
window, and the slot is a bound method rather than a closure.
"""
def __init__(self, window: QMainWindow):
"""Install the recipe button into each screen as it is shown.
:param window: the main window whose stack is watched; also the
QObject parent.
"""
super().__init__(window)
self._window = window
def on_current_changed(self, _index: int) -> None:
"""Install into whatever screen the stack just switched to."""
self.install_current()
def install_current(self) -> Optional[QToolButton]:
"""Install into the stack's current widget, if it has a strip."""
try:
screen = self._window._stack.currentWidget()
except Exception:
return None
if screen is None:
return None
return install(screen)
[docs]
def install_window_hooks(window: QMainWindow) -> Optional[_StackWatcher]:
"""Wire recipes into a live main window.
Called once from :func:`spacr.qt.shortcuts.install`. Every failure is
logged and swallowed, so a failure to add recipes does not stop the window.
:param window: the main window; the Help-menu action is added to it and
its ``_stack`` screen stack is watched so each shown screen gets a
Templates button. Without a ``_stack`` the result is ``None``.
"""
install_help_action(window)
stack = getattr(window, "_stack", None)
if stack is None:
return None
if getattr(window, "_recipe_watcher", None) is not None:
return window._recipe_watcher
watcher = _StackWatcher(window)
try:
stack.currentChanged.connect(watcher.on_current_changed)
except Exception:
LOG.debug("could not follow the screen stack", exc_info=True)
return None
window._recipe_watcher = watcher
QTimer.singleShot(0, watcher.install_current)
return watcher