From a3ca2cb6ea4ff6b8159b967133c245e5c6c7172e Mon Sep 17 00:00:00 2001 From: bhack Date: Sun, 7 Jun 2026 01:32:21 +0200 Subject: [PATCH] Fix saved preset dropdown for large libraries --- pyproject.toml | 3 + src/mini_eq/band_fader.py | 6 +- src/mini_eq/style.css | 13 ---- src/mini_eq/window_presets.py | 87 +++++++++----------------- src/mini_eq/window_utility.py | 28 +++------ tests/test_mini_eq_atspi_widgets.py | 12 +--- tests/test_mini_eq_band_fader.py | 28 +++++++++ tests/test_mini_eq_output_presets.py | 91 +++++++++++++++++++++++++--- 8 files changed, 157 insertions(+), 111 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d18cd3..bd1feb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,9 @@ mini_eq = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +filterwarnings = [ + "ignore:GLib\\.unix_signal_add_full is deprecated; use GLibUnix\\.signal_add_full instead:gi.PyGIDeprecationWarning", +] [tool.ruff] line-length = 120 diff --git a/src/mini_eq/band_fader.py b/src/mini_eq/band_fader.py index 5be7fd5..688efc2 100644 --- a/src/mini_eq/band_fader.py +++ b/src/mini_eq/band_fader.py @@ -218,9 +218,9 @@ def update_accessible_state(self) -> None: [ f"Band {self.index + 1} Gain", description, - GAIN_MIN_DB, - GAIN_MAX_DB, - self.gain_db, + float(GAIN_MIN_DB), + float(GAIN_MAX_DB), + float(self.gain_db), f"{self.gain_db:+.1f} dB", ], ) diff --git a/src/mini_eq/style.css b/src/mini_eq/style.css index 29607ac..2c22df4 100644 --- a/src/mini_eq/style.css +++ b/src/mini_eq/style.css @@ -167,19 +167,6 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { min-width: 190px; } -.preset-library-popover { - min-width: 260px; -} - -.preset-library-list { - min-width: 248px; -} - -.preset-library-action { - padding-top: 5px; - padding-bottom: 5px; -} - .preset-menu-separator { margin-top: 4px; margin-bottom: 4px; diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py index e39c225..7d1559d 100644 --- a/src/mini_eq/window_presets.py +++ b/src/mini_eq/window_presets.py @@ -9,7 +9,7 @@ gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") -from gi.repository import Adw, Gio, GLib, Gtk, Pango +from gi.repository import Adw, Gio, GLib, Gtk from .core import ( DEFAULT_ACTIVE_BANDS, @@ -37,6 +37,8 @@ APO_IMPORT_LABEL_PREFIX = "Imported APO: " DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: " +PRESET_PICKER_PLACEHOLDER = "Choose…" +PRESET_PICKER_EMPTY = "No saved presets" def imported_apo_curve_label_for_name(name: str) -> str: @@ -664,57 +666,14 @@ def refresh_preset_actions(self, state: PresetPanelUiState | None = None) -> Non self.update_output_preset_state() self.update_fallback_preset_state() - def refresh_preset_library_popover(self) -> None: - load_button = getattr(self, "preset_load_button", None) - if load_button is not None: - load_button.set_label("Choose…") - load_button.set_sensitive(bool(self.preset_names)) - load_button.set_tooltip_text("Load a saved preset" if self.preset_names else "No saved presets") - - box = getattr(self, "preset_library_box", None) - if box is None: - return - - while child := box.get_first_child(): - box.remove(child) - - if not self.preset_names: - empty_label = Gtk.Label(label="No saved presets", xalign=0.0) - empty_label.add_css_class("dim-label") - empty_label.set_margin_top(8) - empty_label.set_margin_bottom(8) - empty_label.set_margin_start(10) - empty_label.set_margin_end(10) - box.append(empty_label) + def refresh_preset_picker(self) -> None: + combo = getattr(self, "preset_combo", None) + if combo is None: return - for preset_name in self.preset_names: - button = Gtk.Button() - button.set_can_shrink(True) - button.set_hexpand(True) - button.add_css_class("popover-action") - button.add_css_class("preset-library-action") - button.add_css_class("flat") - button.set_tooltip_text(preset_name) - - label = Gtk.Label(label=preset_name, xalign=0.0) - label.set_hexpand(True) - label.set_wrap(True) - label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) - label.set_max_width_chars(42) - button.set_child(label) - button.connect("clicked", self.on_preset_library_button_clicked, preset_name) - box.append(button) - - def on_preset_library_button_clicked(self, _button: Gtk.Button, preset_name: str) -> None: - popover = getattr(self, "preset_library_popover", None) - if popover is not None: - popover.popdown() - - try: - self.load_library_preset(preset_name) - except Exception as exc: - self.set_status(str(exc)) + has_presets = bool(self.preset_names) + combo.set_sensitive(has_presets) + combo.set_tooltip_text("Load a saved preset" if has_presets else "No saved presets") def selected_preset_combo_index(self) -> int: if ( @@ -722,8 +681,13 @@ def selected_preset_combo_index(self) -> int: and self.current_preset_name in self.preset_names and self.controller.state_signature() == self.saved_preset_signature ): - return self.preset_names.index(self.current_preset_name) - return Gtk.INVALID_LIST_POSITION + return self.preset_names.index(self.current_preset_name) + 1 + return 0 + + def preset_picker_labels(self) -> list[str]: + if not self.preset_names: + return [PRESET_PICKER_EMPTY] + return [PRESET_PICKER_PLACEHOLDER, *self.preset_names] def sync_preset_combo_selection(self) -> None: combo = getattr(self, "preset_combo", None) @@ -818,9 +782,13 @@ def refresh_preset_list(self) -> None: else: self.sync_current_preset_signature_from_library() - self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names) - self.sync_preset_combo_selection() - self.refresh_preset_library_popover() + self.updating_preset_combo = True + try: + self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_picker_labels()) + self.preset_combo.set_selected(self.selected_preset_combo_index()) + finally: + self.updating_preset_combo = False + self.refresh_preset_picker() self.update_preset_state() @@ -1192,11 +1160,16 @@ def on_preset_selected(self, combo: Gtk.DropDown, _param: object) -> None: return selected = combo.get_selected() - if selected == Gtk.INVALID_LIST_POSITION or selected >= len(self.preset_names): + if selected == Gtk.INVALID_LIST_POSITION or selected == 0: + self.sync_preset_combo_selection() + return + + preset_index = selected - 1 + if preset_index >= len(self.preset_names): return try: - self.load_library_preset(self.preset_names[selected]) + self.load_library_preset(self.preset_names[preset_index]) except Exception as exc: self.set_status(str(exc)) diff --git a/src/mini_eq/window_utility.py b/src/mini_eq/window_utility.py index 858e812..5861ee7 100644 --- a/src/mini_eq/window_utility.py +++ b/src/mini_eq/window_utility.py @@ -9,6 +9,7 @@ from .window_utils import ( bind_label_to_control, + make_ellipsizing_string_list_factory, set_accessible_description, set_accessible_label, ) @@ -49,29 +50,20 @@ def make_preset_section(self) -> Gtk.Box: self.current_curve_row.append(self.current_curve_state_label) preset_section.append(self.current_curve_row) - self.preset_library_popover = Gtk.Popover() - self.preset_library_popover.add_css_class("preset-library-popover") - self.preset_library_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - self.preset_library_box.add_css_class("preset-library-list") - self.preset_library_box.set_margin_top(6) - self.preset_library_box.set_margin_bottom(6) - self.preset_library_box.set_margin_start(6) - self.preset_library_box.set_margin_end(6) - self.preset_library_popover.set_child(self.preset_library_box) - - self.preset_load_button = Gtk.MenuButton(label="Choose…") - self.preset_load_button.set_can_shrink(True) - self.preset_load_button.set_hexpand(True) - self.preset_load_button.add_css_class("toolbar-button") - self.preset_load_button.set_popover(self.preset_library_popover) - set_accessible_label(self.preset_load_button, "Load Preset") + self.preset_combo.set_hexpand(True) + self.preset_combo.set_enable_search(True) + self.preset_combo.add_css_class("toolbar-select") + self.preset_combo.set_factory(make_ellipsizing_string_list_factory(28)) + self.preset_combo.set_list_factory(make_ellipsizing_string_list_factory(42)) + set_accessible_label(self.preset_combo, "Load Preset") + set_accessible_description(self.preset_combo, "Load a saved preset") preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) preset_row.add_css_class("utility-row") preset_label = Gtk.Label(label="Load Preset", xalign=0.0) - bind_label_to_control(preset_label, self.preset_load_button) + bind_label_to_control(preset_label, self.preset_combo) preset_row.append(preset_label) - preset_row.append(self.preset_load_button) + preset_row.append(self.preset_combo) preset_section.append(preset_row) self.output_scope_state_label.set_hexpand(True) diff --git a/tests/test_mini_eq_atspi_widgets.py b/tests/test_mini_eq_atspi_widgets.py index 971e087..59a0528 100644 --- a/tests/test_mini_eq_atspi_widgets.py +++ b/tests/test_mini_eq_atspi_widgets.py @@ -439,16 +439,8 @@ def verify_dropdown_exposes_options(frame, *, combo_name, required_options): raise AssertionError("Monitor Off status is missing") if find_accessible(frame, name="EQ output", role="combo box", showing=True) is None: raise AssertionError("EQ output combo box is missing") - if ( - find_accessible_with_roles( - frame, - name="Load Preset", - roles={"push button", "toggle button"}, - showing=True, - ) - is None - ): - raise AssertionError("Load Preset menu button is missing") + if find_accessible(frame, name="Load Preset", role="combo box", showing=True) is None: + raise AssertionError("Load Preset combo box is missing") verify_dropdown_exposes_options(frame, combo_name="Type", required_options=("Notch", "Bell")) diff --git a/tests/test_mini_eq_band_fader.py b/tests/test_mini_eq_band_fader.py index a1dea62..8f08938 100644 --- a/tests/test_mini_eq_band_fader.py +++ b/tests/test_mini_eq_band_fader.py @@ -61,6 +61,34 @@ def test_band_fader_compact_labels_fit_small_tiles() -> None: assert fader.show_q_in_tile(170.0) is True +def test_band_fader_accessible_range_values_are_doubles() -> None: + fader, _selected, _gains, _activated = make_fader(gain_db=-4) + fader.frequency_label = "32 Hz" + fader.filter_type_label = "Bell" + fader.q_label = "0.80" + fader.selected = False + fader.active = True + fader.muted = False + fader.soloed = False + fader.solo_active = False + captured = {} + + def update_property(properties, values) -> None: + captured["properties"] = properties + captured["values"] = values + + fader.update_property = update_property + fader.update_state = lambda _states, _values: None + + fader.update_accessible_state() + + values = captured["values"] + assert isinstance(values[2], float) + assert isinstance(values[3], float) + assert isinstance(values[4], float) + assert values[4] == -4.0 + + def test_band_fader_keyboard_steps_select_and_clamp_gain() -> None: fader, selected, gains, activated = make_fader(gain_db=19.8) diff --git a/tests/test_mini_eq_output_presets.py b/tests/test_mini_eq_output_presets.py index e414d7b..2e7c66d 100644 --- a/tests/test_mini_eq_output_presets.py +++ b/tests/test_mini_eq_output_presets.py @@ -75,18 +75,22 @@ def remove_css_class(self, name: str) -> None: class FakeModel: def __init__(self) -> None: self.items: list[str] = [] + self.on_splice = None def get_n_items(self) -> int: return len(self.items) def splice(self, position: int, removed: int, added: list[str]) -> None: self.items[position : position + removed] = added + if self.on_splice is not None: + self.on_splice() class FakeCombo: def __init__(self, selected: int = 0) -> None: self.selected = selected self.sensitive = True + self.tooltip = "" def get_selected(self) -> int: return self.selected @@ -97,6 +101,9 @@ def set_selected(self, selected: int) -> None: def set_sensitive(self, sensitive: bool) -> None: self.sensitive = sensitive + def set_tooltip_text(self, text: str) -> None: + self.tooltip = text + class FakeOutputTransitionController(SimpleNamespace): def __init__( @@ -442,7 +449,7 @@ def test_reset_to_neutral_clears_loaded_preset_selection(monkeypatch, tmp_path) test_window.on_preset_reset_to_neutral_clicked(FakeButton()) assert test_window.current_preset_name is None - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_combo.selected == 0 assert controller.state_signature() == controller.default_state_signature() assert test_window.current_curve_state_label.text == "Neutral" assert test_window.current_curve_state_label.tooltip == "Neutral. Load Headphones to restore." @@ -453,7 +460,7 @@ def test_reset_to_neutral_clears_loaded_preset_selection(monkeypatch, tmp_path) test_window.load_library_preset("Headphones") assert test_window.current_preset_name == "Headphones" - assert test_window.preset_combo.selected == 0 + assert test_window.preset_combo.selected == 1 assert controller.bands[0].gain_db == 2.5 assert test_window.statuses[-1] == "Preset loaded" @@ -514,8 +521,10 @@ def test_unsaved_apo_import_is_shown_as_current_curve(monkeypatch, tmp_path) -> test_window.refresh_preset_list() - assert test_window.preset_model.items == [] - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_model.items == [window_presets.PRESET_PICKER_EMPTY] + assert test_window.preset_combo.selected == 0 + assert test_window.preset_combo.sensitive is False + assert test_window.preset_combo.tooltip == "No saved presets" assert test_window.current_curve_row.visible is True assert test_window.current_curve_state_label.text == "Imported curve" assert test_window.current_curve_state_label.tooltip == "Imported from HD 650." @@ -536,10 +545,10 @@ def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_pat test_window.refresh_preset_list() - assert test_window.preset_model.items == ["Headphones"] + assert test_window.preset_model.items == [window_presets.PRESET_PICKER_PLACEHOLDER, "Headphones"] assert test_window.current_curve_state_label.text == "Imported curve" - test_window.preset_combo.selected = 0 + test_window.preset_combo.selected = 1 test_window.on_preset_selected(test_window.preset_combo, None) assert test_window.current_preset_name == "Headphones" @@ -548,12 +557,74 @@ def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_pat controller.bands[0].gain_db = 5.0 test_window.update_preset_state() - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_combo.selected == 0 assert test_window.preset_state_label.text == "Modified" assert test_window.current_curve_state_label.text == "Headphones" assert test_window.current_curve_state_label.tooltip == "Unsaved edits from Headphones." +def test_preset_dropdown_model_handles_large_saved_library(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + for index in range(35): + write_test_preset(f"Preset {index + 1:02d}", float(index % 11) - 5.0) + controller = make_controller() + test_window = OutputPresetWindow(controller) + + test_window.refresh_preset_list() + + assert len(test_window.preset_model.items) == 36 + assert test_window.preset_model.items[0] == window_presets.PRESET_PICKER_PLACEHOLDER + assert test_window.preset_model.items[1] == "Preset 01" + assert test_window.preset_model.items[-1] == "Preset 35" + assert test_window.preset_combo.sensitive is True + assert test_window.preset_combo.tooltip == "Load a saved preset" + assert test_window.preset_combo.selected == 0 + + test_window.preset_combo.selected = 30 + test_window.on_preset_selected(test_window.preset_combo, None) + + assert test_window.current_preset_name == "Preset 30" + assert controller.bands[0].gain_db == 2.0 + assert test_window.statuses[-1] == "Preset loaded" + + +def test_preset_dropdown_refresh_ignores_transient_model_selection(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Preset 01", -4.0) + write_test_preset("Preset 02", 3.0) + controller = make_controller() + test_window = OutputPresetWindow(controller) + + def select_first_during_model_splice() -> None: + test_window.preset_combo.selected = 1 + test_window.on_preset_selected(test_window.preset_combo, None) + + test_window.preset_model.on_splice = select_first_during_model_splice + + test_window.refresh_preset_list() + + assert test_window.current_preset_name is None + assert test_window.preset_combo.selected == 0 + assert controller.bands[0].gain_db == 0.0 + assert "Preset loaded" not in test_window.statuses + + +def test_preset_dropdown_placeholder_selection_keeps_loaded_preset(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 4.0) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + + test_window.preset_combo.selected = 0 + test_window.on_preset_selected(test_window.preset_combo, None) + + assert test_window.current_preset_name == "Headphones" + assert test_window.preset_combo.selected == 1 + assert controller.bands[0].gain_db == 4.0 + + def test_save_as_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") write_test_preset("Headphones", 4.0) @@ -1089,7 +1160,7 @@ def test_external_loaded_preset_delete_keeps_reselectable_unsaved_copy(monkeypat test_window.refresh_preset_list() assert test_window.preset_names == [] - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_combo.selected == 0 assert test_window.current_preset_name is None assert test_window.preset_state_label.text == "Unsaved" assert test_window.current_curve_state_label.text == "Deleted preset copy" @@ -1137,7 +1208,7 @@ def test_external_current_preset_overwrite_marks_curve_modified(monkeypatch, tmp assert controller.bands[0].gain_db == 2.5 assert test_window.preset_state_label.text == "Modified" assert test_window.preset_revert_button.visible is False - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_combo.selected == 0 test_window.load_library_preset("Headphones") @@ -1158,7 +1229,7 @@ def test_external_current_preset_corruption_keeps_curve_as_unsaved_copy(monkeypa test_window.refresh_preset_list() assert test_window.current_preset_name is None - assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.preset_combo.selected == 0 assert controller.bands[0].gain_db == 2.5 assert test_window.current_curve_state_label.text == "Deleted preset copy" assert test_window.statuses[-1] == "Preset unavailable"