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:
2026-10-10 01:41:01 -07:00
parent 8b42580f97
commit 599972fa1a
6 changed files with 499 additions and 2 deletions
+8
View File
@@ -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 -1
View File
@@ -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"
+109 -1
View File
@@ -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):
+207
View File
@@ -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)
+1
View File
@@ -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
+173
View File
@@ -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")