diff --git a/AGENTS.md b/AGENTS.md index d428702..1bf66b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,16 @@ modules from a system-site build venv. behavior on `capabilities`. Bump the API version only for semantic breaks, and support the old version for a short, documented release window before removing it. +- Keep app-owned JSON documents explicitly versioned with a top-level + `version` field plus migration, unsupported-version, and corrupted-schema + normalization tests. Presets and output preset links are JSON documents; do + not move them to GSettings. Valid legacy documents should load without being + rewritten on startup, then be written in the current schema only when the + user changes related state. Future-version or corrupted documents should not + be overwritten just because the app started. If GSettings is introduced + later, keep it to small typed preferences such as appearance, monitor, and + background/startup choices, and update the schema install, Flatpak, and test + paths in the same change. - Keep the `mini-eq` CLI user-oriented. Maintainer automation belongs in `tools/`, `docs/`, or this file. - Keep the GNOME Shell extension source in `extensions/gnome-shell/`; do not diff --git a/docs/release.md b/docs/release.md index fe16922..7239630 100644 --- a/docs/release.md +++ b/docs/release.md @@ -20,6 +20,8 @@ Run the narrowest gate that covers the release risk: - **When background mode, Start at Login, hidden-window lifecycle, or Shell control changed:** one clean-permission Flatpak portal smoke in a real GNOME session. +- **When preset, output, startup, routing, monitor, or inspector UI behavior + changed:** run the workflow usability gate below before release. - **When the GNOME Shell extension source changed:** run the extension checker, build the review zip, test the supported Shell versions, and upload after the app release is ready. @@ -31,6 +33,44 @@ TestPyPI is package-index validation. It is not a user beta channel. Flathub PR test builds are the normal stable handoff validation. Flathub beta is a temporary user-installable Flatpak beta, not a permanent second release line. +## Workflow Usability Gate + +Before releasing UI or state-machine changes, review the workflows as state +transitions, not as isolated controls. Every changed workflow should have a +single obvious current state, a reversible path, and no first-frame state +change after the window is shown. + +For preset and output changes, cover these cases with unit tests when possible +and with AT-SPI or live smoke when they require real GTK behavior: + +- Load, edit, reset to neutral, then reload the same saved preset from the + preset loader. Keep revert-style actions for unsaved sources that are not in + the preset library. +- Verify the preset loader does not pretend to be the running state: the visible + running-curve label must distinguish neutral, exact saved preset, modified + preset, and unsaved/imported curves. +- Import or create an unsaved curve, save it, reset it, and recover a neutral + curve without deleting the only route back. +- Delete the loaded preset, delete or modify it outside the app, and keep the + current curve understandable as an unsaved copy. +- Link, unlink, miss, and modify auto presets for both port-scoped and + output-scoped targets. +- Set, miss, and clear the default preset. +- Change output while a curve is clean, modified, auto-applied, missing, or + unavailable. +- Turn Monitor on/off and freeze/unfreeze it without leaving hidden frozen + state behind. +- Start the app with auto/default preset and auto-route inputs and verify the + visible window appears only after startup state is applied. +- Check Shell extension/D-Bus state after preset, output, background, and + window-visibility changes. + +AT-SPI tests should assert externally visible behavior: accessible names, roles, +checked state, sensitivity, and critical status labels. They should not depend +on widget internals when a unit test can cover the state transition directly. +When in doubt, add a small state-level unit test first, then one AT-SPI smoke +assertion for the visible contract. + ## Prepare Version Set the release version once for the shell session: diff --git a/docs/screenshots/mini-eq-dark.png b/docs/screenshots/mini-eq-dark.png index f1cacd3..fe55d66 100644 Binary files a/docs/screenshots/mini-eq-dark.png and b/docs/screenshots/mini-eq-dark.png differ diff --git a/docs/screenshots/mini-eq.png b/docs/screenshots/mini-eq.png index 080eb0b..d2b211b 100644 Binary files a/docs/screenshots/mini-eq.png and b/docs/screenshots/mini-eq.png differ diff --git a/docs/social-preview.png b/docs/social-preview.png index 4cc1839..ef8638a 100644 Binary files a/docs/social-preview.png and b/docs/social-preview.png differ diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py index ac4d407..8d11031 100644 --- a/src/mini_eq/app.py +++ b/src/mini_eq/app.py @@ -85,9 +85,10 @@ def ensure_window(self, *, present: bool) -> None: return if present: self.window.present_after_setup = True - self.window.set_visible(True) - self.window.present() - self.emit_control_state_changed() + if self.window.post_present_ready: + self.window.set_visible(True) + self.window.present() + self.emit_control_state_changed() self.window.schedule_post_present_setup() return @@ -110,13 +111,9 @@ def ensure_window(self, *, present: bool) -> None: self.window = MiniEqWindow(self, self.controller, self.args.auto_route, initial_curve_label=initial_curve_label) self.window.set_icon_name(APP_ICON_NAME) self.window.present_after_setup = present - self.window.set_visible(present) - if present: - self.window.present() + self.window.set_visible(False) self.window.schedule_post_present_setup() - if present: - self.window_present_source_id = GLib.idle_add(self.on_window_present_idle) - else: + if not present: self.update_background_status() self.emit_control_state_changed() diff --git a/src/mini_eq/appearance.py b/src/mini_eq/appearance.py index 1883430..39a4a72 100644 --- a/src/mini_eq/appearance.py +++ b/src/mini_eq/appearance.py @@ -9,7 +9,7 @@ from gi.repository import Adw -from .settings import load_settings, update_setting +from .settings import APPEARANCE_KEY, load_settings, update_setting from .settings import settings_path as _settings_path APPEARANCE_SYSTEM: Final = "system" @@ -18,7 +18,6 @@ APPEARANCE_MODES: Final = (APPEARANCE_SYSTEM, APPEARANCE_LIGHT, APPEARANCE_DARK) DEFAULT_APPEARANCE: Final = APPEARANCE_SYSTEM SETTINGS_FILE_NAME: Final = "settings.json" -APPEARANCE_KEY: Final = "appearance" def normalize_appearance(value: object) -> str: diff --git a/src/mini_eq/background.py b/src/mini_eq/background.py index 1c0d4a6..f9784a8 100644 --- a/src/mini_eq/background.py +++ b/src/mini_eq/background.py @@ -11,11 +11,14 @@ from gi.repository import Gio, GLib from .desktop_integration import APP_DISPLAY_NAME, APP_ICON_NAME, APP_ID, quote_desktop_exec_arg -from .settings import load_settings, update_setting +from .settings import ( + BACKGROUND_MODE_KEY, + START_ACTIVE_AT_LOGIN_KEY, + START_AT_LOGIN_KEY, + load_settings, + update_setting, +) -BACKGROUND_MODE_KEY: Final = "background_mode" -START_AT_LOGIN_KEY: Final = "start_at_login" -START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login" BACKGROUND_PORTAL_REASON: Final = "Keep equalizer settings active for desktop audio." BACKGROUND_PORTAL_BUS_NAME: Final = "org.freedesktop.portal.Desktop" BACKGROUND_PORTAL_OBJECT_PATH: Final = "/org/freedesktop/portal/desktop" diff --git a/src/mini_eq/core.py b/src/mini_eq/core.py index 7ba924c..da73d16 100644 --- a/src/mini_eq/core.py +++ b/src/mini_eq/core.py @@ -332,6 +332,42 @@ def list_preset_names() -> list[str]: return sorted(dict.fromkeys(names), key=str.casefold) +def json_document_version(payload: dict[str, object], document_name: str, supported_version: int) -> int: + if "version" not in payload: + return 0 + + version = payload["version"] + if isinstance(version, bool) or not isinstance(version, int) or version < 0: + raise ValueError(f"{document_name} version must be a non-negative integer") + + if version > supported_version: + raise ValueError(f"{document_name} version {version} is newer than this Mini EQ build") + + return version + + +def preset_payload_state_signature(payload: dict[str, object]) -> str: + json_document_version(payload, "preset", PRESET_VERSION) + + bands_data = payload.get("bands") + if not isinstance(bands_data, list): + raise ValueError("preset file does not contain a valid bands list") + + bands = inactive_eq_bands() + for index, band_data in enumerate(bands_data[:MAX_BANDS]): + if not isinstance(band_data, dict): + raise ValueError("preset bands must be JSON objects") + + bands[index] = eq_band_from_dict(band_data, bands[index]) + + signature_payload = { + "version": PRESET_VERSION, + "preamp_db": clamp(float(payload.get("preamp_db", 0.0)), EQ_PREAMP_MIN_DB, EQ_PREAMP_MAX_DB), + "bands": [eq_band_to_dict(band) for band in bands], + } + return json.dumps(signature_payload, sort_keys=True, separators=(",", ":")) + + def normalize_output_preset_links(links: dict[object, object]) -> dict[str, str]: normalized: dict[str, str] = {} @@ -382,9 +418,7 @@ def load_output_preset_config() -> tuple[dict[str, str], str | None]: if not isinstance(payload, dict): raise ValueError("output preset links file must contain a JSON object") - version = int(payload.get("version", 0)) - if version > OUTPUT_PRESET_LINKS_VERSION: - raise ValueError(f"output preset links version {version} is newer than this Mini EQ build") + json_document_version(payload, "output preset links", OUTPUT_PRESET_LINKS_VERSION) links = payload.get("links", {}) if not isinstance(links, dict): @@ -500,9 +534,7 @@ def load_mini_eq_preset_file(path: str | Path) -> dict[str, object]: if not isinstance(payload, dict): raise ValueError("preset file must contain a JSON object") - version = int(payload.get("version", 0)) - if version > PRESET_VERSION: - raise ValueError(f"preset version {version} is newer than this Mini EQ build") + json_document_version(payload, "preset", PRESET_VERSION) bands = payload.get("bands") if not isinstance(bands, list): diff --git a/src/mini_eq/settings.py b/src/mini_eq/settings.py index 34bd4e6..5ec399d 100644 --- a/src/mini_eq/settings.py +++ b/src/mini_eq/settings.py @@ -7,13 +7,64 @@ from .core import app_config_dir SETTINGS_FILE_NAME: Final = "settings.json" +SETTINGS_VERSION_KEY: Final = "version" +SETTINGS_VERSION: Final = 1 MONITOR_ENABLED_KEY: Final = "monitor_enabled" +APPEARANCE_KEY: Final = "appearance" +BACKGROUND_MODE_KEY: Final = "background_mode" +START_AT_LOGIN_KEY: Final = "start_at_login" +START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login" +BOOL_SETTINGS_KEYS: Final = frozenset( + ( + MONITOR_ENABLED_KEY, + BACKGROUND_MODE_KEY, + START_AT_LOGIN_KEY, + START_ACTIVE_AT_LOGIN_KEY, + ), +) +APPEARANCE_VALUES: Final = frozenset(("system", "light", "dark")) def settings_path() -> Path: return app_config_dir() / SETTINGS_FILE_NAME +def settings_payload_version(payload: dict[str, object]) -> int | None: + if SETTINGS_VERSION_KEY not in payload: + return 0 + + raw_version = payload[SETTINGS_VERSION_KEY] + if isinstance(raw_version, bool) or not isinstance(raw_version, int) or raw_version < 0: + return None + + return raw_version + + +def normalize_settings_values(payload: dict[str, object]) -> dict[str, object]: + normalized: dict[str, object] = {} + + for key in BOOL_SETTINGS_KEYS: + value = payload.get(key) + if isinstance(value, bool): + normalized[key] = value + + appearance = payload.get(APPEARANCE_KEY) + if isinstance(appearance, str) and appearance in APPEARANCE_VALUES: + normalized[APPEARANCE_KEY] = appearance + + return normalized + + +def normalize_settings_payload(payload: dict[str, object]) -> dict[str, object]: + version = settings_payload_version(payload) + if version is None or version > SETTINGS_VERSION: + return {} + + normalized = normalize_settings_values(payload) + normalized[SETTINGS_VERSION_KEY] = SETTINGS_VERSION + return normalized + + def load_settings() -> dict[str, object]: path = settings_path() if not path.is_file(): @@ -27,13 +78,15 @@ def load_settings() -> dict[str, object]: if not isinstance(payload, dict): return {} - return payload + return normalize_settings_payload(payload) def save_settings(payload: dict[str, object]) -> None: path = settings_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + normalized = normalize_settings_values(payload) + normalized[SETTINGS_VERSION_KEY] = SETTINGS_VERSION + path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8") def update_setting(key: str, value: object) -> None: diff --git a/src/mini_eq/style.css b/src/mini_eq/style.css index fbe9860..dffd29c 100644 --- a/src/mini_eq/style.css +++ b/src/mini_eq/style.css @@ -167,6 +167,11 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { color: alpha(var(--window-fg-color), 0.86); } +.current-curve-label { + color: alpha(var(--window-fg-color), 0.92); + font-weight: 700; +} + .compare-row { padding: 4px 0; border-radius: 0; @@ -206,6 +211,19 @@ 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.py b/src/mini_eq/window.py index 646c559..55ec348 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -257,12 +257,13 @@ def on_post_present_setup_idle(self) -> bool: return False self.start_analyzer_preview() - self.notify_control_state_changed() if self.auto_route_on_startup: - self.schedule_startup_auto_route() + self.apply_startup_auto_route() if not self.ui_shutting_down and self.present_after_setup: + self.set_visible(True) self.present() + self.notify_control_state_changed() return False def schedule_startup_auto_route(self) -> None: @@ -271,18 +272,21 @@ def schedule_startup_auto_route(self) -> None: self.startup_auto_route_source_id = GLib.idle_add(self.on_startup_auto_route_idle) - def on_startup_auto_route_idle(self) -> bool: - self.startup_auto_route_source_id = 0 - - if self.ui_shutting_down or not self.auto_route_on_startup: - return False - + def apply_startup_auto_route(self) -> None: eq_was_enabled = self.controller.eq_enabled try: self.controller.route_system_audio(True) except Exception as exc: self.set_status(str(exc)) self.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled) + + def on_startup_auto_route_idle(self) -> bool: + self.startup_auto_route_source_id = 0 + + if self.ui_shutting_down or not self.auto_route_on_startup: + return False + + self.apply_startup_auto_route() return False def prepare_for_shutdown(self) -> None: @@ -546,6 +550,8 @@ def on_preset_dir_changed_idle(self) -> bool: return False self.refresh_preset_list() + self.notify_control_presets_changed() + self.notify_control_state_changed() return False def on_close_request(self, window: Gtk.Window) -> bool: @@ -668,7 +674,7 @@ def profile_summary(self, sink: PipeWireNode | None) -> tuple[str, str, bool, li if profile == "a2dp-sink": return "Bluetooth A2DP", f"{sample_text} music profile", False, warnings - if "headset" in profile: + if profile and "headset" in profile: warnings.append( "Bluetooth output is in headset mode. Switch back to A2DP for full-band music playback." ) @@ -758,20 +764,22 @@ def refresh_output_sinks(self, *, handle_observed_output_change: bool = True) -> active = self.controller.output_sink previous_output = self.last_output_preset_sink_name previous_output_preset_auto_loaded = self.output_preset_curve_auto_loaded - default_sink_name = self.controller.get_default_output_sink_name() visible_sinks = self.list_visible_output_sinks() visible_sink_names = [sink.node_name for sink in visible_sinks if sink.node_name is not None] visible_sink_labels = self.build_output_sink_labels(visible_sinks) self.output_sink_names = [None, *visible_sink_names] self.output_sink_labels = [self.follow_default_output_label(), *visible_sink_labels] - self.output_sink_model.splice(0, self.output_sink_model.get_n_items(), self.output_sink_labels) selected_index = 0 if not self.controller.follow_default_output: if active in visible_sink_names: selected_index = visible_sink_names.index(active) + 1 - elif default_sink_name in visible_sink_names: - selected_index = visible_sink_names.index(default_sink_name) + 1 + elif active: + self.output_sink_names.append(active) + self.output_sink_labels.append("Unavailable output") + selected_index = len(self.output_sink_names) - 1 + + self.output_sink_model.splice(0, self.output_sink_model.get_n_items(), self.output_sink_labels) self.output_combo.set_sensitive(len(self.output_sink_names) > 1) @@ -819,7 +827,7 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> path = file.get_path() if path is None: - self.set_status("Could Not Resolve APO Preset Path") + self.set_status("Could not open APO preset") return try: @@ -833,7 +841,7 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> self.output_preset_curve_auto_loaded = False self.refresh_preset_list() self.sync_ui_from_state() - self.set_status(curve_label) + self.set_status("Imported APO curve") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) diff --git a/src/mini_eq/window_analyzer.py b/src/mini_eq/window_analyzer.py index 90817dc..a885b86 100644 --- a/src/mini_eq/window_analyzer.py +++ b/src/mini_eq/window_analyzer.py @@ -172,12 +172,13 @@ def start_analyzer_preview(self) -> None: try: started = self.controller.set_analyzer_enabled(True) - except Exception as exc: - self.set_status(f"Monitor Unavailable: {exc}") + except Exception: + self.set_status("Monitor unavailable") started = False if not started: self.analyzer_enabled = False + self.analyzer_frozen = False self.refresh_after_monitor_state_changed(monitor_visibility_changed=True) return @@ -398,6 +399,7 @@ def on_analyzer_preview_tick(self, now: float | None = None) -> bool: def _sync_monitor_controls_unlocked(self) -> None: set_switch_confirmed_state(self.analyzer_switch, self.analyzer_enabled) set_switch_confirmed_state(self.analyzer_freeze_switch, self.analyzer_frozen) + self.analyzer_freeze_switch.set_sensitive(self.analyzer_enabled) self.analyzer_state_label.set_text( "Frozen" if self.analyzer_frozen and self.analyzer_enabled else ("Live" if self.analyzer_enabled else "Off") ) @@ -440,10 +442,11 @@ def on_analyzer_changed(self, switch: Gtk.Switch, state: object | None) -> bool: else: try: self.stop_analyzer_preview() - except Exception as exc: + except Exception: self.analyzer_enabled = previous_enabled - self.set_status(f"Monitor Unavailable: {exc}") + self.set_status("Monitor unavailable") else: + self.analyzer_frozen = False self.analyzer_levels = [0.0] * len(self.analyzer_levels) self.analyzer_loudness_snapshot = None self.analyzer_session_max_shortterm_lufs = None diff --git a/src/mini_eq/window_headroom.py b/src/mini_eq/window_headroom.py index fb15a06..4804289 100644 --- a/src/mini_eq/window_headroom.py +++ b/src/mini_eq/window_headroom.py @@ -161,7 +161,7 @@ def on_set_safe_preamp_clicked(self, _button: Gtk.Button) -> None: target_preamp = self.controller.preamp_db - peak - 1.0 self.preamp_scale.set_value(target_preamp) - self.set_status("Preamp Lowered for Safe Headroom") + self.set_status("Preamp lowered") def on_headroom_meter_draw(self, _area: Gtk.DrawingArea, cr, width: int, height: int) -> None: width_f = float(max(width, 1)) diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py index 5ff23c2..4b6de71 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 +from gi.repository import Adw, Gio, GLib, Gtk, Pango from .core import ( DEFAULT_ACTIVE_BANDS, @@ -25,12 +25,13 @@ list_preset_names, load_mini_eq_preset_file, preset_path_for_name, + preset_payload_state_signature, sanitize_preset_name, set_default_preset_name, set_output_preset_link, write_mini_eq_preset_file, ) -from .window_utils import requested_switch_state, set_switch_confirmed_state +from .window_utils import requested_switch_state, set_accessible_label, set_switch_confirmed_state APO_IMPORT_LABEL_PREFIX = "Imported APO: " DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: " @@ -48,7 +49,11 @@ class PresetPanelUiState: preset_state_text: str preset_state_class: str preset_state_tooltip: str + current_curve_text: str + current_curve_tooltip: str save_label: str + save_tooltip: str + primary_action: str save_as_visible: bool revert_visible: bool revert_label: str @@ -161,6 +166,20 @@ def set_curve_revert_baseline(self, label: str) -> None: self.curve_revert_baseline_signature = self.controller.state_signature() self.curve_revert_baseline_payload = self.controller.build_preset_payload(label) + def relabel_curve_revert_baseline(self, label: str) -> None: + self.curve_revert_baseline_label = label + + def set_curve_revert_baseline_payload( + self, + label: str, + payload: dict[str, object], + signature: str, + ) -> None: + self.curve_revert_baseline_label = label + self.curve_revert_baseline_signature = signature + self.curve_revert_baseline_payload = dict(payload) + self.curve_revert_baseline_payload["name"] = label + def clear_curve_revert_baseline(self) -> None: self.curve_revert_baseline_label = None self.curve_revert_baseline_signature = None @@ -185,6 +204,26 @@ def has_curve_revert_changes(self) -> bool: def curve_revert_target_is_neutral(self) -> bool: return self.current_preset_name is None and self.curve_revert_signature() == self.default_preset_signature + def curve_revert_target_is_library_preset(self) -> bool: + label = self.curve_revert_label() + return bool(label and self.preset_name_exists(label)) + + def compact_curve_source_label(self, label: str) -> str: + if label.startswith(APO_IMPORT_LABEL_PREFIX): + return "Imported curve" + if label.startswith(DELETED_PRESET_LABEL_PREFIX): + return "Deleted preset copy" + return label + + def curve_source_tooltip(self, label: str) -> str: + if label.startswith(APO_IMPORT_LABEL_PREFIX): + source_name = label[len(APO_IMPORT_LABEL_PREFIX) :] + return f"Imported from {source_name}." + if label.startswith(DELETED_PRESET_LABEL_PREFIX): + source_name = label[len(DELETED_PRESET_LABEL_PREFIX) :] + return f"Deleted preset: {source_name}. Curve is kept." + return label + def current_curve_source_label(self) -> str | None: if self.current_preset_name is not None: return None @@ -195,10 +234,57 @@ def current_curve_source_label(self) -> str | None: return None return label + def current_curve_running_text( + self, + *, + current_signature: str | None = None, + revert_signature: str | None = None, + ) -> tuple[str, str]: + current_signature = current_signature or self.controller.state_signature() + if revert_signature is None: + revert_signature = self.curve_revert_signature() + + if self.current_preset_name is not None: + if current_signature == self.saved_preset_signature: + return (self.current_preset_name, f"Saved preset: {self.current_preset_name}.") + return (self.current_preset_name, f"Unsaved edits from {self.current_preset_name}.") + + label = self.curve_revert_label() + if current_signature == self.default_preset_signature: + if ( + self.has_neutral_reapply_target( + current_signature=current_signature, + revert_signature=revert_signature, + ) + and label + ): + compact_label = self.compact_curve_source_label(label) + if self.preset_name_exists(label): + return ( + "Neutral", + f"Neutral. Load {compact_label} to restore.", + ) + return ( + "Neutral", + f"Neutral. Reapply restores {compact_label}.", + ) + return ("Neutral", "Neutral curve.") + + if label and revert_signature is not None and revert_signature != self.default_preset_signature: + compact_label = self.compact_curve_source_label(label) + if current_signature == revert_signature: + return (compact_label, self.curve_source_tooltip(label)) + return (compact_label, f"Unsaved edits from {compact_label}.") + + return ("Unsaved curve", "Not saved as a preset.") + def suggested_save_as_name(self) -> str: if self.current_preset_name is not None: return self.current_preset_name + if self.controller.state_signature() == self.default_preset_signature: + return "" + label = self.current_curve_source_label() if label and label.startswith(APO_IMPORT_LABEL_PREFIX): return sanitize_preset_name(label[len(APO_IMPORT_LABEL_PREFIX) :]) @@ -267,30 +353,59 @@ def has_unsaved_curve_changes(self) -> bool: def has_neutral_curve_changes(self) -> bool: return self.controller.state_signature() != self.default_preset_signature + def has_neutral_reapply_target( + self, + *, + current_signature: str | None = None, + revert_signature: str | None = None, + ) -> bool: + current_signature = current_signature or self.controller.state_signature() + if revert_signature is None: + revert_signature = self.curve_revert_signature() + return bool( + self.current_preset_name is None + and current_signature == self.default_preset_signature + and revert_signature is not None + and revert_signature != self.default_preset_signature + ) + def preset_panel_ui_state(self) -> PresetPanelUiState: current_signature = self.controller.state_signature() has_named_preset = self.current_preset_name is not None + clean_named_preset = has_named_preset and current_signature == self.saved_preset_signature neutral = current_signature == self.default_preset_signature revert_signature = self.curve_revert_signature() revert_label = self.curve_revert_label() or "curve baseline" + revert_display_label = self.compact_curve_source_label(revert_label) has_revert_target = revert_signature is not None and not self.curve_revert_target_is_neutral() - revert_visible = has_revert_target and current_signature != revert_signature + revert_target_is_library_preset = self.curve_revert_target_is_library_preset() + revert_visible = ( + has_revert_target and current_signature != revert_signature and not revert_target_is_library_preset + ) + neutral_reapply_target = self.has_neutral_reapply_target( + current_signature=current_signature, + revert_signature=revert_signature, + ) reset_visible = not neutral default_preset = self.default_preset_name() - default_set_visible = has_named_preset + default_set_visible = clean_named_preset default_clear_visible = default_preset is not None curve_group_visible = has_named_preset or revert_visible or reset_visible default_group_visible = default_set_visible or default_clear_visible export_label = "Export Preset…" if has_named_preset else "Export Current Curve…" + current_curve_text, current_curve_tooltip = self.current_curve_running_text( + current_signature=current_signature, + revert_signature=revert_signature, + ) if has_named_preset and current_signature == self.saved_preset_signature: - preset_state_text = "Saved" + preset_state_text = "Preset" preset_state_class = "preset-state-saved" - preset_state_tooltip = f"{self.current_preset_name} matches the saved preset" + preset_state_tooltip = f"Running curve matches saved preset {self.current_preset_name}" elif has_named_preset: preset_state_text = "Modified" preset_state_class = "preset-state-modified" - preset_state_tooltip = f"{self.current_preset_name} has unsaved curve changes" + preset_state_tooltip = f"Running curve is modified from {self.current_preset_name}" elif neutral: preset_state_text = "Neutral" preset_state_class = "preset-state-neutral" @@ -304,21 +419,39 @@ def preset_panel_ui_state(self) -> PresetPanelUiState: preset_state_class = "preset-state-modified" preset_state_tooltip = "Current curve has unsaved changes" - if revert_visible: - revert_tooltip = f"Revert to {revert_label}" + if revert_visible and neutral_reapply_target: + revert_action_label = f"Reapply {revert_display_label}" + revert_tooltip = f"Apply {revert_display_label} again" + elif revert_visible: + revert_action_label = f"Revert to {revert_display_label}" + revert_tooltip = f"Revert to {revert_display_label}" elif has_revert_target: + revert_action_label = f"Revert to {revert_display_label}" revert_tooltip = "No curve changes to revert" else: + revert_action_label = f"Revert to {revert_display_label}" revert_tooltip = "No preset baseline to revert to" + primary_action = "reapply" if neutral_reapply_target and revert_visible else "save" + save_label = "Reapply" if primary_action == "reapply" else ("Save" if has_named_preset else "Save As…") + save_tooltip = ( + f"Apply {revert_display_label} again" + if primary_action == "reapply" + else ("Save changes to the current preset" if has_named_preset else "Save the running curve as a preset") + ) + return PresetPanelUiState( preset_state_text=preset_state_text, preset_state_class=preset_state_class, preset_state_tooltip=preset_state_tooltip, - save_label="Save" if has_named_preset else "Save As…", - save_as_visible=has_named_preset, + current_curve_text=current_curve_text, + current_curve_tooltip=current_curve_tooltip, + save_label=save_label, + save_tooltip=save_tooltip, + primary_action=primary_action, + save_as_visible=has_named_preset or primary_action == "reapply", revert_visible=revert_visible, - revert_label=f"Revert to {revert_label}", + revert_label=revert_action_label, revert_tooltip=revert_tooltip, reset_visible=reset_visible, reset_tooltip="Reset all bands and preamp to neutral" if reset_visible else "Curve is already neutral", @@ -340,8 +473,7 @@ def update_output_preset_state(self) -> None: self.output_preset_auto_applied = False target = self.output_preset_target() self.update_output_scope_state(target) - scope_text, scope_kind, _status_scope = self.output_preset_scope_text(target) - linked_status_text = "Linked to port" if scope_kind == "port" else "Linked to EQ output" + scope_text, _scope_kind, _status_scope = self.output_preset_scope_text(target) clear_tooltip = f"Clear auto preset for {scope_text}" def sync_output_preset_switch( @@ -379,18 +511,22 @@ def sync_output_preset_switch( return has_output = bool(self.controller.output_sink) + current_signature = self.controller.state_signature() has_named_preset = self.current_preset_name is not None + has_linkable_preset = has_named_preset and current_signature == self.saved_preset_signature if not linked_preset: if not has_output: tooltip = "Select an EQ Output" elif not has_named_preset: tooltip = "Save a Preset First" + elif not has_linkable_preset: + tooltip = "Save or load the preset before linking it" else: tooltip = f"Use selected preset automatically for {scope_text}" sync_output_preset_switch( active=False, - sensitive=has_output and has_named_preset, + sensitive=has_output and has_linkable_preset, tooltip=tooltip, ) return @@ -400,22 +536,33 @@ def sync_output_preset_switch( and self.current_preset_name == linked_preset and self.controller.state_signature() == self.saved_preset_signature ) + if not self.preset_name_exists(linked_preset): + sync_output_preset_switch( + active=True, + sensitive=has_output, + tooltip=clear_tooltip, + status_text="Missing", + status_tooltip=f"Auto preset for {scope_text} uses missing preset {linked_preset}", + ) + return + if self.output_preset_auto_applied: sync_output_preset_switch( active=True, sensitive=has_output, tooltip=clear_tooltip, - status_text=linked_status_text, + status_text="Applied", status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", ) return if has_named_preset: + status_text = "Modified" if self.current_preset_name == linked_preset else "Different" sync_output_preset_switch( active=True, sensitive=has_output, tooltip=clear_tooltip, - status_text="Different", + status_text=status_text, status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", ) return @@ -424,8 +571,8 @@ def sync_output_preset_switch( active=True, sensitive=has_output, tooltip=clear_tooltip, - status_text=linked_status_text, - status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", + status_text="Linked", + status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}; current curve is not that preset.", ) def set_preset_widget_visible(self, name: str, visible: bool) -> None: @@ -446,8 +593,10 @@ def set_preset_widget_label(self, name: str, text: str) -> None: def refresh_preset_actions(self, state: PresetPanelUiState | None = None) -> None: state = state or self.preset_panel_ui_state() + self.preset_primary_action = state.primary_action self.set_preset_widget_label("preset_save_button", state.save_label) self.preset_save_button.set_sensitive(True) + self.preset_save_button.set_tooltip_text(state.save_tooltip) self.set_preset_widget_visible("preset_save_as_button", state.save_as_visible) self.preset_save_as_button.set_sensitive(True) @@ -477,6 +626,78 @@ def refresh_preset_actions(self, state: PresetPanelUiState | None = None) -> Non self.update_output_preset_state() self.update_default_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) + 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)) + + def selected_preset_combo_index(self) -> int: + if ( + self.current_preset_name is not None + 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 + + def sync_preset_combo_selection(self) -> None: + combo = getattr(self, "preset_combo", None) + if combo is None: + return + + self.updating_preset_combo = True + try: + combo.set_selected(self.selected_preset_combo_index()) + finally: + self.updating_preset_combo = False + def update_default_preset_state(self) -> None: label = getattr(self, "default_preset_state_label", None) set_button = getattr(self, "default_preset_set_button", None) @@ -516,20 +737,44 @@ def update_default_preset_state(self) -> None: label.set_text("Missing") label.set_tooltip_text(f"Default preset {default_preset} is unavailable") - def refresh_preset_list(self) -> None: - self.preset_names = list_preset_names() + def keep_current_curve_as_unsaved_copy(self, preset_name: str) -> None: + preserve_revert_baseline = ( + self.curve_revert_baseline_label == preset_name and self.curve_revert_baseline_payload is not None + ) + self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + if preserve_revert_baseline: + self.relabel_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}") + return - selected_index = Gtk.INVALID_LIST_POSITION - if self.current_preset_name in self.preset_names: - selected_index = self.preset_names.index(self.current_preset_name) + self.set_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}") - self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names) + def sync_current_preset_signature_from_library(self) -> None: + if self.current_preset_name is None: + return - self.updating_preset_combo = True + preset_name = self.current_preset_name try: - self.preset_combo.set_selected(selected_index) - finally: - self.updating_preset_combo = False + payload = load_mini_eq_preset_file(preset_path_for_name(preset_name)) + signature = preset_payload_state_signature(payload) + except Exception: + self.keep_current_curve_as_unsaved_copy(preset_name) + self.set_status("Preset unavailable") + return + + self.saved_preset_signature = signature + self.set_curve_revert_baseline_payload(preset_name, payload, signature) + + def refresh_preset_list(self) -> None: + self.preset_names = list_preset_names() + if self.current_preset_name is not None and self.current_preset_name not in self.preset_names: + self.keep_current_curve_as_unsaved_copy(self.current_preset_name) + 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.update_preset_state() @@ -546,6 +791,7 @@ def update_preset_state(self) -> None: self.preset_state_label.set_tooltip_text(state.preset_state_tooltip) self.update_current_curve_state() + self.sync_preset_combo_selection() self.refresh_preset_actions(state) def update_current_curve_state(self) -> None: @@ -554,16 +800,9 @@ def update_current_curve_state(self) -> None: if label is None: return - current_curve_label = self.current_curve_source_label() - if current_curve_label is None: - label.set_text("") - label.set_tooltip_text("No provisional curve source") - if row is not None: - row.set_visible(False) - return - - label.set_text(current_curve_label) - label.set_tooltip_text(f"{current_curve_label}\nUse Save As to add it to the preset library.") + current_curve_text, current_curve_tooltip = self.current_curve_running_text() + label.set_text(current_curve_text) + label.set_tooltip_text(current_curve_tooltip) if row is not None: row.set_visible(True) @@ -580,7 +819,7 @@ def save_current_state_to_preset(self, name: str) -> None: self.output_preset_curve_auto_loaded = False self.refresh_preset_list() self.sync_ui_from_state() - self.set_status(f"Saved Preset: {preset_name}") + self.set_status("Preset saved") self.notify_control_presets_changed() self.notify_control_state_changed() @@ -622,20 +861,33 @@ def load_library_preset( if status_message is not None: self.set_status(status_message) elif auto: - self.set_status(f"Applied Auto Preset: {preset_name}") + self.set_status("Auto preset applied") else: - self.set_status(f"Loaded Preset: {preset_name}") + self.set_status("Preset loaded") self.notify_control_state_changed() - def reset_curve_to_neutral(self, status_message: str = "Reset to Neutral") -> None: + def reset_curve_to_neutral(self, status_message: str = "Reset to neutral") -> None: + reapply_label = self.curve_revert_label() + reapply_signature = self.curve_revert_signature() + reapply_payload = getattr(self, "curve_revert_baseline_payload", None) + self.controller.reset_state() self.current_preset_name = None self.saved_preset_signature = self.controller.state_signature() - self.set_curve_revert_baseline("Neutral") + if ( + reapply_label + and reapply_signature is not None + and reapply_signature != self.default_preset_signature + and reapply_payload is not None + ): + self.set_curve_revert_baseline_payload(reapply_label, reapply_payload, reapply_signature) + else: + self.set_curve_revert_baseline("Neutral") self.selected_band_index = None self.set_visible_band_count(DEFAULT_ACTIVE_BANDS) self.output_preset_curve_auto_loaded = False self.output_preset_auto_applied = False + self.refresh_preset_list() self.sync_ui_from_state() self.set_status(status_message) self.notify_control_state_changed() @@ -661,7 +913,7 @@ def apply_output_preset_for_current_output( self.output_preset_curve_auto_loaded = False self.update_preset_state() if announce_no_output_preset: - self.set_status("No Auto Preset: Kept Unsaved Changes") + self.set_status("Current curve kept") self.notify_control_state_changed() return announce_no_output_preset @@ -675,33 +927,32 @@ def apply_output_preset_for_current_output( default_preset, auto=True, output_preset_auto=False, - status_message="No Auto Preset: Applied Default Preset", + status_message="Default preset applied", ) except Exception: self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.update_preset_state() + self.set_status("Default preset unavailable") self.notify_control_state_changed() else: return True if reset_auto_preset_without_link: - self.reset_curve_to_neutral("No Auto Preset: Reset to Neutral") + self.reset_curve_to_neutral("Reset to neutral") return True self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.update_preset_state() - if announce_no_output_preset: - self.set_status("No Auto Preset: Curve Unchanged") self.notify_control_state_changed() - return announce_no_output_preset + return False if self.has_unsaved_curve_changes(): self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.update_preset_state() - self.set_status("Kept Unsaved Changes") + self.set_status("Current curve kept") self.notify_control_state_changed() return True @@ -711,7 +962,7 @@ def apply_output_preset_for_current_output( self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.update_preset_state() - self.set_status(f"Auto Preset Unavailable: {linked_preset}") + self.set_status("Auto preset unavailable") self.notify_control_state_changed() return True @@ -745,6 +996,7 @@ def prompt_for_preset_name( entry = Gtk.Entry() entry.set_hexpand(True) entry.set_text(initial_text) + set_accessible_label(entry, "Preset name") content.append(entry) actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -778,7 +1030,7 @@ def on_preset_name_dialog_accept( ) -> None: preset_name = sanitize_preset_name(entry.get_text()) if not preset_name: - self.set_status("Preset Name Is Empty") + self.set_status("Preset name is empty") entry.grab_focus() return @@ -805,6 +1057,10 @@ def on_preset_selected(self, combo: Gtk.DropDown, _param: object) -> None: self.set_status(str(exc)) def on_preset_save_clicked(self, button: Gtk.Button) -> None: + if getattr(self, "preset_primary_action", "save") == "reapply": + self.on_preset_revert_clicked(button) + return + if self.current_preset_name is not None: try: self.save_current_state_to_preset(self.current_preset_name) @@ -823,17 +1079,29 @@ def on_preset_revert_clicked(self, button: Gtk.Button) -> None: preset_name = self.current_preset_name try: self.load_library_preset(preset_name) - self.set_status(f"Reverted to Preset: {preset_name}") + self.set_status("Preset restored") except Exception as exc: self.set_status(str(exc)) return payload = getattr(self, "curve_revert_baseline_payload", None) if payload is None: - self.set_status("No Curve Baseline") + self.set_status("Nothing to restore") return baseline_label = self.curve_revert_label() or "Curve Baseline" + reapply_from_neutral = self.has_neutral_reapply_target() + if reapply_from_neutral: + try: + if self.preset_name_exists(baseline_label): + self.load_library_preset( + baseline_label, + status_message="Preset restored", + ) + return + except Exception: + pass + try: self.controller.apply_preset_payload(payload) self.current_preset_name = None @@ -843,7 +1111,7 @@ def on_preset_revert_clicked(self, button: Gtk.Button) -> None: self.selected_band_index = None self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) self.sync_ui_from_state() - self.set_status(f"Reverted to {baseline_label}") + self.set_status("Curve restored") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -856,31 +1124,36 @@ def on_preset_reset_to_neutral_clicked(self, _button: Gtk.Button) -> None: def on_use_preset_for_output_clicked(self, _button: Gtk.Widget) -> None: if self.current_preset_name is None: - self.set_status("No Preset Selected") + self.set_status("Choose a preset first") + return + if self.controller.state_signature() != self.saved_preset_signature: + self.set_status("Save or load preset first") return target = self.output_preset_target() - _scope_text, _scope_kind, status_scope = self.output_preset_scope_text(target) try: - preset_name = set_output_preset_link(self.output_preset_link_key(target), self.current_preset_name) + set_output_preset_link(self.output_preset_link_key(target), self.current_preset_name) self.output_preset_auto_applied = self.output_preset_is_active() self.output_preset_curve_auto_loaded = False self.update_preset_state() - self.set_status(f"Linked Auto Preset to {status_scope}: {preset_name}") + self.set_status("Auto preset linked") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) def on_use_preset_as_default_clicked(self, _button: Gtk.Widget) -> None: if self.current_preset_name is None: - self.set_status("No Preset Selected") + self.set_status("Choose a preset first") + return + if self.controller.state_signature() != self.saved_preset_signature: + self.set_status("Save or load preset first") return try: - preset_name = set_default_preset_name(self.current_preset_name) + set_default_preset_name(self.current_preset_name) self.output_preset_curve_auto_loaded = False self.update_preset_state() - self.set_status(f"Default Preset Set: {preset_name}") + self.set_status("Default preset set") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -892,9 +1165,9 @@ def on_clear_default_preset_clicked(self, _button: Gtk.Widget) -> None: self.output_preset_curve_auto_loaded = False self.update_preset_state() if removed: - self.set_status(f"Cleared Default Preset: {removed}") + self.set_status("Default preset cleared") else: - self.set_status("No Default Preset") + self.set_status("No default preset") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -902,16 +1175,15 @@ def on_clear_default_preset_clicked(self, _button: Gtk.Widget) -> None: def on_clear_output_preset_link_clicked(self, _button: Gtk.Widget) -> None: target = self.output_preset_target() - _scope_text, _scope_kind, status_scope = self.output_preset_scope_text(target) try: removed = clear_output_preset_link(self.output_preset_keys(target)) self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.update_preset_state() if removed: - self.set_status(f"Cleared Auto Preset from {status_scope}: {removed}") + self.set_status("Auto preset cleared") else: - self.set_status(f"No Auto Preset for {status_scope}") + self.set_status("No auto preset") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -935,7 +1207,7 @@ def on_output_preset_switch_changed(self, switch: Gtk.Switch, state: object | No def on_preset_delete_clicked(self, button: Gtk.Button) -> None: if self.current_preset_name is None: - self.set_status("No Preset Selected") + self.set_status("Choose a preset first") return preset_name = self.current_preset_name @@ -965,12 +1237,10 @@ def on_preset_delete_dialog_done( try: delete_preset_file(preset_name) - self.current_preset_name = None - self.saved_preset_signature = self.controller.state_signature() - self.set_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}") + self.keep_current_curve_as_unsaved_copy(preset_name) self.refresh_preset_list() self.sync_ui_from_state() - self.set_status(f"Deleted Preset: {preset_name}; Current Curve Kept") + self.set_status("Preset deleted; curve kept") self.notify_control_presets_changed() self.notify_control_state_changed() except Exception as exc: @@ -997,7 +1267,7 @@ def import_library_preset_payload(self, preset_name: str, payload: dict[str, obj self.set_curve_revert_baseline(preset_name) self.refresh_preset_list() self.sync_ui_from_state() - self.set_status(f"Imported Preset: {preset_name}") + self.set_status("Preset imported") self.notify_control_presets_changed() self.notify_control_state_changed() @@ -1009,7 +1279,7 @@ def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) path = file.get_path() if path is None: - self.set_status("Could Not Resolve Preset Path") + self.set_status("Could not open preset") return try: @@ -1042,7 +1312,8 @@ def on_preset_export_clicked(self, button: Gtk.Button) -> None: filters.append(file_filter) dialog.set_filters(filters) dialog.set_default_filter(file_filter) - dialog.set_initial_name(f"{sanitize_preset_name(self.current_preset_name or 'mini-eq')}{PRESET_FILE_SUFFIX}") + export_name = self.current_preset_name or self.suggested_save_as_name() or "mini-eq" + dialog.set_initial_name(f"{sanitize_preset_name(export_name)}{PRESET_FILE_SUFFIX}") dialog.save(self, None, self.on_preset_export_done) def on_preset_export_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: @@ -1053,13 +1324,13 @@ def on_preset_export_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) path = file.get_path() if path is None: - self.set_status("Could Not Resolve Export Path") + self.set_status("Could not export preset") return try: export_path = ensure_json_suffix(Path(path)) payload = self.controller.build_preset_payload(self.current_preset_name or export_path.stem) write_mini_eq_preset_file(export_path, payload) - self.set_status("Exported Preset") + self.set_status("Preset exported") 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 cb517e2..6b3cc25 100644 --- a/src/mini_eq/window_utility.py +++ b/src/mini_eq/window_utility.py @@ -9,7 +9,6 @@ from .window_utils import ( bind_label_to_control, - make_ellipsizing_string_list_factory, set_accessible_description, set_accessible_label, ) @@ -21,7 +20,7 @@ def make_preset_section(self) -> Gtk.Box: preset_section.add_css_class("utility-section") preset_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - preset_title = Gtk.Label(label="Presets", xalign=0.0) + preset_title = Gtk.Label(label="Current Curve", xalign=0.0) preset_title.add_css_class("heading") preset_header.append(preset_title) preset_header_spacer = Gtk.Box() @@ -34,36 +33,47 @@ def make_preset_section(self) -> Gtk.Box: preset_header.append(self.preset_state_label) preset_section.append(preset_header) - self.preset_combo.set_hexpand(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(28)) - set_accessible_label(self.preset_combo, "Preset") - - preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - preset_row.add_css_class("utility-row") - preset_label = Gtk.Label(label="Preset", xalign=0.0) - bind_label_to_control(preset_label, self.preset_combo) - preset_row.append(preset_label) - preset_row.append(self.preset_combo) - preset_section.append(preset_row) - self.current_curve_state_label = Gtk.Label(xalign=0.0) self.current_curve_state_label.set_hexpand(True) self.current_curve_state_label.set_width_chars(1) self.current_curve_state_label.set_max_width_chars(28) self.current_curve_state_label.add_css_class("dim-label") + self.current_curve_state_label.add_css_class("current-curve-label") self.current_curve_state_label.set_ellipsize(Pango.EllipsizeMode.END) - set_accessible_label(self.current_curve_state_label, "Current Curve Source") + set_accessible_label(self.current_curve_state_label, "Running Curve") self.current_curve_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) self.current_curve_row.add_css_class("utility-row") - current_curve_label = Gtk.Label(label="Curve", xalign=0.0) + current_curve_label = Gtk.Label(label="Running", xalign=0.0) self.current_curve_row.append(current_curve_label) self.current_curve_row.append(self.current_curve_state_label) - self.current_curve_row.set_visible(False) 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") + + 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) + preset_row.append(preset_label) + preset_row.append(self.preset_load_button) + preset_section.append(preset_row) + self.output_scope_state_label.set_hexpand(True) self.output_scope_state_label.add_css_class("dim-label") self.output_scope_state_label.add_css_class("output-scope-state-label") diff --git a/tests/test_mini_eq_app.py b/tests/test_mini_eq_app.py index e59803e..803bfce 100644 --- a/tests/test_mini_eq_app.py +++ b/tests/test_mini_eq_app.py @@ -66,6 +66,44 @@ def test_window_present_idle_presents_active_window() -> None: assert window.present_count == 1 +def test_ensure_window_defers_existing_window_present_until_setup(monkeypatch) -> None: + monkeypatch.setattr(app, "install_app_icon", lambda: None) + calls: list[object] = [] + window = SimpleNamespace( + ui_shutting_down=False, + post_present_ready=False, + present_after_setup=False, + set_visible=lambda visible: calls.append(("visible", visible)), + present=lambda: calls.append("present"), + schedule_post_present_setup=lambda: calls.append("setup"), + ) + application = SimpleNamespace(window=window, emit_control_state_changed=lambda: calls.append("state")) + + app.MiniEqApplication.ensure_window(application, present=True) + + assert window.present_after_setup is True + assert calls == ["setup"] + + +def test_ensure_window_presents_existing_ready_window_immediately(monkeypatch) -> None: + monkeypatch.setattr(app, "install_app_icon", lambda: None) + calls: list[object] = [] + window = SimpleNamespace( + ui_shutting_down=False, + post_present_ready=True, + present_after_setup=False, + set_visible=lambda visible: calls.append(("visible", visible)), + present=lambda: calls.append("present"), + schedule_post_present_setup=lambda: calls.append("setup"), + ) + application = SimpleNamespace(window=window, emit_control_state_changed=lambda: calls.append("state")) + + app.MiniEqApplication.ensure_window(application, present=True) + + assert window.present_after_setup is True + assert calls == [("visible", True), "present", "state", "setup"] + + def test_close_action_closes_active_window() -> None: window = FakeWindow(ui_shutting_down=False) application = FakeApplication(window=window) diff --git a/tests/test_mini_eq_appearance.py b/tests/test_mini_eq_appearance.py index 8188559..fdcaa8c 100644 --- a/tests/test_mini_eq_appearance.py +++ b/tests/test_mini_eq_appearance.py @@ -6,6 +6,7 @@ from tests._mini_eq_imports import core, import_mini_eq_module appearance = import_mini_eq_module("appearance") +settings = import_mini_eq_module("settings") def test_appearance_preference_round_trips_through_app_config(tmp_path, monkeypatch) -> None: @@ -16,6 +17,7 @@ def test_appearance_preference_round_trips_through_app_config(tmp_path, monkeypa assert appearance.load_appearance_preference() == appearance.APPEARANCE_DARK assert json.loads(appearance.settings_path().read_text(encoding="utf-8")) == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, appearance.APPEARANCE_KEY: appearance.APPEARANCE_DARK, } diff --git a/tests/test_mini_eq_atspi_widgets.py b/tests/test_mini_eq_atspi_widgets.py index 107104a..cb7c996 100644 --- a/tests/test_mini_eq_atspi_widgets.py +++ b/tests/test_mini_eq_atspi_widgets.py @@ -437,8 +437,16 @@ 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(frame, name="Preset", role="combo box", showing=True) is None: - raise AssertionError("Preset 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") verify_dropdown_exposes_options(frame, combo_name="Type", required_options=("Notch", "Bell")) @@ -508,6 +516,14 @@ def verify_dropdown_exposes_options(frame, *, combo_name, required_options): else None ), ) + freeze_switch = wait_for( + "Freeze Monitor switch after Monitor turns off", + lambda: find_accessible(desktop, name="Freeze Monitor", role="switch", showing=True), + ) + if sensitive(freeze_switch): + raise AssertionError("Freeze Monitor switch should be insensitive while Monitor is off") + if checked(freeze_switch): + raise AssertionError("Freeze Monitor switch should clear when Monitor turns off") finally: if atspi_event_thread is not None: stop_accessible_event_loop(atspi_event_thread) diff --git a/tests/test_mini_eq_background.py b/tests/test_mini_eq_background.py index 26bc542..6e521ed 100644 --- a/tests/test_mini_eq_background.py +++ b/tests/test_mini_eq_background.py @@ -29,6 +29,7 @@ def test_background_preferences_round_trip_in_settings_file(tmp_path, monkeypatc assert background.load_start_at_login() is True assert background.load_start_active_at_login() is True assert json.loads(settings.settings_path().read_text(encoding="utf-8")) == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, background.BACKGROUND_MODE_KEY: True, background.START_AT_LOGIN_KEY: True, background.START_ACTIVE_AT_LOGIN_KEY: True, diff --git a/tests/test_mini_eq_core.py b/tests/test_mini_eq_core.py index 7d64b4d..19dc971 100644 --- a/tests/test_mini_eq_core.py +++ b/tests/test_mini_eq_core.py @@ -117,6 +117,38 @@ def test_output_preset_links_missing_file_returns_empty(monkeypatch: pytest.Monk assert core.get_output_preset_link("alsa_output.speakers") is None +def test_legacy_output_preset_links_load_without_rewriting(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + links_path = tmp_path / "output-presets.json" + legacy_payload = ( + json.dumps( + { + "links": { + "alsa_output.speakers": "Speakers", + }, + "default": "Neutral", + }, + indent=2, + ) + + "\n" + ) + links_path.write_text(legacy_payload, encoding="utf-8") + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", links_path) + + assert core.load_output_preset_config() == ({"alsa_output.speakers": "Speakers"}, "Neutral") + assert links_path.read_text(encoding="utf-8") == legacy_payload + + core.set_output_preset_link("alsa_output.usb", "USB") + + assert json.loads(links_path.read_text(encoding="utf-8")) == { + "version": core.OUTPUT_PRESET_LINKS_VERSION, + "links": { + "alsa_output.speakers": "Speakers", + "alsa_output.usb": "USB", + }, + "default": "Neutral", + } + + def test_output_preset_links_reject_invalid_json(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: links_path = tmp_path / "output-presets.json" links_path.write_text("{broken", encoding="utf-8") @@ -135,6 +167,43 @@ def test_output_preset_links_reject_invalid_links_shape(monkeypatch: pytest.Monk core.load_output_preset_links() +def test_output_preset_links_reject_newer_version(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + links_path = tmp_path / "output-presets.json" + links_path.write_text( + json.dumps( + { + "version": core.OUTPUT_PRESET_LINKS_VERSION + 1, + "links": {}, + }, + ) + + "\n", + encoding="utf-8", + ) + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", links_path) + + with pytest.raises(ValueError, match="newer than this Mini EQ build"): + core.load_output_preset_links() + + +@pytest.mark.parametrize("version_value", [True, "1", -1, 1.0, None]) +def test_output_preset_links_reject_corrupt_version(version_value, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + links_path = tmp_path / "output-presets.json" + links_path.write_text( + json.dumps( + { + "version": version_value, + "links": {}, + }, + ) + + "\n", + encoding="utf-8", + ) + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", links_path) + + with pytest.raises(ValueError, match="version must be a non-negative integer"): + core.load_output_preset_links() + + def test_clear_output_preset_link_keeps_other_outputs(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") core.write_output_preset_links( diff --git a/tests/test_mini_eq_output_presets.py b/tests/test_mini_eq_output_presets.py index 5e4652e..f4dc359 100644 --- a/tests/test_mini_eq_output_presets.py +++ b/tests/test_mini_eq_output_presets.py @@ -303,8 +303,9 @@ def test_neutral_curve_uses_neutral_state_and_contextual_menu() -> None: assert test_window.preset_state_label.text == "Neutral" assert test_window.preset_state_label.tooltip == "Current curve is neutral" assert test_window.preset_state_label.classes == {"preset-state-neutral"} - assert test_window.current_curve_row.visible is False - assert test_window.current_curve_state_label.text == "" + assert test_window.current_curve_row.visible is True + assert test_window.current_curve_state_label.text == "Neutral" + assert test_window.current_curve_state_label.tooltip == "Neutral curve." assert test_window.preset_save_button.label == "Save As…" assert test_window.preset_save_as_button.visible is False assert test_window.preset_revert_button.visible is False @@ -362,7 +363,7 @@ def test_reset_to_neutral_action_tracks_current_curve() -> None: assert test_window.visible_band_count == core.DEFAULT_ACTIVE_BANDS assert test_window.output_preset_auto_applied is False assert test_window.output_preset_curve_auto_loaded is False - assert test_window.statuses[-1] == "Reset to Neutral" + assert test_window.statuses[-1] == "Reset to neutral" def test_revert_action_updates_for_named_preset_changes() -> None: @@ -391,6 +392,52 @@ def test_revert_action_updates_for_named_preset_changes() -> None: assert test_window.preset_reset_to_neutral_button.visible is True +def test_reset_to_neutral_clears_loaded_preset_selection(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + controller.bands[0].frequency = 80.0 + + 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 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." + assert test_window.preset_save_button.label == "Save As…" + assert test_window.preset_save_as_button.visible is False + assert test_window.preset_revert_button.visible is False + + test_window.load_library_preset("Headphones") + + assert test_window.current_preset_name == "Headphones" + assert test_window.preset_combo.selected == 0 + assert controller.bands[0].gain_db == 2.5 + assert test_window.statuses[-1] == "Preset loaded" + + +def test_reset_to_neutral_keeps_auto_preset_link_but_marks_it_unapplied(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + write_test_preset("Headphones", 2.5) + core.set_output_preset_link("alsa_output.headphones", "Headphones") + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + + test_window.on_preset_reset_to_neutral_clicked(FakeButton()) + + assert core.get_output_preset_link("alsa_output.headphones") == "Headphones" + assert test_window.current_preset_name is None + assert test_window.output_preset_switch.active is True + assert test_window.output_preset_state_label.text == "Linked" + + def test_revert_action_tracks_unsaved_import_baseline() -> None: controller = make_controller() controller.bands[0].gain_db = 2.0 @@ -417,7 +464,7 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None: assert test_window.current_preset_name is None assert controller.bands[0].gain_db == 2.0 assert test_window.preset_revert_button.sensitive is False - assert test_window.statuses[-1] == "Reverted to Imported APO Preset" + assert test_window.statuses[-1] == "Curve restored" def test_unsaved_apo_import_is_shown_as_current_curve(monkeypatch, tmp_path) -> None: @@ -432,10 +479,8 @@ def test_unsaved_apo_import_is_shown_as_current_curve(monkeypatch, tmp_path) -> assert test_window.preset_model.items == [] assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION assert test_window.current_curve_row.visible is True - assert test_window.current_curve_state_label.text == "Imported APO: HD 650" - assert test_window.current_curve_state_label.tooltip == ( - "Imported APO: HD 650\nUse Save As to add it to the preset library." - ) + assert test_window.current_curve_state_label.text == "Imported curve" + assert test_window.current_curve_state_label.tooltip == "Imported from HD 650." assert test_window.suggested_save_as_name() == "HD 650" test_window.on_preset_selected(test_window.preset_combo, None) @@ -454,7 +499,7 @@ 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.current_curve_state_label.text == "Imported APO: HD 650" + assert test_window.current_curve_state_label.text == "Imported curve" test_window.preset_combo.selected = 0 test_window.on_preset_selected(test_window.preset_combo, None) @@ -462,6 +507,14 @@ def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_pat assert test_window.current_preset_name == "Headphones" assert controller.bands[0].gain_db == 4.0 + 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_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_save_as_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") @@ -538,7 +591,7 @@ def test_initial_output_preset_auto_loads_linked_preset(monkeypatch, tmp_path) - assert test_window.output_preset_auto_applied is True assert test_window.output_preset_curve_auto_loaded is True assert controller.bands[0].gain_db == 2.5 - assert test_window.output_preset_state_label.text == "Linked to EQ output" + assert test_window.output_preset_state_label.text == "Applied" assert test_window.output_scope_state_label.text == "Output-wide" assert test_window.output_preset_switch.active is True @@ -557,7 +610,7 @@ def test_output_preset_auto_apply_protects_unsaved_edits(monkeypatch, tmp_path) assert test_window.current_preset_name is None assert test_window.output_preset_auto_applied is False assert controller.bands[0].gain_db == -4.0 - assert test_window.statuses[-1] == "Kept Unsaved Changes" + assert test_window.statuses[-1] == "Current curve kept" def test_output_change_without_link_resets_previous_auto_preset(monkeypatch, tmp_path) -> None: @@ -578,7 +631,7 @@ def test_output_change_without_link_resets_previous_auto_preset(monkeypatch, tmp assert test_window.output_preset_auto_applied is False assert test_window.output_preset_curve_auto_loaded is False assert test_window.sync_count == 1 - assert test_window.statuses[-1] == "No Auto Preset: Reset to Neutral" + assert test_window.statuses[-1] == "Reset to neutral" def test_output_change_without_link_applies_default_preset(monkeypatch, tmp_path) -> None: @@ -598,7 +651,7 @@ def test_output_change_without_link_applies_default_preset(monkeypatch, tmp_path assert controller.bands[0].gain_db == -1.5 assert test_window.output_preset_auto_applied is False assert test_window.output_preset_curve_auto_loaded is True - assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset" + assert test_window.statuses[-1] == "Default preset applied" def test_default_preset_loads_for_initial_unlinked_output(monkeypatch, tmp_path) -> None: @@ -613,7 +666,20 @@ def test_default_preset_loads_for_initial_unlinked_output(monkeypatch, tmp_path) assert test_window.current_preset_name == "Neutral" assert controller.bands[0].gain_db == -1.5 - assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset" + assert test_window.statuses[-1] == "Default preset applied" + + +def test_missing_default_preset_reports_unavailable(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + core.set_default_preset_name("Missing") + controller = make_controller("alsa_output.speakers") + test_window = OutputPresetWindow(controller) + + assert test_window.apply_output_preset_for_current_output() is False + + assert test_window.default_preset_state_label.text == "Missing" + assert test_window.statuses[-1] == "Default preset unavailable" def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) -> None: @@ -627,12 +693,12 @@ def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) - test_window.current_preset_name = "Manual" test_window.saved_preset_signature = controller.state_signature() - assert test_window.apply_output_preset_for_current_output(announce_no_output_preset=True) is True + assert test_window.apply_output_preset_for_current_output(announce_no_output_preset=True) is False assert test_window.current_preset_name == "Manual" assert controller.bands[0].gain_db == 2.5 assert test_window.sync_count == 0 - assert test_window.statuses[-1] == "No Auto Preset: Curve Unchanged" + assert test_window.statuses == [] def test_output_change_without_link_keeps_unsaved_auto_preset_edits(monkeypatch, tmp_path) -> None: @@ -656,7 +722,7 @@ def test_output_change_without_link_keeps_unsaved_auto_preset_edits(monkeypatch, assert test_window.current_preset_name == "Headphones" assert controller.bands[0].gain_db == 3.5 assert test_window.sync_count == 0 - assert test_window.statuses[-1] == "No Auto Preset: Kept Unsaved Changes" + assert test_window.statuses[-1] == "Current curve kept" def test_deleted_output_preset_link_is_left_clearable(monkeypatch, tmp_path) -> None: @@ -669,24 +735,25 @@ def test_deleted_output_preset_link_is_left_clearable(monkeypatch, tmp_path) -> assert test_window.apply_output_preset_for_current_output() is True assert core.get_output_preset_link("alsa_output.headphones") == "Missing" - assert test_window.output_preset_state_label.text == "Linked to EQ output" + assert test_window.output_preset_state_label.text == "Missing" assert test_window.output_preset_state_label.visible is True assert test_window.output_preset_switch.active is True assert test_window.output_preset_switch.sensitive is True - assert test_window.statuses[-1] == "Auto Preset Unavailable: Missing" + assert test_window.statuses[-1] == "Auto preset unavailable" def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + write_test_preset("Headphones", 2.5) controller = make_controller() test_window = OutputPresetWindow(controller) - test_window.current_preset_name = "Headphones" + test_window.load_library_preset("Headphones") test_window.on_use_preset_for_output_clicked(FakeButton()) assert core.get_output_preset_link("alsa_output.headphones") == "Headphones" - assert test_window.output_preset_state_label.text == "Linked to EQ output" + assert test_window.output_preset_state_label.text == "Applied" assert test_window.output_preset_scope_label.text == "Auto Preset" assert test_window.output_preset_switch.active is True @@ -729,14 +796,14 @@ def test_output_preset_actions_use_route_key_when_available(monkeypatch, tmp_pat assert core.get_output_preset_link(controller.output_sink) is None assert test_window.output_scope_state_label.text == "Headphones" assert test_window.output_preset_scope_label.text == "Auto Preset" - assert test_window.statuses[-1] == "Linked Auto Preset to Port: Headphones" + assert test_window.statuses[-1] == "Auto preset linked" core.set_output_preset_link(controller.output_sink, "Legacy Output") test_window.on_clear_output_preset_link_clicked(FakeButton()) assert core.get_output_preset_link(route_key) is None assert core.get_output_preset_link(controller.output_sink) is None - assert test_window.statuses[-1] == "Cleared Auto Preset from Port: Headphones" + assert test_window.statuses[-1] == "Auto preset cleared" def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(monkeypatch, tmp_path) -> None: @@ -756,20 +823,131 @@ def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(monkey assert controller.bands[0].gain_db == 2.5 assert test_window.preset_state_label.text == "Unsaved" assert test_window.current_curve_row.visible is True - assert test_window.current_curve_state_label.text == "Unsaved copy: Headphones" + assert test_window.current_curve_state_label.text == "Deleted preset copy" + assert test_window.current_curve_state_label.tooltip == "Deleted preset: Headphones. Curve is kept." assert test_window.suggested_save_as_name() == "Headphones" assert test_window.preset_revert_button.visible is False assert test_window.preset_revert_button.sensitive is False assert test_window.preset_revert_button.tooltip == "No curve changes to revert" assert test_window.preset_reset_to_neutral_button.visible is True assert test_window.preset_reset_to_neutral_button.sensitive is True - assert test_window.statuses[-1] == "Deleted Preset: Headphones; Current Curve Kept" + assert test_window.statuses[-1] == "Preset deleted; curve kept" test_window.on_preset_reset_to_neutral_clicked(FakeButton()) assert controller.state_signature() == controller.default_state_signature() assert test_window.current_preset_name is None - assert test_window.statuses[-1] == "Reset to Neutral" + assert test_window.statuses[-1] == "Reset to neutral" + + +def test_deleting_modified_loaded_preset_keeps_revert_baseline(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + controller.bands[0].gain_db = 4.0 + + test_window.on_preset_delete_dialog_done(FakeDeleteDialog(), None, "Headphones") + + assert test_window.current_preset_name is None + assert test_window.preset_state_label.text == "Modified" + assert test_window.current_curve_state_label.text == "Deleted preset copy" + assert test_window.preset_revert_button.visible is True + + test_window.on_preset_revert_clicked(FakeButton()) + + assert test_window.current_preset_name is None + assert controller.bands[0].gain_db == 2.5 + assert test_window.statuses[-1] == "Curve restored" + + +def test_external_loaded_preset_delete_keeps_reselectable_unsaved_copy(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + + core.delete_preset_file("Headphones") + 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.current_preset_name is None + assert test_window.preset_state_label.text == "Unsaved" + assert test_window.current_curve_state_label.text == "Deleted preset copy" + assert test_window.suggested_save_as_name() == "Headphones" + assert test_window.preset_delete_button.visible is False + + +def test_external_modified_preset_delete_keeps_revert_baseline(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + controller.bands[0].gain_db = 4.0 + + core.delete_preset_file("Headphones") + test_window.refresh_preset_list() + + assert test_window.current_preset_name is None + assert test_window.preset_state_label.text == "Modified" + assert test_window.current_curve_state_label.text == "Deleted preset copy" + assert test_window.preset_revert_button.visible is True + assert test_window.preset_revert_button.label == "Revert to Deleted preset copy" + + test_window.on_preset_revert_clicked(FakeButton()) + + assert test_window.current_preset_name is None + assert controller.bands[0].gain_db == 2.5 + assert test_window.statuses[-1] == "Curve restored" + + +def test_external_current_preset_overwrite_marks_curve_modified(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + + write_test_preset("Headphones", -3.0) + test_window.refresh_preset_list() + + assert test_window.current_preset_name == "Headphones" + 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 + + test_window.load_library_preset("Headphones") + + assert test_window.current_preset_name == "Headphones" + assert controller.bands[0].gain_db == -3.0 + assert test_window.statuses[-1] == "Preset loaded" + + +def test_external_current_preset_corruption_keeps_curve_as_unsaved_copy(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 2.5) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.refresh_preset_list() + test_window.load_library_preset("Headphones") + + core.preset_path_for_name("Headphones").write_text("{}", encoding="utf-8") + 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 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" def test_default_preset_actions_set_and_clear(monkeypatch, tmp_path) -> None: @@ -787,7 +965,7 @@ def test_default_preset_actions_set_and_clear(monkeypatch, tmp_path) -> None: assert test_window.output_preset_curve_auto_loaded is False assert test_window.default_preset_state_label.text == "Headphones" assert test_window.default_preset_clear_button.sensitive is True - assert test_window.statuses[-1] == "Default Preset Set: Headphones" + assert test_window.statuses[-1] == "Default preset set" test_window.output_preset_curve_auto_loaded = True test_window.on_clear_default_preset_clicked(FakeButton()) @@ -796,12 +974,13 @@ def test_default_preset_actions_set_and_clear(monkeypatch, tmp_path) -> None: assert test_window.output_preset_curve_auto_loaded is False assert test_window.default_preset_state_label.text == "None" assert test_window.default_preset_clear_button.sensitive is False - assert test_window.statuses[-1] == "Cleared Default Preset: Headphones" + assert test_window.statuses[-1] == "Default preset cleared" def test_output_preset_link_state_shows_different_selected_preset(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + write_test_preset("Headphones", 2.5) controller = make_controller() test_window = OutputPresetWindow(controller) test_window.current_preset_name = "Speakers" @@ -815,6 +994,22 @@ def test_output_preset_link_state_shows_different_selected_preset(monkeypatch, t assert test_window.output_preset_switch.tooltip == "Clear auto preset for EQ output" +def test_output_preset_link_state_shows_modified_linked_preset(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + write_test_preset("Headphones", 2.5) + core.set_output_preset_link("alsa_output.headphones", "Headphones") + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.load_library_preset("Headphones") + controller.bands[0].gain_db = 4.0 + + test_window.update_output_preset_state() + + assert test_window.output_preset_state_label.text == "Modified" + assert test_window.output_preset_switch.active is True + + def test_output_preset_link_toggle_clears_different_selected_preset(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") @@ -985,3 +1180,40 @@ def test_manual_output_refresh_updates_selector_without_handling_observed_output ("auto", {"reset_auto_preset_without_link": False, "announce_no_output_preset": True}), ] assert fake_window.last_output_preset_sink_name == "alsa_output.usb" + + +def test_missing_manual_output_stays_visible_in_selector() -> None: + calls: list[object] = [] + visible_sink = SimpleNamespace(node_name="alsa_output.usb") + fake_window = SimpleNamespace( + ui_shutting_down=False, + controller=SimpleNamespace( + output_sink="alsa_output.missing", + follow_default_output=False, + get_default_output_sink_name=lambda: "alsa_output.usb", + get_sink=lambda _sink_name: None, + ), + last_output_preset_sink_name=None, + output_preset_auto_applied=False, + output_preset_curve_auto_loaded=False, + post_present_ready=True, + list_visible_output_sinks=lambda: [visible_sink], + build_output_sink_labels=lambda _sinks: ["USB DAC"], + follow_default_output_label=lambda: "Follow system output (USB DAC)", + output_sink_names=[], + output_sink_labels=[], + output_sink_model=FakeModel(), + output_combo=FakeCombo(), + updating_output_combo=False, + update_preset_state=lambda: calls.append("preset-state"), + update_info_label=lambda: calls.append("info"), + update_status_summary=lambda: calls.append("summary"), + apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)), + ) + + window.MiniEqWindow.refresh_output_sinks(fake_window) + + assert fake_window.output_sink_names == [None, "alsa_output.usb", "alsa_output.missing"] + assert fake_window.output_sink_labels == ["Follow system output (USB DAC)", "USB DAC", "Unavailable output"] + assert fake_window.output_combo.selected == 2 + assert calls == ["preset-state", "info", "summary"] diff --git a/tests/test_mini_eq_settings.py b/tests/test_mini_eq_settings.py index fb01c10..16781f3 100644 --- a/tests/test_mini_eq_settings.py +++ b/tests/test_mini_eq_settings.py @@ -1,5 +1,9 @@ from __future__ import annotations +import json + +import pytest + from tests._mini_eq_imports import core, import_mini_eq_module settings = import_mini_eq_module("settings") @@ -23,6 +27,102 @@ def test_monitor_preference_round_trip(tmp_path, monkeypatch) -> None: settings.save_monitor_enabled(True) assert settings.load_monitor_enabled() is True + assert json.loads(settings.settings_path().read_text(encoding="utf-8")) == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, + settings.MONITOR_ENABLED_KEY: True, + } + + +def test_legacy_settings_without_version_still_load(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", None) + path = settings.settings_path() + path.parent.mkdir(parents=True) + legacy_payload = '{"monitor_enabled": false}\n' + path.write_text(legacy_payload, encoding="utf-8") + + assert settings.load_monitor_enabled() is False + assert settings.load_settings() == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, + settings.MONITOR_ENABLED_KEY: False, + } + assert path.read_text(encoding="utf-8") == legacy_payload + + settings.save_monitor_enabled(True) + + assert json.loads(path.read_text(encoding="utf-8")) == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, + settings.MONITOR_ENABLED_KEY: True, + } + + +def test_newer_settings_version_uses_defaults(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", None) + path = settings.settings_path() + path.parent.mkdir(parents=True) + future_payload = ( + json.dumps( + { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION + 1, + settings.MONITOR_ENABLED_KEY: False, + }, + ) + + "\n" + ) + path.write_text(future_payload, encoding="utf-8") + + assert settings.load_settings() == {} + assert settings.load_monitor_enabled() is True + assert path.read_text(encoding="utf-8") == future_payload + + +@pytest.mark.parametrize("version_value", [True, "1", -1, 1.0, None]) +def test_corrupt_settings_version_uses_defaults(version_value, tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", None) + path = settings.settings_path() + path.parent.mkdir(parents=True) + corrupt_payload = ( + json.dumps( + { + settings.SETTINGS_VERSION_KEY: version_value, + settings.MONITOR_ENABLED_KEY: False, + }, + ) + + "\n" + ) + path.write_text(corrupt_payload, encoding="utf-8") + + assert settings.load_settings() == {} + assert settings.load_monitor_enabled() is True + assert path.read_text(encoding="utf-8") == corrupt_payload + + +def test_corrupt_settings_values_are_dropped(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", None) + path = settings.settings_path() + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, + settings.MONITOR_ENABLED_KEY: "false", + settings.BACKGROUND_MODE_KEY: "true", + settings.START_AT_LOGIN_KEY: True, + settings.APPEARANCE_KEY: "sepia", + "unknown": True, + }, + ) + + "\n", + encoding="utf-8", + ) + + assert settings.load_settings() == { + settings.SETTINGS_VERSION_KEY: settings.SETTINGS_VERSION, + settings.START_AT_LOGIN_KEY: True, + } def test_invalid_monitor_preference_uses_default(tmp_path, monkeypatch) -> None: diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py index 3b96969..f3354de 100644 --- a/tests/test_mini_eq_window.py +++ b/tests/test_mini_eq_window.py @@ -73,6 +73,10 @@ def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: window.MiniEqWindow.refresh_after_eq_state_changed, fake_window, ) + fake_window.apply_startup_auto_route = MethodType( + window.MiniEqWindow.apply_startup_auto_route, + fake_window, + ) def test_on_close_request_starts_custom_shutdown_sequence() -> None: @@ -97,6 +101,35 @@ def test_compact_warning_title_keeps_bluetooth_warning_glanceable() -> None: ) +def test_bluetooth_profile_summary_handles_missing_profile() -> None: + fake_window = SimpleNamespace(format_sample_spec=lambda _sink: "48 kHz stereo") + sink = SimpleNamespace(property_value=lambda key: {"device.api": "bluez5"}.get(key)) + + assert window.MiniEqWindow.profile_summary(fake_window, sink) == ( + "Bluetooth", + "48 kHz stereo | profile unknown", + False, + [], + ) + + +def test_preset_directory_refresh_notifies_control_clients() -> None: + calls: list[str] = [] + fake_window = SimpleNamespace( + ui_shutting_down=False, + preset_refresh_source_id=42, + refresh_preset_list=lambda: calls.append("refresh"), + notify_control_presets_changed=lambda: calls.append("presets"), + notify_control_state_changed=lambda: calls.append("state"), + ) + + keep_source = window.MiniEqWindow.on_preset_dir_changed_idle(fake_window) + + assert keep_source is False + assert fake_window.preset_refresh_source_id == 0 + assert calls == ["refresh", "presets", "state"] + + def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monkeypatch) -> None: scheduled: list[tuple[int, object]] = [] application = SimpleNamespace(quit_count=0) @@ -224,7 +257,7 @@ def test_begin_close_request_shutdown_hides_when_background_mode_is_enabled() -> ] -def test_post_present_setup_schedules_auto_route_after_startup_work() -> None: +def test_post_present_setup_applies_startup_state_before_presenting() -> None: calls: list[object] = [] controller = SimpleNamespace(eq_enabled=True, routed=False) @@ -241,7 +274,7 @@ def route_system_audio(enabled: bool) -> None: post_present_ready=False, auto_route_on_startup=True, updating_ui=False, - present_after_setup=False, + present_after_setup=True, route_switch=FakeSwitch(False), controller=controller, start_preset_monitoring=lambda: calls.append("preset-monitor"), @@ -253,7 +286,7 @@ def route_system_audio(enabled: bool) -> None: start_analyzer_preview=lambda: calls.append("monitor"), notify_control_state_changed=lambda: calls.append("notify"), set_status=lambda message: calls.append(("status", message)), - schedule_startup_auto_route=lambda: calls.append("schedule-route"), + set_visible=lambda visible: calls.append(("visible", visible)), present=lambda: calls.append("present"), ) fake_window.bypass_switch = FakeSwitch(True) @@ -264,14 +297,21 @@ def route_system_audio(enabled: bool) -> None: assert keep_source is False assert fake_window.post_present_source_id == 0 assert fake_window.post_present_ready is True - assert fake_window.route_switch.get_active() is False - assert fake_window.route_switch.get_state() is False + assert fake_window.route_switch.get_active() is True + assert fake_window.route_switch.get_state() is True assert calls == [ "preset-monitor", "output-preset", "monitor", + ("route", True), + ("power", True), + ("info", True), + ("summary", True), + "focus", + "notify", + ("visible", True), + "present", "notify", - "schedule-route", ] @@ -453,7 +493,7 @@ def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) assert fake_window.current_preset_name is None assert fake_window.saved_preset_signature == "imported-signature" assert fake_window.output_preset_curve_auto_loaded is False - assert statuses == ["Imported APO: HD 650"] + assert statuses == ["Imported APO curve"] assert calls == [ ("import", str(apo_path)), ("visible-bands", 7), diff --git a/tests/test_mini_eq_window_analyzer.py b/tests/test_mini_eq_window_analyzer.py index 020b783..fb9658b 100644 --- a/tests/test_mini_eq_window_analyzer.py +++ b/tests/test_mini_eq_window_analyzer.py @@ -157,6 +157,7 @@ class FakeSwitch: def __init__(self, active: bool) -> None: self.active = active self.state = active + self.sensitive = True def get_active(self) -> bool: return self.active @@ -170,6 +171,9 @@ def get_state(self) -> bool: def set_state(self, state: bool) -> None: self.state = state + def set_sensitive(self, sensitive: bool) -> None: + self.sensitive = sensitive + class FakeSummaryLabel: def __init__(self) -> None: @@ -452,6 +456,23 @@ def test_analyzer_toggle_off_emits_zero_level_signal(monkeypatch) -> None: assert saved_values == [False] +def test_analyzer_toggle_off_clears_and_disables_freeze(monkeypatch) -> None: + saved_values: list[bool] = [] + monkeypatch.setattr(window_analyzer, "save_monitor_enabled", saved_values.append) + window = AnalyzerToggleWindow() + window.analyzer_frozen = True + window.analyzer_freeze_switch.set_state(True) + monitor_switch = FakeSwitch(False) + + handled = window.on_analyzer_changed(monitor_switch, None) + + assert handled is True + assert window.analyzer_enabled is False + assert window.analyzer_frozen is False + assert window.analyzer_freeze_switch.get_state() is False + assert window.analyzer_freeze_switch.sensitive is False + + def test_analyzer_freeze_refreshes_monitor_controls() -> None: window = AnalyzerToggleWindow() freeze_switch = FakeSwitch(True) @@ -469,6 +490,19 @@ def test_analyzer_freeze_refreshes_monitor_controls() -> None: assert window.analyzer_draws == 1 +def test_analyzer_preview_failure_clears_and_disables_freeze() -> None: + window = AnalyzerPreviewWindow(start_result=False) + window.analyzer_frozen = True + window.analyzer_freeze_switch.set_state(True) + + window.start_analyzer_preview() + + assert window.analyzer_enabled is False + assert window.analyzer_frozen is False + assert window.analyzer_freeze_switch.get_state() is False + assert window.analyzer_freeze_switch.sensitive is False + + def test_analyzer_summary_prefers_live_shortterm_loudness() -> None: window = AnalyzerSummaryWindow() diff --git a/tools/demo_runtime.py b/tools/demo_runtime.py index 6bf60d5..48a39ec 100644 --- a/tools/demo_runtime.py +++ b/tools/demo_runtime.py @@ -93,8 +93,7 @@ def build_demo_bands(self) -> list[EqBand]: def state_signature(self) -> str: return json.dumps( { - "eq_enabled": self.eq_enabled, - "eq_mode": self.eq_mode, + "version": PRESET_VERSION, "preamp_db": self.preamp_db, "bands": [eq_band_to_dict(band) for band in self.bands], },