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
|
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
|
it so the widget re-reads. The laser takes every heard sample exactly once
|
||||||
(`_drawn` index), never the newest-decoded ones.
|
(`_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:
|
The highlight swatches can be repainted with a color picker:
|
||||||
`highlight_palette` overrides a slot, and `theme.highlight_color` reads
|
`highlight_palette` overrides a slot, and `theme.highlight_color` reads
|
||||||
through `theme.highlight_palette`, so nothing else needs to know.
|
through `theme.highlight_palette`, so nothing else needs to know.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""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,
|
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||||
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
||||||
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
||||||
QColorDialog, QMenu, QDoubleSpinBox,
|
QColorDialog, QMenu, QDoubleSpinBox, QComboBox, QInputDialog,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtGui import QFont, QColor
|
from PyQt6.QtGui import QFont, QColor
|
||||||
@@ -14,6 +15,7 @@ from lintunes import theme
|
|||||||
from lintunes.config import load_config, save_config
|
from lintunes.config import load_config, save_config
|
||||||
from lintunes.gui import visualizer_styles
|
from lintunes.gui import visualizer_styles
|
||||||
from lintunes.gui.range_slider import RangeSlider
|
from lintunes.gui.range_slider import RangeSlider
|
||||||
|
from lintunes.gui import visualizer_presets as presets
|
||||||
from lintunes.gui import sounds
|
from lintunes.gui import sounds
|
||||||
from lintunes.theme import HIGHLIGHT_COLORS
|
from lintunes.theme import HIGHLIGHT_COLORS
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ _COLOR_ROWS = [
|
|||||||
_VALUE_WIDTH = 56
|
_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.
|
# The laser's speaker range: 20 Hz – 20 kHz in log steps of 1/30 octave.
|
||||||
SPEAKER_STEPS = 300
|
SPEAKER_STEPS = 300
|
||||||
|
|
||||||
@@ -442,11 +447,14 @@ class PreferencesDialog(QDialog):
|
|||||||
|
|
||||||
# Only the chosen style's settings are shown.
|
# Only the chosen style's settings are shown.
|
||||||
self._viz_pages = QStackedWidget()
|
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 "
|
fade_tip = ("How long what's left behind takes to fade into the "
|
||||||
"background — 0 is no trail")
|
"background — 0 is no trail")
|
||||||
|
|
||||||
bars = QWidget()
|
bars = QWidget()
|
||||||
form = self._viz_form(bars)
|
form = self._viz_form(bars)
|
||||||
|
self._preset_row(form, "bars")
|
||||||
self._bars_fade_slider, _ = self._viz_slider(
|
self._bars_fade_slider, _ = self._viz_slider(
|
||||||
form, "Fade:", "visualizer_bars_fade", config, scale=20,
|
form, "Fade:", "visualizer_bars_fade", config, scale=20,
|
||||||
fmt=_fade_text, tip=fade_tip)
|
fmt=_fade_text, tip=fade_tip)
|
||||||
@@ -454,6 +462,7 @@ class PreferencesDialog(QDialog):
|
|||||||
|
|
||||||
radial = QWidget()
|
radial = QWidget()
|
||||||
form = self._viz_form(radial)
|
form = self._viz_form(radial)
|
||||||
|
self._preset_row(form, "radial")
|
||||||
self._fade_slider, _ = self._viz_slider(
|
self._fade_slider, _ = self._viz_slider(
|
||||||
form, "Fade:", "visualizer_ghost_fade", config, scale=20,
|
form, "Fade:", "visualizer_ghost_fade", config, scale=20,
|
||||||
fmt=_fade_text, tip=fade_tip)
|
fmt=_fade_text, tip=fade_tip)
|
||||||
@@ -476,6 +485,7 @@ class PreferencesDialog(QDialog):
|
|||||||
|
|
||||||
laser = QWidget()
|
laser = QWidget()
|
||||||
form = self._viz_form(laser)
|
form = self._viz_form(laser)
|
||||||
|
self._preset_row(form, "laser")
|
||||||
self._laser_fade_slider, _ = self._viz_slider(
|
self._laser_fade_slider, _ = self._viz_slider(
|
||||||
form, "Afterimage:", "visualizer_laser_afterimage", config,
|
form, "Afterimage:", "visualizer_laser_afterimage", config,
|
||||||
scale=20, fmt=_fade_text,
|
scale=20, fmt=_fade_text,
|
||||||
@@ -559,8 +569,104 @@ class PreferencesDialog(QDialog):
|
|||||||
row.addWidget(box)
|
row.addWidget(box)
|
||||||
row.addStretch()
|
row.addStretch()
|
||||||
form.addRow("Line width:", row)
|
form.addRow("Line width:", row)
|
||||||
|
self._viz_setters["visualizer_laser_width"] = box.setValue
|
||||||
return box
|
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:
|
def _speaker_range(self, form, config) -> RangeSlider:
|
||||||
"""The speaker under the mirror: which frequencies it plays, on a
|
"""The speaker under the mirror: which frequencies it plays, on a
|
||||||
log scale (each slider step is the same musical interval)."""
|
log scale (each slider step is the same musical interval)."""
|
||||||
@@ -635,6 +741,7 @@ class PreferencesDialog(QDialog):
|
|||||||
box.setToolTip(tip)
|
box.setToolTip(tip)
|
||||||
box.toggled.connect(lambda on: self._prefs.set(key, on))
|
box.toggled.connect(lambda on: self._prefs.set(key, on))
|
||||||
form.addRow(box)
|
form.addRow(box)
|
||||||
|
self._viz_setters[key] = lambda value: box.setChecked(bool(value))
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str,
|
def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str,
|
||||||
@@ -661,6 +768,7 @@ class PreferencesDialog(QDialog):
|
|||||||
row.addSpacing(8)
|
row.addSpacing(8)
|
||||||
row.addWidget(text)
|
row.addWidget(text)
|
||||||
form.addRow(label, row)
|
form.addRow(label, row)
|
||||||
|
self._viz_setters[key] = lambda value: slider.setValue(round(value * scale))
|
||||||
return slider, text
|
return slider, text
|
||||||
|
|
||||||
def _on_viz_style(self, key: str):
|
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_head": True, # a dot at the moving beam's tip
|
||||||
"visualizer_laser_glow": True, # the haze/scatter bloom
|
"visualizer_laser_glow": True, # the haze/scatter bloom
|
||||||
"visualizer_laser_grid": False, # an oscilloscope's dot graticule
|
"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_low_hz": 20, # the speaker under the mirror:
|
||||||
"visualizer_laser_high_hz": 20000, # what it plays (ends = no filter)
|
"visualizer_laser_high_hz": 20000, # what it plays (ends = no filter)
|
||||||
"visualizer_radial_spokes": 120, # frequency bands around the circle
|
"visualizer_radial_spokes": 120, # frequency bands around the circle
|
||||||
|
|||||||
@@ -866,3 +866,176 @@ class TestNewControls:
|
|||||||
return sum(QColor(img.pixel(x, y)) != QColor("white")
|
return sum(QColor(img.pixel(x, y)) != QColor("white")
|
||||||
for x in range(400) for y in range(0, 400, 2))
|
for x in range(400) for y in range(0, 400, 2))
|
||||||
assert ink(0.5) < ink(1.0) < ink(2.0)
|
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