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.
208 lines
7.6 KiB
Python
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)
|