Skip to content

Commit 740aaf0

Browse files
committed
Tighten preset replacement UX
1 parent 376d857 commit 740aaf0

6 files changed

Lines changed: 250 additions & 17 deletions

File tree

src/mini_eq/window.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,14 +824,17 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) ->
824824

825825
try:
826826
imported_count = self.controller.import_apo_preset(path)
827+
curve_label = imported_apo_curve_label(path)
827828
self.selected_band_index = None
828829
self.set_visible_band_count(imported_count)
829830
self.current_preset_name = None
830831
self.saved_preset_signature = self.controller.state_signature()
831-
self.set_curve_revert_baseline(imported_apo_curve_label(path))
832+
self.set_curve_revert_baseline(curve_label)
832833
self.output_preset_curve_auto_loaded = False
833834
self.refresh_preset_list()
834835
self.sync_ui_from_state()
836+
self.set_status(curve_label)
837+
self.notify_control_state_changed()
835838
except Exception as exc:
836839
self.set_status(str(exc))
837840

src/mini_eq/window_layout.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .window_utils import (
3030
bind_label_to_control,
3131
constrain_editor_label,
32+
make_ellipsizing_string_list_factory,
3233
set_accessible_description,
3334
set_accessible_label,
3435
)
@@ -107,6 +108,8 @@ def build_window_content(self, auto_route: bool) -> None:
107108
self.output_combo.set_hexpand(False)
108109
self.output_combo.set_size_request(300, -1)
109110
self.output_combo.add_css_class("toolbar-select")
111+
self.output_combo.set_factory(make_ellipsizing_string_list_factory(34))
112+
self.output_combo.set_list_factory(make_ellipsizing_string_list_factory(42))
110113
set_accessible_label(self.output_combo, "EQ output")
111114
bind_label_to_control(output_label, self.output_combo)
112115
output_inline.append(self.output_combo)

src/mini_eq/window_presets.py

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .window_utils import requested_switch_state, set_switch_confirmed_state
3434

3535
APO_IMPORT_LABEL_PREFIX = "Imported APO: "
36+
DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: "
3637

3738

3839
def imported_apo_curve_label(path: str) -> str:
@@ -201,10 +202,54 @@ def suggested_save_as_name(self) -> str:
201202
label = self.current_curve_source_label()
202203
if label and label.startswith(APO_IMPORT_LABEL_PREFIX):
203204
return sanitize_preset_name(label[len(APO_IMPORT_LABEL_PREFIX) :])
205+
if label and label.startswith(DELETED_PRESET_LABEL_PREFIX):
206+
return sanitize_preset_name(label[len(DELETED_PRESET_LABEL_PREFIX) :])
204207
if label == "Imported APO":
205208
return label
206209
return ""
207210

211+
def preset_name_exists(self, name: str) -> bool:
212+
return preset_path_for_name(name).exists()
213+
214+
def confirm_preset_replacement(
215+
self,
216+
preset_name: str,
217+
body: str,
218+
replace_callback: Callable[[], None],
219+
) -> None:
220+
dialog = Adw.AlertDialog()
221+
dialog.set_heading("Replace preset?")
222+
dialog.set_body(body)
223+
dialog.add_response("cancel", "Cancel")
224+
dialog.add_response("replace", "Replace")
225+
dialog.set_default_response("cancel")
226+
dialog.set_close_response("cancel")
227+
dialog.set_response_appearance("replace", Adw.ResponseAppearance.DESTRUCTIVE)
228+
dialog.choose(
229+
self,
230+
None,
231+
lambda dialog, result: self.on_preset_replace_dialog_done(dialog, result, replace_callback),
232+
)
233+
234+
def on_preset_replace_dialog_done(
235+
self,
236+
dialog: Adw.AlertDialog,
237+
result: Gio.AsyncResult,
238+
replace_callback: Callable[[], None],
239+
) -> None:
240+
try:
241+
response = dialog.choose_finish(result)
242+
except GLib.Error:
243+
return
244+
245+
if response != "replace":
246+
return
247+
248+
try:
249+
replace_callback()
250+
except Exception as exc:
251+
self.set_status(str(exc))
252+
208253
def output_preset_is_active(self) -> bool:
209254
linked_preset = self.output_preset_link_name()
210255
return bool(
@@ -539,6 +584,21 @@ def save_current_state_to_preset(self, name: str) -> None:
539584
self.notify_control_presets_changed()
540585
self.notify_control_state_changed()
541586

587+
def save_current_state_to_preset_as(self, name: str) -> None:
588+
preset_name = sanitize_preset_name(name)
589+
if not preset_name:
590+
raise ValueError("Preset name is empty")
591+
592+
if preset_name != self.current_preset_name and self.preset_name_exists(preset_name):
593+
self.confirm_preset_replacement(
594+
preset_name,
595+
f"{preset_name} already exists. Replace it with the current curve?",
596+
lambda: self.save_current_state_to_preset(preset_name),
597+
)
598+
return
599+
600+
self.save_current_state_to_preset(preset_name)
601+
542602
def load_library_preset(
543603
self,
544604
name: str,
@@ -756,7 +816,7 @@ def on_preset_save_clicked(self, button: Gtk.Button) -> None:
756816

757817
def on_preset_save_as_clicked(self, button: Gtk.Button) -> None:
758818
initial_name = self.suggested_save_as_name()
759-
self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset)
819+
self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset_as)
760820

761821
def on_preset_revert_clicked(self, button: Gtk.Button) -> None:
762822
if self.current_preset_name is not None:
@@ -907,7 +967,7 @@ def on_preset_delete_dialog_done(
907967
delete_preset_file(preset_name)
908968
self.current_preset_name = None
909969
self.saved_preset_signature = self.controller.state_signature()
910-
self.clear_curve_revert_baseline()
970+
self.set_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}")
911971
self.refresh_preset_list()
912972
self.sync_ui_from_state()
913973
self.set_status(f"Deleted Preset: {preset_name}; Current Curve Kept")
@@ -927,6 +987,20 @@ def on_preset_import_clicked(self, button: Gtk.Button) -> None:
927987
dialog.set_default_filter(file_filter)
928988
dialog.open(self, None, self.on_preset_import_done)
929989

990+
def import_library_preset_payload(self, preset_name: str, payload: dict[str, object]) -> None:
991+
write_mini_eq_preset_file(preset_path_for_name(preset_name), payload)
992+
self.controller.apply_preset_payload(payload)
993+
self.selected_band_index = None
994+
self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands))
995+
self.current_preset_name = preset_name
996+
self.saved_preset_signature = self.controller.state_signature()
997+
self.set_curve_revert_baseline(preset_name)
998+
self.refresh_preset_list()
999+
self.sync_ui_from_state()
1000+
self.set_status(f"Imported Preset: {preset_name}")
1001+
self.notify_control_presets_changed()
1002+
self.notify_control_state_changed()
1003+
9301004
def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
9311005
try:
9321006
file = dialog.open_finish(result)
@@ -947,18 +1021,15 @@ def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult)
9471021
stored_payload = dict(payload)
9481022
stored_payload["version"] = PRESET_VERSION
9491023
stored_payload["name"] = preset_name
950-
write_mini_eq_preset_file(preset_path_for_name(preset_name), stored_payload)
951-
self.controller.apply_preset_payload(stored_payload)
952-
self.selected_band_index = None
953-
self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands))
954-
self.current_preset_name = preset_name
955-
self.saved_preset_signature = self.controller.state_signature()
956-
self.set_curve_revert_baseline(preset_name)
957-
self.refresh_preset_list()
958-
self.sync_ui_from_state()
959-
self.set_status(f"Imported Preset: {preset_name}")
960-
self.notify_control_presets_changed()
961-
self.notify_control_state_changed()
1024+
if self.preset_name_exists(preset_name):
1025+
self.confirm_preset_replacement(
1026+
preset_name,
1027+
f"{preset_name} already exists. Replace it with the imported preset?",
1028+
lambda: self.import_library_preset_payload(preset_name, stored_payload),
1029+
)
1030+
return
1031+
1032+
self.import_library_preset_payload(preset_name, stored_payload)
9621033
except Exception as exc:
9631034
self.set_status(str(exc))
9641035

tests/test_mini_eq_atspi_widgets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,10 @@ def verify_dropdown_exposes_options(frame, *, combo_name, required_options):
435435
raise AssertionError("Not Applied status is missing")
436436
if find_accessible(frame, name="Off", role="status bar", showing=True) is None:
437437
raise AssertionError("Monitor Off status is missing")
438+
if find_accessible(frame, name="EQ output", role="combo box", showing=True) is None:
439+
raise AssertionError("EQ output combo box is missing")
440+
if find_accessible(frame, name="Preset", role="combo box", showing=True) is None:
441+
raise AssertionError("Preset combo box is missing")
438442
439443
verify_dropdown_exposes_options(frame, combo_name="Type", required_options=("Notch", "Bell"))
440444

tests/test_mini_eq_output_presets.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ def choose_finish(self, _result: object) -> str:
104104
return self.response
105105

106106

107+
class FakeFile:
108+
def __init__(self, path: str) -> None:
109+
self.path = path
110+
111+
def get_path(self) -> str:
112+
return self.path
113+
114+
115+
class FakeOpenDialog:
116+
def __init__(self, path: str) -> None:
117+
self.path = path
118+
119+
def open_finish(self, _result: object) -> FakeFile:
120+
return FakeFile(self.path)
121+
122+
107123
class OutputPresetWindow(window_presets.MiniEqWindowPresetMixin):
108124
def __init__(self, controller) -> None:
109125
self.controller = controller
@@ -123,6 +139,7 @@ def __init__(self, controller) -> None:
123139
self.sync_count = 0
124140
self.state_count = 0
125141
self.presets_count = 0
142+
self.replace_confirmations: list[SimpleNamespace] = []
126143
self.preset_state_label = FakeLabel()
127144
self.current_curve_state_label = FakeLabel()
128145
self.current_curve_row = FakeButton()
@@ -160,6 +177,20 @@ def notify_control_state_changed(self) -> None:
160177
def notify_control_presets_changed(self) -> None:
161178
self.presets_count += 1
162179

180+
def confirm_preset_replacement(
181+
self,
182+
preset_name: str,
183+
body: str,
184+
replace_callback,
185+
) -> None:
186+
self.replace_confirmations.append(
187+
SimpleNamespace(
188+
preset_name=preset_name,
189+
body=body,
190+
replace_callback=replace_callback,
191+
)
192+
)
193+
163194

164195
def make_controller(output_sink: str = "alsa_output.headphones"):
165196
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
@@ -432,6 +463,67 @@ def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_pat
432463
assert controller.bands[0].gain_db == 4.0
433464

434465

466+
def test_save_as_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None:
467+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
468+
write_test_preset("Headphones", 4.0)
469+
controller = make_controller()
470+
controller.bands[0].gain_db = 2.0
471+
test_window = OutputPresetWindow(controller)
472+
473+
test_window.save_current_state_to_preset_as("Headphones")
474+
475+
assert len(test_window.replace_confirmations) == 1
476+
confirmation = test_window.replace_confirmations[0]
477+
assert confirmation.preset_name == "Headphones"
478+
assert confirmation.body == "Headphones already exists. Replace it with the current curve?"
479+
assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0
480+
481+
confirmation.replace_callback()
482+
483+
assert test_window.current_preset_name == "Headphones"
484+
assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0
485+
486+
487+
def test_save_as_current_preset_overwrites_without_replace_confirmation(monkeypatch, tmp_path) -> None:
488+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
489+
write_test_preset("Headphones", 4.0)
490+
controller = make_controller()
491+
test_window = OutputPresetWindow(controller)
492+
test_window.load_library_preset("Headphones")
493+
controller.bands[0].gain_db = 2.0
494+
495+
test_window.save_current_state_to_preset_as("Headphones")
496+
497+
assert test_window.replace_confirmations == []
498+
assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0
499+
500+
501+
def test_importing_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None:
502+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
503+
write_test_preset("Headphones", 4.0)
504+
import_controller = make_controller()
505+
import_controller.bands[0].gain_db = 6.0
506+
import_path = tmp_path / "headphones.json"
507+
core.write_mini_eq_preset_file(import_path, import_controller.build_preset_payload("Headphones"))
508+
controller = make_controller()
509+
test_window = OutputPresetWindow(controller)
510+
511+
test_window.on_preset_import_done(FakeOpenDialog(str(import_path)), None)
512+
513+
assert len(test_window.replace_confirmations) == 1
514+
confirmation = test_window.replace_confirmations[0]
515+
assert confirmation.preset_name == "Headphones"
516+
assert confirmation.body == "Headphones already exists. Replace it with the imported preset?"
517+
assert controller.bands[0].gain_db == 0.0
518+
assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0
519+
520+
confirmation.replace_callback()
521+
522+
assert test_window.current_preset_name == "Headphones"
523+
assert controller.bands[0].gain_db == 6.0
524+
assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 6.0
525+
526+
435527
def test_initial_output_preset_auto_loads_linked_preset(monkeypatch, tmp_path) -> None:
436528
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
437529
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
@@ -662,10 +754,13 @@ def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(monkey
662754
assert test_window.preset_names == []
663755
assert test_window.current_preset_name is None
664756
assert controller.bands[0].gain_db == 2.5
665-
assert test_window.preset_state_label.text == "Modified"
757+
assert test_window.preset_state_label.text == "Unsaved"
758+
assert test_window.current_curve_row.visible is True
759+
assert test_window.current_curve_state_label.text == "Unsaved copy: Headphones"
760+
assert test_window.suggested_save_as_name() == "Headphones"
666761
assert test_window.preset_revert_button.visible is False
667762
assert test_window.preset_revert_button.sensitive is False
668-
assert test_window.preset_revert_button.tooltip == "No preset baseline to revert to"
763+
assert test_window.preset_revert_button.tooltip == "No curve changes to revert"
669764
assert test_window.preset_reset_to_neutral_button.visible is True
670765
assert test_window.preset_reset_to_neutral_button.sensitive is True
671766
assert test_window.statuses[-1] == "Deleted Preset: Headphones; Current Curve Kept"

0 commit comments

Comments
 (0)