"""
User-facing preferences — language, theme, font scale and accessibility.
Persistent settings backed by :class:`PySide6.QtCore.QSettings`, so
they survive app restarts. New knobs can slot in alongside the existing
ones without changing consumers thanks to the small typed API
(``get_theme()`` / ``set_theme(...)`` etc.).
Wire-up:
* :func:`apply_preferences_to_app` — call once at startup and again
whenever a setting changes; reapplies the stylesheet with the
current theme + font scale.
* :class:`PreferencesDialog` — the modal Settings dialog opened by
Ctrl+P (see :mod:`spacr.qt.shortcuts`).
Public API::
from spacr.qt.preferences import (
get_theme, set_theme, get_theme_choice, set_theme_choice,
get_language, set_language,
get_cell_variant, set_cell_variant,
cell_background_path,
theme_background_path,
get_sound_music_file, set_sound_music_file,
get_ambient_enabled, set_ambient_enabled,
get_ambient_animation, set_ambient_animation,
get_ambient_theme, set_ambient_theme,
get_spacr_mode, set_spacr_mode, mode_label, mode_note, mode_warning,
confirm_resource_action, run_resource_action,
get_ambient_palette, set_ambient_palette,
get_ambient_blur, set_ambient_blur,
get_ambient_speed, set_ambient_speed,
get_ambient_size, set_ambient_size,
get_ambient_resolution, set_ambient_resolution,
get_ambient_density, set_ambient_density,
get_ambient_drift_direction, set_ambient_drift_direction,
get_spinner_delay, set_spinner_delay,
ambient_default_palette, apply_ambient_preferences,
get_setting_animations_enabled, set_setting_animations_enabled,
get_tooltips_enabled, set_tooltips_enabled,
get_font_scale, set_font_scale,
get_gui_scale, set_gui_scale,
get_figure_save_mode, set_figure_save_mode,
get_color_blind_mode, set_color_blind_mode,
get_db_browser_editable, set_db_browser_editable,
get_dock_mode, set_dock_mode,
get_pane_opacity, set_pane_opacity, effective_pane_alpha,
get_field_fade_enabled, set_field_fade_enabled,
get_show_alpha, set_show_alpha,
get_show_beta, set_show_beta, maturity_is_visible,
apply_preferences_to_app,
PreferencesDialog,
)
Values:
* ``theme``: ``"dark"`` | ``"light"`` | ``"cell"`` | ``"glass"`` |
``"high_contrast"`` | one of
the ten night themes in :data:`spacr.qt.night_themes.NIGHT_THEME_KEYS` |
the five data-art presets in :data:`spacr.qt.night_themes.DATA_ART_THEME_KEYS` |
``"system"`` (default ``"dark"``). ``"system"`` follows the operating
system color scheme, and only once somebody has picked it: a stored
``"system"`` written before dark became the default (2026-09-21) was
the old default, not a choice, and reads as ``"dark"``; see
:func:`get_theme`. ``"cell"`` uses fluorescence imagery and ``"glass"``
uses neutral layered materials over a built-in light field. A night
or data-art preset also carries a backdrop and a sound set, written by
:func:`apply_night_theme` when it is chosen. Space is not a selectable
theme. The retired ``space_variant`` and ``space_seed`` values are
removed from an older store the first time the theme is read; see
:func:`get_theme`.
* ``font_scale``: float, 1.0 = 100 % (the default). Clamped to [0.10, 2.0].
* ``gui_scale``: float, 1.0 = 100 % (the default). Clamped to [0.10, 2.0].
Scales every size of the interface, not only text, and applies live.
See :mod:`spacr.qt.gui_scale`.
* ``figure_save_mode``: ``"print"`` | ``"screen"`` | ``"transparent"``
(default ``"print"``). Controls the page and figure-element colours used
for saved figures. ``SPACR_FIGURE_SAVE_MODE`` remains a process-local
override; see :func:`spacr.figure_style.figure_save_mode`.
* ``color_blind_mode``: ``"off"`` | ``"deuteranopia"`` | ``"protanopia"``
| ``"tritanopia"`` (default ``"off"``). Swaps matplotlib rainbow /
red-green palettes for perceptually-uniform + colour-blind-safe
alternatives (viridis for continuous, Okabe-Ito for categorical).
* ``performance_logging``: ``"off"`` | ``"summary"`` | ``"detailed"``
(default ``"summary"``). Records process-tree resource use independently
of verbose call tracing; see :func:`get_performance_logging` and
:mod:`spacr.resource_log`.
* ``db_browser_editable``: bool, default ``False``. Permits the
Database Browser to open a read-write connection at all; see
:func:`get_db_browser_editable`.
* ``dock_mode``: ``"locked"`` | ``"hidden"`` (default
``"locked"``). Whether the left app dock reveals on hover, is pinned
open as a permanent column, or is not there at all.
* ``pane_opacity``: int percent, default ``60``. How solid shared surfaces
are, or the relative material strength in Glass. Clamped up to
:func:`spacr.qt.theme.pane_alpha_floor` at paint time — the
preference is a request, legibility is not negotiable.
* ``field_fade``: bool, default ``True``. Whether an input field's
container and outline ramp from solid on the left to fully transparent
on the right. Fields are **exempt from** ``pane_opacity`` while this is
on — see :func:`get_field_fade_enabled` and
:mod:`spacr.qt.widgets.field_fade`.
* ``show_alpha`` / ``show_beta``: bool, both default ``True``. Control
whether modules and settings at that maturity are shown. Stable features
are always visible.
* ``sound/music_file``: str, default ``""``. A WAV of the user's own that
the music bed plays instead of the synthesized one, and that the
Resonance backdrop is driven by. See :func:`get_sound_music_file`.
* ``ambient_enabled``: bool, default ``True``. Whether module screens
paint the animated background at all. Turning it off is a first-class
choice — see :func:`get_ambient_enabled`. The user-facing control is the
``None`` entry in the Animation list rather than a second switch: one
row, one meaning. Choosing an animation turns it back on.
* ``spacr_mode``: ``"extra_performance"`` | ``"performance"`` |
``"balanced"`` (default ``"balanced"``). How hard spaCR tries to stay out
of the machine's way — when it frees its own caches, and whether it
overrides the visual settings. Balanced does neither. See
:func:`set_spacr_mode` and :mod:`spacr.qt.resource_cleanup`, which owns
what a cleanup is allowed to touch (spaCR's own memory, and nothing
else — no other process, ever).
* ``ambient_theme`` / ``ambient_palette``: which animation, and in which
colours. ``ambient_theme`` also holds
:data:`spacr.qt.widgets.ambient.NO_ANIMATION` — read it with
:func:`get_ambient_animation`, which can answer ``"none"``, or with
:func:`get_ambient_theme`, whose answer is always something paintable.
Validated against
:data:`spacr.qt.widgets.ambient.AMBIENT_THEMES` and
:func:`spacr.qt.widgets.ambient.palettes_for` respectively; palettes
are *per theme*, so see :func:`get_ambient_palette` for how the two
keys stay consistent with each other.
* ``ambient_speed`` / ``ambient_size`` / ``ambient_resolution`` /
``ambient_density``: floats; speed, size and detail default to ``1.0``,
density to ``0.1``. All are *multipliers* on
what the chosen animation already does — how fast it moves, how large its
elements are, how much detail it is drawn with and how many elements
there are. Clamped on read and on write
to the ranges the engines declare
(:data:`spacr.qt.widgets.ambient.SPEED_RANGE` and friends).
* ``ambient_blur``: a legacy float retained for reading older preferences.
Current animation widgets render without this saved blur setting. Image
detail is controlled by ``ambient_resolution``.
* ``ambient_drift_direction``: ``"up"`` | ``"down"`` | ``"random"``
(default ``"up"``). Which way spaCR stratified travels. A
preference rather than three entries in the animation menu; see
:data:`spacr.qt.widgets.ambient.DRIFT_DIRECTIONS` for why.
* ``spinner_delay``: float seconds, default ``2.0``. How long background
work has to run before the activity spinner appears at all — see
:func:`get_spinner_delay`.
* ``setting_animations``: bool, default ``False``. Whether setting tooltips
play their animations automatically. When disabled, hover remains text-only
until the user activates **Animation** in that tooltip's footer; see
:func:`get_setting_animations_enabled`.
* ``language``: one of the bundled language codes from
:mod:`spacr.qt.i18n`; defaults to English and falls back safely when a
persisted value is invalid.
"""
from __future__ import annotations
import contextlib
import logging
import os
import threading
from PySide6.QtCore import QSettings, Qt
from .night_themes import (DATA_ART_THEMES, DATA_ART_THEME_KEYS,
NIGHT_THEME_KEYS, is_night_theme, theme_for)
from .prefs import _store_args
LOG = logging.getLogger(__name__)
_ORG = "spacr"
_APP = "qt"
_KEY_THEME = "prefs/theme"
_KEY_THEME_FOLLOW_SYSTEM_CHOSEN = "prefs/theme_follow_system_chosen"
_KEY_LANGUAGE = "prefs/language"
_KEY_FONT_SCALE = "prefs/font_scale"
_KEY_GUI_SCALE = "prefs/gui_scale"
_KEY_CB_MODE = "prefs/color_blind_mode"
_KEY_VERBOSE_LOG = "prefs/verbose_logging"
_KEY_PERFORMANCE_LOG = "prefs/performance_logging"
_KEY_SHARE_DIAGNOSTICS = "privacy/share_diagnostic_logs"
_KEY_REFRESH_NEWS = "privacy/refresh_news_from_github"
_KEY_UPDATE_CHANNEL = "updates/channel"
_KEY_SEEN_VERSION = "updates/last_seen_version"
_KEY_PREVIOUS_VERSION = "updates/previous_version"
_KEY_LOG_FILE_LEVELS = "prefs/log_file_levels"
_KEY_LOG_CONSOLE_LEVELS = "prefs/log_console_levels"
_KEY_DB_EDIT = "prefs/db_browser_editable"
_KEY_DOCK_MODE = "prefs/dock_mode"
_KEY_DOCK_WIDTH = "prefs/dock_width"
_KEY_RUNTIME_TEXT_SCALE = "prefs/runtime_text_scale"
_KEY_HOME_ASIDE_TEXT = "prefs/home_aside_text"
_KEY_PANE_OPACITY = "prefs/pane_opacity"
_KEY_FIELD_FADE = "prefs/field_fade"
_KEY_SHOW_ALPHA = "prefs/show_alpha"
_KEY_SHOW_BETA = "prefs/show_beta"
_KEY_SHOW_ALPHA_FEATURES = "prefs/show_alpha_features"
_KEY_SHOW_ALPHA_SPECIES = "prefs/show_alpha_species"
_KEY_AMBIENT_ENABLED = "prefs/ambient_enabled"
_KEY_AMBIENT_THEME = "prefs/ambient_theme"
_KEY_SPACEOUT_ENABLED = "spaceout/ambient_enabled"
_KEY_SPACEOUT_ANIMATION = "spaceout/animation"
_KEY_SPACEOUT_PALETTE = "spaceout/animation_palette"
_SPACEOUT_FIELD_EFFECT_KEYS = (
"attractors", "relaxation", "elastic_release", "vortex",
"density_pulses", "density_waves", "color_waves", "spirals",
)
_KEY_AMBIENT_PALETTE = "prefs/ambient_palette"
_KEY_AMBIENT_BACKGROUND = "prefs/ambient_background"
_KEY_AMBIENT_BLUR = "prefs/ambient_blur"
_KEY_AMBIENT_SPEED = "prefs/ambient_speed"
_KEY_AMBIENT_SIZE = "prefs/ambient_size"
_KEY_AMBIENT_RESOLUTION = "prefs/ambient_resolution"
_KEY_AMBIENT_DENSITY = "prefs/ambient_density"
_KEY_AMBIENT_BLINK_PERCENT = "prefs/ambient_blink_percent"
_KEY_FIELD_POPUP_WAVES = "prefs/field_popup_wave_frequency"
_KEY_FIELD_RIPPLES = "prefs/field_ripples"
_KEY_FIELD_RIPPLE_INTENSITY = "prefs/field_ripple_intensity"
_KEY_AMBIENT_GRAVITY_RADIUS = "prefs/ambient_gravity_radius"
_KEY_AMBIENT_DRIFT_DIR = "prefs/ambient_drift_direction"
#: Which generation of the motion keys the store was last written by. Only
#: ``ambient_blur`` has ever changed meaning, and this is how a value written
#: under the old one is recognised — see :func:`_migrate_ambient_motion`.
_KEY_AMBIENT_SCALE = "prefs/ambient_motion_scale"
AMBIENT_MOTION_SCALE = 2
_KEY_SPINNER_DELAY = "prefs/spinner_delay"
_KEY_SETTING_ANIMATIONS = "prefs/setting_animations"
#: The two tooltip surfaces. INDEPENDENT: both on,
#: both off, or either alone are all legal, which is why they are two
#: booleans and not a three-way choice wearing two checkboxes.
_KEY_TOOLTIPS_BOX = "prefs/tooltips_box"
_KEY_TOOLTIPS_BOTTOM = "prefs/tooltips_bottom"
#: The master switch over every ordinary Qt tooltip in the application --
#: buttons, toolbars, table headers, the lot. It is not one of the two
#: SETTINGS surfaces above: those answer "what is this setting", this one
#: answers "do small labels pop up at all".
_KEY_TOOLTIPS_ENABLED = "prefs/tooltips_enabled"
_KEY_TOOLTIP_DELAY = "prefs/tooltip_delay"
_KEY_PREFERENCES_HELP_HEIGHT = "prefs/preferences_help_height"
_KEY_SPACR_MODE = "prefs/spacr_mode"
_KEY_LAPTOP_MODE = "prefs/laptop_mode"
_KEY_FONT_WEIGHT = "prefs/interface_font_weight"
_KEY_PRELOAD = "prefs/preload_policy"
_KEY_FRACTAL_PATTERN = "spaceout/fractal_pattern"
_KEY_FRACTAL_BACKEND = "spaceout/fractal_backend"
_KEY_FRACTAL_QUALITY = "spaceout/fractal_quality"
_KEY_FRACTAL_SCALE = "spaceout/fractal_scale"
_KEY_FRACTAL_SPEED = "spaceout/fractal_speed"
_KEY_FRACTAL_DREAM = "spaceout/fractal_dream"
_KEY_FRACTAL_VARIABLE_SPEED = "spaceout/fractal_variable_speed"
_KEY_FRACTAL_SPEED_MIN = "spaceout/fractal_speed_min"
_KEY_FRACTAL_SPEED_MAX = "spaceout/fractal_speed_max"
#: What a fractal number must satisfy to be usable, as
#: ``name -> (floor, ceiling, why)``. ``None`` for a bound means there is
#: none.
#:
#: These are validation bounds, not display-field caps. A field accepts the
#: typed value and :func:`explain_a_fractal_number` says plainly when it
#: cannot be used and why; it never silently changes 8000 to 8.
#:
#: A bound is here only where a value outside it CANNOT WORK -- a
#: supersampling of 0 takes no samples, a scale of 0 renders nothing, a
#: negative iteration count is not a count. Values that are merely
#: extravagant are the user's business.
def _mandelbrot_defaults() -> dict:
"""What the fractal settings start at, before anyone chooses a level.
THE SHIPPED DEFAULTS ARE THE LIGHT ONES, keeping first launch responsive
on modest hardware.
So the numbers that decide COST -- supersampling, render scale and the
iteration budget -- come from the `balanced` preset rather than from the
Mandelbrot renderer's published set, which is a `high` profile. The
published numbers are still what the Mandelbrot pattern documents and
what choosing High restores; they are not what a user who has chosen
nothing gets.
Everything that does not cost anything -- the steering behaviour, the
starting scale, the precision the reference orbit is built to -- keeps
the published value, because making those timid would change what the
pattern IS rather than how hard it works.
"""
try:
from .widgets.fractal_mandelbrot import DEFAULTS
return dict(DEFAULTS)
except Exception: # noqa: BLE001
return {"supersampling": 2, "seconds_per_decade": 24.0,
"base_iterations": 300, "iterations_per_decade": 55.0,
"max_iterations": 2200, "precision_digits": 320,
"initial_scale": 1.25, "zoom_rate": 1.0,
"render_scale": 1.0, "steering_strength": 0.09,
"steering_interval_decades": 0.40,
"steering_duration": 3.8, "candidate_count": 24,
"max_depth": 21.0}
class _LazyDefaults(dict):
"""The Mandelbrot defaults, resolved on first use.
NOT AT IMPORT. `_mandelbrot_defaults` reaches into
`spacr.qt.widgets.fractal_mandelbrot`, and importing that package pulls
QtWidgets in -- which `test_preferences_imports_without_touching_the_
ambient_widget` exists to prevent, because this module is imported by
headless paths that must never build a widget toolkit.
A dict subclass rather than a function, so every existing
`_MANDEL_DEFAULTS[name]` and `.get(name)` reads the same as before.
"""
_loaded = False
def _load(self) -> None:
"""Fill the defaults once, on first read.
The flag is set BEFORE the work so a failure part-way through does not
leave every subsequent read retrying an import that already failed.
"""
if self._loaded:
return
self._loaded = True
try:
self.update(_mandelbrot_defaults())
except Exception: # noqa: BLE001
LOG.debug("could not read the fractal defaults", exc_info=True)
def __getitem__(self, key):
"""Force the load, then read as a normal dict."""
self._load()
return dict.__getitem__(self, key)
def get(self, key, default=None):
"""Read one default, loading the table on first use.
:param key: the setting name.
:param default: what to return when it has none.
:returns: the default value.
"""
self._load()
return dict.get(self, key, default)
def __contains__(self, key) -> bool:
"""Force the load, then answer as a normal dict.
Every access point forces it, not just ``__getitem__``: a caller testing
``in`` before reading would otherwise see an empty mapping and conclude
the key does not exist.
"""
self._load()
return dict.__contains__(self, key)
def keys(self):
"""Return the setting names, loading the table on first use."""
self._load()
return dict.keys(self)
def items(self):
"""Return the name/default pairs, loading the table on first use."""
self._load()
return dict.items(self)
_MANDEL_DEFAULTS = _LazyDefaults()
FRACTAL_LIMITS = {
"scale": (0.01, None,
"a scale of zero or less renders nothing at all"),
"speed": (0.0, None, "speed cannot run backwards"),
"dream": (0.0, None, "dream is an amount, and cannot be negative"),
"speed_min": (0.0, None, "speed cannot run backwards"),
"speed_max": (0.0, None, "speed cannot run backwards"),
"speed_period": (0.1, None,
"a period of zero would change speed infinitely fast"),
"pointer_size": (0.0, None, "a reach cannot be negative"),
"pointer_strength": (0.0, None, "a strength cannot be negative"),
"magnifier_size": (0.25, 3.0,
"below a quarter the lens is smaller than the "
"cursor, and above three times it covers the "
"window"),
"supersampling": (1, None,
"fewer than one sample a pixel draws nothing"),
"seconds_per_decade": (0.1, None,
"a decade cannot take no time at all"),
"base_iterations": (1, None, "a frame needs at least one iteration"),
"iterations_per_decade": (0.0, None,
"iterations cannot be taken away as you "
"descend; the picture would go solid"),
"max_iterations": (1, 4096,
"the shader's loop is bounded at 4096, so a larger "
"number would be silently ignored"),
"precision_digits": (16, None,
"below about sixteen digits the reference orbit "
"is no better than the float it is meant to "
"rescue"),
"initial_scale": (0.0000001, None,
"a starting scale of zero has nothing to zoom out of"),
"tile_rows": (1, None, "a tile needs at least one row"),
"render_scale": (0.05, None,
"below about a twentieth there are not enough pixels "
"to see"),
"fps": (1, None, "a frame rate of zero never draws"),
"zoom_rate": (0.0, None, "the zoom cannot run backwards"),
"steering_strength": (0.0, None, "a strength cannot be negative"),
"steering_interval_decades": (0.01, None,
"steering every zero decades would never "
"stop choosing a new target"),
"steering_duration": (0.1, None,
"a move that takes no time is a jump"),
"candidate_count": (1, None,
"choosing between no candidates chooses nothing"),
"steering": (0.0, 1.0,
"steering is an amount between none and restless"),
"max_depth": (0.1, 23.0,
"the reference orbit is carried as three float32s and "
"reproduces Z to about 4.2e-24, so past roughly "
"twenty-three decades the perturbation is measuring noise "
"and the picture turns to mush"),
}
#: Every setting that is really about SPEED, and what one Speed of 1.0
#: means for each.
#:
#: One control drives the related timing values, preventing separate fields
#: that answer the same question from contradicting one another.
#:
#: Speed multiplies the first four and DIVIDES seconds-per-decade, because
#: that one is a duration: a bigger number there is a slower dive, and a
#: control called Speed that made things slower as it rose would be a trap.
SPEED_GROUP = {
"speed": 1.0,
"zoom_rate": 1.0,
"speed_min": 0.55,
"speed_max": 1.65,
"speed_period": 41.0,
}
#: The duration that Speed divides rather than multiplies.
SPEED_SECONDS_PER_DECADE: float = 24.0
#: Every setting that is really about SCALE -- how much is drawn, and how
#: finely -- and what one Scale of 1.0 means for each.
#:
#: Supersampling is a whole number of samples a side, so it steps rather
#: than scaling smoothly: below 1.0 it is 1, and it reaches 2 and 3 as the
#: control rises. The cost is its square, which is why it is the last thing
#: to go up.
SCALE_GROUP = {
"scale": 1.0,
"render_scale": 1.0,
}
[docs]
def speed_group_values(speed: float) -> dict:
"""What one Speed means for every setting that follows it.
:param speed: the single user-facing number.
:returns: ``{setting: value}`` for the whole group.
"""
amount = max(0.01, float(speed))
values = {name: round(base * amount, 4)
for name, base in SPEED_GROUP.items()}
values["seconds_per_decade"] = round(
SPEED_SECONDS_PER_DECADE / amount, 4)
return values
[docs]
def scale_group_values(scale: float) -> dict:
"""What one Scale means for every setting that follows it.
:param scale: the single user-facing number.
:returns: ``{setting: value}`` for the whole group.
"""
amount = max(0.05, float(scale))
values = {name: round(base * amount, 4)
for name, base in SCALE_GROUP.items()}
values["supersampling"] = 1 if amount < 1.5 else (2 if amount < 2.5
else 3)
return values
[docs]
def explain_a_fractal_number(name: str, value) -> str:
"""Why ``value`` cannot be used for ``name``, or ``""`` if it can.
:param name: a fractal setting name.
:param value: whatever the field holds.
:returns: a sentence for the user, empty when the value is fine.
THE FIELD TAKES ANYTHING; this is what decides whether it WORKS. The
message names the setting, the value and the reason, because "invalid
input" tells a user only that the software disagrees with them.
"""
floor, ceiling, why = FRACTAL_LIMITS.get(name, (None, None, ""))
try:
number = float(value)
except (TypeError, ValueError):
return f"{name}: {value!r} is not a number."
if number != number:
return f"{name}: not a number."
if floor is not None and number < floor:
return (f"{name}: {value} is too small (needs at least {floor}) "
f"— {why}.")
if ceiling is not None and number > ceiling:
return (f"{name}: {value} is too large (at most {ceiling}) "
f"— {why}.")
return ""
MAX_FRACTAL_SPEED: float = 1_000_000.0
#: Whether the pointer pulls the backdrop about at all.
_KEY_FRACTAL_POINTER = "spaceout/fractal_pointer_gravity"
#: How far that pull reaches, and how hard it pulls.
_KEY_FRACTAL_POINTER_SIZE = "spaceout/fractal_pointer_size"
_KEY_FRACTAL_POINTER_STRENGTH = "spaceout/fractal_pointer_strength"
_KEY_FRACTAL_MAGNIFIER_SIZE = "spaceout/fractal_magnifier_size"
#: Supersampling, and the Mandelbrot renderer's own numbers.
_KEY_FRACTAL_SUPERSAMPLING = "spaceout/fractal_supersampling"
_KEY_FRACTAL_SECONDS_PER_DECADE = "spaceout/fractal_seconds_per_decade"
_KEY_FRACTAL_BASE_ITERATIONS = "spaceout/fractal_base_iterations"
_KEY_FRACTAL_ITERATIONS_PER_DECADE = "spaceout/fractal_iterations_per_decade"
_KEY_FRACTAL_MAX_ITERATIONS = "spaceout/fractal_max_iterations"
_KEY_FRACTAL_PRECISION_DIGITS = "spaceout/fractal_precision_digits"
_KEY_FRACTAL_INITIAL_SCALE = "spaceout/fractal_initial_scale"
_KEY_FRACTAL_ZOOM_RATE = "spaceout/fractal_zoom_rate"
_KEY_FRACTAL_RENDER_SCALE = "spaceout/fractal_render_scale"
_KEY_FRACTAL_STEERING_STRENGTH = "spaceout/fractal_steering_strength"
_KEY_FRACTAL_STEERING_INTERVAL_DECADES = "spaceout/fractal_steering_interval_decades"
_KEY_FRACTAL_STEERING_DURATION = "spaceout/fractal_steering_duration"
_KEY_FRACTAL_CANDIDATE_COUNT = "spaceout/fractal_candidate_count"
_KEY_FRACTAL_MAX_DEPTH = "spaceout/fractal_max_depth"
#: The one user-facing steering control, 0..1.
_KEY_FRACTAL_STEERING = "spaceout/fractal_steering"
#: "fixed" descends to one point; "guided" searches as it goes; "tour"
#: floats between the twenty mapped regions.
_KEY_FRACTAL_PATH = "spaceout/fractal_path"
#: The memory budget: how long an unused thing may sit, how much may be
#: held, and how much of the machine must stay free for everything else.
_KEY_IDLE_MINUTES = "prefs/cache_idle_minutes"
_KEY_CACHE_CEILING = "prefs/cache_ceiling_mb"
_KEY_HEADROOM = "prefs/headroom_mb"
_KEY_DATABASE_WRITE_QUEUE = "prefs/database_write_queue_gib"
_KEY_FRACTAL_SPEED_PERIOD = "spaceout/fractal_speed_period"
#: Where the visual settings Extra Performance overrode are kept, so
#: leaving that mode gives the user back exactly what they had.
_KEY_MODE_VISUAL_STASH = "prefs/mode_visual_stash"
#: Themes with a palette of their own — mirrors
#: :data:`spacr.qt.theme.THEMES`, restated here so importing this module
#: does not pull in QtGui/QtWidgets.
#:
#: The ten night themes are appended from :mod:`spacr.qt.night_themes`
#: rather than written out again, because that module is Qt-free for
#: exactly this reason: it can be imported here without QtGui, and it
#: cannot then drift from what :data:`spacr.qt.theme.THEMES` holds.
PALETTE_THEMES = ("dark", "light", "cell", "glass",
"high_contrast") + NIGHT_THEME_KEYS + DATA_ART_THEME_KEYS
#: Persisted values. An existing install has ``prefs/theme`` set to one
#: of dark/light/system/space; those keep resolving exactly as before,
#: and an unrecognised value (hand-edited INI, a downgrade from a build
#: with more themes) falls back to :data:`DEFAULT_THEME` rather than
#: raising.
VALID_THEMES = PALETTE_THEMES + ("system",)
_SPACEOUT_NIGHT_THEMES = ("data_art_fungal_growth", "data_art_tissue_facets")
#: Dark on every platform until somebody chooses otherwise (maintainer,
#: 2026-09-21: start spaCR dark by default, and the setup screen too). The
#: operating system's own light or dark setting does not override it; only
#: an explicit "Follow system" choice does.
DEFAULT_THEME = "dark"
#: RETIRED 2026-09-09. Kept as names only so `_forget_the_space_theme_keys`
#: can remove a stored value; nothing reads them. See the note above
#: `theme_background_path`.
_KEY_SPACE_VARIANT = "prefs/space_variant"
_KEY_SPACE_SEED = "prefs/space_seed"
#: Store files already cleared of the two keys above in this process.
_SPACE_KEYS_CLEARED: set = set()
_KEY_CELL_VARIANT = "prefs/cell_variant"
FONT_SCALE_MIN = 0.10
FONT_SCALE_MAX = 2.00
#: Presented as "Zoom" rather than "Font scale", because it scales the whole
#: interface — spacing, tiles, dots and icons move with the type, so calling it
#: a font setting undersells what the control does.
#:
#: 100% IS THE DEFAULT, and the reason it was 150% is worth keeping: spaCR's
#: natural size was laid out on a 1080p display, and on a 4K panel driven at
#: 1x everything read small.
#:
#: But this scales the whole interface, and on a HiDPI display the operating
#: system is ALREADY scaling -- macOS reports a 2x device pixel ratio and
#: draws accordingly. Applying 1.5 on top of that is 3x linear and NINE
#: TIMES the pixels of a 100% layout, which is a laptop rendering nine
#: screens' worth of work to show one. Reported as spaCR being "extremely
#: slow" on a machine measurably faster than the workstation it runs well
#: on.
#:
#: The 4K case is a preference a user on that display sets once. The laptop
#: case was everybody, silently, by default.
DEFAULT_FONT_SCALE = 1.0
#: The whole-GUI scale's bounds and default (item 471): the same 10 % to
#: 200 % as Zoom, so the two sliders read alike. Applied live by the
#: scaling layer in :mod:`spacr.qt.gui_scale`, which says how.
GUI_SCALE_MIN = 0.10
GUI_SCALE_MAX = 2.00
DEFAULT_GUI_SCALE = 1.0
#: What the GUI scale row says it is, including the way back.
GUI_SCALE_TIP = (
"Scale every part of the interface -- widget sizes, spacing, icons, "
"figures and text -- from 10 % to 200 %. Lower it to fit more on a "
"small or low-resolution screen. It applies straight away and then "
"asks whether to keep it; with no answer it goes back by itself after "
"15 seconds. Font scale applies on top: 50 % GUI at 200 % font gives "
"half-size controls with text the usual size. Ctrl+Alt+0 puts GUI "
"scale and font scale back to 100 % from anywhere.")
VALID_CB_MODES = ("off", "deuteranopia", "protanopia", "tritanopia")
DEFAULT_CB_MODE = "off"
_KEY_FIG_FORMAT = "prefs/figure_format"
_KEY_FIG_PNG_DPI = "prefs/figure_png_dpi"
_KEY_FIG_SAVE_MODE = "prefs/figure_save_mode"
_KEY_FIG_INTEGRITY = "prefs/figure_integrity_check"
_FIG_INTEGRITY_WIDGET = "FigureIntegrityCheck"
VALID_FIG_FORMATS = ("png", "pdf")
DEFAULT_FIG_FORMAT = "pdf"
VALID_PNG_DPIS = (100, 200, 300, 600, 1200)
DEFAULT_PNG_DPI = 300
VALID_FIG_SAVE_MODES = ("print", "screen", "transparent")
DEFAULT_FIG_SAVE_MODE = "print"
_KEY_FIG_LIVE_CACHE = "prefs/figure_live_cache"
_KEY_FIG_DYNAMIC = "prefs/figure_dynamic"
DEFAULT_FIG_LIVE_CACHE = 20
#: Bounds, not a menu: any number in range is legal.
MIN_FIG_LIVE_CACHE = 1
MAX_FIG_LIVE_CACHE = 500
DEFAULT_FIG_DYNAMIC = True
#: Set by :func:`enable_safe_mode` before anything reads a preference.
#: Process-local and never persisted: safe mode is a way IN, not a state to
#: get stuck in, so an ordinary `spacr` start can never inherit it.
_SAFE_MODE = False
#: What safe mode turns OFF outright, rather than leaving to a default.
#:
#: DEFAULTS ARE NOT SAFE BY THEMSELVES. The animated backdrop is on by
#: default, and the backdrop and the GL path it can take are exactly what
#: the crash log points at -- so a safe mode that merely ignored the stored
#: preferences would start the very thing it exists to avoid. Verbose
#: logging is here for the same reason: it traces per-frame paint calls and
#: writes megabytes a minute, which is its own way of making the interface
#: unusable.
_SAFE_OVERRIDES = {}
class _DefaultsForReadingRealForWriting:
"""Reads answer with the caller's default; writes reach the real store.
THE POINT OF SAFE MODE IS TO ESCAPE A SAVED VALUE. When a preference is
what makes spaCR die on launch, a safe mode that reads that same
preference inherits the fault it exists to escape -- so here every read
returns the fallback the caller passed, exactly as a first-ever launch
would see it, without consulting the stored value at all.
Writes are NOT shadowed. The user opened safe mode to change a setting
and save it, and a write that went to a scratch file would leave the
broken value in place and the next ordinary start would die again.
:param real: the ``QSettings`` writes are forwarded to.
"""
def __init__(self, real: QSettings):
"""Hold the real store, which is where WRITES still go."""
self._real = real
def value(self, key, default=None, type=None):
"""A forced-safe value where there is one, else the caller's default.
:returns: the entry in :data:`_SAFE_OVERRIDES` for ``key``, or
``default`` -- so every other getter falls back to its own
documented default without a branch of its own.
"""
if key in _SAFE_OVERRIDES:
return _SAFE_OVERRIDES[key]
return default
def setValue(self, key, value) -> None:
"""Write through to the real store."""
self._real.setValue(key, value)
def remove(self, key) -> None:
"""Remove from the real store."""
self._real.remove(key)
def sync(self) -> None:
"""Flush the real store."""
self._real.sync()
def _fill_safe_overrides() -> None:
"""Populate :data:`_SAFE_OVERRIDES` once the key names exist."""
_SAFE_OVERRIDES.update({
_KEY_AMBIENT_ENABLED: False,
_KEY_SETTING_ANIMATIONS: False,
_KEY_VERBOSE_LOG: False,
_KEY_PRELOAD: "on_demand",
})
[docs]
def enable_safe_mode() -> None:
"""Read preferences as defaults for the rest of this process.
Called by the ``safespacr`` entry point before any preference is read.
Idempotent.
"""
global _SAFE_MODE
_fill_safe_overrides()
_SAFE_MODE = True
[docs]
def in_safe_mode() -> bool:
"""Whether this process is running in safe mode.
:returns: ``True`` after :func:`enable_safe_mode`.
"""
return _SAFE_MODE
def _settings():
"""The preference store: the real one, or safe mode's read shadow."""
real = QSettings(*_store_args(_ORG, _APP))
return _DefaultsForReadingRealForWriting(real) if _SAFE_MODE else real
def _in_one_store(fn):
"""Call ``fn`` inside :func:`_one_store` and return what it returns."""
with _one_store():
return fn()
@contextlib.contextmanager
def _one_store():
"""Serve this thread's preference reads and writes from one store.
Each ``set_*`` opens its own ``QSettings``, and closing one that changed
rewrites the whole file: 6 ms a value, half a second for the Preferences
dialog's Save. Inside this block the calling thread shares one store,
written to disk once when the block ends. Other threads keep their own.
:yields: the shared store.
"""
global _settings
shadowed = _settings
store = shadowed()
owner = threading.get_ident()
_settings = lambda: (store if threading.get_ident() == owner
else shadowed())
try:
yield store
finally:
_settings = shadowed
try:
store.sync()
except Exception:
LOG.debug("could not write the preferences", exc_info=True)
[docs]
def get_language() -> str:
"""Return the persisted UI language code, falling back to English."""
from .i18n import DEFAULT_LANGUAGE, normalize_language
raw = _settings().value(_KEY_LANGUAGE, DEFAULT_LANGUAGE)
return normalize_language(raw)
[docs]
def set_language(language: str) -> None:
"""Persist one of the bundled UI languages.
:param language: a UI language code from
:data:`spacr.qt.i18n.VALID_LANGUAGE_CODES`; stripped, with ``-`` read
as ``_``.
:raises ValueError: if ``language`` is not a supported language code.
"""
from .i18n import VALID_LANGUAGE_CODES
code = str(language or "").strip().replace("-", "_")
if code not in VALID_LANGUAGE_CODES:
raise ValueError(
f"unknown language {language!r}. "
f"Choose from {VALID_LANGUAGE_CODES}."
)
_settings().setValue(_KEY_LANGUAGE, code)
#: Where the general figure style lives in QSettings.
_KEY_FIG_STYLE = "figures/style_general"
#: Where the per-graph overrides live, as one JSON blob keyed by graph kind.
_KEY_FIG_STYLE_PER_GRAPH = "figures/style_per_graph"
#: Name of the preferred AI provider used when the console opens.
_KEY_AI_PROVIDER = "ai/preferred_provider"
[docs]
def get_preferred_provider() -> str:
"""Return the preferred AI provider name.
An empty string allows the console to select an available provider.
"""
return str(_settings().value(_KEY_AI_PROVIDER, "") or "")
[docs]
def set_preferred_provider(name: str) -> None:
"""Store the preferred AI provider name.
Parameters
----------
name : str
Provider name. An empty string clears the preference.
"""
_settings().setValue(_KEY_AI_PROVIDER, str(name or ""))
#: QSettings key for the mapping of panel identifiers to folded state. One
#: mapping accommodates newly added panels without introducing new preference
#: keys or requiring callers to discover them individually.
_KEY_FOLDED = "ui/folded_panels"
[docs]
def get_folded_panels() -> dict:
"""Which panels the user left folded, ``{key: True}``, or opened, ``{key: False}``.
False is stored only for a panel that starts folded; see
:func:`set_folded_panel`.
Keyed by ``"<module>/<panel>"`` so folding the console on Mask does not
fold it on Sequencing -- the same rule the console/chat splitter already
follows, and for the same reason: the modules are used for different
work and want different amounts of room.
"""
import json
raw = _settings().value(_KEY_FOLDED, "")
if not raw:
return {}
try:
value = json.loads(raw)
return {str(k): bool(v) for k, v in value.items()} \
if isinstance(value, dict) else {}
except (TypeError, ValueError, AttributeError):
return {}
[docs]
def set_folded_panel(key: str, shut: bool, *, default_shut: bool = False) -> None:
"""Remember that ``key`` is folded, or is not.
A PANEL IN ITS DEFAULT STATE IS REMOVED rather than stored. Most panels
default to open, so storing that would grow the dict by one entry for
every panel the user has ever touched and never shrink it. A panel that
starts folded (the advanced PSF, restoration and CLAHE rows)
passes ``default_shut=True``, so opening it is what gets stored, as
False, and folding it again forgets it.
:param key: the panel key, ``"<module>/<panel>"``; stripped, and an empty
key does nothing.
:param shut: true to record the panel as folded, false as open.
:param default_shut: the panel's state when nothing is stored.
"""
import json
key = str(key or "").strip()
if not key:
return
state = get_folded_panels()
if bool(shut) == bool(default_shut):
state.pop(key, None)
else:
state[key] = bool(shut)
_settings().setValue(_KEY_FOLDED, json.dumps(state))
#: Where a SAVED STYLE OBJECT's per-project default lives.
#:
#: NOT the same store as `_KEY_FIG_STYLE_PER_GRAPH`, and the difference is
#: worth stating because the two look alike from a distance. That one holds
#: `spacr.figure_style`'s own vocabulary -- font, palette, marker size -- which
#: `figure_style.resolve` merges into rcParams for every figure spaCR draws.
#: THIS one holds a verbatim snapshot of one interactive plot's own style
#: DATACLASS (`volcano_style.VolcanoStyle` and whatever joins it), keyed by the
#: kind of style it is. Merging the two vocabularies would put `label_top_n`
#: into `rcParams.update`, which raises rather than being ignored.
_KEY_FIG_STYLE_DEFAULTS = "figures/style_defaults"
#: Which graph is drawn FIRST, per data shape. ``{shape: graph_type}``.
#:
#: Regression lets the user right-click to change a drawn graph; this mapping
#: also chooses what is drawn before the first right-click. Stored per shape
#: rather than as one value
#: because "Bar" is not an answer for two continuous axes -- a bar needs
#: groups to summarise, and there are none -- so a single setting would be
#: ignored by most graphs and look broken.
_KEY_DEFAULT_GRAPH_TYPES = "figures/default_graph_types"
[docs]
def get_default_graph_types() -> dict:
"""Every saved default graph type, as ``{shape: graph_type}``.
:returns: the saved mapping, empty when nothing has been chosen.
"""
import json
raw = _settings().value(_KEY_DEFAULT_GRAPH_TYPES, "")
if isinstance(raw, dict):
return {str(k): str(v) for k, v in raw.items() if v}
if not isinstance(raw, str) or not raw.strip():
return {}
try:
loaded = json.loads(raw)
except ValueError:
return {}
if not isinstance(loaded, dict):
return {}
return {str(k): str(v) for k, v in loaded.items() if v}
[docs]
def get_default_graph_type(shape: str) -> str:
"""The graph type the user wants drawn first for ``shape``.
:param shape: a `spacr.graph_types` data shape.
:returns: the saved graph type, or ``""`` when none is saved.
Empty rather than the table's default, so `graph_types.default_for` can
tell "the user chose this" from "nothing was chosen" -- the table moves
when the package does, and a stored copy of it is a preference that has
stopped tracking.
"""
return str(get_default_graph_types().get(str(shape), ""))
[docs]
def set_default_graph_type(shape: str, graph_type: str) -> None:
"""Persist which graph is drawn first for ``shape``.
:param shape: a `spacr.graph_types` data shape.
:param graph_type: a graph type, or ``""`` to go back to the default.
"""
import json
saved = get_default_graph_types()
if graph_type:
saved[str(shape)] = str(graph_type)
else:
saved.pop(str(shape), None)
settings = _settings()
settings.setValue(_KEY_DEFAULT_GRAPH_TYPES, json.dumps(saved))
settings.sync()
def _get_figure_integrity() -> bool:
"""Whether exported image figures are checked and stamped (default off).
Read by :func:`spacr.plot.save_figure` on every export, from the app,
the command line and notebooks alike; the ``SPACR_FIGURE_INTEGRITY``
environment variable overrides it there.
"""
return _as_bool(_settings().value(_KEY_FIG_INTEGRITY, False), False)
def _set_figure_integrity(on: bool) -> None:
"""Persist whether exported image figures are checked and stamped.
:param on: true to check display ranges, saturation, repeated panels
and lossy formats on export and write a provenance sidecar.
"""
_settings().setValue(_KEY_FIG_INTEGRITY, bool(on))
_KEY_FIG_BG = "prefs/figure_bg"
_KEY_FIG_FG = "prefs/figure_fg"
#: Preference key for line colour, including axis spines and tick marks.
#: Text and tick-label colour remains under :data:`_KEY_FIG_FG`.
#: `_KEY_FIG_FG` is the font half and predates the split, which is why it is
#: still spelled `fg` -- renaming the key would silently discard the colour of
#: every store that already holds one.
_KEY_FIG_LINE = "prefs/figure_line"
_KEY_FIG_TEXT_SIZE = "prefs/figure_text_size"
#: Marker recording which generation of the un-freeze migration a store has
#: been through — see :func:`_migrate_frozen_figure_colors`.
_KEY_FIG_COLOR_SCALE = "prefs/figure_color_scale"
#: Distinguishes a current explicit choice from the indistinguishable colour
#: pair written by the retired dialog. Older stores do not carry this marker.
_KEY_FIG_COLORS_EXPLICIT = "prefs/figure_colors_explicit"
#: Bump when a *new* family of frozen values needs unfreezing; every store
#: below this number is examined once and then marked.
FIGURE_COLOR_SCALE = 1
#: The token meaning "ask the theme, every time". Not a colour.
AUTO_FIGURE_COLOR = "auto"
#: What "no background at all" is spelled as, in the one place that decides
#: it. matplotlib understands "none" for a facecolor; savefig needs
#: `transparent=True` as well, which is why callers test against this
#: constant rather than comparing strings of their own.
TRANSPARENT_FIGURE_BG = "none"
#: Background values that a historical ``"auto"`` preference could persist:
#: the current transparent value and the former opaque light/dark values. A
#: stored value equal to one of these is
#: indistinguishable from a resolution that was written back, which is why
#: the migration below cannot be cleverer than "assume the bug".
_FROZEN_BG_VALUES = frozenset({TRANSPARENT_FIGURE_BG, "#000000", "#ffffff"})
#: The same, for the text colour. "auto" has only ever produced black or
#: white, so any black or white in the store is suspect.
_FROZEN_FG_VALUES = frozenset({"#000000", "#ffffff"})
def _migrate_frozen_figure_colors() -> None:
"""Restore persisted theme-derived figure colors to ``"auto"`` once.
Older dialogs could store the resolved theme colors as explicit values.
Values that match a known automatic background, text, or line color are
therefore returned to automatic mode. A scale marker prevents repeated
migration, and preference-access failures are ignored because they must
not interrupt figure rendering.
"""
settings = _settings()
try:
if int(settings.value(_KEY_FIG_COLOR_SCALE, 0) or 0) >= \
FIGURE_COLOR_SCALE:
return
except (TypeError, ValueError):
pass
try:
changed = []
for key, frozen, label in (
(_KEY_FIG_BG, _FROZEN_BG_VALUES, "background"),
(_KEY_FIG_FG, _FROZEN_FG_VALUES, "text colour"),
(_KEY_FIG_LINE, _FROZEN_FG_VALUES, "line colour")):
raw = settings.value(key, None)
if raw is None:
continue
token = str(raw).strip().lower()
if token != AUTO_FIGURE_COLOR and token in frozen:
settings.setValue(key, AUTO_FIGURE_COLOR)
changed.append(f"{label} {str(raw).strip()!r}")
settings.setValue(_KEY_FIG_COLOR_SCALE, FIGURE_COLOR_SCALE)
settings.sync()
if changed:
LOG.warning(
"Figure colours: %s had been saved as a fixed colour that is "
"exactly what \"follow the theme\" produces, which is how an "
"older Figure settings dialog left them. They now follow the "
"theme again. Pick a colour in Figure settings… to set one "
"deliberately.", " and ".join(changed))
except Exception:
LOG.debug("could not migrate the figure colour keys", exc_info=True)
def _unfreeze_figure_colors_that_fight_the_theme() -> None:
"""Restore an implicit frozen color pair when it conflicts with the theme.
The repair applies only when neither color was explicitly selected, both
values are known automatic resolutions, and the pair differs from the
current theme. Explicit or custom colors remain unchanged. Preference
access failures are ignored so a cosmetic repair cannot stop rendering.
"""
try:
settings = _settings()
if _as_bool(settings.value(_KEY_FIG_COLORS_EXPLICIT, False), False):
return
bg = str(settings.value(_KEY_FIG_BG, AUTO_FIGURE_COLOR))
fg = str(settings.value(_KEY_FIG_FG, AUTO_FIGURE_COLOR))
if figure_color_is_auto(bg) or figure_color_is_auto(fg):
return
if bg.lower() not in _FROZEN_BG_VALUES:
return
if fg.lower() not in _FROZEN_FG_VALUES:
return
if (bg.lower(), fg.lower()) == tuple(
str(v).lower() for v in auto_figure_colors()):
return
settings.setValue(_KEY_FIG_BG, AUTO_FIGURE_COLOR)
settings.setValue(_KEY_FIG_FG, AUTO_FIGURE_COLOR)
print(f"Figure colours were pinned to {bg} / {fg}, which is what "
f"'follow the theme' resolved to on a different theme. They "
f"have been handed back to the theme; set them explicitly in "
f"Preferences > Figures if that was deliberate.")
except Exception: # noqa: BLE001
return
[docs]
def get_figure_text_size() -> int:
"""Return the saved figure font size; zero leaves Matplotlib unchanged."""
try:
return int(_settings().value(_KEY_FIG_TEXT_SIZE, 0))
except (TypeError, ValueError):
return 0
[docs]
def set_figure_text_size(size: int) -> None:
"""Persist a figure font size; zero delegates sizing to Matplotlib.
:param size: the figure font size; converted to ``int``, and 0 leaves
sizing to Matplotlib.
"""
_settings().setValue(_KEY_FIG_TEXT_SIZE, int(size))
def _forget_the_space_theme_keys(store) -> None:
"""Remove the retired Space theme's two keys from ``store``, once.
``prefs/space_variant`` and ``prefs/space_seed`` chose between skies for
a theme :data:`VALID_THEMES` does not offer, and nothing has read either
since their accessors were retired on 2026-09-09. A store written before
then still holds them, so they are removed here and the file stops
carrying values that look live and are not.
Runs once per store file per process, and writes only when there was
something to remove. Skipped in safe mode, which reads nothing it was
given; the next ordinary start removes them. Never raises: a key that
cannot be removed is a stale line in the store and changes nothing.
:param store: the preference store :func:`get_theme` is reading.
:returns: None; ``store`` is edited in place.
"""
if _SAFE_MODE or not isinstance(store, QSettings):
return
try:
name = str(store.fileName())
if name in _SPACE_KEYS_CLEARED:
return
gone = [key for key in (_KEY_SPACE_VARIANT, _KEY_SPACE_SEED)
if store.contains(key)]
for key in gone:
store.remove(key)
if gone:
store.sync()
LOG.info("removed %s from the preferences: they belonged to the "
"retired Space theme and nothing reads them.",
" and ".join(gone))
_SPACE_KEYS_CLEARED.add(name)
except Exception: # noqa: BLE001
LOG.debug("could not remove the retired Space theme keys",
exc_info=True)
def _follow_system_was_chosen(store) -> bool:
"""Whether a stored ``"system"`` theme was somebody's choice.
Until 2026-09-21 ``"system"`` was the default, and both the setup
screen and Preferences write the value their Theme control shows when
they are saved. So a store holding ``"system"`` without this flag was
most likely written by a user who never touched the control, so the
default is dark unless this flag records a choice. :func:`set_theme`
sets the flag whenever ``"system"`` is chosen from now on.
:param store: the preference store being read.
:returns: ``True`` only when the flag is present and true.
"""
return _as_bool(store.value(_KEY_THEME_FOLLOW_SYSTEM_CHOSEN, False),
False)
[docs]
def get_theme() -> str:
"""Return the saved application theme, or the default when invalid.
A stored ``"system"`` counts only when it was chosen (see
:func:`_follow_system_was_chosen`); otherwise it reads as
:data:`DEFAULT_THEME`, which is dark. An explicit Light, or any other
stored theme, is returned as it is.
The first read of a store also removes the retired Space theme's
``space_variant`` and ``space_seed`` values from it; see
:func:`_forget_the_space_theme_keys`.
"""
store = _settings()
_forget_the_space_theme_keys(store)
raw = str(store.value(_KEY_THEME, DEFAULT_THEME))
if raw == "system" and not _follow_system_was_chosen(store):
raw = DEFAULT_THEME
if raw in _SPACEOUT_NIGHT_THEMES:
from .theme import spaceout_enabled
if not spaceout_enabled():
return DEFAULT_THEME
return raw if raw in VALID_THEMES else DEFAULT_THEME
[docs]
def set_theme(theme: str) -> None:
"""Persist a supported application theme.
Choosing ``"system"`` also records that it was chosen, so
:func:`get_theme` honours it instead of reading it as the default.
:param theme: one of :data:`VALID_THEMES`.
:raises ValueError: if ``theme`` is not in :data:`VALID_THEMES`.
"""
if theme not in VALID_THEMES:
raise ValueError(f"unknown theme {theme!r}. "
f"Choose from {VALID_THEMES}.")
if theme in _SPACEOUT_NIGHT_THEMES:
from .theme import spaceout_enabled
if not spaceout_enabled():
raise ValueError(f"theme {theme!r} is available only in spaceout")
_restore_brand_palette_once()
store = _settings()
store.setValue(_KEY_THEME, theme)
if theme == "system":
store.setValue(_KEY_THEME_FOLLOW_SYSTEM_CHOSEN, True)
else:
store.remove(_KEY_THEME_FOLLOW_SYSTEM_CHOSEN)
[docs]
def theme_choices() -> tuple:
"""Return ``(label, token)`` choices for the single Theme control.
Image variants are represented as composite tokens in the UI while the
persisted keys remain backward compatible.
The ten night themes follow the older palettes, in
:data:`spacr.qt.night_themes.NIGHT_THEMES` order, so the four the
application has always had stay where a returning user looks for them
and read as one family. Five data-art presets follow them in a
separate block. Their tokens are plain keys with no variant, so
there is nothing to compose into the token the way Cell does.
"""
from .imagery import CELL_VARIANTS, title_for
from .night_themes import NIGHT_THEMES
from .i18n import tr
choices = [
(tr("Dark"), "dark"),
(tr("Light"), "light"),
(tr("Glass"), "glass"),
(tr("High contrast"), "high_contrast"),
(tr("Follow system"), "system"),
]
choices.extend(
(title_for(key), f"cell:{key}")
for key in CELL_VARIANTS
)
choices.extend((theme.label, key) for key, theme in NIGHT_THEMES.items())
from .theme import spaceout_enabled
try:
from .widgets.ambient import SPACEOUT_ONLY_THEMES
except ImportError:
return tuple(choices)
choices.extend((theme.label, key) for key, theme in DATA_ART_THEMES.items()
if spaceout_enabled() or key not in SPACEOUT_ONLY_THEMES)
return tuple(choices)
[docs]
def theme_description(token: str) -> str:
"""Return the one-sentence explanation of a :func:`theme_choices` token.
Night and data-art themes each carry a sentence saying what colours,
backdrop and sound set come with them; that sentence is what the
Theme control shows as the entry's tooltip, the way the Sound set
control shows :attr:`spacr.qt.sound_synth.SoundTheme.description`.
:param token: a token from :func:`theme_choices`.
:returns: the theme's sentence (the high-contrast theme has one too),
or ``""`` for the four themes that predate the family and for any token without one. The caller
passes the result through :func:`tr` and sets no tooltip when it
is empty.
"""
from .night_themes import NIGHT_THEMES
if token == "high_contrast":
return ("White text and outlines on black with a yellow accent, "
"for low vision and bright rooms.")
theme = NIGHT_THEMES.get(token) or DATA_ART_THEMES.get(token)
return theme.description if theme is not None else ""
[docs]
def get_theme_choice() -> str:
"""Return the composite token representing the current visual theme."""
theme = get_theme()
if theme == "cell":
return f"cell:{get_cell_variant()}"
return theme
[docs]
def set_theme_choice(choice: str) -> None:
"""Persist one token from :func:`theme_choices`.
Choosing a night or data-art preset also writes its
backdrop and its sound set — see :func:`apply_night_theme`, which is
where the reasoning for doing so lives.
:param choice: a token from :func:`theme_choices`; a ``"cell:<variant>"``
token sets the Cell theme and that variant. Any other value raises
:class:`ValueError`.
"""
valid = {token for _label, token in theme_choices()}
if choice not in valid:
raise ValueError(
f"unknown theme choice {choice!r}. Choose from {sorted(valid)}.")
if choice.startswith("cell:"):
set_cell_variant(choice.split(":", 1)[1])
set_theme("cell")
else:
set_theme(choice)
if is_night_theme(choice) or choice in DATA_ART_THEMES:
apply_night_theme(choice)
[docs]
def apply_night_theme(name: str) -> None:
"""Write the backdrop and sound set a night or data-art preset brings.
A night theme is one choice that moves three things: the colours, the
animation behind them and the set of sounds spaCR would play. So this
writes the ambient animation, the ambient palette and the sound set
that go with the colours.
IT DOES NOT SWITCH ANYTHING ON. The sound master stays exactly where
the user left it, which on a fresh install and on every install that
has never opened the Sound tab is off; all this decides is *which*
set would play if it were ever switched on. It leaves the animation
master alone in the same way: if the backdrop is off, the stored
animation is what comes back when it is switched on again, and
:func:`set_ambient_animation` is not used here for exactly that
reason -- that setter also switches the backdrop on.
IT IS A PRESET, NOT AN OVERRIDE. The three values are written once,
at the moment the theme is chosen, into the same keys the Animation
and Sound controls read and write. Nothing re-imposes them, so a user
who picks Nocturne and then changes the animation to Bokeh keeps
Bokeh.
AND IT NEVER SWITCHES THE BACKDROP BACK ON. "No animation" is stored
as the animation NAME (:data:`spacr.qt.widgets.ambient.NO_ANIMATION`),
which :func:`get_ambient_enabled` reads, so writing an animation over
it would hand a moving backdrop to a user who had turned motion off
-- through the Animation control, or through Extra Performance, which
turns it off the same way. :func:`backdrop_is_switched_off` is
therefore asked first, and when it says yes the two ambient keys are
left exactly as they are. The theme still changes the colours and the
sound set; it just does not start anything moving. What that costs is
small and worth saying: a user who later switches the backdrop on
gets the animation they had before, not the one this theme would have
brought, and they can pick it on the same control they just used.
:param name: a night or data-art preset key.
:raises KeyError: if ``name`` is unknown.
"""
theme = theme_for(name)
settings = _settings()
if not backdrop_is_switched_off():
settings.setValue(_ambient_theme_key(), theme.ambient)
settings.setValue(_ambient_palette_key(), theme.ambient_palette)
settings.setValue(_KEY_SOUND_THEME, theme.sound)
settings.sync()
[docs]
def backdrop_is_switched_off() -> bool:
"""Whether the user has turned the animated backdrop off and left it.
The STORED choice, not the live answer:
:func:`get_ambient_enabled` also reports ``False`` for
``SPACR_NO_BACKDROP``, which :mod:`spacr.qt.crash_recovery` sets for
one process after two failed launches. That is a suppression and not
a preference, and treating it as one would silently strip the
backdrop out of a theme the user chose during that one run.
:returns: ``True`` when the Animation control reads "None", or when
the separate on/off key is off.
"""
if _raw_ambient_animation() == _no_animation_key():
return True
return not _as_bool(_settings().value(_ambient_enabled_key(),
DEFAULT_AMBIENT_ENABLED),
DEFAULT_AMBIENT_ENABLED)
[docs]
def get_cell_variant() -> str:
"""Which of the user's micrographs the Cell theme uses."""
from .imagery import CELL_VARIANTS, DEFAULT_CELL_VARIANT
raw = str(_settings().value(_KEY_CELL_VARIANT, DEFAULT_CELL_VARIANT))
return raw if raw in CELL_VARIANTS else DEFAULT_CELL_VARIANT
[docs]
def set_cell_variant(variant: str) -> None:
"""Persist one of the bundled Cell-theme microscopy variants.
:param variant: one of :data:`spacr.qt.imagery.CELL_VARIANTS`; any other
value raises :class:`ValueError`.
"""
from .imagery import CELL_VARIANTS
if variant not in CELL_VARIANTS:
raise ValueError(f"unknown cell variant {variant!r}. "
f"Choose from {CELL_VARIANTS}.")
_settings().setValue(_KEY_CELL_VARIANT, variant)
#: THE SPACE THEME'S KEYS WERE RETIRED, 2026-09-09.
#:
#: `space_variants`, `get_space_variant`, `set_space_variant`,
#: `get_space_seed`, `set_space_seed` and `space_background_path` lived here
#: and only ever called each other. `space_background_path` had exactly one
#: caller -- the `theme == "space"` branch of `theme_background_path` -- and
#: `"space"` is not in `VALID_THEMES`, so `set_theme` refuses it,
#: `theme_choices()` offers no space token, and `get_theme()` maps anything
#: unrecognised to `DEFAULT_THEME`. The branch could not be entered by any
#: route through this module.
#:
#: The comment in `get_theme_choice` above already made half this argument --
#: "a branch for it could not be reached by any route through this module" --
#: and then kept the accessors on the grounds that "spaceout still draws it".
#: That half was wrong. `spaceout` is the fractal dressing, and the "space"
#: fractal PATTERN is `widgets/fractal_space.py`, a starfield shader that
#: reads neither key. `set_space_variant` and `set_space_seed` were called
#: from nowhere at all.
#:
#: The Space ARTWORK is untouched; what is gone is the accessor pair for a
#: theme name nothing can select.
[docs]
def cell_background_path(width: int = 0, height: int = 0):
"""Path of the background image for the Cell theme, or ``None``.
``None`` when the masters were stripped from the build; the
stylesheet then paints the Cell gradient, which is a dark teal wash
rather than anything broken.
"""
try:
from .imagery import background_path
return background_path(get_cell_variant(), width, height)
except Exception:
return None
[docs]
def theme_background_path(theme: str, width: int = 0, height: int = 0):
"""Background image for ``theme``, or ``None`` if it does not use one.
One place for the "which theme wants which picture" question, so
:func:`apply_preferences_to_app` and anything else that re-applies
the stylesheet cannot drift apart.
:param theme: an application theme name; only ``"cell"`` has a background
image.
:param width: the wanted image width in pixels; 0 or less means the screen
size.
:param height: the wanted image height in pixels; 0 or less means the
screen size.
"""
if theme == "cell":
return cell_background_path(width, height)
return None
[docs]
def resolve_effective_theme() -> str:
"""Return the theme to render — one of :data:`PALETTE_THEMES`.
Resolves an explicitly chosen ``"system"`` to the operating system's
colour scheme as Qt reports it (``QStyleHints.colorScheme``), and to
dark when Qt can't tell. It does not read the application palette:
that is spaCR's own once a theme has been applied, so it would answer
with whatever was applied last. Every other value passes through, so
callers that only understand light/dark should compare against
``"light"`` and treat everything else as dark (Space and Cell are dark
themes).
"""
theme = get_theme()
if theme in PALETTE_THEMES:
return theme
try:
from .theme import system_colour_scheme
return system_colour_scheme() or "dark"
except Exception:
LOG.debug("could not read the system colour scheme", exc_info=True)
return "dark"
#: The animation is on out of the box. It costs nothing while a screen is
#: hidden (:class:`spacr.qt.widgets.ambient.AmbientWidget` stops its timer
#: on ``hideEvent``), so leaving it on does not tax a machine that is busy
#: segmenting on the GPU behind a different tab.
DEFAULT_AMBIENT_ENABLED = True
[docs]
def get_ambient_enabled() -> bool:
"""Whether module screens paint the animated background.
Answers ``False`` outright when ``SPACR_NO_BACKDROP`` is set, whatever
is stored. `spacr.qt.crash_recovery` sets it after spaCR has died on
launch twice running: the backdrop is the only thing spaCR asks a
driver to do at startup, and the setting that would turn it off is
behind the window that never appears. Process-local and never saved, so
the next clean run brings it back with nothing for the user to undo.
Default ``True``. When this is ``False`` no ambient widget should be
installed at all — and any already-installed one is hidden and
stopped by :func:`apply_ambient_preferences`, so the toggle takes
effect the moment Preferences is saved rather than at the next launch.
**Two keys answer this one question, and both are honoured.** The
Animation preference gained a ``None`` entry (see
:data:`spacr.qt.widgets.ambient.NO_ANIMATION`), and "no animation" has
to mean *nothing is constructed* rather than "an engine that paints an
empty frame sixty times a second". The three install sites all read this
function before they build anything, so answering ``False`` for None
here is what makes the guarantee true everywhere at once, without a
second condition in three other modules that could drift apart.
The separate on/off key stays because it is the programmatic switch —
:mod:`spacr.qt.resource_cleanup` uses it, and so does any caller that
wants the animation back exactly as the user had it.
"""
import os
if os.environ.get("SPACR_NO_BACKDROP"):
return False
if _raw_ambient_animation() == _no_animation_key():
return False
return _as_bool(_settings().value(_ambient_enabled_key(),
DEFAULT_AMBIENT_ENABLED),
DEFAULT_AMBIENT_ENABLED)
def _no_animation_key() -> str:
"""``NO_ANIMATION``, defended: this module is imported headless."""
try:
from .widgets.ambient import NO_ANIMATION
return NO_ANIMATION
except Exception:
return "none"
def _animation_choices() -> tuple:
"""Everything the Animation preference may hold, in menu order.
Falls back to "none plus whatever themes exist" rather than to a bare
default, so a stored animation stays readable against an ambient module
that predates this list — including the test doubles that stand in for
it.
"""
try:
from .widgets.ambient import ANIMATION_CHOICES, SPACEOUT_ONLY_THEMES, SPACEOUT_THEME
from .theme import spaceout_enabled
if spaceout_enabled():
return tuple(ANIMATION_CHOICES[:-1]) + SPACEOUT_ONLY_THEMES + (SPACEOUT_THEME, _no_animation_key())
return tuple(ANIMATION_CHOICES)
except Exception:
pass
try:
from .widgets.ambient import AMBIENT_THEMES
return (_no_animation_key(),) + tuple(AMBIENT_THEMES)
except Exception:
return (_no_animation_key(),)
def _ambient_theme_key() -> str:
"""Keep spaceout choices separate from the normal launch preference."""
from .theme import spaceout_enabled
return _KEY_SPACEOUT_ANIMATION if spaceout_enabled() else _KEY_AMBIENT_THEME
def _ambient_palette_key() -> str:
"""Keep decorative spaceout colors out of ordinary spaCR launches."""
return (_KEY_SPACEOUT_PALETTE if _ambient_theme_key() == _KEY_SPACEOUT_ANIMATION
else _KEY_AMBIENT_PALETTE)
def _ambient_enabled_key() -> str:
"""Keep each launcher's animation-off choice in its own settings key."""
return (_KEY_SPACEOUT_ENABLED if _ambient_theme_key() == _KEY_SPACEOUT_ANIMATION
else _KEY_AMBIENT_ENABLED)
def _raw_ambient_animation() -> str:
"""The stored animation choice, validated, ``None`` included.
:func:`get_ambient_theme` cannot do this job: it promises a *paintable*
theme, and half the callers hand what it returns straight to
``make_engine``.
"""
try:
from .widgets.ambient import DEFAULT_THEME as _default
except Exception:
_default = "blobs"
key = _ambient_theme_key()
if key == _KEY_SPACEOUT_ANIMATION:
from .widgets import ambient
_default = getattr(ambient, "DEFAULT_SPACEOUT_THEME",
"data_art_spaceout_field")
raw = str(_settings().value(key, _default))
return raw if raw in _animation_choices() else _default
[docs]
def get_ambient_animation() -> str:
"""Which animation the user chose, or :data:`NO_ANIMATION` for none.
The value the Preferences dropdown shows. Use :func:`get_ambient_theme`
when you are about to paint something — it never returns ``"none"``.
"""
return _raw_ambient_animation()
[docs]
def set_ambient_animation(name: str) -> None:
"""Persist an entry of ``ANIMATION_CHOICES``, ``"none"`` included.
Choosing an animation turns the backdrop on, and choosing None turns it
off, so the dropdown is the whole control: a user who picks Blobs after
something switched the backdrop off gets Blobs, not silence.
Picking None does **not** disturb the stored theme's palette, so
switching back later restores exactly the animation that was there.
:param name: an entry of ``ANIMATION_CHOICES`` from
:mod:`spacr.qt.widgets.ambient`; its ``NO_ANIMATION`` entry
(``"none"``) switches the backdrop off, and any other value raises
:class:`ValueError`.
"""
choices = _animation_choices()
if name not in choices:
raise ValueError(f"unknown animation {name!r}. Choose from {choices}.")
if name == _no_animation_key():
settings = _settings()
settings.setValue(_ambient_theme_key(), name)
settings.setValue(_ambient_enabled_key(), False)
settings.sync()
return
set_ambient_theme(name)
set_ambient_enabled(True)
[docs]
def set_ambient_enabled(on: bool) -> None:
"""Turn the animated background on or off.
Flushed immediately: module screens re-read this key when they are
built, and a stale read right after the user cleared the checkbox
would put the animation back on the very next screen they open.
:param on: true to turn it on, false to turn it off; stored as a ``bool``.
"""
settings = _settings()
settings.setValue(_ambient_enabled_key(), bool(on))
settings.sync()
[docs]
def get_ambient_theme() -> str:
"""Which animation screens paint in the active launch mode.
Validated on read: a value written by a newer spaCR (or by hand)
that this build does not know about falls back to the default theme
rather than propagating an unpaintable name into the widget.
"""
from .widgets.ambient import DEFAULT_THEME as DEFAULT_AMBIENT_THEME
raw = _raw_ambient_animation()
if raw == _no_animation_key():
from .widgets import ambient
return (getattr(ambient, "DEFAULT_SPACEOUT_THEME", "data_art_spaceout_field")
if _ambient_theme_key() == _KEY_SPACEOUT_ANIMATION
else DEFAULT_AMBIENT_THEME)
return raw
[docs]
def set_ambient_theme(name: str) -> None:
"""Persist an animation offered in the active launch mode.
Palettes belong to a theme, so switching themes can strand the
stored palette. Rather than raise — the user picked a theme, not a
broken pair — the stored palette is repaired in the same write: it
is kept if the new theme also offers it, and otherwise replaced with
that theme's default (see :func:`ambient_default_palette`).
:param name: an animation offered in the current launcher's menu.
:raises ValueError: if ``name`` is not a known ambient theme.
"""
from .widgets.ambient import palettes_for
choices = tuple(key for key in _animation_choices() if key != _no_animation_key())
if name not in choices:
raise ValueError(f"unknown ambient theme {name!r}. Choose from {choices}.")
settings = _settings()
settings.setValue(_ambient_theme_key(), name)
stored = str(settings.value(_ambient_palette_key(), ""))
if stored not in palettes_for(name):
settings.setValue(_ambient_palette_key(), ambient_default_palette(name))
settings.sync()
[docs]
def ambient_default_palette(theme: str) -> str:
"""The palette a theme falls back to.
:data:`spacr.qt.widgets.ambient.DEFAULT_PALETTE` when that theme
offers it (spaCR's own brand colours are the intended default
everywhere they exist), otherwise the theme's first palette. Never
raises for an unknown theme — it reports the global default.
:param theme: an ambient theme name, as offered by
:data:`spacr.qt.widgets.ambient.AMBIENT_THEMES`; an unknown name gives
the global default palette.
"""
from .widgets.ambient import DEFAULT_PALETTE, palettes_for
try:
valid = palettes_for(theme)
except Exception:
return DEFAULT_PALETTE
if DEFAULT_PALETTE in valid:
return DEFAULT_PALETTE
return valid[0] if valid else DEFAULT_PALETTE
[docs]
def get_ambient_palette() -> str:
"""Which colours the current ambient theme is painted in.
Validated against ``palettes_for(get_ambient_theme())``, so this can
never hand a widget a palette its theme does not have — not after a
downgrade, not after a hand-edited INI, and not after a theme change
that stranded the old palette. Falls back to
:func:`ambient_default_palette` for the current theme.
"""
theme = get_ambient_theme()
fallback = ambient_default_palette(theme)
from .widgets.ambient import palettes_for
raw = str(_settings().value(_ambient_palette_key(), fallback))
return raw if raw in palettes_for(theme) else fallback
[docs]
def set_ambient_palette(name: str) -> None:
"""Persist a palette offered by the *current* ambient theme.
:param name: a palette name offered by the current ambient theme.
:raises ValueError: if ``name`` is not one of
``palettes_for(get_ambient_theme())``. Set the theme first: a
palette is only meaningful next to the theme that draws it.
"""
from .widgets.ambient import palettes_for
valid = palettes_for(get_ambient_theme())
if name not in valid:
raise ValueError(f"unknown ambient palette {name!r} for theme "
f"{get_ambient_theme()!r}. Choose from {valid}.")
_restore_brand_palette_once()
settings = _settings()
settings.setValue(_ambient_palette_key(), name)
settings.sync()
def _ambient_custom_colors():
"""Read two validated opaque colours without changing the settings store."""
from PySide6.QtGui import QColor
settings = _settings()
colors = []
for key, fallback in (("prefs/ambient_primary", "#3b82f6"),
("prefs/ambient_accent", "#ff00ff")):
value = QColor(str(settings.value(key, fallback)))
colors.append(value.name() if value.isValid() else fallback)
return tuple(colors)
def _set_ambient_custom_colors(colors):
"""Persist a complete validated pair of user-chosen animation colours."""
from PySide6.QtGui import QColor
if len(colors) != 2:
raise ValueError("two animation colours are required")
values = [QColor(color) for color in colors]
if not all(value.isValid() for value in values):
raise ValueError("invalid animation colour")
_restore_brand_palette_once()
settings = _settings()
for key, value in zip(("prefs/ambient_primary", "prefs/ambient_accent"), values):
settings.setValue(key, value.name())
settings.sync()
def _ambient_background_choice():
"""Read an optional opaque animation fill; None follows the page theme."""
from PySide6.QtGui import QColor
value = _settings().value(_KEY_AMBIENT_BACKGROUND, None)
color = QColor(str(value)) if value else QColor()
return color.name() if color.isValid() else None
def _set_ambient_background_choice(value):
"""Persist an optional animation fill, rejecting invalid colours."""
from PySide6.QtGui import QColor
if value is None:
_restore_brand_palette_once()
settings = _settings()
settings.remove(_KEY_AMBIENT_BACKGROUND)
else:
color = QColor(value)
if not color.isValid():
raise ValueError("invalid animation background colour")
_restore_brand_palette_once()
settings = _settings()
settings.setValue(_KEY_AMBIENT_BACKGROUND, color.name())
settings.sync()
def _effective_ambient_background():
"""Keep the chosen animation fill on the active page's lightness side."""
from PySide6.QtGui import QColor
from .theme import active_page_colour
chosen = _ambient_background_choice()
if chosen is None:
return QColor(active_page_colour())
color = QColor(chosen).toHsl()
lightness = color.lightness()
if resolve_effective_theme() == "light":
lightness = max(lightness, 184)
else:
lightness = min(lightness, 72)
color.setHsl(color.hslHue(), color.hslSaturation(), lightness)
return color.toRgb()
def _ambient_gravity_radius() -> float:
"""Read a finite viewport-relative mouse radius; zero disables influence."""
import math
try:
value = float(_settings().value(_KEY_AMBIENT_GRAVITY_RADIUS, 0.15))
except (TypeError, ValueError):
return 0.0
return max(0.0, min(1.0, value)) if math.isfinite(value) else 0.0
def _set_ambient_gravity_radius(value: float) -> None:
"""Persist the bounded radius, using the disabled default for invalid input."""
import math
try:
radius = float(value)
except (TypeError, ValueError):
radius = 0.0
if not math.isfinite(radius):
radius = 0.0
settings = _settings()
settings.setValue(_KEY_AMBIENT_GRAVITY_RADIUS, max(0.0, min(1.0, radius)))
settings.sync()
def _ambient_ranges():
"""``(blur, speed, size, resolution, density)`` ranges and defaults, from
the widget module.
Imported lazily and defended, like every other ambient read here: this
module is imported headless (no QtGui) in places, and a decorative
setting is never a reason to fail.
"""
try:
from .widgets.ambient import (BLUR_RANGE, DEFAULT_BLUR,
DEFAULT_DENSITY, DEFAULT_RESOLUTION,
DEFAULT_SIZE, DEFAULT_SPEED,
DENSITY_RANGE, RESOLUTION_RANGE,
SIZE_RANGE, SPEED_RANGE)
return ((BLUR_RANGE, DEFAULT_BLUR), (SPEED_RANGE, DEFAULT_SPEED),
(SIZE_RANGE, DEFAULT_SIZE),
(RESOLUTION_RANGE, DEFAULT_RESOLUTION),
(DENSITY_RANGE, DEFAULT_DENSITY))
except Exception:
return (((0.0, 3.0), 0.0), ((0.1, 4.0), 1.0), ((0.25, 2.5), 1.0),
((0.25, 2.0), 1.0), ((0.01, 3.0), 0.1))
def _migrate_ambient_motion() -> None:
"""Bring a store written under the old blur scale up to the current one.
``ambient_blur`` used to be a *buffer resolution* divisor: 0.25 meant a
four-times-larger buffer (sharp, dear), 1.0 the shipped one, 3.0 a
third of it (soft, cheap). One slider therefore answered two questions,
and the sharp end of it is now a separate ``ambient_resolution``. A
stored value is translated rather than reinterpreted, because
reinterpreting it would silently invert what half the range meant:
resolution <- 1 / old (0.25 asked for four times the pixels)
blur <- max(0, old-1) (only the soft half was ever a blur)
Recognised by the absence of :data:`AMBIENT_MOTION_SCALE` rather than by
guessing from the value, because 1.0 is a legal reading on both scales.
Runs once; writes the marker even when there was nothing to migrate, so
a default store is not re-examined on every read.
Never raises. A preference that cannot be migrated is a preference that
stays at its default, which is a cosmetic loss.
"""
settings = _settings()
try:
if int(settings.value(_KEY_AMBIENT_SCALE, 0) or 0) >= \
AMBIENT_MOTION_SCALE:
return
except (TypeError, ValueError):
pass
try:
raw = settings.value(_KEY_AMBIENT_BLUR, None)
if raw is not None:
old = float(raw)
if old == old and old > 0:
(res_low, res_high), _ = _ambient_ranges()[3]
(blur_low, blur_high), _ = _ambient_ranges()[0]
settings.setValue(
_KEY_AMBIENT_RESOLUTION,
max(res_low, min(res_high, 1.0 / old)))
settings.setValue(
_KEY_AMBIENT_BLUR,
max(blur_low, min(blur_high, max(0.0, old - 1.0))))
settings.setValue(_KEY_AMBIENT_SCALE, AMBIENT_MOTION_SCALE)
settings.sync()
except Exception:
LOG.debug("could not migrate the ambient motion keys", exc_info=True)
def _ambient_multiplier(key: str, index: int) -> float:
"""Read one ambient-motion multiplier, clamped to its range.
A hand-edited INI can hold ``nan``, which compares false against every
bound and would pass a range check written as two comparisons -- so it
is tested for explicitly and falls back to the default.
:param key: the settings key.
:param index: which multiplier, indexing the range table.
:returns: the value, within range.
"""
_migrate_ambient_motion()
(low, high), default = _ambient_ranges()[index]
try:
value = float(_settings().value(key, default))
except (TypeError, ValueError):
return default
if value != value:
return default
return max(low, min(high, value))
def _set_ambient_multiplier(key: str, index: int, value: float) -> None:
"""Write one ambient-motion multiplier, clamped to its range.
:param key: the settings key.
:param index: which multiplier, indexing the range table.
:param value: the value to store; anything unparseable, or ``nan``,
stores the default instead.
"""
_migrate_ambient_motion()
(low, high), default = _ambient_ranges()[index]
try:
value = float(value)
except (TypeError, ValueError):
value = default
if value != value:
value = default
settings = _settings()
settings.setValue(key, max(low, min(high, value)))
settings.sync()
[docs]
def get_ambient_blur() -> float:
"""Read the retained legacy animation-blur preference.
Current animation widgets do not apply this value, and Settings has no
Blur control. Drawing detail is controlled by
:func:`get_ambient_resolution`.
The legacy value is clamped to
``spacr.qt.widgets.ambient.BLUR_RANGE`` on read.
"""
return _ambient_multiplier(_KEY_AMBIENT_BLUR, 0)
[docs]
def set_ambient_blur(value: float) -> None:
"""Store the legacy animation-blur preference. Current widgets ignore it.
:param value: the retained legacy blur value; clamped to ``BLUR_RANGE``
from :mod:`spacr.qt.widgets.ambient`. An unparseable value or NaN
stores ``DEFAULT_BLUR``.
"""
_set_ambient_multiplier(_KEY_AMBIENT_BLUR, 0, value)
[docs]
def get_ambient_resolution() -> float:
"""The drawing-detail multiplier on each animation's working grid.
1.0 is as designed. Higher detail increases sampling work within the
engine's screen-pixel limits. :func:`get_ambient_density` controls the
selected population independently; increasing detail does not reduce
that population. Current widgets apply no animation blur.
"""
return _ambient_multiplier(_KEY_AMBIENT_RESOLUTION, 3)
[docs]
def set_ambient_resolution(value: float) -> None:
"""Set the detail multiplier. Clamped.
:param value: the multiplier on each animation's own shading buffer (1.0 is
as designed); clamped to ``RESOLUTION_RANGE`` from
:mod:`spacr.qt.widgets.ambient`, and an unparseable value or NaN stores
``DEFAULT_RESOLUTION``.
"""
_set_ambient_multiplier(_KEY_AMBIENT_RESOLUTION, 3, value)
[docs]
def get_ambient_density() -> float:
"""How many elements the animated background draws — blobs, curtains,
ripple sources, stars, discs, cells — as a multiplier on each
animation's own count. 1.0 is as designed.
Density determines the population independently of detail. Render
sampling and the native screen-pixel budget bound the combined work.
"""
return _ambient_multiplier(_KEY_AMBIENT_DENSITY, 4)
[docs]
def set_ambient_density(value: float) -> None:
"""Set the element-count multiplier. Clamped.
:param value: the multiplier on each animation's own element count (1.0 is
as designed); clamped to ``DENSITY_RANGE`` from
:mod:`spacr.qt.widgets.ambient`, and an unparseable value or NaN stores
``DEFAULT_DENSITY``.
"""
_set_ambient_multiplier(_KEY_AMBIENT_DENSITY, 4, value)
def _ambient_blink_percent() -> float:
"""Percentage of dot centres that flash white, from zero to 10.
Fresh profiles use zero (off). Invalid stored values use that
default; finite values are clamped. This is independent of density.
"""
import math
try:
value = float(_settings().value(_KEY_AMBIENT_BLINK_PERCENT, 0.0))
except (TypeError, ValueError):
value = 0.0
return max(0.0, min(10.0, value)) if math.isfinite(value) else 0.0
def _set_ambient_blink_percent(value: float) -> None:
"""Persist the dot-blinking percentage, clamped to 0–10."""
import math
try:
value = float(value)
except (TypeError, ValueError):
value = 0.0
if not math.isfinite(value):
value = 0.0
_settings().setValue(_KEY_AMBIENT_BLINK_PERCENT,
max(0.0, min(10.0, value)))
def _field_ripples_enabled() -> bool:
"""Whether clicks, containers and window changes send spaCR field ripples."""
return _as_bool(_settings().value(_KEY_FIELD_RIPPLES, True), True)
def _field_ripple_intensity() -> float:
"""Read finite ripple amplitude independently of mouse gravity."""
import math
try:
value = float(_settings().value(_KEY_FIELD_RIPPLE_INTENSITY, 1.0))
except (TypeError, ValueError):
value = 1.0
return max(0.0, min(2.0, value)) if math.isfinite(value) else 1.0
def _set_field_ripple_intensity(value: float) -> None:
"""Persist ripple amplitude; zero is calm and one is the default."""
_settings().setValue(_KEY_FIELD_RIPPLE_INTENSITY, float(value))
_settings().sync()
def _spaceout_field_effects() -> dict[str, bool]:
"""Read the eight independent Spaceout field effects, all on by default."""
store = _settings()
return {key: _as_bool(store.value(f"spaceout/field/{key}", True), True)
for key in _SPACEOUT_FIELD_EFFECT_KEYS}
def _set_spaceout_field_effects(effects: dict[str, bool]) -> None:
"""Persist an exact set of Spaceout field effect switches."""
if set(effects) != set(_SPACEOUT_FIELD_EFFECT_KEYS):
raise ValueError("Spaceout field effects must contain every known key")
store = _settings()
for key in _SPACEOUT_FIELD_EFFECT_KEYS:
store.setValue(f"spaceout/field/{key}", bool(effects[key]))
store.sync()
def _set_field_ripples_enabled(value: bool) -> None:
"""Persist the ripple switch independently of mouse gravity."""
_settings().setValue(_KEY_FIELD_RIPPLES, bool(value))
_settings().sync()
def _field_popup_wave_frequency() -> float:
"""Automatic spaCR field waves per minute from an open popup; zero is off."""
import math
try:
value = float(_settings().value(_KEY_FIELD_POPUP_WAVES, 5.0))
except (TypeError, ValueError):
value = 0.0
return max(0.0, min(60.0, value)) if math.isfinite(value) else 0.0
def _set_field_popup_wave_frequency(value: float) -> None:
"""Persist automatic popup-wave frequency in waves per minute (0–60)."""
import math
try:
value = float(value)
except (TypeError, ValueError):
value = 0.0
if not math.isfinite(value):
value = 0.0
_settings().setValue(_KEY_FIELD_POPUP_WAVES, max(0.0, min(60.0, value)))
[docs]
def get_ambient_drift_direction() -> str:
"""Which way spaCR stratified travels.
Validated on read against
:data:`spacr.qt.widgets.ambient.DRIFT_DIRECTIONS`, so a value from a
newer build or a hand-edited file falls back to the default rather than
reaching an engine that cannot honour it.
"""
try:
from .widgets.ambient import (DEFAULT_DRIFT_DIRECTION,
DRIFT_DIRECTIONS)
except Exception:
DEFAULT_DRIFT_DIRECTION, DRIFT_DIRECTIONS = "up", ("up", "down",
"random")
raw = str(_settings().value(_KEY_AMBIENT_DRIFT_DIR,
DEFAULT_DRIFT_DIRECTION))
return raw if raw in DRIFT_DIRECTIONS else DEFAULT_DRIFT_DIRECTION
[docs]
def set_ambient_drift_direction(name: str) -> None:
"""Persist one of :data:`spacr.qt.widgets.ambient.DRIFT_DIRECTIONS`.
:param name: the starfield drift direction, one of ``DRIFT_DIRECTIONS``
(``"up"``, ``"down"`` or ``"random"`` when the widget module cannot be
imported).
:raises ValueError: if ``name`` is not one of them.
"""
try:
from .widgets.ambient import DRIFT_DIRECTIONS
except Exception:
DRIFT_DIRECTIONS = ("up", "down", "random")
if name not in DRIFT_DIRECTIONS:
raise ValueError(f"unknown starfield direction {name!r}. "
f"Choose from {DRIFT_DIRECTIONS}.")
settings = _settings()
settings.setValue(_KEY_AMBIENT_DRIFT_DIR, name)
settings.sync()
[docs]
def get_ambient_speed() -> float:
"""How fast the animated background moves, as a multiplier on each
theme's own motion. 1.0 is as designed."""
return _ambient_multiplier(_KEY_AMBIENT_SPEED, 1)
[docs]
def set_ambient_speed(value: float) -> None:
"""Set the motion multiplier. Clamped.
:param value: the multiplier on each theme's own motion (1.0 is as
designed); clamped to ``SPEED_RANGE`` from
:mod:`spacr.qt.widgets.ambient`, and an unparseable value or NaN stores
``DEFAULT_SPEED``.
"""
_set_ambient_multiplier(_KEY_AMBIENT_SPEED, 1, value)
[docs]
def get_ambient_size() -> float:
"""How large the animated background's elements are, as a multiplier on
each theme's own size range. 1.0 is as designed."""
return _ambient_multiplier(_KEY_AMBIENT_SIZE, 2)
[docs]
def set_ambient_size(value: float) -> None:
"""Set the element-size multiplier. Clamped.
:param value: the multiplier on each animation's own element size (1.0 is
as designed); clamped to ``SIZE_RANGE`` from
:mod:`spacr.qt.widgets.ambient`, and an unparseable value or NaN stores
``DEFAULT_SIZE``.
"""
_set_ambient_multiplier(_KEY_AMBIENT_SIZE, 2, value)
[docs]
def apply_ambient_preferences(app=None) -> None:
"""Push the ambient preferences onto every live ambient widget.
The user's explicit ask was a toggle that works *now*, so this walks
the running widget tree the same way
:func:`spacr.qt.button_roles.install_button_roles` does and updates
the widgets in place instead of waiting for the screens to be
rebuilt. Hiding one also stops its timer (the widget stops animating
whenever it is not visible), so "off" really is zero frames.
The settings-window backdrop has its own choice and remains active when
module animation is off unless its own choice is None.
Turning module animation back *on* only resumes the widgets that are actually on
screen. Every module screen keeps its ambient widget alive while the
user is on some other tab, and un-pausing those would spend frames
on pixels nobody can see — which is the one thing this animation is
not allowed to do. Their own ``showEvent`` restarts them when the
tab comes back.
Never raises. A widget whose C++ half is already gone, or an ambient
module that could not be imported, is a cosmetic problem — not a
reason to fail a preferences save.
"""
import sys
if (sys.modules.get(f"{__package__}.widgets.ambient") is None
and not get_ambient_enabled()):
from PySide6.QtWidgets import QApplication, QDialog
app = app or QApplication.instance()
if (app is None
or not any(isinstance(widget, QDialog)
and widget.property("spacrGlassed")
for widget in app.allWidgets())
or get_popup_backdrop() == "off"):
return
try:
from PySide6.QtWidgets import QApplication
from .widgets.ambient import AmbientWidget
except Exception:
return
app = app or QApplication.instance()
if app is None:
return
from .widgets.ambient import _apply_spaceout_animation_choice
_apply_spaceout_animation_choice(app)
try:
from PySide6.QtWidgets import QDialog
from .widgets.glass import GLASSED, _install_the_backdrop
for dialog in app.allWidgets():
if isinstance(dialog, QDialog) and dialog.property(GLASSED):
_install_the_backdrop(dialog)
except Exception:
LOG.debug("could not refresh popup backdrops", exc_info=True)
try:
widgets = list(app.allWidgets())
except Exception:
return
enabled = get_ambient_enabled()
theme = get_ambient_theme() if enabled else None
palette = get_ambient_palette()
blur = get_ambient_blur()
speed = get_ambient_speed()
size = get_ambient_size()
resolution = get_ambient_resolution()
density = get_ambient_density()
direction = get_ambient_drift_direction()
background = _effective_ambient_background()
field_effects = _spaceout_field_effects()
for widget in widgets:
try:
if not isinstance(widget, AmbientWidget):
continue
if widget.property("spacrRetiringBackdrop"):
continue
widget.set_ripples_enabled(_field_ripples_enabled())
widget._set_ripple_intensity(_field_ripple_intensity())
widget.set_field_effects(field_effects)
if widget.property("spacrSetupBackdrop"):
continue
popup = bool(widget.property("spacrPopupBackdrop"))
selected_theme = get_popup_backdrop() if popup else theme
if (not enabled and not popup) or selected_theme == "off":
widget.set_animating(False)
widget.setVisible(False)
continue
widget.setVisible(True)
widget.set_animating(True)
try:
widget.set_theme(selected_theme)
from .widgets.ambient import coerce_palette
widget.set_palette(coerce_palette(selected_theme, palette))
if not getattr(widget, "_background_explicit", False):
current = getattr(widget, "background_color", None)
if not callable(current) or current() != background:
apply_background = getattr(widget, "_apply_background", None)
if callable(apply_background):
apply_background(background, explicit=False)
else:
widget.set_background_color(background)
widget.set_blur(blur)
motion = _popup_backdrop_motion() if popup else {
"speed": speed, "size": size, "resolution": resolution, "density": density}
widget.set_speed(motion["speed"])
widget.set_size_scale(motion["size"])
widget.set_resolution(motion["resolution"])
widget.set_density(motion["density"])
widget.set_blink_percent(_ambient_blink_percent())
widget.set_popup_wave_frequency(_field_popup_wave_frequency())
widget.set_direction(direction)
widget.set_gravity_radius(_ambient_gravity_radius())
except Exception:
LOG.debug("could not restyle an ambient backdrop",
exc_info=True)
except Exception:
continue
#: The three modes, most aggressive first (the order the dropdown lists
#: them, so the default is at the bottom where a reader lands last).
SPACR_MODES = ("extra_performance", "performance", "balanced")
#: THE ONE PERFORMANCE SETTING, ordered by how much of the machine spaCR
#: keeps for itself: least first.
#:
#: Laptop was a second control that quietly overrode this one, so a user who
#: chose Workstation-like behaviour here could have it undone by a setting on
#: another row -- two answers to one question. It is a LEVEL, not an
#: independent axis: the most constrained end of the same scale.
#:
#: Scientific computation and results are identical at every level. Only
#: scheduling, caching, memory retention and interface decoration differ.
PERFORMANCE_LEVELS = ("laptop", "extra_performance", "performance",
"balanced", "workstation")
#: What the dialog calls each level.
PERFORMANCE_LABELS = {
"laptop": "Laptop",
"extra_performance": "Extra Performance",
"performance": "Performance",
"balanced": "Balanced",
"workstation": "Workstation",
}
#: The hardware each level is for, and what it trades. Shown as the level's
#: tooltip, so the choice can be made without guessing.
#: 286: EVERY CLAIM HERE IS ONE THE CODE KEEPS. The minutes and megabytes
#: are `memory_budget.RECOMMENDED` for the level, which an untouched budget
#: follows, and a test holds each note to them. The old wording promised
#: "one worker", "dropped as soon as a run finishes" and "nothing is dropped
#: until you ask", none of which any code did.
PERFORMANCE_NOTES = {
"laptop": (
"For a machine with 8 GB of memory or less, or one running on "
"battery. spaCR keeps the least: no animated backdrop, the fewest "
"editable figures, and by default cached data is dropped after 2 "
"minutes unused or above 256 MB. Caches are also cleared at launch "
"and before every run, so going back to a figure is slower."),
"extra_performance": (
"For a shared machine you do not want spaCR to crowd. spaCR clears "
"its caches, unused GPU memory and idle threads at launch and before "
"every run, turns every visual setting to its minimum, and by "
"default drops cached data after 5 minutes unused or above 512 MB."),
"performance": (
"For a machine with other work on it. spaCR clears its caches and "
"unused GPU memory once, at launch, leaves your visual settings "
"alone, and by default drops cached data after 10 minutes unused or "
"above 1024 MB."),
"balanced": (
"For an ordinary desktop with 16 GB or more. Nothing is cleared at "
"launch or before a run, recent figures and images stay ready so "
"going back is instant, and by default cached data is dropped after "
"15 minutes unused or above 2048 MB."),
"workstation": (
"For a machine with 64 GB or more that is yours alone. spaCR keeps "
"the most: the most editable figures, and by default cached data "
"for 60 minutes unused and up to 16384 MB. Nothing is cleared at "
"launch or before a run. Uses the most memory of any level, by "
"design."),
}
#: The level a machine gets when nothing has been chosen.
DEFAULT_PERFORMANCE_LEVEL = "balanced"
_KEY_PERFORMANCE_LEVEL = "prefs/performance_level"
#: Balanced. A tool that starts by taking things away from you has made a
#: decision you did not ask for; the other two are opt-in and both warn.
DEFAULT_SPACR_MODE = "balanced"
MODE_LABELS = {
"extra_performance": "Extra Performance",
"performance": "Performance",
"balanced": "Balanced",
}
MODE_NOTES = {
"extra_performance": (
"Frees as much as is safe: spaCR drops its own caches, returns its "
"unused GPU blocks and retires its idle threads at launch AND "
"before every module run, and every visual setting goes to its "
"minimum — no animated backdrop, no field fade, no setting "
"animations."),
"performance": (
"Frees spaCR's own caches and unused GPU blocks once, at launch, "
"and whenever you press one of the four buttons below. Visual "
"settings are left alone."),
"balanced": (
"The default. Nothing is freed at launch or before a run, and your "
"visual settings stay exactly as you set them. The four buttons "
"below still work whenever you press them."),
}
#: Shown when the mode is *selected*, before it is saved. Both performance
#: modes cost something, and the cost is named rather than implied.
MODE_WARNINGS = {
"extra_performance": (
"Extra Performance overwrites your visual settings with their "
"minimums — the animated backdrop is switched off, field fade and "
"setting animations are cleared. They are remembered and put back "
"when you leave this mode.\n\n"
"spaCR will also drop its caches before every run, so the first "
"preview after a run redraws from disk. It never touches another "
"program's memory or processes."),
"performance": (
"Performance drops spaCR's own caches once at launch, so the first "
"screen you open redraws from disk instead of from memory. Your "
"visual settings are not changed.\n\n"
"It never touches another program's memory or processes."),
"balanced": "",
}
#: The visual settings Extra Performance overrides, and the value it
#: overrides them with. Names are read back through this module's own
#: setters, so a stashed value is validated on the way home like any other.
_MODE_MINIMISED_VISUALS = (
"ambient_animation", "ambient_resolution", "ambient_density",
"setting_animations", "field_fade",
)
#: What the laptop-mode preference may be set to. ``"automatic"`` leaves the
#: decision to the measurement, which is what an unset preference has always
#: meant; the other two override it in either direction.
LAPTOP_MODE_CHOICES = ("automatic", "on", "off")
#: What the dialog calls each one.
LAPTOP_MODE_LABELS = {
"automatic": "Automatic (decide from this machine)",
"on": "On (reduce animation work)",
"off": "Off (keep everything on)",
}
#: The spaceout fractal's settings. SPACEOUT ONLY -- the rows are not built
#: in an ordinary launch, and these functions are the only readers, so a
#: normal session neither shows them nor is affected by them.
#:
#: The published defaults use ``auto`` so one set of numbers selects the GPU
#: when vispy is importable and the CPU otherwise.
from .fractal_defaults import PATTERNS as FRACTAL_PATTERNS # noqa: E402
FRACTAL_BACKENDS = ("auto", "gpu", "cpu")
#: The quality levels, least demanding first.
#:
#: A quality level governs the related render numbers. A level that only
#: nudged an internal detail count while supersampling,
#: render scale and the iteration budget sat at whatever they were is a
#: label rather than a setting.
#:
#: `auto` stays first because it is not a level but a refusal to choose one:
#: it asks the machine.
FRACTAL_QUALITIES = ("auto", "balanced", "high", "ultra")
#: What each level sets, as ``quality -> {setting: value}``.
#:
#: A LEVEL IS A SET OF NUMBERS, not an adjective. These are applied when the
#: level is chosen, and every one of them remains a field the user can then
#: change -- picking a level is a starting point, not a lock.
QUALITY_PRESETS = {
"balanced": {
"scale": 0.75,
"base_iterations": 200,
"iterations_per_decade": 40.0,
"max_iterations": 1200,
},
"high": {
"scale": 1.75,
"base_iterations": 300,
"iterations_per_decade": 55.0,
"max_iterations": 2200,
},
"ultra": {
"scale": 2.5,
"base_iterations": 500,
"iterations_per_decade": 90.0,
"max_iterations": 2200,
},
}
[docs]
def apply_quality_preset(quality: str) -> dict:
"""Set the numbers a quality level implies, and return them.
:param quality: one of :data:`FRACTAL_QUALITIES`.
:returns: what was applied; empty for ``auto`` or an unknown level.
``auto`` applies nothing on purpose: it means "decide from the machine"
and the renderer does that per backend, so writing numbers here would
turn a decision that follows the hardware into one frozen at the moment
somebody opened Preferences.
"""
preset = QUALITY_PRESETS.get(str(quality))
if not preset:
return {}
set_fractal_settings(**preset)
return dict(preset)
[docs]
def get_fractal_settings() -> dict:
"""Every spaceout fractal setting, ready for `Settings`/`RuntimeControls`.
Read through one function so the dialog and the backdrop cannot disagree
about a default. Out-of-range stored values are clamped rather than
refused -- a backdrop must not stop the application from starting.
"""
from .fractal_defaults import (
DEFAULT_BACKEND, DEFAULT_DREAM, DEFAULT_PATTERN, DEFAULT_QUALITY,
DEFAULT_SCALE, DEFAULT_SPEED, DEFAULT_SPEED_MAX, DEFAULT_SPEED_MIN,
DEFAULT_SPEED_PERIOD, DEFAULT_VARIABLE_SPEED, clamp,
DEFAULT_FOLLOW_POINTER, DEFAULT_POINTER_SIZE,
DEFAULT_POINTER_STRENGTH, DEFAULT_MAGNIFIER_SIZE,
)
settings = _settings()
def _text(key, default, allowed):
"""One stored string, or the default when it is not an allowed value.
Falls back rather than raising: a stale preference naming a theme that no
longer exists must not stop the settings loading.
"""
raw = str(settings.value(key, default))
return raw if raw in allowed else default
def _number(key, default, low, high):
"""A stored number, with only the bounds that are real.
``None`` for a bound means there is none: the settings are FIELDS,
and a value the user typed is not quietly reduced on the way back
out. Only a value that cannot work at all is refused, and
`explain_a_fractal_number` is what says so, in words, at the point
it is entered.
"""
try:
value = float(settings.value(key, default))
except (TypeError, ValueError):
return default
if value != value:
return default
if low is not None and value < low:
return low
if high is not None and value > high:
return high
return value
def _truth(key, default):
"""A stored boolean, however QSettings gave it back.
An INI file hands every value back as a string, so `bool("false")`
is True and a switch the user turned off comes back on.
"""
raw = settings.value(key, default)
if isinstance(raw, str):
return raw.strip().lower() in ("1", "true", "yes", "on")
return bool(raw)
raw_variable = settings.value(_KEY_FRACTAL_VARIABLE_SPEED,
DEFAULT_VARIABLE_SPEED)
if isinstance(raw_variable, str):
variable = raw_variable.strip().lower() in ("1", "true", "yes", "on")
else:
variable = bool(raw_variable)
return {
"pattern": _text(_KEY_FRACTAL_PATTERN, DEFAULT_PATTERN,
FRACTAL_PATTERNS),
"backend": _text(_KEY_FRACTAL_BACKEND, DEFAULT_BACKEND,
FRACTAL_BACKENDS),
"quality": _text(_KEY_FRACTAL_QUALITY, DEFAULT_QUALITY,
FRACTAL_QUALITIES),
"scale": _number(_KEY_FRACTAL_SCALE, DEFAULT_SCALE, 0.01,
None),
"speed": _number(_KEY_FRACTAL_SPEED, DEFAULT_SPEED, 0.0, None),
"dream": _number(_KEY_FRACTAL_DREAM, DEFAULT_DREAM, 0.0, None),
"variable_speed": variable,
"speed_min": _number(_KEY_FRACTAL_SPEED_MIN, DEFAULT_SPEED_MIN,
0.0, None),
"speed_max": _number(_KEY_FRACTAL_SPEED_MAX, DEFAULT_SPEED_MAX,
0.0, None),
"speed_period": _number(_KEY_FRACTAL_SPEED_PERIOD,
DEFAULT_SPEED_PERIOD, 0.1, None),
"pointer_gravity": _truth(_KEY_FRACTAL_POINTER,
DEFAULT_FOLLOW_POINTER),
"pointer_size": _number(_KEY_FRACTAL_POINTER_SIZE,
DEFAULT_POINTER_SIZE, 0.0, None),
"pointer_strength": _number(_KEY_FRACTAL_POINTER_STRENGTH,
DEFAULT_POINTER_STRENGTH, 0.0, None),
"magnifier_size": _number(_KEY_FRACTAL_MAGNIFIER_SIZE,
DEFAULT_MAGNIFIER_SIZE,
*FRACTAL_LIMITS["magnifier_size"][:2]),
"supersampling": int(_number(_KEY_FRACTAL_SUPERSAMPLING,
_MANDEL_DEFAULTS["supersampling"],
FRACTAL_LIMITS['supersampling'][0], None)),
"seconds_per_decade": _number(_KEY_FRACTAL_SECONDS_PER_DECADE,
_MANDEL_DEFAULTS["seconds_per_decade"],
FRACTAL_LIMITS['seconds_per_decade'][0], None),
"base_iterations": int(_number(_KEY_FRACTAL_BASE_ITERATIONS,
_MANDEL_DEFAULTS["base_iterations"],
FRACTAL_LIMITS['base_iterations'][0], None)),
"iterations_per_decade": _number(_KEY_FRACTAL_ITERATIONS_PER_DECADE,
_MANDEL_DEFAULTS["iterations_per_decade"],
FRACTAL_LIMITS['iterations_per_decade'][0], None),
"max_iterations": int(_number(_KEY_FRACTAL_MAX_ITERATIONS,
_MANDEL_DEFAULTS["max_iterations"],
FRACTAL_LIMITS['max_iterations'][0], None)),
"precision_digits": int(_number(_KEY_FRACTAL_PRECISION_DIGITS,
_MANDEL_DEFAULTS["precision_digits"],
FRACTAL_LIMITS['precision_digits'][0], None)),
"initial_scale": _number(_KEY_FRACTAL_INITIAL_SCALE,
_MANDEL_DEFAULTS["initial_scale"],
FRACTAL_LIMITS['initial_scale'][0], None),
"zoom_rate": _number(_KEY_FRACTAL_ZOOM_RATE,
_MANDEL_DEFAULTS["zoom_rate"],
FRACTAL_LIMITS['zoom_rate'][0], None),
"render_scale": _number(_KEY_FRACTAL_RENDER_SCALE,
_MANDEL_DEFAULTS["render_scale"],
FRACTAL_LIMITS['render_scale'][0], None),
"steering_strength": _number(_KEY_FRACTAL_STEERING_STRENGTH,
_MANDEL_DEFAULTS["steering_strength"],
FRACTAL_LIMITS['steering_strength'][0], None),
"steering_interval_decades": _number(_KEY_FRACTAL_STEERING_INTERVAL_DECADES,
_MANDEL_DEFAULTS["steering_interval_decades"],
FRACTAL_LIMITS['steering_interval_decades'][0], None),
"steering_duration": _number(_KEY_FRACTAL_STEERING_DURATION,
_MANDEL_DEFAULTS["steering_duration"],
FRACTAL_LIMITS['steering_duration'][0], None),
"candidate_count": int(_number(_KEY_FRACTAL_CANDIDATE_COUNT,
_MANDEL_DEFAULTS["candidate_count"],
FRACTAL_LIMITS['candidate_count'][0], None)),
"path": _text(_KEY_FRACTAL_PATH,
_MANDEL_DEFAULTS.get("path", "tour"),
("fixed", "guided", "tour")),
"steering": _number(_KEY_FRACTAL_STEERING,
_MANDEL_DEFAULTS.get("steering", 0.35),
0.0, 1.0),
"max_depth": _number(_KEY_FRACTAL_MAX_DEPTH,
_MANDEL_DEFAULTS["max_depth"],
*FRACTAL_LIMITS["max_depth"][:2]),
}
[docs]
def set_fractal_settings(**values) -> None:
"""Persist any subset of the fractal settings.
:raises ValueError: on an unknown name, or a backend/quality outside its
set. A number is stored as given; only one that cannot work at all
is moved, and `explain_a_fractal_number` says so in words before it
reaches here. What follows describes the old behaviour, kept because
the reasoning about a slider still applies to the two sliders left
produce one and a hand-edited file should still start.
"""
from .fractal_defaults import clamp
keys = {
"pattern": (_KEY_FRACTAL_PATTERN, None),
"backend": (_KEY_FRACTAL_BACKEND, None),
"quality": (_KEY_FRACTAL_QUALITY, None),
"scale": (_KEY_FRACTAL_SCALE, (0.01, None)),
"speed": (_KEY_FRACTAL_SPEED, (0.0, None)),
"dream": (_KEY_FRACTAL_DREAM, (0.0, None)),
"variable_speed": (_KEY_FRACTAL_VARIABLE_SPEED, None),
"speed_min": (_KEY_FRACTAL_SPEED_MIN, (0.0, None)),
"speed_max": (_KEY_FRACTAL_SPEED_MAX, (0.0, None)),
"speed_period": (_KEY_FRACTAL_SPEED_PERIOD, (0.1, None)),
"pointer_gravity": (_KEY_FRACTAL_POINTER, None),
"pointer_size": (_KEY_FRACTAL_POINTER_SIZE, (0.0, None)),
"pointer_strength": (_KEY_FRACTAL_POINTER_STRENGTH, (0.0, None)),
"magnifier_size": (_KEY_FRACTAL_MAGNIFIER_SIZE,
FRACTAL_LIMITS["magnifier_size"][:2]),
"supersampling": (_KEY_FRACTAL_SUPERSAMPLING,
(FRACTAL_LIMITS['supersampling'][0], FRACTAL_LIMITS['supersampling'][1])),
"seconds_per_decade": (_KEY_FRACTAL_SECONDS_PER_DECADE,
(FRACTAL_LIMITS['seconds_per_decade'][0], FRACTAL_LIMITS['seconds_per_decade'][1])),
"base_iterations": (_KEY_FRACTAL_BASE_ITERATIONS,
(FRACTAL_LIMITS['base_iterations'][0], FRACTAL_LIMITS['base_iterations'][1])),
"iterations_per_decade": (_KEY_FRACTAL_ITERATIONS_PER_DECADE,
(FRACTAL_LIMITS['iterations_per_decade'][0], FRACTAL_LIMITS['iterations_per_decade'][1])),
"max_iterations": (_KEY_FRACTAL_MAX_ITERATIONS,
(FRACTAL_LIMITS['max_iterations'][0], FRACTAL_LIMITS['max_iterations'][1])),
"precision_digits": (_KEY_FRACTAL_PRECISION_DIGITS,
(FRACTAL_LIMITS['precision_digits'][0], FRACTAL_LIMITS['precision_digits'][1])),
"initial_scale": (_KEY_FRACTAL_INITIAL_SCALE,
(FRACTAL_LIMITS['initial_scale'][0], FRACTAL_LIMITS['initial_scale'][1])),
"zoom_rate": (_KEY_FRACTAL_ZOOM_RATE,
(FRACTAL_LIMITS['zoom_rate'][0], FRACTAL_LIMITS['zoom_rate'][1])),
"render_scale": (_KEY_FRACTAL_RENDER_SCALE,
(FRACTAL_LIMITS['render_scale'][0], FRACTAL_LIMITS['render_scale'][1])),
"steering_strength": (_KEY_FRACTAL_STEERING_STRENGTH,
(FRACTAL_LIMITS['steering_strength'][0], FRACTAL_LIMITS['steering_strength'][1])),
"steering_interval_decades": (_KEY_FRACTAL_STEERING_INTERVAL_DECADES,
(FRACTAL_LIMITS['steering_interval_decades'][0], FRACTAL_LIMITS['steering_interval_decades'][1])),
"steering_duration": (_KEY_FRACTAL_STEERING_DURATION,
(FRACTAL_LIMITS['steering_duration'][0], FRACTAL_LIMITS['steering_duration'][1])),
"candidate_count": (_KEY_FRACTAL_CANDIDATE_COUNT,
(FRACTAL_LIMITS['candidate_count'][0], FRACTAL_LIMITS['candidate_count'][1])),
"path": (_KEY_FRACTAL_PATH, None),
"steering": (_KEY_FRACTAL_STEERING, (0.0, 1.0)),
"max_depth": (_KEY_FRACTAL_MAX_DEPTH,
(FRACTAL_LIMITS["max_depth"][0],
FRACTAL_LIMITS["max_depth"][1])),
}
store = _settings()
for name, value in values.items():
if name not in keys:
raise ValueError(f"unknown fractal setting {name!r}; "
f"expected one of {sorted(keys)}")
key, bounds = keys[name]
if name == "pattern" and value not in FRACTAL_PATTERNS:
raise ValueError(f"unknown fractal pattern {value!r}")
if name == "backend" and value not in FRACTAL_BACKENDS:
raise ValueError(f"unknown fractal backend {value!r}")
if name == "quality" and value not in FRACTAL_QUALITIES:
raise ValueError(f"unknown fractal quality {value!r}")
if name in ("speed", "scale"):
derived = (speed_group_values(value) if name == "speed"
else scale_group_values(value))
store.setValue(key, float(value))
for _name, _value in derived.items():
if _name == name:
continue
_key, _ = keys[_name]
store.setValue(_key, _value)
continue
if name == "steering":
from .widgets.fractal_mandelbrot import steering_from_one_number
store.setValue(key, float(value))
derived = steering_from_one_number(
float(value),
float(get_fractal_settings().get("seconds_per_decade", 24.0)))
for _name, _value in derived.items():
_key, _ = keys[_name]
store.setValue(_key, _value)
continue
if bounds is not None:
low, high = bounds
value = float(value)
if low is not None and value < low:
value = low
if high is not None and value > high:
value = high
if name == "variable_speed":
value = bool(value)
store.setValue(key, value)
store.sync()
#: The two weights the interface is drawn in. Bold and SemiBold stay
#: registered for a stylesheet that asks for emphasis; this is what
#: everything else defaults to.
INTERFACE_FONT_WEIGHTS = ("regular", "light")
#: When the heavy pipeline modules are imported.
#:
#: ``'on_demand'`` -- when the operation that needs them is called; this is
#: the default.
#: ``'eager'`` -- at startup, on a worker thread. It is useful only on a
#: machine that will certainly run a pipeline and prefers to pay a potentially
#: tens-of-seconds import cost at the beginning.
PRELOAD_POLICIES = ("on_demand", "eager")
[docs]
def get_preload_policy() -> str:
"""When to import torch and the rest. 'on_demand' or 'eager'."""
raw = str(_settings().value(_KEY_PRELOAD, "on_demand")).strip().lower()
return raw if raw in PRELOAD_POLICIES else "on_demand"
[docs]
def set_preload_policy(policy: str) -> None:
"""Persist it. Takes effect at the next launch, and says so.
:param policy: one of :data:`PRELOAD_POLICIES`, matched after stripping and
lower-casing.
:raises ValueError: on anything but the two policies.
"""
text = str(policy).strip().lower()
if text not in PRELOAD_POLICIES:
raise ValueError(f"unknown preload policy {policy!r}; expected one "
f"of {list(PRELOAD_POLICIES)}")
_settings().setValue(_KEY_PRELOAD, text)
_settings().sync()
#: Body text is Light while titles are Regular. Only the application font is
#: set from this -- the headings,
#: buttons and section titles carry their own `font-weight` in the
#: stylesheet (400 and above), so making the default body weight lighter
#: does not thin the titles with it.
DEFAULT_INTERFACE_FONT_WEIGHT = "light"
[docs]
def get_interface_font_weight() -> str:
"""Which Open Sans weight the interface's body text uses.
:returns: ``'light'`` or ``'regular'``, defaulting to
:data:`DEFAULT_INTERFACE_FONT_WEIGHT`.
"""
raw = str(_settings().value(
_KEY_FONT_WEIGHT, DEFAULT_INTERFACE_FONT_WEIGHT)).strip().lower()
return raw if raw in INTERFACE_FONT_WEIGHTS \
else DEFAULT_INTERFACE_FONT_WEIGHT
[docs]
def set_interface_font_weight(weight: str) -> None:
"""Persist the weight and apply it to the running application.
:param weight: one of :data:`INTERFACE_FONT_WEIGHTS`, matched after
stripping and lower-casing.
:raises ValueError: on anything but 'regular' or 'light'.
"""
text = str(weight).strip().lower()
if text not in INTERFACE_FONT_WEIGHTS:
raise ValueError(f"unknown interface font weight {weight!r}; "
f"expected one of {list(INTERFACE_FONT_WEIGHTS)}")
_settings().setValue(_KEY_FONT_WEIGHT, text)
_settings().sync()
try:
from PySide6.QtWidgets import QApplication
from .app import _use_open_sans
instance = QApplication.instance()
if instance is not None:
_use_open_sans(instance, text)
except Exception: # noqa: BLE001
pass
[docs]
def get_laptop_mode() -> str:
"""Whether laptop constraints apply.
:returns: ``"on"`` at the Laptop level, otherwise ``"off"``.
DERIVED, NOT STORED. This was a second control that quietly overrode
the mode selector, so a user could choose one posture on one row and
have another row undo it -- two answers to one question. Laptop is now
the most constrained LEVEL of the single selector, and this answers
from it so callers that still ask in these words agree with it.
There is no ``"automatic"`` any more: it meant "measure the machine and
decide", which is a guess presented as a setting. The five levels say
which hardware each is for and let the user pick.
"""
return "on" if get_performance_level() == "laptop" else "off"
[docs]
def set_laptop_mode(choice: str) -> None:
"""Persist the laptop-mode preference and apply it now.
:param choice: one of :data:`LAPTOP_MODE_CHOICES`: ``"on"`` selects the
Laptop performance level, ``"off"`` moves a Laptop level back to the
default level, and ``"automatic"`` changes nothing.
:raises ValueError: on an unknown choice.
Applied immediately rather than at the next launch, because the two
animation it changes is visible in the window behind the dialog. A
performance setting
that needs a restart to show its effect cannot be judged by the person
setting it.
"""
if choice not in LAPTOP_MODE_CHOICES:
raise ValueError(f"unknown laptop mode {choice!r}; "
f"expected one of {list(LAPTOP_MODE_CHOICES)}")
if choice == "on":
set_performance_level("laptop")
elif choice == "off" and get_performance_level() == "laptop":
set_performance_level(DEFAULT_PERFORMANCE_LEVEL)
return None
[docs]
def laptop_mode_note(choice: str) -> str:
"""What the chosen setting will do on THIS machine, said before saving.
Automatic is the case that needs saying: the label cannot state the
outcome, because the outcome depends on the machine reading it.
:param choice: one of :data:`LAPTOP_MODE_CHOICES`: ``"automatic"`` reports
what this machine's measurement decides, ``"on"`` lists what is turned
down, and anything else is described as off.
"""
from .laptop_mode import measure, wanted, what_it_turns_down
turns_down = ", ".join(what for what, _cost in what_it_turns_down())
if choice == "automatic":
_on, why = wanted({**measure(), "override": None})
return why
if choice == "on":
return (f"Turns down {turns_down}. Only the drawing changes: a run "
f"computes exactly the same answer either way.")
return "Keeps the animation at its chosen settings, whatever this machine is."
[docs]
def get_idle_minutes() -> float:
"""How long an unused cache entry may sit before it is dropped.
:returns: minutes; 0 means "as soon as nothing is using it".
"""
from .memory_budget import MAX_IDLE_MINUTES, MIN_IDLE_MINUTES
fallback = float(_level_budget()[0])
raw = _settings().value(_KEY_IDLE_MINUTES, None)
if raw is None or raw == "":
return max(MIN_IDLE_MINUTES, min(MAX_IDLE_MINUTES, fallback))
try:
value = float(raw)
except (TypeError, ValueError):
return fallback
return max(MIN_IDLE_MINUTES, min(MAX_IDLE_MINUTES, value))
[docs]
def set_idle_minutes(minutes: float) -> None:
"""Persist the idle timeout.
:param minutes: how long an unused cache entry may sit before it is
dropped, in minutes; 0 drops it as soon as nothing uses it. Stored as a
``float``.
"""
settings = _settings()
settings.setValue(_KEY_IDLE_MINUTES, float(minutes))
settings.sync()
[docs]
def get_cache_ceiling_mb() -> int:
"""How much cache spaCR may hold at once, in megabytes."""
from .memory_budget import MAX_CACHE_CEILING_MB, MIN_CACHE_CEILING_MB
fallback = int(_level_budget()[1])
raw = _settings().value(_KEY_CACHE_CEILING, None)
if raw is None or raw == "":
return max(MIN_CACHE_CEILING_MB, min(MAX_CACHE_CEILING_MB, fallback))
try:
value = int(float(raw))
except (TypeError, ValueError):
return fallback
return max(MIN_CACHE_CEILING_MB, min(MAX_CACHE_CEILING_MB, value))
[docs]
def set_cache_ceiling_mb(megabytes: int) -> None:
"""Persist the cache ceiling.
:param megabytes: the most cache spaCR may hold at once, in megabytes;
stored as an ``int``.
"""
settings = _settings()
settings.setValue(_KEY_CACHE_CEILING, int(megabytes))
settings.sync()
[docs]
def get_headroom_mb() -> int:
"""How much memory must stay free for everything else on the machine.
THE FIRST OF THE THREE. The idle timeout and the ceiling say what may be
kept; this says when keeping it stops being acceptable, and without it
neither of the others has anything to answer to.
"""
from .memory_budget import MAX_HEADROOM_MB, MIN_HEADROOM_MB
fallback = int(_level_budget()[2])
raw = _settings().value(_KEY_HEADROOM, None)
if raw is None or raw == "":
return max(MIN_HEADROOM_MB, min(MAX_HEADROOM_MB, fallback))
try:
value = int(float(raw))
except (TypeError, ValueError):
return fallback
return max(MIN_HEADROOM_MB, min(MAX_HEADROOM_MB, value))
[docs]
def set_headroom_mb(megabytes: int) -> None:
"""Persist the headroom floor.
:param megabytes: the memory that must stay free for everything else on the
machine, in megabytes; stored as an ``int``.
"""
settings = _settings()
settings.setValue(_KEY_HEADROOM, int(megabytes))
settings.sync()
[docs]
def get_database_write_queue_gib() -> float:
"""Return the serialized database queue RAM budget in GiB.
Zero uses disk-only buffering. This is not the total process RAM limit.
"""
import math
try:
value = float(_settings().value(_KEY_DATABASE_WRITE_QUEUE, 1.0))
except (TypeError, ValueError):
return 1.0
return max(0.0, min(64.0, value)) if math.isfinite(value) else 1.0
[docs]
def set_database_write_queue_gib(gib: float) -> None:
"""Persist the database queue payload budget for subsequent runs.
:param gib: serialized queued-data RAM allowance in GiB, from zero to 64.
Zero stores queued payloads on disk.
:raises ValueError: if the value is nonfinite or outside the valid range.
"""
import math
value = float(gib)
if not math.isfinite(value) or not 0 <= value <= 64:
raise ValueError('Database write queue RAM must be between 0 and 64 GiB')
settings = _settings()
settings.setValue(_KEY_DATABASE_WRITE_QUEUE, value)
settings.sync()
def _level_budget():
"""The budget the current performance level recommends.
:returns: ``(idle minutes, cache MB, headroom MB)`` for the stored
level, or the shipped defaults when the level cannot be read.
The getters use this as the value of a budget nobody has typed, so an
untouched budget follows the level (286). It never raises: a background
budget sweep with no user in front of it reads these numbers, and an
unreadable store must cost that sweep a default rather than an exception.
"""
from .memory_budget import (DEFAULT_CACHE_CEILING_MB,
DEFAULT_HEADROOM_MB, DEFAULT_IDLE_MINUTES,
recommended_for)
try:
return recommended_for(get_performance_level())
except Exception: # noqa: BLE001
return (DEFAULT_IDLE_MINUTES, DEFAULT_CACHE_CEILING_MB,
DEFAULT_HEADROOM_MB)
def _save_budget_for_level(level, idle_minutes, cache_mb, headroom_mb):
"""Store the three budget numbers Preferences' Save was given (286).
:param level: the performance level being saved beside them.
:param idle_minutes: the idle timeout shown in the dialog.
:param cache_mb: the cache ceiling shown in the dialog.
:param headroom_mb: the free-memory floor shown in the dialog.
:returns: None; the store is written and synced.
A number equal to ``level``'s own recommendation is stored as "follow
the level" -- its key removed -- so a later level change still moves
it; any other number is the user's and is kept at every level. Writing
all three unconditionally, as Save once did, froze the budget at
whatever level happened to be showing on the first Save.
"""
from .memory_budget import recommended_for
own = recommended_for(level)
settings = _settings()
for key, value, level_value in (
(_KEY_IDLE_MINUTES, float(idle_minutes), float(own[0])),
(_KEY_CACHE_CEILING, int(cache_mb), int(own[1])),
(_KEY_HEADROOM, int(headroom_mb), int(own[2]))):
if value == level_value:
settings.remove(key)
else:
settings.setValue(key, value)
settings.sync()
def _budget_follows_level(mode_combo, last_level, spins) -> None:
"""Move each untouched budget spin box to the newly chosen level's value.
:param mode_combo: the Preferences dialog's Performance combo; its
current data is the level just chosen.
:param last_level: a one-item list holding the level the spin boxes were
last aligned to. It is updated in place, so the next change compares
against the level this one chose.
:param spins: the ``(idle minutes, cache MB, headroom MB)`` spin boxes,
in the order of :data:`memory_budget.RECOMMENDED`'s tuples.
:returns: None; the spin boxes are changed in place.
286: a number still equal to the previous level's recommendation was
never chosen by the user, so it moves with the level; a number the user
typed differs from it and stays where they put it. That keeps the dialog
agreeing with :func:`_save_budget_for_level`, which stores a number equal
to the level's own as "follow the level". An unknown level on either
side moves nothing, because there is no recommendation to compare with.
"""
from .memory_budget import RECOMMENDED
new, old = mode_combo.currentData(), last_level[0]
last_level[0] = new
if new == old or new not in RECOMMENDED or old not in RECOMMENDED:
return
for index, spin in enumerate(spins):
if spin.value() == RECOMMENDED[old][index]:
spin.setValue(RECOMMENDED[new][index])
_KEY_SCREEN_PREWARM = "prefs/screen_prewarm"
_KEY_SCREEN_PREWARM_ORDER = "prefs/screen_prewarm_order"
#: The module screens built while Home sits idle, most used first.
_SCREEN_PREWARM_ORDER = ("mask", "measure", "make_masks", "classify_merged",
"analyze_plaques", "regression", "annotate")
#: Performance levels that never build screens ahead of the user.
_NO_SCREEN_PREWARM_LEVELS = frozenset({"laptop", "extra_performance"})
def _screen_prewarm_order() -> tuple[str, ...]:
"""The module screens to build ahead of time while Home is idle.
``SPACR_PREWARM_ORDER`` (comma separated module keys) wins over the
stored ``prefs/screen_prewarm_order``; an empty answer from both gives
:data:`_SCREEN_PREWARM_ORDER`.
:returns: module keys in build order, without repeats.
"""
raw = os.environ.get("SPACR_PREWARM_ORDER")
if raw is None:
try:
raw = _settings().value(_KEY_SCREEN_PREWARM_ORDER, "")
except Exception: # noqa: BLE001
raw = ""
if isinstance(raw, (list, tuple)):
raw = ",".join(str(part) for part in raw)
keys = [part.strip() for part in str(raw or "").split(",") if part.strip()]
return tuple(dict.fromkeys(keys)) or _SCREEN_PREWARM_ORDER
def _screen_prewarm_allowed() -> tuple[bool, str]:
"""Whether idle screen building may run now, and why not when it may not.
Off when ``SPACR_PREWARM`` is ``0``, when ``prefs/screen_prewarm`` is
false, at the Laptop and Extra Performance levels, when laptop mode is
wanted for this machine (few cores or little memory), and in safe mode.
:returns: ``(allowed, reason)``.
"""
env = os.environ.get("SPACR_PREWARM", "").strip().lower()
if env in {"0", "false", "no", "off"}:
return False, "SPACR_PREWARM is off"
if _SAFE_MODE:
return False, "safe mode"
try:
stored = _settings().value(_KEY_SCREEN_PREWARM, True)
except Exception: # noqa: BLE001
stored = True
if str(stored).strip().lower() in {"0", "false", "no", "off"}:
return False, "turned off in the preferences"
level = get_performance_level()
if level in _NO_SCREEN_PREWARM_LEVELS:
return False, f"performance level {level}"
if env not in {"1", "true", "yes", "on"}:
try:
from .laptop_mode import wanted
if wanted()[0]:
return False, "laptop or low-memory machine"
except Exception: # noqa: BLE001
LOG.debug("could not read laptop mode", exc_info=True)
return True, ""
def _level_is_durable(settings, level: str) -> bool:
"""Whether a migrated performance level really reached the store.
:param settings: the QSettings (or stand-in) the level was written to.
:param level: the level that was written.
:returns: True only if the store reports no error after the sync AND
reads ``level`` back; False on any doubt, including an exception.
The migration in :func:`get_performance_level` deletes the obsolete
Laptop-mode and spaCR-mode answers only when this is True, because until
the level is durable those answers are the only record of the user's
choice. Both checks are needed: QSettings keeps a written value in
memory even when its file cannot be written, so a read-back alone would
pass a store whose disk write failed, and ``status()`` is what reports
that.
"""
status = getattr(settings, "status", None)
if callable(status):
try:
if status() != QSettings.Status.NoError:
return False
except Exception: # noqa: BLE001
return False
try:
return str(settings.value(_KEY_PERFORMANCE_LEVEL, "") or "") == level
except Exception: # noqa: BLE001
return False
def _backdrop_follows_the_level(level: str) -> None:
"""Apply a newly set level's laptop constraint to this running process.
:param level: the performance level just stored.
:returns: None; a failure is logged at debug level and swallowed.
286: the level, not a second switch, decides this run's laptop
constraint -- the same answer ``launch`` gives at startup. Laptop
suppresses the animated backdrop for this process; any other level lifts
only a suppression THIS process made (``laptop_mode._suppressed_here``),
so crash recovery's variable and the user's stored answer are never
touched. Nothing here is persisted, and a failure must not stop the
level itself from being saved.
"""
try:
from .laptop_mode import apply as _apply
_apply(level == "laptop")
except Exception: # noqa: BLE001
LOG.debug("could not bring this run's backdrop in line with the "
"performance level", exc_info=True)
[docs]
def spacr_mode_for_level(level: str) -> str:
"""The resource posture a level implies, in the old three-mode words.
:param level: one of :data:`PERFORMANCE_LEVELS`.
:returns: one of :data:`SPACR_MODES`.
Laptop is more constrained than Extra Performance and Workstation is
less constrained than Balanced, but neither has its own posture in the
cleanup code -- they differ in what is RETAINED, not in how launch
cleanup runs. Mapping them onto the nearest existing posture keeps one
answer to "how hard does spaCR try to stay out of the way".
"""
return {
"laptop": "extra_performance",
"extra_performance": "extra_performance",
"performance": "performance",
"balanced": "balanced",
"workstation": "balanced",
}.get(str(level), DEFAULT_SPACR_MODE)
#: What each level keeps, as multiples of the Balanced allowance.
#:
#: MONOTONIC BY CONSTRUCTION, and asserted by a test: the order of
#: :data:`PERFORMANCE_LEVELS` is meant to be a resource scale, and a table
#: that broke that order would make the selector a list of unrelated words.
#: Laptop keeps the least, Workstation the most.
PERFORMANCE_RETENTION = {
"laptop": 0.25,
"extra_performance": 0.5,
"performance": 0.75,
"balanced": 1.0,
"workstation": 2.5,
}
[docs]
def retention_scale(level: str = "") -> float:
"""How much reusable state this level allows, relative to Balanced.
:param level: a performance level; the current one when omitted.
:returns: a positive multiplier.
"""
level = str(level or get_performance_level())
return float(PERFORMANCE_RETENTION.get(level, 1.0))
[docs]
def get_spacr_mode() -> str:
"""Which resource posture spaCR is in — one of :data:`SPACR_MODES`.
DERIVED FROM THE PERFORMANCE LEVEL, which is the one stored setting.
Kept because the cleanup code speaks in these three words.
"""
return spacr_mode_for_level(get_performance_level())
[docs]
def set_spacr_mode(mode: str) -> None:
"""Persist the mode, and move the visual settings with it.
Entering Extra Performance stashes the five visual settings it
overrides and writes their minimums; leaving it puts the stashed values
back. Nothing else about a mode change is retroactive — the launch
cleanup has already happened or not happened by the time anyone can
reach this dialog.
:param mode: one of :data:`SPACR_MODES`.
:raises ValueError: on an unknown mode.
"""
if mode not in SPACR_MODES:
raise ValueError(f"unknown spaCR mode {mode!r}. "
f"Choose from {SPACR_MODES}.")
previous = get_spacr_mode()
settings = _settings()
settings.setValue(_KEY_PERFORMANCE_LEVEL, mode)
settings.sync()
if mode == "extra_performance" and previous != "extra_performance":
_stash_visuals()
_minimise_visuals()
elif previous == "extra_performance" and mode != "extra_performance":
_restore_visuals()
[docs]
def mode_label(mode: str) -> str:
"""The name the dropdown shows for ``mode`` — e.g. ``"Extra Performance"``.
:param mode: One of :data:`SPACR_MODES`.
:returns: The human-readable label from :data:`MODE_LABELS`, falling back
to ``mode`` itself when the name is not one this module knows, so a
stored value from a newer build still renders as text rather than as
a blank row.
"""
return MODE_LABELS.get(mode, str(mode))
[docs]
def mode_note(mode: str) -> str:
"""What ``mode`` does, as the standing description under the dropdown.
The note says what is freed, when it is freed, and whether the visual
settings are touched. It is not the warning — :func:`mode_warning` carries
what *switching* to the mode costs you, and is shown on selection.
:param mode: One of :data:`SPACR_MODES`.
:returns: The prose from :data:`MODE_NOTES`, or ``""`` for a mode this
module does not know, so the caller can render it unconditionally.
"""
return MODE_NOTES.get(mode, "")
[docs]
def mode_warning(mode: str) -> str:
"""What choosing ``mode`` will cost, or ``""`` when it costs nothing.
:param mode: one of :data:`SPACR_MODES`; a mode with no entry in
:data:`MODE_WARNINGS` gives ``""``.
"""
return MODE_WARNINGS.get(mode, "")
def _visual_snapshot() -> dict:
"""The five settings Extra Performance overrides, as they are now."""
return {
"ambient_animation": get_ambient_animation(),
"ambient_enabled": _as_bool(
_settings().value(_KEY_AMBIENT_ENABLED, DEFAULT_AMBIENT_ENABLED),
DEFAULT_AMBIENT_ENABLED),
"ambient_resolution": get_ambient_resolution(),
"ambient_density": get_ambient_density(),
"setting_animations": get_setting_animations_enabled(),
"field_fade": get_field_fade_enabled(),
}
def _stash_visuals() -> None:
"""Save the current visual settings, so a mode change can restore them.
Written and synced immediately: the stash exists to survive a crash
during the mode switch it is protecting.
"""
import json
settings = _settings()
settings.setValue(_KEY_MODE_VISUAL_STASH,
json.dumps(_visual_snapshot()))
settings.sync()
def _minimise_visuals() -> None:
"""Every overridden visual to its cheapest setting.
"Minimum" means the cheapest value the control offers, not zero for its
own sake: the animation goes to None (no widget, no timer at all — see
:func:`get_ambient_enabled`), detail and density to the bottom of the
ranges the engines declare, and the two per-paint effects off.
"""
ranges = _ambient_ranges()
try:
set_ambient_animation(_no_animation_key())
except Exception:
LOG.debug("could not switch the animation off", exc_info=True)
set_ambient_resolution(ranges[3][0][0])
set_ambient_density(ranges[4][0][0])
set_setting_animations_enabled(False)
set_field_fade_enabled(False)
def _restore_visuals() -> bool:
"""Put back what :func:`_stash_visuals` recorded. ``True`` if it did.
A stash that cannot be read is discarded rather than guessed at: the
user keeps the minimums they can see and change, which is better than
being handed somebody's idea of a default.
"""
import json
settings = _settings()
raw = settings.value(_KEY_MODE_VISUAL_STASH, "")
settings.remove(_KEY_MODE_VISUAL_STASH)
settings.sync()
try:
stashed = json.loads(str(raw)) if raw else None
except Exception:
stashed = None
if not isinstance(stashed, dict):
return False
try:
if "ambient_animation" in stashed:
set_ambient_animation(str(stashed["ambient_animation"]))
if "ambient_resolution" in stashed:
set_ambient_resolution(float(stashed["ambient_resolution"]))
if "ambient_density" in stashed:
set_ambient_density(float(stashed["ambient_density"]))
if "setting_animations" in stashed:
set_setting_animations_enabled(bool(stashed["setting_animations"]))
if "field_fade" in stashed:
set_field_fade_enabled(bool(stashed["field_fade"]))
if "ambient_enabled" in stashed:
set_ambient_enabled(_as_bool(stashed["ambient_enabled"], True))
except Exception:
LOG.debug("could not restore the stashed visuals", exc_info=True)
return False
return True
#: How long work has to run before the spinner appears, in seconds.
#:
#: Two, because the spinner exists to say "this is going to take a moment",
#: and the great majority of what goes through ``make_thread`` — reading a
#: measurement table, listing a plate, loading a settings file — is done
#: inside one. A spinner that appears and vanishes inside a second is not
#: information, it is a flicker in the corner of the eye, and it trains the
#: reader to stop looking at the one place the app says it is busy.
DEFAULT_SPINNER_DELAY = 2.0
#: Nought is a real setting: it means "always show it", which is what
#: somebody debugging a hang wants. The top is chosen so a mistyped value
#: cannot hide the indicator for the length of a real job.
SPINNER_DELAY_MIN = 0.0
SPINNER_DELAY_MAX = 10.0
[docs]
def get_spinner_delay() -> float:
"""How long background work must run before the activity spinner shows.
In seconds, default :data:`DEFAULT_SPINNER_DELAY`. This is a *delay
before showing*, not a prediction: the widget starts a single-shot timer
when work begins and only becomes visible if the work is still running
when it fires, so a job that finishes at 1.9 s never puts a spinner on
screen at all. See :class:`spacr.qt.widgets.activity_spinner
.ActivitySpinner`.
Clamped on read: a hand-edited file must not be able to hide the
indicator for the length of a real job.
"""
try:
value = float(_settings().value(_KEY_SPINNER_DELAY,
DEFAULT_SPINNER_DELAY))
except (TypeError, ValueError):
return DEFAULT_SPINNER_DELAY
if value != value:
return DEFAULT_SPINNER_DELAY
return max(SPINNER_DELAY_MIN, min(SPINNER_DELAY_MAX, value))
[docs]
def set_spinner_delay(seconds: float) -> None:
"""Set the spinner's appearance delay, in seconds. Clamped, not
refused.
:param seconds: how long background work must run before the spinner shows;
clamped between :data:`SPINNER_DELAY_MIN` and
:data:`SPINNER_DELAY_MAX`, and an unparseable value or NaN stores
:data:`DEFAULT_SPINNER_DELAY`.
"""
try:
value = float(seconds)
except (TypeError, ValueError):
value = DEFAULT_SPINNER_DELAY
if value != value:
value = DEFAULT_SPINNER_DELAY
settings = _settings()
settings.setValue(_KEY_SPINNER_DELAY,
max(SPINNER_DELAY_MIN, min(SPINNER_DELAY_MAX, value)))
settings.sync()
#: Off by default. Every hover is text only until the reader presses the
#: **Animation** word in that tooltip's footer, and pressing it speaks for
#: that setting alone: 141 settings have an animation and each one is a
#: ~73 ms decoded movie, so neither a hover that only wanted the sentence nor
#: the 140 hovers after a press should pay for one. This preference is the
#: escape hatch for the reader who never wants to be asked.
DEFAULT_SETTING_ANIMATIONS = False
#: The two tooltip surfaces, BOTH ON by default.
#:
#: The box is today's behaviour -- `spacr.qt.widgets.hover_tooltip` has been
#: the setting tooltip for some time -- and the bottom strip is the nearest
#: thing to it, since the same strip already carries CATEGORY help on hover.
#: Defaulting either to off would take away help nobody asked to lose.
#:
#: BOTH OFF IS A LEGAL STATE AND IT COSTS SOMETHING. On several forms the
#: API link inside a setting's tooltip is the only route from that setting to
#: its documentation, so a reader who clears both has no way from the control
#: to the page describing it. The Preferences rows say so; see the tooltips
#: on the two switches.
#:
#: NEITHER TOUCHES THE CATEGORY STRIP, which answers a different question --
#: "what is this whole group of settings for".
#: THE BOX DEFAULTS OFF, AND THAT IS NOT A JUDGEMENT ABOUT THE BOX. The
#: popup box is redundant when the tooltip is shown at the bottom of the
#: window, so it appears by default only on screens with no strip. Both
#: surfaces are CHOOSABLE, and the switch does not reverse that default:
#: defaulting the box on would hand back a popup a user who reads the strip
#: does not need, and they would have to find a checkbox to undo it.
#:
#: So the box is one click away.
#: `tests/qt/test_setting_tooltip_footer.py::
#: test_hovering_a_real_setting_shows_no_tooltip_box` is the guard for the
#: default.
DEFAULT_TOOLTIPS_BOX = False
DEFAULT_TOOLTIPS_BOTTOM = True
#: Tooltips are ON by default, at the maintainer's instruction (2026-09-24).
#: They are how spaCR explains a button without spending a line of the
#: window on it; the switch exists for the person who already knows.
DEFAULT_TOOLTIPS_ENABLED = True
#: Seconds the pointer rests before a tooltip appears, out of the box.
_TOOLTIP_DELAY_DEFAULT = 2.0
_TOOLTIP_DELAY_MIN = 0.0
_TOOLTIP_DELAY_MAX = 10.0
def _clamped_tooltip_delay(value) -> float:
"""``value`` as seconds within the allowed range; the default if junk.
:param value: anything a store or a caller may hand over.
:returns: seconds between the minimum and the maximum.
"""
try:
seconds = float(value)
except (TypeError, ValueError):
return _TOOLTIP_DELAY_DEFAULT
if seconds != seconds:
return _TOOLTIP_DELAY_DEFAULT
return max(_TOOLTIP_DELAY_MIN, min(_TOOLTIP_DELAY_MAX, seconds))
def _get_tooltip_delay() -> float:
"""How long the pointer rests before a tooltip appears, in seconds.
Two seconds unless chosen otherwise, clamped to 0-10 on read so a
hand-edited store cannot make tooltips unreachable. Read by
:mod:`spacr.qt.tooltip_policy`, which caches it.
"""
return _clamped_tooltip_delay(
_settings().value(_KEY_TOOLTIP_DELAY, _TOOLTIP_DELAY_DEFAULT))
def _set_tooltip_delay(seconds) -> float:
"""Store the tooltip delay and apply it at once, everywhere.
Drops the tooltip policy's cached delay, so the next hover anywhere in
the application waits the new time.
:param seconds: the delay; clamped to 0-10, junk stores the default.
:returns: the value stored.
"""
value = _clamped_tooltip_delay(seconds)
settings = _settings()
settings.setValue(_KEY_TOOLTIP_DELAY, value)
settings.sync()
try:
from .tooltip_policy import invalidate_tooltip_policy
invalidate_tooltip_policy()
except Exception: # noqa: BLE001
LOG.debug("could not refresh the tooltip policy", exc_info=True)
return value
[docs]
def get_setting_animations_enabled() -> bool:
"""Whether setting tooltips show their animation WITHOUT being asked.
Default ``False``: a hover is text only, no GIF is decoded, no frames are
cached and no timer runs, and the teal **Animation** word in the footer is
the invitation to see one — for that setting, once. Turning this on starts
every tooltip revealed instead, and the word then folds the one in front
of the reader away. The meaning is "stop asking me", not "allow
animations".
The two cannot disagree and neither needs to defer to the other, because
they are scoped differently: a press names exactly one setting, so it can
never stop this preference reaching the rest. See
:meth:`spacr.qt.widgets.hover_tooltip.HoverTooltip.animations_shown`.
Read on every tooltip, not once at startup:
:class:`spacr.qt.widgets.hover_tooltip.HoverTooltip` is a process-wide
singleton that outlives the Preferences dialog, so caching this would
keep animating until the app was restarted.
"""
return _as_bool(_settings().value(_KEY_SETTING_ANIMATIONS,
DEFAULT_SETTING_ANIMATIONS),
DEFAULT_SETTING_ANIMATIONS)
[docs]
def set_setting_animations_enabled(on: bool) -> None:
"""Turn the animation inside setting tooltips on or off.
Flushed immediately so the very next hover honours it — see
:func:`get_setting_animations_enabled` for why nothing caches it.
:param on: true to turn it on, false to turn it off; stored as a ``bool``.
"""
settings = _settings()
settings.setValue(_KEY_SETTING_ANIMATIONS, bool(on))
settings.sync()
#: Where the first-run layout decision is recorded.
#:
#: PRIVATE, deliberately, though the rest of this module's accessors are
#: public. Each has exactly one caller -- `app._open_at_the_measured_width`
#: -- and a public pair would put two more symbols on the documented API
#: surface, which means another nine-language catalog pass for a record
#: nothing outside this module reads.
#:
#: RESOLVED ONCE AND RECORDED WITH ITS EVIDENCE, rather than recomputed on
#: every launch. The value is a
#: JSON object holding the width chosen AND the measurements that justified
#: it, so a later launch can tell whether the answer still applies rather
#: than re-deriving it and hoping it matches.
_KEY_LAYOUT_DECISION = "layout/first_run_decision"
def _get_layout_decision() -> dict:
"""The recorded first-run layout decision, or ``{}``.
:returns: the stored object, or an empty dict when nothing has been
recorded or what is stored cannot be read.
NEVER RAISES AND NEVER RETURNS A PARTIAL RECORD. A decision that cannot
be parsed is the same as no decision: the caller re-derives one. A
half-read record is worse than none, because it would be compared
against current metrics and could match by accident.
"""
import json
try:
raw = _settings().value(_KEY_LAYOUT_DECISION, "")
if not raw:
return {}
found = json.loads(raw)
except (TypeError, ValueError):
return {}
if not isinstance(found, dict):
return {}
required = {"width", "available", "font_scale"}
return found if required <= set(found) else {}
def _set_layout_decision(record: dict) -> None:
"""Record the first-run layout decision and the metrics behind it.
:param record: what was chosen and what it was chosen from.
Storing is best-effort: a launch is not worth failing over a
preference that only makes the NEXT launch cheaper.
"""
import json
try:
_settings().setValue(_KEY_LAYOUT_DECISION, json.dumps(record))
except (TypeError, ValueError):
return
[docs]
def get_font_scale() -> float:
"""Return the saved UI font scale, clamped to supported bounds."""
try:
raw = float(_settings().value(_KEY_FONT_SCALE,
DEFAULT_FONT_SCALE))
except (TypeError, ValueError):
raw = DEFAULT_FONT_SCALE
return max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, raw))
[docs]
def set_font_scale(scale: float) -> None:
"""Persist a UI font scale after clamping it to supported bounds.
:param scale: the UI font scale, 1.0 for the designed size; converted to
``float`` and clamped between :data:`FONT_SCALE_MIN` and
:data:`FONT_SCALE_MAX`.
"""
scale = float(scale)
scale = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, scale))
_settings().setValue(_KEY_FONT_SCALE, scale)
#: The text size of a module screen's right-hand column (item 529), as a
#: multiple of the size the rest of the interface has. Ctrl + wheel over the
#: column moves it; nothing outside the column follows it.
RUNTIME_TEXT_SCALE_MIN = 0.60
RUNTIME_TEXT_SCALE_MAX = 2.00
DEFAULT_RUNTIME_TEXT_SCALE = 1.0
[docs]
def get_runtime_text_scale() -> float:
"""The right-hand column's text size, clamped to its bounds.
One value for every module screen, so the console reads the same size
wherever the user goes; see :mod:`spacr.qt.live_zoom`.
"""
try:
raw = float(_settings().value(_KEY_RUNTIME_TEXT_SCALE,
DEFAULT_RUNTIME_TEXT_SCALE))
except (TypeError, ValueError):
raw = DEFAULT_RUNTIME_TEXT_SCALE
return max(RUNTIME_TEXT_SCALE_MIN, min(RUNTIME_TEXT_SCALE_MAX, raw))
[docs]
def set_runtime_text_scale(scale: float) -> float:
"""Persist the right-hand column's text size, clamped to its bounds.
:param scale: 1.0 for the size the rest of the interface has.
:returns: the value stored.
"""
scale = round(max(RUNTIME_TEXT_SCALE_MIN,
min(RUNTIME_TEXT_SCALE_MAX, float(scale))), 4)
_settings().setValue(_KEY_RUNTIME_TEXT_SCALE, scale)
return scale
def _home_aside_scale(key: str, low: float, high: float) -> float:
"""A Home right-column size stored under ``key``, clamped to its bounds.
:param key: the preference key.
:param low: the smallest factor allowed.
:param high: the largest factor allowed.
:returns: the factor, 1.0 when nothing usable is stored.
"""
try:
raw = float(_settings().value(key, 1.0))
except (TypeError, ValueError):
raw = 1.0
if raw != raw:
raw = 1.0
return max(low, min(high, raw))
def _set_home_aside_scale(key: str, scale: float, low: float,
high: float) -> float:
"""Store a Home right-column size under ``key``, clamped to its bounds.
:param key: the preference key.
:param scale: the factor, 1.0 for the designed size.
:param low: the smallest factor allowed.
:param high: the largest factor allowed.
:returns: the value stored.
"""
scale = round(max(low, min(high, float(scale))), 4)
_settings().setValue(key, scale)
return scale
[docs]
def get_dock_width() -> int:
"""The width the user dragged the dock to, or 0 for its fitting width.
Stored in logical pixels; the dock clamps it to its drag
bounds when it applies it, see :meth:`spacr.qt.widgets.dock.Dock.column_width`.
"""
try:
return max(0, int(float(_settings().value(_KEY_DOCK_WIDTH, 0) or 0)))
except (TypeError, ValueError):
return 0
[docs]
def set_dock_width(width: int) -> None:
"""Persist the dock's dragged width; 0 goes back to the fitting width.
:param width: logical pixels.
"""
try:
width = max(0, int(width))
except (TypeError, ValueError):
width = 0
_settings().setValue(_KEY_DOCK_WIDTH, width)
[docs]
def get_gui_scale() -> float:
"""Return the saved whole-GUI scale, clamped to supported bounds.
Applied at startup by :func:`spacr.qt.gui_scale.apply_saved_gui_scale`
and live by :func:`spacr.qt.gui_scale.set_gui_scale_live`.
"""
try:
raw = float(_settings().value(_KEY_GUI_SCALE, DEFAULT_GUI_SCALE))
except (TypeError, ValueError):
raw = DEFAULT_GUI_SCALE
return max(GUI_SCALE_MIN, min(GUI_SCALE_MAX, raw))
[docs]
def set_gui_scale(scale: float) -> None:
"""Persist a whole-GUI scale after clamping it to supported bounds.
:param scale: the factor, 1.0 = 100 %. Only stored; drawing it is
:func:`spacr.qt.gui_scale.set_gui_scale_live`'s job.
"""
scale = max(GUI_SCALE_MIN, min(GUI_SCALE_MAX, float(scale)))
_settings().setValue(_KEY_GUI_SCALE, scale)
#: How the auto-issue reporter behaves when something goes wrong.
#:
#: Three states, not two. "prompt me" and "never prompt me" leave out the
#: user who wants a report filed and does not want to be asked, and the
#: moment someone wants this off is the moment it has just interrupted them.
#:
#: 'always' files a redacted report to the public tracker as soon as a run
#: fails, with no preview. 'ask' opens the report in a preview and sends it
#: only on Send. 'never' files nothing.
ISSUE_PROMPT_ASK = "ask"
ISSUE_PROMPT_NEVER = "never"
ISSUE_PROMPT_ALWAYS = "always"
ISSUE_PROMPT_MODES = (ISSUE_PROMPT_ASK, ISSUE_PROMPT_NEVER,
ISSUE_PROMPT_ALWAYS)
_KEY_ISSUE_PROMPT = "ai/issue_prompt"
#: Written beside the mode by :func:`set_issue_prompt_mode`, so a value
#: stored by a build that knew the three modes apart can be told from one an
#: older build wrote on the user's behalf.
#:
#: IT IS NOT DECORATION, AND WITHOUT IT THE DECISION BELOW REACHED ALMOST
#: NOBODY. `SetupSlides.accept()` and `reject()` both call
#: `setup_screen.apply(self.answers())`, and `issue_prompt` has been one of
#: those answers since 6c57da8d6 (2026-08-21, shipped in 1.5.0.5). So every
#: profile that ever opened first-run setup — including one dismissed at the
#: first slide — has `'ask'` written into it, chosen by the default of the
#: day rather than by the user. Read as "an explicit earlier choice", the
#: new default would have applied to brand-new profiles only, and the
#: reporter of issue #117 would still have been on 'ask' after upgrading.
_KEY_ISSUE_PROMPT_CHOSEN = "ai/issue_prompt_chosen"
#: The default before 2026-09-19, and so the value an unmarked profile
#: holds when nobody chose it. 'never' and 'always' are never written by
#: accident: both took an answer, so both are kept as they stand.
_SUPERSEDED_ISSUE_PROMPT_MODE = ISSUE_PROMPT_ASK
#: The mode of a profile that has never chosen one.
#:
#: Automatic filing is the default, and a user agrees to it in the user
#: agreement when the mode is 'always'. The agreement is Section 5.6 of
#: :data:`spacr.qt.terms.TERMS`, which every profile is asked to accept
#: again (4.1 -> 4.2) and which nothing is filed without. A stored choice is
#: kept.
DEFAULT_ISSUE_PROMPT_MODE = ISSUE_PROMPT_ALWAYS
#: Whether the AI assistant is on when spaCR opens (248).
#:
#: The setup card's three launch toggles ship ON together. The card still
#: records an explicit opt-out, so a user who turns the assistant off stays
#: opted out when the shipped default changes.
#:
#: A stored value that is not recognised reads as OFF for the same reason a
#: bad `issue_prompt` reads as 'ask': the failure has to fall on the quiet
#: side.
_KEY_AI_DEFAULT_ON = "ai/on_by_default"
#: The untouched-profile value required by the three-toggle setup contract.
DEFAULT_AI_ON_AT_LAUNCH = True
[docs]
def get_ai_on_by_default() -> bool:
"""Is the assistant on when spaCR opens?
:returns: :data:`DEFAULT_AI_ON_AT_LAUNCH` unless the user has said
otherwise. An explicit choice is always written, so an opt-out
survives a change to the default rather than being overwritten
by it.
"""
raw = _settings().value(_KEY_AI_DEFAULT_ON, DEFAULT_AI_ON_AT_LAUNCH)
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("1", "true", "yes", "on")
[docs]
def set_ai_on_by_default(enabled: bool) -> None:
"""Persist whether the assistant starts enabled.
:param enabled: True to have it on at launch.
"""
_settings().setValue(_KEY_AI_DEFAULT_ON, bool(enabled))
[docs]
def get_issue_prompt_mode() -> str:
"""How to behave when a report could be filed.
:returns: one of :data:`ISSUE_PROMPT_MODES`.
:data:`DEFAULT_ISSUE_PROMPT_MODE` (``'always'``) when nothing is
stored, and also when the only thing stored is the superseded
default ``'ask'`` written by a build that did not mark what the user
had chosen (:data:`_KEY_ISSUE_PROMPT_CHOSEN`). A choice this build or
a later one wrote is returned as it stands, 'ask' included. A stored
value that is not recognised reads as ``'ask'``: it was somebody's
choice, even if this build cannot read it, so it must neither
silence the reporter nor start publishing without a preview.
"""
store = _settings()
if not store.contains(_KEY_ISSUE_PROMPT):
return DEFAULT_ISSUE_PROMPT_MODE
value = str(store.value(_KEY_ISSUE_PROMPT, ISSUE_PROMPT_ASK) or "")
if value not in ISSUE_PROMPT_MODES:
return ISSUE_PROMPT_ASK
if (value == _SUPERSEDED_ISSUE_PROMPT_MODE
and not store.contains(_KEY_ISSUE_PROMPT_CHOSEN)):
return DEFAULT_ISSUE_PROMPT_MODE
return value
[docs]
def set_issue_prompt_mode(mode: str) -> None:
"""Persist the auto-issue behaviour, and that it was chosen.
The marker is what makes a later 'ask' stick: from here on, 'ask' in the
store is an answer somebody gave, not the default of the day written
into every profile that opened first-run setup. Every writer goes
through this function — the setup slides, the Preferences dialog, the
AI Console and the installer's consent page — so all four count as
choosing.
:param mode: one of :data:`ISSUE_PROMPT_MODES`.
:raises ValueError: for anything else. Silently storing an unknown mode
would read back as 'ask' and look like the setting was ignored.
"""
mode = str(mode)
if mode not in ISSUE_PROMPT_MODES:
raise ValueError(
f"issue prompt mode {mode!r} is not one of "
f"{list(ISSUE_PROMPT_MODES)}.")
store = _settings()
store.setValue(_KEY_ISSUE_PROMPT, mode)
store.setValue(_KEY_ISSUE_PROMPT_CHOSEN, True)
[docs]
def scaled_px(base_px: int) -> int:
"""Return ``base_px`` scaled by the current user font scale.
Widget sizes set from Python (``setMinimumWidth`` etc.) don't grow
when the stylesheet's font size grows, so any control tuned to
match a text width goes wrong at large font scales. Route those
calls through this helper so they track the preference.
Rounds to the nearest int; caps to at least 1 px so a very small
scale doesn't collapse things to zero.
:param base_px: a size in pixels as designed for a font scale of 1.0.
"""
return max(1, int(round(base_px * get_font_scale())))
#: Dynamic property carrying an icon's width at 100 %, in logical pixels.
#:
#: A Qt property rather than a Python attribute for two reasons: it lives on
#: the C++ side, so it survives the wrapper being collected and rebuilt, and
#: a plain ``QPushButton`` can carry it without anyone subclassing Qt to
#: give it somewhere to put the number.
_KEY_ICON_BASE_W = "spacrIconBaseWidth"
#: The same for the icon's height. Two integer properties rather than one
#: pair, because Qt stores an int natively while a tuple crosses the
#: boundary as an opaque Python object that only PySide can read back.
_KEY_ICON_BASE_H = "spacrIconBaseHeight"
#: The method a widget may implement to re-derive its own icon geometry.
#:
#: Found by duck-typing, the way ``refresh_theme`` is. It exists for the
#: widgets whose icon size is not the only thing that has to move with it --
#: a tile whose hover animation has a resting size to return to, a square
#: button whose frame is drawn around the mark -- and it is handed the scale
#: so it never has to ask twice and get a different answer.
_ICON_SCALE_HOOK = "_apply_icon_scale"
#: Qt's own small-icon default, in logical pixels before the scale.
#:
#: A button that is given an icon and no size gets this from the style, and
#: the style's copy of it does NOT follow the font scale -- which is how a
#: handful of ghost buttons stayed 16 px wide while the interface around
#: them doubled. Naming it lets those buttons be registered at the size
#: they already draw at, so they start tracking the scale without changing
#: what they look like at 100 %.
_SMALL_ICON_PX = 16
def _scaled_side(base_px: int, scale: float) -> int:
"""Return ``base_px`` at ``scale``, by the rule :func:`scaled_px` uses.
Split out so a caller that already knows the scale -- the sweep below
resizes hundreds of widgets from one reading -- does not re-read the
setting once per widget, and so the two can never round differently.
:param base_px: the size at 100 %.
:param scale: the multiplier to apply.
:returns: the scaled size, never below 1 px.
"""
return max(1, int(round(base_px * scale)))
def _set_scaled_icon_size(widget, base_w: int, base_h=None, scale=None):
"""Size a widget's icon from a base, and remember that base.
THE BASE IS WHAT MAKES THE GESTURE REVERSIBLE. An icon resized from
the size it is already wearing compounds its rounding: twenty notches
of ``round(px * 1.05)`` and twenty back do not return a 20 px icon to
20 px. Every size this sets is computed from the number stored here,
which is the size at 100 % and never changes, so the scale alone
decides the answer and the round trip is exact by construction.
Idempotent, and cheap to call again: assigning an icon size a widget
already has still invalidates its layout, so the assignment is skipped
when nothing would move.
:param widget: anything with ``setIconSize`` -- a button, a list view.
:param base_w: the icon's width at 100 %, in logical pixels.
:param base_h: its height at 100 %; square when omitted.
:param scale: the scale to apply; the stored preference when omitted.
:returns: the :class:`~PySide6.QtCore.QSize` now on the widget.
"""
from PySide6.QtCore import QSize
base_w = max(1, int(base_w))
base_h = base_w if base_h is None else max(1, int(base_h))
if scale is None:
scale = get_font_scale()
widget.setProperty(_KEY_ICON_BASE_W, base_w)
widget.setProperty(_KEY_ICON_BASE_H, base_h)
size = QSize(_scaled_side(base_w, scale), _scaled_side(base_h, scale))
if widget.iconSize() != size:
widget.setIconSize(size)
return size
def _rescale_icon_sizes(app=None) -> int:
"""Re-derive every remembered icon size at the scale now in force.
WHY THIS EXISTS. An icon size is a widget PROPERTY, set once when the
widget is built. Nothing in a stylesheet reaches it, so a font scale
that grew every caption left every glyph beside those captions exactly
where it was: the text followed the wheel of the hold-Z zoom and the
marks beside it did not.
WHERE IT RUNS, AND WHY THERE. In
:func:`apply_preferences_to_app`, which is the one step both routes to
a new scale already take: the Preferences slider on its way out of the
dialog, and :meth:`spacr.qt.live_zoom.LiveZoomFilter.settle` when the
wheel goes quiet. That is the deliberate half of the gesture -- the
one that rebuilds the stylesheet -- so the icons catch up with the
spacing, in the same step, rather than stuttering alongside the text.
Widgets built AFTER a scale change need none of this: they size
themselves through :func:`scaled_px` and :func:`_set_scaled_icon_size`,
both of which read the scale at construction.
:param app: optional QApplication; falls back to the running instance.
:returns: how many widgets had their icon geometry re-derived.
"""
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QApplication
app = app or QApplication.instance()
if app is None:
return 0
scale = get_font_scale()
moved = 0
for widget in app.allWidgets():
try:
hook = getattr(widget, _ICON_SCALE_HOOK, None)
if callable(hook):
hook(scale)
moved += 1
continue
base_w = widget.property(_KEY_ICON_BASE_W)
if base_w is None:
continue
base_h = widget.property(_KEY_ICON_BASE_H) or base_w
size = QSize(_scaled_side(int(base_w), scale),
_scaled_side(int(base_h), scale))
if widget.iconSize() != size:
widget.setIconSize(size)
moved += 1
except (RuntimeError, AttributeError, TypeError, ValueError):
continue
return moved
#: ``"auto"`` the 6 px hot strip on the left edge reveals the app list
#: on dwell and hides it again.
#: ``"locked"`` the app list is a real column in the window layout: it
#: never slides, never covers the page, and never has to be
#: summoned. This is the default; users with narrower screens
#: can switch to hover reveal or hide it completely.
#: ``"hidden"`` no strip, no reveal, no column. Apps stay reachable from
#: the spaCR menu, Ctrl+1..9 and the command palette — a
#: dock you cannot summon must not be a dead end.
VALID_DOCK_MODES = ("locked", "hidden")
#: HIDDEN UNTIL ASKED FOR. The dock is a permanent 220 px column, and every
#: app in it is already reachable from the spaCR menu, Ctrl+1..9 and Ctrl+K --
#: so a first run spends that width on navigation nobody has asked for yet.
#: The "All apps" action carries a tooltip saying where to turn it on.
DEFAULT_DOCK_MODE = "hidden"
#: Withdrawn: the dock used to slide in over the page when the pointer rested
#: against the left edge. It overlaid the home screen -- the module tiles sat
#: underneath it and did not move aside -- and it drew a second container
#: behind the dock's own panel. A stored ``auto`` reads as ``locked`` rather
#: than being refused, so an existing settings file keeps working and gets the
#: column it was already half-asking for.
#:
#: NOT the new default. ``auto`` was somebody CHOOSING to have a dock, and the
#: default changing underneath them is not a reason to take theirs away.
RETIRED_DOCK_MODES = {"auto": "locked"}
[docs]
def get_dock_mode() -> str:
"""How the left app dock behaves — one of :data:`VALID_DOCK_MODES`.
A withdrawn mode is MIGRATED rather than rejected; see
:data:`RETIRED_DOCK_MODES`.
"""
raw = str(_settings().value(_KEY_DOCK_MODE, DEFAULT_DOCK_MODE))
raw = RETIRED_DOCK_MODES.get(raw, raw)
return raw if raw in VALID_DOCK_MODES else DEFAULT_DOCK_MODE
[docs]
def set_dock_mode(mode: str) -> None:
"""Persist a valid left-navigation dock mode.
A withdrawn mode is accepted and stored as its replacement, so code that
still names one is migrated rather than made to raise.
:param mode: one of :data:`VALID_DOCK_MODES`, or a retired mode from
:data:`RETIRED_DOCK_MODES`, which is stored as its replacement;
anything else raises :class:`ValueError`.
"""
mode = RETIRED_DOCK_MODES.get(mode, mode)
if mode not in VALID_DOCK_MODES:
raise ValueError(f"unknown dock mode {mode!r}. "
f"Choose from {VALID_DOCK_MODES}.")
_settings().setValue(_KEY_DOCK_MODE, mode)
#: 0 = the box is not painted at all, 100 = solid. Stored as a percent
#: because that is what the slider shows and what a user reading the INI
#: would expect to find.
#:
#: **This is a request, not the final alpha.** It is clamped up to
#: :func:`spacr.qt.theme.pane_alpha_floor` before anything is painted, so
#: dragging it to zero on the Space theme thins the panel to the point
#: where the tile names still clear WCAG AA over the brightest star the
#: sky can put behind them, and no further. See
#: :func:`spacr.qt.theme.pane_alpha` for why the solver's *upper* bound
#: is deliberately not applied to it.
DEFAULT_PANE_OPACITY_PCT = 60
[docs]
def get_pane_opacity() -> float:
"""The user's requested page-panel opacity, 0.0-1.0.
Un-clamped: the floor belongs to the theme, which is the only thing
that knows what is behind the panel. Callers want
:func:`spacr.qt.theme.pane_alpha`, which applies it.
"""
try:
raw = int(_settings().value(_KEY_PANE_OPACITY,
DEFAULT_PANE_OPACITY_PCT))
except (TypeError, ValueError):
raw = DEFAULT_PANE_OPACITY_PCT
return max(0, min(100, raw)) / 100.0
[docs]
def set_pane_opacity(fraction: float) -> None:
"""Store the requested opacity. Accepts 0.0-1.0; clamped, then rounded.
:param fraction: the opacity from 0.0 to 1.0, stored as a whole percentage;
an unparseable value stores :data:`DEFAULT_PANE_OPACITY_PCT`.
"""
try:
value = float(fraction)
except (TypeError, ValueError):
value = DEFAULT_PANE_OPACITY_PCT / 100.0
_settings().setValue(_KEY_PANE_OPACITY,
int(round(max(0.0, min(1.0, value)) * 100)))
[docs]
def effective_pane_alpha() -> float:
"""The opacity a user-controlled page surface is painted at.
The user's request put through :func:`spacr.qt.theme.pane_alpha`.
One call so the Home page and any test asking "what will it look
like" get the same number.
"""
from .theme import pane_alpha
return pane_alpha(resolve_effective_theme(), get_pane_opacity())
#: On by default. The fade is what makes a form of value boxes read as
#: values rather than as a wall of boxes, and it is the shipped look;
#: the preference exists because an effect that touches every input in
#: the app has to be refusable.
#: On, and it is not free. Hashing every file under every path-valued
#: setting is proportional to the DATA and not to the run: on a plate of raw
#: images it is minutes of reading before the first mask is made, and it
#: happens whether or not anybody ever compares the digests. It ships on
#: anyway, because a result that cannot be traced back to its inputs is
#: worth less than the minutes, and the cost is refusable in one click.
#: The manifest is written either way and SAYS which it was, so the record
#: is never ambiguous.
DEFAULT_HASH_INPUTS = True
_KEY_HASH_INPUTS = "prefs/hash_inputs"
DEFAULT_FIELD_FADE = True
[docs]
def get_field_fade_enabled() -> bool:
"""Whether input fields dissolve towards their right edge.
``True`` (the default) means every line edit, combo box and spin box
paints its container and outline through
:func:`spacr.qt.theme.field_fade_alpha` — solid where the value
starts, gone at the right edge — and is **exempt** from
``pane_opacity``. The text inside is never faded.
``False`` restores the flat opaque input styling exactly:
:func:`spacr.qt.widgets.field_fade.field_fade_qss` emits nothing, so
the built-in rules in :func:`spacr.qt.theme.stylesheet` are the only
thing that styles a field, and the paint hook returns immediately.
"""
return _as_bool(_settings().value(_KEY_FIELD_FADE, DEFAULT_FIELD_FADE),
DEFAULT_FIELD_FADE)
[docs]
def set_field_fade_enabled(on: bool) -> None:
"""Turn the field fade on or off.
Flushed immediately and the paint hook's cache dropped, so the very
next repaint honours it. Re-applying the stylesheet
(:func:`apply_preferences_to_app`) is what makes it land on fields
that are already on screen.
:param on: true to turn it on, false to turn it off; stored as a ``bool``.
"""
settings = _settings()
settings.setValue(_KEY_FIELD_FADE, bool(on))
settings.sync()
try:
from .widgets.field_fade import invalidate_field_fade
invalidate_field_fade()
except Exception:
pass
[docs]
def get_color_blind_mode() -> str:
"""Return the active colour-vision mode, falling back to ``off``."""
raw = str(_settings().value(_KEY_CB_MODE, DEFAULT_CB_MODE))
return raw if raw in VALID_CB_MODES else DEFAULT_CB_MODE
[docs]
def set_color_blind_mode(mode: str) -> None:
"""Persist a supported colour-vision mode.
:param mode: one of :data:`VALID_CB_MODES`; any other value raises
:class:`ValueError`.
"""
if mode not in VALID_CB_MODES:
raise ValueError(f"unknown CB mode {mode!r}. "
f"Choose from {VALID_CB_MODES}.")
_settings().setValue(_KEY_CB_MODE, mode)
#: The stored colour-vision mode -> the display-primaries mode
#: :func:`spacr.crops.apply_display_primaries` takes.
#:
#: TWO VOCABULARIES, ON PURPOSE. This preference names a CONDITION, because
#: that is what a user knows about themselves and what they pick in
#: Preferences: "I have deuteranopia". :data:`spacr.crops.DISPLAY_PRIMARIES`
#: names a RENDERING: "draw this for a deuteranope". They are one fact seen
#: from its two ends, and renaming either would break stored settings for no
#: gain, so the bridge is written down once here rather than guessed at every
#: call site.
#:
#: ``cmy`` is deliberately absent. It is a PUBLISHING convention, not an
#: accessibility mode -- measured against a deuteranope simulation it is
#: WORSE than plain RGB -- so it must never be reached by having a
#: deficiency. It is chosen per view, by somebody making a figure.
_CB_MODE_TO_PRIMARIES = {
"off": "rgb",
"deuteranopia": "deuteranope",
"protanopia": "protanope",
"tritanopia": "tritanope",
}
[docs]
def image_display_primaries() -> str:
"""How images should be drawn for this user, everywhere.
The global half of the colour-blind mode. A user who needs the
substitution needs it in Annotate, in every live view and in every crop
grid, in every session -- not as a toggle they re-find on each screen.
A view may still override it, because a figure being prepared for
publication wants ``cmy`` whatever the author's vision, but this is
what every view starts from.
:returns: one of :data:`spacr.crops.DISPLAY_PRIMARIES`.
"""
return _CB_MODE_TO_PRIMARIES.get(get_color_blind_mode(), "rgb")
[docs]
def color_blind_categorical_palette() -> list:
"""Return a list of hex colours safe for the active CB mode.
Uses the Okabe-Ito categorical palette whenever colour-blind mode is
enabled.
"""
if get_color_blind_mode() == "off":
return ["#4A9EFF", "#3fb950", "#f0883e", "#a78bfa",
"#f85149", "#e879f9", "#22d3ee", "#facc15"]
return ["#0072B2", "#E69F00", "#009E73", "#F0E442",
"#56B4E9", "#D55E00", "#CC79A7", "#000000"]
def _level_names(levels) -> str:
"""Render a set of logging levels as their names.
:param levels: the level numbers.
:returns: a sorted, comma-separated list -- sorted so the same set
always writes the same string, which is what makes it comparable.
"""
return ",".join(logging.getLevelName(level) for level in sorted(levels))
def _parse_levels(raw, fallback) -> frozenset:
"""Read a stored ``"INFO,WARNING"`` string back into level numbers."""
from ..logging_util import normalise_levels
if raw is None or raw == "":
return frozenset(fallback)
if isinstance(raw, (list, tuple)):
text = ",".join(str(item) for item in raw)
else:
text = str(raw)
found = set()
for token in text.split(","):
token = token.strip().upper()
if not token:
continue
value = logging.getLevelName(token)
if isinstance(value, int):
found.add(value)
return normalise_levels(found) or frozenset(fallback)
[docs]
def get_log_file_levels() -> frozenset:
"""Levels written to the log files. The master switch of the pair.
:returns: the levels the file handler admits.
VERBOSE LOGGING ADDS DEBUG, because otherwise the two settings
contradict each other and the one the user did not touch wins.
With verbose on, spaCR's loggers emit DEBUG records. Omitting DEBUG from
the file handler would build every one of those records and then discard
it.
Whatever verbose means, it cannot mean "do the work and write none of
it". It is not stored into the level preference: the user's own choice
of levels is left exactly as they set it, and DEBUG goes away again
when they turn verbose off.
"""
return _with_verbose_debug(_chosen_log_file_levels())
def _chosen_log_file_levels() -> frozenset:
"""The file levels the user switched on, without verbose's DEBUG.
:returns: the stored switch set, or the defaults when none is stored.
"""
from ..logging_util import DEFAULT_FILE_LEVELS
return frozenset(_parse_levels(
_settings().value(_KEY_LOG_FILE_LEVELS, None), DEFAULT_FILE_LEVELS))
def _with_verbose_debug(levels) -> frozenset:
"""Add DEBUG to ``levels`` while verbose logging is on.
:param levels: a set of file levels as the user chose them.
:returns: the levels the log files actually keep.
"""
if get_verbose_logging():
return frozenset(levels) | {logging.DEBUG}
return frozenset(levels)
[docs]
def get_log_console_levels() -> frozenset:
"""Levels echoed to the in-app console, always a subset of the files.
Clamped on read as well as on write: the stored value can predate a
change to the file switches made by a different code path, and a
console line with no matching entry in the log file is exactly what
the subset rule exists to prevent.
"""
from ..logging_util import DEFAULT_CONSOLE_LEVELS, clamp_console_to_file
stored = _parse_levels(_settings().value(_KEY_LOG_CONSOLE_LEVELS, None),
DEFAULT_CONSOLE_LEVELS)
return clamp_console_to_file(stored, get_log_file_levels())
[docs]
def set_log_levels(file_levels, console_levels) -> tuple:
"""Persist both switch sets, then apply them to the live handlers.
:param file_levels: the ``logging`` level numbers the log files record;
anything other than DEBUG, INFO, WARNING, ERROR and CRITICAL is
discarded.
:param console_levels: the ``logging`` level numbers shown on the console;
a level the log files do not keep is dropped.
:returns: ``(file_levels, console_levels)`` as actually stored, which
is not necessarily what was asked for -- a console level whose file
level is off is dropped rather than saved and silently ignored.
``file_levels`` is the user's own choice, and it is stored as given.
While verbose logging is on, the log files keep DEBUG as well, so a
console DEBUG switch is kept and the live handlers are given DEBUG. The
DEBUG that verbose adds is not written into the stored file levels.
"""
from ..logging_util import (apply_level_policy, clamp_console_to_file,
normalise_levels)
files = normalise_levels(file_levels)
kept = _with_verbose_debug(files)
console = clamp_console_to_file(console_levels, kept)
settings = _settings()
settings.setValue(_KEY_LOG_FILE_LEVELS, _level_names(files))
settings.setValue(_KEY_LOG_CONSOLE_LEVELS, _level_names(console))
apply_level_policy(kept, console)
return files, console
#: Verbose diagnostic logging is ON unless the user turns it off.
#:
#: It used to be off because the preference installed the interpreter-wide
#: function tracer: offscreen, a usable Home took 3.05 s with verbose off and
#: 65.28 s with it on. The preference no longer installs the tracer; it only
#: raises log levels and adds no work to an ordinary call.
#:
#: Benchmarked with ``tools/spacr_startup_benchmark.py``'s workers,
#: offscreen. Every one of the 45 registered modules was opened, in
#: a cold and a warm process per arm, and the arms were run off, on, on, off:
#:
#: Home, cold on 4.00 / 4.07 s off 4.04 / 4.18 s budget 5 s
#: Home, warm on 2.07 / 1.95 s off 2.01 / 1.92 s
#: slowest module on 7.08-7.26 s off 7.02-7.54 s budget 10 s
#: peak RSS on 1,165-1,177 MB off 1,164-1,174 MB
#:
#: Every difference is inside the spread between two runs of the same arm.
#: The 500 ms event-loop stall ceiling is breached by both arms alike, and
#: verbose logging does not move it.
#:
#: The tracer is still there for developers, as
#: :func:`spacr.logging_util.enable_function_trace`, and it is not cheap:
#: with it installed, Home took 7.4 s and the next screen did not open
#: within the benchmark's 10 s hang guard.
DEFAULT_VERBOSE_LOGGING = True
#: Process-tree accounting is cheap enough to leave on. It samples once a
#: second and installs no Python profile hook.
PERFORMANCE_LOGGING_LEVELS = ("off", "summary", "detailed")
DEFAULT_PERFORMANCE_LOGGING = "summary"
[docs]
def get_verbose_logging() -> bool:
"""Return True when the user has opted into the verbose diagnostic
logger. Toggled via the Preferences dialog; consulted at startup by
:func:`apply_preferences_to_app`.
The wording of that first paragraph is deliberate: a reviewed Korean
translation of it is held in `docs/i18n/reviewed/api`, and the
localisation audit refuses a source block that no longer matches what
was reviewed. Changing it discards a human translation, so it is left
exactly as it was and anything new goes below.
Defaults to :data:`DEFAULT_VERBOSE_LOGGING`, which is on. Opening every
module took the same time with it on as with it off, measured.
"""
raw = _settings().value(_KEY_VERBOSE_LOG, DEFAULT_VERBOSE_LOGGING)
if isinstance(raw, str):
return raw.lower() in ("true", "1", "yes", "on")
return bool(raw)
[docs]
def set_verbose_logging(on: bool) -> None:
"""Persist whether package-wide diagnostic tracing is enabled.
:param on: true to turn it on, false to turn it off; stored as a ``bool``.
"""
_settings().setValue(_KEY_VERBOSE_LOG, bool(on))
#: On, and only safe to be. A report no longer carries log lines in its
#: body: the bundle is written to a local path and the body NAMES that
#: path, so a public issue can never carry a user's logs. The preference
#: governs whether that bundle is prepared at all, every report still
#: stops at an editable preview, and nothing is sent without its own Send.
DEFAULT_SHARE_DIAGNOSTIC_LOGS = True
[docs]
def get_share_diagnostic_logs() -> bool:
"""Whether an error report saves a redacted copy of the recent log.
The copy is written to a file on this computer and the report names
that file. The log is never posted to GitHub. Whether a report is sent
at all is :func:`get_issue_prompt_mode`.
"""
return _as_bool(_settings().value(_KEY_SHARE_DIAGNOSTICS,
DEFAULT_SHARE_DIAGNOSTIC_LOGS),
DEFAULT_SHARE_DIAGNOSTIC_LOGS)
[docs]
def set_share_diagnostic_logs(on: bool) -> None:
"""Persist the revocable diagnostic-log preview opt-in.
:param on: true to let an error report save a redacted copy of the recent
log, false not to; stored as a ``bool``.
"""
_settings().setValue(_KEY_SHARE_DIAGNOSTICS, bool(on))
#: On, because "the news section should always automatically reflect the
#: latest spaCR release news" (2026-09-24) and a wheel's bundled notes stop
#: at the release before its own. Nothing is sent: the request is a GET of
#: the public releases list, unauthenticated, at most once a day, on a
#: worker thread, after Home has been drawn. Off leaves the panel exactly
#: as it was -- the bundled resource, and no socket.
DEFAULT_REFRESH_NEWS = True
[docs]
def get_refresh_news() -> bool:
"""Whether Home's News panel may ask GitHub for newer releases.
Read by :meth:`spacr.qt.app.MainWindow._refresh_news`, which is the one
place that starts the fetch. The bundled
``spacr/resources/release_notes.json`` is drawn either way.
"""
return _as_bool(_settings().value(_KEY_REFRESH_NEWS,
DEFAULT_REFRESH_NEWS),
DEFAULT_REFRESH_NEWS)
[docs]
def set_refresh_news(on: bool) -> None:
"""Persist the News panel's release-refresh opt-out.
:param on: true to let the News panel ask GitHub for newer releases, false
to opt out; stored as a ``bool``.
"""
_settings().setValue(_KEY_REFRESH_NEWS, bool(on))
def _get_update_channel() -> str:
"""The release channel Help → Check for updates offers: stable or nightly."""
value = str(_settings().value(_KEY_UPDATE_CHANNEL, "stable") or "stable")
return value if value in ("stable", "nightly") else "stable"
def _set_update_channel(channel: str) -> None:
"""Persist the update channel; anything but ``"nightly"`` means stable."""
_settings().setValue(_KEY_UPDATE_CHANNEL,
"nightly" if channel == "nightly" else "stable")
def _note_running_version(version: str):
"""Remember ``version`` as seen and say which version ran before it.
:param version: the version running now.
:returns: the previously seen version when it differs from ``version``,
otherwise ``None``; a first launch remembers without answering.
"""
store = _settings()
seen = str(store.value(_KEY_SEEN_VERSION, "") or "")
if not version or version == "unknown" or seen == version:
return None
store.setValue(_KEY_SEEN_VERSION, version)
if not seen:
return None
store.setValue(_KEY_PREVIOUS_VERSION, seen)
return seen
def _previous_version() -> str:
"""The version that ran before the latest update, or ``""``."""
return str(_settings().value(_KEY_PREVIOUS_VERSION, "") or "")
def _apply_network_preferences() -> None:
"""Export the saved proxy and certificate bundle to every downloader."""
try:
from spacr.updater import _apply_network_settings
_apply_network_settings()
except Exception: # noqa: BLE001
LOG.debug("could not apply the network settings", exc_info=True)
#: The Database Browser opens ``measurements.db`` read-only. Editing is a
#: separate, deliberate opt-in because an UPDATE against a measurements
#: database is unrecoverable — there is no undo and no backup.
DEFAULT_DB_BROWSER_EDITABLE = False
def _as_bool(raw, default: bool) -> bool:
"""Coerce a QSettings value to bool.
The INI backend hands strings back ("true"), the native backends hand
real bools back, and a hand-edited file can hold anything at all —
which must fall back to ``default`` rather than turn editing on.
"""
if raw is None:
return default
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
text = str(raw).strip().lower()
if text in ("true", "1", "yes", "on"):
return True
if text in ("false", "0", "no", "off", ""):
return False
return default
[docs]
def get_db_browser_editable() -> bool:
"""True when the user has allowed the Database Browser to write.
Default ``False``: the browser opens every database with
``mode=ro``. Turning this on only *permits* edit mode — the user
still has to arm it per session, per database, in the browser
itself.
"""
return _as_bool(_settings().value(_KEY_DB_EDIT, DEFAULT_DB_BROWSER_EDITABLE),
DEFAULT_DB_BROWSER_EDITABLE)
[docs]
def set_db_browser_editable(on: bool) -> None:
"""Allow (or forbid) edit mode in the Database Browser.
Flushed immediately. QSettings writes back lazily, and the Database
Browser re-reads this key on every UI refresh — a stale read right
after the user ticked the box would tell them editing is still off.
One tiny INI write is worth not having to explain that.
:param on: true to allow edit mode, false to forbid it; stored as a
``bool``.
"""
settings = _settings()
settings.setValue(_KEY_DB_EDIT, bool(on))
settings.sync()
DEFAULT_SHOW_ALPHA = True
DEFAULT_SHOW_BETA = True
[docs]
def get_show_alpha() -> bool:
"""Whether Alpha modules and settings are visible (default: ``True``)."""
return _as_bool(_settings().value(_KEY_SHOW_ALPHA, DEFAULT_SHOW_ALPHA),
DEFAULT_SHOW_ALPHA)
[docs]
def set_show_alpha(on: bool) -> None:
"""Show or hide modules and settings classified as Alpha.
:param on: true to show Alpha modules and settings, false to hide them;
stored as a ``bool``.
"""
_settings().setValue(_KEY_SHOW_ALPHA, bool(on))
[docs]
def get_show_beta() -> bool:
"""Whether Beta modules and settings are visible (default: ``True``)."""
return _as_bool(_settings().value(_KEY_SHOW_BETA, DEFAULT_SHOW_BETA),
DEFAULT_SHOW_BETA)
[docs]
def set_show_beta(on: bool) -> None:
"""Show or hide modules and settings classified as Beta.
:param on: true to show Beta modules and settings, false to hide them;
stored as a ``bool``.
"""
_settings().setValue(_KEY_SHOW_BETA, bool(on))
[docs]
def maturity_is_visible(stage: str) -> bool:
"""Return whether a maturity stage should be present in the UI.
Unknown stages are treated as stable. Stable features cannot be hidden;
the two preferences are deliberately scoped to unfinished features.
:param stage: the maturity stage, e.g. ``"alpha"`` or ``"beta"``; matched
case-insensitively, and an empty value or any other stage counts as
stable.
"""
normalized = str(stage or "stable").strip().lower()
if normalized == "alpha":
return get_show_alpha()
if normalized == "beta":
return get_show_beta()
return True
DEFAULT_SHOW_ALPHA_FEATURES = False
def _get_show_alpha_features() -> bool:
"""Whether features built from the future list are shown (default off).
Separate from :func:`get_show_alpha`, which is about a module's
maturity stage. This one hides everything registered in
``spacr.settings.ALPHA_FEATURES`` until the user turns it on.
"""
return _as_bool(
_settings().value(_KEY_SHOW_ALPHA_FEATURES,
DEFAULT_SHOW_ALPHA_FEATURES),
DEFAULT_SHOW_ALPHA_FEATURES)
def _set_show_alpha_features(on: bool) -> None:
"""Show or hide every feature registered with the alpha gate.
Flushed at once, because the open screens re-read it as soon as the
Preferences dialog closes.
:param on: true to show alpha features, false to hide them.
"""
settings = _settings()
settings.setValue(_KEY_SHOW_ALPHA_FEATURES, bool(on))
settings.sync()
DEFAULT_RESTORE_SESSION = True
_KEY_RESTORE_SESSION = "prefs/restore_last_session"
def _get_restore_session() -> bool:
"""Whether an ordinary start reopens the last session (default on)."""
return _as_bool(
_settings().value(_KEY_RESTORE_SESSION, DEFAULT_RESTORE_SESSION),
DEFAULT_RESTORE_SESSION)
def _set_restore_session(on: bool) -> None:
"""Turn reopening the last module, settings and folder on or off.
:param on: true to reopen the last session on every start.
"""
settings = _settings()
settings.setValue(_KEY_RESTORE_SESSION, bool(on))
settings.sync()
DEFAULT_SHOW_ALPHA_SPECIES = False
def _get_show_alpha_species() -> bool:
"""Whether the alpha organism pages are shown (default off).
Independent of :func:`_get_show_alpha_features`: it shows or hides only
what ``spacr.settings.ALPHA_SPECIES`` lists.
"""
return _as_bool(
_settings().value(_KEY_SHOW_ALPHA_SPECIES,
DEFAULT_SHOW_ALPHA_SPECIES),
DEFAULT_SHOW_ALPHA_SPECIES)
def _set_show_alpha_species(on: bool) -> None:
"""Show or hide every organism page registered as an alpha species.
:param on: true to show the alpha organism pages, false to hide them.
"""
settings = _settings()
settings.setValue(_KEY_SHOW_ALPHA_SPECIES, bool(on))
settings.sync()
def _alpha_switch_for(kind, name) -> bool:
"""The preference that gates one registered name.
:param kind: one of ``spacr.settings.ALPHA_KINDS``.
:param name: the registered name.
:returns: Show alpha species for an ``ALPHA_SPECIES`` name, otherwise
Show alpha features.
"""
from ..settings import _alpha_species_names
if str(name) in _alpha_species_names(kind):
return _get_show_alpha_species()
return _get_show_alpha_features()
def _is_alpha_visible(kind=None, name=None, choice=None) -> bool:
"""THE alpha gate: whether something should be on screen right now.
With no arguments, whether alpha features are shown at all. With a kind
and a name, True for anything not registered in
``spacr.settings.ALPHA_FEATURES`` and, for a registered thing,
whether the Show alpha features preference is on; for a name in
``spacr.settings.ALPHA_SPECIES``, whether Show alpha species is on.
:param kind: one of ``spacr.settings.ALPHA_KINDS``, or None.
:param name: the settings key, object name, module key or model key.
:param choice: with ``kind='choices'``, the dropdown entry asked about.
"""
if kind is None:
return _get_show_alpha_features()
from ..settings import _alpha_species_names, _is_alpha
if kind != 'choices' and str(name) in _alpha_species_names(kind):
return _get_show_alpha_species()
if _get_show_alpha_features():
return True
return not _is_alpha(kind, name, choice)
def _apply_alpha_widgets(root) -> int:
"""Hide or restore every registered alpha widget and action under ``root``.
Found by object name. A widget is hidden only if it was showing, and it
is marked when hidden, so turning the preference back on restores just
the ones this hid; a label its owner keeps hidden until a run starts is
left for its owner to show.
:param root: a widget whose children are walked, itself included.
:returns: how many widgets or actions changed.
"""
from PySide6.QtCore import QObject
from ..settings import _alpha_names
changed = 0
for name in sorted(_alpha_names("widgets")):
shown = _alpha_switch_for("widgets", name)
found = list(root.findChildren(QObject, name))
if root.objectName() == name:
found.append(root)
for thing in found:
try:
hidden = (thing.isHidden() if hasattr(thing, "isHidden")
else not thing.isVisible())
if not shown and not hidden:
thing.setProperty("_spacr_alpha_hid", True)
thing.setVisible(False)
changed += 1
elif shown and thing.property("_spacr_alpha_hid"):
thing.setProperty("_spacr_alpha_hid", False)
thing.setVisible(True)
changed += 1
except RuntimeError:
continue
return changed
_KEY_NOTIFY_PREFIX = "notify/"
_KEY_NOTIFY_SAVED_SECRETS = "notify/saved_secrets"
_NOTIFY_DEFAULTS = {
"enabled": False,
"when": "always",
"min_minutes": 5,
"desktop": True,
"email": False,
"smtp_host": "",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_user": "",
"email_from": "",
"email_to": "",
"slack": False,
"ntfy": False,
"ntfy_server": "https://ntfy.sh",
"teams": False,
"webhook": False,
}
"""Run-finished notification preferences and what a fresh install holds."""
_NOTIFY_WHEN = ("always", "failed")
_NOTIFY_SECURITY = ("starttls", "ssl", "none")
_NOTIFY_ALPHA_WIDGET = "NotifyRunsEnabled"
def _get_run_notifications() -> dict:
"""The stored run-finished notification preferences, secrets excluded.
Every value falls back to :data:`_NOTIFY_DEFAULTS` when it is missing or
unreadable.
:returns: a dict with the keys of :data:`_NOTIFY_DEFAULTS`.
"""
store = _settings()
out = {}
for key, default in _NOTIFY_DEFAULTS.items():
raw = store.value(_KEY_NOTIFY_PREFIX + key, default)
if isinstance(default, bool):
out[key] = _as_bool(raw, default)
elif isinstance(default, int):
try:
out[key] = int(raw)
except (TypeError, ValueError):
out[key] = default
else:
out[key] = str(raw if raw is not None else default).strip()
if out["when"] not in _NOTIFY_WHEN:
out["when"] = _NOTIFY_DEFAULTS["when"]
if out["smtp_security"] not in _NOTIFY_SECURITY:
out["smtp_security"] = _NOTIFY_DEFAULTS["smtp_security"]
out["min_minutes"] = max(0, min(1440, out["min_minutes"]))
if not 1 <= out["smtp_port"] <= 65535:
out["smtp_port"] = _NOTIFY_DEFAULTS["smtp_port"]
return out
def _saved_notification_secrets() -> frozenset:
"""Which notification secrets have been saved, by name, never by value."""
raw = _settings().value(_KEY_NOTIFY_SAVED_SECRETS, "")
if isinstance(raw, (list, tuple)):
raw = ",".join(str(part) for part in raw)
return frozenset(part for part in str(raw or "").split(",") if part)
def _set_run_notifications(values: dict, secrets=None) -> None:
"""Store run-finished notification preferences and any new secrets.
Secrets go to the OS keyring, or without one to a file only the user can
read; the preference store keeps only which ones are saved. An empty
secret leaves the saved one as it is.
:param values: any of the keys of :data:`_NOTIFY_DEFAULTS`.
:param secrets: optional ``{name: value}`` for the names in
``spacr.run_journal._NOTIFY_SECRET_NAMES``.
"""
store = _settings()
for key in _NOTIFY_DEFAULTS:
if key in values:
store.setValue(_KEY_NOTIFY_PREFIX + key, values[key])
saved = set(_saved_notification_secrets())
if secrets:
from ..run_journal import _store_notify_secret
for name, value in secrets.items():
if value:
_store_notify_secret(name, value)
saved.add(name)
store.setValue(_KEY_NOTIFY_SAVED_SECRETS, ",".join(sorted(saved)))
store.sync()
def _forget_run_notification_secrets() -> None:
"""Delete every saved notification secret from the keyring and the file."""
from ..run_journal import _NOTIFY_SECRET_NAMES, _store_notify_secret
for name in _NOTIFY_SECRET_NAMES:
_store_notify_secret(name, "")
store = _settings()
store.setValue(_KEY_NOTIFY_SAVED_SECRETS, "")
store.sync()
def _run_notification_config():
"""The notification settings a closing run is announced with, or None.
None unless notifications are switched on, at least one channel is
ready, and the Show alpha features gate shows them: a configuration the
gate hides sends nothing.
:returns: the stored preferences with ``desktop``, ``email``, ``slack``,
``ntfy``, ``teams`` and ``webhook`` reduced to the channels that are
ready, or ``None``.
"""
if not _is_alpha_visible("widgets", _NOTIFY_ALPHA_WIDGET):
return None
values = _get_run_notifications()
if not values["enabled"]:
return None
saved = _saved_notification_secrets()
values["email"] = bool(values["email"] and values["smtp_host"]
and values["email_to"])
values["slack"] = bool(values["slack"] and "slack_webhook" in saved)
values["ntfy"] = bool(values["ntfy"] and "ntfy_topic" in saved)
values["teams"] = bool(values["teams"] and "teams_webhook" in saved)
values["webhook"] = bool(values["webhook"] and "webhook_url" in saved)
values["desktop"] = bool(values["desktop"])
if not any(values[name] for name in ("desktop", "email", "slack",
"ntfy", "teams", "webhook")):
return None
return values
class _NotificationsPage:
"""The Notifications tab: when and how a finished or failed run is told.
Secrets are never read back into the dialog: a secret field is empty,
says whether one is saved, and replaces it only when something is typed.
:param form: the tab's form layout, from the dialog's ``_page``.
:param dialog: the Preferences dialog.
"""
def __init__(self, form, dialog) -> None:
"""Build every row, reading the stored values."""
from PySide6.QtWidgets import (QComboBox, QLabel, QLineEdit,
QPushButton, QSpinBox)
from .i18n import tr
from .widgets.toggle import Toggle
self._dialog = dialog
self._thread = None
self._timer = None
values = _get_run_notifications()
help_label = QLabel(tr(
"spaCR can tell you when a long run finishes or fails: on this "
"computer's desktop, by email, in Slack or Microsoft Teams, "
"through ntfy or a webhook. Nothing is sent until you switch "
"it on here. Passwords and addresses "
"that work like passwords are kept in the system keyring."))
help_label.setWordWrap(True)
help_label.setObjectName("NotifyTabHelp")
form.addRow(help_label)
self.enabled = Toggle()
self.enabled.setObjectName("NotifyRunsEnabled")
self.enabled.setToolTip(
"Announce every run that finishes or fails, by the ways switched "
"on below, with its name, duration, outcome, a short QC summary "
"and where its output is. Runs from the app and from the command "
"line are both announced. A run you cancel is not. Default off.")
form.addRow(tr("Notify me"), self.enabled)
self.when = QComboBox()
self.when.setObjectName("NotifyRunsWhen")
self.when.addItem(tr("When a run finishes or fails"), "always")
self.when.addItem(tr("Only when a run fails"), "failed")
self.when.setToolTip(
"Which runs are announced: every run that ends, or only the ones "
"that fail. Default when a run finishes or fails.")
form.addRow(tr("When"), self.when)
self.min_minutes = QSpinBox()
self.min_minutes.setObjectName("NotifyRunsMinMinutes")
self.min_minutes.setRange(0, 1440)
self.min_minutes.setSuffix(tr(" min"))
self.min_minutes.setToolTip(
"Only runs that took at least this long are announced, so a "
"quick run you are watching does not send anything. 0 announces "
"every run. Default 5 min.")
form.addRow(tr("Runs longer than"), self.min_minutes)
self.desktop = Toggle()
self.desktop.setObjectName("NotifyDesktop")
self.desktop.setToolTip(
"Show a notification on this computer, from the system tray "
"while the app is open, or through the desktop's own "
"notifications for a command-line run. Default on.")
form.addRow(tr("Desktop"), self.desktop)
self.email = Toggle()
self.email.setObjectName("NotifyEmail")
self.email.setToolTip(
"Send an email through the SMTP server below. Your institution's "
"or mail provider's server works; many need an app password "
"rather than your usual one. Default off.")
form.addRow(tr("Email"), self.email)
def line(tip, placeholder="", secret=False):
"""A text field with its tooltip and placeholder."""
field = QLineEdit()
field.setToolTip(tip)
if placeholder:
field.setPlaceholderText(placeholder)
if secret:
field.setEchoMode(QLineEdit.Password)
return field
self.smtp_host = line(
"The outgoing mail server, for example smtp.example.org. "
"Default empty.", "smtp.example.org")
self.smtp_host.setObjectName("NotifySmtpHost")
form.addRow(tr("SMTP server"), self.smtp_host)
self.smtp_port = QSpinBox()
self.smtp_port.setObjectName("NotifySmtpPort")
self.smtp_port.setRange(1, 65535)
self.smtp_port.setToolTip(
"The server's port: usually 587 with STARTTLS, 465 with SSL. "
"Default 587.")
form.addRow(tr("SMTP port"), self.smtp_port)
self.smtp_security = QComboBox()
self.smtp_security.setObjectName("NotifySmtpSecurity")
self.smtp_security.addItem("STARTTLS", "starttls")
self.smtp_security.addItem("SSL", "ssl")
self.smtp_security.addItem(tr("None"), "none")
self.smtp_security.setToolTip(
"How the connection to the mail server is encrypted. None sends "
"the password in the clear and is only for a server on your own "
"network. Default STARTTLS.")
form.addRow(tr("Encryption"), self.smtp_security)
self.smtp_user = line(
"The name you sign in to the mail server with, often your email "
"address. Leave empty for a server that needs no sign-in. "
"Default empty.")
self.smtp_user.setObjectName("NotifySmtpUser")
form.addRow(tr("SMTP user name"), self.smtp_user)
self.smtp_password = line(
"The mail server password. Kept in the system keyring, or "
"without one in a file only you can read, and never written to "
"a log. Leave empty to keep the saved one. Default empty.",
secret=True)
self.smtp_password.setObjectName("NotifySmtpPassword")
form.addRow(tr("SMTP password"), self.smtp_password)
self.email_from = line(
"The sender address. Empty uses the user name. Default empty.")
self.email_from.setObjectName("NotifyEmailFrom")
form.addRow(tr("From"), self.email_from)
self.email_to = line(
"Who is told, one or more addresses separated by commas. "
"Default empty.", "you@example.org")
self.email_to.setObjectName("NotifyEmailTo")
form.addRow(tr("To"), self.email_to)
self.slack = Toggle()
self.slack.setObjectName("NotifySlack")
self.slack.setToolTip(
"Post to a Slack channel through an incoming webhook. Default "
"off.")
form.addRow(tr("Slack"), self.slack)
self.slack_webhook = line(
"The incoming-webhook address Slack gives you, starting "
"https://hooks.slack.com/. Anyone with it can post to the "
"channel, so it is kept like a password. Leave empty to keep the "
"saved one. Default empty.", secret=True)
self.slack_webhook.setObjectName("NotifySlackWebhook")
form.addRow(tr("Slack webhook"), self.slack_webhook)
self.ntfy = Toggle()
self.ntfy.setObjectName("NotifyNtfy")
self.ntfy.setToolTip(
"Publish to an ntfy topic, which the ntfy phone app or web page "
"shows as a push notification. Default off.")
form.addRow(tr("ntfy"), self.ntfy)
self.ntfy_server = line(
"The ntfy server: the public one, or your own. Default "
"https://ntfy.sh.", "https://ntfy.sh")
self.ntfy_server.setObjectName("NotifyNtfyServer")
form.addRow(tr("ntfy server"), self.ntfy_server)
self.ntfy_topic = line(
"The topic to publish to. On a public server anyone who knows "
"the topic can read it, so choose one nobody would guess; it is "
"kept like a password. Leave empty to keep the saved one. "
"Default empty.", secret=True)
self.ntfy_topic.setObjectName("NotifyNtfyTopic")
form.addRow(tr("ntfy topic"), self.ntfy_topic)
self.ntfy_token = line(
"An access token, for a server or topic that needs one. Kept "
"like a password. Leave empty to keep the saved one. Default "
"empty.", secret=True)
self.ntfy_token.setObjectName("NotifyNtfyToken")
form.addRow(tr("ntfy access token"), self.ntfy_token)
self.teams = Toggle()
self.teams.setObjectName("NotifyTeams")
self.teams.setToolTip(
"Post an Adaptive Card to a Microsoft Teams channel or chat "
"through a workflow webhook. Default off.")
form.addRow(tr("Microsoft Teams"), self.teams)
self.teams_webhook = line(
"The webhook address from a Teams workflow configured to allow "
"Anyone to call it. Anyone with the address can post, so it is "
"kept like a password. Leave empty to keep the saved one. "
"Default empty.", secret=True)
self.teams_webhook.setObjectName("NotifyTeamsWebhook")
form.addRow(tr("Teams webhook"), self.teams_webhook)
self.webhook = Toggle()
self.webhook.setObjectName("NotifyWebhook")
self.webhook.setToolTip(
"Send a JSON object with title, body and failed fields to the "
"webhook below. The body contains the run summary and output "
"path; failed is true when the run failed. Default off.")
form.addRow(tr("Webhook"), self.webhook)
self.webhook_url = line(
"The HTTP or HTTPS address that receives the JSON notification. "
"Kept like a password because webhook addresses can contain "
"access keys. Leave empty to keep the saved one. Default "
"empty.", secret=True)
self.webhook_url.setObjectName("NotifyWebhookUrl")
form.addRow(tr("Webhook address"), self.webhook_url)
self.webhook_token = line(
"An optional bearer token for the webhook. Kept like a password. "
"Leave empty to keep the saved one. Default empty.", secret=True)
self.webhook_token.setObjectName("NotifyWebhookToken")
form.addRow(tr("Webhook access token"), self.webhook_token)
self.send_test = QPushButton(tr("Send a test"))
self.send_test.setObjectName("NotifySendTest")
self.send_test.setToolTip(
"Send a test message now by every way switched on above, using "
"what is typed here, and say which got through. Nothing is "
"saved. Default not sent.")
self.send_test.clicked.connect(self._send_test)
form.addRow(tr("Try it"), self.send_test)
self.forget = QPushButton(tr("Forget saved secrets"))
self.forget.setObjectName("NotifyForgetSecrets")
self.forget.setToolTip(
"Delete all saved notification passwords, webhook addresses, "
"topics and tokens from the keyring and from spaCR's own file, "
"at once. "
"Default kept.")
self.forget.clicked.connect(self._forget)
form.addRow(tr("Saved secrets"), self.forget)
self.test_result = QLabel("")
self.test_result.setObjectName("NotifyTestResult")
self.test_result.setWordWrap(True)
form.addRow(self.test_result)
self._secrets = {
"smtp_password": self.smtp_password,
"slack_webhook": self.slack_webhook,
"ntfy_topic": self.ntfy_topic,
"ntfy_token": self.ntfy_token,
"teams_webhook": self.teams_webhook,
"webhook_url": self.webhook_url,
"webhook_token": self.webhook_token,
}
self._show(values)
self._mark_saved_secrets()
for toggle in (self.email, self.slack, self.ntfy, self.teams,
self.webhook):
toggle.toggled.connect(lambda _on: self._sync())
self._sync()
def _show(self, values: dict) -> None:
"""Put ``values`` into the controls; secret fields are cleared."""
self.enabled.setChecked(bool(values["enabled"]))
self.when.setCurrentIndex(max(0, self.when.findData(values["when"])))
self.min_minutes.setValue(int(values["min_minutes"]))
self.desktop.setChecked(bool(values["desktop"]))
self.email.setChecked(bool(values["email"]))
self.smtp_host.setText(values["smtp_host"])
self.smtp_port.setValue(int(values["smtp_port"]))
self.smtp_security.setCurrentIndex(
max(0, self.smtp_security.findData(values["smtp_security"])))
self.smtp_user.setText(values["smtp_user"])
self.email_from.setText(values["email_from"])
self.email_to.setText(values["email_to"])
self.slack.setChecked(bool(values["slack"]))
self.ntfy.setChecked(bool(values["ntfy"]))
self.ntfy_server.setText(values["ntfy_server"])
self.teams.setChecked(bool(values["teams"]))
self.webhook.setChecked(bool(values["webhook"]))
for field in self._secrets.values():
field.clear()
def _mark_saved_secrets(self) -> None:
"""Say in each secret field whether a secret is saved for it."""
from .i18n import tr
saved = _saved_notification_secrets()
for name, field in self._secrets.items():
field.setPlaceholderText(
tr("Saved; type to replace") if name in saved
else tr("Not saved"))
def _sync(self) -> None:
"""A channel's fields are editable only while it is switched on."""
for toggle, fields in (
(self.email, (self.smtp_host, self.smtp_port,
self.smtp_security, self.smtp_user,
self.smtp_password, self.email_from,
self.email_to)),
(self.slack, (self.slack_webhook,)),
(self.teams, (self.teams_webhook,)),
(self.webhook, (self.webhook_url, self.webhook_token)),
(self.ntfy, (self.ntfy_server, self.ntfy_topic,
self.ntfy_token))):
for field in fields:
field.setEnabled(toggle.isChecked())
def values(self) -> dict:
"""What the controls hold, secrets excluded."""
return {
"enabled": self.enabled.isChecked(),
"when": self.when.currentData(),
"min_minutes": self.min_minutes.value(),
"desktop": self.desktop.isChecked(),
"email": self.email.isChecked(),
"smtp_host": self.smtp_host.text().strip(),
"smtp_port": self.smtp_port.value(),
"smtp_security": self.smtp_security.currentData(),
"smtp_user": self.smtp_user.text().strip(),
"email_from": self.email_from.text().strip(),
"email_to": self.email_to.text().strip(),
"slack": self.slack.isChecked(),
"ntfy": self.ntfy.isChecked(),
"teams": self.teams.isChecked(),
"webhook": self.webhook.isChecked(),
"ntfy_server": (self.ntfy_server.text().strip()
or _NOTIFY_DEFAULTS["ntfy_server"]),
}
def secrets(self) -> dict:
"""The secrets typed into the dialog, by name; empty ones left out."""
return {name: field.text() for name, field in self._secrets.items()
if field.text()}
def save(self) -> None:
"""Store the controls, and any secret that was typed."""
_set_run_notifications(self.values(), self.secrets())
def reset(self) -> None:
"""Put the controls back to a fresh install's values.
Saved secrets are not touched; Forget saved secrets does that.
"""
self._show(_get_run_notifications())
self._sync()
def _forget(self) -> None:
"""Delete every saved secret now and say so."""
from .i18n import tr
try:
_forget_run_notification_secrets()
self.test_result.setText(tr("Saved secrets forgotten."))
except Exception as exc:
LOG.warning("could not forget the notification secrets (%s)",
type(exc).__name__)
self.test_result.setText(tr("Could not forget the saved "
"secrets."))
self._mark_saved_secrets()
def _send_test(self):
"""Send a test message by the channels switched on, off the GUI thread.
:returns: the sending thread, or ``None`` when no channel is on.
"""
from PySide6.QtCore import QTimer
from ..run_journal import _dispatch_notification
from .i18n import tr
trial = self.values()
trial["secrets"] = self.secrets()
if not any(trial[name] for name in ("desktop", "email", "slack",
"ntfy", "teams", "webhook")):
self.test_result.setText(tr(
"Switch on at least one way to be told first."))
return None
message = {
"title": tr("spaCR test notification"),
"body": tr("If you can read this, spaCR can tell you when a run "
"finishes or fails."),
"failed": False,
}
self.send_test.setEnabled(False)
self.test_result.setText(tr("Sending…"))
self._thread = _dispatch_notification(message, trial)
self._timer = QTimer(self.test_result)
self._timer.setInterval(200)
self._timer.timeout.connect(self._test_finished)
self._timer.start()
return self._thread
def _test_finished(self) -> bool:
"""Report the test once its thread is done.
:returns: ``True`` when the result was shown.
"""
from .i18n import tr
thread = self._thread
if thread is None or thread.is_alive():
return False
if self._timer is not None:
self._timer.stop()
results = dict(getattr(thread, "results", {}) or {})
sent = [name for name, result in results.items() if result == "sent"]
failed = [f"{name} ({result})" for name, result in results.items()
if result != "sent"]
lines = []
if sent:
lines.append(tr("Sent: {channels}").format(
channels=", ".join(sent)))
if failed:
lines.append(tr("Not sent: {channels}").format(
channels="; ".join(failed)))
try:
self.test_result.setText("\n".join(lines))
self.send_test.setEnabled(True)
except RuntimeError:
return False
return True
_KEY_PLUGIN_CATALOGUE = "plugins/catalogue"
_PLUGIN_CATALOGUE_ALPHA_WIDGET = "PluginCatalogueTable"
_STORAGE_LABELS = (
("logs", "Daily logs"),
("run_logs", "Run logs"),
("run_folders", "Run folders"),
)
def _get_storage_caps() -> dict:
"""Return ``{kind: (keep days, size cap in MB)}`` for home-folder pruning.
A stored value that cannot be read gives the default for that kind.
"""
from spacr.run_journal import _PRUNE_DEFAULTS
store = _settings()
caps = {}
for kind, (days, megabytes) in _PRUNE_DEFAULTS.items():
try:
stored_days = int(store.value(f"storage/{kind}_days", days))
stored_mb = int(store.value(f"storage/{kind}_cap_mb", megabytes))
except (TypeError, ValueError):
stored_days, stored_mb = days, megabytes
caps[kind] = (max(1, stored_days), max(0, stored_mb))
return caps
def _set_storage_caps(caps: dict) -> None:
"""Remember the pruning caps.
:param caps: ``{kind: (keep days, size cap in MB)}``; days below 1 are
stored as 1 and negative caps as 0.
"""
store = _settings()
for kind, (days, megabytes) in caps.items():
store.setValue(f"storage/{kind}_days", max(1, int(days)))
store.setValue(f"storage/{kind}_cap_mb", max(0, int(megabytes)))
def _confirm_storage_action(title: str, text: str, parent=None) -> bool:
"""Ask before deleting or moving anything; Cancel is the default.
:param title: what will happen, also the accept button's label.
:param text: what exactly will be deleted or moved.
:param parent: the widget the box is parented to.
:returns: ``True`` only when the person pressed the accept button.
"""
from PySide6.QtWidgets import QMessageBox
from .i18n import tr
box = QMessageBox(parent)
box.setObjectName("StorageActionConfirm")
box.setIcon(QMessageBox.Warning)
box.setWindowTitle(title)
box.setText(title)
box.setInformativeText(text)
proceed = box.addButton(title, QMessageBox.AcceptRole)
cancel = box.addButton(tr("Cancel"), QMessageBox.RejectRole)
box.setDefaultButton(cancel)
box.exec()
return box.clickedButton() is proceed
def _show_storage_result(title: str, text: str, parent=None) -> None:
"""Say what a storage action did.
:param title: the action.
:param text: the outcome.
:param parent: the widget the box is parented to.
"""
from PySide6.QtWidgets import QMessageBox
box = QMessageBox(parent)
box.setObjectName("StorageActionResult")
box.setIcon(QMessageBox.Information)
box.setWindowTitle(title)
box.setText(text)
box.exec()
def _choose_cache_parent(parent=None) -> str:
"""Ask for the folder a cache moves into; ``""`` when cancelled.
:param parent: the widget the dialog is parented to.
"""
from PySide6.QtWidgets import QFileDialog
from .i18n import tr
return QFileDialog.getExistingDirectory(
parent, tr("Move the cache into this folder"))
class _StoragePage:
"""The Storage tab: prune spaCR's home folder and manage disk caches.
Pruning has an age floor and a size cap for daily logs, run logs and run
folders. Nothing newer than the floor, the current run and open log
files are never deleted. The cache table lists the model, Hugging Face,
Torch, backend and news caches with their sizes, and empties or moves
the selected one. Every deletion and move is listed and asked about
first, and the disk is read on a worker thread.
:param form: the tab's form layout, from the dialog's ``_page``.
:param dialog: the Preferences dialog.
"""
def __init__(self, form, dialog) -> None:
"""Build the caps, the prune button and the cache table."""
from PySide6.QtWidgets import (
QAbstractItemView, QHBoxLayout, QLabel, QPushButton, QSpinBox,
QTableWidget, QWidget,
)
from .i18n import tr
self._dialog = dialog
self._rows = []
help_label = QLabel(tr(
"spaCR keeps logs and a folder per run in its home folder. "
"Pruning deletes the oldest of them once a folder is over its "
"size cap, and never anything newer than the age you keep, the "
"run in progress or a log that is open. You see the list before "
"anything is deleted."))
help_label.setWordWrap(True)
help_label.setObjectName("StorageHelp")
form.addRow(help_label)
caps = _get_storage_caps()
self.spins = {}
for kind, label in _STORAGE_LABELS:
days, megabytes = caps[kind]
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
keep = QSpinBox()
keep.setObjectName(f"StorageKeepDays_{kind}")
keep.setRange(1, 3650)
keep.setSuffix(tr(" days"))
keep.setValue(days)
cap = QSpinBox()
cap.setObjectName(f"StorageCapMb_{kind}")
cap.setRange(0, 1_000_000)
cap.setSingleStep(100)
cap.setSuffix(tr(" MB"))
cap.setSpecialValueText(tr("no cap"))
cap.setValue(megabytes)
layout.addWidget(QLabel(tr("Keep")))
layout.addWidget(keep)
layout.addWidget(QLabel(tr("Cap")))
layout.addWidget(cap)
layout.addStretch(1)
row.setToolTip(tr(
"Keep: nothing modified within this many days is deleted. "
"Cap: older entries are deleted, oldest first, only while "
"the folder is bigger than this; no cap deletes every older "
"entry. Pruning runs only when you press Prune now."))
self.spins[kind] = (keep, cap)
form.addRow(tr(label), row)
self.prune_button = QPushButton(tr("Prune now…"))
self.prune_button.setObjectName("StoragePruneButton")
self.prune_button.setToolTip(tr(
"List what the caps above would delete from the spaCR home "
"folder, then ask before deleting it. Nothing is deleted "
"automatically. Default off."))
self.prune_button.clicked.connect(self.prune)
form.addRow(tr("Home folder"), self.prune_button)
columns = [tr("Cache"), tr("Size"), tr("Folder")]
self.table = QTableWidget(0, len(columns))
self.table.setObjectName("StorageCacheTable")
self.table.setHorizontalHeaderLabels(columns)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.itemSelectionChanged.connect(self._sync_buttons)
self.table.setToolTip(tr(
"spaCR's model, Hugging Face, Torch, backend and news caches "
"with their sizes. Press Measure to read the sizes."))
form.addRow(self.table)
buttons = QWidget()
buttons.setToolTip(tr(
"Measure reads every cache's size, Clear empties the selected "
"cache and Move puts it in another folder. Clear and Move ask "
"first."))
button_row = QHBoxLayout(buttons)
button_row.setContentsMargins(0, 0, 0, 0)
self.measure_button = QPushButton(tr("Measure sizes"))
self.measure_button.setObjectName("StorageCacheMeasure")
self.measure_button.setToolTip(tr(
"Read the size of every cache. Default not measured."))
self.measure_button.clicked.connect(self.refresh)
self.clear_button = QPushButton(tr("Clear…"))
self.clear_button.setObjectName("StorageCacheClear")
self.clear_button.setToolTip(tr(
"Empty the selected cache after asking. Models and environments "
"are downloaded again when next needed. Default nothing cleared."))
self.clear_button.clicked.connect(self.clear)
self.move_button = QPushButton(tr("Move…"))
self.move_button.setObjectName("StorageCacheRelocate")
self.move_button.setToolTip(tr(
"Move the selected cache to another folder or drive after "
"asking; spaCR uses the new place from then on. Default the "
"standard folder."))
self.move_button.clicked.connect(self.relocate)
for button in (self.measure_button, self.clear_button,
self.move_button):
button_row.addWidget(button)
button_row.addStretch(1)
form.addRow(tr("Caches"), buttons)
self._list(self._rows_without_sizes())
@staticmethod
def _rows_without_sizes():
"""The caches with their folders, before the disk is read."""
from spacr.run_journal import _CACHE_ROWS, _cache_folder
return [{"key": key, "label": label, "path": str(_cache_folder(key)),
"env": env, "exists": None, "size": None,
"relocatable": bool(env)}
for key, label, env in _CACHE_ROWS]
def caps(self) -> dict:
"""Return the caps as the spin boxes show them."""
return {kind: (keep.value(), cap.value())
for kind, (keep, cap) in self.spins.items()}
def save(self) -> None:
"""Remember the caps; called by the dialog's Save."""
_set_storage_caps(self.caps())
def _list(self, rows) -> None:
"""Show ``rows`` in the cache table."""
from .i18n import tr
from .resource_cleanup import human_bytes
from .widgets.sortable_table import table_item
self._rows = list(rows)
self.table.setRowCount(len(self._rows))
for index, row in enumerate(self._rows):
if row["size"] is None:
size = "…"
elif not row["exists"]:
size = tr("empty")
else:
size = human_bytes(row["size"])
for column, text in enumerate((tr(row["label"]), size,
row["path"])):
item = table_item(text)
item.setToolTip(row["path"])
self.table.setItem(index, column, item)
self._sync_buttons()
def _selected(self):
"""The selected cache row, or ``None``."""
index = self.table.currentRow()
if 0 <= index < len(self._rows) and self.table.selectedItems():
return self._rows[index]
return None
def _sync_buttons(self) -> None:
"""Clear and Move work on a selected cache only."""
row = self._selected()
self.clear_button.setEnabled(row is not None)
self.move_button.setEnabled(row is not None and row["relocatable"])
@staticmethod
def _guarded(work):
"""On the worker thread: ``work()``, or the exception it raised.
Returned rather than raised, so the callback always runs and can
give the buttons back.
"""
try:
return work()
except Exception as exc:
return exc
def _finish(self, done, result) -> None:
"""On the GUI thread: hand ``result`` to ``done`` while the tab lives."""
if isinstance(result, BaseException):
LOG.warning("a storage action failed", exc_info=result)
result = None
if _widget_is_alive(self.table):
done(result)
def _background(self, work, done) -> None:
"""Run ``work`` on the disk worker and ``done`` with its result."""
from functools import partial
if not _disk_report_runner().submit(partial(self._guarded, work),
partial(self._finish, done)):
LOG.debug("the storage worker is busy")
def refresh(self) -> None:
"""Measure every cache on the worker and list the sizes."""
from spacr.run_journal import _disk_caches
self.measure_button.setEnabled(False)
self._background(_disk_caches, self._measured)
def _measured(self, rows) -> None:
"""List the measured caches."""
self.measure_button.setEnabled(True)
if rows is not None:
self._list(rows)
@staticmethod
def _busy() -> bool:
"""Whether a run is going, which clearing or moving must wait for."""
from .resource_cleanup import _a_run_is_active
return _a_run_is_active()
def _refuse_while_busy(self, title: str) -> bool:
"""Say so and return ``True`` when a run is going."""
from .i18n import tr
if not self._busy():
return False
_show_storage_result(title, tr(
"A run is in progress. Try again when it has finished."),
self._dialog)
return True
def clear(self) -> None:
"""Ask, then empty the selected cache on the worker."""
from functools import partial
from spacr.run_journal import _clear_cache
from .i18n import tr
row = self._selected()
if row is None:
return
title = tr("Clear cache")
if self._refuse_while_busy(title):
return
text = tr("Delete everything inside {folder}? The folder stays. "
"Other programs that share this cache download their "
"files again too.").format(folder=row["path"])
if not _confirm_storage_action(title, text, self._dialog):
return
self._background(partial(_clear_cache, row["key"]),
partial(self._cleared, title))
def _cleared(self, title: str, result) -> None:
"""Report what clearing removed and measure again."""
from .i18n import tr
removed, refused = result if result else (0, [tr("failed")])
message = tr("Removed {count} item(s).").format(count=removed)
if refused:
message += "\n" + "\n".join(refused[:20])
_show_storage_result(title, message, self._dialog)
self.refresh()
def relocate(self, parent_folder: str = "") -> None:
"""Ask for a folder, confirm, then move the selected cache there."""
from functools import partial
from pathlib import Path
from .i18n import tr
row = self._selected()
if row is None or not row["relocatable"]:
return
title = tr("Move cache")
if self._refuse_while_busy(title):
return
folder = parent_folder or _choose_cache_parent(self._dialog)
if not folder:
return
target = str(Path(folder) / f"spacr-{row['key']}")
text = tr("Move {source} to {target}? spaCR uses the new folder "
"from now on; a program already holding the old path "
"picks it up after a restart.").format(
source=row["path"], target=target)
if not _confirm_storage_action(title, text, self._dialog):
return
self._background(partial(self._move, row["key"], folder),
partial(self._moved, title))
@staticmethod
def _move(key: str, folder: str):
"""On the worker: whether it moved and the new folder or failure."""
from spacr.run_journal import _relocate_cache
try:
return True, str(_relocate_cache(key, folder))
except (ValueError, OSError) as error:
return False, str(error)
def _moved(self, title: str, result) -> None:
"""Report the move and measure again."""
from .i18n import tr
moved, detail = result if result else (False, tr("failed"))
if moved:
message = tr("Moved to {target}.").format(target=detail)
else:
message = tr("Not moved: {reason}").format(reason=detail)
_show_storage_result(title, message, self._dialog)
self.refresh()
def prune(self) -> None:
"""Plan on the worker, list what would go, ask, then delete."""
from functools import partial
from .i18n import tr
self.prune_button.setEnabled(False)
self._background(partial(self._plan, self.caps()),
partial(self._planned, tr("Prune home folder")))
@staticmethod
def _plan(caps: dict) -> list:
"""On the worker: one pruning plan per kind."""
from spacr.run_journal import _prune_plan
return [_prune_plan(kind, *caps[kind]) for kind, _l in _STORAGE_LABELS]
@staticmethod
def _delete(plans: list) -> list:
"""On the worker: carry out the plans."""
from spacr.run_journal import _prune
return [_prune(one) for one in plans]
def _planned(self, title: str, plans) -> None:
"""List the plans and ask before anything is deleted."""
from functools import partial
from .i18n import tr
from .resource_cleanup import human_bytes
if not plans:
self.prune_button.setEnabled(True)
return
labels = dict(_STORAGE_LABELS)
lines, chosen = [], 0
for one in plans:
size = sum(s for _p, s in one["delete"])
chosen += len(one["delete"])
lines.append(tr(
"{label}: delete {count} of {total} ({size} of {all})").format(
label=tr(labels[one["kind"]]),
count=len(one["delete"]), total=one["count"],
size=human_bytes(size), all=human_bytes(one["total"])))
if not chosen:
self.prune_button.setEnabled(True)
_show_storage_result(title, tr(
"Nothing is over its caps; nothing was deleted.") + "\n"
+ "\n".join(lines), self._dialog)
return
if not _confirm_storage_action(title, "\n".join(lines), self._dialog):
self.prune_button.setEnabled(True)
return
self._background(partial(self._delete, plans),
partial(self._pruned, title))
def _pruned(self, title: str, results) -> None:
"""Report what pruning freed."""
from .i18n import tr
from .resource_cleanup import human_bytes
self.prune_button.setEnabled(True)
if not results:
return
count = sum(r[0] for r in results)
freed = sum(r[1] for r in results)
refused = [why for r in results for why in r[2]]
message = tr("Deleted {count} item(s), freeing {size}.").format(
count=count, size=human_bytes(freed))
if refused:
message += "\n" + "\n".join(refused[:20])
_show_storage_result(title, message, self._dialog)
_LOG_GROUP_LABELS = (
("daily", "Daily logs"),
("run_logs", "Run logs"),
("crash", "Crash logs"),
("verbose", "Verbose logs"),
)
class _LogClearer:
"""The Logging tab's "Clear all logs…" button.
Lists the daily, run, crash and verbose logs with their count and size,
asks, then deletes them on the disk worker. A log this session holds
open is emptied instead of deleted, and the run in progress keeps its
log.
:param form: the Logging tab's form layout.
:param dialog: the Preferences dialog.
"""
def __init__(self, form, dialog) -> None:
"""Add the button to ``form``."""
from PySide6.QtWidgets import QPushButton
from .i18n import tr
self._dialog = dialog
self.button = QPushButton(tr("Clear all logs…"))
self.button.setObjectName("LoggingClearAllLogs")
self.button.setToolTip(tr(
"List every daily, run, crash and verbose log with its count "
"and size, then ask before deleting them. Logs this session "
"has open are emptied instead. Default nothing cleared."))
self.button.clicked.connect(self.clear)
form.addRow(tr("Log files"), self.button)
def _finish(self, done, result) -> None:
"""On the GUI thread: hand ``result`` to ``done`` while the tab lives."""
if isinstance(result, BaseException):
LOG.warning("clearing the logs failed", exc_info=result)
result = None
if _widget_is_alive(self.button):
done(result)
else:
LOG.debug("the Logging tab closed before the logs were cleared")
def _background(self, work, done) -> None:
"""Run ``work`` on the disk worker and ``done`` with its result."""
from functools import partial
if not _disk_report_runner().submit(
partial(_StoragePage._guarded, work),
partial(self._finish, done)):
LOG.debug("the storage worker is busy")
self.button.setEnabled(True)
def clear(self) -> None:
"""List the logs on the worker, then ask."""
from spacr.run_journal import _clear_logs_plan
self.button.setEnabled(False)
self._background(_clear_logs_plan, self._planned)
def _planned(self, plan) -> None:
"""Show the count and size per kind and ask before deleting."""
from functools import partial
from spacr.run_journal import _clear_logs
from .i18n import tr
from .resource_cleanup import human_bytes
title = tr("Clear all logs")
groups = (plan or {}).get("groups", {})
lines, files, opened = [], 0, 0
for group, label in _LOG_GROUP_LABELS:
entries = groups.get(group, [])
files += len(entries)
opened += sum(1 for _p, _s, is_open in entries if is_open)
lines.append(tr("{label}: {count} file(s), {size}").format(
label=tr(label), count=len(entries),
size=human_bytes(sum(size for _p, size, _o in entries))))
if not files:
self.button.setEnabled(True)
_show_storage_result(title, tr("There are no logs to clear."),
self._dialog)
return
if opened:
lines.append(tr(
"{count} file(s) this session has open are emptied, not "
"deleted.").format(count=opened))
if not _confirm_storage_action(title, "\n".join(lines), self._dialog):
self.button.setEnabled(True)
return
self._background(partial(_clear_logs, plan),
partial(self._cleared, title))
def _cleared(self, title: str, result) -> None:
"""Report what clearing removed."""
from .i18n import tr
from .resource_cleanup import human_bytes
self.button.setEnabled(True)
if not result:
return
deleted, emptied, freed, refused = result
message = tr(
"Deleted {count} file(s) and emptied {emptied}, freeing "
"{size}.").format(count=deleted, emptied=emptied,
size=human_bytes(freed))
if refused:
message += "\n" + "\n".join(refused[:20])
_show_storage_result(title, message, self._dialog)
def _get_plugin_catalogue() -> str:
"""The catalogue the Plugins tab opens with, or ``$SPACR_PLUGIN_CATALOGUE``."""
import os
stored = str(_settings().value(_KEY_PLUGIN_CATALOGUE, "") or "").strip()
return stored or os.environ.get("SPACR_PLUGIN_CATALOGUE", "").strip()
def _set_plugin_catalogue(source: str) -> None:
"""Remember the catalogue the Plugins tab opens with.
:param source: a catalogue file, its folder or an http(s) address.
"""
settings = _settings()
settings.setValue(_KEY_PLUGIN_CATALOGUE, str(source or "").strip())
settings.sync()
class _PluginCatalogueJob(threading.Thread):
"""Finish catalogue reads or mutations independently of Preferences.
No Qt object enters this non-daemon worker. Closing the dialog leaves the
install running; interpreter shutdown waits for staging/commit to finish.
The GUI polls the result and performs every widget update on its own thread.
"""
def __init__(self, row, source, install):
"""Snapshot the selected entry and its source before starting work."""
super().__init__(name="spacr-plugin-catalogue", daemon=False)
self.row = dict(row or {})
self.source = source
self.install = install
self.record = None
self.error = None
self.rows = None
self.refresh_error = None
def run(self):
"""Mutate the catalogue and fetch its refreshed rows off the GUI thread.
A refresh failure never misreports a committed install as failed.
"""
from ..plugins import (
_catalogue_rows,
_install_from_catalogue,
_uninstall_from_catalogue,
)
if self.install is None:
try:
self.rows = _catalogue_rows(self.source)
except Exception as exc:
self.error = str(exc)
return
try:
if self.install:
self.record = _install_from_catalogue(self.row["key"], self.source)
else:
_uninstall_from_catalogue(self.row["key"])
except Exception as exc:
self.error = str(exc)
return
try:
self.rows = _catalogue_rows(self.source)
except Exception as exc:
self.refresh_error = str(exc)
class _PluginCataloguePage:
"""The Plugins tab: browse a catalogue and install plugins and recipes.
Each row shows an entry's version, the installed version, its status,
author and licence; its summary is the row's tooltip. Installing,
updating and uninstalling go through the plugin SDK, which puts each
plugin in its own folder and each recipe in a settings file.
:param form: the tab's form layout, from the dialog's ``_page``.
:param dialog: the Preferences dialog.
"""
def __init__(self, form, dialog) -> None:
"""Build the rows and list the remembered catalogue, if any."""
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTableWidget,
QWidget,
)
from .i18n import tr
self._dialog = dialog
self._rows = []
self._job = None
self._job_timer = QTimer(dialog)
self._job_timer.setInterval(50)
self._job_timer.timeout.connect(self._finish_job)
help_label = QLabel(tr(
"Browse a catalogue of community plugins and assay recipes. A "
"plugin is installed into its own folder with the libraries it "
"needs, so it never replaces a package spaCR uses; a recipe is "
"saved as a settings file you can load into its module."))
help_label.setWordWrap(True)
help_label.setObjectName("PluginCatalogueHelp")
form.addRow(help_label)
self.source = QLineEdit(_get_plugin_catalogue())
self.source.setObjectName("PluginCatalogueSource")
self.source.setPlaceholderText(tr("Catalogue file, folder or address"))
self.source.setToolTip(tr(
"Where the catalogue is: a catalogue.json file, the folder "
"holding one, or an http(s) address. It is remembered for next "
"time. Default the SPACR_PLUGIN_CATALOGUE variable, else empty."))
self.load_button = QPushButton(tr("List"))
self.load_button.setObjectName("PluginCatalogueLoad")
self.load_button.clicked.connect(self.refresh)
source_row = QWidget()
source_layout = QHBoxLayout(source_row)
source_layout.setContentsMargins(0, 0, 0, 0)
source_layout.addWidget(self.source, 1)
source_layout.addWidget(self.load_button)
form.addRow(tr("Catalogue"), source_row)
columns = [tr("Type"), tr("Name"), tr("Version"), tr("Installed"),
tr("Status"), tr("Author"), tr("Licence")]
self.table = QTableWidget(0, len(columns))
from .widgets.sortable_table import install_sorting
install_sorting(self.table)
self.table.setObjectName("PluginCatalogueTable")
self.table.setHorizontalHeaderLabels(columns)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.itemSelectionChanged.connect(self._sync_buttons)
form.addRow(self.table)
self.install_button = QPushButton(tr("Install or update"))
self.install_button.setObjectName("PluginCatalogueInstall")
self.install_button.clicked.connect(self.install_selected)
self.uninstall_button = QPushButton(tr("Uninstall"))
self.uninstall_button.setObjectName("PluginCatalogueUninstall")
self.uninstall_button.clicked.connect(self.uninstall_selected)
self.open_button = QPushButton(tr("Open"))
self.open_button.setObjectName("PluginCatalogueOpen")
self.open_button.clicked.connect(self._open_selected)
actions = QWidget()
actions_layout = QHBoxLayout(actions)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.addWidget(self.install_button)
actions_layout.addWidget(self.uninstall_button)
actions_layout.addWidget(self.open_button)
actions_layout.addStretch(1)
form.addRow(actions)
self.status = QLabel("")
self.status.setObjectName("PluginCatalogueStatus")
self.status.setWordWrap(True)
form.addRow(self.status)
self._sync_buttons()
if self.source.text().strip():
self.refresh()
def selected(self):
"""The selected catalogue row as a dict, or None."""
rows = self.table.selectionModel().selectedRows()
if not rows:
return None
item = self.table.item(rows[0].row(), 0)
key = item.data(Qt.UserRole) if item is not None else None
return next((row for row in self._rows if row["key"] == key), None)
def _sync_buttons(self) -> None:
"""Offer only the actions the selected row allows."""
row = self.selected()
busy = self._job is not None
self.source.setEnabled(not busy)
self.load_button.setEnabled(not busy)
self.table.setEnabled(not busy)
self.install_button.setEnabled(
not busy and row is not None and row["status"] in ("available",
"update available"))
self.uninstall_button.setEnabled(
not busy and row is not None and bool(row["installed"]))
self.open_button.setEnabled(
not busy and row is not None and row["kind"] == "recipe"
and bool(row["installed"]))
def _open_selected(self) -> bool:
"""Load the installed recipe into its desktop module without running it."""
from ..cli import load_settings_file
from ..plugins import _catalogue_installed, get_app
from .app import APPS, _opened_module_screen, app_is_visible
from .chaining import screen_for_module
from .i18n import tr
row = self.selected()
if self._job is not None or row is None or not _is_alpha_visible(
"widgets", _PLUGIN_CATALOGUE_ALPHA_WIDGET):
return False
try:
installed = _catalogue_installed().get(row["key"], {})
if installed.get("kind") != "recipe":
return False
requested = str(installed.get("app") or "")
host = screen_for_module(requested)
plugin = get_app(host)
if (not requested
or (host not in {app[0] for app in APPS} and plugin is None)
or (plugin is not None and not maturity_is_visible(plugin.stage))
or not app_is_visible(requested)
or not app_is_visible(host)):
raise ValueError(f"{tr('Could not apply template')}: {requested}")
settings = load_settings_file(installed.get("path"))
window = self._dialog.parentWidget()
if window is None or not callable(getattr(window, "open_module", None)):
raise ValueError(tr("Could not apply template"))
opened = window.open_module(requested)
screen = _opened_module_screen(window, requested, opened)
if screen is None or not callable(getattr(screen, "apply_settings_dict", None)):
raise ValueError(f"{tr('Could not apply template')}: {requested}")
if not screen.apply_settings_dict(settings):
raise ValueError(tr("Could not apply template"))
except Exception as exc:
self.status.setText(tr("{name} failed: {error}").format(
name=row["name"], error=exc))
return False
self._dialog.close()
return True
def refresh(self) -> bool:
"""Fetch the current source off-thread and show its result when ready.
:returns: True when the read started, False while another job is busy.
"""
from .i18n import tr
if self._job is not None:
return False
source = self.source.text().strip()
_set_plugin_catalogue(source)
self._job = _PluginCatalogueJob(None, source or None, None)
self._rows = []
self.table.setRowCount(0)
self.status.setText(tr("Working…"))
self._sync_buttons()
self._job.start()
self._job_timer.start()
return True
def _show_rows(self, rows):
"""Render a previously fetched catalogue snapshot on the GUI thread."""
from .i18n import tr
from .widgets.sortable_table import _settle_sorting, table_item
self._rows = rows
kinds = {"plugin": tr("Plugin"), "recipe": tr("Recipe")}
states = {"available": tr("available"), "installed": tr("installed"),
"update available": tr("update available"),
"incompatible": tr("incompatible")}
self.table.setRowCount(0)
self.table.setRowCount(len(self._rows))
for index, row in enumerate(self._rows):
values = (kinds.get(row["kind"], row["kind"]), row["name"],
row["version"], row["installed"],
states.get(row["status"], row["status"]),
row["author"], row["licence"])
for column, value in enumerate(values):
item = table_item(str(value))
item.setData(Qt.UserRole, row["key"])
item.setToolTip(row["summary"] or row["name"])
self.table.setItem(index, column, item)
_settle_sorting(self.table)
self.table.resizeColumnsToContents()
self.status.setText(tr("{count} entries in the catalogue.")
.format(count=len(self._rows)))
self._sync_buttons()
return True
def _select_key(self, key: str) -> None:
"""Select the row for ``key`` again after the table is refilled."""
for index in range(self.table.rowCount()):
item = self.table.item(index, 0)
if item is not None and item.data(Qt.UserRole) == key:
self.table.selectRow(index)
return
def _act(self, install: bool) -> bool:
"""Start one catalogue action; return whether it was accepted."""
from .i18n import tr
row = self.selected()
if row is None or self._job is not None:
return False
self._job = _PluginCatalogueJob(
row, self.source.text().strip() or None, install)
self.status.setText(tr("Working…"))
self._sync_buttons()
self._job.start()
self._job_timer.start()
return True
def _finish_job(self):
"""Present a finished operation without coupling its lifetime to Qt.
A programmatic source change can occur while controls are disabled; the
old request's results or error are never attached to the new source.
"""
from .i18n import tr
job = self._job
if job is None or job.is_alive():
return False
self._job_timer.stop()
self._job = None
if job.install is None:
if (self.source.text().strip() or None) != job.source:
self.status.clear()
self._sync_buttons()
return False
self._show_rows(job.rows if job.error is None else [])
if job.error is not None:
self.status.setText(tr("Could not read the catalogue: {error}").format(error=job.error))
return True
row = job.row
if job.error is not None:
message = tr("{name} failed: {error}").format(name=row["name"], error=job.error)
else:
if job.install:
message = tr("Installed {name} {version}.").format(
name=row["name"], version=job.record["version"])
if row["kind"] == "recipe":
message += " " + tr("Its settings are in {path}.").format(path=job.record["path"])
else:
message = tr("Uninstalled {name}.").format(name=row["name"])
if job.rows is not None:
self._show_rows(job.rows)
self._select_key(row["key"])
elif job.refresh_error:
rows = [dict(item) for item in self._rows]
for item in rows:
if item["key"] == row["key"]:
item["installed"] = str(job.record["version"]) if job.install else ""
item["status"] = ("installed" if job.install else
"incompatible" if item["status"] == "incompatible" else "available")
self._show_rows(rows)
self._select_key(row["key"])
message += " " + tr("Could not read the catalogue: {error}").format(error=job.refresh_error)
self.status.setText(message)
self._sync_buttons()
return True
def install_selected(self) -> bool:
"""Start installing the selected entry; True when the action started."""
return self._act(True)
def uninstall_selected(self) -> bool:
"""Start uninstalling the selected entry; True when the action started."""
return self._act(False)
def _install_run_notifier(app=None):
"""Let run-finished notifications reach this app's desktop.
Installs the desktop sender the run journal calls from whichever thread
closed the run. The message is carried to the GUI thread and shown from
a system tray icon, or, where the desktop has no tray, by the operating
system's own notification command.
:param app: the ``QApplication``; falls back to the running one.
:returns: the relay object, or ``None`` without an application.
"""
import threading
from PySide6.QtCore import QObject, QTimer, Signal, Slot
from PySide6.QtWidgets import QApplication, QStyle, QSystemTrayIcon
from .. import run_journal
app = app or QApplication.instance()
if app is None:
return None
existing = app.findChild(QObject, "RunFinishedNotifier")
if existing is not None:
run_journal._DESKTOP_NOTIFIER[0] = existing.arrived.emit
return existing
def _without_qt(title: str, body: str) -> None:
"""The operating system's notification, any failure logged."""
try:
run_journal._desktop_os_notify(title, body)
except Exception as exc:
LOG.info("no desktop notification shown (%s)", exc)
class _RunFinishedRelay(QObject):
"""Carries a message from the thread that sent it to the GUI thread."""
arrived = Signal(str, str, bool)
def __init__(self, parent) -> None:
"""Listen for messages on the thread this object lives in."""
super().__init__(parent)
self.setObjectName("RunFinishedNotifier")
self._tray = None
self.arrived.connect(self._show)
@Slot(str, str, bool)
def _show(self, title: str, body: str, failed: bool) -> None:
"""Show one message from the tray, or without Qt when none."""
try:
if not QSystemTrayIcon.isSystemTrayAvailable():
threading.Thread(target=_without_qt, args=(title, body),
daemon=True).start()
return
if self._tray is None:
icon = app.windowIcon()
if icon.isNull():
icon = app.style().standardIcon(
QStyle.StandardPixmap.SP_MessageBoxInformation)
self._tray = QSystemTrayIcon(icon, self)
self._tray.setToolTip("spaCR")
self._tray.show()
self._tray.showMessage(
title, body,
(QSystemTrayIcon.MessageIcon.Critical if failed
else QSystemTrayIcon.MessageIcon.Information), 15000)
QTimer.singleShot(20000, self._tray.hide)
except Exception:
LOG.debug("could not show the run notification",
exc_info=True)
relay = _RunFinishedRelay(app)
run_journal._DESKTOP_NOTIFIER[0] = relay.arrived.emit
return relay
[docs]
def color_blind_continuous_cmap() -> str:
"""Return a matplotlib colormap name safe for the active CB mode.
* Off → the current default (``"viridis"`` is already CB-safe but
keeping the app's default until the user asks otherwise).
* Any CB mode → ``"cividis"`` (viridis's cousin, tuned for
protanopia + deuteranopia + tritanopia).
"""
return "cividis" if get_color_blind_mode() != "off" else "viridis"
def _restore_brand_palette_once() -> None:
"""Restore requested standard colors once, then retain later choices."""
settings = _settings()
key = "prefs/brand_palette_revision"
if _as_bool(settings.value(key, False), False):
return
settings.setValue(_KEY_THEME, DEFAULT_THEME)
settings.setValue(_KEY_AMBIENT_PALETTE, "spacr")
for color_key in (_KEY_AMBIENT_BACKGROUND, "prefs/ambient_primary", "prefs/ambient_accent"):
settings.remove(color_key)
settings.setValue(key, True)
settings.sync()
[docs]
def apply_preferences_to_app(app=None) -> None:
"""Re-apply language, theme and font scale to ``QApplication``.
Called at startup from :func:`spacr.qt.app.launch`, and again
whenever the user changes a preference (via :class:`PreferencesDialog`
``accepted`` signal).
:param app: optional QApplication. Falls back to
``QApplication.instance()``.
"""
from PySide6.QtWidgets import QApplication
from .theme import (
apply_qpalette,
apply_stylesheet_per_window,
clear_widget_qss_overlays,
set_widget_qss_context,
stylesheet,
widget_qss_names,
window_stylesheet,
)
app = app or QApplication.instance()
if app is None:
return
_restore_brand_palette_once()
app.setProperty("spacrLanguage", get_language())
_apply_network_preferences()
try:
apply_workspace_preference()
except Exception: # noqa: BLE001
LOG.debug("could not push the workspace preference", exc_info=True)
theme = resolve_effective_theme()
scale = get_font_scale()
pane_opacity = get_pane_opacity()
background = theme_background_path(theme)
try:
from .widgets.field_fade import (install_field_fade,
invalidate_field_fade)
invalidate_field_fade()
install_field_fade(app)
except Exception:
LOG.exception("Could not install the field fade")
try:
from .tooltip_policy import (install_tooltip_policy,
invalidate_tooltip_policy)
invalidate_tooltip_policy()
install_tooltip_policy(app)
except Exception:
LOG.exception("Could not install the tooltip policy")
style_signature = (
str(theme),
float(scale),
pane_opacity,
str(background or ""),
bool(get_field_fade_enabled()),
widget_qss_names(),
)
style_changed = (
getattr(app, "_spacr_preferences_style_signature", None)
!= style_signature
or window_stylesheet(app)
!= getattr(app, "_spacr_preferences_stylesheet", None)
or bool(app.styleSheet())
)
if style_changed:
set_widget_qss_context(app, theme, scale, pane_opacity)
apply_qpalette(app, theme=theme,
follow_system=get_theme() == "system")
sheet = stylesheet(
theme=theme, font_scale=scale, background=background,
surface_opacity=pane_opacity, load_widget_registrars=False)
clear_widget_qss_overlays(app)
apply_stylesheet_per_window(app, sheet)
setattr(app, "_spacr_preferences_style_signature", style_signature)
setattr(app, "_spacr_preferences_stylesheet", sheet)
try:
from .widgets.field_fade import repaint_fields
repaint_fields(app)
except Exception:
pass
try:
import sys as _sys
if _sys.modules.get(__package__ + ".widgets.preview_scale"):
from .widgets.preview_scale import refresh_all_preview_scales
refresh_all_preview_scales()
except Exception: # noqa: BLE001
LOG.debug("could not re-scale the previews", exc_info=True)
from .button_roles import install_button_roles
install_button_roles(app)
apply_ambient_preferences(app)
try:
import sys as _sys
if (_sys.modules.get(__package__ + ".sound") is not None
or get_sound_enabled()):
from .sound import apply_sound_preferences
apply_sound_preferences(app)
except Exception: # noqa: BLE001
LOG.debug("could not apply the sound preferences", exc_info=True)
try:
_rescale_icon_sizes(app)
except Exception: # noqa: BLE001
LOG.debug("could not re-derive the icon sizes", exc_info=True)
try:
from .widgets.console_panel import ConsolePanel
for widget in app.allWidgets():
if isinstance(widget, ConsolePanel):
widget.apply_zoom()
except Exception:
pass
try:
from .verbose_logger import (
apply_verbose_logging, _ensure_file_handler,
)
_ensure_file_handler()
apply_verbose_logging(get_verbose_logging())
from ..logging_util import apply_level_policy
apply_level_policy(get_log_file_levels(), get_log_console_levels())
except Exception:
pass
[docs]
def confirm_resource_action(action: str, parent=None) -> bool:
"""Ask before doing ``action``, by saying what it will do.
The dialog states the steps in the order they will happen and what the
action cannot do (see
:func:`spacr.qt.resource_cleanup.confirmation_text`), and its accept
button is labelled with the action rather than "OK". "Are you sure?"
is not a question anybody can answer: a user cannot consent to an
unnamed action, and a button called OK does not name one.
Cancel is the default, so a stray Return key does nothing.
:param action: which clean-up to confirm: ``"ram"``, ``"vram"``, ``"cpu"``
or ``"disk"``.
:param parent: the widget the message box is parented to, or ``None``.
:returns: ``True`` only if the user explicitly accepted.
"""
from PySide6.QtWidgets import QMessageBox
from . import resource_cleanup
from .i18n import tr
title = resource_cleanup.confirmation_title(action)
box = QMessageBox(parent)
box.setObjectName("ResourceActionConfirm")
box.setIcon(QMessageBox.Question)
box.setWindowTitle(tr(title))
box.setText(tr(title))
box.setInformativeText(tr(resource_cleanup.confirmation_text(action)))
proceed = box.addButton(tr(title), QMessageBox.AcceptRole)
cancel = box.addButton(tr("Cancel"), QMessageBox.RejectRole)
box.setDefaultButton(cancel)
box.exec()
return box.clickedButton() is proceed
def _show_resource_result(action: str, result, parent=None) -> None:
"""Report what actually happened. Split out so a test can silence it."""
from PySide6.QtWidgets import QMessageBox
from . import resource_cleanup
from .i18n import tr
box = QMessageBox(parent)
box.setObjectName("ResourceActionResult")
box.setIcon(QMessageBox.Information)
box.setWindowTitle(tr(resource_cleanup.confirmation_title(action)))
box.setText(result.summary())
details = getattr(result, "details", ())
if details:
box.setDetailedText("\n".join(details))
box.exec()
#: The one worker the disk readout uses, for every caller.
#:
#: MODULE-SCOPED ON PURPOSE, AND NOT PARENTED TO THE DIALOG. A JobRunner
#: collected while its QThread is still running ABORTS the process (see
#: :mod:`spacr.qt.job_runner`), and a runner parented to the Preferences
#: dialog is held alive by nothing else: closing Preferences during a slow
#: read releases the dialog, its Python attributes, the runner and finally
#: the QThread wrapper -- while the stat is still in the kernel. Twenty
#: seconds on a sleeping automount is exactly the window in which a user
#: gives up and closes the dialog, so that is the likely case, not the
#: unlikely one. Held here instead, the runner outlives every dialog and the
#: report is dropped by :func:`_still_asking` rather than by a crash.
_DISK_RUNNER = None
#: Whether :data:`_DISK_RUNNER` was built to use a thread.
_DISK_RUNNER_THREADED = False
#: Runners replaced by :func:`_disk_report_runner`, kept forever. See there.
_RETIRED_DISK_RUNNERS = []
def _disk_report_runner():
"""The worker that reads the disk, made once for the whole module.
``user_visible=False``: the run banner on Home is for module runs, and
``spacr/qt/widgets/home.py`` filters on exactly this flag. This runner
carries the disk readout and nothing else -- it is not shared with any
user-started job that a banner would then hide -- and the readout is a
handful of stat calls behind a modal dialog that covers Home anyway. The
activity spinner still turns, so something IS visibly running.
Unthreaded when there is no ``QApplication``, because then there is no
event loop to deliver the callback on -- and no GUI thread to protect
either, which is the only reason the thread was wanted.
"""
global _DISK_RUNNER, _DISK_RUNNER_THREADED
from PySide6.QtWidgets import QApplication
from .job_runner import JobRunner
threaded = QApplication.instance() is not None
if _DISK_RUNNER is None or _DISK_RUNNER_THREADED != threaded:
if _DISK_RUNNER is not None:
try:
_DISK_RUNNER.cancel()
except RuntimeError:
pass
_RETIRED_DISK_RUNNERS.append(_DISK_RUNNER)
_DISK_RUNNER = JobRunner(None, threaded=threaded,
app_key="disk report", user_visible=False)
_DISK_RUNNER_THREADED = threaded
return _DISK_RUNNER
def _disk_button(parent=None):
"""The "Check disk space" button inside ``parent``, if it is there."""
if parent is None:
return None
try:
from PySide6.QtWidgets import QPushButton
return parent.findChild(QPushButton, "CheckDiskButton")
except (AttributeError, RuntimeError):
return None
def _still_asking(parent) -> bool:
"""True while ``parent`` is still on screen to be answered.
A report that lands after the user closed Preferences has no one left to
show it to: the question was abandoned, and a message box arriving out
of a dialog that is gone is not the answer to anything. ``None`` -- the
caller that owns no dialog -- is always answered.
"""
if parent is None:
return True
try:
return bool(parent.isVisible())
except RuntimeError:
return False
except AttributeError:
return True
def _start_disk_report(parent=None) -> None:
"""Read the disk on a worker thread and report it when it lands.
WHY THIS IS NOT A PLAIN CALL, which is what it was until 2026-09-04.
:func:`spacr.qt.resource_cleanup.disk_report` asks
:func:`~spacr.qt.resource_cleanup.project_paths` for every folder the
project touches — the source folders every module remembers, read back
out of QSettings — and then does ``os.stat`` and ``shutil.disk_usage`` on
each one. Those are paths the USER chose. Measured on one workstation that
day: one of them was under ``/nas_mnt``, an ``autofs`` mount
whose share was asleep, and a single stat on it had NOT RETURNED AFTER
TWENTY SECONDS — the stat is what triggers the automount.
Run from the button's ``clicked`` slot, that is the whole interface
frozen between the confirmation box and the result box, with no traceback
to show for it, because a stalled event loop is not a crash.
:mod:`spacr.qt.path_probe` is the wrong tool here and deliberately not
used: it answers a cheap yes/no optimistically from a cache, and a disk
readout needs real device ids and real byte counts. Work that must
genuinely touch the disk belongs on a worker, not behind a cache.
``DiskReport`` and ``DiskEntry`` are frozen dataclasses of plain numbers,
so the result crosses the thread boundary safely and the message box is
still opened on the GUI thread, by the callback.
THE FAILURE PATH IS THE CALLBACK PATH. ``JobRunner`` calls ``on_done``
only for a job that succeeded, so a worker that raised would leave the
button disabled and reading "Reading the disk…" for the rest of the
session — the one failure a user cannot recover from, because the button
that would retry is the one that is stuck. The read is therefore wrapped
so the worker returns its exception instead of raising it, and the
callback always runs: it gives the button back first and decides what to
show second.
"""
from . import resource_cleanup
from .i18n import tr
button = _disk_button(parent)
resting_tip = None
if button is not None:
try:
resting_tip = button.toolTip()
button.setEnabled(False)
button.setToolTip(tr("Reading the disk…"))
except RuntimeError:
button = None
def read():
"""On the worker thread. Returns the report, or the exception.
Returned rather than raised so the callback below is reached either
way; see the failure paragraph above. ``disk_report`` is looked up
here, not captured, so a caller that replaces it still gets its own.
"""
try:
return resource_cleanup.disk_report()
except Exception as exc: # noqa: BLE001 — returned, not swallowed
return exc
def restore() -> None:
"""Give the button back. The C++ half may be gone; that is fine."""
if button is None:
return
try:
button.setEnabled(True)
button.setToolTip(resting_tip or "")
except RuntimeError:
pass
def done(report) -> None:
"""On the GUI thread, with whatever the worker came back with."""
restore()
if isinstance(report, BaseException):
LOG.warning("the disk could not be read", exc_info=report)
return
if not _still_asking(parent):
LOG.debug("the disk report outlived the dialog that asked for it")
return
try:
_show_resource_result("disk", report, parent)
except RuntimeError:
LOG.debug("the disk report outlived its dialog", exc_info=True)
if not _disk_report_runner().submit(read, done):
restore()
[docs]
def run_resource_action(action: str, parent=None):
"""Confirm ``action``, run it, and report the measured result.
:param action: which clean-up to run: ``"ram"``, ``"vram"``, ``"cpu"`` or
``"disk"``.
:param parent: the widget the confirmation and result dialogs are parented
to, or ``None``.
:returns: the :class:`~spacr.qt.resource_cleanup.Reclaim` for "ram",
"vram" and "cpu", or ``None`` when the user declined — in which case
**nothing ran**. The confirmation is asked before any work is
started, not after, which is the whole point of asking.
``None`` for "disk" as well, and that one is not a refusal: the disk
readout stats folders the user chose, so it goes to a worker thread
(:func:`_start_disk_report`) and its result arrives in the same
message box a moment later rather than in this return value. The
other three free memory and threads and touch no path, so they stay
inline where their before/after measurements are taken.
"""
from . import resource_cleanup
if not confirm_resource_action(action, parent):
return None
if action == "disk":
_start_disk_report(parent)
return None
result = {
"ram": lambda: resource_cleanup.clear_ram(aggressive=True),
"vram": resource_cleanup.clear_vram,
"cpu": resource_cleanup.clear_cpu,
}[action]()
_show_resource_result(action, result, parent)
return result
#: What each Preferences row means, keyed by the label it carries.
#:
#: ON THE LABEL, NOT THE FIELD, and that is the house rule everywhere in
#: spaCR: the words are what a reader points at when they want to know
#: what something is, and a tooltip on the control is one they find only
#: after reaching for it. :func:`explain_every_row` moves any that were
#: put on a field, so a row explained either way ends up explained the
#: same way.
PREFERENCE_TIPS = {
"Debug": "Record all diagnostic messages, including internal run steps. This produces large logs and is recommended when preparing a bug report.",
"Info": "Record one message for each major run step. Recommended for routine use.",
"Warning": "Record conditions that may require review or intervention.",
"Error": "Record failed operations.",
"Critical": "Record failures that stop the run.",
"Font family": "Typeface used for text in saved figures.",
"Font size": "Base font size for saved figures. Title, label and tick sizes are scaled relative to this value.",
"Title size": "Figure-title size relative to the base font size.",
"Label size": "Axis-label size relative to the base font size.",
"Tick size": "Axis-tick label size relative to the base font size.",
"Palette": "Sequence of colours used when a figure contains multiple series.",
"Background": "Figure background colour. A transparent background uses the colour of the destination document or interface.",
"Foreground": "Axis lines, ticks and text.",
"Chrome colour": "Colour used for the figure frame, axis ticks and spines.",
"Mark colouring": "Assign point colours from the palette or from a selected data column.",
"Grid": "Draw grid lines behind the data.",
"Grid colour": "Colour used for grid lines.",
"Grid width": "Grid-line width in points.",
"Grid style": "Solid, dashed or dotted.",
"Spines": "Select which of the four plot-frame edges are drawn.",
"Spine width": "Plot-frame edge width in points.",
"Marker size": "Plotted-point area in points squared.",
"Marker style": "Shape used for plotted points.",
"Line width": "Plotted-line width in points.",
"Jitter width": "Horizontal displacement applied to overlapping points.",
"Point alpha": "Point opacity. Values below 1 make overlapping points appear darker.",
"Fill colour": "Interior colour of bars and histogram bins.",
"Edge colour": "Outline colour of bars and histogram bins.",
"Edge width": "Outline width in points.",
"Error bars": "Statistic represented by error bars: standard deviation, standard error or confidence interval.",
"Reference style": "Line style used for reference or baseline values.",
"Reference colour": "Reference-line colour.",
"Trend line": "Fit and draw a trend line through the points.",
"Trend colour": "Trend-line colour.",
"Threshold style": "Line style used for significance or effect-size thresholds.",
"Threshold colour": "Threshold-line colour.",
"Threshold width": "Threshold-line width in points.",
"Bins": "Number of intervals used to partition a histogram's range.",
"Log y": "Use a logarithmic y-axis scale.",
"Split axis": "Omit an intermediate axis interval when one group is separated substantially from the others.",
"Centred": "Centre a diverging colour scale on zero so colour indicates the sign of each value.",
"Colormap": "Colour scale used to represent continuous values.",
"Annotate": "Display values on the plot.",
"Annotate cells": "Display each cell's value in a heatmap.",
"Label top n": "Number of highest-ranked points labelled by name.",
"Legend": "Display a legend at the selected location.",
"Per row": "Number of panels in each row of a grid figure.",
"Lock axis scales": "Whether one y unit is drawn the same length as one x "
"unit ('equal', which is what keeps a plate's wells "
"square), or the panel is filled instead ('auto'). This "
"locks the axis scales, which is a statement about the "
"data; the proportions of the figure are 'Page shape'.",
"Page shape": "Aspect of the saved page.",
"Dpi": "Resolution of the saved image in pixels per inch. A value of 300 is commonly required for print.",
"Format": "File format used when saving figures.",
"Tight layout": "Adjust margins to fit labels within the saved figure.",
"Theme": "Application colour scheme. 'Follow system' uses the desktop colour scheme.",
"Font scale": "Scale interface text independently of saved-figure font sizes.",
"GUI scale": GUI_SCALE_TIP,
"Colour-blind mode": "Use interface and figure colours designed to remain distinguishable for common colour-vision deficiencies.",
"Module visibility": "Select the module maturity levels shown in navigation: stable only, or stable with beta and alpha modules.",
"Show busy spinner after": "Delay before displaying the busy indicator for a running task.",
"Tooltip delay": "Seconds the pointer rests on a control before its tooltip appears. 0 shows tooltips at once. Default 2.0 s.",
"Page opacity": "Page opacity relative to the animated background.",
"Animation detail": "Backdrop rendering detail. Reduce this value if animation affects interface performance.",
"Animation colours": "Choose the primary and accent colours for the Custom colours palette. Changes apply when you save Preferences.",
"Animation background": "Choose a background for every animation, independent of its palette. It follows the active page theme until chosen; its brightness stays on the active Dark or Light side. Changes apply when you save Preferences.",
"Mouse gravity radius": "How far mouse gravity reaches, as a percentage of the shorter screen edge. Zero disables mouse influence. Applies to backgrounds that respond to the mouse.",
"Field ripples": "Ripples from clicks, opening or closing containers, and window snapping. Independent of mouse gravity.",
"Pattern": "Which fractal spaceout draws. Orbit fold is an orbit-fold map antialiased across four frames; fold-inversion cascade is a Kaliset-like fold and sphere inversion coloured by three orbit traps, travelling through two overlapping scale windows so it never resets. The cascade takes four samples of one instant per pixel, so it costs about four times as much and runs at a lower frame rate by design. Space is forward flight through a dark star field with six parallax layers and three object slots that pass by -- mostly stars, occasionally a lit planet or a bright sun. It is mostly empty sky, so it is the cheapest option and the one that competes least with what you are reading. Mandelbrot is a continuous deep zoom into one point on the set's boundary, rendered by perturbation around a high-precision reference orbit -- which is what lets it keep descending past the depth a float can address, hundreds of decades in, still finding structure. GPU only: it needs a texture of the reference orbit.",
"Backend": "Which renderer draws the fractal. GPU is a shader and is far cheaper; it needs vispy and a real display, and falls back to the CPU renderer when either is missing. Automatic picks the GPU when it can and says below which one this machine will get.",
"Quality": "How much detail the fractal is asked for. Balanced costs less per frame; high adds an iteration to the fractal and raises the internal resolution. Automatic chooses from the number of cores on the CPU renderer and uses balanced on the GPU.",
"Scale": "A resource multiplier for the CPU renderer's internal resolution, applied before it adapts. Below 1.0 draws fewer pixels and scales them up; above 1.0 draws more. It does not change what the fractal looks like, only how finely it is sampled. The GPU renderer ignores it.",
"Speed": "How fast the view travels inward. It scales the depth the fractal is sampled at, so a higher number moves through the structure sooner; it does not change the frame rate or the cost of a frame.",
"Dream": "How much the pattern warps, drifts and shears as it travels. 0.0 is a still camera moving straight in; 1.5 is the default, and higher values exaggerate the motion without a fixed ceiling. It costs nothing extra to raise.",
"Variable speed": "Let the travel speed breathe instead of holding one value. It modulates the speed above rather than replacing it, so the number you set is still the middle of the range.",
"Interface font": "The weight the interface is drawn in. spaCR ships Open Sans and uses it everywhere, so the application looks the same whatever fonts the machine has. Light is thinner and suits a large high-resolution display; Regular is easier to read on a small or low-resolution one. Bold stays available to anything that asks for emphasis.",
"Animation speed": "Background-animation speed.",
"Animation size": "Size of background shapes.",
"Animation density": "Number of background shapes.",
"Dot blinking": "Percentage of visible dots that flash white. Applies to dot animations; density and movement stay unchanged.",
"Popup wave frequency": "Waves per minute spreading from the centre of an open popup window into spaCR field. Zero disables automatic waves; mouse gravity is independent.",
"Settings animation speed": "Speed of the settings-window animation, independent of the main background.",
"Settings animation size": "Element size in the settings-window animation, independent of the main background.",
"Settings animation detail": "Rendering detail in the settings-window animation, independent of the main background.",
"Settings animation density": "Number of elements in the settings-window animation, independent of the main background.",
"Settings backdrop darkness": "Opacity of the settings card over its animation. Higher values cover more of the backdrop so text is easier to read. Combined with Page opacity.",
"Rim length": "Fraction of a card border covered by the moving highlight.",
"Rim chase": "Responsiveness of the border highlight to pointer movement.",
"Rim cycle": "Duration of one border-highlight cycle.",
}
def _widget_is_alive(widget) -> bool:
"""Whether ``widget``'s C++ half still exists.
The same check :func:`spacr.qt.screens.settings_model._widget_is_alive`
and ``live_zoom._alive`` make, for the same reason: a Python wrapper
outlives the object it wraps, and reading through it is undefined rather
than an exception.
:param widget: any Qt object, or None.
:returns: whether it is safe to touch.
"""
if widget is None:
return False
try:
from shiboken6 import isValid
return bool(isValid(widget))
except Exception: # noqa: BLE001
try:
widget.objectName()
return True
except RuntimeError:
return False
[docs]
def explain_every_row(dialog) -> int:
"""Put a tooltip on every Preferences LABEL. Returns how many it set.
Two jobs, and the second is why this walks the finished dialog rather
than being written at each call site: it fills in from
:data:`PREFERENCE_TIPS`, and it MOVES a tooltip that was put on the
control to the label beside it. A row explained either way ends up
explained the same way, and a row added later without a tooltip is
reported by the test rather than passing unnoticed.
:param dialog: the finished Preferences dialog; every ``QFormLayout``
inside it is walked, and each row whose label is a ``QLabel`` is given
a tooltip when one is known.
"""
from PySide6.QtWidgets import (QFormLayout, QLabel, QPushButton,
QToolButton)
from .widgets.hint_bar import explain_through_the_bar
from .i18n import tr
explained = 0
for form in dialog.findChildren(QFormLayout):
for index in range(form.rowCount()):
label_item = form.itemAt(index, QFormLayout.LabelRole)
field_item = form.itemAt(index, QFormLayout.FieldRole)
if label_item is None or field_item is None:
continue
label = label_item.widget()
field = field_item.widget()
if not _widget_is_alive(label) or not _widget_is_alive(field):
continue
if not isinstance(label, QLabel) or field is None:
continue
text = (label.text() or "").replace("&", "").strip()
tip = PREFERENCE_TIPS.get(text, "")
is_action = isinstance(field, (QPushButton, QToolButton))
if not tip:
tip = (field.toolTip() or "").strip()
if not tip:
continue
label.setToolTip(tr(tip))
if is_action:
explain_through_the_bar(field)
else:
field.setToolTip("")
explained += 1
return explained
_PREFERENCES_WINDOW_CLASS = None
_PAGE_STAND_IN_CLASS = None
def _page_stand_in_class():
"""The placeholder a tab holds while its page waits. Made once, on first use.
:returns: a ``QWidget`` subclass, built with no arguments.
"""
global _PAGE_STAND_IN_CLASS
if _PAGE_STAND_IN_CLASS is not None:
return _PAGE_STAND_IN_CLASS
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QWidget
class _PageStandIn(QWidget):
"""Holds a tab's place until its page comes back, asking for the most.
The dialog opens at the size of its largest tab, and a tab's scroll
area caps what its page may ask for at 36 by 24 lines of text. The
Figures page is always taller than the cap, so the dialog always
opened at the cap's height; this keeps that. Its width was the
widest page's, 548 px at an 18 px line where the cap is 648, and
what a page measures across is known only once it is styled -- the
cost this stand-in exists to put off. Asking the waiting page is no
answer either: a page that has never been shown lays out as empty,
because Qt leaves out widgets that have not been shown yet.
SO IT ASKS FOR THE CAP BOTH WAYS, and the dialog opens about 100 px
wider than it did, the same at every open and in every language.
Narrower was measured and is worse: at the open page's width the
resize filter judges the dialog stuck at its contents and wraps the
whole window in a second scroll area.
"""
def sizeHint(self): # noqa: N802 - Qt naming
"""Larger than any scroll area lets a page ask to be."""
return QSize(1 << 20, 1 << 20)
_PAGE_STAND_IN_CLASS = _PageStandIn
return _PAGE_STAND_IN_CLASS
def _preferences_window_class():
"""The dialog class Preferences is built on. Made once, on first use.
Qt is imported here rather than at module scope, for the reason
:class:`PreferencesDialog` gives.
:returns: a ``QDialog`` subclass.
"""
global _PREFERENCES_WINDOW_CLASS
if _PREFERENCES_WINDOW_CLASS is not None:
return _PREFERENCES_WINDOW_CLASS
from PySide6.QtWidgets import QDialog, QWidget
class _PreferencesWindow(QDialog):
"""The Preferences window, which styles one tab when it opens.
THE OPEN WAS THE SHOW, NOT THE BUILD. Building the dialog took
80-150 ms at load 15-21; showing it took 400-550 ms, because the
show styles every widget on every tab -- the window's stylesheet,
the glass card's transparent containers, the resize filter's polish
and the first-show translation each walk all ~755 widgets, and
nobody can see more than one tab. Figures alone is 386 of them.
So a tab nobody is looking at gives its page up for the first show
and gets it back the moment it is chosen: the page leaves its scroll
area, parentless and hidden, as a closed settings category's body
does (:meth:`spacr.qt.widgets.section.Section._detach_body_while_hidden`),
and a :func:`_page_stand_in_class` holds its place. Coming back, it
is styled by the window's sheet, given what the glass gave the rest
of the dialog, and translated, in the click that chose it.
NOTHING IS BUILT LATER. Every control on every page is made in the
build as it always was, and Save, Reset and Cancel read and write
those same controls whether their page is in the window or waiting,
so a page nobody opened saves exactly what it was built with.
AND PYTHON STILL SEES THE WHOLE DIALOG. ``findChild`` finds a
control on a waiting page and brings that page back, and
``findChildren`` brings every page back before it walks, so code
that finds a control by its object name (item 569's switch) or walks
the dialog sees what it saw before -- except during the first show,
when the walks are the sheet's, the glass's and the resize filter's
and not bringing the pages back is the point. Qt's own C++ searches
see only the window, which is what they style.
"""
def __init__(self, parent=None):
"""An empty Preferences window; the builder fills it.
:param parent: the owning window, or ``None``.
"""
super().__init__(parent)
self._page_tabs = None
self._pages_wait_for_the_show = False
self._window_only = 0
self._pages_away = {}
def _show_only_the_open_page_at_first(self, tabs) -> None:
"""Have the first show style only the page of the current tab.
Called once the build is finished. The pages stay where they
are until the show, so the navigation that picks the tab to
open on finds each control on its tab.
:param tabs: the dialog's ``QTabWidget``; every page is a scroll
area holding the page.
"""
self._page_tabs = tabs
self._pages_wait_for_the_show = True
tabs.currentChanged.connect(self._bring_the_page_back)
def setVisible(self, visible): # noqa: N802 - Qt naming
"""Send the unseen pages away just before the first show.
HERE, AND NOT ON THE FIRST ``Polish``. The window sheet, the
glass and the resize filter all act on that event from the
application, which hears it before the window does, and the
resize filter polishes every child as it does. Qt's own show
begins in this call, so the pages are gone before any of them
runs.
:param visible: as for ``QWidget.setVisible``.
"""
if not (visible and self._pages_wait_for_the_show):
super().setVisible(visible)
return
self._pages_wait_for_the_show = False
self._send_the_unseen_pages_away()
self._window_only += 1
try:
super().setVisible(visible)
finally:
self._window_only -= 1
def findChild(self, *args, **kwargs): # noqa: N802 - Qt naming
"""``QObject.findChild``, finding a control whose page is waiting.
A control looked up by its object name is the dialog's wherever its page
is, so what the builder, Save and a test find by name does not
depend on which tabs have been chosen. The page it is on comes
back into its tab, hidden unless its tab is current, as it was
before pages waited: a caller that goes on to click or read the
geometry of what it found is holding a widget in the window.
:returns: the first match, or ``None``.
"""
found = super().findChild(*args, **kwargs)
if found is not None or self._window_only:
return found
for index, page in list(self._pages_away.items()):
found = page.findChild(*args, **kwargs)
if found is not None:
self._bring_the_page_back(index)
return found
return None
def findChildren(self, *args, **kwargs): # noqa: N802 - Qt naming
"""``QObject.findChildren`` over every page, waiting or not.
A walk of the dialog from Python -- a test's, or code that
looks at every control -- sees the dialog it saw before pages
waited, so every waiting page comes back first. Not during the
first show: the window sheet, the glass and the resize filter
walk the dialog then, and bringing the pages back for them is
the cost the waiting exists to save. Nor while a page is coming
back, whose glass asks the dialog for its card.
:returns: every match.
"""
if not self._window_only:
for index in list(self._pages_away):
self._bring_the_page_back(index)
return super().findChildren(*args, **kwargs)
def _send_the_unseen_pages_away(self) -> int:
"""Take every page but the current tab's out of the window.
:returns: how many widgets left the window.
"""
tabs = self._page_tabs
if tabs is None:
return 0
current = tabs.currentIndex()
stand_in = _page_stand_in_class()
moved = 0
for index in range(tabs.count()):
if index == current or index in self._pages_away:
continue
scroll = tabs.widget(index)
page = scroll.widget() if scroll is not None else None
if page is None:
continue
moved += len(page.findChildren(QWidget)) + 1
scroll.takeWidget()
page._spacr_detached_from = scroll
page.setVisible(False)
self.destroyed.connect(page.deleteLater)
scroll.setWidget(stand_in())
self._pages_away[index] = page
return moved
def _bring_the_page_back(self, index) -> bool:
"""Put a waiting page back in its tab as the tab is chosen.
:param index: the tab just chosen.
:returns: ``True`` when this call put a page back.
"""
page = self._pages_away.pop(index, None)
if page is None:
return False
scroll = self._page_tabs.widget(index)
try:
self.destroyed.disconnect(page.deleteLater)
except (RuntimeError, TypeError):
pass
page._spacr_detached_from = None
self._window_only += 1
try:
holder = scroll.takeWidget()
scroll.setWidget(page)
if holder is not None:
holder.deleteLater()
_what_a_page_missed_while_away(self, page)
finally:
self._window_only -= 1
return True
_PREFERENCES_WINDOW_CLASS = _PreferencesWindow
return _PREFERENCES_WINDOW_CLASS
def _what_a_page_missed_while_away(dialog, page) -> None:
"""Give a page coming back what the dialog's first show gave the rest.
The first-show translation pass and the glass card's treatment of the
containers and buttons each walked the dialog as it was shown, and a
page waiting outside it was not there to be walked.
:param dialog: the Preferences window.
:param page: the page just put back in its tab.
"""
try:
from .i18n import retranslate_widget_tree, ui_language_resolved_once
with ui_language_resolved_once():
retranslate_widget_tree(page)
except Exception: # noqa: BLE001
LOG.debug("a Preferences page was not translated", exc_info=True)
try:
from .widgets.glass import _glass_a_part_that_came_later
_glass_a_part_that_came_later(dialog, page)
except Exception: # noqa: BLE001
LOG.debug("a Preferences page was not glassed", exc_info=True)
[docs]
class PreferencesDialog:
"""Wrapper that builds the modal Preferences dialog on demand.
Kept as a factory (not a real class subclass) so this module can
be imported headless without pulling in QtWidgets. The real
:class:`QDialog` -- the subclass :func:`_preferences_window_class`
makes on first use -- is returned by ``PreferencesDialog(parent)``.
"""
[docs]
def __new__(cls, parent=None):
"""Build the dialog with the UI language resolved once.
The scope is the whole reason this wrapper exists. Building this
dialog was measured asking the preference store what language the
interface is in 346 times, through 415 ``QSettings`` reads, and none
of those answers could differ: nothing runs between them.
:param parent: parent widget, or ``None``.
:returns: the dialog, ready to ``exec``.
"""
from .i18n import ui_language_resolved_once
global _settings
shadowed = _settings
if _SAFE_MODE:
_settings = lambda: QSettings(*_store_args(_ORG, _APP))
try:
with ui_language_resolved_once():
return cls._build_the_dialog(parent)
finally:
_settings = shadowed
@classmethod
def _build_the_dialog(cls, parent=None):
"""Build and return the preferences dialog.
A ``__new__`` returning a ``QDialog`` rather than an ``__init__`` on a
subclass of one: everything Qt is imported inside the call, so
importing this module costs nothing until a dialog is actually asked
for.
The window is detached from the window manager's point of view, so the
user can put it where they like -- it is still parented, still modal and
still calls ``exec``, and only the window TYPE changes.
:param parent: parent widget, or ``None``.
:returns: the dialog, ready to ``exec``.
"""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialogButtonBox,
QDoubleSpinBox, QFormLayout,
QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSlider,
QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from .i18n import language_choices, tr
from .theme import spaceout_enabled
from .widgets.toggle import Toggle
dlg = _preferences_window_class()(parent)
dlg._apply_confirmation = None
from .dialogs import detach_from_window_manager
detach_from_window_manager(dlg)
dlg.setWindowTitle(tr("spaCR — Preferences"))
dlg.setMinimumWidth(scaled_px(460))
outer = QVBoxLayout(dlg)
tabs = QTabWidget()
tabs.setObjectName("PreferencesTabs")
def _page(title: str, object_name: str) -> "QFormLayout":
"""Add a tab and return the form to fill it with.
Each page scrolls on its own so that a small screen shortens
the tallest tab instead of the whole dialog, and every tab is
still reachable at any window height.
"""
page = QWidget()
page.setObjectName(object_name)
column = QVBoxLayout(page)
column.setContentsMargins(4, 8, 4, 8)
page_form = QFormLayout()
page_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
column.addLayout(page_form)
column.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setWidget(page)
tabs.addTab(scroll, tr(title))
return page_form
form = _page("General", "PreferencesTabGeneral")
appearance = _page("Appearance", "PreferencesTabAppearance")
from .widgets.section import Section
def _category(title: str, object_name: str):
"""Add a folded category to Appearance and return its form.
The category is the same widget the module screens group their
settings with, so it folds and looks the way every other
settings category does. Its rows sit in a holder named
``object_name``, which is what the Help search opens the dialog
on; the navigation unfolds the category on the way to a row.
"""
category = Section(title)
holder = QWidget()
holder.setObjectName(object_name)
category_form = QFormLayout(holder)
category_form.setContentsMargins(0, 0, 0, 0)
category_form.setFieldGrowthPolicy(
QFormLayout.AllNonFixedFieldsGrow)
category.add_prose(holder)
return category, category_form
theme_category, theme_tab = _category("Theme", "PreferencesTabTheme")
animation_category, animation = _category(
"Animation", "PreferencesTabAnimation")
performance = _page("Performance", "PreferencesTabPerformance")
modules = _page("Modules", "PreferencesTabModules")
figures = _page("Figures", "PreferencesTabFigures")
logging_form = _page("Logging", "PreferencesTabLogging")
ai_form = _page("AI", "PreferencesTabAI")
dlg._storage_page = _StoragePage(
_page("Storage", "PreferencesTabStorage"), dlg)
log_level_toggles = {}
_log_header = QLabel(tr(
"Each level is written to its own file, plus a master log "
"containing everything. The console can only show a level the "
"log file is keeping."))
_log_header.setWordWrap(True)
_log_header.setObjectName("LoggingTabHelp")
logging_form.addRow(_log_header)
_file_levels_now = set(get_log_file_levels())
_console_levels_now = set(get_log_console_levels())
def _sync_console_enabled(level_value) -> None:
"""A console switch is only live while its file switch is."""
file_toggle, console_toggle = log_level_toggles[level_value]
allowed = file_toggle.isChecked()
console_toggle.setEnabled(allowed)
if not allowed and console_toggle.isChecked():
console_toggle.setChecked(False)
for _level in (logging.DEBUG, logging.INFO, logging.WARNING,
logging.ERROR, logging.CRITICAL):
_name = logging.getLevelName(_level)
_row = QWidget()
_row_layout = QHBoxLayout(_row)
_row_layout.setContentsMargins(0, 0, 0, 0)
_row_layout.setSpacing(12)
_file_toggle = Toggle()
_file_toggle.setObjectName(f"LogFileLevel{_name.title()}")
_file_toggle.setChecked(_level in _file_levels_now)
_console_toggle = Toggle()
_console_toggle.setObjectName(f"LogConsoleLevel{_name.title()}")
_console_toggle.setChecked(_level in _console_levels_now)
_row_layout.addWidget(QLabel(tr("Log file")))
_row_layout.addWidget(_file_toggle)
_row_layout.addSpacing(16)
_row_layout.addWidget(QLabel(tr("Console")))
_row_layout.addWidget(_console_toggle)
_row_layout.addStretch(1)
log_level_toggles[_level] = (_file_toggle, _console_toggle)
_file_toggle.toggled.connect(
lambda _checked, value=_level: _sync_console_enabled(value))
logging_form.addRow(_name.title(), _row)
for _level in log_level_toggles:
_sync_console_enabled(_level)
dlg._log_clearer = _LogClearer(logging_form, dlg)
_debug_file_toggle = log_level_toggles[logging.DEBUG][0]
_debug_file_toggle.setToolTip(tr(
"While verbose logging is on (Modules tab), DEBUG is always "
"written to the log files, so this switch stays on. Turn "
"verbose logging off to choose it yourself. Your own choice "
"is kept for when you do."))
_chosen_debug = [logging.DEBUG in _chosen_log_file_levels()]
def _remember_the_debug_choice(checked) -> None:
"""Record the DEBUG file switch only while the user holds it."""
if _debug_file_toggle.isEnabled():
_chosen_debug[0] = bool(checked)
_debug_file_toggle.toggled.connect(_remember_the_debug_choice)
language_combo = QComboBox()
language_combo.setObjectName("LanguagePreference")
for label, key in language_choices():
language_combo.addItem(label, key)
current_language = get_language()
for i in range(language_combo.count()):
if language_combo.itemData(i) == current_language:
language_combo.setCurrentIndex(i)
break
language_combo.setToolTip(
"Choose the language used by spaCR navigation, Preferences, "
"common actions and settings terminology. Untranslated "
"scientific terms safely remain in English."
)
form.addRow(tr("Language"), language_combo)
theme_combo = QComboBox()
for label, key in theme_choices():
theme_combo.addItem(tr(label), key)
blurb = theme_description(key)
if blurb:
theme_combo.setItemData(theme_combo.count() - 1, tr(blurb),
Qt.ItemDataRole.ToolTipRole)
current = get_theme_choice()
for i in range(theme_combo.count()):
if theme_combo.itemData(i) == current:
theme_combo.setCurrentIndex(i); break
theme_tab.addRow(tr("Theme"), theme_combo)
from .widgets.ambient import palette_label, palettes_for, theme_label
try:
from .widgets.ambient import NO_ANIMATION, animation_label
except ImportError:
NO_ANIMATION = _no_animation_key()
animation_label = theme_label
try:
from .widgets.ambient import animation_note
except ImportError:
try:
from .widgets.ambient import theme_note as animation_note
except ImportError:
animation_note = None
ambient_theme_combo = QComboBox()
ambient_theme_combo.setObjectName("AmbientTheme")
for key in _animation_choices():
ambient_theme_combo.addItem(tr(animation_label(key)), key)
current_ambient = get_ambient_animation()
for i in range(ambient_theme_combo.count()):
if ambient_theme_combo.itemData(i) == current_ambient:
ambient_theme_combo.setCurrentIndex(i); break
animation.addRow(tr("Animation"), ambient_theme_combo)
ambient_palette_combo = QComboBox()
ambient_palette_combo.setObjectName("AmbientPalette")
hints = None
def _reload_ambient_palettes(preferred=None):
"""Refill the palette list for the selected animation.
Palettes are per theme, so the two controls cannot be filled
independently. The current choice is carried across when the
new theme also offers it; otherwise that theme's default is
selected, which is exactly what the stored keys do.
"""
theme_key = ambient_theme_combo.currentData()
valid = () if theme_key == NO_ANIMATION else palettes_for(theme_key)
wanted = (preferred if preferred in valid
else ambient_default_palette(theme_key))
blocked = ambient_palette_combo.blockSignals(True)
try:
ambient_palette_combo.clear()
for key in valid:
ambient_palette_combo.addItem(
tr(palette_label(theme_key, key)), key)
for index in range(ambient_palette_combo.count()):
if ambient_palette_combo.itemData(index) == wanted:
ambient_palette_combo.setCurrentIndex(index); break
finally:
ambient_palette_combo.blockSignals(blocked)
ambient_theme_combo.setToolTip(
tr(animation_note(theme_key))
if animation_note is not None else "")
ambient_theme_combo.setAccessibleDescription(
ambient_theme_combo.toolTip())
if hints is not None:
note = ambient_theme_combo.toolTip()
hints.explain(ambient_theme_combo, note)
label = animation.labelForField(ambient_theme_combo)
label.setAccessibleDescription(note)
hints.explain(label, note)
ambient_theme_combo.currentIndexChanged.connect(
lambda _index: _reload_ambient_palettes(
ambient_palette_combo.currentData()))
_reload_ambient_palettes(get_ambient_palette())
ambient_palette_combo.setToolTip(
"Which colours the animation uses. \"spaCR\" is built from "
"the app's own blue, magenta and green-cyan."
)
animation.addRow(tr("Animation palette"), ambient_palette_combo)
from .widgets.colour_picker import pick_colour
custom_colors = list(_ambient_custom_colors())
custom_background = [_ambient_background_choice()]
primary_color_button = QPushButton()
primary_color_button.setObjectName("AmbientPrimaryColor")
accent_color_button = QPushButton()
accent_color_button.setObjectName("AmbientAccentColor")
background_color_button = QPushButton()
background_color_button.setObjectName("AmbientBackgroundColor")
background_reset_button = QPushButton(tr("Theme colour"))
background_reset_button.setObjectName("AmbientBackgroundReset")
color_buttons = (primary_color_button, accent_color_button)
def _refresh_custom_colors():
"""Show the pending colour pair while preserving dialog cancellation."""
for index, button in enumerate(color_buttons):
label = tr("Primary") if index == 0 else tr("Accent")
button.setText(f"{label} · {custom_colors[index]}")
button.setToolTip(tr("Choose a crisp data-art animation colour."))
if hints is not None:
hints.explain(button)
background_color_button.setText(
f"{tr('Background')} · {custom_background[0]}"
if custom_background[0] is not None
else tr("Background · Theme colour"))
background_color_button.setToolTip(
tr("Choose the animation background colour. Brightness is adjusted to fit the active Dark or Light theme."))
if hints is not None:
hints.explain(background_color_button)
background_reset_button.setEnabled(custom_background[0] is not None)
def _pick_ambient_color(index):
"""Select a local colour and activate the custom palette on save."""
color = pick_colour(dlg, custom_colors[index],
tr("Animation colour"))
if color.isValid():
custom_colors[index] = color.name()
_refresh_custom_colors()
ambient_palette_combo.setCurrentIndex(
ambient_palette_combo.findData("custom"))
primary_color_button.clicked.connect(lambda: _pick_ambient_color(0))
accent_color_button.clicked.connect(lambda: _pick_ambient_color(1))
def _pick_ambient_background():
"""Keep the candidate fill local until the dialog is saved."""
from .theme import active_page_colour
color = pick_colour(dlg, custom_background[0] or active_page_colour(),
tr("Animation background colour"))
if color.isValid():
custom_background[0] = color.name()
_refresh_custom_colors()
background_color_button.clicked.connect(_pick_ambient_background)
background_reset_button.clicked.connect(
lambda: (custom_background.__setitem__(0, None),
_refresh_custom_colors()))
_refresh_custom_colors()
color_row = QHBoxLayout()
color_row.addWidget(primary_color_button)
color_row.addWidget(accent_color_button)
animation.addRow(tr("Animation colours"), _hbox_wrap(color_row))
background_row = QHBoxLayout()
background_row.addWidget(background_color_button)
background_row.addWidget(background_reset_button)
animation.addRow(tr("Animation background"), _hbox_wrap(background_row))
def _sync_custom_colors(*_args):
"""Offer custom colours for the retained procedural data-art scenes."""
available = "custom" in palettes_for(ambient_theme_combo.currentData()) \
if ambient_theme_combo.currentData() != NO_ANIMATION else False
for button in color_buttons:
button.setEnabled(available)
ambient_theme_combo.currentIndexChanged.connect(_sync_custom_colors)
_sync_custom_colors()
ambient_dir_combo = QComboBox()
ambient_dir_combo.setObjectName("AmbientDriftDirection")
try:
from .widgets.ambient import (DRIFT_DIRECTIONS,
drift_direction_label,
drift_direction_note)
except ImportError: # pragma: no cover - ambient always imports
DRIFT_DIRECTIONS = ()
drift_direction_label = drift_direction_note = None
for key in DRIFT_DIRECTIONS:
ambient_dir_combo.addItem(tr(drift_direction_label(key)), key)
current_dir = get_ambient_drift_direction()
for i in range(ambient_dir_combo.count()):
if ambient_dir_combo.itemData(i) == current_dir:
ambient_dir_combo.setCurrentIndex(i); break
dir_label = QLabel(tr("spaCR stratified direction"))
animation.addRow(dir_label, ambient_dir_combo)
def _sync_direction_row(*_args):
"""Show the drift direction only for the theme that travels."""
wanted = ambient_theme_combo.currentData() == "drift"
dir_label.setVisible(wanted)
ambient_dir_combo.setVisible(wanted)
key = ambient_dir_combo.currentData()
if key is not None and drift_direction_note is not None:
ambient_dir_combo.setToolTip(tr(drift_direction_note(key)))
ambient_theme_combo.currentIndexChanged.connect(_sync_direction_row)
ambient_dir_combo.currentIndexChanged.connect(_sync_direction_row)
_sync_direction_row()
(speed_lo, speed_hi) = _ambient_ranges()[1][0]
(size_lo, size_hi) = _ambient_ranges()[2][0]
(res_lo, res_hi) = _ambient_ranges()[3][0]
(den_lo, den_hi) = _ambient_ranges()[4][0]
def _percent_row(name, label_text, low, high, current, tip,
designed=1.0, target=None):
"""Build one labelled percentage slider and return its parts."""
slider = QSlider(Qt.Horizontal)
slider.setObjectName(name)
slider.setRange(int(round(low * 100)), int(round(high * 100)))
slider.setSingleStep(1)
slider.setPageStep(25)
slider.setTickInterval(50)
slider.setValue(int(round(current * 100)))
slider.setToolTip(tip)
value = QLabel()
mark = int(round(designed * 100))
def _update(v):
"""Show the percentage, saying when it is the designed value.
"100%" alone does not tell a reader that it is the one to come back to.
"""
value.setText(f"{v}% — as designed" if v == mark
else f"{v}%")
slider.valueChanged.connect(_update)
_update(slider.value())
column = QVBoxLayout()
column.setContentsMargins(0, 0, 0, 0)
column.addWidget(slider)
column.addWidget(value)
(animation if target is None else target).addRow(
tr(label_text), _hbox_wrap(column))
return slider
resolution_slider = _percent_row(
"AmbientResolution", "Animation detail",
res_lo, res_hi, get_ambient_resolution(),
"How much detail the animation is drawn with. This is the one "
"that decides whether it looks pixelated: it sets how many "
"pixels the picture is worked out in before it is stretched to "
"fill the page. Costs roughly the square of what it says — "
"200 % is four times the work — so turn it down on a machine "
"that is busy.")
speed_slider = _percent_row(
"AmbientSpeed", "Animation speed",
speed_lo, speed_hi, get_ambient_speed(),
"How fast the animation moves, against the speed each one was "
"designed at. It applies to every kind of motion in the chosen "
"animation at once, and changing it never makes what is already "
"on screen jump.")
size_slider = _percent_row(
"AmbientSize", "Animation size",
size_lo, size_hi, get_ambient_size(),
"How large the moving elements are: blob width, curtain height, "
"the spacing between ripples, star size, cell diameter. Scaled "
"against each animation's own range, so one setting means the "
"same thing in all of them.")
density_slider = _percent_row(
"AmbientDensity", "Animation density",
den_lo, den_hi, get_ambient_density(),
"How many things there are: blobs, aurora curtains, ripple "
"sources, stars and cells. Density changes their population "
"independently of detail; rendering stays within the screen's "
"pixel budget.")
blink_slider = QSlider(Qt.Horizontal)
blink_slider.setObjectName("AmbientBlinkPercent")
blink_slider.setRange(0, 601)
blink_slider.setSingleStep(1)
blink_slider.setPageStep(25)
blink_value = QDoubleSpinBox()
blink_value.setObjectName("AmbientBlinkPercentValue")
blink_value.setDecimals(5)
blink_value.setRange(0.0, 10.0)
blink_value.setSingleStep(0.00001)
blink_value.setSuffix("%")
blink_tip = tr(
"Percentage of visible dots that flash white. Applies to dot "
"animations; density and movement stay unchanged.")
blink_slider.setToolTip(blink_tip)
blink_value.setToolTip(blink_tip)
def _blink_value_changed(value):
"""Keep the logarithmic slider aligned with an exact percentage."""
import math
previous = blink_slider.blockSignals(True)
blink_slider.setValue(0 if value <= 0 else
max(1, round((math.log10(value) + 5.0) * 100) + 1))
blink_slider.blockSignals(previous)
blink_value.valueChanged.connect(_blink_value_changed)
blink_slider.valueChanged.connect(
lambda position: blink_value.setValue(
0.0 if position == 0 else 10.0 ** ((position - 1) / 100.0 - 5.0)))
blink_value.setValue(_ambient_blink_percent())
_blink_value_changed(blink_value.value())
blink_column = QVBoxLayout()
blink_column.setContentsMargins(0, 0, 0, 0)
blink_column.addWidget(blink_slider)
blink_column.addWidget(blink_value)
animation.addRow(tr("Dot blinking"), _hbox_wrap(blink_column))
ripples_check = Toggle()
ripples_check.setObjectName("FieldRipplesEnabled")
ripples_check.setAccessibleName(tr("Field ripples"))
ripples_check.setChecked(_field_ripples_enabled())
ripples_check.setToolTip(tr(
"Ripples from clicks, opening or closing containers, and window "
"snapping. Independent of mouse gravity."))
animation.addRow(tr("Field ripples"), ripples_check)
ripple_intensity_value = QDoubleSpinBox()
ripple_intensity_value.setObjectName("FieldRippleIntensity")
ripple_intensity_value.setAccessibleName(tr("Ripple intensity"))
ripple_intensity_value.setRange(0.0, 200.0)
ripple_intensity_value.setDecimals(0)
ripple_intensity_value.setSingleStep(10.0)
ripple_intensity_value.setSuffix("%")
ripple_intensity_value.setValue(_field_ripple_intensity() * 100.0)
ripple_intensity_value.setToolTip(ripples_check.toolTip())
animation.addRow(tr("Ripple intensity"), ripple_intensity_value)
popup_waves_slider = QSlider(Qt.Horizontal)
popup_waves_slider.setObjectName("FieldPopupWaveFrequency")
popup_waves_slider.setRange(0, 600)
popup_waves_slider.setSingleStep(1)
popup_waves_slider.setPageStep(10)
popup_waves_value = QDoubleSpinBox()
popup_waves_value.setObjectName("FieldPopupWaveFrequencyValue")
popup_waves_value.setRange(0.0, 60.0)
popup_waves_value.setDecimals(1)
popup_waves_value.setSingleStep(0.1)
popup_waves_value.setSuffix(tr(" waves/min"))
popup_waves_tip = tr(
"Waves per minute spreading from the centre of an open popup "
"window into spaCR field. Zero disables automatic waves; mouse "
"gravity is independent.")
popup_waves_slider.setToolTip(popup_waves_tip)
popup_waves_value.setToolTip(popup_waves_tip)
popup_waves_slider.valueChanged.connect(
lambda value: popup_waves_value.setValue(value / 10.0))
popup_waves_value.valueChanged.connect(
lambda value: popup_waves_slider.setValue(round(value * 10)))
popup_waves_value.setValue(_field_popup_wave_frequency())
popup_waves_column = QVBoxLayout()
popup_waves_column.setContentsMargins(0, 0, 0, 0)
popup_waves_column.addWidget(popup_waves_slider)
popup_waves_column.addWidget(popup_waves_value)
animation.addRow(tr("Popup wave frequency"), _hbox_wrap(popup_waves_column))
gravity_slider = _percent_row(
"AmbientGravityRadius", "Mouse gravity radius",
0.0, 1.0, _ambient_gravity_radius(),
tr("How far mouse gravity reaches, as a percentage of the shorter "
"screen edge. Zero disables mouse influence. Applies to "
"backgrounds that respond to the mouse."), designed=0.15)
def _sync_ambient_enabled(*_args):
"""Grey out the shaping controls when there is nothing to paint.
Driven by the Animation row itself now that None lives in it.
The controls stay *visible* rather than disappearing, so the
reader can see what choosing an animation would give them back;
they are simply not settings that mean anything while nothing
is being drawn.
"""
on = ambient_theme_combo.currentData() != NO_ANIMATION
ambient_palette_combo.setEnabled(on)
ambient_dir_combo.setEnabled(on)
resolution_slider.setEnabled(on)
speed_slider.setEnabled(on)
size_slider.setEnabled(on)
density_slider.setEnabled(on)
blink_slider.setEnabled(on)
blink_value.setEnabled(on)
field = ambient_theme_combo.currentData() in (
"data_art_impulse_lens", "data_art_spaceout_field")
ripple_intensity_value.setEnabled(field)
popup_waves_slider.setEnabled(field)
popup_waves_value.setEnabled(field)
gravity_slider.setEnabled(on)
ambient_theme_combo.currentIndexChanged.connect(_sync_ambient_enabled)
_sync_ambient_enabled()
tooltips_all_check = Toggle(tr("Show tooltips"))
tooltips_all_check.setObjectName("TooltipsEnabled")
tooltips_all_check.setToolTip(
"Resting the pointer on a button, a field or a column header "
"for the chosen Tooltip delay shows a small label saying what it is. The "
"label stays while the pointer is on it and leaves a second "
"after the pointer goes. Cleared, no tooltip appears anywhere "
"in spaCR."
)
tooltips_all_check.setChecked(get_tooltips_enabled())
appearance.addRow(tr("Tooltips"), tooltips_all_check)
tooltip_delay_slider = QSlider(Qt.Horizontal)
tooltip_delay_slider.setObjectName("TooltipDelay")
tooltip_delay_slider.setRange(int(_TOOLTIP_DELAY_MIN * 10),
int(_TOOLTIP_DELAY_MAX * 10))
tooltip_delay_slider.setSingleStep(1)
tooltip_delay_slider.setPageStep(5)
tooltip_delay_slider.setTickInterval(10)
tooltip_delay_slider.setValue(
int(round(_get_tooltip_delay() * 10)))
tooltip_delay_slider.setToolTip(PREFERENCE_TIPS["Tooltip delay"])
tooltip_delay_value = QLabel()
def _update_tooltip_delay_lbl(v):
"""Show the tooltip delay in seconds, or "show immediately" at zero."""
tooltip_delay_value.setText(
tr("show immediately") if v == 0 else f"{v / 10:.1f} s")
tooltip_delay_slider.valueChanged.connect(_update_tooltip_delay_lbl)
_update_tooltip_delay_lbl(tooltip_delay_slider.value())
tooltips_all_check.toggled.connect(tooltip_delay_slider.setEnabled)
tooltip_delay_slider.setEnabled(tooltips_all_check.isChecked())
tooltip_delay_column = QVBoxLayout()
tooltip_delay_column.setContentsMargins(0, 0, 0, 0)
tooltip_delay_column.addWidget(tooltip_delay_slider)
tooltip_delay_column.addWidget(tooltip_delay_value)
appearance.addRow(tr("Tooltip delay"),
_hbox_wrap(tooltip_delay_column))
tooltips_box_check = Toggle(tr("Tooltips box"))
tooltips_box_check.setObjectName("TooltipsBox")
tooltips_box_check.setToolTip(
"Hovering a setting's title opens a box beside it with the "
"explanation, an API link and an Animation link. The box stays "
"while the pointer moves into it, which is what makes those two "
"links clickable at all."
)
tooltips_box_check.setChecked(get_tooltips_box_enabled())
appearance.addRow(tr("Tooltips box"), tooltips_box_check)
tooltips_bottom_check = Toggle(tr("Tooltips bottom"))
tooltips_bottom_check.setObjectName("TooltipsBottom")
tooltips_bottom_check.setToolTip(
"The same explanation appears along the bottom of the window, "
"where a category's help already appears. It holds the LAST "
"setting you hovered for ten seconds, so you can move the "
"pointer down to its API link and press it."
)
tooltips_bottom_check.setChecked(get_tooltips_bottom_enabled())
appearance.addRow(tr("Tooltips bottom"), tooltips_bottom_check)
def _warn_when_both_are_off() -> None:
"""Say what turning both off costs, on the rows themselves.
Not a refusal: both off is legal and the request says so. But on
several forms the API link inside a setting's tooltip is the only
route from that control to its documentation, so a reader who
clears both loses that route with nothing on screen to say why.
The warning is on the switches because that is where the choice
is made.
"""
silent = not (tooltips_box_check.isChecked()
or tooltips_bottom_check.isChecked())
for widget in (tooltips_box_check, tooltips_bottom_check):
widget.setProperty("warns", "true" if silent else "false")
widget.setToolTip(widget.toolTip().split("\n\nBOTH OFF")[0]
+ ("\n\nBOTH OFF: no setting will explain "
"itself anywhere, and the API link in a "
"setting's tooltip is the only route "
"from some controls to their "
"documentation. Category help is "
"unaffected." if silent else ""))
tooltips_box_check.toggled.connect(_warn_when_both_are_off)
tooltips_bottom_check.toggled.connect(_warn_when_both_are_off)
_warn_when_both_are_off()
spinner_slider = QSlider(Qt.Horizontal)
spinner_slider.setObjectName("SpinnerDelay")
spinner_slider.setRange(int(SPINNER_DELAY_MIN * 10),
int(SPINNER_DELAY_MAX * 10))
spinner_slider.setSingleStep(1)
spinner_slider.setPageStep(5)
spinner_slider.setTickInterval(10)
spinner_slider.setValue(int(round(get_spinner_delay() * 10)))
spinner_slider.setToolTip(
"How long a background job has to run before the spinner beside "
"Clear console appears. Short jobs never show it at all — the "
"timer starts when the work does and the spinner only appears "
"if the work is still going when it fires, so nothing flashes. "
"Set it to 0 to see every job."
)
spinner_value = QLabel()
def _update_spinner_lbl(v):
"""Show the spinner delay in seconds, or "show immediately" at zero."""
spinner_value.setText(
tr("show immediately") if v == 0 else f"{v / 10:.1f} s")
spinner_slider.valueChanged.connect(_update_spinner_lbl)
_update_spinner_lbl(spinner_slider.value())
spinner_column = QVBoxLayout()
spinner_column.setContentsMargins(0, 0, 0, 0)
spinner_column.addWidget(spinner_slider)
spinner_column.addWidget(spinner_value)
appearance.addRow(tr("Show busy spinner after"),
_hbox_wrap(spinner_column))
scale_slider = QSlider(Qt.Horizontal)
scale_slider.setObjectName("FontScale")
scale_slider.setRange(int(FONT_SCALE_MIN * 100),
int(FONT_SCALE_MAX * 100))
scale_slider.setSingleStep(5)
scale_slider.setPageStep(25)
scale_slider.setTickInterval(25)
scale_slider.setValue(int(get_font_scale() * 100))
scale_value = QLabel(f"{int(get_font_scale() * 100)}%")
def _update_scale_lbl(v):
"""Show the font scale as a percentage."""
scale_value.setText(f"{v}%")
scale_slider.valueChanged.connect(_update_scale_lbl)
scale_row = QVBoxLayout()
scale_row.addWidget(scale_slider)
scale_row.addWidget(scale_value)
_wrap = _hbox_wrap(scale_row)
form.addRow(tr("Font scale"), _wrap)
from PySide6.QtCore import QTimer
from .gui_scale import change_scales
gui_scale_slider = QSlider(Qt.Horizontal)
gui_scale_slider.setObjectName("GuiScale")
gui_scale_slider.setRange(int(round(GUI_SCALE_MIN * 100)),
int(round(GUI_SCALE_MAX * 100)))
gui_scale_slider.setSingleStep(5)
gui_scale_slider.setPageStep(25)
gui_scale_slider.setTickInterval(25)
gui_scale_slider.setValue(int(round(get_gui_scale() * 100)))
gui_scale_slider.setToolTip(tr(GUI_SCALE_TIP))
gui_scale_value = QLabel(f"{gui_scale_slider.value()}%")
gui_scale_value.setObjectName("GuiScaleValue")
gui_scale_slider.valueChanged.connect(
lambda v: gui_scale_value.setText(f"{v}%"))
gui_scale_row = QVBoxLayout()
gui_scale_row.addWidget(gui_scale_slider)
gui_scale_row.addWidget(gui_scale_value)
form.addRow(tr("GUI scale"), _hbox_wrap(gui_scale_row))
scale_settle = QTimer(dlg)
scale_settle.setObjectName("ScaleSettle")
scale_settle.setSingleShot(True)
scale_settle.setInterval(450)
def _put_the_sliders_back(kept: bool) -> None:
"""After a Revert, show the values that are in force again."""
if kept:
return
for slider, value in ((scale_slider, get_font_scale()),
(gui_scale_slider, get_gui_scale())):
try:
slider.blockSignals(True)
slider.setValue(int(round(value * 100)))
slider.blockSignals(False)
scale_value.setText(f"{scale_slider.value()}%")
gui_scale_value.setText(f"{gui_scale_slider.value()}%")
except RuntimeError:
return
def _apply_the_scales_live() -> None:
"""Apply the two scales now and ask whether to keep them."""
gui = gui_scale_slider.value() / 100.0
font = scale_slider.value() / 100.0
if (abs(gui - get_gui_scale()) < 1e-9
and abs(font - get_font_scale()) < 1e-9):
return
owner = parent if parent is not None else dlg
change_scales(owner, gui=gui, font=font,
on_done=_put_the_sliders_back)
scale_settle.timeout.connect(_apply_the_scales_live)
def _settle_unless_dragging(slider) -> None:
"""A drag applies on release; a click or key applies at once."""
if not slider.isSliderDown():
scale_settle.start()
for slider in (scale_slider, gui_scale_slider):
slider.sliderReleased.connect(scale_settle.start)
slider.valueChanged.connect(
lambda _v, s=slider: _settle_unless_dragging(s))
dock_combo = QComboBox()
for label, key in (
("Locked open", "locked"),
("Hidden", "hidden"),
):
dock_combo.addItem(tr(label), key)
current_dock = get_dock_mode()
for i in range(dock_combo.count()):
if dock_combo.itemData(i) == current_dock:
dock_combo.setCurrentIndex(i); break
dock_combo.setToolTip(
"Locked open: the app list is a permanent column to the left "
"of the page. It never covers what you are working on, and "
"costs its own width.\n"
"Hidden: no column. Apps stay reachable from the spaCR menu, "
"Ctrl+1..9 and Ctrl+K."
)
form.addRow(tr("App dock"), dock_combo)
opacity_slider = QSlider(Qt.Horizontal)
opacity_slider.setObjectName("PaneOpacity")
opacity_slider.setRange(0, 100)
opacity_slider.setSingleStep(5)
opacity_slider.setPageStep(10)
opacity_slider.setTickInterval(25)
opacity_slider.setValue(int(round(get_pane_opacity() * 100)))
opacity_value = QLabel()
def _update_opacity_lbl(v):
"""Show what was asked for, and what the theme will allow.
The floor is the whole design of this control: on Space it
is 78 %, so "20 %" would otherwise be a number the user set
and the app quietly ignored.
"""
from .theme import pane_alpha
if resolve_effective_theme() == "glass":
opacity_value.setText(
f"{v}% material strength — Glass stays translucent "
"by design")
return
actual = pane_alpha(resolve_effective_theme(), v / 100.0)
shown = int(round(actual * 100))
opacity_value.setText(
f"{v}%" if shown == v else
f"{v}% — held at {shown}% so the text stays readable "
"over the background")
opacity_slider.valueChanged.connect(_update_opacity_lbl)
_update_opacity_lbl(opacity_slider.value())
opacity_value.setWordWrap(True)
opacity_slider.setToolTip(
"How solid cards, settings sections, consoles, previews and the "
"rounded Home panel are. This applies in every theme, including "
"Glass, Space, Cell, Dark and Light. In Glass this controls "
"material strength while preserving its designed translucency; "
"in other themes it is literal surface opacity. A surface will not go "
"thinner than the point where its text stops clearing WCAG AA "
"over the background."
)
opacity_col = QVBoxLayout()
opacity_col.addWidget(opacity_slider)
opacity_col.addWidget(opacity_value)
theme_tab.addRow(tr("Page opacity"), _hbox_wrap(opacity_col))
field_fade_check = Toggle(tr("Fade fields towards the right"))
field_fade_check.setObjectName("FieldFadeEnabled")
field_fade_check.setToolTip(
"Input fields ignore Page opacity and dissolve instead: solid "
"where the value starts, fully transparent at their right edge, "
"fading faster the further right it goes. The outline fades with "
"the box; the text inside never fades. Clear this for plain "
"opaque fields."
)
field_fade_check.setChecked(get_field_fade_enabled())
theme_tab.addRow(tr("Field fade"), field_fade_check)
rim_length_slider = QSlider(Qt.Horizontal)
rim_length_slider.setObjectName("RimLength")
rim_length_slider.setRange(4, 62)
rim_length_slider.setSingleStep(1)
rim_length_slider.setPageStep(5)
rim_length_slider.setValue(int(round(_rim_length_fraction() * 100)))
rim_length_slider.setToolTip(
"The percentage of a settings card's perimeter lit by the rim. "
"The same percentage is used at every window size and resolution.")
rim_length_value = QLabel()
def _rim_length_says(percent):
"""Show the rim length as a percentage of the perimeter."""
rim_length_value.setText(f"{int(percent)}%")
_rim_length_says(rim_length_slider.value())
rim_length_slider.valueChanged.connect(_rim_length_says)
rim_length_row = QHBoxLayout()
rim_length_row.setContentsMargins(0, 0, 0, 0)
rim_length_row.addWidget(rim_length_slider, 1)
rim_length_row.addWidget(rim_length_value)
theme_tab.addRow(tr("Rim length"), _hbox_wrap(rim_length_row))
rim_lag_slider = QSlider(Qt.Horizontal)
rim_lag_slider.setObjectName("RimLag")
rim_lag_slider.setRange(int(RIM_LAG_RANGE[0] * 100),
int(RIM_LAG_RANGE[1] * 100))
rim_lag_slider.setSingleStep(1)
rim_lag_slider.setPageStep(5)
rim_lag_slider.setValue(int(round(get_rim_lag() * 100)))
rim_lag_slider.setToolTip(
"How much of the distance to the pointer the accent covers each "
"frame. Lower is a longer, lazier trail; at 100% it is under "
"the pointer with no travel at all, and the travel is the whole "
"effect.")
rim_lag_value = QLabel()
def _rim_lag_says(percent):
"""Show the rim chase as a percentage."""
rim_lag_value.setText(tr("%d%%") % int(percent))
_rim_lag_says(rim_lag_slider.value())
rim_lag_slider.valueChanged.connect(_rim_lag_says)
rim_lag_row = QHBoxLayout()
rim_lag_row.setContentsMargins(0, 0, 0, 0)
rim_lag_row.addWidget(rim_lag_slider, 1)
rim_lag_row.addWidget(rim_lag_value)
theme_tab.addRow(tr("Rim chase"), _hbox_wrap(rim_lag_row))
rim_align_combo = QComboBox()
rim_align_combo.setObjectName("RimAlignment")
for label, key in (("Centred on the pointer", "centre"),
("Trailing behind it", "head")):
rim_align_combo.addItem(tr(label), key)
index = rim_align_combo.findData(get_rim_alignment())
rim_align_combo.setCurrentIndex(index if index >= 0 else 0)
rim_align_combo.setToolTip(
"Centred puts the middle of the lit run under the pointer; "
"trailing puts its leading end there and drags the rest of the "
"light behind.")
theme_tab.addRow(tr("Rim alignment"), rim_align_combo)
rim_mode_combo = QComboBox()
rim_mode_combo.setObjectName("RimMode")
for label, key in (("Glow", "glow"), ("Rainbow", "rainbow"),
("Beat", "beat")):
rim_mode_combo.addItem(tr(label), key)
index = rim_mode_combo.findData(get_rim_mode())
rim_mode_combo.setCurrentIndex(index if index >= 0 else 0)
rim_mode_combo.setToolTip(
"Glow is the theme's accent with a fading tail. Rainbow walks "
"the hue along the light and turns it over time. Beat keeps the "
"accent and pulses it. Rainbow and Beat repaint every frame; "
"Glow only repaints when the light moves.")
theme_tab.addRow(tr("Rim mode"), rim_mode_combo)
rim_period_slider = QSlider(Qt.Horizontal)
rim_period_slider.setObjectName("RimPeriod")
rim_period_slider.setRange(int(RIM_PERIOD_RANGE[0] * 10),
int(RIM_PERIOD_RANGE[1] * 10))
rim_period_slider.setSingleStep(1)
rim_period_slider.setPageStep(5)
rim_period_slider.setValue(int(round(get_rim_period() * 10)))
rim_period_slider.setToolTip(
"How long one pulse of Beat takes, or one full turn of "
"Rainbow's hue. Ignored by Glow, which does not animate.")
rim_period_value = QLabel()
def _rim_period_says(tenths):
"""Show the rim period in seconds."""
rim_period_value.setText(tr("%.1f s") % (int(tenths) / 10.0))
_rim_period_says(rim_period_slider.value())
rim_period_slider.valueChanged.connect(_rim_period_says)
rim_period_row = QHBoxLayout()
rim_period_row.setContentsMargins(0, 0, 0, 0)
rim_period_row.addWidget(rim_period_slider, 1)
rim_period_row.addWidget(rim_period_value)
theme_tab.addRow(tr("Rim cycle"), _hbox_wrap(rim_period_row))
popup_backdrop_combo = QComboBox()
popup_backdrop_combo.setObjectName("PopupBackdrop")
for key in _popup_backdrop_choices():
popup_backdrop_combo.addItem(
tr("None") if key == "off" else tr(animation_label(key)), key)
index = popup_backdrop_combo.findData(get_popup_backdrop())
popup_backdrop_combo.setCurrentIndex(index if index >= 0 else 0)
popup_backdrop_combo.setToolTip(
"Which animation drifts behind a settings window. Separate from "
"the module screens' own backdrop above: what belongs behind a "
"screen of figures is not necessarily what belongs behind a form "
"you are reading. None keeps the card and the rim and drops only "
"the movement.")
animation.addRow(tr("Settings backdrop"), popup_backdrop_combo)
popup_motion = _popup_backdrop_motion()
popup_motion_sliders = {}
for key, caption, index in (("speed", "Settings animation speed", 1),
("size", "Settings animation size", 2),
("resolution", "Settings animation detail", 3),
("density", "Settings animation density", 4)):
bounds, _default = _ambient_ranges()[index]
popup_motion_sliders[key] = _percent_row(
"PopupBackdrop" + key.title(), caption,
bounds[0], bounds[1], popup_motion[key],
tr("Controls only the settings-window animation; the main background stays unchanged."))
def _sync_popup_motion(_index=0):
"""Offer these controls only to the compatible Qt paint engines."""
from .widgets.ambient import SPACEOUT_THEME
enabled = popup_backdrop_combo.currentData() not in (
"off", SPACEOUT_THEME)
for slider in popup_motion_sliders.values():
slider.setEnabled(enabled)
popup_backdrop_combo.currentIndexChanged.connect(_sync_popup_motion)
_sync_popup_motion()
popup_darkness_slider = QSlider(Qt.Horizontal)
popup_darkness_slider.setObjectName("PopupBackdropDarkness")
popup_darkness_slider.setRange(0, 100)
popup_darkness_slider.setValue(int(round(_popup_backdrop_darkness() * 100)))
popup_darkness_slider.setToolTip(tr(
"Opacity of the settings card over its animation. Higher values cover more of the backdrop so text is easier to read. Combined with Page opacity."))
popup_darkness_value = QLabel()
popup_darkness_slider.valueChanged.connect(
lambda value: popup_darkness_value.setText(f"{value}%"))
popup_darkness_value.setText(f"{popup_darkness_slider.value()}%")
popup_darkness_row = QHBoxLayout()
popup_darkness_row.addWidget(popup_darkness_slider, 1)
popup_darkness_row.addWidget(popup_darkness_value)
animation.addRow(tr("Settings backdrop darkness"),
_hbox_wrap(popup_darkness_row))
popup_backdrop_combo.currentIndexChanged.connect(
lambda _index: popup_darkness_slider.setEnabled(
popup_backdrop_combo.currentData() != "off"))
popup_darkness_slider.setEnabled(popup_backdrop_combo.currentData() != "off")
cb_combo = QComboBox()
for label, key in (
("Off", "off"),
("Deuteranopia (red-green)", "deuteranopia"),
("Protanopia (red-green)", "protanopia"),
("Tritanopia (blue-yellow)", "tritanopia"),
):
cb_combo.addItem(tr(label), key)
current_cb = get_color_blind_mode()
for i in range(cb_combo.count()):
if cb_combo.itemData(i) == current_cb:
cb_combo.setCurrentIndex(i); break
form.addRow(tr("Colour-blind mode"), cb_combo)
verbose_check = Toggle(tr("Enable verbose logging"))
verbose_check.setToolTip(tr(
"Adds spaCR's DEBUG messages to the log files in ~/.spacr/logs. "
"It also lets cellpose report which model it loaded, and it "
"records which buttons you pressed. That trail is what makes a "
"bug report worth reading.\n\n"
"Starting spaCR and opening its screens took no longer with "
"this on. This was measured on one "
"workstation by opening every module, once with this on and "
"once with it off. Home was ready in "
"about 4 seconds after a cold start both ways. "
"The slowest module opened in "
"about 7 seconds both ways. Pipeline runs were not timed.\n\n"
"The Console still shows only the levels you switch on for it "
"on the Logging tab. This switch "
"does not trace every function call. "
"That tracer is a separate tool for developers, and nothing "
"here turns it on."
))
verbose_check.setChecked(get_verbose_logging())
modules.addRow(tr("Diagnostics"), verbose_check)
def _debug_follows_verbose(on) -> None:
"""Hold the DEBUG file switch on while verbose is on.
Verbose adds DEBUG to the log files whatever the switch says,
so the switch shows that and cannot be changed. When verbose
goes off, the switch is given back with the user's own choice.
"""
if on:
_debug_file_toggle.setEnabled(False)
_debug_file_toggle.setChecked(True)
elif not _debug_file_toggle.isEnabled():
_debug_file_toggle.setEnabled(True)
_debug_file_toggle.setChecked(_chosen_debug[0])
_sync_console_enabled(logging.DEBUG)
verbose_check.toggled.connect(_debug_follows_verbose)
_debug_follows_verbose(verbose_check.isChecked())
performance_log_combo = QComboBox()
performance_log_combo.setObjectName("PerformanceLogging")
for label, key in (
("Off", "off"),
("Summary (recommended)", "summary"),
("Detailed", "detailed"),
):
performance_log_combo.addItem(tr(label), key)
performance_index = performance_log_combo.findData(
get_performance_logging())
performance_log_combo.setCurrentIndex(
performance_index if performance_index >= 0 else 1)
performance_log_combo.setToolTip(tr(
"Records what spaCR's process tree costs without tracing every "
"function call. Summary samples about once a second and keeps "
"whole-run totals and peaks. Detailed also retains a bounded "
"per-process and per-thread time series. Off starts no sampler "
"thread. This setting is independent of verbose logging."
))
modules.addRow(tr("Performance log"), performance_log_combo)
share_diagnostics_check = Toggle(
tr("Include redacted log excerpts in issue previews")
)
share_diagnostics_check.setToolTip(
"On by default. When on, an error report saves recent log lines, "
"with paths and credentials redacted, to a file on this computer "
"and names that file in the report. The log itself is never "
"posted to GitHub."
)
share_diagnostics_check.setChecked(get_share_diagnostic_logs())
modules.addRow(tr("Report logs"), share_diagnostics_check)
refresh_news_check = Toggle(
tr("Show newer releases than this build in Home's News panel")
)
refresh_news_check.setObjectName("RefreshNews")
refresh_news_check.setToolTip(
"On by default. A build's bundled release notes stop at the "
"release before its own, so with this on spaCR reads the public "
"list of releases from GitHub once a day, on a background "
"thread, after Home is drawn, and shows anything newer at the "
"top of News. Nothing is sent and nothing is signed in. Off "
"shows only the notes bundled in this build."
)
refresh_news_check.setChecked(get_refresh_news())
modules.addRow(tr("Release news"), refresh_news_check)
restore_session_check = Toggle(
tr("Reopen the last module, settings and folder on start"))
restore_session_check.setObjectName("RestoreLastSession")
restore_session_check.setToolTip(tr(
"Every start reopens the module that was on screen when spaCR "
"last closed or crashed, with its settings and folder. Open "
"fresh in the status bar, or starting with --fresh, skips it "
"once. Off always starts on Home. Default on."
))
restore_session_check.setChecked(_get_restore_session())
modules.addRow(tr("Session"), restore_session_check)
update_channel_combo = QComboBox()
update_channel_combo.setObjectName("UpdateChannel")
update_channel_combo.addItem(tr("Stable releases"), "stable")
update_channel_combo.addItem(tr("Nightly builds"), "nightly")
update_channel_combo.setToolTip(tr(
"Which versions Help → Check for updates offers. Stable offers "
"only full releases. Nightly also offers pre-releases and "
"development builds, which arrive sooner and are tested less. "
"After an update spaCR shows what changed. Default stable."))
update_channel_combo.setCurrentIndex(max(
0, update_channel_combo.findData(_get_update_channel())))
modules.addRow(tr("Update channel"), update_channel_combo)
from spacr.updater import _read_network_config
network_now = _read_network_config()
proxy_edit = QLineEdit(network_now["proxy"])
proxy_edit.setObjectName("NetworkProxy")
proxy_edit.setPlaceholderText("http://proxy.example.org:3128")
proxy_edit.setToolTip(tr(
"Proxy for every download: model weights, updates, plug-ins, "
"pip and backend installers. Empty uses the HTTPS_PROXY "
"environment variable when it is set. spacr-doctor reports "
"whether the proxy reaches PyPI. Default empty."))
modules.addRow(tr("Proxy"), proxy_edit)
ca_edit = QLineEdit(network_now["ca_bundle"])
ca_edit.setObjectName("NetworkCaBundle")
ca_edit.setPlaceholderText("/etc/ssl/certs/corporate-ca.pem")
ca_edit.setToolTip(tr(
"A PEM file of certificates to trust, for networks that inspect "
"HTTPS with their own certificate authority. Empty uses the "
"REQUESTS_CA_BUNDLE or SSL_CERT_FILE environment variable when "
"it is set. Default empty."))
modules.addRow(tr("Certificate bundle"), ca_edit)
db_edit_check = Toggle(tr("Allow editing in the Database Browser"))
db_edit_check.setToolTip(
"Off by default. The Database Browser opens measurements.db "
"read-only (mode=ro). With this on you can still only edit "
"after arming 'Edit mode' for a database you chose yourself, "
"and every change is one UPDATE scoped to one row. There is "
"no undo — spaCR writes straight into your measurements file."
)
db_edit_check.setChecked(get_db_browser_editable())
modules.addRow(tr("Database Browser"), db_edit_check)
ai_provider_combo = QComboBox()
ai_provider_combo.setObjectName("AiProvider")
ai_provider_combo.addItem(tr("Automatic (first available)"), "")
try:
from . import ai as _ai_module
for _p in _ai_module.configured_providers():
ai_provider_combo.addItem(_p.label, _p.name)
except Exception: # noqa: BLE001
LOG.debug("could not list AI providers", exc_info=True)
_wanted = get_preferred_provider()
_at = ai_provider_combo.findData(_wanted)
ai_provider_combo.setCurrentIndex(_at if _at >= 0 else 0)
ai_provider_combo.setToolTip(tr(
"Which assistant the AI switch routes through. Automatic picks "
"the first vendor CLI that is installed and logged in."
))
ai_form.addRow(tr("Provider"), ai_provider_combo)
ai_providers_btn = QPushButton(tr("Providers…"))
ai_providers_btn.setObjectName("AiProvidersButton")
ai_providers_btn.setToolTip(tr(
"Install a vendor CLI, or sign in to one you have."
))
def _open_providers():
"""Open the install/login dialog, then re-list the providers."""
from PySide6.QtWidgets import QDialog
from .widgets.ai_chat_panel import _ProvidersDialog
if _ProvidersDialog(dlg).exec() != QDialog.Accepted:
return
chosen = ai_provider_combo.currentData()
ai_provider_combo.clear()
ai_provider_combo.addItem(
tr("Automatic (first available)"), "")
try:
from . import ai as _ai
for _q in _ai.configured_providers():
ai_provider_combo.addItem(_q.label, _q.name)
except Exception: # noqa: BLE001
LOG.debug("could not re-list AI providers", exc_info=True)
back = ai_provider_combo.findData(chosen)
ai_provider_combo.setCurrentIndex(back if back >= 0 else 0)
ai_providers_btn.clicked.connect(_open_providers)
ai_form.addRow("", ai_providers_btn)
alpha_check = Toggle(tr("Show Alpha modules and settings"))
alpha_check.setObjectName("ShowAlphaFeatures")
alpha_check.setToolTip(
"Hide modules and settings that are built but not yet trusted "
"end to end. Stable and Beta features are unaffected."
)
alpha_check.setChecked(get_show_alpha())
beta_check = Toggle(tr("Show Beta modules and settings"))
beta_check.setObjectName("ShowBetaFeatures")
beta_check.setToolTip(
"Hide modules and settings that are in regular use but not yet "
"signed off. Stable and Alpha features are unaffected."
)
beta_check.setChecked(get_show_beta())
alpha_features_check = Toggle(tr("Show alpha features"))
alpha_features_check.setObjectName("ShowAlphaFutureFeatures")
alpha_features_check.setToolTip(tr(
"Show the settings, controls, screens and models built from the "
"future-features list that are not yet released. Off hides them; "
"saved values still reach every run."
))
alpha_features_check.setChecked(_get_show_alpha_features())
alpha_species_check = Toggle(tr(
"Show alpha species (Plasmodium, Candida, Trypanosoma, "
"Leishmania, Giardia, Virus, Mammalian)"))
alpha_species_check.setObjectName("ShowAlphaSpecies")
alpha_species_check.setToolTip(tr(
"Show the organism pages that are not yet released: Plasmodium, "
"Candida, Trypanosoma, Leishmania, Giardia, Virus and Mammalian "
"cells. Separate from Show alpha features. Toxoplasma is always "
"shown. Default off."
))
alpha_species_check.setChecked(_get_show_alpha_species())
maturity_col = QVBoxLayout()
maturity_col.setContentsMargins(0, 0, 0, 0)
maturity_col.addWidget(alpha_check)
maturity_col.addWidget(beta_check)
maturity_col.addWidget(alpha_features_check)
maturity_col.addWidget(alpha_species_check)
modules.addRow(tr("Module visibility"), _hbox_wrap(maturity_col))
figure_save_mode_combo = QComboBox()
figure_save_mode_combo.setObjectName("FigureSaveMode")
figure_save_mode_combo.addItem(tr("Print (light page)"), "print")
figure_save_mode_combo.addItem(tr("Screen (as shown)"), "screen")
figure_save_mode_combo.addItem(
tr("Transparent (no page)"), "transparent")
figure_save_mode_combo.setToolTip(tr(
"How saved figures handle their background, text and lines. "
"Print uses a light page with dark figure elements; Screen "
"keeps the colours shown in spaCR; Transparent removes the "
"page and chooses figure-element colours for the current theme. "
"SPACR_FIGURE_SAVE_MODE can temporarily override this setting "
"for command-line and notebook runs."
))
save_mode_index = figure_save_mode_combo.findData(
get_figure_save_mode())
figure_save_mode_combo.setCurrentIndex(
save_mode_index if save_mode_index >= 0 else 0)
figures.addRow(tr("Figure save mode"), figure_save_mode_combo)
fig_format_combo = QComboBox()
fig_format_combo.addItem("PNG (raster, lighter)", "png")
fig_format_combo.addItem("PDF (vector, editable)", "pdf")
fig_format_combo.setToolTip(
"How figures are rendered into the Figures panel. PDF also writes "
"a vector page with TrueType-embedded text — sharper on screen "
"when zoomed, and editable in Illustrator or Inkscape. Figures a "
"pipeline saves to its results folder keep the format that "
"pipeline chose and are not affected."
)
cur_fmt = get_figure_format()
for i in range(fig_format_combo.count()):
if fig_format_combo.itemData(i) == cur_fmt:
fig_format_combo.setCurrentIndex(i); break
figures.addRow(tr("Figure format"), fig_format_combo)
integrity_check = None
if _is_alpha_visible("widgets", _FIG_INTEGRITY_WIDGET):
integrity_check = Toggle(tr("Check figure integrity on export"))
integrity_check.setObjectName("FigureIntegrityCheck")
integrity_check.setToolTip(tr(
"When an image figure or montage is saved, warn if panels "
"meant for comparison use different display ranges, if "
"pixels are saturated or clipped, if a panel is repeated, "
"or if a lossy format was chosen. Also writes the source "
"files, display settings, processing steps and spaCR "
"version into the file's metadata and a .provenance.json "
"file beside it. Default off."
))
integrity_check.setChecked(_get_figure_integrity())
figures.addRow(integrity_check)
from ..graph_types import (DATA_SHAPES, GRAPH_NAMES, DEFAULTS,
types_for)
default_graph_combos = {}
for shape, shape_caption in DATA_SHAPES:
combo = QComboBox()
combo.setObjectName(f"DefaultGraphType_{shape}")
table_default = DEFAULTS.get(shape, "")
combo.addItem(
tr("Recommended — {name}").format(
name=tr(GRAPH_NAMES.get(table_default, table_default))),
"")
for kind in types_for(shape):
combo.addItem(tr(GRAPH_NAMES.get(kind, kind)), kind)
saved = get_default_graph_type(shape)
index = combo.findData(saved) if saved else 0
combo.setCurrentIndex(index if index >= 0 else 0)
combo.setToolTip(tr(
"Which graph is drawn first for {shape}. Right-click any "
"graph to change that one; this chooses where they all "
"start. Leave it on Recommended to follow spaCR's own "
"choice, which moves as the package does."
).format(shape=tr(shape_caption)))
figures.addRow(tr("First graph — {shape}").format(
shape=tr(shape_caption)), combo)
default_graph_combos[shape] = combo
png_dpi_combo = QComboBox()
for dpi in VALID_PNG_DPIS:
png_dpi_combo.addItem(f"{dpi} dpi", dpi)
png_dpi_combo.setToolTip(
"Resolution of the raster spaCR renders for the Figures panel, "
"and of any image embedded inside a vector PDF page. Very large "
"figures are rendered at a lower DPI for the screen so they stay "
"quick to draw; the PDF page is written at the full resolution "
"chosen here."
)
cur_dpi = get_figure_png_dpi()
for i in range(png_dpi_combo.count()):
if png_dpi_combo.itemData(i) == cur_dpi:
png_dpi_combo.setCurrentIndex(i); break
figures.addRow(tr("PNG resolution"), png_dpi_combo)
live_cache_spin = QSpinBox()
live_cache_spin.setRange(MIN_FIG_LIVE_CACHE, MAX_FIG_LIVE_CACHE)
live_cache_spin.setValue(get_figure_live_cache())
live_cache_spin.setToolTip(
"How many of the most recent figures keep their live figure, and "
"so stay restylable rather than being a picture of a figure. "
"Older ones are still shown and still on disk. Higher costs "
"memory: a figure with a large image panel can hold tens of "
"megabytes."
)
figures.addRow(tr("Editable figures kept"), live_cache_spin)
montage_columns_spin = QSpinBox()
montage_columns_spin.setRange(*MONTAGE_COLUMNS_RANGE)
montage_columns_spin.setValue(get_montage_columns())
montage_columns_spin.setToolTip(
"How many cells a well's montage puts on a row. The count stays "
"the same whatever size the window is — a wider panel draws the "
"same cells larger, up to their natural size, rather than "
"fitting more of them in, so two wells are always laid out "
"alike. How many ROWS fit is still measured, because that is "
"what decides the page."
)
figures.addRow(tr("Cells per montage row"), montage_columns_spin)
dynamic_check = QCheckBox()
dynamic_check.setChecked(get_figure_dynamic())
dynamic_check.setToolTip(
"When you go back past the number above and select a figure, "
"load its PDF page if one exists, so an old figure stays sharp "
"at any zoom instead of being an enlarged screen raster. It "
"cannot make an old figure editable again — a PDF page has no "
"legend to toggle — but it does make it legible."
)
figures.addRow(tr("Dynamic figures"), dynamic_check)
from .widgets.figure_settings import FigureStylePreferences
style_panel = FigureStylePreferences(get_figure_style(),
get_figure_style_per_graph())
style_heading = QLabel(tr(
"<b>Graph style</b> — how every figure is drawn, and per graph "
"type where they differ."))
style_heading.setWordWrap(True)
figures.addRow(style_heading)
figures.addRow(style_panel)
mode_combo = QComboBox()
mode_combo.setObjectName("PerformanceLevel")
for key in PERFORMANCE_LEVELS:
mode_combo.addItem(tr(PERFORMANCE_LABELS[key]), key)
current_mode = get_performance_level()
for i in range(mode_combo.count()):
if mode_combo.itemData(i) == current_mode:
mode_combo.setCurrentIndex(i); break
performance.addRow(tr("Performance"), mode_combo)
mode_note_label = QLabel()
mode_note_label.setObjectName("PerformanceLevelNote")
mode_note_label.setWordWrap(True)
performance.addRow("", mode_note_label)
def _sync_mode_note(*_args):
"""Say which hardware the selected mode is for.
A selector whose levels do not name their hardware makes the user guess
which one their machine is.
"""
key = mode_combo.currentData()
said = PERFORMANCE_NOTES.get(key) or mode_note(
spacr_mode_for_level(key))
mode_combo.setToolTip(tr(said))
text = tr(said)
warning = mode_warning(spacr_mode_for_level(key))
if warning:
text = f"{text}\n\n⚠ {tr(warning)}"
mode_note_label.setText(text)
mode_combo.currentIndexChanged.connect(_sync_mode_note)
from .memory_budget import (DEFAULT_CACHE_CEILING_MB,
DEFAULT_HEADROOM_MB,
DEFAULT_IDLE_MINUTES, HARDWARE_NOTES,
MAX_CACHE_CEILING_MB, MAX_HEADROOM_MB,
MAX_IDLE_MINUTES, MIN_CACHE_CEILING_MB,
MIN_HEADROOM_MB, MIN_IDLE_MINUTES,
RECOMMENDED)
def _suggestions(index: int) -> str:
"""What each level suggests for this row, as one sentence."""
parts = []
for level in PERFORMANCE_LEVELS:
value = RECOMMENDED[level][index]
shown = (f"{value:g} min" if index == 0
else f"{value} MB")
parts.append(
f"{tr(PERFORMANCE_LABELS[level])} "
f"({tr(HARDWARE_NOTES[level])}): "
f"{shown}")
return "\n".join(parts)
headroom_spin = QSpinBox()
headroom_spin.setObjectName("HeadroomMb")
headroom_spin.setRange(MIN_HEADROOM_MB, MAX_HEADROOM_MB)
headroom_spin.setSingleStep(256)
headroom_spin.setSuffix(tr(" MB"))
headroom_spin.setValue(get_headroom_mb())
headroom_spin.setToolTip(tr(
"How much memory must stay free for everything else on this "
"machine. When free memory falls below it, spaCR drops what it "
"is holding rather than competing for the last of it.\n\n"
"Suggested:\n{levels}").format(levels=_suggestions(2)))
performance.addRow(tr("Keep free"), headroom_spin)
idle_spin = QDoubleSpinBox()
idle_spin.setObjectName("CacheIdleMinutes")
idle_spin.setRange(MIN_IDLE_MINUTES, MAX_IDLE_MINUTES)
idle_spin.setDecimals(1)
idle_spin.setSingleStep(1.0)
idle_spin.setSuffix(tr(" min"))
idle_spin.setValue(get_idle_minutes())
idle_spin.setToolTip(tr(
"How long something spaCR is holding — a merged frame, a loaded "
"image, a model's weights — may sit unused before it is "
"dropped. Zero drops it as soon as nothing needs it, which "
"costs time when you go back to it.\n\n"
"This does NOT unload a library: a Python C extension cannot be "
"unloaded, and nothing here pretends otherwise. What it "
"governs is what spaCR chose to keep.\n\n"
"Suggested:\n{levels}").format(levels=_suggestions(0)))
performance.addRow(tr("Drop unused after"), idle_spin)
cache_spin = QSpinBox()
cache_spin.setObjectName("CacheCeilingMb")
cache_spin.setRange(MIN_CACHE_CEILING_MB, MAX_CACHE_CEILING_MB)
cache_spin.setSingleStep(256)
cache_spin.setSuffix(tr(" MB"))
cache_spin.setValue(get_cache_ceiling_mb())
cache_spin.setToolTip(tr(
"The most spaCR will hold in caches at once. Over it, the least "
"recently used goes first — the one least likely to be wanted "
"next — until what remains fits.\n\n"
"Suggested:\n{levels}").format(levels=_suggestions(1)))
performance.addRow(tr("Cache ceiling"), cache_spin)
database_queue_spin = QDoubleSpinBox()
database_queue_spin.setObjectName("DatabaseWriteQueueGiB")
database_queue_spin.setRange(0.0, 64.0)
database_queue_spin.setDecimals(2)
database_queue_spin.setSingleStep(0.25)
database_queue_spin.setSuffix(tr(" {unit}").format(unit="GiB"))
database_queue_spin.setValue(get_database_write_queue_gib())
database_queue_spin.setToolTip(tr(
"RAM allowed for serialized data waiting for Measure's single "
"database writer. Overflow is stored in a private temporary folder "
"inside measurements. Zero uses disk-only buffering. This limits "
"queued data, not total process memory. Applies to new runs."))
performance.addRow(tr("Database write queue RAM"), database_queue_spin)
_budget_level = [mode_combo.currentData()]
_budget_spins = (idle_spin, cache_spin, headroom_spin)
mode_combo.currentIndexChanged.connect(
lambda *_args: _budget_follows_level(
mode_combo, _budget_level, _budget_spins))
font_weight = QComboBox()
font_weight.setObjectName("InterfaceFontWeight")
for _key, _label in (("regular", "Regular"), ("light", "Light")):
font_weight.addItem(tr(_label), _key)
font_weight.setCurrentIndex(
max(0, font_weight.findData(get_interface_font_weight())))
appearance.addRow(tr("Interface font"), font_weight)
appearance.addRow(theme_category)
appearance.addRow(animation_category)
spaceout_field_checks = {}
if spaceout_enabled():
field_category, field_form = _category(
"spaCR field", "PreferencesTabSpaceoutField")
attractors_check = Toggle()
attractors_check.setObjectName("SpaceoutFieldAttractors")
relaxation_check = Toggle()
relaxation_check.setObjectName("SpaceoutFieldRelaxation")
elastic_release_check = Toggle()
elastic_release_check.setObjectName("SpaceoutFieldElasticRelease")
vortex_check = Toggle()
vortex_check.setObjectName("SpaceoutFieldVortex")
density_pulses_check = Toggle()
density_pulses_check.setObjectName("SpaceoutFieldDensityPulses")
density_waves_check = Toggle()
density_waves_check.setObjectName("SpaceoutFieldDensityWaves")
color_waves_check = Toggle()
color_waves_check.setObjectName("SpaceoutFieldColorWaves")
spirals_check = Toggle()
spirals_check.setObjectName("SpaceoutFieldSpirals")
saved_effects = _spaceout_field_effects()
for key, check, label, note in (
("attractors", attractors_check, "Random attractors",
"Several moving pull points shape the field."),
("relaxation", relaxation_check, "Relaxation",
"The field settles after a disturbance."),
("elastic_release", elastic_release_check, "Elastic release",
"Released field points spring back smoothly."),
("vortex", vortex_check, "Vortices",
"Rotating motion bends nearby field points."),
("density_pulses", density_pulses_check, "Density pulses",
"Local field density rises and falls in place."),
("density_waves", density_waves_check, "Density waves",
"Density changes travel through the field."),
("color_waves", color_waves_check, "Colour waves",
"Colour changes travel through the field."),
("spirals", spirals_check, "Spirals",
"Spiral motion winds across the field."),
):
check.setChecked(saved_effects[key])
check.setAccessibleName(tr(label))
check.setToolTip(tr(note))
check.setAccessibleDescription(tr(note))
field_form.addRow(tr(label), check)
spaceout_field_checks[key] = check
appearance.addRow(field_category)
if spaceout_enabled():
fractal = _page("Fractal", "PreferencesTabFractal")
_fractal_values = get_fractal_settings()
fractal_pattern = QComboBox()
fractal_pattern.setObjectName("FractalPattern")
from .widgets.fractal_travel import PATTERN_LABELS
for _key in FRACTAL_PATTERNS:
fractal_pattern.addItem(tr(PATTERN_LABELS.get(_key, _key)),
_key)
fractal_pattern.setCurrentIndex(
max(0, fractal_pattern.findData(_fractal_values["pattern"])))
fractal.addRow(tr("Pattern"), fractal_pattern)
fractal_backend = QComboBox()
fractal_backend.setObjectName("FractalBackend")
for _key in FRACTAL_BACKENDS:
fractal_backend.addItem(tr(_key), _key)
fractal_backend.setCurrentIndex(
max(0, fractal_backend.findData(_fractal_values["backend"])))
fractal.addRow(tr("Backend"), fractal_backend)
fractal_note = QLabel()
fractal_note.setObjectName("FractalBackendNote")
fractal_note.setWordWrap(True)
fractal.addRow("", fractal_note)
def _sync_fractal_note(*_args):
"""Say which renderer 'auto' will actually pick HERE.
The label cannot state it: it depends on whether vispy is
importable on this machine, and the honest answer is the one
the user will get.
"""
from .widgets.fractal_travel import (
gpu_is_available, platform_can_do_opengl, resolve_backend)
chosen = fractal_backend.currentData()
actual = resolve_backend(chosen)
wants_gpu = chosen in ("auto", "gpu")
if wants_gpu and not platform_can_do_opengl():
text = tr(
"No usable display/OpenGL context is available in "
"this session, so the CPU renderer runs instead. "
"Installing VisPy cannot enable GPU rendering in a "
"headless session."
)
elif wants_gpu and not gpu_is_available():
text = tr(
"VisPy is not installed, so the CPU renderer runs "
"instead. Install the GPU renderer with pip install "
"\"spacr[fractal]\"."
)
elif chosen == "auto":
text = tr(
"Automatic: this machine will use the {renderer} "
"renderer."
).format(renderer=actual.upper())
else:
text = tr("The {renderer} renderer.").format(
renderer=actual.upper())
fractal_note.setText(text)
fractal_backend.currentIndexChanged.connect(_sync_fractal_note)
_sync_fractal_note()
fractal_quality = QComboBox()
fractal_quality.setObjectName("FractalQuality")
for _key in FRACTAL_QUALITIES:
fractal_quality.addItem(tr(_key), _key)
fractal_quality.setCurrentIndex(
max(0, fractal_quality.findData(_fractal_values["quality"])))
fractal_quality.setToolTip(tr(
"A level is a set of numbers, not an adjective: choosing "
"one fills in supersampling, render scale, the iteration "
"budget and the scale below. Every one of them stays a "
"field you can then change — a level is a starting point, "
"not a lock.\n\n"
"Auto asks the machine instead, per backend, so it keeps "
"following the hardware rather than freezing an answer."))
fractal.addRow(tr("Quality"), fractal_quality)
def _tenths(name, value, low=None, high=None):
"""A NUMBER FIELD, not a capped slider.
A spin box whose maximum is 2 turns a typed 40 into 2 without
explaining the change. These settings accept the typed number
and let validation report clearly when it cannot be used.
The range is opened to the widest a QDoubleSpinBox has, so
the widget refuses nothing; `explain_a_fractal_number` is
what decides whether a value can be used, and says why when
it cannot.
"""
box = QDoubleSpinBox()
box.setObjectName(name)
box.setRange(-1e12, 1e12)
box.setSingleStep(0.05)
box.setDecimals(4)
box.setKeyboardTracking(False)
box.setValue(float(value))
return box
def _whole(name, value):
"""A whole-number field, equally uncapped."""
box = QSpinBox()
box.setObjectName(name)
box.setRange(-2_000_000_000, 2_000_000_000)
box.setKeyboardTracking(False)
box.setValue(int(value))
return box
fractal_scale = _tenths("FractalScale",
_fractal_values["scale"], 0.25, 2.0)
fractal.addRow(tr("Scale"), fractal_scale)
fractal_speed = _tenths("FractalSpeed",
_fractal_values["speed"], 0.15,
MAX_FRACTAL_SPEED)
fractal.addRow(tr("Speed"), fractal_speed)
fractal_dream = _tenths("FractalDream",
_fractal_values["dream"], 0.0, 1.5)
fractal.addRow(tr("Dream"), fractal_dream)
fractal_variable = Toggle()
fractal_variable.setObjectName("FractalVariableSpeed")
fractal_variable.setChecked(bool(_fractal_values["variable_speed"]))
fractal.addRow(tr("Variable speed"), fractal_variable)
fractal_speed_min = None
fractal_speed_max = None
fractal_ss = _whole(
"FractalSupersampling", _fractal_values["supersampling"])
fractal_ss.setToolTip(tr(
"Samples per pixel along each axis. 1 is none, 2 is four "
"samples a pixel and is what the published defaults use, 3 "
"is nine. The cost is the square of this number, so it is "
"the first thing to turn down on a slow machine and the "
"first to turn up on a fast one."))
fractal.addRow(tr("Supersampling"), fractal_ss)
fractal_path = QComboBox()
fractal_path.setObjectName("FractalPath")
for _key, _label in (("fixed", "Straight down"),
("guided", "Search as it goes"),
("tour", "Tour the interesting places")):
fractal_path.addItem(tr(_label), _key)
fractal_path.setCurrentIndex(
max(0, fractal_path.findData(_fractal_values["path"])))
fractal_path.setToolTip(tr(
"Straight down descends to one point on the boundary and "
"stays pointed at it — the steadiest picture, and what the "
"published settings use.\n\n"
"Search as it goes looks for somewhere more interesting "
"every so often and moves the camera onto it. It finds more "
"variety, and moving the camera is visible: the Steering "
"control below sets how much.\n\n"
"Tour the interesting places measures the view as it "
"descends and glides toward the part with the most colours "
"in it, never toward a single-colour patch. The camera "
"eases in and out of every move and turns away before the "
"detail runs out, and at the end of a dive it glides back "
"up. Dragging the view stops the tour; Ctrl+R hands the "
"camera back to it."))
fractal.addRow(tr("Path"), fractal_path)
fractal_steering = _tenths(
"FractalSteering", _fractal_values["steering"], 0.0, 1.0)
fractal_steering.setRange(0.0, 1.0)
fractal_steering.setSingleStep(0.05)
fractal_steering.setToolTip(tr(
"How much the view wanders while it descends. 0 goes "
"straight down; 1 keeps looking for somewhere more "
"interesting.\n\n"
"It sets how far each move reaches, how often one happens "
"and how long it takes, together — a move never takes more "
"than half the gap before the next, at any setting, so it "
"always settles rather than being caught mid-course."))
fractal.addRow(tr("Steering"), fractal_steering)
fractal_depth = _tenths(
"FractalMaxDepth", _fractal_values["max_depth"], 0.1, 23.0)
fractal_depth.setDecimals(1)
fractal_depth.setSingleStep(1.0)
fractal_depth.setToolTip(tr(
"How many factors of ten the zoom descends before it starts "
"again. At the default speed each decade takes 24 seconds, "
"so 21 is about eight and a half minutes — and slower "
"Speed makes it longer.\n\n"
"It stops rather than running for ever because the "
"reference orbit is carried as three 32-bit floats, which "
"reproduce it to about 4.2e-24: past roughly 23 decades the "
"perturbation is measuring its own error and the picture "
"turns to mush. Going deeper needs more precision, not a "
"larger number here — which is why this one refuses to go "
"above it."))
fractal.addRow(tr("Depth (decades)"), fractal_depth)
def _steering_only_matters_when_guided(*_args):
"""Grey Steering on the fixed path, where it does nothing.
Shown rather than hidden, so it is clear that choosing the
other path is what makes it live -- a control that vanishes
is one the user has to rediscover.
"""
fractal_steering.setEnabled(
fractal_path.currentData() == "guided")
fractal_path.currentIndexChanged.connect(
_steering_only_matters_when_guided)
_steering_only_matters_when_guided()
fractal_mandel = {}
fractal_pointer = Toggle(tr("Mouse gravity"))
fractal_pointer.setObjectName("FractalPointerGravity")
fractal_pointer.setChecked(
bool(_fractal_values["pointer_gravity"]))
fractal_pointer.setToolTip(tr(
"Let the backdrop follow the pointer: it drifts toward the "
"cursor and is shoved away by a click. The backdrop never "
"receives the click itself — it reads where the mouse is "
"rather than taking events, so nothing on top of it loses a "
"press."))
fractal.addRow(tr("Pointer"), fractal_pointer)
fractal_pointer_size = _tenths(
"FractalPointerSize", _fractal_values["pointer_size"],
0.0, 3.0)
fractal_pointer_size.setToolTip(tr(
"How far the pointer reaches, as a share of the window's "
"short edge. 1.0 pulls the pattern within roughly one short "
"edge of the cursor; 0 turns the reach off without turning "
"the pointer off, so a click still shoves. The pull fades "
"toward the edge of the reach rather than stopping at it."))
fractal.addRow(tr("Pointer reach"), fractal_pointer_size)
fractal_pointer_size.setEnabled(fractal_pointer.isChecked())
fractal_pointer.toggled.connect(fractal_pointer_size.setEnabled)
fractal_magnifier = QSlider(Qt.Horizontal)
fractal_magnifier.setObjectName("FractalMagnifierSize")
_lens_low, _lens_high = FRACTAL_LIMITS["magnifier_size"][:2]
fractal_magnifier.setRange(int(round(_lens_low * 100)),
int(round(_lens_high * 100)))
fractal_magnifier.setSingleStep(5)
fractal_magnifier.setPageStep(25)
fractal_magnifier.setTickInterval(25)
fractal_magnifier.setValue(
int(round(_fractal_values["magnifier_size"] * 100)))
fractal_magnifier.setToolTip(tr(
"How big the magnifying glass under the pointer is, as a "
"share of its usual size. The whole lens scales together: "
"the bulge under the cursor and the soft edge around it. "
"25% is a small loupe; 300% bends most of the window. "
"Applies wherever the pointer bends the picture: both "
"orbit folds, and the cascade and space on the GPU "
"renderer. The Mandelbrot is dragged instead. "
"Default 100%."))
fractal_magnifier_value = QLabel()
def _magnifier_says(percent):
"""Show the lens size the slider is at, as a percentage."""
fractal_magnifier_value.setText(f"{int(percent)}%")
fractal_magnifier.valueChanged.connect(_magnifier_says)
_magnifier_says(fractal_magnifier.value())
_magnifier_column = QVBoxLayout()
_magnifier_column.setContentsMargins(0, 0, 0, 0)
_magnifier_column.addWidget(fractal_magnifier)
_magnifier_column.addWidget(fractal_magnifier_value)
_magnifier_row = _hbox_wrap(_magnifier_column)
_magnifier_row.setToolTip(fractal_magnifier.toolTip())
fractal_magnifier.setAccessibleDescription(
fractal_magnifier.toolTip())
fractal_magnifier.setToolTip("")
fractal.addRow(tr("Magnifier size"), _magnifier_row)
fractal_magnifier.setEnabled(fractal_pointer.isChecked())
fractal_pointer.toggled.connect(fractal_magnifier.setEnabled)
fractal_pointer_strength = None
fractal_speed_period = None
_sync_mode_note()
def _quit_spacr(parent) -> None:
"""Ask how, then either stop cooperatively or leave outright.
The graceful path is the same one `closeEvent` takes --
`cancel_all` with a short budget -- and then closes the window,
so a normal quit still runs every shutdown hook. What this adds
is the five-minute re-prompt, for the case `closeEvent` cannot
handle: a worker wedged in a C extension that will never see
the cancel flag, which leaves the window refusing to close with
no way out from inside the application.
"""
from .shutdown import (CANCEL, FORCE, GracefulQuitWatcher,
ask_how_to_quit, describe_active,
force_quit_now)
window = parent.window() if parent is not None else None
registry = getattr(window, "_runs", None)
active = list(registry.active()) if registry is not None else []
choice = ask_how_to_quit(parent, what="spaCR",
detail=describe_active(active))
if choice == CANCEL:
return
if choice == FORCE:
force_quit_now()
return
if registry is not None:
registry.cancel_all(reason="quit from Preferences")
watcher = GracefulQuitWatcher(
window,
lambda: bool(registry is not None and registry.active()),
what="spaCR",
describe=lambda: describe_active(
list(registry.active()) if registry is not None else []),
)
watcher.start()
if parent is not None:
parent.accept()
if window is not None:
window.close()
def _resource_button(action, label_text, row_label):
"""Build one labelled action button for the resources row."""
button = QPushButton(tr(label_text))
button.setObjectName({
"ram": "ClearRamButton", "vram": "ClearVramButton",
"cpu": "ClearCpuButton", "disk": "CheckDiskButton",
}[action])
from . import resource_cleanup
button.setToolTip(resource_cleanup.summary_text(action))
button.clicked.connect(lambda: run_resource_action(action, dlg))
performance.addRow(tr(row_label), button)
return button
hash_check = Toggle(tr("Hash inputs for the run manifest"))
hash_check.setObjectName("HashInputsEnabled")
hash_check.setToolTip(
"Record a SHA-256 of every input and output file in the run "
"manifest, so a result can be traced to the exact data and "
"weights that produced it. Costs minutes on a large plate. The "
"manifest is written either way and says which it was, so a run "
"without hashes is never mistaken for one whose hashes matched."
)
hash_check.setChecked(get_hash_inputs())
performance.addRow(tr("Reproducibility"), hash_check)
workspace_combo = QComboBox()
workspace_combo.setObjectName("SaveWorkspaceMode")
for value, label in (
("off", tr("Nothing — settings and manifest only")),
("reference", tr("What was open, and where its files are")),
("copy", tr("What was open, and copies of its files")),
):
workspace_combo.addItem(label, value)
workspace_combo.setToolTip(tr(
"What a finished run records about the workspace around it — the "
"databases attached, the montage, and the view built on every "
"figure.\n\n"
"Where its files are: the paths, sizes and checksums, so a "
"restore can say a database moved instead of failing obscurely. "
"Kilobytes.\n\n"
"Copies of its files: the databases and tables as well, up to the "
"per-file limit below. A source folder of images is tens to "
"hundreds of gigabytes, so anything over the limit is named in "
"the run's own record rather than copied — nothing is skipped "
"silently.\n\n"
"Figures the session generated are copied either way: they exist "
"nowhere else."))
index = workspace_combo.findData(get_save_workspace())
workspace_combo.setCurrentIndex(max(0, index))
performance.addRow(tr("Saved runs carry"), workspace_combo)
workspace_limit = QSpinBox()
workspace_limit.setObjectName("WorkspaceCopyLimitMb")
workspace_limit.setRange(0, 1024 * 1024)
workspace_limit.setSingleStep(64)
workspace_limit.setSuffix(" MB")
workspace_limit.setValue(int(get_workspace_copy_limit_mb()))
workspace_limit.setToolTip(tr(
"The largest single file a saved run copies in. Files over it are "
"recorded with their size and the limit that excluded them."))
performance.addRow(tr("Copy files up to"), workspace_limit)
def _workspace_copying(mode: str) -> None:
"""The limit only means anything when files are being copied."""
copying = mode == "copy"
workspace_limit.setEnabled(copying)
workspace_limit.setToolTip(workspace_limit.toolTip() if copying else tr(
"Only used when saved runs carry copies of their files."))
workspace_combo.currentIndexChanged.connect(
lambda _i: _workspace_copying(str(workspace_combo.currentData())))
_workspace_copying(str(workspace_combo.currentData()))
_resource_button("ram", "Clear RAM", "Memory")
_resource_button("vram", "Clear VRAM", "GPU memory")
_resource_button("cpu", "Clear CPU", "Threads")
_resource_button("disk", "Check disk space", "Disk")
quit_button = QPushButton(tr("Quit spaCR…"))
quit_button.setObjectName("QuitSpacrButton")
quit_button.setToolTip(tr(
"Stop spaCR. You are asked whether to let running work finish "
"the step it is on, or to stop immediately. Immediately leaves "
"anything being written half-written."))
from .shutdown import style_as_danger
style_as_danger(quit_button)
quit_button.clicked.connect(lambda: _quit_spacr(dlg))
performance.addRow(tr("Application"), quit_button)
notifications_page = None
if _is_alpha_visible("widgets", _NOTIFY_ALPHA_WIDGET):
notifications_page = _NotificationsPage(
_page("Notifications", "PreferencesTabNotifications"), dlg)
if _is_alpha_visible("widgets", _PLUGIN_CATALOGUE_ALPHA_WIDGET):
dlg._plugin_catalogue_page = _PluginCataloguePage(
_page("Plugins", "PreferencesTabPlugins"), dlg)
sound_page = None
if sound_is_offered():
from .sound_preferences import SoundPage
sound_page = SoundPage(_page("Sound", "PreferencesTabSound"),
dlg)
def _the_theme_brings_its_backdrop_and_its_sound(_index=0) -> None:
"""Move the other three controls when a night theme is picked.
THE BINDING HAS TO HAPPEN HERE AND NOT ONLY IN
`set_theme_choice`. Save writes the Theme control and then
writes the Animation, palette and Sound set controls straight
after it, so a preset applied inside `set_theme_choice` would
be overwritten three lines later by whatever the untouched
combos still held. Moving the controls instead means the two
paths agree and, more to the point, that the user SEES what
the theme brought with it and can put any of it back before
pressing Save.
The four themes that are not night themes change nothing else,
which is why this returns early rather than reaching for a
default: Dark has never carried an opinion about the backdrop
and is not being given one now.
AND IT LEAVES THE ANIMATION ALONE WHEN THE CONTROL SAYS NONE,
for the reason :func:`apply_night_theme` gives: a theme must
not start something moving for a user who has turned motion
off. The Sound set still moves, because that control decides
WHICH sounds would play and not WHETHER any do.
"""
choice = theme_combo.currentData()
if not (is_night_theme(choice) or choice in DATA_ART_THEMES):
return
night = theme_for(choice)
if ambient_theme_combo.currentData() == NO_ANIMATION:
if sound_page is not None:
sound_page.select_theme(night.sound)
return
for index in range(ambient_theme_combo.count()):
if ambient_theme_combo.itemData(index) == night.ambient:
ambient_theme_combo.setCurrentIndex(index)
break
_reload_ambient_palettes(night.ambient_palette)
if sound_page is not None:
sound_page.select_theme(night.sound)
theme_combo.currentIndexChanged.connect(
_the_theme_brings_its_backdrop_and_its_sound)
outer.addWidget(tabs)
buttons = QDialogButtonBox(
QDialogButtonBox.Save | QDialogButtonBox.Cancel
| QDialogButtonBox.Apply
)
save_button = buttons.button(QDialogButtonBox.Save)
cancel_button = buttons.button(QDialogButtonBox.Cancel)
apply_button = buttons.button(QDialogButtonBox.Apply)
apply_button.setObjectName("PreferencesApply")
apply_button.setText(tr("Apply"))
if save_button is not None:
save_button.setText(tr("Save"))
if cancel_button is not None:
cancel_button.setText(tr("Cancel"))
reset_button = buttons.addButton(
tr("Reset to defaults"), QDialogButtonBox.ResetRole)
reset_button.setObjectName("PreferencesReset")
reset_button.setToolTip(tr(
"Put every preference back to the value a fresh install has. "
"Nothing is written until you press Apply or Save."))
outer.addWidget(buttons)
def _select(combo, value) -> None:
"""Point ``combo`` at the entry whose data is ``value``."""
if value is None:
return
index = combo.findData(value)
if index >= 0:
combo.setCurrentIndex(index)
def _reset_to_defaults() -> None:
"""Put every control back to what a fresh install would show.
Read through the real getters against an EMPTY store rather
than from a second copy of the default values. A hand-written
table here would be a second place to update every time a
preference gains a default, and the failure mode of getting it
wrong is silent: a Reset that quietly sets something to a value
no code path ever chose.
Only the controls change. Nothing is persisted until Save, so
Cancel still walks away from a reset the user did not mean --
which is why this does not write the empty store back.
"""
import os
import tempfile
from PySide6.QtCore import QSettings
global _settings
original = _settings
empty = os.path.join(
tempfile.mkdtemp(prefix="spacr-defaults-"), "defaults.ini")
_settings = lambda: QSettings(empty, QSettings.IniFormat)
try:
_select(language_combo, get_language())
_select(theme_combo, get_theme_choice())
_select(ambient_theme_combo, get_ambient_animation())
_select(ambient_palette_combo, get_ambient_palette())
custom_colors[:] = _ambient_custom_colors()
custom_background[0] = _ambient_background_choice()
_refresh_custom_colors()
_select(ambient_dir_combo, get_ambient_drift_direction())
_select(dock_combo, get_dock_mode())
_select(cb_combo, get_color_blind_mode())
_select(figure_save_mode_combo, get_figure_save_mode())
_select(fig_format_combo, get_figure_format())
if integrity_check is not None:
integrity_check.setChecked(_get_figure_integrity())
_select(png_dpi_combo, get_figure_png_dpi())
live_cache_spin.setValue(get_figure_live_cache())
dynamic_check.setChecked(get_figure_dynamic())
style_panel.reset()
_select(mode_combo, get_spacr_mode())
database_queue_spin.setValue(get_database_write_queue_gib())
resolution_slider.setValue(
int(round(get_ambient_resolution() * 100)))
speed_slider.setValue(int(round(get_ambient_speed() * 100)))
size_slider.setValue(int(round(get_ambient_size() * 100)))
density_slider.setValue(
int(round(get_ambient_density() * 100)))
blink_value.setValue(_ambient_blink_percent())
ripples_check.setChecked(_field_ripples_enabled())
ripple_intensity_value.setValue(_field_ripple_intensity() * 100.0)
for key, enabled in _spaceout_field_effects().items():
if key in spaceout_field_checks:
spaceout_field_checks[key].setChecked(enabled)
popup_waves_value.setValue(_field_popup_wave_frequency())
gravity_slider.setValue(
int(round(_ambient_gravity_radius() * 100)))
rim_length_slider.setValue(
int(round(_rim_length_fraction() * 100)))
rim_lag_slider.setValue(int(round(get_rim_lag() * 100)))
_select(rim_align_combo, get_rim_alignment())
_select(rim_mode_combo, get_rim_mode())
rim_period_slider.setValue(int(round(get_rim_period() * 10)))
_select(popup_backdrop_combo, get_popup_backdrop())
for key, value in _popup_backdrop_motion().items():
popup_motion_sliders[key].setValue(int(round(value * 100)))
popup_darkness_slider.setValue(
int(round(_popup_backdrop_darkness() * 100)))
spinner_slider.setValue(
int(round(get_spinner_delay() * 10)))
scale_slider.setValue(int(round(get_font_scale() * 100)))
gui_scale_slider.setValue(int(round(get_gui_scale() * 100)))
opacity_slider.setValue(
int(round(get_pane_opacity() * 100)))
tooltips_all_check.setChecked(get_tooltips_enabled())
tooltip_delay_slider.setValue(
int(round(_get_tooltip_delay() * 10)))
field_fade_check.setChecked(get_field_fade_enabled())
hash_check.setChecked(get_hash_inputs())
verbose_check.setChecked(get_verbose_logging())
_chosen_debug[0] = logging.DEBUG in _chosen_log_file_levels()
default_file_levels = set(get_log_file_levels())
default_console_levels = set(get_log_console_levels())
for level, (file_toggle, _c) in log_level_toggles.items():
file_toggle.setChecked(level in default_file_levels)
for level, (_f, console_toggle) in log_level_toggles.items():
_sync_console_enabled(level)
console_toggle.setChecked(
console_toggle.isEnabled()
and level in default_console_levels)
_select(performance_log_combo, get_performance_logging())
share_diagnostics_check.setChecked(
get_share_diagnostic_logs())
refresh_news_check.setChecked(get_refresh_news())
restore_session_check.setChecked(_get_restore_session())
_select(update_channel_combo, _get_update_channel())
proxy_edit.clear()
ca_edit.clear()
db_edit_check.setChecked(get_db_browser_editable())
alpha_check.setChecked(get_show_alpha())
beta_check.setChecked(get_show_beta())
alpha_features_check.setChecked(_get_show_alpha_features())
alpha_species_check.setChecked(_get_show_alpha_species())
if sound_page is not None:
sound_page.reset()
if notifications_page is not None:
notifications_page.reset()
finally:
_settings = original
reset_button.clicked.connect(_reset_to_defaults)
def _save(*, close=True, save_secrets=True):
"""Write every preference this dialog owns, rim first.
THE RIM GOES FIRST because every open card rereads it: doing it before
the theme work means one repaint rather than two.
"""
_set_rim_length_fraction(rim_length_slider.value() / 100.0)
dlg._storage_page.save()
set_rim_lag(rim_lag_slider.value() / 100.0)
set_rim_alignment(rim_align_combo.currentData())
set_rim_mode(rim_mode_combo.currentData())
set_rim_period(rim_period_slider.value() / 10.0)
set_popup_backdrop(popup_backdrop_combo.currentData())
for key, slider in popup_motion_sliders.items():
_set_popup_backdrop_motion(key, slider.value() / 100.0)
_set_popup_backdrop_darkness(popup_darkness_slider.value() / 100.0)
_tell_the_cards_the_rim_changed()
set_language(language_combo.currentData())
set_theme_choice(theme_combo.currentData())
set_ambient_animation(ambient_theme_combo.currentData())
palette_choice = ambient_palette_combo.currentData()
if palette_choice is not None:
set_ambient_palette(palette_choice)
_set_ambient_custom_colors(custom_colors)
_set_ambient_background_choice(custom_background[0])
set_ambient_speed(speed_slider.value() / 100.0)
set_ambient_size(size_slider.value() / 100.0)
set_ambient_resolution(resolution_slider.value() / 100.0)
set_ambient_density(density_slider.value() / 100.0)
_set_ambient_blink_percent(blink_value.value())
_set_field_ripples_enabled(ripples_check.isChecked())
_set_field_ripple_intensity(ripple_intensity_value.value() / 100.0)
if spaceout_field_checks:
_set_spaceout_field_effects({
key: check.isChecked()
for key, check in spaceout_field_checks.items()
})
_set_field_popup_wave_frequency(popup_waves_value.value())
_set_ambient_gravity_radius(gravity_slider.value() / 100.0)
direction_choice = ambient_dir_combo.currentData()
if direction_choice is not None:
set_ambient_drift_direction(direction_choice)
set_spinner_delay(spinner_slider.value() / 10.0)
set_tooltips_enabled(tooltips_all_check.isChecked())
_set_tooltip_delay(tooltip_delay_slider.value() / 10.0)
set_tooltips_box_enabled(tooltips_box_check.isChecked())
set_tooltips_bottom_enabled(
tooltips_bottom_check.isChecked())
set_preferred_provider(ai_provider_combo.currentData() or "")
scale_settle.stop()
set_font_scale(scale_slider.value() / 100.0)
set_gui_scale(gui_scale_slider.value() / 100.0)
try:
from .gui_scale import set_gui_scale_live
set_gui_scale_live(get_gui_scale())
except Exception: # noqa: BLE001
LOG.debug("could not apply the GUI scale", exc_info=True)
set_dock_mode(dock_combo.currentData())
set_pane_opacity(opacity_slider.value() / 100.0)
set_field_fade_enabled(field_fade_check.isChecked())
set_hash_inputs(hash_check.isChecked())
set_workspace_copy_limit_mb(workspace_limit.value())
set_save_workspace(workspace_combo.currentData())
set_color_blind_mode(cb_combo.currentData())
set_verbose_logging(verbose_check.isChecked())
set_performance_logging(performance_log_combo.currentData())
set_share_diagnostic_logs(share_diagnostics_check.isChecked())
set_refresh_news(refresh_news_check.isChecked())
_set_restore_session(restore_session_check.isChecked())
_set_update_channel(update_channel_combo.currentData())
try:
from spacr.updater import _write_network_config
_write_network_config(proxy_edit.text(), ca_edit.text())
except OSError:
LOG.warning("could not save the network settings",
exc_info=True)
verbose_holds_debug = verbose_check.isChecked()
set_log_levels(
[level for level, (file_t, _c) in log_level_toggles.items()
if (_chosen_debug[0]
if file_t is _debug_file_toggle and verbose_holds_debug
else file_t.isChecked())],
[level for level, (_f, console_t) in log_level_toggles.items()
if console_t.isChecked()],
)
set_db_browser_editable(db_edit_check.isChecked())
set_show_alpha(alpha_check.isChecked())
set_show_beta(beta_check.isChecked())
_set_show_alpha_features(alpha_features_check.isChecked())
_set_show_alpha_species(alpha_species_check.isChecked())
set_figure_save_mode(figure_save_mode_combo.currentData())
set_figure_format(fig_format_combo.currentData())
if integrity_check is not None:
_set_figure_integrity(integrity_check.isChecked())
for shape, combo in default_graph_combos.items():
set_default_graph_type(shape, combo.currentData() or "")
set_figure_png_dpi(png_dpi_combo.currentData())
set_figure_live_cache(live_cache_spin.value())
set_database_write_queue_gib(database_queue_spin.value())
set_montage_columns(montage_columns_spin.value())
set_figure_dynamic(dynamic_check.isChecked())
style_general, style_per_graph = style_panel.values()
set_figure_style(style_general)
set_figure_style_per_graph(style_per_graph)
if spaceout_enabled():
set_fractal_settings(
pattern=fractal_pattern.currentData(),
backend=fractal_backend.currentData(),
quality=fractal_quality.currentData(),
scale=fractal_scale.value(),
speed=fractal_speed.value(),
dream=fractal_dream.value(),
variable_speed=fractal_variable.isChecked(),
speed_min=fractal_speed.value() * 0.55,
speed_max=fractal_speed.value() * 1.65,
pointer_gravity=fractal_pointer.isChecked(),
pointer_size=(fractal_pointer_size.value()
if fractal_pointer_size is not None
else 1.0),
pointer_strength=(1.0 if fractal_pointer.isChecked()
else 0.0),
magnifier_size=fractal_magnifier.value() / 100.0,
supersampling=int(fractal_ss.value()),
path=fractal_path.currentData(),
steering=fractal_steering.value(),
max_depth=fractal_depth.value(),
**{name: box.value()
for name, box in fractal_mandel.items()},
)
complaints = [
explain_a_fractal_number(name, box.value())
for name, box in
list(fractal_mandel.items())
+ [("max_depth", fractal_depth),
("supersampling", fractal_ss),
("scale", fractal_scale),
("speed", fractal_speed)]
]
complaints = [text for text in complaints if text]
try:
from .widgets.fractal_travel import (
apply_saved_controls, restart_the_dive)
apply_saved_controls()
restart_the_dive()
except Exception: # noqa: BLE001
LOG.debug("could not restart the dive", exc_info=True)
try:
from .widgets.ambient import (
rebuild_the_spaceout_backdrops)
rebuild_the_spaceout_backdrops()
except Exception: # noqa: BLE001
LOG.debug("could not rebuild the backdrop",
exc_info=True)
if complaints:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(
dlg, tr("Some numbers cannot be used"),
tr("These were saved as the nearest value that "
"works:") + "\n\n" + "\n".join(complaints))
set_interface_font_weight(font_weight.currentData())
set_performance_level(mode_combo.currentData())
_save_budget_for_level(mode_combo.currentData(),
idle_spin.value(), cache_spin.value(),
headroom_spin.value())
if sound_page is not None:
sound_page.save()
if notifications_page is not None:
try:
if save_secrets:
notifications_page.save()
else:
_set_run_notifications(notifications_page.values())
except Exception as exc: # noqa: BLE001
LOG.warning("could not save the notification settings "
"(%s)", type(exc).__name__)
_settings().sync()
apply_preferences_to_app()
_refresh_owner_window(parent)
if close:
dlg.accept()
def _apply():
"""Preview the edits and ask separately, keeping this dialog open.
Only keys changed by this application are rolled back. New
notification secrets are written only after Keep, so Revert
cannot leave a replacement password in an external keyring.
Edited controls remain available as a draft after Revert.
"""
from PySide6.QtWidgets import QMessageBox
from spacr.updater import (
_apply_network_settings, _network_config_path,
)
def _snapshot():
"""Copy persisted preference values, bypassing any temporary store shadow."""
store = _settings()
store = getattr(store, "_real", store)
return {key: store.value(key) for key in store.allKeys()}
network_path = _network_config_path()
def _network_bytes():
"""Read the network configuration bytes, or None when no file exists."""
try:
return network_path.read_bytes()
except FileNotFoundError:
return None
before = _snapshot()
old_network = _network_bytes()
missing = object()
def _restore():
"""Restore this preview's changed keys, network configuration and live appearance."""
after = _snapshot()
store = _settings()
for key in before.keys() | after.keys():
if before.get(key, missing) != applied.get(key, missing):
if key in before:
store.setValue(key, before[key])
else:
store.remove(key)
store.sync()
if (_network_bytes() == applied_network
and applied_network != old_network):
if old_network is None:
network_path.unlink(missing_ok=True)
else:
network_path.write_bytes(old_network)
_apply_network_settings()
from .gui_scale import set_gui_scale_live
set_gui_scale_live(get_gui_scale())
_tell_the_cards_the_rim_changed()
_backdrop_follows_the_level(get_performance_level())
if spaceout_enabled():
from .widgets.fractal_travel import (
apply_saved_controls, restart_the_dive,
)
from .widgets.ambient import rebuild_the_spaceout_backdrops
apply_saved_controls()
restart_the_dive()
rebuild_the_spaceout_backdrops()
apply_preferences_to_app()
_refresh_owner_window(parent)
try:
_in_one_store(lambda: _save(close=False, save_secrets=False))
except Exception:
applied = _snapshot()
applied_network = _network_bytes()
_in_one_store(_restore)
LOG.exception("could not apply the preferences")
QMessageBox.warning(dlg, tr("Preferences"), tr(
"Could not apply settings. Previous settings restored."))
return
applied = _snapshot()
applied_network = _network_bytes()
question = QMessageBox(dlg)
question.setObjectName("PreferencesKeepOrRevert")
question.setWindowTitle(tr("Keep these settings?"))
question.setText(tr("Your settings have been applied."))
question.setInformativeText(tr(
"Keep these settings or revert to the previous settings. "
"Preferences stays open. After Revert, your edits remain "
"available to change or apply again."))
keep = question.addButton(tr("Keep"), QMessageBox.AcceptRole)
revert = question.addButton(tr("Revert"), QMessageBox.RejectRole)
question.setDefaultButton(revert)
question.setEscapeButton(revert)
question.setWindowModality(Qt.WindowModal)
apply_button.setEnabled(False)
dlg._apply_confirmation = question
answered = False
def _answered(_result):
"""Keep or revert once, then release the question and enable another Apply."""
nonlocal answered
if answered:
return
answered = True
try:
if question.clickedButton() is keep:
if notifications_page is not None:
try:
_in_one_store(notifications_page.save)
except Exception as exc:
LOG.warning(
"could not save the notification settings "
"(%s)", type(exc).__name__)
else:
_in_one_store(_restore)
finally:
dlg.finished.disconnect(question.reject)
apply_button.setEnabled(True)
dlg._apply_confirmation = None
question.deleteLater()
question.finished.connect(_answered)
dlg.finished.connect(question.reject)
question.open()
buttons.accepted.connect(lambda: _in_one_store(_save))
apply_button.clicked.connect(_apply)
buttons.rejected.connect(dlg.reject)
from .widgets.hint_bar import HintBar
hints = HintBar(parent=dlg)
hints.explain(
hints._resize_handle,
"Drag this edge to make the help area taller or shorter.")
layout = dlg.layout()
row_of_buttons = layout.indexOf(buttons)
if row_of_buttons >= 0:
layout.insertWidget(row_of_buttons, hints)
else:
layout.addWidget(hints)
saved_help_height = _settings().value(_KEY_PREFERENCES_HELP_HEIGHT)
if saved_help_height is not None:
try:
hints._manual_height = int(saved_help_height)
except (TypeError, ValueError):
pass
hints.helpHeightCommitted.connect(
lambda height: _settings().setValue(
_KEY_PREFERENCES_HELP_HEIGHT, height))
explain_every_row(dlg)
_everything_explains_itself_in_the_strip(dlg, hints)
_reload_ambient_palettes(ambient_palette_combo.currentData())
dlg._show_only_the_open_page_at_first(tabs)
return dlg
def _everything_explains_itself_in_the_strip(dialog, bar) -> int:
"""Move every remaining tooltip in ``dialog`` into ``bar``.
:param dialog: the finished Preferences dialog.
:param bar: its :class:`~spacr.qt.widgets.hint_bar.HintBar`.
:returns: how many were moved, so a test can assert a number.
THE STRIP IS THE ANSWER, NOT A SECOND ONE. A control that both writes to
the explanatory strip and pops a tooltip window answers twice, and the
window can cover the strip it duplicates.
`explain_every_row` pairs a row's label with its field, which reached 5
of this dialog's controls; the other 125 are labels and buttons that are
not settings rows -- log levels, figure options, the resource actions --
and each kept a tooltip of its own. Sweeping the finished dialog cannot
miss a shape, including one added later.
The strip's own label is skipped: it is the thing being written to.
"""
from PySide6.QtWidgets import QWidget
from .widgets.hint_bar import HintBar
moved = 0
for widget in dialog.findChildren(QWidget):
if isinstance(widget, HintBar) or widget is bar:
continue
if not (widget.toolTip() or "").strip():
continue
try:
if bar.explain(widget):
moved += 1
except Exception: # noqa: BLE001
continue
return moved
def _refresh_owner_window(parent) -> None:
"""Ask the window that opened Preferences to rebuild itself.
A QIcon bakes its pixmap when it is built, so re-applying the
stylesheet leaves every existing icon in the *old* theme's ink —
switch to the light theme and the sidebar keeps its white glyphs, on
white. Only the dialog's own window is touched: walking
``QApplication.topLevelWidgets()`` instead reaches leftover windows
whose C++ side is already being torn down, and rebuilding one of
those segfaults rather than raising.
Never raises: a window that cannot rebuild is a cosmetic problem,
not a reason to fail the Save.
"""
if parent is None:
return
try:
window = parent.window()
except Exception:
return
refresh = getattr(window, "refresh_theme", None)
if callable(refresh):
try:
refresh()
except Exception:
pass
def _tell_the_cards_the_rim_changed() -> int:
"""Make every card on screen take the new rim settings. Returns how many.
A PREFERENCE THE USER CANNOT SEE TAKE EFFECT is a preference they will
set twice. The cards read length, chase and alignment when they draw,
so all this has to do is tell them to draw -- and re-read the length,
which is the one they cache.
"""
try:
from PySide6.QtWidgets import QApplication
from .widgets.setup_card import SetupCard
except Exception: # noqa: BLE001
return 0
application = QApplication.instance()
if application is None:
return 0
told = 0
for widget in application.allWidgets():
if isinstance(widget, SetupCard):
try:
widget.reread_the_preferences()
told += 1
except Exception: # noqa: BLE001
LOG.debug("a card would not reread the rim", exc_info=True)
return told
def _hbox_wrap(layout):
"""Wrap a layout in a widget so it can be placed where a widget is wanted.
:param layout: the layout to wrap.
:returns: the widget owning it.
"""
from PySide6.QtWidgets import QWidget
w = QWidget()
w.setLayout(layout)
return w
#: Where a panel's folded sections and divider positions live, as one JSON
#: blob keyed by panel name.
_KEY_SECTION_LAYOUT = "panels/section_layout"
[docs]
def get_section_layout(panel: str) -> dict:
"""What ``panel`` looked like when it was last used.
Divider sizes and collapsed sections are remembered per category so the
next session restores the user's working layout.
:param panel: the stable category or panel key the layout was saved under
with :func:`set_section_layout`; converted to ``str``.
:returns: ``{"folded": [title, ...], "sizes": [int, ...]}``, plus
``"steps"`` and ``"boxes"`` for a panel whose nested sections fold or
whose boxes are draggable -- see :func:`set_section_layout`. An empty
dict when the panel has never been arranged. EMPTY, not a default
layout -- the panel's own first-run arrangement is the right one, and
freezing today's into every user's settings would make improving it
impossible. Same reasoning as :func:`get_figure_style`.
"""
import json
raw = _settings().value(_KEY_SECTION_LAYOUT, "")
if not raw:
return {}
try:
stored = json.loads(raw)
except (TypeError, ValueError):
return {}
if not isinstance(stored, dict):
return {}
layout = stored.get(str(panel))
return layout if isinstance(layout, dict) else {}
[docs]
def set_section_layout(panel: str, folded=(), sizes=(), steps=None,
boxes=None) -> None:
"""Remember which sections of ``panel`` are folded, and the divider sizes.
:param panel: stable category or panel name under which this layout is
stored, independently of every other panel's arrangement.
:param folded: the titles that are folded away.
:param sizes: the splitter's sizes, in its own order.
:param steps: the SUB-subsections -- ``{"1": False}`` for a numbered
workflow step folded away. A panel's nested sections
collapse too, and a collapse that is forgotten on the way out of the
module is a collapse the user does again every visit.
:param boxes: dragged heights, ``{name: px at 100 % font scale}``. STORED
UNSCALED on purpose: a user who drags the merge report to eleven
lines and then doubles the font wants eleven lines, not half of them,
so the number that comes back is re-scaled rather than replayed.
Both new mappings are written only when they hold something, so a panel
that has neither goes on producing exactly the record it always did.
"""
import json
raw = _settings().value(_KEY_SECTION_LAYOUT, "")
try:
stored = json.loads(raw) if raw else {}
except (TypeError, ValueError):
stored = {}
if not isinstance(stored, dict):
stored = {}
record = {
"folded": [str(title) for title in (folded or ())],
"sizes": [int(size) for size in (sizes or ())],
}
if steps:
record["steps"] = {str(key): bool(value)
for key, value in dict(steps).items()}
if boxes:
record["boxes"] = {str(key): int(value)
for key, value in dict(boxes).items()}
stored[str(panel)] = record
_settings().setValue(_KEY_SECTION_LAYOUT, json.dumps(stored))
#: How wide one figure tile is drawn in the grid.
_KEY_FIGURE_GRID_SIZE = "figures/grid_cell_px"
_KEY_SAVE_WORKSPACE = "runs/save_workspace"
_KEY_WORKSPACE_COPY_LIMIT = "runs/workspace_copy_limit_mb"
[docs]
def get_save_workspace() -> str:
"""Return how a completed run records its open workspace.
The result is ``"off"``, ``"reference"``, or ``"copy"``; see
:mod:`spacr.workspace`. This application preference applies to every run
until changed.
"""
from ..workspace import resolve_mode
return resolve_mode(_settings().value(_KEY_SAVE_WORKSPACE, None))
[docs]
def set_save_workspace(mode) -> str:
"""Store the workspace mode and update the process-wide default.
Updating both values makes the change available immediately to pipeline
code that cannot read Qt settings directly.
:param mode: ``"off"``, ``"reference"`` or ``"copy"``, or anything
:func:`spacr.workspace.resolve_mode` accepts (booleans and yes/no
aliases); unrecognised values select the default mode.
"""
from ..workspace import resolve_mode, set_default_mode
resolved = resolve_mode(mode)
_settings().setValue(_KEY_SAVE_WORKSPACE, resolved)
set_default_mode(resolved, get_workspace_copy_limit_mb())
return resolved
[docs]
def get_workspace_copy_limit_mb() -> float:
"""The per-file ceiling on what ``copy`` mode brings in, in megabytes."""
from ..workspace import DEFAULT_COPY_LIMIT_MB
raw = _settings().value(_KEY_WORKSPACE_COPY_LIMIT, DEFAULT_COPY_LIMIT_MB)
try:
limit = float(raw)
except (TypeError, ValueError):
return float(DEFAULT_COPY_LIMIT_MB)
return limit if limit >= 0 else float(DEFAULT_COPY_LIMIT_MB)
[docs]
def set_workspace_copy_limit_mb(limit) -> float:
"""Remember the per-file copy limit, and push it down with the mode.
:param limit: the largest file ``copy`` mode brings in, in megabytes;
negative values store 0.0, and an unparseable value stores the
workspace default.
"""
from ..workspace import DEFAULT_COPY_LIMIT_MB, set_default_mode
try:
value = float(limit)
except (TypeError, ValueError):
value = float(DEFAULT_COPY_LIMIT_MB)
value = max(0.0, value)
_settings().setValue(_KEY_WORKSPACE_COPY_LIMIT, value)
set_default_mode(get_save_workspace(), value)
return value
[docs]
def apply_workspace_preference() -> str:
"""Push the stored preference into :mod:`spacr.workspace`. Call at startup.
Without this the journal writes the module default on the first run of
every session, whatever the user chose last time.
"""
from ..workspace import set_default_mode
return set_default_mode(get_save_workspace(), get_workspace_copy_limit_mb())
_KEY_RIM_LENGTH = "rim/length_px"
_KEY_RIM_LENGTH_FRACTION = "rim/length_fraction"
def _rim_length_fraction() -> float:
"""Read relative rim length, retaining the appearance of legacy pixels."""
import math
raw = _settings().value(_KEY_RIM_LENGTH_FRACTION, None)
if raw is None:
if not _settings().contains(_KEY_RIM_LENGTH):
return 0.17
from PySide6.QtCore import QRectF
from PySide6.QtGui import QPainterPath
from .widgets.setup_card import REFERENCE_CARD
path = QPainterPath()
path.addRoundedRect(QRectF(0.0, 0.0, *REFERENCE_CARD), 18, 18)
return min(0.62, max(0.04, get_rim_length() * 2.0 / path.length()))
try:
value = float(raw)
except (TypeError, ValueError):
value = 0.17
if not math.isfinite(value):
value = 0.17
return min(0.62, max(0.04, value))
def _set_rim_length_fraction(fraction) -> None:
"""Store the perimeter fraction used by every default settings card."""
import math
value = float(fraction)
if not math.isfinite(value):
value = 0.17
_settings().setValue(_KEY_RIM_LENGTH_FRACTION, min(0.62, max(0.04, value)))
#: How many cells the montage puts on a row, per well.
_KEY_MONTAGE_COLUMNS = "montage/columns"
#: The default number of cells per row in a well's montage tab.
#:
#: A DECIDED NUMBER, not one that falls out of the window. The tab used to
#: compute `viewport_width // cell_px`, so a narrow panel showed three cells
#: per well and widening it showed more -- "the cell tab shows 3 cells per
#: well and then more if i change the size of the container". The count is
#: now the same whatever the window does, and the THUMBNAILS take up the
#: slack instead, which is the half of the geometry it makes sense to let a
#: container drive.
DEFAULT_MONTAGE_COLUMNS = 6
#: Sensible bounds. One column is a list; past a dozen the thumbnails are
#: smaller than the objects in them on any ordinary screen.
MONTAGE_COLUMNS_RANGE = (1, 12)
[docs]
def get_montage_columns() -> int:
"""Cells per row in a well's montage tab."""
low, high = MONTAGE_COLUMNS_RANGE
try:
value = int(_settings().value(_KEY_MONTAGE_COLUMNS,
DEFAULT_MONTAGE_COLUMNS))
except (TypeError, ValueError):
return DEFAULT_MONTAGE_COLUMNS
return max(low, min(high, value))
[docs]
def set_montage_columns(columns) -> int:
"""Store the cells-per-row count. Returns the value actually stored.
:param columns: cells per row; converted to ``int`` and clamped to
:data:`MONTAGE_COLUMNS_RANGE`, and an unparseable value stores
:data:`DEFAULT_MONTAGE_COLUMNS`.
"""
low, high = MONTAGE_COLUMNS_RANGE
try:
value = max(low, min(high, int(columns)))
except (TypeError, ValueError):
value = DEFAULT_MONTAGE_COLUMNS
_settings().setValue(_KEY_MONTAGE_COLUMNS, value)
return value
_KEY_RIM_LAG = "rim/lag"
_KEY_RIM_ALIGNMENT = "rim/alignment"
#: How far the lit run reaches along the rim, in pixels.
DEFAULT_RIM_LENGTH = 280
#: How hard the accent chases the pointer, per frame. Smaller is slower.
DEFAULT_RIM_LAG = 0.5
#: Where the run sits relative to the pointer.
RIM_ALIGNMENTS = ("centre", "head")
DEFAULT_RIM_ALIGNMENT = "centre"
#: Bounds the settings panel and the reader both honour.
RIM_LENGTH_RANGE = (60, 900)
RIM_LAG_RANGE = (0.02, 1.0)
[docs]
def get_rim_length() -> int:
"""Pixels of rim the accent lights up.
Clamped on READ as well as on write: the stored value can come from a
settings file written by hand or by an older build, and a rim longer
than its own perimeter is a border rather than a highlight.
"""
low, high = RIM_LENGTH_RANGE
try:
value = int(_settings().value(_KEY_RIM_LENGTH, DEFAULT_RIM_LENGTH))
except (TypeError, ValueError):
return DEFAULT_RIM_LENGTH
return max(low, min(high, value))
[docs]
def set_rim_length(pixels) -> int:
"""Store the rim length. Returns the value actually stored.
:param pixels: how far the lit run reaches along the rim, in pixels;
clamped to :data:`RIM_LENGTH_RANGE`, and an unparseable value stores
:data:`DEFAULT_RIM_LENGTH`.
"""
low, high = RIM_LENGTH_RANGE
try:
value = max(low, min(high, int(pixels)))
except (TypeError, ValueError):
value = DEFAULT_RIM_LENGTH
settings = _settings()
settings.setValue(_KEY_RIM_LENGTH, value)
settings.remove(_KEY_RIM_LENGTH_FRACTION)
settings.sync()
return value
[docs]
def get_rim_lag() -> float:
"""How far the accent closes the gap to the pointer each frame.
SMALLER IS SLOWER, and the name is the user's: what they see is the lag
between the pointer arriving and the light catching up. 1.0 would put
the light under the pointer with no travel at all, and the travel is
the whole effect -- so that is the top of the range, not past it.
"""
low, high = RIM_LAG_RANGE
try:
value = float(_settings().value(_KEY_RIM_LAG, DEFAULT_RIM_LAG))
except (TypeError, ValueError):
return DEFAULT_RIM_LAG
return max(low, min(high, value))
[docs]
def set_rim_lag(fraction) -> float:
"""Store the chase fraction. Returns the value actually stored.
:param fraction: how far the accent closes the gap to the pointer each
frame; clamped to :data:`RIM_LAG_RANGE`, and an unparseable value
stores :data:`DEFAULT_RIM_LAG`.
"""
low, high = RIM_LAG_RANGE
try:
value = max(low, min(high, float(fraction)))
except (TypeError, ValueError):
value = DEFAULT_RIM_LAG
settings = _settings()
settings.setValue(_KEY_RIM_LAG, value)
settings.sync()
return value
[docs]
def get_rim_alignment() -> str:
"""Where the lit run sits relative to the pointer.
``centre`` puts the MIDDLE of the run under the pointer, ``head`` puts
its leading end there and trails the rest behind.
"""
value = str(_settings().value(_KEY_RIM_ALIGNMENT,
DEFAULT_RIM_ALIGNMENT) or "").strip().lower()
return value if value in RIM_ALIGNMENTS else DEFAULT_RIM_ALIGNMENT
[docs]
def set_rim_alignment(name: str) -> str:
"""Store the alignment. An unknown name stores the default instead.
:param name: one of :data:`RIM_ALIGNMENTS`, matched after stripping and
lower-casing.
"""
value = str(name or "").strip().lower()
if value not in RIM_ALIGNMENTS:
value = DEFAULT_RIM_ALIGNMENT
settings = _settings()
settings.setValue(_KEY_RIM_ALIGNMENT, value)
settings.sync()
return value
#: How the lit run of rim is coloured.
#:
#: `glow` is the accent colour with a fading tail. `rainbow` walks the hue
#: along the run so the light carries a spectrum. `beat` keeps the accent
#: colour and PULSES it, brightening and dimming on a steady cycle.
_KEY_RIM_MODE = "rim/mode"
RIM_MODES = ("glow", "rainbow", "beat")
DEFAULT_RIM_MODE = "beat"
#: Seconds for one full pulse of `beat`, or one full hue turn of `rainbow`.
_KEY_RIM_PERIOD = "rim/period_s"
DEFAULT_RIM_PERIOD = 1.5
RIM_PERIOD_RANGE = (0.4, 12.0)
[docs]
def get_rim_mode() -> str:
"""Which way the rim is coloured -- glow, rainbow or beat."""
value = str(_settings().value(_KEY_RIM_MODE,
DEFAULT_RIM_MODE) or "").strip().lower()
return value if value in RIM_MODES else DEFAULT_RIM_MODE
[docs]
def set_rim_mode(name: str) -> str:
"""Store the rim mode. An unknown name stores the default instead.
:param name: one of :data:`RIM_MODES`, matched after stripping and
lower-casing.
"""
value = str(name or "").strip().lower()
if value not in RIM_MODES:
value = DEFAULT_RIM_MODE
settings = _settings()
settings.setValue(_KEY_RIM_MODE, value)
settings.sync()
return value
[docs]
def get_rim_period() -> float:
"""Seconds for one pulse of `beat` or one hue turn of `rainbow`."""
low, high = RIM_PERIOD_RANGE
try:
value = float(_settings().value(_KEY_RIM_PERIOD, DEFAULT_RIM_PERIOD))
except (TypeError, ValueError):
return DEFAULT_RIM_PERIOD
return max(low, min(high, value))
[docs]
def set_rim_period(seconds) -> float:
"""Store the pulse period. Returns the value actually stored.
:param seconds: seconds for one pulse of ``beat`` or one hue turn of
``rainbow``; clamped to :data:`RIM_PERIOD_RANGE`, and an unparseable
value stores :data:`DEFAULT_RIM_PERIOD`.
"""
low, high = RIM_PERIOD_RANGE
try:
value = max(low, min(high, float(seconds)))
except (TypeError, ValueError):
value = DEFAULT_RIM_PERIOD
settings = _settings()
settings.setValue(_KEY_RIM_PERIOD, value)
settings.sync()
return value
#: Which animation drifts behind a settings popup.
#:
#: SEPARATE FROM THE MODULE SCREENS' OWN. A backdrop that is right behind a
#: full screen of figures is not necessarily the one somebody wants behind a
#: form they are reading; `off` keeps the card and the rim and drops only the
#: movement.
#:
#: SEPARATE CHOICE, NOT A SHORTER LIST. What is curated here is which
#: *setting* a popup follows, not which animations exist: every name in
#: :data:`spacr.qt.widgets.ambient.AMBIENT_THEMES` is offered, alphabetically,
#: behind `off`. An animation that appeared in one of the two menus and not
#: the other would be a difference nobody decided on, so a new theme is added
#: here at the same time as there.
_KEY_POPUP_BACKDROP = "rim/popup_backdrop"
POPUP_BACKDROPS = ("off", "blobs", "data_art_genetic_advection",
"data_art_impulse_lens", "data_art_point_atlas", "drift")
DEFAULT_POPUP_BACKDROP = "drift"
_POPUP_MOTION_KEYS = {
"speed": ("rim/popup_speed", 1),
"size": ("rim/popup_size", 2),
"resolution": ("rim/popup_resolution", 3),
"density": ("rim/popup_density", 4),
}
def _popup_backdrop_motion() -> dict:
"""Read settings-window motion independently of module backgrounds."""
return {name: _ambient_multiplier(key, index)
for name, (key, index) in _POPUP_MOTION_KEYS.items()}
def _set_popup_backdrop_motion(name, value) -> None:
"""Store one bounded settings-window motion control."""
key, index = _POPUP_MOTION_KEYS[name]
_set_ambient_multiplier(key, index, value)
_KEY_POPUP_BACKDROP_DARKNESS = "rim/popup_backdrop_darkness"
def _popup_backdrop_darkness() -> float:
"""Read the animated popup card's opacity, independently of Page opacity."""
import math
try:
value = float(_settings().value(_KEY_POPUP_BACKDROP_DARKNESS, 0.85))
return max(0.0, min(1.0, value)) if math.isfinite(value) else 0.85
except (TypeError, ValueError):
return 0.85
def _set_popup_backdrop_darkness(value: float) -> None:
"""Persist a finite popup-card opacity between zero and one."""
import math
try:
value = float(value)
value = max(0.0, min(1.0, value)) if math.isfinite(value) else 0.85
except (TypeError, ValueError):
value = 0.85
settings = _settings()
settings.setValue(_KEY_POPUP_BACKDROP_DARKNESS, value)
settings.sync()
def _popup_backdrop_choices() -> tuple:
"""Offer the same active-mode materials as the main animation selector."""
return ("off",) + tuple(sorted(key for key in _animation_choices()
if key != _no_animation_key()))
#: When the user last pressed **Clear** on Home's Recent runs, and **Reset**
#: on Totals, as a UTC ISO-8601 string. Empty means never.
_KEY_RUNS_CLEARED = "home/runs_cleared_utc"
_KEY_TOTALS_RESET = "home/totals_reset_utc"
#: The two watermarks, by the name the panels ask for them under.
DASHBOARD_WATERMARKS = {"runs": _KEY_RUNS_CLEARED,
"totals": _KEY_TOTALS_RESET}
[docs]
def get_dashboard_watermark(which: str) -> str:
"""When Home's ``which`` panel was last cleared, as a UTC ISO string.
A WATERMARK, NOT A DELETION, and that is the whole design. **Clear** on
Recent runs and **Reset** on Totals sit beside the queue's Clear, but
the queue holds plates waiting to start while
these two read the run journal -- which is the record of what this
installation has actually done, is what the Run History screen searches,
and is what a run's `manifest.json` is for. Emptying a dashboard panel
must not delete that.
So the panels remember a time instead and show only what happened after
it. The journal is untouched, Run History still has everything, and a
user who clears by accident loses a view rather than a history.
:param which: ``runs`` or ``totals``.
:returns: the stored ISO string, or ``""`` for never cleared.
"""
key = DASHBOARD_WATERMARKS.get(which)
if key is None:
return ""
return str(_settings().value(key, "") or "").strip()
[docs]
def set_dashboard_watermark(which: str, when: str = "") -> str:
"""Move ``which``'s watermark to ``when``, or to now when empty.
:param which: ``runs`` or ``totals``. An unknown name is ignored.
:param when: a UTC ISO-8601 string. Empty means "now".
:returns: what was stored, or ``""`` when nothing was.
"""
key = DASHBOARD_WATERMARKS.get(which)
if key is None:
return ""
if not when:
from datetime import datetime, timezone
when = datetime.now(timezone.utc).isoformat()
settings = _settings()
settings.setValue(key, when)
settings.sync()
return when
[docs]
def clear_dashboard_watermark(which: str) -> None:
"""Forget ``which``'s watermark, so its panel shows everything again.
:param which: the Home panel, ``"runs"`` or ``"totals"`` (the keys of
:data:`DASHBOARD_WATERMARKS`); any other name does nothing.
"""
key = DASHBOARD_WATERMARKS.get(which)
if key is None:
return
settings = _settings()
settings.remove(key)
settings.sync()
#: How tall the reader dragged Home's News list, in px at 100 % font scale.
#: 0 means "never dragged" and the panel uses its own default.
_KEY_NEWS_HEIGHT = "home/news_height_px"
[docs]
def get_news_height() -> int:
"""The remembered height of Home's release-notes list, or 0.
Stored in FONT-SCALE-INDEPENDENT px, so a reader who drags the box tall
and then raises the interface zoom gets a box that is still the same
size relative to the text in it, rather than one that keeps the pixel
count and loses two of its four visible lines.
"""
try:
return max(0, int(_settings().value(_KEY_NEWS_HEIGHT, 0) or 0))
except (TypeError, ValueError):
return 0
[docs]
def set_news_height(px: int) -> int:
"""Remember how tall Home's release-notes list was dragged.
:param px: the list height in font-scale-independent pixels; negative
values store 0, and an unparseable value stores nothing and returns 0.
"""
try:
value = max(0, int(px))
except (TypeError, ValueError):
return 0
settings = _settings()
settings.setValue(_KEY_NEWS_HEIGHT, value)
settings.sync()
return value
#: Sound (427). Every key is off, or quiet, on a fresh install: a scientific
#: tool that makes noise the first time it is opened, in a shared office or
#: during a talk, is a tool people learn to distrust.
_KEY_SOUND_ENABLED = "sound/enabled"
_KEY_SOUND_VOLUME = "sound/volume"
_KEY_SOUND_THEME = "sound/theme"
_KEY_SOUND_EVENT = "sound/event/{}"
_KEY_SOUND_MUSIC = "sound/music_file"
#: The master switch. Nothing is imported, constructed or played while off.
DEFAULT_SOUND_ENABLED = False
#: Master volume as a fraction of the slider, 0 to 1.
DEFAULT_SOUND_VOLUME = 0.5
#: Each event's own switch, as a fresh install has it. They only matter once
#: the master switch is on; hover and the music bed stay off even then,
#: because each is the one a user should have to ask for by name.
SOUND_EVENT_DEFAULTS = {
"click": True,
"hover": False,
"run_finished": True,
"run_failed": True,
"bed": False,
}
#: Performance levels at which the music bed rests. They are the two that
#: switch the animated backdrop off, and a loop playing for hours is the
#: audio equivalent of one.
SOUND_BED_RESTS_AT = ("laptop", "extra_performance")
[docs]
def sound_is_offered() -> bool:
"""Whether this process offers sound at all: only in spaceout mode.
Ordinary spaCR leaves sound off and hides its preferences tab. Spaceout is
process-local (:func:`spacr.qt.theme.enable_spaceout`, called only by
the ``spaceout`` launcher), so this is read live and never stored.
Does not import :mod:`spacr.qt.theme`: a process that has not imported
it cannot have enabled spaceout, and this is asked on paths that must
stay free of QtGui.
:returns: ``True`` in spaceout mode, ``False`` in ordinary spaCR.
"""
import sys as _sys
theme = _sys.modules.get(__package__ + ".theme")
if theme is None:
return False
try:
return bool(theme.spaceout_enabled())
except Exception: # noqa: BLE001
return False
[docs]
def get_saved_sound_enabled() -> bool:
"""The stored master sound switch, whatever the mode.
Ordinary spaCR ignores it (see :func:`get_sound_enabled`) but never
erases it, so a user who switched sound on in spaceout finds it on the
next time spaceout starts.
:returns: the stored switch, default ``False``.
"""
return _as_bool(_settings().value(_KEY_SOUND_ENABLED,
DEFAULT_SOUND_ENABLED),
DEFAULT_SOUND_ENABLED)
[docs]
def get_sound_enabled() -> bool:
"""Whether spaCR plays any sound at all. Default ``False``.
Always ``False`` outside spaceout mode (:func:`sound_is_offered`),
whatever is stored: ordinary spaCR has no Sound tab, so a switch the
user cannot see must not be able to make a noise.
:returns: the stored master switch in spaceout mode, else ``False``.
"""
return sound_is_offered() and get_saved_sound_enabled()
[docs]
def set_sound_enabled(on: bool) -> None:
"""Persist the master sound switch.
:param on: play sounds when True.
"""
settings = _settings()
settings.setValue(_KEY_SOUND_ENABLED, bool(on))
settings.sync()
[docs]
def get_sound_volume() -> float:
"""The master volume, 0 to 1, clamped on read.
:returns: the stored fraction, or :data:`DEFAULT_SOUND_VOLUME` when the
store holds something that is not a number.
"""
try:
value = float(_settings().value(_KEY_SOUND_VOLUME,
DEFAULT_SOUND_VOLUME))
except (TypeError, ValueError):
return DEFAULT_SOUND_VOLUME
if value != value:
return DEFAULT_SOUND_VOLUME
return min(1.0, max(0.0, value))
[docs]
def set_sound_volume(fraction: float) -> float:
"""Persist the master volume.
:param fraction: 0 to 1; values outside are clamped.
:returns: the value stored.
"""
try:
value = min(1.0, max(0.0, float(fraction)))
except (TypeError, ValueError):
value = DEFAULT_SOUND_VOLUME
settings = _settings()
settings.setValue(_KEY_SOUND_VOLUME, value)
settings.sync()
return value
def _sound_theme_keys() -> tuple:
"""Every sound set that can be chosen, by key."""
from .sound_synth import SOUND_THEMES
return tuple(SOUND_THEMES)
[docs]
def get_sound_theme() -> str:
"""Which sound set plays, validated against the sets that exist.
:returns: a key of :data:`spacr.qt.sound_synth.SOUND_THEMES`; a stored
key that no longer exists reads as the default set.
"""
from .sound_synth import DEFAULT_THEME
raw = str(_settings().value(_KEY_SOUND_THEME, DEFAULT_THEME) or "")
return raw if raw in _sound_theme_keys() else DEFAULT_THEME
[docs]
def set_sound_theme(key: str) -> None:
"""Persist the chosen sound set.
:param key: a key of :data:`spacr.qt.sound_synth.SOUND_THEMES`.
:raises ValueError: for a key no sound set has.
"""
if key not in _sound_theme_keys():
raise ValueError(f"unknown sound set {key!r}. "
f"Choose from {_sound_theme_keys()}.")
settings = _settings()
settings.setValue(_KEY_SOUND_THEME, str(key))
settings.sync()
[docs]
def get_sound_event_enabled(event: str) -> bool:
"""Whether one event's sound is switched on, apart from the master.
:param event: a key of :data:`SOUND_EVENT_DEFAULTS`.
:returns: the stored switch.
:raises KeyError: for an event spaCR has no sound for.
"""
default = SOUND_EVENT_DEFAULTS[event]
return _as_bool(_settings().value(_KEY_SOUND_EVENT.format(event), default),
default)
[docs]
def set_sound_event_enabled(event: str, on: bool) -> None:
"""Persist one event's switch.
:param event: a key of :data:`SOUND_EVENT_DEFAULTS`.
:param on: play that event's sound when the master switch is on.
:raises KeyError: for an event spaCR has no sound for.
"""
if event not in SOUND_EVENT_DEFAULTS:
raise KeyError(event)
settings = _settings()
settings.setValue(_KEY_SOUND_EVENT.format(event), bool(on))
settings.sync()
[docs]
def get_sound_music_file() -> str:
"""A WAV of the user's own to play as the music bed, or ``""``.
Empty -- the default -- means spaCR's own synthesized bed. The file is
NOT checked here: this is called on the GUI thread on every settings
read, and ``spacr.qt.sound`` looks for the file on its audio thread,
where a network home directory costs nobody a frame.
:returns: the stored path, or ``""``.
"""
return str(_settings().value(_KEY_SOUND_MUSIC, "") or "").strip()
[docs]
def set_sound_music_file(path) -> str:
"""Persist the music file the bed plays.
:param path: a path, or anything empty for spaCR's own music.
:returns: the value stored.
"""
value = str(path or "").strip()
settings = _settings()
settings.setValue(_KEY_SOUND_MUSIC, value)
settings.sync()
return value
[docs]
def sound_bed_rests() -> bool:
"""Whether the current performance level silences the music bed.
:returns: True at the levels named in :data:`SOUND_BED_RESTS_AT`.
"""
try:
return get_performance_level() in SOUND_BED_RESTS_AT
except Exception: # noqa: BLE001
return False