diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8185f4f..73339d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,11 +240,11 @@ jobs: run: | python3 -m venv /tmp/mini-eq-pwg-build /tmp/mini-eq-pwg-build/bin/python -m pip install --upgrade pip - /tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.4,<0.4' -w /tmp/mini-eq-wheelhouse + /tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.5,<0.4' -w /tmp/mini-eq-wheelhouse python3 -m venv --system-site-packages .venv .venv/bin/python -m pip install --upgrade pip - .venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' + .venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<0.4' .venv/bin/python -m pip install -e '.[dev]' - name: Lint @@ -272,7 +272,7 @@ jobs: run: | python3 -m venv --system-site-packages /tmp/mini-eq-wheel-test /tmp/mini-eq-wheel-test/bin/python -m pip install --upgrade pip - /tmp/mini-eq-wheel-test/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' + /tmp/mini-eq-wheel-test/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<0.4' /tmp/mini-eq-wheel-test/bin/python -m pip install dist/mini_eq-*.whl /tmp/mini-eq-wheel-test/bin/mini-eq --help @@ -318,7 +318,7 @@ jobs: - name: Check pipewire-gobject GI compatibility run: | flatpak run --filesystem="$PWD":ro --command=python3 io.github.bhack.mini-eq \ - "$PWD/tools/check_pipewire_gobject.py" --expect-version 0.3.4 + "$PWD/tools/check_pipewire_gobject.py" --expect-version 0.3.5 - name: Smoke-test Flatpak runtime modules run: | diff --git a/AGENTS.md b/AGENTS.md index 3ccbb4c..d428702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,8 +103,11 @@ modules from a system-site build venv. - Prefer existing patterns and small, targeted patches. - Do not move logic between the large modules just to tidy them; split modules only when the user asked for that refactor or the change needs it. -- Keep the pipewire-gobject API boundary small and app-facing. WirePlumber stays - the host session manager, not a bundled GI dependency. +- Keep the pipewire-gobject API boundary small, general-purpose, and + app-facing. Mini EQ may validate new pipewire-gobject API in a real GTK app, + but do not add Mini EQ-shaped concepts, preset/filter-chain policy, or + hardware-selection policy to pipewire-gobject. WirePlumber stays the host + session manager, not a bundled GI dependency. - Treat the Mini EQ D-Bus control interface as a project-internal app/Shell extension contract with version-skew tolerance. Keep `api_version = 1` additive only: add state fields, methods, and capabilities when needed, but do diff --git a/CHANGELOG.md b/CHANGELOG.md index 952baf6..645cb4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,14 @@ # Changelog -## 0.6.0 - 2026-05-09 +## 0.7.0 - 2026-05-09 - Replace the direct WirePlumber Python integration with pipewire-gobject for app-facing PipeWire registry, metadata, routing, and monitor-capture access. -- Bundle pipewire-gobject 0.3.4 in Flatpak and require - `pipewire-gobject>=0.3.4,<0.4` for PyPI installs. +- Use pipewire-gobject device route params so auto preset links follow the + detected output port when available while preserving output-node links as a + fallback. +- Bundle pipewire-gobject 0.3.5 in Flatpak and require + `pipewire-gobject>=0.3.5,<0.4` for PyPI installs. - Keep system-wide EQ, monitor capture, output changes, and shutdown behavior covered by a live nested-GNOME/AT-SPI runtime smoke test. - Remove the old WirePlumber 0.4 compatibility build path and tighten release diff --git a/README.md b/README.md index fa94ab6..18dc59d 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ also show live LUFS loudness. - PipeWire filter-chain DSP using builtin biquad filters. - Optional spectrum analyzer and LUFS loudness readout through a PipeWire monitor capture stream. -- Per-output preset links for automatically using different saved presets with - headphones, speakers, HDMI, and other outputs. +- Auto preset links can follow the detected PipeWire port when available and + fall back to the selected EQ output when a port is not reported. - Optional background mode keeps the EQ active after closing the window, with a separate Start at Login preference and optional active-at-login routing. - Optional GNOME Shell extension for quick panel access to routing, EQ, - analyzer status, presets, and output preset links. + analyzer status, presets, and auto preset links. - Equalizer APO preset import from the UI or `--import-apo`, including compatible presets exported by [AutoEq](https://autoeq.app/). @@ -134,11 +134,11 @@ Install the Python package after the system packages are present: ```bash python3 -m venv /tmp/mini-eq-pwg-build /tmp/mini-eq-pwg-build/bin/python -m pip install --upgrade pip -/tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.4,<0.4' -w /tmp/mini-eq-wheelhouse +/tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.5,<0.4' -w /tmp/mini-eq-wheelhouse python3 -m venv --system-site-packages ~/.local/share/mini-eq/venv ~/.local/share/mini-eq/venv/bin/python -m pip install --upgrade pip -~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' +~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<0.4' ~/.local/share/mini-eq/venv/bin/python -m pip install mini-eq ~/.local/share/mini-eq/venv/bin/mini-eq --check-deps ~/.local/share/mini-eq/venv/bin/mini-eq @@ -168,7 +168,7 @@ mini-eq --install-desktop ## GNOME Shell Extension Mini EQ also has an optional GNOME Shell extension for quick panel access to -routing, EQ, analyzer status, presets, and output preset links. +routing, EQ, analyzer status, presets, and auto preset links. Install it from GNOME Shell Extensions: https://extensions.gnome.org/extension/9803/mini-eq-controls/ diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml index b32d6c5..53416f5 100644 --- a/data/io.github.bhack.mini-eq.metainfo.xml +++ b/data/io.github.bhack.mini-eq.metainfo.xml @@ -33,11 +33,11 @@ - https://raw.githubusercontent.com/bhack/mini-eq/v0.6.0/docs/screenshots/mini-eq.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.7.0/docs/screenshots/mini-eq.png Adjust sound output with equalizer controls - https://raw.githubusercontent.com/bhack/mini-eq/v0.6.0/docs/screenshots/mini-eq-dark.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.7.0/docs/screenshots/mini-eq-dark.png Use the equalizer with dark style @@ -45,11 +45,12 @@ https://github.com/bhack/mini-eq/issues https://github.com/bhack/mini-eq - +
  • Use pipewire-gobject for PipeWire registry, metadata, routing, and monitor capture.
  • -
  • Bundle pipewire-gobject 0.3.4 in Flatpak and remove the old WirePlumber 0.4 compatibility build path.
  • +
  • Add auto preset links that follow the detected output port when available.
  • +
  • Bundle pipewire-gobject 0.3.5 in Flatpak and remove the old WirePlumber 0.4 compatibility build path.
  • Improve live runtime validation for routing, monitor capture, output changes, and shutdown behavior.
diff --git a/docs/screenshots/mini-eq-dark.png b/docs/screenshots/mini-eq-dark.png index 445a57d..f1cacd3 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 b99270f..080eb0b 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 4bf8967..4cc1839 100644 Binary files a/docs/social-preview.png and b/docs/social-preview.png differ diff --git a/extensions/gnome-shell/README.md b/extensions/gnome-shell/README.md index 4704a00..df33013 100644 --- a/extensions/gnome-shell/README.md +++ b/extensions/gnome-shell/README.md @@ -17,6 +17,7 @@ Current scope: - Focus or present the Mini EQ app from the panel menu. - Control Mini EQ system-wide routing and equalized/original audio state. - List and load saved Mini EQ presets over the Mini EQ D-Bus control API. +- Show the current auto preset link exposed by Mini EQ. Install for local testing: diff --git a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js index be51398..8223352 100644 --- a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js +++ b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js @@ -90,7 +90,7 @@ class MiniEqIndicator extends PanelMenu.Button { this._statusItem.setSensitive(false); this.menu.addMenuItem(this._statusItem); - this._outputPresetItem = new PopupMenu.PopupMenuItem(_('Output preset: None')); + this._outputPresetItem = new PopupMenu.PopupMenuItem(_('Auto preset: None')); this._outputPresetItem.setSensitive(false); this.menu.addMenuItem(this._outputPresetItem); @@ -387,7 +387,7 @@ class MiniEqIndicator extends PanelMenu.Button { this._presetsItem.setSensitive(false); this._presetsItem.label.text = _('Presets'); this._statusItem.label.text = _('Mini EQ is not running'); - this._outputPresetItem.label.text = _('Output preset: None'); + this._outputPresetItem.label.text = _('Auto preset: None'); this._quitItem.visible = false; this._setAnalyzerLevels([]); this._setPresets([]); @@ -405,8 +405,8 @@ class MiniEqIndicator extends PanelMenu.Button { _outputPresetText(running, presetName) { if (!running || !presetName) - return _('Output preset: None'); - return _('Output preset: %s').format(presetName); + return _('Auto preset: None'); + return _('Auto preset: %s').format(presetName); } _applyAnalyzerLevels(levels) { diff --git a/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json b/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json index 6bf19a7..0cbbfd4 100644 --- a/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json +++ b/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json @@ -1,7 +1,7 @@ { "uuid": "mini-eq@bhack.github.io", "name": "Mini EQ Controls", - "description": "Control Mini EQ from the panel: open the app, toggle system-wide routing and EQ, view analyzer activity, switch saved presets, and see output preset links.", + "description": "Control Mini EQ from the panel: open the app, toggle system-wide routing and EQ, view analyzer activity, switch saved presets, and see auto preset links.", "shell-version": ["50"], "url": "https://github.com/bhack/mini-eq" } diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml index 4b8b22b..7076270 100644 --- a/io.github.bhack.mini-eq.yaml +++ b/io.github.bhack.mini-eq.yaml @@ -124,8 +124,8 @@ modules: sources: - type: git url: https://github.com/bhack/pipewire-gobject.git - tag: 0.3.4 - commit: 82658f10338c2e9530ae575db30f15e709626cdc + tag: 0.3.5 + commit: b570bc4ff0a53223416fff9a4fc05d367273789d - python3-dependencies.yaml diff --git a/pyproject.toml b/pyproject.toml index c30219c..eb5ad0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mini-eq" -version = "0.6.0" +version = "0.7.0" description = "Compact PipeWire system-wide parametric equalizer for Linux desktops." readme = "README.md" requires-python = ">=3.11" @@ -40,7 +40,7 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio :: Analysis", "Topic :: Multimedia :: Sound/Audio :: Mixers", ] -dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.4,<0.4"] +dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.5,<0.4"] [project.urls] Homepage = "https://github.com/bhack/mini-eq" diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py index 6e3c722..ac4d407 100644 --- a/src/mini_eq/app.py +++ b/src/mini_eq/app.py @@ -28,6 +28,7 @@ from .instance import MiniEqAlreadyRunningError, MiniEqInstanceGuard from .routing import SystemWideEqController from .window import MiniEqWindow +from .window_presets import imported_apo_curve_label class MiniEqApplication(Adw.Application): @@ -91,6 +92,7 @@ def ensure_window(self, *, present: bool) -> None: return controller: SystemWideEqController | None = None + initial_curve_label: str | None = None try: controller = SystemWideEqController(self.args.output_sink) @@ -98,13 +100,14 @@ def ensure_window(self, *, present: bool) -> None: if self.args.import_apo: controller.import_apo_preset(self.args.import_apo) + initial_curve_label = imported_apo_curve_label(self.args.import_apo) except Exception as exc: if controller is not None: controller.shutdown() raise SystemExit(str(exc)) from exc self.controller = controller - self.window = MiniEqWindow(self, self.controller, self.args.auto_route) + 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) diff --git a/src/mini_eq/core.py b/src/mini_eq/core.py index 7f88fd0..7ba924c 100644 --- a/src/mini_eq/core.py +++ b/src/mini_eq/core.py @@ -5,6 +5,7 @@ import math import os import re +from collections.abc import Iterable from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -351,6 +352,23 @@ def normalize_default_preset_name(preset_name: object) -> str | None: return default_preset or None +def normalize_output_preset_key_candidates(output_keys: str | Iterable[str | None] | None) -> list[str]: + if output_keys is None or isinstance(output_keys, str): + raw_keys: Iterable[str | None] = (output_keys,) + else: + raw_keys = output_keys + + normalized: list[str] = [] + seen: set[str] = set() + for raw_key in raw_keys: + output_key = str(raw_key or "").strip() + if output_key and output_key not in seen: + normalized.append(output_key) + seen.add(output_key) + + return normalized + + def load_output_preset_config() -> tuple[dict[str, str], str | None]: links_path = output_preset_links_path() if not links_path.exists(): @@ -399,13 +417,23 @@ def write_output_preset_links(links: dict[str, str]) -> None: write_output_preset_config(links) -def get_output_preset_link(sink_name: str | None) -> str | None: - output_key = str(sink_name or "").strip() - if not output_key: +def get_output_preset_link_match(output_keys: str | Iterable[str | None] | None) -> tuple[str, str] | None: + candidates = normalize_output_preset_key_candidates(output_keys) + if not candidates: return None links, _default_preset = load_output_preset_config() - return links.get(output_key) + for output_key in candidates: + preset_name = links.get(output_key) + if preset_name: + return output_key, preset_name + + return None + + +def get_output_preset_link(output_keys: str | Iterable[str | None] | None) -> str | None: + match = get_output_preset_link_match(output_keys) + return match[1] if match is not None else None def get_default_preset_name() -> str | None: @@ -428,13 +456,17 @@ def set_output_preset_link(sink_name: str, preset_name: str) -> str: return linked_preset -def clear_output_preset_link(sink_name: str | None) -> str | None: - output_key = str(sink_name or "").strip() - if not output_key: +def clear_output_preset_link(output_keys: str | Iterable[str | None] | None) -> str | None: + candidates = normalize_output_preset_key_candidates(output_keys) + if not candidates: return None links, default_preset = load_output_preset_config() - removed = links.pop(output_key, None) + removed = None + for output_key in candidates: + candidate = links.pop(output_key, None) + if removed is None and candidate: + removed = candidate write_output_preset_config(links, default_preset) return removed diff --git a/src/mini_eq/deps.py b/src/mini_eq/deps.py index 6ec15c8..f4316a9 100644 --- a/src/mini_eq/deps.py +++ b/src/mini_eq/deps.py @@ -12,11 +12,15 @@ Status = Literal["ok", "missing", "warning"] -PWG_REQUIRED_VERSION = "0.3.4" -PWG_REQUIRED_VERSION_PARTS = (0, 3, 4) +PWG_REQUIRED_VERSION = "0.3.5" +PWG_REQUIRED_VERSION_PARTS = (0, 3, 5) PWG_REQUIRED_SYMBOLS = ( "Core.set_pipewire_property", + "Device.enum_all_params", + "Device.enum_params", + "Device.new", "Param.new_props_controls", + "RouteInfo.new_from_param", "Stream.set_pipewire_property", ) PYGOBJECT_HINT = "Ubuntu/Debian: python3-gi; Fedora: python3-gobject; Arch: python-gobject" diff --git a/src/mini_eq/pipewire_backend.py b/src/mini_eq/pipewire_backend.py index de96bcb..365b43b 100644 --- a/src/mini_eq/pipewire_backend.py +++ b/src/mini_eq/pipewire_backend.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any +from .pipewire_routes import PipeWireRouteMixin + DEFAULT_METADATA_NAME = "default" DEFAULT_AUDIO_SINK_KEY = "default.audio.sink" DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink" @@ -12,6 +14,7 @@ TARGET_NODE_KEY = "target.node" SPA_ID_TYPE = "Spa:Id" PIPEWIRE_NODE_INTERFACE = "PipeWire:Interface:Node" +PIPEWIRE_DEVICE_INTERFACE = "PipeWire:Interface:Device" STREAM_OUTPUT_AUDIO = "Stream/Output/Audio" AUDIO_SINK = "Audio/Sink" FILTER_CHAIN_MODULE_NAME = "libpipewire-module-filter-chain" @@ -30,6 +33,8 @@ class PipeWireNode: node_description: str | None application_name: str | None node_dont_move: bool + device_id: int = 0 + card_profile_device: int = 0 properties: dict[str, str] = field(default_factory=dict) @property @@ -124,7 +129,7 @@ def node_sample_rate(node: PipeWireNode | None) -> float: return float(rate) if rate > 0 else 0.0 -class PipeWireBackend: +class PipeWireBackend(PipeWireRouteMixin): def __init__(self, timeout_ms: int = 2000) -> None: self.timeout_ms = timeout_ms self._connected = False @@ -136,9 +141,12 @@ def __init__(self, timeout_ms: int = 2000) -> None: self._metadata: Any = None self._metadata_signal_objects: dict[int, Any] = {} self._node_signal_objects: dict[int, Any] = {} + self._device_signal_objects: dict[int, Any] = {} self._node_proxies: dict[int, Any] = {} + self._device_proxies: dict[int, Any] = {} self._loaded_modules: list[Any] = [] self._cached_defaults = PipeWireDefaults(None, None) + self._device_route_refreshing_bound_ids: set[int] = set() def __enter__(self) -> PipeWireBackend: self.connect() @@ -187,6 +195,9 @@ def close(self) -> None: pass self._node_signal_objects.clear() + for handler_id in list(self._device_signal_objects): + self.disconnect_device_handler(handler_id) + for node in list(self._node_proxies.values()): try: node.stop() @@ -194,6 +205,13 @@ def close(self) -> None: pass self._node_proxies.clear() + for device in list(self._device_proxies.values()): + try: + device.stop() + except Exception: + pass + self._device_proxies.clear() + for module in list(self._loaded_modules): try: module.unload() @@ -288,6 +306,61 @@ def disconnect_metadata_handler(self, handler_id: int) -> None: except Exception: pass + def connect_device_route_changed(self, device_bound_id: int, callback) -> int: + self._ensure_connected() + + if not self._has_device_route_subscription_api(): + return 0 + + device = self._device_proxy_by_bound_id(device_bound_id) + if device is None: + return 0 + + route_param_id = self._device_route_param_id(device) + if route_param_id is None: + return 0 + + def on_device_param(_device, param) -> None: + try: + param_id = int(param.get_id()) + except Exception: + return + + if param_id != route_param_id or int(device_bound_id) in self._device_route_refreshing_bound_ids: + return + + callback() + + handler_id = self._GObject.Object.connect(device, "param", on_device_param) + self._device_signal_objects[handler_id] = device + + try: + device.subscribe_params(self._GLib.Variant("au", [route_param_id])) + except Exception: + self.disconnect_device_handler(handler_id) + return 0 + + return handler_id + + def disconnect_device_handler(self, handler_id: int) -> None: + if handler_id <= 0: + return + + device = self._device_signal_objects.pop(handler_id, None) + if device is None: + return + + try: + if self._GLib is not None and hasattr(device, "subscribe_params"): + device.subscribe_params(self._GLib.Variant("au", [])) + except Exception: + pass + + try: + device.disconnect(handler_id) + except Exception: + pass + def sync(self) -> None: self._ensure_connected() self._sync_core() @@ -471,6 +544,10 @@ def _default_metadata(self): def _node_from_global(self, global_) -> PipeWireNode: properties = self._properties_dict(global_) + device_id = parse_positive_int(self._pw_property(global_, "device.id", properties)) + if device_id > 0: + properties = self._properties_with_device_labels(properties, device_id) + return PipeWireNode( bound_id=int(global_.get_id()), object_serial=self._pw_property(global_, "object.serial", properties), @@ -479,6 +556,8 @@ def _node_from_global(self, global_) -> PipeWireNode: node_description=self._pw_property(global_, "node.description", properties), application_name=self._pw_property(global_, "application.name", properties), node_dont_move=parse_bool_property(self._pw_property(global_, "node.dont-move", properties)), + device_id=device_id, + card_profile_device=parse_positive_int(self._pw_property(global_, "card.profile.device", properties)), properties=properties, ) @@ -500,6 +579,24 @@ def _node_proxy_by_bound_id(self, bound_id: int): self._node_proxies[int(bound_id)] = node return node + def _device_proxy_by_bound_id(self, bound_id: int): + global_ = self._registry.lookup_global(int(bound_id)) + if global_ is None or not global_.is_device(): + return None + + device = self._device_proxies.get(int(bound_id)) + if device is not None and device.get_running(): + return device + + device = self._Pwg.Device.new(self._core, global_) + if device is None: + return None + if not device.start(): + raise PipeWireBackendError(f"failed to bind device: {bound_id}") + + self._device_proxies[int(bound_id)] = device + return device + def _pw_property(self, global_, key: str, properties: dict[str, str] | None = None) -> str | None: if properties is not None and key in properties: return properties[key] diff --git a/src/mini_eq/pipewire_routes.py b/src/mini_eq/pipewire_routes.py new file mode 100644 index 0000000..1ab88c8 --- /dev/null +++ b/src/mini_eq/pipewire_routes.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from urllib.parse import quote + +OUTPUT_PRESET_ROUTE_KEY_PREFIX = "pipewire-route:v1:" +OUTPUT_ROUTE_DIRECTION = "output" +DEVICE_ROUTE_PARAM_NAME = "Route" +DEVICE_LABEL_PROPERTY_KEYS = ("device.description", "device.nick", "device.name") + + +@dataclass(frozen=True) +class PipeWireOutputRoute: + device_bound_id: int + device_name: str | None + index: int + route_device: int + profile: int + priority: int + direction: str | None + name: str | None + description: str | None + availability: str | None + info: dict[str, str] = field(default_factory=dict) + + @property + def output_preset_key(self) -> str | None: + return build_output_route_preset_key(self.device_name, self.name, self.route_device) + + +@dataclass(frozen=True) +class PipeWireOutputPresetTarget: + output_key: str | None + route: PipeWireOutputRoute | None + keys: tuple[str, ...] + + @property + def link_key(self) -> str: + return next(iter(self.keys), self.output_key or "") + + @property + def has_route_key(self) -> bool: + route_key = self.route.output_preset_key if self.route is not None else None + return route_key is not None and route_key in self.keys + + +def build_output_route_preset_key(device_name: str | None, route_name: str | None, route_device: int) -> str | None: + device = str(device_name or "").strip() + route = str(route_name or "").strip() + if not device or not route: + return None + + encoded_device = quote(device, safe="") + encoded_route = quote(route, safe="") + return f"{OUTPUT_PRESET_ROUTE_KEY_PREFIX}device={encoded_device};route={encoded_route};route-device={int(route_device)}" + + +class PipeWireRouteMixin: + def output_preset_keys_for_sink_name(self, sink_name: str | None) -> tuple[str, ...]: + return self.output_preset_target_for_sink_name(sink_name).keys + + def output_preset_target_for_sink_name(self, sink_name: str | None) -> PipeWireOutputPresetTarget: + node_name = str(sink_name or "").strip() + if not node_name: + return PipeWireOutputPresetTarget(None, None, ()) + + keys: list[str] = [] + sink = self.audio_sink_by_name(node_name) + route = self.output_route_for_sink(sink) + route_key = route.output_preset_key if route is not None else None + if route_key: + keys.append(route_key) + keys.append(node_name) + + return PipeWireOutputPresetTarget(node_name, route, tuple(dict.fromkeys(keys))) + + def output_route_for_sink(self, sink) -> PipeWireOutputRoute | None: + if sink is None or sink.device_id <= 0 or not self._has_device_route_api(): + return None + + device = self._device_proxy_by_bound_id(sink.device_id) + if device is None: + return None + + routes = self._enumerate_device_routes(device, sink.device_id) + output_routes = [ + route + for route in routes + if str(route.direction or "").casefold() == OUTPUT_ROUTE_DIRECTION + and (route.availability or "unknown").casefold() != "no" + ] + if not output_routes: + return None + + if sink.card_profile_device > 0: + for route in output_routes: + if route.route_device == sink.card_profile_device: + return route + + if len(output_routes) == 1: + return output_routes[0] + + return None + + def _has_device_route_api(self) -> bool: + return ( + self._Pwg is not None + and hasattr(self._Pwg, "Device") + and hasattr(self._Pwg, "RouteInfo") + and hasattr(self._Pwg.Device, "enum_params") + and hasattr(self._Pwg.Device, "new") + and hasattr(self._Pwg.RouteInfo, "new_from_param") + ) + + def _has_device_route_subscription_api(self) -> bool: + return ( + self._has_device_route_api() + and self._GLib is not None + and self._GObject is not None + and hasattr(self._Pwg.Device, "subscribe_params") + ) + + def _device_name_by_bound_id(self, bound_id: int) -> str | None: + return self._device_properties_by_bound_id(bound_id).get("device.name") + + def _properties_with_device_labels(self, node_properties: dict[str, str], device_bound_id: int) -> dict[str, str]: + device_properties = self._device_properties_by_bound_id(device_bound_id) + if not device_properties: + return node_properties + + merged = dict(node_properties) + for key in DEVICE_LABEL_PROPERTY_KEYS: + value = device_properties.get(key) + if value and not merged.get(key): + merged[key] = value + + return merged + + def _device_properties_by_bound_id(self, bound_id: int) -> dict[str, str]: + if self._registry is None: + return {} + + try: + global_ = self._registry.lookup_global(int(bound_id)) + except Exception: + return {} + + try: + if global_ is None or not global_.is_device(): + return {} + except Exception: + return {} + + return self._properties_dict(global_) + + def _enumerate_device_routes(self, device, device_bound_id: int) -> list[PipeWireOutputRoute]: + route_param_id = self._device_route_param_id(device) + if route_param_id is None: + return [] + + bound_id = int(device_bound_id) + self._device_route_refreshing_bound_ids.add(bound_id) + try: + seq = int(device.enum_params(route_param_id, 0, 0)) + if seq < 0: + return [] + + self._wait_for_param_sequence(lambda: device.get_params(), seq) + device_name = self._device_name_by_bound_id(device_bound_id) + routes: list[PipeWireOutputRoute] = [] + for param in self._iterate_model(device.get_params()): + try: + param_name = param.dup_name() + except Exception: + param_name = None + if param_name != DEVICE_ROUTE_PARAM_NAME: + continue + + try: + route_info = self._Pwg.RouteInfo.new_from_param(param) + except Exception: + continue + + if route_info is None: + continue + + routes.append(self._output_route_from_info(route_info, device_bound_id, device_name)) + + return routes + except Exception: + return [] + finally: + self._device_route_refreshing_bound_ids.discard(bound_id) + + def _device_route_param_id(self, device) -> int | None: + route_param_id = self._device_param_id_by_name(device, DEVICE_ROUTE_PARAM_NAME) + if route_param_id is None: + self._wait_for_device_param_info(device, DEVICE_ROUTE_PARAM_NAME) + route_param_id = self._device_param_id_by_name(device, DEVICE_ROUTE_PARAM_NAME) + return route_param_id + + def _device_param_id_by_name(self, device, name: str) -> int | None: + for param_info in self._iterate_model(device.get_param_infos()): + try: + param_name = param_info.dup_name() + param_id = int(param_info.get_id()) + except Exception: + continue + + if param_name == name: + return param_id + + return None + + def _wait_for_device_param_info(self, device, name: str) -> None: + if self._GLib is None: + return + + deadline = time.monotonic() + min(self.timeout_ms / 1000.0, 0.2) + context = self._GLib.MainContext.default() + while time.monotonic() < deadline: + while context.pending(): + context.iteration(False) + + if self._device_param_id_by_name(device, name) is not None: + return + + time.sleep(0.005) + + def _output_route_from_info( + self, + route_info, + device_bound_id: int, + device_name: str | None, + ) -> PipeWireOutputRoute: + return PipeWireOutputRoute( + device_bound_id=device_bound_id, + device_name=device_name, + index=int(route_info.get_index()), + route_device=int(route_info.get_device()), + profile=int(route_info.get_profile()), + priority=int(route_info.get_priority()), + direction=route_info.dup_direction(), + name=route_info.dup_name(), + description=route_info.dup_description(), + availability=route_info.dup_availability(), + info=self._variant_to_string_dict(route_info.get_info()), + ) + + def _wait_for_param_sequence(self, model_factory, seq: int) -> None: + if self._GLib is None: + return + + deadline = time.monotonic() + min(self.timeout_ms / 1000.0, 0.2) + context = self._GLib.MainContext.default() + while time.monotonic() < deadline: + while context.pending(): + context.iteration(False) + + if self._model_has_param_sequence(model_factory(), seq): + return + + time.sleep(0.005) + + def _model_has_param_sequence(self, model, seq: int) -> bool: + for param in self._iterate_model(model): + try: + if int(param.get_seq()) == seq: + return True + except Exception: + continue + + return False + + @staticmethod + def _variant_to_string_dict(variant) -> dict[str, str]: + if variant is None: + return {} + + try: + values = variant.unpack() + except AttributeError: + values = variant + except Exception: + return {} + + try: + items = values.items() + except AttributeError: + return {} + + result: dict[str, str] = {} + for key, value in items: + if key is not None and value is not None: + result[str(key)] = str(value) + return result diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py index a559276..b87724b 100644 --- a/src/mini_eq/routing.py +++ b/src/mini_eq/routing.py @@ -49,6 +49,7 @@ PipeWireNode, node_sample_rate, ) +from .pipewire_routes import PipeWireOutputPresetTarget from .pipewire_stream_router import PipeWireStreamRouter @@ -60,6 +61,8 @@ def __init__(self, output_sink: str | None) -> None: self.original_default_sink = self.resolve_default_output_sink_name() self.follow_default_output = output_sink is None self.output_sink = output_sink or self.original_default_sink + self._output_preset_target_sink: str | None = None + self._output_preset_target: PipeWireOutputPresetTarget | None = None self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}" self.engine_module = None self.filter_node_id: int | None = None @@ -67,6 +70,8 @@ def __init__(self, output_sink: str | None) -> None: self.output_object_added_handler_id = 0 self.output_object_removed_handler_id = 0 self.output_metadata_changed_handler_id = 0 + self.output_route_param_handler_id = 0 + self.output_route_param_device_id = 0 self.accept_output_events = False self.routed = False self.running = False @@ -139,6 +144,27 @@ def get_sink(self, sink_name: str) -> PipeWireNode | None: return self.output_backend.audio_sink_by_name(sink_name) + def output_preset_keys(self) -> tuple[str, ...]: + return self.output_preset_target().keys + + def output_preset_link_key(self) -> str: + return self.output_preset_target().link_key + + def invalidate_output_preset_target(self) -> None: + self._output_preset_target_sink = None + self._output_preset_target = None + + def output_preset_target(self, *, refresh: bool = False) -> PipeWireOutputPresetTarget: + cached_target = getattr(self, "_output_preset_target", None) + cached_sink = getattr(self, "_output_preset_target_sink", None) + if not refresh and cached_target is not None and cached_sink == self.output_sink: + return cached_target + + target = self.output_backend.output_preset_target_for_sink_name(self.output_sink) + self._output_preset_target_sink = self.output_sink + self._output_preset_target = target + return target + def default_output_sink_candidates(self, *, refresh: bool = False) -> tuple[str, ...]: defaults = self.output_backend.refresh_defaults() if refresh else self.output_backend.defaults() return tuple( @@ -245,6 +271,8 @@ def switch_output_sink(self, sink_name: str, explicit: bool) -> None: self.follow_default_output = False self.output_sink = sink_name + self.invalidate_output_preset_target() + self.refresh_output_route_param_monitor() if self.stream_router is not None: self.stream_router.set_output_sink_name(sink_name) if self.output_analyzer is not None: @@ -253,13 +281,20 @@ def switch_output_sink(self, sink_name: str, explicit: bool) -> None: self.output_analyzer.set_output_sink_name(sink_name, output_sink_description) if self.retarget_filter_output(): + if explicit: + self.schedule_output_event_refresh() return self.restart_engine() + if explicit: + self.schedule_output_event_refresh() def follow_system_default_output(self) -> None: + previous_output_sink = getattr(self, "output_sink", None) self.follow_default_output = True self.refresh_followed_output_sink() + if getattr(self, "output_sink", None) != previous_output_sink: + self.schedule_output_event_refresh() def refresh_followed_output_sink(self) -> bool: if not self.follow_default_output: @@ -277,7 +312,7 @@ def refresh_followed_output_sink(self) -> bool: return True def schedule_output_event_refresh(self) -> None: - if not self.accept_output_events: + if not getattr(self, "accept_output_events", False): return if self.output_event_source_id == 0: @@ -312,13 +347,51 @@ def handle_output_metadata_changed( self.output_backend.remember_default_metadata_change(key, _value) self.schedule_output_event_refresh() + def handle_output_route_param_changed(self) -> None: + self.schedule_output_event_refresh() + + def refresh_output_route_param_monitor(self) -> None: + if not getattr(self, "accept_output_events", False): + return + + output_sink_name = getattr(self, "output_sink", "") + if not output_sink_name: + self.disconnect_output_route_param_monitor() + return + + output_sink = self.get_sink(output_sink_name) + device_id = output_sink.device_id if output_sink is not None else 0 + if device_id == getattr(self, "output_route_param_device_id", 0): + return + + self.disconnect_output_route_param_monitor() + self.output_route_param_device_id = device_id + if device_id <= 0: + return + + self.output_route_param_handler_id = self.output_backend.connect_device_route_changed( + device_id, + self.handle_output_route_param_changed, + ) + if self.output_route_param_handler_id == 0: + self.output_route_param_device_id = 0 + + def disconnect_output_route_param_monitor(self) -> None: + if getattr(self, "output_route_param_handler_id", 0) > 0: + self.output_backend.disconnect_device_handler(self.output_route_param_handler_id) + self.output_route_param_handler_id = 0 + + self.output_route_param_device_id = 0 + def on_output_event_idle(self) -> bool: self.output_event_source_id = 0 if not self.accept_output_events: return False + self.invalidate_output_preset_target() self.refresh_followed_output_sink() + self.refresh_output_route_param_monitor() if self.outputs_changed_callback is not None: self.outputs_changed_callback() @@ -343,7 +416,9 @@ def start_output_event_monitoring(self) -> None: self.handle_output_metadata_changed ) + self.invalidate_output_preset_target() self.refresh_followed_output_sink() + self.refresh_output_route_param_monitor() if self.outputs_changed_callback is not None: self.outputs_changed_callback() @@ -367,6 +442,8 @@ def stop_output_event_monitoring(self) -> None: self.output_backend.disconnect_metadata_handler(self.output_metadata_changed_handler_id) self.output_metadata_changed_handler_id = 0 + self.disconnect_output_route_param_monitor() + def pick_virtual_sink_name(self) -> str: existing = {sink.node_name for sink in self.list_sinks() if sink.node_name is not None} diff --git a/src/mini_eq/style.css b/src/mini_eq/style.css index a1f2043..fbe9860 100644 --- a/src/mini_eq/style.css +++ b/src/mini_eq/style.css @@ -59,6 +59,11 @@ min-height: 32px; } +.utility-pane-dense .toolbar-button { + padding: 2px 8px; + min-height: 30px; +} + .toolbar-icon-button { padding: 0; min-width: 36px; @@ -66,16 +71,14 @@ border-radius: 999px; } -.popover-action { - min-width: 132px; - padding: 6px 10px; - border-radius: 10px; -} - .toolbar-select { min-height: 32px; } +.utility-pane-dense .toolbar-select { + min-height: 30px; +} + .toolbar-compact-actions { padding: 0; border-radius: 0; @@ -144,12 +147,26 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { background-color: var(--mini-utility-section-bg); } +.utility-pane-dense .utility-section { + padding: 5px 8px; + border-radius: 10px; +} + .utility-row { padding: 4px 0; border-radius: 0; background-color: transparent; } +.utility-pane-dense .utility-row, +.utility-pane-dense .compare-row { + padding: 2px 0; +} + +.output-scope-state-label { + color: alpha(var(--window-fg-color), 0.86); +} + .compare-row { padding: 4px 0; border-radius: 0; @@ -185,6 +202,49 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { margin-top: 2px; } +.preset-more-menu { + min-width: 190px; +} + +.preset-menu-separator { + margin-top: 4px; + margin-bottom: 4px; + background-color: var(--mini-border-soft); +} + +button.popover-action { + min-width: 178px; + min-height: 34px; + padding: 0 10px; + border-radius: 8px; + border-color: transparent; + background-color: transparent; + background-image: none; + box-shadow: none; +} + +button.popover-action:hover { + background-color: alpha(var(--window-fg-color), 0.070); +} + +button.popover-action:disabled { + opacity: 0.46; + background-color: transparent; + background-image: none; +} + +button.popover-action label { + font-weight: 600; +} + +button.popover-action.destructive-action:not(:disabled) { + color: var(--mini-danger-color); +} + +.utility-pane-dense .preset-row { + margin-top: 0; +} + .preset-state-chip { padding: 3px 10px; border-radius: 999px; @@ -192,6 +252,13 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { font-weight: 800; } +.utility-pane-dense .preset-state-chip, +.utility-pane-dense .system-state-chip, +.utility-pane-dense .compare-state-chip, +.utility-pane-dense .headroom-peak-chip { + padding: 2px 8px; +} + .preset-state-saved { background-color: rgba(78, 184, 109, 0.14); color: var(--mini-success-color); @@ -207,6 +274,11 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { color: alpha(var(--window-fg-color), 0.90); } +.preset-state-neutral { + background-color: rgba(82, 151, 233, 0.12); + color: var(--mini-toggle-checked-color); +} + .system-state-chip { padding: 3px 10px; border-radius: 999px; @@ -240,6 +312,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { background-color: transparent; } +.utility-pane-dense .headroom-panel { + padding: 2px 6px; +} + .headroom-panel-risk { background-color: rgba(255, 92, 80, 0.055); } @@ -250,6 +326,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { color: var(--window-fg-color); } +.utility-pane-dense .headroom-state { + font-size: 12pt; +} + .headroom-peak-chip { padding: 3px 9px; border-radius: 999px; @@ -283,6 +363,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { margin-top: 1px; } +.utility-pane-dense .headroom-preamp-row { + margin-top: 0; +} + .headroom-preamp-row scale trough { min-height: 4px; } @@ -293,16 +377,30 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { border-top: 1px solid var(--mini-border-subtle); } +.utility-pane-dense .monitor-strip { + margin-top: 0; + padding-top: 3px; +} + .monitor-detail-row { min-height: 24px; } +.utility-pane-dense .monitor-detail-row { + min-height: 20px; +} + .monitor-settings-button { min-width: 32px; min-height: 32px; border-radius: 8px; } +.utility-pane-dense .monitor-settings-button { + min-width: 30px; + min-height: 30px; +} + .loudness-value-label { min-width: 68px; font-weight: 800; @@ -313,6 +411,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { min-height: 14px; } +.utility-pane-dense .loudness-meter-area { + min-height: 12px; +} + .headroom-state.headroom-safe { color: var(--mini-success-color); } diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py index a825316..646c559 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -44,7 +44,7 @@ from .window_headroom import MiniEqWindowHeadroomMixin, format_headroom_peak_db from .window_layout import MiniEqWindowLayoutMixin from .window_preferences import MiniEqWindowPreferencesMixin -from .window_presets import MiniEqWindowPresetMixin +from .window_presets import MiniEqWindowPresetMixin, imported_apo_curve_label from .window_utility import MiniEqWindowUtilityPaneMixin from .window_utils import requested_switch_state, set_switch_confirmed_state @@ -81,7 +81,13 @@ class MiniEqWindow( MiniEqWindowLayoutMixin, Adw.ApplicationWindow, ): - def __init__(self, app: Adw.Application, controller: SystemWideEqController, auto_route: bool) -> None: + def __init__( + self, + app: Adw.Application, + controller: SystemWideEqController, + auto_route: bool, + initial_curve_label: str | None = None, + ) -> None: super().__init__(application=app, title=APP_NAME) self.add_css_class("mini-eq-window") self.controller = controller @@ -117,7 +123,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut self.curve_revert_baseline_label: str | None = None self.curve_revert_baseline_signature: str | None = None self.curve_revert_baseline_payload: dict[str, object] | None = None - self.set_curve_revert_baseline("Neutral") + self.set_curve_revert_baseline(initial_curve_label or "Neutral") self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.updating_output_preset_switch = False @@ -197,6 +203,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut self.graph_title_label = Gtk.Label(xalign=0.0) self.graph_title_label.set_wrap(True) self.preset_state_label = Gtk.Label(xalign=1.0) + self.output_scope_state_label = Gtk.Label(xalign=0.0) self.output_preset_state_label = Gtk.Label(xalign=0.0) self.output_preset_switch = Gtk.Switch() self.headroom_peak_db: float | None = None @@ -591,11 +598,31 @@ def output_display_name(self, sink: PipeWireNode | None) -> str: return ( sink.property_value("device.description") + or sink.property_value("device.nick") + or sink.property_value("device.name") or sink.node_description or sink.node_name or self.controller.output_sink ) + def output_sink_detail_name(self, sink: PipeWireNode | None, base_label: str) -> str: + if sink is None: + return "Unavailable" + + node_description = str(sink.node_description or "").strip() + if node_description and node_description != base_label: + detail = node_description + if detail.casefold().startswith(base_label.casefold()): + detail = detail[len(base_label) :].strip().lstrip("-:").strip() + if (detail.startswith("(") and detail.endswith(")")) or ( + detail.startswith("[") and detail.endswith("]") + ): + detail = detail[1:-1].strip() + if detail: + return detail + + return self.transport_label_for_sink(sink) + def list_visible_output_sinks(self) -> list[PipeWireNode]: return [ sink @@ -616,7 +643,7 @@ def build_output_sink_labels(self, sinks: list[PipeWireNode]) -> list[str]: resolved.append(label) continue - resolved.append(f"{label} ({self.transport_label_for_sink(sink)} • {self.format_sample_spec(sink)})") + resolved.append(f"{label} ({self.output_sink_detail_name(sink, label)} • {self.format_sample_spec(sink)})") return resolved @@ -724,7 +751,7 @@ def update_status_summary(self) -> None: self.system_state_label.set_text("Standby") self.system_state_label.add_css_class("system-state-idle") - def refresh_output_sinks(self, *, auto_apply_output_preset: bool = True) -> None: + def refresh_output_sinks(self, *, handle_observed_output_change: bool = True) -> None: if self.ui_shutting_down: return @@ -755,12 +782,15 @@ def refresh_output_sinks(self, *, auto_apply_output_preset: bool = True) -> None self.updating_output_combo = False output_changed = previous_output is not None and previous_output != active - self.last_output_preset_sink_name = active + # App-originated selector refreshes should not consume the output + # transition; the next PipeWire-observed refresh owns preset handling. + if handle_observed_output_change or previous_output is None: + self.last_output_preset_sink_name = active self.update_preset_state() self.update_info_label() self.update_status_summary() - if self.post_present_ready and auto_apply_output_preset and output_changed: + if self.post_present_ready and handle_observed_output_change and output_changed: self.apply_output_preset_for_current_output( reset_auto_preset_without_link=previous_output_preset_auto_loaded, announce_no_output_preset=True, @@ -794,27 +824,20 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> try: imported_count = self.controller.import_apo_preset(path) + curve_label = imported_apo_curve_label(path) self.selected_band_index = None self.set_visible_band_count(imported_count) self.current_preset_name = None self.saved_preset_signature = self.controller.state_signature() - self.set_curve_revert_baseline("Imported APO Preset") + self.set_curve_revert_baseline(curve_label) self.output_preset_curve_auto_loaded = False + self.refresh_preset_list() self.sync_ui_from_state() + self.set_status(curve_label) + self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) - def on_clear_clicked(self, button: Gtk.Button) -> None: - self.controller.reset_state() - self.selected_band_index = None - self.set_visible_band_count(DEFAULT_ACTIVE_BANDS) - self.current_preset_name = None - self.saved_preset_signature = self.controller.state_signature() - self.set_curve_revert_baseline("Neutral") - self.output_preset_curve_auto_loaded = False - self.sync_ui_from_state() - self.set_status("Equalizer Reset") - def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None: if self.updating_output_combo: return @@ -824,26 +847,15 @@ def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None: return sink_name = self.output_sink_names[selected] - previous_output_preset_auto_loaded = self.output_preset_curve_auto_loaded try: if sink_name is None: self.controller.follow_system_default_output() - self.refresh_output_sinks(auto_apply_output_preset=False) - if not self.apply_output_preset_for_current_output( - reset_auto_preset_without_link=previous_output_preset_auto_loaded, - announce_no_output_preset=True, - ): - self.set_status("EQ Output Follows System Output") + self.refresh_output_sinks(handle_observed_output_change=False) return self.controller.change_output_sink(sink_name) - self.refresh_output_sinks(auto_apply_output_preset=False) - if not self.apply_output_preset_for_current_output( - reset_auto_preset_without_link=previous_output_preset_auto_loaded, - announce_no_output_preset=True, - ): - self.set_status("EQ Output Updated") + self.refresh_output_sinks(handle_observed_output_change=False) except Exception as exc: self.set_status(str(exc)) diff --git a/src/mini_eq/window_layout.py b/src/mini_eq/window_layout.py index 7890945..743daa4 100644 --- a/src/mini_eq/window_layout.py +++ b/src/mini_eq/window_layout.py @@ -29,6 +29,7 @@ from .window_utils import ( bind_label_to_control, constrain_editor_label, + make_ellipsizing_string_list_factory, set_accessible_description, set_accessible_label, ) @@ -56,6 +57,7 @@ DEFAULT_FADER_SCROLLER_MIN_HEIGHT = 200 COMPACT_FADER_SCROLLER_MIN_HEIGHT = 150 ROOMY_FADER_SCROLLER_MIN_HEIGHT = 290 +UTILITY_DENSE_HEIGHT = 660 class MiniEqWindowLayoutMixin: @@ -106,6 +108,8 @@ def build_window_content(self, auto_route: bool) -> None: self.output_combo.set_hexpand(False) self.output_combo.set_size_request(300, -1) self.output_combo.add_css_class("toolbar-select") + self.output_combo.set_factory(make_ellipsizing_string_list_factory(34)) + self.output_combo.set_list_factory(make_ellipsizing_string_list_factory(42)) set_accessible_label(self.output_combo, "EQ output") bind_label_to_control(output_label, self.output_combo) output_inline.append(self.output_combo) @@ -125,7 +129,6 @@ def add_window_action(action_name: str, callback) -> None: self.add_action(action) add_window_action("import-apo", lambda: self.on_import_apo_clicked(tools_button)) - add_window_action("reset-eq", lambda: self.on_clear_clicked(tools_button)) add_window_action("preferences", self.show_preferences_dialog) add_window_action("about", self.show_about_dialog) self.appearance_action = Gio.SimpleAction.new_stateful( @@ -138,7 +141,6 @@ def add_window_action(action_name: str, callback) -> None: tools_menu = Gio.Menu() tools_menu.append("Import Equalizer APO…", "win.import-apo") - tools_menu.append("Reset EQ", "win.reset-eq") appearance_menu = Gio.Menu() appearance_menu.append("Follow System", "win.appearance::system") @@ -623,6 +625,10 @@ def sync_visual_layout(height: int | None = None) -> None: ROOMY_FADER_SCROLLER_MIN_HEIGHT, layout_height, ) + if layout_height <= UTILITY_DENSE_HEIGHT: + right_column.add_css_class("utility-pane-dense") + else: + right_column.remove_css_class("utility-pane-dense") right_column.set_spacing(responsive_value(6, 12, layout_height)) right_column.set_margin_top(responsive_value(2, 4, layout_height)) right_column.set_margin_bottom(responsive_value(0, 2, layout_height)) diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py index 130f655..5ff23c2 100644 --- a/src/mini_eq/window_presets.py +++ b/src/mini_eq/window_presets.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path import gi @@ -31,11 +32,121 @@ ) from .window_utils import requested_switch_state, set_switch_confirmed_state +APO_IMPORT_LABEL_PREFIX = "Imported APO: " +DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: " + + +def imported_apo_curve_label(path: str) -> str: + preset_name = sanitize_preset_name(Path(path).stem) + if preset_name: + return f"{APO_IMPORT_LABEL_PREFIX}{preset_name}" + return "Imported APO" + + +@dataclass(frozen=True) +class PresetPanelUiState: + preset_state_text: str + preset_state_class: str + preset_state_tooltip: str + save_label: str + save_as_visible: bool + revert_visible: bool + revert_label: str + revert_tooltip: str + reset_visible: bool + reset_tooltip: str + default_set_visible: bool + default_clear_visible: bool + default_separator_visible: bool + file_separator_visible: bool + library_separator_visible: bool + export_label: str + delete_visible: bool + class MiniEqWindowPresetMixin: + def output_preset_target(self): + try: + return self.controller.output_preset_target() + except Exception: + return None + + def output_preset_keys(self, target=None) -> tuple[str, ...]: + if target is not None: + keys = tuple(getattr(target, "keys", ())) + if keys: + return keys + + output_sink = getattr(self.controller, "output_sink", None) + try: + keys = tuple(self.controller.output_preset_keys()) + except Exception: + keys = () + + if keys: + return keys + return (output_sink,) if output_sink else () + + def output_preset_link_key(self, target=None) -> str: + if target is not None: + link_key = str(getattr(target, "link_key", "") or "").strip() + if link_key: + return link_key + + try: + return self.controller.output_preset_link_key() + except Exception: + return getattr(self.controller, "output_sink", "") or "" + + def output_preset_has_route(self, target=None) -> bool: + return bool(getattr(target, "has_route_key", False)) + + def output_preset_scope_text(self, target=None) -> tuple[str, str, str]: + if self.output_preset_has_route(target): + return "port", "port", "Port" + return "EQ output", "output", "EQ Output" + + def update_output_scope_state(self, target=None) -> None: + label = getattr(self, "output_scope_state_label", None) + scope_label = getattr(self, "output_preset_scope_label", None) + + if scope_label is not None: + scope_label.set_text("Auto Preset") + + if label is None: + return + + output_sink = getattr(self.controller, "output_sink", None) + route = getattr(target, "route", None) + route_name = None + if route is not None: + route_name = getattr(route, "description", None) or getattr(route, "name", None) + + if self.output_preset_has_route(target): + route_key = getattr(route, "output_preset_key", None) + route_id = getattr(route, "name", None) + route_text = str(route_name or route_id or "current output port") + label.set_text(route_text) + tooltip_parts = [f"Auto presets use {route_text}."] + if route_id and route_name and route_id != route_name: + tooltip_parts.append(f"Route: {route_id}") + if route_key: + tooltip_parts.append("The preset link is tied to this detected port.") + label.set_tooltip_text("\n".join(tooltip_parts)) + return + + if output_sink: + label.set_text("Output-wide") + label.set_tooltip_text("No reliable port route was reported; auto preset links use the selected EQ output.") + return + + label.set_text("No output") + label.set_tooltip_text("Select an EQ output before linking an auto preset.") + def output_preset_link_name(self) -> str | None: try: - return get_output_preset_link(self.controller.output_sink) + target = self.output_preset_target() + return get_output_preset_link(self.output_preset_keys(target)) except Exception: return None @@ -71,6 +182,74 @@ def has_curve_revert_changes(self) -> bool: revert_signature = self.curve_revert_signature() return revert_signature is not None and self.controller.state_signature() != revert_signature + 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 current_curve_source_label(self) -> str | None: + if self.current_preset_name is not None: + return None + + label = self.curve_revert_label() + signature = self.curve_revert_signature() + if not label or signature is None or signature == self.default_preset_signature: + return None + return label + + def suggested_save_as_name(self) -> str: + if self.current_preset_name is not None: + return self.current_preset_name + + 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) :]) + if label and label.startswith(DELETED_PRESET_LABEL_PREFIX): + return sanitize_preset_name(label[len(DELETED_PRESET_LABEL_PREFIX) :]) + if label == "Imported APO": + return label + return "" + + def preset_name_exists(self, name: str) -> bool: + return preset_path_for_name(name).exists() + + def confirm_preset_replacement( + self, + preset_name: str, + body: str, + replace_callback: Callable[[], None], + ) -> None: + dialog = Adw.AlertDialog() + dialog.set_heading("Replace preset?") + dialog.set_body(body) + dialog.add_response("cancel", "Cancel") + dialog.add_response("replace", "Replace") + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + dialog.set_response_appearance("replace", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.choose( + self, + None, + lambda dialog, result: self.on_preset_replace_dialog_done(dialog, result, replace_callback), + ) + + def on_preset_replace_dialog_done( + self, + dialog: Adw.AlertDialog, + result: Gio.AsyncResult, + replace_callback: Callable[[], None], + ) -> None: + try: + response = dialog.choose_finish(result) + except GLib.Error: + return + + if response != "replace": + return + + try: + replace_callback() + except Exception as exc: + self.set_status(str(exc)) + def output_preset_is_active(self) -> bool: linked_preset = self.output_preset_link_name() return bool( @@ -85,6 +264,73 @@ def has_unsaved_curve_changes(self) -> bool: return self.controller.state_signature() != self.saved_preset_signature + def has_neutral_curve_changes(self) -> bool: + return self.controller.state_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 + neutral = current_signature == self.default_preset_signature + revert_signature = self.curve_revert_signature() + revert_label = self.curve_revert_label() or "curve baseline" + 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 + reset_visible = not neutral + default_preset = self.default_preset_name() + default_set_visible = has_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…" + + if has_named_preset and current_signature == self.saved_preset_signature: + preset_state_text = "Saved" + preset_state_class = "preset-state-saved" + preset_state_tooltip = f"{self.current_preset_name} matches the saved preset" + 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" + elif neutral: + preset_state_text = "Neutral" + preset_state_class = "preset-state-neutral" + preset_state_tooltip = "Current curve is neutral" + elif revert_signature is not None and current_signature == revert_signature: + preset_state_text = "Unsaved" + preset_state_class = "preset-state-unsaved" + preset_state_tooltip = "Current curve has not been saved as a preset" + else: + preset_state_text = "Modified" + preset_state_class = "preset-state-modified" + preset_state_tooltip = "Current curve has unsaved changes" + + if revert_visible: + revert_tooltip = f"Revert to {revert_label}" + elif has_revert_target: + revert_tooltip = "No curve changes to revert" + else: + revert_tooltip = "No preset baseline to revert to" + + 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, + revert_visible=revert_visible, + revert_label=f"Revert to {revert_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", + default_set_visible=default_set_visible, + default_clear_visible=default_clear_visible, + default_separator_visible=curve_group_visible and default_group_visible, + file_separator_visible=curve_group_visible or default_group_visible, + library_separator_visible=has_named_preset, + export_label=export_label, + delete_visible=has_named_preset, + ) + def update_output_preset_state(self) -> None: label = getattr(self, "output_preset_state_label", None) if label is None: @@ -92,6 +338,11 @@ def update_output_preset_state(self) -> None: switch = getattr(self, "output_preset_switch", 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" + clear_tooltip = f"Clear auto preset for {scope_text}" def sync_output_preset_switch( *, @@ -116,12 +367,12 @@ def sync_output_preset_switch( switch.set_tooltip_text(tooltip) try: - linked_preset = get_output_preset_link(self.controller.output_sink) + linked_preset = get_output_preset_link(self.output_preset_keys(target)) except Exception as exc: sync_output_preset_switch( active=False, sensitive=False, - tooltip="Output preset links are unavailable", + tooltip="Auto preset links are unavailable", status_text="Unavailable", status_tooltip=str(exc), ) @@ -136,7 +387,7 @@ def sync_output_preset_switch( elif not has_named_preset: tooltip = "Save a Preset First" else: - tooltip = "Use Selected Preset for This EQ Output" + tooltip = f"Use selected preset automatically for {scope_text}" sync_output_preset_switch( active=False, sensitive=has_output and has_named_preset, @@ -144,12 +395,18 @@ def sync_output_preset_switch( ) return - self.output_preset_auto_applied = self.output_preset_is_active() + self.output_preset_auto_applied = bool( + linked_preset + and self.current_preset_name == linked_preset + and self.controller.state_signature() == self.saved_preset_signature + ) if self.output_preset_auto_applied: sync_output_preset_switch( active=True, sensitive=has_output, - tooltip="Clear Output Preset", + tooltip=clear_tooltip, + status_text=linked_status_text, + status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", ) return @@ -157,37 +414,66 @@ def sync_output_preset_switch( sync_output_preset_switch( active=True, sensitive=has_output, - tooltip="Clear Output Preset", + tooltip=clear_tooltip, status_text="Different", - status_tooltip=f"This output currently uses {linked_preset}", + status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", ) return sync_output_preset_switch( active=True, sensitive=has_output, - tooltip="Clear Output Preset", - status_text="Linked", - status_tooltip=f"This output uses {linked_preset}", + tooltip=clear_tooltip, + status_text=linked_status_text, + status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}", ) - def refresh_preset_actions(self) -> None: - has_named_preset = self.current_preset_name is not None - has_revert_target = self.curve_revert_signature() is not None - has_revert_changes = self.has_curve_revert_changes() - revert_label = self.curve_revert_label() or "curve baseline" - self.preset_delete_button.set_sensitive(has_named_preset) - self.preset_export_button.set_sensitive(True) - self.preset_import_button.set_sensitive(True) - self.preset_revert_button.set_sensitive(has_revert_changes) - if not has_revert_target: - self.preset_revert_button.set_tooltip_text("Load or import a preset first") - elif has_revert_changes: - self.preset_revert_button.set_tooltip_text(f"Revert to {revert_label}") - else: - self.preset_revert_button.set_tooltip_text("No curve changes to revert") + def set_preset_widget_visible(self, name: str, visible: bool) -> None: + widget = getattr(self, name, None) + if widget is not None: + widget.set_visible(visible) + + def set_preset_widget_label(self, name: str, text: str) -> None: + label = getattr(self, f"{name}_label", None) + if label is not None: + label.set_text(text) + return + + widget = getattr(self, name, None) + if widget is not None: + widget.set_label(text) + + def refresh_preset_actions(self, state: PresetPanelUiState | None = None) -> None: + state = state or self.preset_panel_ui_state() + + self.set_preset_widget_label("preset_save_button", state.save_label) self.preset_save_button.set_sensitive(True) + + self.set_preset_widget_visible("preset_save_as_button", state.save_as_visible) self.preset_save_as_button.set_sensitive(True) + + self.set_preset_widget_visible("preset_revert_button", state.revert_visible) + self.set_preset_widget_label("preset_revert_button", state.revert_label) + self.preset_revert_button.set_sensitive(state.revert_visible) + self.preset_revert_button.set_tooltip_text(state.revert_tooltip) + + self.set_preset_widget_visible("preset_reset_to_neutral_button", state.reset_visible) + self.preset_reset_to_neutral_button.set_sensitive(state.reset_visible) + self.preset_reset_to_neutral_button.set_tooltip_text(state.reset_tooltip) + + self.set_preset_widget_visible("default_preset_set_button", state.default_set_visible) + self.set_preset_widget_visible("default_preset_clear_button", state.default_clear_visible) + + self.set_preset_widget_visible("preset_default_separator", state.default_separator_visible) + self.set_preset_widget_visible("preset_file_separator", state.file_separator_visible) + self.set_preset_widget_visible("preset_library_separator", state.library_separator_visible) + + self.preset_export_button.set_sensitive(True) + self.set_preset_widget_label("preset_export_button", state.export_label) + self.preset_import_button.set_sensitive(True) + + self.set_preset_widget_visible("preset_delete_button", state.delete_visible) + self.preset_delete_button.set_sensitive(state.delete_visible) self.update_output_preset_state() self.update_default_preset_state() @@ -224,7 +510,7 @@ def update_default_preset_state(self) -> None: if default_preset in self.preset_names: label.set_text(default_preset) - label.set_tooltip_text("Used when the selected output has no preset") + label.set_tooltip_text("Used when the selected output has no auto preset") return label.set_text("Missing") @@ -232,12 +518,13 @@ def update_default_preset_state(self) -> None: def refresh_preset_list(self) -> None: self.preset_names = list_preset_names() - self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names) 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.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names) + self.updating_preset_combo = True try: self.preset_combo.set_selected(selected_index) @@ -247,32 +534,38 @@ def refresh_preset_list(self) -> None: self.update_preset_state() def update_preset_state(self) -> None: - current_signature = self.controller.state_signature() - current_name = self.current_preset_name or "Current State" + state = self.preset_panel_ui_state() self.preset_state_label.remove_css_class("preset-state-saved") self.preset_state_label.remove_css_class("preset-state-modified") self.preset_state_label.remove_css_class("preset-state-unsaved") + self.preset_state_label.remove_css_class("preset-state-neutral") - if self.current_preset_name is None and self.has_curve_revert_changes(): - revert_label = self.curve_revert_label() or "Current curve" - self.preset_state_label.set_text("Modified") - self.preset_state_label.add_css_class("preset-state-modified") - self.preset_state_label.set_tooltip_text(f"{revert_label} has unsaved curve changes") - elif self.current_preset_name is None: - self.preset_state_label.set_text("Unsaved") - self.preset_state_label.add_css_class("preset-state-unsaved") - self.preset_state_label.set_tooltip_text("Current curve has not been saved as a preset") - elif current_signature == self.saved_preset_signature: - self.preset_state_label.set_text("Saved") - self.preset_state_label.add_css_class("preset-state-saved") - self.preset_state_label.set_tooltip_text(f"{current_name} matches the saved preset") - else: - self.preset_state_label.set_text("Modified") - self.preset_state_label.add_css_class("preset-state-modified") - self.preset_state_label.set_tooltip_text(f"{current_name} has unsaved curve changes") + self.preset_state_label.set_text(state.preset_state_text) + self.preset_state_label.add_css_class(state.preset_state_class) + self.preset_state_label.set_tooltip_text(state.preset_state_tooltip) + + self.update_current_curve_state() + self.refresh_preset_actions(state) + + def update_current_curve_state(self) -> None: + label = getattr(self, "current_curve_state_label", None) + row = getattr(self, "current_curve_row", None) + if label is None: + return - self.refresh_preset_actions() + 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.") + if row is not None: + row.set_visible(True) def save_current_state_to_preset(self, name: str) -> None: preset_name = sanitize_preset_name(name) @@ -291,6 +584,21 @@ def save_current_state_to_preset(self, name: str) -> None: self.notify_control_presets_changed() self.notify_control_state_changed() + def save_current_state_to_preset_as(self, name: str) -> None: + preset_name = sanitize_preset_name(name) + if not preset_name: + raise ValueError("Preset name is empty") + + if preset_name != self.current_preset_name and self.preset_name_exists(preset_name): + self.confirm_preset_replacement( + preset_name, + f"{preset_name} already exists. Replace it with the current curve?", + lambda: self.save_current_state_to_preset(preset_name), + ) + return + + self.save_current_state_to_preset(preset_name) + def load_library_preset( self, name: str, @@ -314,19 +622,33 @@ def load_library_preset( if status_message is not None: self.set_status(status_message) elif auto: - self.set_status(f"Applied Output Preset: {preset_name}") + self.set_status(f"Applied Auto Preset: {preset_name}") else: self.set_status(f"Loaded Preset: {preset_name}") self.notify_control_state_changed() + def reset_curve_to_neutral(self, status_message: str = "Reset to Neutral") -> None: + self.controller.reset_state() + self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + 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.sync_ui_from_state() + self.set_status(status_message) + self.notify_control_state_changed() + def apply_output_preset_for_current_output( self, *, reset_auto_preset_without_link: bool = False, announce_no_output_preset: bool = False, ) -> bool: + target = self.output_preset_target() try: - linked_preset = get_output_preset_link(self.controller.output_sink) + linked_preset = get_output_preset_link(self.output_preset_keys(target)) except Exception as exc: self.update_preset_state() self.set_status(str(exc)) @@ -339,7 +661,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("Kept Unsaved Changes") + self.set_status("No Auto Preset: Kept Unsaved Changes") self.notify_control_state_changed() return announce_no_output_preset @@ -347,14 +669,13 @@ def apply_output_preset_for_current_output( should_apply_default_preset = default_preset is not None and ( reset_auto_preset_without_link or self.current_preset_name is None ) - # TODO(#8): Prefer a port-specific fallback profile here when route matching is available. if should_apply_default_preset: try: self.load_library_preset( default_preset, auto=True, output_preset_auto=False, - status_message="Applied Default Preset", + status_message="No Auto Preset: Applied Default Preset", ) except Exception: self.output_preset_auto_applied = False @@ -365,24 +686,14 @@ def apply_output_preset_for_current_output( return True if reset_auto_preset_without_link: - self.controller.reset_state() - self.current_preset_name = None - self.saved_preset_signature = self.controller.state_signature() - 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.sync_ui_from_state() - self.set_status("Reset to Neutral") - self.notify_control_state_changed() + self.reset_curve_to_neutral("No Auto Preset: 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("Kept Current Curve") + self.set_status("No Auto Preset: Curve Unchanged") self.notify_control_state_changed() return announce_no_output_preset @@ -400,7 +711,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"Output Preset Unavailable: {linked_preset}") + self.set_status(f"Auto Preset Unavailable: {linked_preset}") self.notify_control_state_changed() return True @@ -504,8 +815,8 @@ def on_preset_save_clicked(self, button: Gtk.Button) -> None: self.on_preset_save_as_clicked(button) def on_preset_save_as_clicked(self, button: Gtk.Button) -> None: - initial_name = self.current_preset_name or "" - self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset) + initial_name = self.suggested_save_as_name() + self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset_as) def on_preset_revert_clicked(self, button: Gtk.Button) -> None: if self.current_preset_name is not None: @@ -537,17 +848,25 @@ def on_preset_revert_clicked(self, button: Gtk.Button) -> None: except Exception as exc: self.set_status(str(exc)) + def on_preset_reset_to_neutral_clicked(self, _button: Gtk.Button) -> None: + try: + self.reset_curve_to_neutral() + except Exception as exc: + self.set_status(str(exc)) + 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") 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.controller.output_sink, self.current_preset_name) + 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 Output Preset: {preset_name}") + self.set_status(f"Linked Auto Preset to {status_scope}: {preset_name}") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -582,15 +901,17 @@ def on_clear_default_preset_clicked(self, _button: Gtk.Widget) -> None: self.notify_control_state_changed() 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.controller.output_sink) + 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 Output Preset: {removed}") + self.set_status(f"Cleared Auto Preset from {status_scope}: {removed}") else: - self.set_status("No Output Preset") + self.set_status(f"No Auto Preset for {status_scope}") self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -620,7 +941,7 @@ def on_preset_delete_clicked(self, button: Gtk.Button) -> None: preset_name = self.current_preset_name dialog = Adw.AlertDialog() dialog.set_heading("Delete preset?") - dialog.set_body(f"{preset_name} will be removed from your preset library.") + dialog.set_body(f"{preset_name} will be removed from your preset library. The current curve will stay active.") dialog.add_response("cancel", "Cancel") dialog.add_response("delete", "Delete") dialog.set_default_response("cancel") @@ -646,10 +967,10 @@ def on_preset_delete_dialog_done( delete_preset_file(preset_name) self.current_preset_name = None self.saved_preset_signature = self.controller.state_signature() - self.clear_curve_revert_baseline() + self.set_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}") self.refresh_preset_list() self.sync_ui_from_state() - self.set_status(f"Deleted Preset: {preset_name}") + self.set_status(f"Deleted Preset: {preset_name}; Current Curve Kept") self.notify_control_presets_changed() self.notify_control_state_changed() except Exception as exc: @@ -666,6 +987,20 @@ def on_preset_import_clicked(self, button: Gtk.Button) -> None: dialog.set_default_filter(file_filter) dialog.open(self, None, self.on_preset_import_done) + def import_library_preset_payload(self, preset_name: str, payload: dict[str, object]) -> None: + write_mini_eq_preset_file(preset_path_for_name(preset_name), payload) + self.controller.apply_preset_payload(payload) + self.selected_band_index = None + self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) + self.current_preset_name = preset_name + self.saved_preset_signature = self.controller.state_signature() + 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.notify_control_presets_changed() + self.notify_control_state_changed() + def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: try: file = dialog.open_finish(result) @@ -686,18 +1021,15 @@ def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) stored_payload = dict(payload) stored_payload["version"] = PRESET_VERSION stored_payload["name"] = preset_name - write_mini_eq_preset_file(preset_path_for_name(preset_name), stored_payload) - self.controller.apply_preset_payload(stored_payload) - self.selected_band_index = None - self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) - self.current_preset_name = preset_name - self.saved_preset_signature = self.controller.state_signature() - 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.notify_control_presets_changed() - self.notify_control_state_changed() + if self.preset_name_exists(preset_name): + self.confirm_preset_replacement( + preset_name, + f"{preset_name} already exists. Replace it with the imported preset?", + lambda: self.import_library_preset_payload(preset_name, stored_payload), + ) + return + + self.import_library_preset_payload(preset_name, stored_payload) 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 4060cb3..cb517e2 100644 --- a/src/mini_eq/window_utility.py +++ b/src/mini_eq/window_utility.py @@ -7,7 +7,12 @@ from gi.repository import Adw, Gtk, Pango -from .window_utils import bind_label_to_control, set_accessible_description, set_accessible_label +from .window_utils import ( + bind_label_to_control, + make_ellipsizing_string_list_factory, + set_accessible_description, + set_accessible_label, +) class MiniEqWindowUtilityPaneMixin: @@ -31,6 +36,8 @@ def make_preset_section(self) -> Gtk.Box: 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) @@ -41,19 +48,48 @@ def make_preset_section(self) -> Gtk.Box: 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.set_ellipsize(Pango.EllipsizeMode.END) + set_accessible_label(self.current_curve_state_label, "Current Curve Source") + + 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) + 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.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") + self.output_scope_state_label.set_ellipsize(Pango.EllipsizeMode.END) + set_accessible_label(self.output_scope_state_label, "Auto Preset Scope") + + output_scope_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + output_scope_row.add_css_class("utility-row") + output_scope_label = Gtk.Label(label="Scope", xalign=0.0) + output_scope_row.append(output_scope_label) + output_scope_row.append(self.output_scope_state_label) + preset_section.append(output_scope_row) + self.output_preset_state_label.set_hexpand(True) self.output_preset_state_label.add_css_class("dim-label") self.output_preset_state_label.set_ellipsize(Pango.EllipsizeMode.END) - set_accessible_label(self.output_preset_state_label, "Output Preset Status") + set_accessible_label(self.output_preset_state_label, "Auto Preset Status") output_preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) output_preset_row.add_css_class("utility-row") - output_preset_label = Gtk.Label(label="Output Preset", xalign=0.0) - bind_label_to_control(output_preset_label, self.output_preset_switch) - output_preset_row.append(output_preset_label) + self.output_preset_scope_label = Gtk.Label(label="Auto Preset", xalign=0.0) + bind_label_to_control(self.output_preset_scope_label, self.output_preset_switch) + output_preset_row.append(self.output_preset_scope_label) output_preset_row.append(self.output_preset_state_label) self.output_preset_switch.set_valign(Gtk.Align.CENTER) - set_accessible_label(self.output_preset_switch, "Output Preset") + set_accessible_label(self.output_preset_switch, "Auto Preset") self.output_preset_switch.connect("state-set", self.on_output_preset_switch_changed) output_preset_row.append(self.output_preset_switch) preset_section.append(output_preset_row) @@ -76,17 +112,19 @@ def make_preset_section(self) -> Gtk.Box: self.preset_save_button.connect("clicked", self.on_preset_save_clicked) self.preset_more_popover = Gtk.Popover() - preset_more_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - preset_more_box.set_margin_top(8) - preset_more_box.set_margin_bottom(8) - preset_more_box.set_margin_start(8) - preset_more_box.set_margin_end(8) - - def append_preset_separator() -> None: + self.preset_more_popover.add_css_class("preset-more-popover") + preset_more_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + preset_more_box.add_css_class("preset-more-menu") + preset_more_box.set_margin_top(6) + preset_more_box.set_margin_bottom(6) + preset_more_box.set_margin_start(6) + preset_more_box.set_margin_end(6) + + def append_preset_separator() -> Gtk.Separator: separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) - separator.set_margin_top(3) - separator.set_margin_bottom(3) + separator.add_css_class("preset-menu-separator") preset_more_box.append(separator) + return separator def connect_preset_action(button: Gtk.Button, callback) -> None: def on_clicked(clicked_button: Gtk.Button) -> None: @@ -95,48 +133,69 @@ def on_clicked(clicked_button: Gtk.Button) -> None: button.connect("clicked", on_clicked) - self.preset_save_as_button = Gtk.Button(label="Save As…") - self.preset_save_as_button.add_css_class("popover-action") - connect_preset_action(self.preset_save_as_button, self.on_preset_save_as_clicked) - preset_more_box.append(self.preset_save_as_button) + def make_preset_action(label: str, callback, *, destructive: bool = False) -> tuple[Gtk.Button, Gtk.Label]: + button = Gtk.Button() + button.set_can_shrink(True) + button.set_hexpand(True) + button.add_css_class("popover-action") + button.add_css_class("flat") + if destructive: + button.add_css_class("destructive-action") + action_label = Gtk.Label(label=label, xalign=0.0) + action_label.set_hexpand(True) + action_label.set_ellipsize(Pango.EllipsizeMode.END) + button.set_child(action_label) + connect_preset_action(button, callback) + preset_more_box.append(button) + return button, action_label + + self.preset_save_as_button, self.preset_save_as_button_label = make_preset_action( + "Save As…", + self.on_preset_save_as_clicked, + ) - self.preset_revert_button = Gtk.Button(label="Revert") - self.preset_revert_button.add_css_class("popover-action") + self.preset_revert_button, self.preset_revert_button_label = make_preset_action( + "Revert", + self.on_preset_revert_clicked, + ) self.preset_revert_button.set_tooltip_text("Loaded Preset") - connect_preset_action(self.preset_revert_button, self.on_preset_revert_clicked) - preset_more_box.append(self.preset_revert_button) - append_preset_separator() + self.preset_reset_to_neutral_button, self.preset_reset_to_neutral_button_label = make_preset_action( + "Reset to Neutral", + self.on_preset_reset_to_neutral_clicked, + ) - self.default_preset_set_button = Gtk.Button(label="Use Selected as Default") - self.default_preset_set_button.add_css_class("popover-action") - connect_preset_action(self.default_preset_set_button, self.on_use_preset_as_default_clicked) - preset_more_box.append(self.default_preset_set_button) + self.preset_default_separator = append_preset_separator() - self.default_preset_clear_button = Gtk.Button(label="Clear Default") - self.default_preset_clear_button.add_css_class("popover-action") - connect_preset_action(self.default_preset_clear_button, self.on_clear_default_preset_clicked) - preset_more_box.append(self.default_preset_clear_button) + self.default_preset_set_button, self.default_preset_set_button_label = make_preset_action( + "Set as Default", + self.on_use_preset_as_default_clicked, + ) - append_preset_separator() + self.default_preset_clear_button, self.default_preset_clear_button_label = make_preset_action( + "Clear Default", + self.on_clear_default_preset_clicked, + ) - self.preset_import_button = Gtk.Button(label="Import Preset…") - self.preset_import_button.add_css_class("popover-action") - connect_preset_action(self.preset_import_button, self.on_preset_import_clicked) - preset_more_box.append(self.preset_import_button) + self.preset_file_separator = append_preset_separator() - self.preset_export_button = Gtk.Button(label="Export Preset…") - self.preset_export_button.add_css_class("popover-action") - connect_preset_action(self.preset_export_button, self.on_preset_export_clicked) - preset_more_box.append(self.preset_export_button) + self.preset_import_button, self.preset_import_button_label = make_preset_action( + "Import Preset…", + self.on_preset_import_clicked, + ) - append_preset_separator() + self.preset_export_button, self.preset_export_button_label = make_preset_action( + "Export Preset…", + self.on_preset_export_clicked, + ) - self.preset_delete_button = Gtk.Button(label="Delete") - self.preset_delete_button.add_css_class("popover-action") - self.preset_delete_button.add_css_class("destructive-action") - connect_preset_action(self.preset_delete_button, self.on_preset_delete_clicked) - preset_more_box.append(self.preset_delete_button) + self.preset_library_separator = append_preset_separator() + + self.preset_delete_button, self.preset_delete_button_label = make_preset_action( + "Delete Preset", + self.on_preset_delete_clicked, + destructive=True, + ) self.preset_more_popover.set_child(preset_more_box) preset_more_button = Gtk.MenuButton(label="More") diff --git a/src/mini_eq/window_utils.py b/src/mini_eq/window_utils.py index 4d32873..c75f8fb 100644 --- a/src/mini_eq/window_utils.py +++ b/src/mini_eq/window_utils.py @@ -36,3 +36,30 @@ def constrain_editor_label(label: Gtk.Label, width_chars: int) -> None: label.set_max_width_chars(width_chars) label.set_ellipsize(Pango.EllipsizeMode.END) label.set_single_line_mode(True) + + +def make_ellipsizing_string_list_factory(max_width_chars: int) -> Gtk.SignalListItemFactory: + factory = Gtk.SignalListItemFactory() + + def setup(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) -> None: + label = Gtk.Label(xalign=0.0) + label.set_hexpand(True) + label.set_width_chars(1) + label.set_max_width_chars(max_width_chars) + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_single_line_mode(True) + list_item.set_child(label) + + def bind(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) -> None: + item = list_item.get_item() + child = list_item.get_child() + if not isinstance(child, Gtk.Label): + return + + text = item.get_string() if isinstance(item, Gtk.StringObject) else "" + child.set_text(text) + child.set_tooltip_text(text) + + factory.connect("setup", setup) + factory.connect("bind", bind) + return factory diff --git a/tests/_mini_eq_imports.py b/tests/_mini_eq_imports.py index 963fbb6..f997c9e 100644 --- a/tests/_mini_eq_imports.py +++ b/tests/_mini_eq_imports.py @@ -12,4 +12,5 @@ def import_mini_eq_module(name: str): routing = import_mini_eq_module("routing") instance = import_mini_eq_module("instance") pipewire_backend = import_mini_eq_module("pipewire_backend") +pipewire_routes = import_mini_eq_module("pipewire_routes") pipewire_stream_router = import_mini_eq_module("pipewire_stream_router") diff --git a/tests/test_mini_eq_atspi_widgets.py b/tests/test_mini_eq_atspi_widgets.py index e57089c..107104a 100644 --- a/tests/test_mini_eq_atspi_widgets.py +++ b/tests/test_mini_eq_atspi_widgets.py @@ -435,6 +435,10 @@ def verify_dropdown_exposes_options(frame, *, combo_name, required_options): raise AssertionError("Not Applied status is missing") if find_accessible(frame, name="Off", role="status bar", showing=True) is None: 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") verify_dropdown_exposes_options(frame, combo_name="Type", required_options=("Notch", "Bell")) diff --git a/tests/test_mini_eq_core.py b/tests/test_mini_eq_core.py index 4dda04d..7d64b4d 100644 --- a/tests/test_mini_eq_core.py +++ b/tests/test_mini_eq_core.py @@ -95,6 +95,21 @@ def test_output_preset_links_roundtrip_and_sanitize_values(monkeypatch: pytest.M } +def test_output_preset_link_uses_first_matching_output_key(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + + core.set_output_preset_link("alsa_output.speakers", "Speakers") + core.set_output_preset_link("pipewire-route:v1:device=card;route=headphones;route-device=8", "Headphones") + + keys = ( + "pipewire-route:v1:device=card;route=headphones;route-device=8", + "alsa_output.speakers", + ) + + assert core.get_output_preset_link(keys) == "Headphones" + assert core.get_output_preset_link(("missing", "alsa_output.speakers")) == "Speakers" + + def test_output_preset_links_missing_file_returns_empty(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "missing.json") @@ -134,6 +149,26 @@ def test_clear_output_preset_link_keeps_other_outputs(monkeypatch: pytest.Monkey assert core.clear_output_preset_link("missing") is None +def test_clear_output_preset_link_removes_all_matching_candidates(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") + core.write_output_preset_links( + { + "alsa_output.speakers": "Speakers", + "pipewire-route:v1:device=card;route=headphones;route-device=8": "Headphones", + } + ) + + removed = core.clear_output_preset_link( + ( + "pipewire-route:v1:device=card;route=headphones;route-device=8", + "alsa_output.speakers", + ) + ) + + assert removed == "Headphones" + assert core.load_output_preset_links() == {} + + def test_default_preset_roundtrip_preserves_output_links(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") core.set_output_preset_link("alsa_output.speakers", "Speakers") diff --git a/tests/test_mini_eq_deps.py b/tests/test_mini_eq_deps.py index 75b310a..8d09823 100644 --- a/tests/test_mini_eq_deps.py +++ b/tests/test_mini_eq_deps.py @@ -93,9 +93,11 @@ def fake_check(namespace: str, version: str, label: str, required: bool, hint: s def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) -> None: fake_pwg = SimpleNamespace( - get_library_version=lambda: "0.3.2", + get_library_version=lambda: "0.3.4", Core=SimpleNamespace(set_pipewire_property=object()), + Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()), Param=SimpleNamespace(new_props_controls=object()), + RouteInfo=SimpleNamespace(new_from_param=object()), Stream=SimpleNamespace(set_pipewire_property=object()), ) @@ -114,14 +116,16 @@ def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) -> check = deps.check_pipewire_gobject() assert not check.ok - assert "older than required 0.3.4" in check.detail + assert "older than required 0.3.5" in check.detail def test_pipewire_gobject_check_requires_property_override_symbols(monkeypatch) -> None: fake_pwg = SimpleNamespace( - get_library_version=lambda: "0.3.4", + get_library_version=lambda: "0.3.5", Core=SimpleNamespace(), + Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()), Param=SimpleNamespace(new_props_controls=object()), + RouteInfo=SimpleNamespace(new_from_param=object()), Stream=SimpleNamespace(set_pipewire_property=object()), ) diff --git a/tests/test_mini_eq_output_presets.py b/tests/test_mini_eq_output_presets.py index 6485d5a..5e4652e 100644 --- a/tests/test_mini_eq_output_presets.py +++ b/tests/test_mini_eq_output_presets.py @@ -12,6 +12,8 @@ class FakeButton: def __init__(self) -> None: self.sensitive = True self.tooltip = "" + self.visible = True + self.label = "" def set_sensitive(self, sensitive: bool) -> None: self.sensitive = sensitive @@ -19,6 +21,12 @@ def set_sensitive(self, sensitive: bool) -> None: def set_tooltip_text(self, text: str) -> None: self.tooltip = text + def set_visible(self, visible: bool) -> None: + self.visible = visible + + def set_label(self, text: str) -> None: + self.label = text + class FakeSwitch(FakeButton): def __init__(self) -> None: @@ -88,6 +96,30 @@ def set_sensitive(self, sensitive: bool) -> None: self.sensitive = sensitive +class FakeDeleteDialog: + def __init__(self, response: str = "delete") -> None: + self.response = response + + def choose_finish(self, _result: object) -> str: + return self.response + + +class FakeFile: + def __init__(self, path: str) -> None: + self.path = path + + def get_path(self) -> str: + return self.path + + +class FakeOpenDialog: + def __init__(self, path: str) -> None: + self.path = path + + def open_finish(self, _result: object) -> FakeFile: + return FakeFile(self.path) + + class OutputPresetWindow(window_presets.MiniEqWindowPresetMixin): def __init__(self, controller) -> None: self.controller = controller @@ -107,14 +139,23 @@ def __init__(self, controller) -> None: self.sync_count = 0 self.state_count = 0 self.presets_count = 0 + self.replace_confirmations: list[SimpleNamespace] = [] self.preset_state_label = FakeLabel() + self.current_curve_state_label = FakeLabel() + self.current_curve_row = FakeButton() + self.output_scope_state_label = FakeLabel() self.output_preset_state_label = FakeLabel() + self.output_preset_scope_label = FakeLabel() self.preset_delete_button = FakeButton() self.preset_export_button = FakeButton() self.preset_import_button = FakeButton() self.preset_revert_button = FakeButton() + self.preset_reset_to_neutral_button = FakeButton() self.preset_save_button = FakeButton() self.preset_save_as_button = FakeButton() + self.preset_default_separator = FakeButton() + self.preset_file_separator = FakeButton() + self.preset_library_separator = FakeButton() self.default_preset_set_button = FakeButton() self.default_preset_clear_button = FakeButton() self.output_preset_switch = FakeSwitch() @@ -136,6 +177,20 @@ def notify_control_state_changed(self) -> None: def notify_control_presets_changed(self) -> None: self.presets_count += 1 + def confirm_preset_replacement( + self, + preset_name: str, + body: str, + replace_callback, + ) -> None: + self.replace_confirmations.append( + SimpleNamespace( + preset_name=preset_name, + body=body, + replace_callback=replace_callback, + ) + ) + def make_controller(output_sink: str = "alsa_output.headphones"): controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) @@ -156,6 +211,74 @@ def write_test_preset(name: str, gain_db: float) -> None: core.write_mini_eq_preset_file(core.preset_path_for_name(name), payload) +class FakeSink: + def __init__( + self, + *, + node_name: str, + node_description: str | None, + properties: dict[str, str], + ) -> None: + self.node_name = node_name + self.node_description = node_description + self.properties = properties + + def property_value(self, key: str) -> str | None: + return self.properties.get(key) + + +def test_output_dropdown_uses_device_first_labels() -> None: + fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback")) + sink = FakeSink( + node_name="alsa_output.hdmi", + node_description="Audio interno Stereo digitale HDMI", + properties={"device.description": "Audio interno"}, + ) + + assert window.MiniEqWindow.output_display_name(fake_window, sink) == "Audio interno" + assert window.MiniEqWindow.output_sink_detail_name(fake_window, sink, "Audio interno") == "Stereo digitale HDMI" + + +def test_output_dropdown_detail_preserves_nested_parentheses() -> None: + fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback")) + sink = FakeSink( + node_name="alsa_output.hdmi", + node_description="Audio interno Stereo digitale (HDMI)", + properties={"device.description": "Audio interno"}, + ) + + assert window.MiniEqWindow.output_sink_detail_name(fake_window, sink, "Audio interno") == "Stereo digitale (HDMI)" + + +def test_output_dropdown_disambiguates_duplicate_device_labels() -> None: + fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback")) + fake_window.output_display_name = lambda sink: window.MiniEqWindow.output_display_name(fake_window, sink) + fake_window.output_sink_detail_name = lambda sink, label: window.MiniEqWindow.output_sink_detail_name( + fake_window, + sink, + label, + ) + fake_window.format_sample_spec = lambda _sink: "48 kHz stereo" + fake_window.transport_label_for_sink = lambda _sink: "ALSA" + sinks = [ + FakeSink( + node_name="alsa_output.hdmi", + node_description="Audio interno HDMI", + properties={"device.description": "Audio interno"}, + ), + FakeSink( + node_name="alsa_output.speakers", + node_description="Audio interno Speakers", + properties={"device.description": "Audio interno"}, + ), + ] + + assert window.MiniEqWindow.build_output_sink_labels(fake_window, sinks) == [ + "Audio interno (HDMI • 48 kHz stereo)", + "Audio interno (Speakers • 48 kHz stereo)", + ] + + def test_revert_action_explains_missing_loaded_preset() -> None: controller = make_controller() controller.bands[0].gain_db = 2.0 @@ -164,8 +287,34 @@ def test_revert_action_explains_missing_loaded_preset() -> None: test_window.refresh_preset_actions() + 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 == "Load or import a preset first" + assert test_window.preset_reset_to_neutral_button.visible is True + assert test_window.preset_save_button.label == "Save As…" + assert test_window.preset_save_as_button.visible is False + + +def test_neutral_curve_uses_neutral_state_and_contextual_menu() -> None: + controller = make_controller() + test_window = OutputPresetWindow(controller) + + test_window.update_preset_state() + + 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.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 + assert test_window.preset_reset_to_neutral_button.visible is False + assert test_window.default_preset_set_button.visible is False + assert test_window.default_preset_clear_button.visible is False + assert test_window.preset_default_separator.visible is False + assert test_window.preset_file_separator.visible is False + assert test_window.preset_delete_button.visible is False + assert test_window.preset_export_button.label == "Export Current Curve…" def test_revert_action_tracks_initial_neutral_baseline() -> None: @@ -174,14 +323,46 @@ def test_revert_action_tracks_initial_neutral_baseline() -> None: test_window.refresh_preset_actions() + 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 False controller.bands[0].gain_db = 2.0 test_window.refresh_preset_actions() - assert test_window.preset_revert_button.sensitive is True - assert test_window.preset_revert_button.tooltip == "Revert to Neutral" + assert test_window.preset_revert_button.visible is False + assert test_window.preset_revert_button.sensitive is False + assert test_window.preset_reset_to_neutral_button.visible is True + assert test_window.preset_reset_to_neutral_button.sensitive is True + + +def test_reset_to_neutral_action_tracks_current_curve() -> None: + controller = make_controller() + test_window = OutputPresetWindow(controller) + + test_window.refresh_preset_actions() + + assert test_window.preset_reset_to_neutral_button.visible is False + assert test_window.preset_reset_to_neutral_button.sensitive is False + assert test_window.preset_reset_to_neutral_button.tooltip == "Curve is already neutral" + + controller.bands[0].gain_db = 2.0 + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.visible is False + assert test_window.preset_revert_button.sensitive is False + 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.preset_reset_to_neutral_button.tooltip == "Reset all bands and preamp to neutral" + + test_window.on_preset_reset_to_neutral_clicked(FakeButton()) + + assert test_window.current_preset_name is None + assert controller.state_signature() == controller.default_state_signature() + 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" def test_revert_action_updates_for_named_preset_changes() -> None: @@ -192,14 +373,22 @@ def test_revert_action_updates_for_named_preset_changes() -> None: test_window.refresh_preset_actions() + assert test_window.preset_save_button.label == "Save" + assert test_window.preset_save_as_button.visible is True + 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.default_preset_set_button.visible is True + assert test_window.preset_export_button.label == "Export Preset…" + assert test_window.preset_delete_button.visible is True controller.bands[0].gain_db = 2.0 test_window.refresh_preset_actions() + assert test_window.preset_revert_button.visible is True assert test_window.preset_revert_button.sensitive is True assert test_window.preset_revert_button.tooltip == "Revert to Headphones" + assert test_window.preset_revert_button.label == "Revert to Headphones" + assert test_window.preset_reset_to_neutral_button.visible is True def test_revert_action_tracks_unsaved_import_baseline() -> None: @@ -210,6 +399,7 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None: test_window.refresh_preset_actions() + 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" @@ -217,6 +407,7 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None: test_window.update_preset_state() assert test_window.preset_state_label.text == "Modified" + assert test_window.preset_revert_button.visible is True assert test_window.preset_revert_button.sensitive is True assert test_window.preset_revert_button.tooltip == "Revert to Imported APO Preset" @@ -229,6 +420,110 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None: assert test_window.statuses[-1] == "Reverted to Imported APO Preset" +def test_unsaved_apo_import_is_shown_as_current_curve(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + controller = make_controller() + controller.bands[0].gain_db = 2.0 + test_window = OutputPresetWindow(controller) + test_window.set_curve_revert_baseline("Imported APO: HD 650") + + test_window.refresh_preset_list() + + assert test_window.preset_model.items == [] + assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION + assert test_window.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.suggested_save_as_name() == "HD 650" + + test_window.on_preset_selected(test_window.preset_combo, None) + + assert test_window.current_preset_name is None + + +def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 4.0) + controller = make_controller() + controller.bands[0].gain_db = 2.0 + test_window = OutputPresetWindow(controller) + test_window.set_curve_revert_baseline("Imported APO: HD 650") + + test_window.refresh_preset_list() + + assert test_window.preset_model.items == ["Headphones"] + assert test_window.current_curve_state_label.text == "Imported APO: HD 650" + + test_window.preset_combo.selected = 0 + test_window.on_preset_selected(test_window.preset_combo, None) + + assert test_window.current_preset_name == "Headphones" + assert controller.bands[0].gain_db == 4.0 + + +def test_save_as_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 4.0) + controller = make_controller() + controller.bands[0].gain_db = 2.0 + test_window = OutputPresetWindow(controller) + + test_window.save_current_state_to_preset_as("Headphones") + + assert len(test_window.replace_confirmations) == 1 + confirmation = test_window.replace_confirmations[0] + assert confirmation.preset_name == "Headphones" + assert confirmation.body == "Headphones already exists. Replace it with the current curve?" + assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0 + + confirmation.replace_callback() + + assert test_window.current_preset_name == "Headphones" + assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0 + + +def test_save_as_current_preset_overwrites_without_replace_confirmation(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 4.0) + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.load_library_preset("Headphones") + controller.bands[0].gain_db = 2.0 + + test_window.save_current_state_to_preset_as("Headphones") + + assert test_window.replace_confirmations == [] + assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0 + + +def test_importing_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") + write_test_preset("Headphones", 4.0) + import_controller = make_controller() + import_controller.bands[0].gain_db = 6.0 + import_path = tmp_path / "headphones.json" + core.write_mini_eq_preset_file(import_path, import_controller.build_preset_payload("Headphones")) + controller = make_controller() + test_window = OutputPresetWindow(controller) + + test_window.on_preset_import_done(FakeOpenDialog(str(import_path)), None) + + assert len(test_window.replace_confirmations) == 1 + confirmation = test_window.replace_confirmations[0] + assert confirmation.preset_name == "Headphones" + assert confirmation.body == "Headphones already exists. Replace it with the imported preset?" + assert controller.bands[0].gain_db == 0.0 + assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0 + + confirmation.replace_callback() + + assert test_window.current_preset_name == "Headphones" + assert controller.bands[0].gain_db == 6.0 + assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 6.0 + + def test_initial_output_preset_auto_loads_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") @@ -243,7 +538,8 @@ 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 == "" + assert test_window.output_preset_state_label.text == "Linked to EQ output" + assert test_window.output_scope_state_label.text == "Output-wide" assert test_window.output_preset_switch.active is True @@ -282,7 +578,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] == "Reset to Neutral" + assert test_window.statuses[-1] == "No Auto Preset: Reset to Neutral" def test_output_change_without_link_applies_default_preset(monkeypatch, tmp_path) -> None: @@ -302,7 +598,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] == "Applied Default Preset" + assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset" def test_default_preset_loads_for_initial_unlinked_output(monkeypatch, tmp_path) -> None: @@ -317,7 +613,7 @@ 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] == "Applied Default Preset" + assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset" def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) -> None: @@ -336,7 +632,7 @@ def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) - 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] == "Kept Current Curve" + assert test_window.statuses[-1] == "No Auto Preset: Curve Unchanged" def test_output_change_without_link_keeps_unsaved_auto_preset_edits(monkeypatch, tmp_path) -> None: @@ -360,7 +656,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] == "Kept Unsaved Changes" + assert test_window.statuses[-1] == "No Auto Preset: Kept Unsaved Changes" def test_deleted_output_preset_link_is_left_clearable(monkeypatch, tmp_path) -> None: @@ -373,11 +669,11 @@ 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" + assert test_window.output_preset_state_label.text == "Linked to EQ output" 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] == "Output Preset Unavailable: Missing" + assert test_window.statuses[-1] == "Auto Preset Unavailable: Missing" def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_path) -> None: @@ -390,7 +686,8 @@ def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_pa 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 == "" + assert test_window.output_preset_state_label.text == "Linked to EQ output" + assert test_window.output_preset_scope_label.text == "Auto Preset" assert test_window.output_preset_switch.active is True test_window.output_preset_curve_auto_loaded = True @@ -402,6 +699,79 @@ def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_pa assert test_window.output_preset_switch.active is False +def test_output_preset_actions_use_route_key_when_available(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.0) + route_key = "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8" + controller = make_controller() + route = SimpleNamespace( + description="Headphones", + name="analog-output-headphones", + output_preset_key=route_key, + ) + target = SimpleNamespace( + output_key=controller.output_sink, + route=route, + keys=(route_key, controller.output_sink), + link_key=route_key, + has_route_key=True, + ) + controller.output_preset_target = lambda: target + controller.output_preset_keys = lambda: (route_key, controller.output_sink) + controller.output_preset_link_key = lambda: route_key + test_window = OutputPresetWindow(controller) + test_window.load_library_preset("Headphones") + + test_window.on_use_preset_for_output_clicked(FakeButton()) + + assert core.get_output_preset_link(route_key) == "Headphones" + 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" + + 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" + + +def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(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.refresh_preset_list() + test_window.load_library_preset("Headphones") + + test_window.on_preset_delete_dialog_done(FakeDeleteDialog(), None, "Headphones") + + assert core.list_preset_names() == [] + assert test_window.preset_names == [] + assert test_window.current_preset_name is None + 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.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" + + 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" + + def test_default_preset_actions_set_and_clear(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") @@ -442,7 +812,7 @@ def test_output_preset_link_state_shows_different_selected_preset(monkeypatch, t assert test_window.output_preset_state_label.text == "Different" 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.tooltip == "Clear Output Preset" + assert test_window.output_preset_switch.tooltip == "Clear auto preset for EQ output" def test_output_preset_link_toggle_clears_different_selected_preset(monkeypatch, tmp_path) -> None: @@ -484,7 +854,7 @@ def test_output_preset_link_toggle_links_and_clears_current_output(monkeypatch, assert test_window.output_preset_switch.state is False -def test_manual_output_change_runs_output_preset_auto_apply() -> None: +def test_manual_output_change_defers_output_preset_handling_to_pipewire_refresh() -> None: calls: list[object] = [] fake_window = SimpleNamespace( updating_output_combo=False, @@ -492,8 +862,8 @@ def test_manual_output_change_runs_output_preset_auto_apply() -> None: output_preset_curve_auto_loaded=True, output_sink_names=[None, "alsa_output.headphones"], controller=SimpleNamespace(change_output_sink=lambda sink: calls.append(("change", sink))), - refresh_output_sinks=lambda *, auto_apply_output_preset=True: calls.append( - ("refresh", auto_apply_output_preset) + refresh_output_sinks=lambda *, handle_observed_output_change=True: calls.append( + ("refresh", handle_observed_output_change) ), apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)) or True, set_status=lambda message: calls.append(("status", message)), @@ -504,35 +874,33 @@ def test_manual_output_change_runs_output_preset_auto_apply() -> None: assert calls == [ ("change", "alsa_output.headphones"), ("refresh", False), - ("auto", {"reset_auto_preset_without_link": True, "announce_no_output_preset": True}), ] -def test_manual_output_change_uses_auto_loaded_source_for_reset_decision() -> None: +def test_manual_output_change_to_follow_default_defers_output_preset_handling() -> None: calls: list[object] = [] fake_window = SimpleNamespace( updating_output_combo=False, output_preset_auto_applied=True, output_preset_curve_auto_loaded=False, output_sink_names=[None, "alsa_output.headphones"], - controller=SimpleNamespace(change_output_sink=lambda sink: calls.append(("change", sink))), - refresh_output_sinks=lambda *, auto_apply_output_preset=True: calls.append( - ("refresh", auto_apply_output_preset) + controller=SimpleNamespace(follow_system_default_output=lambda: calls.append("follow")), + refresh_output_sinks=lambda *, handle_observed_output_change=True: calls.append( + ("refresh", handle_observed_output_change) ), apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)) or True, set_status=lambda message: calls.append(("status", message)), ) - window.MiniEqWindow.on_output_changed(fake_window, FakeCombo(selected=1), None) + window.MiniEqWindow.on_output_changed(fake_window, FakeCombo(selected=0), None) assert calls == [ - ("change", "alsa_output.headphones"), + "follow", ("refresh", False), - ("auto", {"reset_auto_preset_without_link": False, "announce_no_output_preset": True}), ] -def test_system_default_output_change_runs_output_preset_auto_apply() -> None: +def test_pipewire_observed_output_change_runs_output_preset_handling() -> None: calls: list[object] = [] fake_window = SimpleNamespace( ui_shutting_down=False, @@ -569,3 +937,51 @@ def test_system_default_output_change_runs_output_preset_auto_apply() -> None: ("auto", {"reset_auto_preset_without_link": True, "announce_no_output_preset": True}), ] assert fake_window.last_output_preset_sink_name == "alsa_output.usb" + + +def test_manual_output_refresh_updates_selector_without_handling_observed_output_change() -> None: + calls: list[object] = [] + fake_window = SimpleNamespace( + ui_shutting_down=False, + controller=SimpleNamespace( + output_sink="alsa_output.usb", + follow_default_output=False, + get_default_output_sink_name=lambda: "alsa_output.usb", + get_sink=lambda _sink_name: None, + ), + last_output_preset_sink_name="alsa_output.speakers", + output_preset_auto_applied=True, + output_preset_curve_auto_loaded=False, + post_present_ready=True, + list_visible_output_sinks=lambda: [], + build_output_sink_labels=lambda _sinks: [], + follow_default_output_label=lambda: "Follow system output", + 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, + handle_observed_output_change=False, + ) + + assert calls == ["preset-state", "info", "summary"] + assert fake_window.last_output_preset_sink_name == "alsa_output.speakers" + + calls.clear() + window.MiniEqWindow.refresh_output_sinks(fake_window) + + assert calls == [ + "preset-state", + "info", + "summary", + ("auto", {"reset_auto_preset_without_link": False, "announce_no_output_preset": True}), + ] + assert fake_window.last_output_preset_sink_name == "alsa_output.usb" diff --git a/tests/test_mini_eq_pipewire_backend.py b/tests/test_mini_eq_pipewire_backend.py index 61bf250..a27cfb5 100644 --- a/tests/test_mini_eq_pipewire_backend.py +++ b/tests/test_mini_eq_pipewire_backend.py @@ -1,8 +1,11 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from tests._mini_eq_imports import pipewire_backend as pw_backend +from tests._mini_eq_imports import pipewire_routes as pw_routes class FakeCore: @@ -185,6 +188,26 @@ class FakePwg: Param = FakePwgParam +class FakeDeviceApi: + @staticmethod + def enum_params(): + return None + + @staticmethod + def new(): + return None + + @staticmethod + def subscribe_params(): + return None + + +class FakeRouteInfoApi: + @staticmethod + def new_from_param(_param): + return None + + def test_parse_metadata_node_name_reads_wireplumber_json_name() -> None: assert pw_backend.parse_metadata_node_name('{"name":"alsa_output.test"}') == "alsa_output.test" @@ -270,6 +293,79 @@ def test_node_classification_and_display_name() -> None: assert stream.display_name == "spotify" +def test_node_from_global_copies_device_route_properties() -> None: + backend = pw_backend.PipeWireBackend() + + class FakeGlobal(FakePropertyProxy): + def get_id(self) -> int: + return 39 + + node = backend._node_from_global( + FakeGlobal( + FakeGlobalProperties( + [ + FakePropertyItem("object.serial", "67"), + FakePropertyItem("media.class", pw_backend.AUDIO_SINK), + FakePropertyItem("node.name", "alsa_output.test"), + FakePropertyItem("device.id", "72"), + FakePropertyItem("card.profile.device", "8"), + ] + ) + ) + ) + + assert node.device_id == 72 + assert node.card_profile_device == 8 + + +def test_node_from_global_enriches_device_label_properties() -> None: + backend = pw_backend.PipeWireBackend() + + class FakeNodeGlobal(FakePropertyProxy): + def get_id(self) -> int: + return 39 + + class FakeDeviceGlobal(FakePropertyProxy): + def is_device(self) -> bool: + return True + + class FakeRegistry: + def __init__(self) -> None: + self.device = FakeDeviceGlobal( + FakeGlobalProperties( + [ + FakePropertyItem("device.name", "alsa_card.pci-0000_00_1f.3"), + FakePropertyItem("device.description", "Audio interno"), + FakePropertyItem("device.nick", "HDA Intel PCH"), + ] + ) + ) + + def lookup_global(self, bound_id: int) -> FakeDeviceGlobal | None: + return self.device if bound_id == 72 else None + + backend._registry = FakeRegistry() + node = backend._node_from_global( + FakeNodeGlobal( + FakeGlobalProperties( + [ + FakePropertyItem("object.serial", "67"), + FakePropertyItem("media.class", pw_backend.AUDIO_SINK), + FakePropertyItem("node.name", "alsa_output.hdmi"), + FakePropertyItem("node.description", "Audio interno Stereo digitale (HDMI)"), + FakePropertyItem("device.id", "72"), + FakePropertyItem("card.profile.device", "8"), + ] + ) + ) + ) + + assert node.node_description == "Audio interno Stereo digitale (HDMI)" + assert node.property_value("device.description") == "Audio interno" + assert node.property_value("device.nick") == "HDA Intel PCH" + assert node.property_value("device.name") == "alsa_card.pci-0000_00_1f.3" + + def test_new_core_uses_pipewire_gobject_core_constructor() -> None: FakeCore.calls = 0 @@ -325,6 +421,247 @@ def test_move_stream_to_target_sets_stream_target_without_metadata_readback() -> assert calls == [(126, 39, "67")] +def test_output_preset_keys_prefer_matching_active_route(monkeypatch: pytest.MonkeyPatch) -> None: + backend = pw_backend.PipeWireBackend() + backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi) + sink = pw_backend.PipeWireNode( + bound_id=39, + object_serial="67", + media_class=pw_backend.AUDIO_SINK, + node_name="alsa_output.test", + node_description="Test Sink", + application_name=None, + node_dont_move=False, + device_id=72, + card_profile_device=8, + ) + line_out = pw_routes.PipeWireOutputRoute( + device_bound_id=72, + device_name="alsa_card.test", + index=0, + route_device=7, + profile=0, + priority=100, + direction="Output", + name="analog-output-lineout", + description="Line Out", + availability="yes", + ) + headphones = pw_routes.PipeWireOutputRoute( + device_bound_id=72, + device_name="alsa_card.test", + index=1, + route_device=8, + profile=0, + priority=200, + direction="Output", + name="analog-output-headphones", + description="Headphones", + availability="yes", + ) + + monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink) + monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: object()) + monkeypatch.setattr(backend, "_enumerate_device_routes", lambda _device, _bound_id: [line_out, headphones]) + + assert backend.output_preset_keys_for_sink_name("alsa_output.test") == ( + "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8", + "alsa_output.test", + ) + + +def test_output_preset_keys_fall_back_to_sink_name_without_route_api(monkeypatch: pytest.MonkeyPatch) -> None: + backend = pw_backend.PipeWireBackend() + backend._Pwg = SimpleNamespace() + sink = pw_backend.PipeWireNode( + bound_id=39, + object_serial="67", + media_class=pw_backend.AUDIO_SINK, + node_name="alsa_output.test", + node_description="Test Sink", + application_name=None, + node_dont_move=False, + device_id=72, + card_profile_device=8, + ) + + monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink) + + assert backend.output_preset_keys_for_sink_name("alsa_output.test") == ("alsa_output.test",) + + +def test_enumerate_device_routes_ignores_enum_route_params(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeParam: + def __init__(self, name: str) -> None: + self.name = name + + def get_seq(self) -> int: + return 12 + + def dup_name(self) -> str: + return self.name + + class FakeParamInfo: + def get_id(self) -> int: + return 13 + + def dup_name(self) -> str: + return "Route" + + class FakeModel: + def __init__(self, items: list[object]) -> None: + self.items = items + + def get_n_items(self) -> int: + return len(self.items) + + def get_item(self, index: int) -> object: + return self.items[index] + + class FakeDevice: + def __init__(self) -> None: + self.params = FakeModel([FakeParam("EnumRoute"), FakeParam("Route")]) + self.param_infos = FakeModel([FakeParamInfo()]) + self.enum_calls: list[tuple[int, int, int]] = [] + + def enum_params(self, param_id: int, start: int, num: int) -> int: + self.enum_calls.append((param_id, start, num)) + return 12 + + def get_params(self) -> FakeModel: + return self.params + + def get_param_infos(self) -> FakeModel: + return self.param_infos + + class FakeRouteInfo: + def get_index(self) -> int: + return 1 + + def get_device(self) -> int: + return 8 + + def get_profile(self) -> int: + return 0 + + def get_priority(self) -> int: + return 200 + + def dup_direction(self) -> str: + return "output" + + def dup_name(self) -> str: + return "analog-output-headphones" + + def dup_description(self) -> str: + return "Headphones" + + def dup_availability(self) -> str: + return "yes" + + def get_info(self) -> dict[str, str]: + return {} + + created_from: list[str] = [] + + def new_from_param(param: FakeParam) -> FakeRouteInfo: + created_from.append(param.name) + return FakeRouteInfo() + + backend = pw_backend.PipeWireBackend() + backend._Pwg = SimpleNamespace(RouteInfo=SimpleNamespace(new_from_param=new_from_param)) + monkeypatch.setattr(backend, "_device_name_by_bound_id", lambda _bound_id: "alsa_card.test") + + device = FakeDevice() + routes = backend._enumerate_device_routes(device, 72) + + assert device.enum_calls == [(13, 0, 0)] + assert created_from == ["Route"] + assert [route.name for route in routes] == ["analog-output-headphones"] + assert backend._device_route_refreshing_bound_ids == set() + + +def test_connect_device_route_changed_subscribes_to_route_param(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeParam: + def __init__(self, param_id: int) -> None: + self.param_id = param_id + + def get_id(self) -> int: + return self.param_id + + class FakeParamInfo: + def get_id(self) -> int: + return 13 + + def dup_name(self) -> str: + return "Route" + + class FakeModel: + def __init__(self, items: list[object]) -> None: + self.items = items + + def get_n_items(self) -> int: + return len(self.items) + + def get_item(self, index: int) -> object: + return self.items[index] + + class FakeDevice: + def __init__(self) -> None: + self.param_infos = FakeModel([FakeParamInfo()]) + self.subscriptions: list[FakeVariant] = [] + self.disconnected: list[int] = [] + self.param_callback = None + + def get_param_infos(self) -> FakeModel: + return self.param_infos + + def subscribe_params(self, ids: FakeVariant) -> None: + self.subscriptions.append(ids) + + def disconnect(self, handler_id: int) -> None: + self.disconnected.append(handler_id) + + def emit_param(self, param_id: int) -> None: + assert self.param_callback is not None + self.param_callback(self, FakeParam(param_id)) + + class FakeGObjectObject: + @staticmethod + def connect(device: FakeDevice, signal_name: str, callback) -> int: + assert signal_name == "param" + device.param_callback = callback + return 77 + + backend = pw_backend.PipeWireBackend() + device = FakeDevice() + backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi) + backend._GLib = FakeGLib + backend._GObject = SimpleNamespace(Object=FakeGObjectObject) + backend._ensure_connected = lambda: None + monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: device) + calls: list[str] = [] + + handler_id = backend.connect_device_route_changed(72, lambda: calls.append("route")) + + assert handler_id == 77 + assert [(variant.signature, variant.value) for variant in device.subscriptions] == [("au", [13])] + + device.emit_param(12) + assert calls == [] + + device.emit_param(13) + assert calls == ["route"] + + backend._device_route_refreshing_bound_ids.add(72) + device.emit_param(13) + assert calls == ["route"] + + backend.disconnect_device_handler(handler_id) + assert [(variant.signature, variant.value) for variant in device.subscriptions] == [("au", [13]), ("au", [])] + assert device.disconnected == [77] + + def test_move_named_output_stream_to_target_uses_matching_stream() -> None: backend = pw_backend.PipeWireBackend() stream = pw_backend.PipeWireNode( diff --git a/tests/test_mini_eq_pipewire_routes.py b/tests/test_mini_eq_pipewire_routes.py new file mode 100644 index 0000000..bb77706 --- /dev/null +++ b/tests/test_mini_eq_pipewire_routes.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from tests._mini_eq_imports import pipewire_routes as pw_routes + + +def test_output_route_preset_key_encodes_device_and_route_names() -> None: + assert ( + pw_routes.build_output_route_preset_key("alsa_card.pci 0000:00:1f.3", "analog-output-headphones", 8) + == "pipewire-route:v1:device=alsa_card.pci%200000%3A00%3A1f.3;route=analog-output-headphones;route-device=8" + ) + assert pw_routes.build_output_route_preset_key("", "analog-output-headphones", 8) is None + assert pw_routes.build_output_route_preset_key("alsa_card.test", None, 8) is None + + +def test_output_preset_target_uses_route_key_when_present() -> None: + route = pw_routes.PipeWireOutputRoute( + device_bound_id=72, + device_name="alsa_card.test", + index=1, + route_device=8, + profile=0, + priority=200, + direction="Output", + name="analog-output-headphones", + description="Headphones", + availability="yes", + ) + route_key = route.output_preset_key + + target = pw_routes.PipeWireOutputPresetTarget("alsa_output.test", route, (route_key, "alsa_output.test")) + + assert route_key == "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8" + assert target.link_key == route_key + assert target.has_route_key is True + + +def test_output_preset_target_falls_back_to_output_key_without_route() -> None: + target = pw_routes.PipeWireOutputPresetTarget("alsa_output.test", None, ("alsa_output.test",)) + + assert target.link_key == "alsa_output.test" + assert target.has_route_key is False diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py index 7b55272..bd49346 100644 --- a/tests/test_mini_eq_routing.py +++ b/tests/test_mini_eq_routing.py @@ -4,6 +4,7 @@ from tests._mini_eq_imports import core, routing from tests._mini_eq_imports import pipewire_backend as pw_backend +from tests._mini_eq_imports import pipewire_routes as pw_routes def make_node( @@ -11,6 +12,7 @@ def make_node( name: str | None, media_class: str = pw_backend.AUDIO_SINK, properties: dict[str, str] | None = None, + device_id: int = 0, ) -> pw_backend.PipeWireNode: return pw_backend.PipeWireNode( bound_id=bound_id, @@ -20,6 +22,7 @@ def make_node( node_description=None, application_name=None, node_dont_move=False, + device_id=device_id, properties=properties or {}, ) @@ -81,6 +84,32 @@ def test_get_sink_uses_wireplumber_node_name() -> None: assert routing.SystemWideEqController.get_sink(controller, "missing") is None +def test_output_preset_target_is_cached_until_output_changes() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + calls: list[str | None] = [] + + class FakeBackend(FakeOutputBackend): + def output_preset_target_for_sink_name(self, sink_name: str | None) -> pw_routes.PipeWireOutputPresetTarget: + calls.append(sink_name) + return pw_routes.PipeWireOutputPresetTarget(sink_name, None, (sink_name,) if sink_name else ()) + + controller.output_backend = FakeBackend([make_node(1, "speakers"), make_node(2, "hdmi")]) + controller.output_sink = "speakers" + + assert routing.SystemWideEqController.output_preset_target(controller).keys == ("speakers",) + assert routing.SystemWideEqController.output_preset_keys(controller) == ("speakers",) + assert routing.SystemWideEqController.output_preset_link_key(controller) == "speakers" + assert calls == ["speakers"] + + controller.output_sink = "hdmi" + assert routing.SystemWideEqController.output_preset_target(controller).keys == ("hdmi",) + assert calls == ["speakers", "hdmi"] + + routing.SystemWideEqController.invalidate_output_preset_target(controller) + assert routing.SystemWideEqController.output_preset_target(controller).keys == ("hdmi",) + assert calls == ["speakers", "hdmi", "hdmi"] + + def test_get_default_output_sink_name_uses_cached_metadata_by_default() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) backend = FakeDefaultOutputBackend( @@ -203,6 +232,97 @@ def test_output_object_added_schedules_refresh_only_for_audio_sinks(monkeypatch: assert len(scheduled_callbacks) == 1 +def test_output_event_idle_invalidates_output_preset_target_cache() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + controller.accept_output_events = True + controller.output_event_source_id = 123 + controller._output_preset_target_sink = "speakers" + controller._output_preset_target = pw_routes.PipeWireOutputPresetTarget("speakers", None, ("speakers",)) + calls: list[str] = [] + controller.refresh_followed_output_sink = lambda: calls.append("refresh") + controller.outputs_changed_callback = lambda: calls.append("outputs") + + assert routing.SystemWideEqController.on_output_event_idle(controller) is False + + assert controller.output_event_source_id == 0 + assert controller._output_preset_target_sink is None + assert controller._output_preset_target is None + assert calls == ["refresh", "outputs"] + + +def test_output_route_param_change_schedules_output_refresh(monkeypatch: pytest.MonkeyPatch) -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + sink = make_node(1, "speakers", device_id=72) + route_callback = None + calls: list[object] = [] + + class FakeBackend(FakeOutputBackend): + def connect_device_route_changed(self, device_id: int, callback) -> int: + nonlocal route_callback + calls.append(("connect-route", device_id)) + route_callback = callback + return 77 + + def disconnect_device_handler(self, handler_id: int) -> None: + calls.append(("disconnect-route", handler_id)) + + controller.output_backend = FakeBackend([sink]) + controller.output_sink = "speakers" + controller.accept_output_events = True + controller.output_event_source_id = 0 + controller.output_route_param_handler_id = 0 + controller.output_route_param_device_id = 0 + scheduled_callbacks: list[object] = [] + monkeypatch.setattr( + routing.GLib, + "idle_add", + lambda callback: scheduled_callbacks.append(callback) or 321, + ) + + routing.SystemWideEqController.refresh_output_route_param_monitor(controller) + + assert controller.output_route_param_handler_id == 77 + assert controller.output_route_param_device_id == 72 + assert calls == [("connect-route", 72)] + + assert route_callback is not None + route_callback() + + assert controller.output_event_source_id == 321 + assert len(scheduled_callbacks) == 1 + + +def test_output_route_param_monitor_moves_with_active_output() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + calls: list[object] = [] + + class FakeBackend(FakeOutputBackend): + def connect_device_route_changed(self, device_id: int, _callback) -> int: + calls.append(("connect-route", device_id)) + return device_id + 1000 + + def disconnect_device_handler(self, handler_id: int) -> None: + calls.append(("disconnect-route", handler_id)) + + controller.output_backend = FakeBackend( + [ + make_node(1, "speakers", device_id=72), + make_node(2, "hdmi", device_id=84), + ] + ) + controller.accept_output_events = True + controller.output_sink = "speakers" + controller.output_route_param_handler_id = 1072 + controller.output_route_param_device_id = 72 + + controller.output_sink = "hdmi" + routing.SystemWideEqController.refresh_output_route_param_monitor(controller) + + assert controller.output_route_param_handler_id == 1084 + assert controller.output_route_param_device_id == 84 + assert calls == [("disconnect-route", 1072), ("connect-route", 84)] + + def test_follow_system_default_output_enables_follow_mode_and_refreshes() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.follow_default_output = False @@ -220,6 +340,28 @@ def fake_refresh() -> bool: assert calls == ["refresh"] +def test_follow_system_default_output_schedules_refresh_when_output_changes(monkeypatch: pytest.MonkeyPatch) -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + controller.follow_default_output = False + controller.output_sink = "speakers" + controller.accept_output_events = True + controller.output_event_source_id = 0 + scheduled_callbacks: list[object] = [] + + def fake_refresh() -> bool: + controller.output_sink = "hdmi" + return True + + controller.refresh_followed_output_sink = fake_refresh + monkeypatch.setattr(routing.GLib, "idle_add", lambda callback: scheduled_callbacks.append(callback) or 321) + + routing.SystemWideEqController.follow_system_default_output(controller) + + assert controller.follow_default_output is True + assert controller.output_event_source_id == 321 + assert len(scheduled_callbacks) == 1 + + def test_switch_output_sink_retargets_running_filter_output_without_restart() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) calls: list[object] = [] @@ -258,6 +400,36 @@ def set_output_sink_name(self, sink_name: str) -> None: ] +def test_explicit_output_change_schedules_coalesced_output_refresh(monkeypatch: pytest.MonkeyPatch) -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + scheduled_callbacks: list[object] = [] + + class FakeBackend(FakeOutputBackend): + def move_named_output_stream_to_target(self, _stream_node_name: str, _target_node_name: str) -> None: + return + + controller.output_backend = FakeBackend([make_node(1, "speakers"), make_node(2, "hdmi")]) + controller.output_sink = "speakers" + controller.follow_default_output = True + controller.accept_output_events = True + controller.output_event_source_id = 0 + controller.running = True + controller.filter_node_id = 42 + controller.filter_output_name = "mini_eq_sink_output" + controller.virtual_sink_name = "mini_eq_sink" + controller.stream_router = None + controller.output_analyzer = None + controller.apply_state_to_engine = lambda: None + controller.emit_status = lambda _message: None + monkeypatch.setattr(routing.GLib, "idle_add", lambda callback: scheduled_callbacks.append(callback) or 321) + + routing.SystemWideEqController.switch_output_sink(controller, "hdmi", explicit=True) + + assert controller.output_sink == "hdmi" + assert controller.output_event_source_id == 321 + assert len(scheduled_callbacks) == 1 + + def test_switch_output_sink_falls_back_to_restart_when_filter_retarget_fails() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) calls: list[object] = [] diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py index 3db8cc1..3b96969 100644 --- a/tests/test_mini_eq_window.py +++ b/tests/test_mini_eq_window.py @@ -44,6 +44,22 @@ def remove_css_class(self, css_class: str) -> None: self.css_classes.discard(css_class) +class FakeFile: + def __init__(self, path: str) -> None: + self.path = path + + def get_path(self) -> str: + return self.path + + +class FakeOpenDialog: + def __init__(self, path: str) -> None: + self.path = path + + def open_finish(self, _result: object) -> FakeFile: + return FakeFile(self.path) + + def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: fake_window.sync_control_switches_from_controller = MethodType( window.MiniEqWindow.sync_control_switches_from_controller, @@ -407,6 +423,47 @@ def route_system_audio(enabled: bool) -> None: ] +def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) -> None: + calls: list[object] = [] + statuses: list[str] = [] + apo_path = tmp_path / "HD 650.txt" + apo_path.write_text("Preamp: 0 dB\n", encoding="utf-8") + + fake_window = SimpleNamespace( + controller=SimpleNamespace( + import_apo_preset=lambda path: calls.append(("import", path)) or 7, + state_signature=lambda: "imported-signature", + build_preset_payload=lambda label: {"name": label}, + ), + selected_band_index=0, + current_preset_name="Old", + saved_preset_signature="old-signature", + output_preset_curve_auto_loaded=True, + set_visible_band_count=lambda count: calls.append(("visible-bands", count)), + set_curve_revert_baseline=lambda label: calls.append(("baseline", label)), + refresh_preset_list=lambda: calls.append("presets"), + sync_ui_from_state=lambda: calls.append("sync"), + set_status=lambda message: statuses.append(message), + notify_control_state_changed=lambda: calls.append("notify"), + ) + + window.MiniEqWindow.on_import_apo_done(fake_window, FakeOpenDialog(str(apo_path)), None) + + assert fake_window.selected_band_index is None + 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 calls == [ + ("import", str(apo_path)), + ("visible-bands", 7), + ("baseline", "Imported APO: HD 650"), + "presets", + "sync", + "notify", + ] + + def test_on_bypass_changed_resets_switch_when_engine_update_fails() -> None: calls: list[object] = [] bypass_switch = FakeSwitch(False) diff --git a/tools/check_flatpak_runtime.py b/tools/check_flatpak_runtime.py index 88da618..9ee89d0 100644 --- a/tools/check_flatpak_runtime.py +++ b/tools/check_flatpak_runtime.py @@ -384,7 +384,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--timeout", type=float, - default=8.0, + default=20.0, help="Timeout in seconds for each PipeWire state transition.", ) parser.add_argument( diff --git a/tools/check_live_ui_runtime.py b/tools/check_live_ui_runtime.py index 2ff4443..63dfc90 100755 --- a/tools/check_live_ui_runtime.py +++ b/tools/check_live_ui_runtime.py @@ -28,6 +28,8 @@ ANALYZER_NODE_NAME = "mini-eq-analyzer" PRIMARY_SINK_NAME = "ci_null_sink" ALT_SINK_NAME = "ci_alt_sink" +EXPORT_CURRENT_CURVE_ACTION = "Export Current Curve\u2026" +IMPORT_PRESET_ACTION = "Import Preset\u2026" DEFAULT_AUDIO_SINK_KEY = "default.audio.sink" DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink" DEFAULT_METADATA_VALUE_TYPE = "Spa:String:JSON" @@ -241,8 +243,21 @@ def verify_pipewire_gobject_probe(timeout_seconds: float) -> None: raise RuntimeError(f"pipewire-gobject probe did not see sink {sink_name}") if not sink.object_serial: raise RuntimeError(f"pipewire-gobject probe saw {sink_name} without object.serial") - - defaults = backend.refresh_defaults() + target = backend.output_preset_target_for_sink_name(sink_name) + if target.has_route_key: + raise RuntimeError(f"pipewire-gobject probe unexpectedly found a route key for {sink_name}") + if target.keys != (sink_name,): + raise RuntimeError(f"pipewire-gobject probe built unexpected output preset keys: {target.keys!r}") + + defaults = wait_for( + "pipewire-gobject configured default sink", + lambda: ( + defaults + if (defaults := backend.refresh_defaults()).configured_audio_sink == PRIMARY_SINK_NAME + else None + ), + timeout_seconds, + ) if defaults.configured_audio_sink != PRIMARY_SINK_NAME: raise RuntimeError( f"pipewire-gobject probe read unexpected configured default sink: {defaults.configured_audio_sink!r}" @@ -478,6 +493,24 @@ def find_list_item_with_descendant(root, pyatspi, *, descendant_name: str, showi return None +def find_accessible_with_descendant( + root, + pyatspi, + *, + descendant_name: str, + role: str | None = None, + showing: bool | None = None, +): + for node in iter_accessibles(root): + if role is not None and accessible_role(node) != role: + continue + if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing: + continue + if has_descendant_name(node, descendant_name): + return node + return None + + def snapshot_frames(root, pyatspi) -> list[tuple[str, str, bool]]: rows = [] for node in iter_accessibles(root): @@ -573,6 +606,22 @@ def find_list_item_with_descendant(self, root, *, descendant_name: str, showing: showing=showing, ) + def find_with_descendant( + self, + root, + *, + descendant_name: str, + role: str | None = None, + showing: bool | None = None, + ): + return find_accessible_with_descendant( + root, + self.pyatspi, + descendant_name=descendant_name, + role=role, + showing=showing, + ) + def visible_switch_with_state(self, root, *, name: str, expected_checked: bool): node = self.find(root, name=name, role="switch", showing=True) if node is None or self.checked(node) != expected_checked: @@ -603,10 +652,44 @@ def run_action(self, node, action_names: tuple[str, ...]) -> None: ) def activate(self, node) -> None: + self.run_action_or_ancestor(node, ("press", "click", "activate", "toggle")) + + def run_action_or_ancestor(self, node, action_names: tuple[str, ...]) -> None: + exposed_action_names: list[tuple[str, list[str]]] = [] + current = node + for _depth in range(8): + try: + action = current.queryAction() + except Exception: + action = None + + if action is not None: + current_actions = [] + for index in range(action.nActions): + name = action.getName(index) + current_actions.append(name) + if name in action_names: + if not action.doAction(index): + raise AssertionError(f"AT-SPI {name!r} action failed for {accessible_name(current)!r}") + return + exposed_action_names.append((accessible_name(current), current_actions)) + + try: + parent = current.parent + except Exception: + parent = None + if parent is None or parent is current: + break + current = parent + + # Prefer AT-SPI Action. This fallback is only for GTK children that + # expose visible text while their clickable ancestor exposes no action. try: - self.run_action(node, ("press", "click", "activate", "toggle")) - except AssertionError: self.click(node) + except AssertionError as exc: + raise AssertionError( + f"{accessible_name(node)!r} does not expose an activatable AT-SPI action: {exposed_action_names!r}" + ) from exc def toggle_switch(self, node) -> None: self.run_action(node, ("toggle",)) @@ -820,6 +903,75 @@ def verify_dropdown_exposes_options( ) +def find_preset_action(driver: UiDriver, name: str): + return driver.find(driver.desktop(), name=name, showing=True) or driver.find_with_descendant( + driver.desktop(), + descendant_name=name, + role="push button", + showing=True, + ) + + +def more_preset_actions_button(driver: UiDriver, frame, timeout_seconds: float): + return driver.wait_for_accessible( + "More Preset Actions button", + lambda: driver.find_with_roles( + frame, + name="More Preset Actions", + roles={"push button", "toggle button"}, + showing=True, + ), + min(timeout_seconds, 5.0), + ) + + +def verify_preset_menu_neutral_state(driver: UiDriver, frame, timeout_seconds: float) -> None: + menu_timeout = min(timeout_seconds, 5.0) + more_button = more_preset_actions_button(driver, frame, timeout_seconds) + + driver.activate(more_button) + try: + export_button = driver.wait_for_accessible( + "Export Current Curve preset action", + lambda: find_preset_action(driver, EXPORT_CURRENT_CURVE_ACTION), + menu_timeout, + ) + import_button = driver.wait_for_accessible( + "Import Preset preset action", + lambda: find_preset_action(driver, IMPORT_PRESET_ACTION), + menu_timeout, + ) + if not driver.sensitive(export_button): + raise AssertionError("Export Current Curve action should be sensitive") + if not driver.sensitive(import_button): + raise AssertionError("Import Preset action should be sensitive") + if find_preset_action(driver, "Reset to Neutral") is not None: + raise AssertionError("Reset to Neutral should be hidden while the curve is already neutral") + finally: + driver.activate(more_button) + + driver.wait_for_accessible( + "More Preset Actions popover to close", + lambda: find_preset_action(driver, EXPORT_CURRENT_CURVE_ACTION) is None, + menu_timeout, + ) + + +def reset_curve_through_preset_menu(driver: UiDriver, frame, timeout_seconds: float) -> None: + menu_timeout = min(timeout_seconds, 5.0) + more_button = more_preset_actions_button(driver, frame, timeout_seconds) + + driver.activate(more_button) + reset_button = driver.wait_for_accessible( + "Reset to Neutral preset action", + lambda: find_preset_action(driver, "Reset to Neutral"), + menu_timeout, + ) + if not driver.sensitive(reset_button): + raise AssertionError("Reset to Neutral action should be sensitive after editing the curve") + driver.activate(reset_button) + + def run_ui_flow( *, pyatspi, @@ -889,6 +1041,24 @@ def run_ui_flow( required_options=("Notch", "Bell"), timeout_seconds=timeout_seconds, ) + verify_dropdown_exposes_options( + driver, + frame, + combo_name="EQ output", + required_options=("Follow system output (CI Null Sink)", "CI Null Sink", "CI Alt Sink"), + timeout_seconds=timeout_seconds, + ) + driver.wait_for_accessible( + "Output-wide auto preset scope", + lambda: driver.find_with_roles( + frame, + name="Output-wide", + roles={"label", "status bar", "text"}, + showing=True, + ), + timeout_seconds, + ) + verify_preset_menu_neutral_state(driver, frame, timeout_seconds) route_switch = driver.wait_for_accessible( "System-wide EQ switch to start active", @@ -985,10 +1155,10 @@ def run_ui_flow( timeout_seconds, ) - driver.set_numeric_value(gain_spin, 0.0) + reset_curve_through_preset_menu(driver, frame, timeout_seconds) driver.wait_for_accessible( - "Modified preset state to clear after returning band gain to neutral", - lambda: not driver.status_is_visible(frame, "Modified"), + "Neutral preset state after resetting from preset menu", + lambda: driver.status_is_visible(frame, "Neutral"), timeout_seconds, ) @@ -1028,7 +1198,8 @@ def run_ui_flow( print( "Live UI runtime smoke passed: AT-SPI UI flow, dropdown options, " - "pipewire-gobject probe, synthetic stream routing, default-output follow, monitor, and shutdown verified." + "pipewire-gobject probe, output preset scope, preset menu reset, synthetic stream routing, " + "default-output follow, monitor, and shutdown verified." ) finally: stop_accessible_event_loop(pyatspi, event_thread) diff --git a/tools/check_pipewire_gobject.py b/tools/check_pipewire_gobject.py index 02a53d3..0b95b22 100644 --- a/tools/check_pipewire_gobject.py +++ b/tools/check_pipewire_gobject.py @@ -9,6 +9,9 @@ "Core.new", "Core.load_module", "Core.set_pipewire_property", + "Device.enum_all_params", + "Device.enum_params", + "Device.new", "Global.dup_property", "get_library_version", "Metadata.new", @@ -18,6 +21,7 @@ "Param.new_props_controls", "Registry.new", "Registry.dup_globals_by_interface", + "RouteInfo.new_from_param", "Stream.new_audio_capture", "Stream.set_deliver_audio_blocks", "Stream.set_pipewire_property",