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.
This commit is contained in:
@@ -224,6 +224,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
lives in **`config.json`**; the dialog emits `prefs.changed` after writing
|
||||
it so the widget re-reads. The laser takes every heard sample exactly once
|
||||
(`_drawn` index), never the newest-decoded ones.
|
||||
**Presets** (`gui/visualizer_presets.py`): per style, `STYLE_KEYS` under a
|
||||
name, in `visualizer_presets` (synced; never the sync delay). `Default` is
|
||||
built in and reserved. Applying one moves the dialog's *controls* (each
|
||||
registers a setter in `_viz_setters`), and their own handlers save the
|
||||
values, so controls and prefs can't disagree. The drop-down reads
|
||||
"Custom" whenever the settings match no preset. A setting added later
|
||||
must join `STYLE_KEYS` and register a setter, or presets silently skip it
|
||||
(`test_every_style_key_has_a_control` pins that).
|
||||
The highlight swatches can be repainted with a color picker:
|
||||
`highlight_palette` overrides a slot, and `theme.highlight_color` reads
|
||||
through `theme.highlight_palette`, so nothing else needs to know.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.42.0"
|
||||
__version__ = "0.43.0"
|
||||
|
||||
@@ -5,7 +5,8 @@ from PyQt6.QtWidgets import (
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
||||
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
||||
QColorDialog, QMenu, QDoubleSpinBox,
|
||||
QColorDialog, QMenu, QDoubleSpinBox, QComboBox, QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QFont, QColor
|
||||
@@ -14,6 +15,7 @@ from lintunes import theme
|
||||
from lintunes.config import load_config, save_config
|
||||
from lintunes.gui import visualizer_styles
|
||||
from lintunes.gui.range_slider import RangeSlider
|
||||
from lintunes.gui import visualizer_presets as presets
|
||||
from lintunes.gui import sounds
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
@@ -35,6 +37,9 @@ _COLOR_ROWS = [
|
||||
_VALUE_WIDTH = 56
|
||||
|
||||
|
||||
_EDIT_PRESETS = "\0edit" # the preset drop-down's last item
|
||||
_SPEAKER_KEYS = ("visualizer_laser_low_hz", "visualizer_laser_high_hz")
|
||||
|
||||
# The laser's speaker range: 20 Hz – 20 kHz in log steps of 1/30 octave.
|
||||
SPEAKER_STEPS = 300
|
||||
|
||||
@@ -442,11 +447,14 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
# Only the chosen style's settings are shown.
|
||||
self._viz_pages = QStackedWidget()
|
||||
self._viz_setters = {} # key → puts a value into its control
|
||||
self._preset_combos = {} # style → its preset drop-down
|
||||
fade_tip = ("How long what's left behind takes to fade into the "
|
||||
"background — 0 is no trail")
|
||||
|
||||
bars = QWidget()
|
||||
form = self._viz_form(bars)
|
||||
self._preset_row(form, "bars")
|
||||
self._bars_fade_slider, _ = self._viz_slider(
|
||||
form, "Fade:", "visualizer_bars_fade", config, scale=20,
|
||||
fmt=_fade_text, tip=fade_tip)
|
||||
@@ -454,6 +462,7 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
radial = QWidget()
|
||||
form = self._viz_form(radial)
|
||||
self._preset_row(form, "radial")
|
||||
self._fade_slider, _ = self._viz_slider(
|
||||
form, "Fade:", "visualizer_ghost_fade", config, scale=20,
|
||||
fmt=_fade_text, tip=fade_tip)
|
||||
@@ -476,6 +485,7 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
laser = QWidget()
|
||||
form = self._viz_form(laser)
|
||||
self._preset_row(form, "laser")
|
||||
self._laser_fade_slider, _ = self._viz_slider(
|
||||
form, "Afterimage:", "visualizer_laser_afterimage", config,
|
||||
scale=20, fmt=_fade_text,
|
||||
@@ -559,8 +569,104 @@ class PreferencesDialog(QDialog):
|
||||
row.addWidget(box)
|
||||
row.addStretch()
|
||||
form.addRow("Line width:", row)
|
||||
self._viz_setters["visualizer_laser_width"] = box.setValue
|
||||
return box
|
||||
|
||||
# ---- Visualizer presets ----
|
||||
|
||||
def _preset_row(self, form, style: str):
|
||||
combo = QComboBox()
|
||||
combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
|
||||
combo.activated.connect(
|
||||
lambda index, s=style: self._on_preset_chosen(s, index))
|
||||
save = QPushButton("Save as Preset…")
|
||||
save.clicked.connect(lambda _=False, s=style: self._save_preset(s))
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(combo, stretch=1)
|
||||
row.addWidget(save)
|
||||
form.addRow("Preset:", row)
|
||||
self._preset_combos[style] = combo
|
||||
self._sync_preset_combo(style)
|
||||
if len(self._preset_combos) == 1:
|
||||
# Any change — a slider, a preset applied, an edit on the other
|
||||
# machine — may make the settings match a preset, or stop.
|
||||
self._prefs.changed.connect(self._sync_preset_combos)
|
||||
|
||||
def _sync_preset_combos(self):
|
||||
for style in self._preset_combos:
|
||||
self._sync_preset_combo(style)
|
||||
|
||||
def _sync_preset_combo(self, style: str):
|
||||
"""List the presets, showing the one the settings match — or
|
||||
"Custom" when they match none (unsaved changes)."""
|
||||
combo = self._preset_combos[style]
|
||||
match = presets.matching(self._prefs, style)
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
if match is None:
|
||||
combo.addItem("Custom", None)
|
||||
for name in presets.names(self._prefs, style):
|
||||
combo.addItem(name, name)
|
||||
combo.insertSeparator(combo.count())
|
||||
combo.addItem("Edit Presets…", _EDIT_PRESETS)
|
||||
combo.setCurrentIndex(0 if match is None else combo.findData(match))
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _on_preset_chosen(self, style: str, index: int):
|
||||
choice = self._preset_combos[style].itemData(index)
|
||||
if choice == _EDIT_PRESETS:
|
||||
self._edit_presets(style)
|
||||
elif choice is not None:
|
||||
self._apply_preset(style, choice)
|
||||
self._sync_preset_combo(style)
|
||||
|
||||
def _apply_preset(self, style: str, name: str):
|
||||
"""Move the controls to the preset; they save it as if dragged."""
|
||||
values = presets.values_of(self._prefs, style, name)
|
||||
for key, value in values.items():
|
||||
if key not in _SPEAKER_KEYS:
|
||||
self._viz_setters[key](value)
|
||||
if style == "laser":
|
||||
self._speaker.setValues(
|
||||
_hz_to_step(values["visualizer_laser_low_hz"]),
|
||||
_hz_to_step(values["visualizer_laser_high_hz"]))
|
||||
|
||||
def _save_preset(self, style: str):
|
||||
suggestion = presets.matching(self._prefs, style) or ""
|
||||
if suggestion == presets.DEFAULT_NAME:
|
||||
suggestion = ""
|
||||
name = (self._ask_preset_name(suggestion) or "").strip()
|
||||
if not name:
|
||||
return
|
||||
if name == presets.DEFAULT_NAME:
|
||||
self._preset_warning(
|
||||
f"“{name}” is the built-in preset. Pick another name.")
|
||||
return
|
||||
if name in presets.presets(self._prefs, style) and not \
|
||||
self._confirm_overwrite(name):
|
||||
return
|
||||
presets.save(self._prefs, style, name)
|
||||
self._sync_preset_combo(style)
|
||||
|
||||
def _edit_presets(self, style: str):
|
||||
presets.PresetEditor(self._prefs, style, _VISUALIZER_STYLES[style],
|
||||
self).exec()
|
||||
|
||||
# Overridable, so tests needn't type into dialogs.
|
||||
def _ask_preset_name(self, suggestion: str) -> str:
|
||||
name, ok = QInputDialog.getText(self, "Save as Preset", "Name:",
|
||||
text=suggestion)
|
||||
return name if ok else ""
|
||||
|
||||
def _confirm_overwrite(self, name: str) -> bool:
|
||||
return QMessageBox.question(
|
||||
self, "Save as Preset",
|
||||
f"Replace the preset “{name}” with these settings?") == \
|
||||
QMessageBox.StandardButton.Yes
|
||||
|
||||
def _preset_warning(self, text: str):
|
||||
QMessageBox.warning(self, "Save as Preset", text)
|
||||
|
||||
def _speaker_range(self, form, config) -> RangeSlider:
|
||||
"""The speaker under the mirror: which frequencies it plays, on a
|
||||
log scale (each slider step is the same musical interval)."""
|
||||
@@ -635,6 +741,7 @@ class PreferencesDialog(QDialog):
|
||||
box.setToolTip(tip)
|
||||
box.toggled.connect(lambda on: self._prefs.set(key, on))
|
||||
form.addRow(box)
|
||||
self._viz_setters[key] = lambda value: box.setChecked(bool(value))
|
||||
return box
|
||||
|
||||
def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str,
|
||||
@@ -661,6 +768,7 @@ class PreferencesDialog(QDialog):
|
||||
row.addSpacing(8)
|
||||
row.addWidget(text)
|
||||
form.addRow(label, row)
|
||||
self._viz_setters[key] = lambda value: slider.setValue(round(value * scale))
|
||||
return slider, text
|
||||
|
||||
def _on_viz_style(self, key: str):
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""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)
|
||||
@@ -25,6 +25,7 @@ DEFAULTS = {
|
||||
"visualizer_laser_head": True, # a dot at the moving beam's tip
|
||||
"visualizer_laser_glow": True, # the haze/scatter bloom
|
||||
"visualizer_laser_grid": False, # an oscilloscope's dot graticule
|
||||
"visualizer_presets": {}, # {style: {name: settings}}
|
||||
"visualizer_laser_low_hz": 20, # the speaker under the mirror:
|
||||
"visualizer_laser_high_hz": 20000, # what it plays (ends = no filter)
|
||||
"visualizer_radial_spokes": 120, # frequency bands around the circle
|
||||
|
||||
@@ -866,3 +866,176 @@ class TestNewControls:
|
||||
return sum(QColor(img.pixel(x, y)) != QColor("white")
|
||||
for x in range(400) for y in range(0, 400, 2))
|
||||
assert ink(0.5) < ink(1.0) < ink(2.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 8. Presets
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
from lintunes.gui import visualizer_presets as presets
|
||||
|
||||
|
||||
def _combo_texts(combo):
|
||||
return [combo.itemText(i) for i in range(combo.count())]
|
||||
|
||||
|
||||
class TestPresets:
|
||||
def _dialog(self, prefs):
|
||||
return TestPreferencesPage._dialog(None, prefs)
|
||||
|
||||
def test_every_style_has_a_preset_menu(self, qapp, tmp_path):
|
||||
dialog = self._dialog(Preferences(tmp_path))
|
||||
for style in ("bars", "radial", "laser"):
|
||||
combo = dialog._preset_combos[style]
|
||||
assert combo.currentText() == "Default" # untouched = Default
|
||||
assert _combo_texts(combo)[-1] == "Edit Presets…"
|
||||
|
||||
def test_changing_a_setting_reads_custom(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._spokes_slider.setValue(60)
|
||||
assert dialog._preset_combos["radial"].currentText() == "Custom"
|
||||
assert dialog._preset_combos["laser"].currentText() == "Default"
|
||||
|
||||
def test_save_and_apply(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._angle_sliders["visualizer_laser_pitch"].setValue(40)
|
||||
dialog._grid.setChecked(True)
|
||||
dialog._speaker.setValues(150, 300)
|
||||
dialog._width_box.setValue(3.5)
|
||||
dialog._ask_preset_name = lambda suggestion: "Big Tilt"
|
||||
dialog._save_preset("laser")
|
||||
combo = dialog._preset_combos["laser"]
|
||||
assert combo.currentText() == "Big Tilt"
|
||||
|
||||
# Back to Default: every control (and setting) follows.
|
||||
dialog._on_preset_chosen("laser", combo.findData("Default"))
|
||||
assert prefs.get("visualizer_laser_pitch") == 0
|
||||
assert prefs.get("visualizer_laser_grid") is False
|
||||
assert prefs.get("visualizer_laser_width") == 1.5
|
||||
assert dialog._speaker.values() == (0, 300)
|
||||
assert combo.currentText() == "Default"
|
||||
|
||||
dialog._on_preset_chosen("laser", combo.findData("Big Tilt"))
|
||||
assert prefs.get("visualizer_laser_pitch") == 40
|
||||
assert prefs.get("visualizer_laser_grid") is True
|
||||
assert prefs.get("visualizer_laser_width") == 3.5
|
||||
assert dialog._angle_sliders["visualizer_laser_pitch"].value() == 40
|
||||
assert dialog._speaker.values() == (150, 300)
|
||||
assert combo.currentText() == "Big Tilt"
|
||||
|
||||
def test_presets_survive_a_restart(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._bars_fade_slider.setValue(20)
|
||||
dialog._ask_preset_name = lambda suggestion: "Ghosty"
|
||||
dialog._save_preset("bars")
|
||||
again = self._dialog(Preferences(tmp_path))
|
||||
assert "Ghosty" in _combo_texts(again._preset_combos["bars"])
|
||||
|
||||
def test_default_is_reserved(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
warned = []
|
||||
dialog._preset_warning = warned.append
|
||||
dialog._ask_preset_name = lambda suggestion: "Default"
|
||||
dialog._save_preset("radial")
|
||||
assert warned and presets.presets(prefs, "radial") == {}
|
||||
|
||||
def test_overwrite_asks_first(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._ask_preset_name = lambda suggestion: "Mine"
|
||||
dialog._save_preset("radial")
|
||||
dialog._spokes_slider.setValue(200)
|
||||
dialog._confirm_overwrite = lambda name: False
|
||||
dialog._save_preset("radial")
|
||||
assert presets.presets(prefs, "radial")["Mine"][
|
||||
"visualizer_radial_spokes"] == 120 # kept
|
||||
dialog._confirm_overwrite = lambda name: True
|
||||
dialog._save_preset("radial")
|
||||
assert presets.presets(prefs, "radial")["Mine"][
|
||||
"visualizer_radial_spokes"] == 200
|
||||
|
||||
def test_a_preset_only_holds_its_own_style(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "bars", "x")
|
||||
assert set(presets.presets(prefs, "bars")["x"]) == {"visualizer_bars_fade"}
|
||||
assert "visualizer_sync_delay_ms" not in presets.STYLE_KEYS["laser"]
|
||||
|
||||
def test_an_old_preset_takes_new_settings_defaults(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set(presets.PRESETS_KEY,
|
||||
{"laser": {"Old": {"visualizer_laser_pitch": 20}}})
|
||||
values = presets.values_of(prefs, "laser", "Old")
|
||||
assert values["visualizer_laser_pitch"] == 20
|
||||
assert values["visualizer_laser_glow"] is True # default
|
||||
|
||||
def test_every_style_key_has_a_control(self, qapp, tmp_path):
|
||||
dialog = self._dialog(Preferences(tmp_path))
|
||||
for style, keys in presets.STYLE_KEYS.items():
|
||||
for key in keys:
|
||||
assert key in dialog._viz_setters or key in (
|
||||
"visualizer_laser_low_hz", "visualizer_laser_high_hz")
|
||||
|
||||
|
||||
class TestPresetEditor:
|
||||
def _editor(self, prefs, style="laser"):
|
||||
return presets.PresetEditor(prefs, style, "Laser")
|
||||
|
||||
def test_lists_only_user_presets(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
editor = self._editor(prefs)
|
||||
assert editor.list.count() == 0 and not editor.empty.isHidden()
|
||||
presets.save(prefs, "laser", "b")
|
||||
presets.save(prefs, "laser", "A")
|
||||
editor = self._editor(prefs)
|
||||
assert [editor.list.item(i).text() for i in range(2)] == ["A", "b"]
|
||||
|
||||
def test_rename_in_place(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "laser", "Old")
|
||||
editor = self._editor(prefs)
|
||||
editor.list.item(0).setText("New")
|
||||
assert list(presets.presets(prefs, "laser")) == ["New"]
|
||||
assert editor.list.item(0).text() == "New"
|
||||
|
||||
def test_rename_to_a_taken_name_is_refused(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "laser", "One")
|
||||
presets.save(prefs, "laser", "Two")
|
||||
editor = self._editor(prefs)
|
||||
warned = []
|
||||
editor._warn = warned.append
|
||||
editor.list.item(0).setText("Two")
|
||||
assert warned
|
||||
assert sorted(presets.presets(prefs, "laser")) == ["One", "Two"]
|
||||
assert editor.list.item(0).text() == "One"
|
||||
|
||||
def test_rename_to_default_is_refused(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "laser", "One")
|
||||
editor = self._editor(prefs)
|
||||
editor._warn = lambda text: None
|
||||
editor.list.item(0).setText("Default")
|
||||
assert list(presets.presets(prefs, "laser")) == ["One"]
|
||||
|
||||
def test_delete_asks_then_deletes(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "laser", "Gone")
|
||||
editor = self._editor(prefs)
|
||||
editor._confirm = lambda text: False
|
||||
editor._delete_current()
|
||||
assert "Gone" in presets.presets(prefs, "laser")
|
||||
editor._confirm = lambda text: True
|
||||
editor._delete_current()
|
||||
assert presets.presets(prefs, "laser") == {}
|
||||
assert not editor.delete_button.isEnabled()
|
||||
|
||||
def test_editing_one_style_leaves_the_others(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
presets.save(prefs, "bars", "Keep")
|
||||
presets.save(prefs, "laser", "Drop")
|
||||
presets.delete(prefs, "laser", "Drop")
|
||||
assert "Keep" in presets.presets(prefs, "bars")
|
||||
|
||||
Reference in New Issue
Block a user