Files
lintunes/lintunes/gui/visualizer_presets.py
T
trav 599972fa1a v0.43.0: visualizer presets
Each visualizer style's settings page has a Preset drop-down and a
"Save as Preset…" button. The drop-down lists the built-in Default, your
presets, and "Edit Presets…" (rename in place, delete); it reads "Custom"
whenever the settings match no preset. Presets sync with the preferences;
the per-machine sync delay is never part of one.
2026-10-10 01:41:01 -07:00

208 lines
7.6 KiB
Python

"""Named presets for each visualizer style.
A preset is the style's own settings (``STYLE_KEYS``) under a name, kept in
preferences.json as ``visualizer_presets: {style: {name: {key: value}}}`` —
so presets ride the Syncthing share like the settings themselves. The sync
delay is never part of one: that is this machine's speakers, in config.json.
``Default`` is built in for every style (the settings as shipped), can't be
renamed, deleted or overwritten, and is the way back after experimenting.
"""
import copy
import math
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QDialogButtonBox, QLabel, QMessageBox,
)
from PyQt6.QtCore import Qt
from lintunes.preferences import DEFAULTS
from lintunes.gui import visualizer_styles
PRESETS_KEY = "visualizer_presets"
DEFAULT_NAME = "Default"
STYLE_KEYS = {
"bars": ("visualizer_bars_fade",),
"radial": ("visualizer_ghost_fade", "visualizer_radial_spokes",
"visualizer_radial_levels", "visualizer_radial_dot",
"visualizer_radial_normalize"),
"laser": ("visualizer_laser_afterimage", "visualizer_laser_low_hz",
"visualizer_laser_high_hz", "visualizer_laser_width",
"visualizer_laser_glow", "visualizer_laser_grid",
"visualizer_laser_head", "visualizer_laser_dot",
"visualizer_laser_distance", "visualizer_laser_pitch",
"visualizer_laser_yaw", "visualizer_laser_roll"),
}
def _all(prefs) -> dict:
saved = prefs.get(PRESETS_KEY) or {}
return saved if isinstance(saved, dict) else {}
def presets(prefs, style: str) -> dict:
"""The user's presets for ``style``, {name: {key: value}} — not Default."""
saved = _all(prefs).get(style) or {}
return {name: values for name, values in saved.items()
if isinstance(values, dict) and name != DEFAULT_NAME}
def names(prefs, style: str) -> list:
"""Default, then the user's presets in alphabetical order."""
return [DEFAULT_NAME] + sorted(presets(prefs, style), key=str.casefold)
def values_of(prefs, style: str, name: str) -> dict:
"""A preset's settings, every key present: a preset saved before a
setting existed takes that setting's default."""
stored = {} if name == DEFAULT_NAME else presets(prefs, style).get(name, {})
return {key: stored.get(key, DEFAULTS[key]) for key in STYLE_KEYS[style]}
def current(prefs, style: str) -> dict:
config = visualizer_styles.settings(prefs)
return {key: config[key] for key in STYLE_KEYS[style]}
def matching(prefs, style: str):
"""The preset the current settings are, or None (unsaved changes)."""
now = current(prefs, style)
for name in names(prefs, style):
if _same(now, values_of(prefs, style, name)):
return name
return None
def _same(a: dict, b: dict) -> bool:
for key, value in a.items():
other = b.get(key)
if isinstance(value, bool) or isinstance(other, bool):
if bool(value) != bool(other):
return False
elif not math.isclose(float(value), float(other),
rel_tol=1e-6, abs_tol=1e-6):
return False
return True
def _write(prefs, style: str, style_presets: dict):
everything = copy.deepcopy(_all(prefs))
everything[style] = style_presets
prefs.set(PRESETS_KEY, everything)
def save(prefs, style: str, name: str):
"""Save the current settings as ``name`` (overwriting one of that name)."""
name = name.strip()
if not name or name == DEFAULT_NAME:
raise ValueError(f"can't save a preset named {name!r}")
style_presets = copy.deepcopy(presets(prefs, style))
style_presets[name] = current(prefs, style)
_write(prefs, style, style_presets)
def rename(prefs, style: str, old: str, new: str):
new = new.strip()
style_presets = copy.deepcopy(presets(prefs, style))
if (old not in style_presets or not new or new == DEFAULT_NAME
or (new != old and new in style_presets)):
raise ValueError(f"can't rename {old!r} to {new!r}")
style_presets[new] = style_presets.pop(old)
_write(prefs, style, style_presets)
def delete(prefs, style: str, name: str):
style_presets = copy.deepcopy(presets(prefs, style))
style_presets.pop(name, None)
_write(prefs, style, style_presets)
class PresetEditor(QDialog):
"""Rename or delete one style's presets. Changes apply at once; Close
just closes. Default isn't listed: it can't be changed."""
def __init__(self, prefs, style: str, title: str, parent=None):
super().__init__(parent)
self._prefs, self._style = prefs, style
self.setWindowTitle(f"{title} Presets")
self.list = QListWidget()
self.list.itemChanged.connect(self._on_item_edited)
self.list.currentRowChanged.connect(lambda _row: self._enable())
self.rename_button = QPushButton("Rename")
self.rename_button.clicked.connect(
lambda: self.list.editItem(self.list.currentItem()))
self.delete_button = QPushButton("Delete")
self.delete_button.clicked.connect(self._delete_current)
self.empty = QLabel("No saved presets yet — use “Save as Preset…”.")
self.empty.setEnabled(False)
side = QVBoxLayout()
side.addWidget(self.rename_button)
side.addWidget(self.delete_button)
side.addStretch()
body = QHBoxLayout()
body.addWidget(self.list, stretch=1)
body.addLayout(side)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(body)
layout.addWidget(self.empty)
layout.addWidget(buttons)
self.resize(360, 280)
self._fill()
def _fill(self, select: str = None):
self.list.blockSignals(True)
self.list.clear()
for name in names(self._prefs, self._style)[1:]:
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, name)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
self.list.addItem(item)
if name == select:
self.list.setCurrentItem(item)
if self.list.currentRow() < 0 and self.list.count():
self.list.setCurrentRow(0)
self.list.blockSignals(False)
self.empty.setVisible(self.list.count() == 0)
self._enable()
def _enable(self):
chosen = self.list.currentItem() is not None
self.rename_button.setEnabled(chosen)
self.delete_button.setEnabled(chosen)
def _on_item_edited(self, item):
old = item.data(Qt.ItemDataRole.UserRole)
new = item.text().strip()
if new == old:
return
try:
rename(self._prefs, self._style, old, new)
except ValueError:
self._warn(f"“{new}” can't be used — it's empty, taken, or "
f"reserved for the built-in preset.")
self._fill(select=old)
return
self._fill(select=new)
def _delete_current(self):
item = self.list.currentItem()
if item is None:
return
name = item.data(Qt.ItemDataRole.UserRole)
if self._confirm(f"Delete the preset “{name}”?"):
delete(self._prefs, self._style, name)
self._fill()
# Overridable, so tests needn't click through message boxes.
def _confirm(self, text: str) -> bool:
return QMessageBox.question(self, "Delete Preset", text) == \
QMessageBox.StandardButton.Yes
def _warn(self, text: str):
QMessageBox.warning(self, "Rename Preset", text)