From 599972fa1adfe7b1e032eb5d09682c62686a741c Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 01:41:01 -0700 Subject: [PATCH] v0.43.0: visualizer presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 8 ++ lintunes/__init__.py | 2 +- lintunes/gui/preferences_dialog.py | 110 ++++++++++++++- lintunes/gui/visualizer_presets.py | 207 +++++++++++++++++++++++++++++ lintunes/preferences.py | 1 + tests/test_round78.py | 173 ++++++++++++++++++++++++ 6 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 lintunes/gui/visualizer_presets.py diff --git a/CLAUDE.md b/CLAUDE.md index 2e36a9d..e384c0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index b085ef0..2eb11d1 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.42.0" +__version__ = "0.43.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 2f8da56..98f347f 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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): diff --git a/lintunes/gui/visualizer_presets.py b/lintunes/gui/visualizer_presets.py new file mode 100644 index 0000000..87aa064 --- /dev/null +++ b/lintunes/gui/visualizer_presets.py @@ -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) diff --git a/lintunes/preferences.py b/lintunes/preferences.py index df2e12a..9c705f4 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -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 diff --git a/tests/test_round78.py b/tests/test_round78.py index f011050..93883ac 100644 --- a/tests/test_round78.py +++ b/tests/test_round78.py @@ -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")