feat(hyprdrive,hyprlua): add a Cards tab, move profile switching off device cards

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkkH9eiCBfpWysUXAD9bDG
main
Amir Alexander Abdelbaki 2026-07-29 16:55:29 +02:00
parent ed43cd2ac7
commit 55d443ecdb
14 changed files with 314 additions and 184 deletions

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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: