From 55d443ecdbd60de9cfc57f80837849ab07ad1463 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 29 Jul 2026 16:55:29 +0200 Subject: [PATCH] feat(hyprdrive,hyprlua): add a Cards tab, move profile switching off device cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fifth mixer tab, Cards: one row per pactl card (onboard audio, USB headsets, HDMI, Bluetooth) with just its profile dropdown (Analog Stereo Duplex, Pro Audio, Off, etc.). Profile is a card-level setting, not a per-device one — a single card can back a device in both Output and Input Devices at once, and switching its profile can make either side appear or disappear entirely. It used to live on individual DeviceCards in both tabs, which was confusing (same card, edited from two places) and is exactly what pavucontrol avoids by giving profiles their own "Configuration" tab. Pulled it out of DeviceCard/ OutputTab/InputTab (which also drops their now-unneeded list_cards() fetch) and into a new ui/profile_card.py + ui/cards_tab.py. hyprlua's audio-panel picks this up via regen-audio-panel.sh, unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDG --- .../hyprdrive/supersonic-booster/main.py | 6 +- .../supersonic-booster/ui/cards_tab.py | 36 ++++++++ .../supersonic-booster/ui/device_card.py | 78 +++------------- .../supersonic-booster/ui/input_tab.py | 16 ++-- .../supersonic-booster/ui/output_tab.py | 18 ++-- .../supersonic-booster/ui/profile_card.py | 89 +++++++++++++++++++ .../hyprdrive/supersonic-booster/window.py | 6 +- desktopenvs/hyprlua/audio-panel/main.py | 6 +- .../hyprlua/audio-panel/ui/cards_tab.py | 36 ++++++++ .../hyprlua/audio-panel/ui/device_card.py | 78 +++------------- .../hyprlua/audio-panel/ui/input_tab.py | 16 ++-- .../hyprlua/audio-panel/ui/output_tab.py | 18 ++-- .../hyprlua/audio-panel/ui/profile_card.py | 89 +++++++++++++++++++ desktopenvs/hyprlua/audio-panel/window.py | 6 +- 14 files changed, 314 insertions(+), 184 deletions(-) create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py create mode 100644 desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/cards_tab.py create mode 100644 desktopenvs/hyprlua/audio-panel/ui/profile_card.py diff --git a/desktopenvs/hyprdrive/supersonic-booster/main.py b/desktopenvs/hyprdrive/supersonic-booster/main.py index 19d24ae..0133510 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/main.py +++ b/desktopenvs/hyprdrive/supersonic-booster/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""supersonic-booster — PipeWire mixer panel for hyprdrive. Four tabs: -Applications / Output Devices / Input Devices / General Settings, each a -horizontally-scrolling row of cards (see window.py, ui/*_tab.py). +"""supersonic-booster — PipeWire mixer panel for hyprdrive. Five tabs: +Applications / Input Devices / Output Devices / Cards / General Settings, +each a horizontally-scrolling row of cards (see window.py, ui/*_tab.py). Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's main.py: the first launch builds the (hidden) window and holds; later diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py new file mode 100644 index 0000000..7370609 --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/cards_tab.py @@ -0,0 +1,36 @@ +"""Cards tab: horizontally-scrolling row of every sound card (pactl cards) — +onboard audio, USB headsets, HDMI, Bluetooth devices — each with just its +profile dropdown (pro-audio vs stereo duplex etc.). See ui/profile_card.py +for why this is its own tab rather than living on individual device cards.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.profile_card import ProfileCard +from ui.scroll_row import ScrollRow + + +class CardsTab(Gtk.Box): + def __init__(self, backend) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._row = ScrollRow(self._make_card, empty_text="No sound cards found") + self.append(self._row) + + def _make_card(self, item: dict) -> ProfileCard: + return ProfileCard(self._backend, item) + + def refresh(self) -> None: + self._backend.list_cards(self._row.sync) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py index dbf3f78..3a4c990 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/device_card.py @@ -1,10 +1,12 @@ """DeviceCard — one card in the Output Devices / Input Devices tabs: MeterCard's -usual icon+name/peak/meter/mute, plus a profile dropdown (pro-audio vs the -plain stereo duplex profile, etc. — whatever the owning card advertises via -`pactl list cards`) and a "Set Default" button. Shared between both tabs via -the `kind` parameter ("sink" for outputs, "source" for inputs) — the only -difference between an output and an input device, as far as pactl's verbs go, -is which noun you say. +usual icon+name/peak/meter/mute, plus a "Set Default" button. Shared between +both tabs via the `kind` parameter ("sink" for outputs, "source" for inputs) — +the only difference between an output and an input device, as far as pactl's +verbs go, is which noun you say. + +Profile switching used to live here too, but that's a *card*-level setting +(one physical card can back a device in both this tab and the other one at +once), not a per-device one — see ui/profile_card.py and the Cards tab. """ from __future__ import annotations @@ -21,80 +23,22 @@ _MONITOR_SUFFIX = ".monitor" class DeviceCard(MeterCard): def __init__(self, backend, peaks, kind: str, item: dict, - cards_by_id: dict[int, dict], default_name: str, - set_default) -> None: - self._cards_by_id = cards_by_id + default_name: str, set_default) -> None: self._default_name = default_name self._set_default = set_default - self._profile_names: list[str] = [] - self._profile_guard = False super().__init__(backend, peaks, kind, item) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - - profile_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - profile_label = Gtk.Label(label="Profile", xalign=0.0) - profile_label.add_css_class("sb-field-label") - profile_col.append(profile_label) - self._profile_dropdown = Gtk.DropDown() - self._profile_dropdown.add_css_class("sb-device-dropdown") - self._profile_dropdown.connect("notify::selected", self._on_profile_selected) - profile_col.append(self._profile_dropdown) - box.append(profile_col) - self._default_btn = Gtk.ToggleButton(label="Set Default") self._default_btn.add_css_class("sb-default-btn") self._default_btn.connect("toggled", self._on_default_toggled) - box.append(self._default_btn) - - self._sync_profile(item, self._cards_by_id) self._sync_default(item, self._default_name) - return box + return self._default_btn - def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: - self._cards_by_id = cards_by_id + def _update_extra(self, item: dict, default_name: str) -> None: self._default_name = default_name - self._sync_profile(item, cards_by_id) self._sync_default(item, default_name) - # -- profile dropdown ----------------------------------------------------------- - def _owning_card(self, item: dict, cards_by_id: dict[int, dict]) -> dict | None: - card_id = item.get("card") - return cards_by_id.get(card_id) if card_id is not None else None - - def _sync_profile(self, item: dict, cards_by_id: dict[int, dict]) -> None: - card = self._owning_card(item, cards_by_id) - profiles = card.get("profiles") if card else [] - if not profiles: - self._profile_dropdown.set_sensitive(False) - return - self._profile_dropdown.set_sensitive(True) - names = [p["name"] for p in profiles] - if names != self._profile_names: - self._profile_names = names - self._profile_guard = True - self._profile_dropdown.set_model( - Gtk.StringList.new([p["description"] for p in profiles])) - self._profile_guard = False - active = card.get("active_profile") if card else None - idx = names.index(active) if active in names else None - if idx is not None and self._profile_dropdown.get_selected() != idx: - self._profile_guard = True - self._profile_dropdown.set_selected(idx) - self._profile_guard = False - - def _on_profile_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._profile_guard: - return - card = self._owning_card(self._item, self._cards_by_id) - if card is None: - return - idx = dropdown.get_selected() - if 0 <= idx < len(self._profile_names): - self._backend.set_card_profile(card["id"], self._profile_names[idx]) - - # -- default device --------------------------------------------------------------- def _sync_default(self, item: dict, default_name: str) -> None: is_default = item.get("name") == default_name self._default_btn.handler_block_by_func(self._on_default_toggled) diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py index abbd9d1..26dd6df 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/input_tab.py @@ -1,6 +1,6 @@ """Input Devices tab: same shape as Output Devices (see output_tab.py) but for recording devices (pactl sources) — mic gain meter/L-R pair, mute, live peak -bar, profile dropdown, "Set Default".""" +bar, "Set Default". Profile switching lives on the Cards tab instead.""" from __future__ import annotations @@ -23,21 +23,17 @@ class InputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No input devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "source", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "source", item, default_name, self._backend.set_default_source) def refresh(self) -> None: def got_defaults(_sink_name: str, source_name: str) -> None: self._default_name = source_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sources(sources: list[dict]) -> None: - self._row.sync(sources, cards_by_id, self._default_name) - self._backend.list_sources(got_sources) - self._backend.list_cards(got_cards) + def got_sources(sources: list[dict]) -> None: + self._row.sync(sources, self._default_name) + self._backend.list_sources(got_sources) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py index 330ae63..dae52d6 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/output_tab.py @@ -1,7 +1,7 @@ """Output Devices tab: horizontally-scrolling row of every playback device (pactl sinks), each with its own volume meter/L-R pair, mute, live peak bar, -profile dropdown (pro-audio vs stereo duplex etc.) and a "Set Default" button. -""" +and a "Set Default" button. Profile switching lives on the Cards tab instead +(see ui/profile_card.py for why).""" from __future__ import annotations @@ -24,21 +24,17 @@ class OutputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No output devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "sink", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "sink", item, default_name, self._backend.set_default_sink) def refresh(self) -> None: def got_defaults(sink_name: str, _source_name: str) -> None: self._default_name = sink_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sinks(sinks: list[dict]) -> None: - self._row.sync(sinks, cards_by_id, self._default_name) - self._backend.list_sinks(got_sinks) - self._backend.list_cards(got_cards) + def got_sinks(sinks: list[dict]) -> None: + self._row.sync(sinks, self._default_name) + self._backend.list_sinks(got_sinks) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py b/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py new file mode 100644 index 0000000..fb702dc --- /dev/null +++ b/desktopenvs/hyprdrive/supersonic-booster/ui/profile_card.py @@ -0,0 +1,89 @@ +"""ProfileCard — one card in the Cards tab: a sound *card* (the physical or +virtual device behind one or more sinks/sources — onboard audio, a USB +headset, HDMI, a Bluetooth device) and its profile dropdown. + +Profile is a card-level setting, not a per-device one — pavucontrol puts it +in its own "Configuration" tab for the same reason: a single card can back +multiple sinks/sources at once, and switching its profile can make some of +them appear or disappear entirely (e.g. "Analog Stereo Duplex" exposes both +an output and an input from the same card; "Analog Stereo Output" drops the +input side; "Off" drops both; "Pro Audio" breaks the card into raw individual +ports instead of one grouped stereo pair). Output/Input Devices used to carry +their own copy of this dropdown per-device, which was confusing when one +card backed a device in both tabs — this replaces that.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ProfileCard(Gtk.Box): + def __init__(self, backend, item: dict) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("sb-card") + self._backend = backend + self._id = item["id"] + self._profile_names: list[str] = [] + self._guard = False + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + head.add_css_class("sb-card-head") + icon = Gtk.Image.new_from_icon_name("audio-card") + icon.set_pixel_size(28) + head.append(icon) + self._name_lbl = Gtk.Label(xalign=0.0) + self._name_lbl.add_css_class("sb-card-title") + self._name_lbl.set_ellipsize(3) # Pango.EllipsizeMode.END + self._name_lbl.set_max_width_chars(20) + head.append(self._name_lbl) + self.append(head) + + label = Gtk.Label(label="Profile", xalign=0.0) + label.add_css_class("sb-field-label") + self.append(label) + self._dropdown = Gtk.DropDown() + self._dropdown.add_css_class("sb-device-dropdown") + self._dropdown.connect("notify::selected", self._on_selected) + self.append(self._dropdown) + + self.update(item) + + def update(self, item: dict) -> None: + self._item = item + name = item.get("description") or item.get("name") or f"#{item.get('id')}" + self._name_lbl.set_label(name) + self._name_lbl.set_tooltip_text(name) + + profiles = item.get("profiles") or [] + names = [p["name"] for p in profiles] + if names != self._profile_names: + self._profile_names = names + self._guard = True + self._dropdown.set_model(Gtk.StringList.new([p["description"] for p in profiles])) + self._guard = False + self._dropdown.set_sensitive(bool(profiles)) + + active = item.get("active_profile") + idx = names.index(active) if active in names else None + if idx is not None and self._dropdown.get_selected() != idx: + self._guard = True + self._dropdown.set_selected(idx) + self._guard = False + + def _on_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._profile_names): + self._backend.set_card_profile(self._id, self._profile_names[idx]) + + # ScrollRow's diff-sync calls these on every card unconditionally (see + # scroll_row.py) — cards have no live audio to meter, so these are no-ops. + def start_peak(self) -> None: + pass + + def stop_peak(self) -> None: + pass diff --git a/desktopenvs/hyprdrive/supersonic-booster/window.py b/desktopenvs/hyprdrive/supersonic-booster/window.py index 4dac709..e7abbb8 100644 --- a/desktopenvs/hyprdrive/supersonic-booster/window.py +++ b/desktopenvs/hyprdrive/supersonic-booster/window.py @@ -14,7 +14,7 @@ whichever bar/build you're running against. Gtk.Window (layer TOP, anchored TOP+RIGHT, height/width = content) └ Gtk.Overlay - main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + main : .sb-panel (title, tab switcher, Gtk.Stack of the five tabs) over : hologram overlay over : ✕ close button (top-right) @@ -38,6 +38,7 @@ from backend.peaklevel import PeakMonitorManager from backend.pipewire import AudioBackend from lib.hologram import HologramOverlay from ui.applications_tab import ApplicationsTab +from ui.cards_tab import CardsTab from ui.general_tab import GeneralTab from ui.input_tab import InputTab from ui.output_tab import OutputTab @@ -79,18 +80,21 @@ class SupersonicWindow(Gtk.ApplicationWindow): self._applications_tab = ApplicationsTab(self._backend, self._peaks) self._output_tab = OutputTab(self._backend, self._peaks) self._input_tab = InputTab(self._backend, self._peaks) + self._cards_tab = CardsTab(self._backend) self._general_tab = GeneralTab(self._backend, on_hologram_changed=self._on_hologram_setting_changed) self._tabs = { "applications": self._applications_tab, "output": self._output_tab, "input": self._input_tab, + "cards": self._cards_tab, "general": self._general_tab, } tab_order = [ ("applications", "Applications", self._applications_tab), ("input", "Input Devices", self._input_tab), ("output", "Output Devices", self._output_tab), + ("cards", "Cards", self._cards_tab), ("general", "General Settings", self._general_tab), ] for name, label, widget in tab_order: diff --git a/desktopenvs/hyprlua/audio-panel/main.py b/desktopenvs/hyprlua/audio-panel/main.py index 829abfd..5aa3e9a 100644 --- a/desktopenvs/hyprlua/audio-panel/main.py +++ b/desktopenvs/hyprlua/audio-panel/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""audio-panel — PipeWire mixer panel for hyprlua. Four tabs: -Applications / Output Devices / Input Devices / General Settings, each a -horizontally-scrolling row of cards (see window.py, ui/*_tab.py). +"""audio-panel — PipeWire mixer panel for hyprlua. Five tabs: +Applications / Input Devices / Output Devices / Cards / General Settings, +each a horizontally-scrolling row of cards (see window.py, ui/*_tab.py). Single-instance, same pattern as astro-menu/horizon-dock/transmitter-panel's main.py: the first launch builds the (hidden) window and holds; later diff --git a/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py b/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py new file mode 100644 index 0000000..7370609 --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/cards_tab.py @@ -0,0 +1,36 @@ +"""Cards tab: horizontally-scrolling row of every sound card (pactl cards) — +onboard audio, USB headsets, HDMI, Bluetooth devices — each with just its +profile dropdown (pro-audio vs stereo duplex etc.). See ui/profile_card.py +for why this is its own tab rather than living on individual device cards.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from ui.profile_card import ProfileCard +from ui.scroll_row import ScrollRow + + +class CardsTab(Gtk.Box): + def __init__(self, backend) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("sb-tab-page") + self._backend = backend + self._row = ScrollRow(self._make_card, empty_text="No sound cards found") + self.append(self._row) + + def _make_card(self, item: dict) -> ProfileCard: + return ProfileCard(self._backend, item) + + def refresh(self) -> None: + self._backend.list_cards(self._row.sync) + + def activate(self) -> None: + self._row.activate() + self.refresh() + + def deactivate(self) -> None: + self._row.deactivate() diff --git a/desktopenvs/hyprlua/audio-panel/ui/device_card.py b/desktopenvs/hyprlua/audio-panel/ui/device_card.py index dbf3f78..3a4c990 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/device_card.py +++ b/desktopenvs/hyprlua/audio-panel/ui/device_card.py @@ -1,10 +1,12 @@ """DeviceCard — one card in the Output Devices / Input Devices tabs: MeterCard's -usual icon+name/peak/meter/mute, plus a profile dropdown (pro-audio vs the -plain stereo duplex profile, etc. — whatever the owning card advertises via -`pactl list cards`) and a "Set Default" button. Shared between both tabs via -the `kind` parameter ("sink" for outputs, "source" for inputs) — the only -difference between an output and an input device, as far as pactl's verbs go, -is which noun you say. +usual icon+name/peak/meter/mute, plus a "Set Default" button. Shared between +both tabs via the `kind` parameter ("sink" for outputs, "source" for inputs) — +the only difference between an output and an input device, as far as pactl's +verbs go, is which noun you say. + +Profile switching used to live here too, but that's a *card*-level setting +(one physical card can back a device in both this tab and the other one at +once), not a per-device one — see ui/profile_card.py and the Cards tab. """ from __future__ import annotations @@ -21,80 +23,22 @@ _MONITOR_SUFFIX = ".monitor" class DeviceCard(MeterCard): def __init__(self, backend, peaks, kind: str, item: dict, - cards_by_id: dict[int, dict], default_name: str, - set_default) -> None: - self._cards_by_id = cards_by_id + default_name: str, set_default) -> None: self._default_name = default_name self._set_default = set_default - self._profile_names: list[str] = [] - self._profile_guard = False super().__init__(backend, peaks, kind, item) def _build_extra(self, item: dict): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - - profile_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - profile_label = Gtk.Label(label="Profile", xalign=0.0) - profile_label.add_css_class("sb-field-label") - profile_col.append(profile_label) - self._profile_dropdown = Gtk.DropDown() - self._profile_dropdown.add_css_class("sb-device-dropdown") - self._profile_dropdown.connect("notify::selected", self._on_profile_selected) - profile_col.append(self._profile_dropdown) - box.append(profile_col) - self._default_btn = Gtk.ToggleButton(label="Set Default") self._default_btn.add_css_class("sb-default-btn") self._default_btn.connect("toggled", self._on_default_toggled) - box.append(self._default_btn) - - self._sync_profile(item, self._cards_by_id) self._sync_default(item, self._default_name) - return box + return self._default_btn - def _update_extra(self, item: dict, cards_by_id: dict[int, dict], default_name: str) -> None: - self._cards_by_id = cards_by_id + def _update_extra(self, item: dict, default_name: str) -> None: self._default_name = default_name - self._sync_profile(item, cards_by_id) self._sync_default(item, default_name) - # -- profile dropdown ----------------------------------------------------------- - def _owning_card(self, item: dict, cards_by_id: dict[int, dict]) -> dict | None: - card_id = item.get("card") - return cards_by_id.get(card_id) if card_id is not None else None - - def _sync_profile(self, item: dict, cards_by_id: dict[int, dict]) -> None: - card = self._owning_card(item, cards_by_id) - profiles = card.get("profiles") if card else [] - if not profiles: - self._profile_dropdown.set_sensitive(False) - return - self._profile_dropdown.set_sensitive(True) - names = [p["name"] for p in profiles] - if names != self._profile_names: - self._profile_names = names - self._profile_guard = True - self._profile_dropdown.set_model( - Gtk.StringList.new([p["description"] for p in profiles])) - self._profile_guard = False - active = card.get("active_profile") if card else None - idx = names.index(active) if active in names else None - if idx is not None and self._profile_dropdown.get_selected() != idx: - self._profile_guard = True - self._profile_dropdown.set_selected(idx) - self._profile_guard = False - - def _on_profile_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: - if self._profile_guard: - return - card = self._owning_card(self._item, self._cards_by_id) - if card is None: - return - idx = dropdown.get_selected() - if 0 <= idx < len(self._profile_names): - self._backend.set_card_profile(card["id"], self._profile_names[idx]) - - # -- default device --------------------------------------------------------------- def _sync_default(self, item: dict, default_name: str) -> None: is_default = item.get("name") == default_name self._default_btn.handler_block_by_func(self._on_default_toggled) diff --git a/desktopenvs/hyprlua/audio-panel/ui/input_tab.py b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py index abbd9d1..26dd6df 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/input_tab.py +++ b/desktopenvs/hyprlua/audio-panel/ui/input_tab.py @@ -1,6 +1,6 @@ """Input Devices tab: same shape as Output Devices (see output_tab.py) but for recording devices (pactl sources) — mic gain meter/L-R pair, mute, live peak -bar, profile dropdown, "Set Default".""" +bar, "Set Default". Profile switching lives on the Cards tab instead.""" from __future__ import annotations @@ -23,21 +23,17 @@ class InputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No input devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "source", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "source", item, default_name, self._backend.set_default_source) def refresh(self) -> None: def got_defaults(_sink_name: str, source_name: str) -> None: self._default_name = source_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sources(sources: list[dict]) -> None: - self._row.sync(sources, cards_by_id, self._default_name) - self._backend.list_sources(got_sources) - self._backend.list_cards(got_cards) + def got_sources(sources: list[dict]) -> None: + self._row.sync(sources, self._default_name) + self._backend.list_sources(got_sources) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprlua/audio-panel/ui/output_tab.py b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py index 330ae63..dae52d6 100644 --- a/desktopenvs/hyprlua/audio-panel/ui/output_tab.py +++ b/desktopenvs/hyprlua/audio-panel/ui/output_tab.py @@ -1,7 +1,7 @@ """Output Devices tab: horizontally-scrolling row of every playback device (pactl sinks), each with its own volume meter/L-R pair, mute, live peak bar, -profile dropdown (pro-audio vs stereo duplex etc.) and a "Set Default" button. -""" +and a "Set Default" button. Profile switching lives on the Cards tab instead +(see ui/profile_card.py for why).""" from __future__ import annotations @@ -24,21 +24,17 @@ class OutputTab(Gtk.Box): self._row = ScrollRow(self._make_card, empty_text="No output devices found") self.append(self._row) - def _make_card(self, item: dict, cards_by_id: dict, default_name: str) -> DeviceCard: - return DeviceCard(self._backend, self._peaks, "sink", item, cards_by_id, + def _make_card(self, item: dict, default_name: str) -> DeviceCard: + return DeviceCard(self._backend, self._peaks, "sink", item, default_name, self._backend.set_default_sink) def refresh(self) -> None: def got_defaults(sink_name: str, _source_name: str) -> None: self._default_name = sink_name - def got_cards(cards: list[dict]) -> None: - cards_by_id = {c["id"]: c for c in cards} - - def got_sinks(sinks: list[dict]) -> None: - self._row.sync(sinks, cards_by_id, self._default_name) - self._backend.list_sinks(got_sinks) - self._backend.list_cards(got_cards) + def got_sinks(sinks: list[dict]) -> None: + self._row.sync(sinks, self._default_name) + self._backend.list_sinks(got_sinks) self._backend.get_defaults(got_defaults) def activate(self) -> None: diff --git a/desktopenvs/hyprlua/audio-panel/ui/profile_card.py b/desktopenvs/hyprlua/audio-panel/ui/profile_card.py new file mode 100644 index 0000000..fb702dc --- /dev/null +++ b/desktopenvs/hyprlua/audio-panel/ui/profile_card.py @@ -0,0 +1,89 @@ +"""ProfileCard — one card in the Cards tab: a sound *card* (the physical or +virtual device behind one or more sinks/sources — onboard audio, a USB +headset, HDMI, a Bluetooth device) and its profile dropdown. + +Profile is a card-level setting, not a per-device one — pavucontrol puts it +in its own "Configuration" tab for the same reason: a single card can back +multiple sinks/sources at once, and switching its profile can make some of +them appear or disappear entirely (e.g. "Analog Stereo Duplex" exposes both +an output and an input from the same card; "Analog Stereo Output" drops the +input side; "Off" drops both; "Pro Audio" breaks the card into raw individual +ports instead of one grouped stereo pair). Output/Input Devices used to carry +their own copy of this dropdown per-device, which was confusing when one +card backed a device in both tabs — this replaces that.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +class ProfileCard(Gtk.Box): + def __init__(self, backend, item: dict) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("sb-card") + self._backend = backend + self._id = item["id"] + self._profile_names: list[str] = [] + self._guard = False + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + head.add_css_class("sb-card-head") + icon = Gtk.Image.new_from_icon_name("audio-card") + icon.set_pixel_size(28) + head.append(icon) + self._name_lbl = Gtk.Label(xalign=0.0) + self._name_lbl.add_css_class("sb-card-title") + self._name_lbl.set_ellipsize(3) # Pango.EllipsizeMode.END + self._name_lbl.set_max_width_chars(20) + head.append(self._name_lbl) + self.append(head) + + label = Gtk.Label(label="Profile", xalign=0.0) + label.add_css_class("sb-field-label") + self.append(label) + self._dropdown = Gtk.DropDown() + self._dropdown.add_css_class("sb-device-dropdown") + self._dropdown.connect("notify::selected", self._on_selected) + self.append(self._dropdown) + + self.update(item) + + def update(self, item: dict) -> None: + self._item = item + name = item.get("description") or item.get("name") or f"#{item.get('id')}" + self._name_lbl.set_label(name) + self._name_lbl.set_tooltip_text(name) + + profiles = item.get("profiles") or [] + names = [p["name"] for p in profiles] + if names != self._profile_names: + self._profile_names = names + self._guard = True + self._dropdown.set_model(Gtk.StringList.new([p["description"] for p in profiles])) + self._guard = False + self._dropdown.set_sensitive(bool(profiles)) + + active = item.get("active_profile") + idx = names.index(active) if active in names else None + if idx is not None and self._dropdown.get_selected() != idx: + self._guard = True + self._dropdown.set_selected(idx) + self._guard = False + + def _on_selected(self, dropdown: Gtk.DropDown, _pspec) -> None: + if self._guard: + return + idx = dropdown.get_selected() + if 0 <= idx < len(self._profile_names): + self._backend.set_card_profile(self._id, self._profile_names[idx]) + + # ScrollRow's diff-sync calls these on every card unconditionally (see + # scroll_row.py) — cards have no live audio to meter, so these are no-ops. + def start_peak(self) -> None: + pass + + def stop_peak(self) -> None: + pass diff --git a/desktopenvs/hyprlua/audio-panel/window.py b/desktopenvs/hyprlua/audio-panel/window.py index 173b39e..be39b6c 100644 --- a/desktopenvs/hyprlua/audio-panel/window.py +++ b/desktopenvs/hyprlua/audio-panel/window.py @@ -14,7 +14,7 @@ whichever bar/build you're running against. Gtk.Window (layer TOP, anchored TOP+RIGHT, height/width = content) └ Gtk.Overlay - main : .sb-panel (title, tab switcher, Gtk.Stack of the four tabs) + main : .sb-panel (title, tab switcher, Gtk.Stack of the five tabs) over : hologram overlay over : ✕ close button (top-right) @@ -38,6 +38,7 @@ from backend.peaklevel import PeakMonitorManager from backend.pipewire import AudioBackend from lib.hologram import HologramOverlay from ui.applications_tab import ApplicationsTab +from ui.cards_tab import CardsTab from ui.general_tab import GeneralTab from ui.input_tab import InputTab from ui.output_tab import OutputTab @@ -79,18 +80,21 @@ class AudioPanelWindow(Gtk.ApplicationWindow): self._applications_tab = ApplicationsTab(self._backend, self._peaks) self._output_tab = OutputTab(self._backend, self._peaks) self._input_tab = InputTab(self._backend, self._peaks) + self._cards_tab = CardsTab(self._backend) self._general_tab = GeneralTab(self._backend, on_hologram_changed=self._on_hologram_setting_changed) self._tabs = { "applications": self._applications_tab, "output": self._output_tab, "input": self._input_tab, + "cards": self._cards_tab, "general": self._general_tab, } tab_order = [ ("applications", "Applications", self._applications_tab), ("input", "Input Devices", self._input_tab), ("output", "Output Devices", self._output_tab), + ("cards", "Cards", self._cards_tab), ("general", "General Settings", self._general_tab), ] for name, label, widget in tab_order: