control panel: audio devices, icon font, padding, blur

- audio view: output/input device switching, master volume + mic, and
  per-application streams with their own sliders (pactl sink-inputs)
- icons rendered black because GTK used the desktop font (Noto Sans), which
  has no Nerd Font glyphs; Pango's per-glyph fallback then ignored the css
  colour. Icon classes now name the font explicitly.
- bspwm drew a focus border around the panel that fought the css rounded
  corners: border=off on the rule
- picom now blurs translucent surfaces, so the panel reads as glass rather
  than showing the text behind it
- padding and spacing throughout: frame 20px, section margins, card padding

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt
This commit is contained in:
Sarthak 2026-09-04 08:33:51 +05:30
parent b4c6c7dbd5
commit ddf4b5b8d9
5 changed files with 239 additions and 34 deletions

View file

@ -59,6 +59,7 @@ bspc config pointer_action2 resize_side
bspc config pointer_action3 resize_corner
# Rules
bspc rule -a Control-panel.py state=floating layer=above center=on sticky=on border=off
bspc rule -a feh state=floating
bspc rule -a '*:sun-awt-X11-XWindowPeer' manage=off
bspc rule -a 'vlc' state=floating center=true

View file

@ -68,3 +68,15 @@ fade-exclude = [
animation-exclude = [
"class_g = 'Rofi'",
];
# Translucent surfaces over busy content read as noise without blur.
blur-method = "dual_kawase";
blur-strength = 6;
blur-background = true;
blur-background-frame = true;
blur-background-exclude = [
# the particle layer is transparent on purpose; blurring it would smear
# the wallpaper behind it
"window_type = 'desktop'",
"class_g = 'Particles'",
];

View file

@ -36,7 +36,7 @@ module-margin = 0
modules-left = bspwm
# modules-center = media-window-toggle
modules-center = xwindow
modules-right = cpu sep ram sep gpu sep volume sep time sep control
modules-right = cpu sep ram sep gpu sep volume sep time
font-0 = JetBrainsMono Nerd Font:style=Bold:pixelsize=10;2
font-1 = JetBrainsMono Nerd Font:style=Bold:pixelsize=12;2

View file

@ -17,28 +17,44 @@ gi.require_version("Gdk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa: E402
CSS = b"""
window.panel {
background-color: rgba(15, 13, 23, 0.86);
border: 1px solid #3d3752;
border-radius: 16px;
/* Glyphs come from the Nerd Font. Without an explicit family, GTK uses the
desktop font (Noto Sans), which lacks them, and Pango's per-glyph fallback
picks a font that ignores the css colour - that is why icons rendered black. */
.icon, .tile-icon, .pw, .rowbtn, .title {
font-family: "JetBrainsMono Nerd Font", "Symbols Nerd Font", monospace;
}
window.panel { background-color: transparent; }
.frame {
background-color: rgba(15, 13, 23, 0.82);
border: 1px solid rgba(146, 185, 234, 0.16);
border-radius: 18px;
}
.title { font-weight: bold; font-size: 15px; color: #e9eefb; }
.subtle { color: #7f7796; font-size: 12px; }
.section { color: #92b9ea; font-size: 11px; font-weight: bold; }
.subtle { color: #8d85a4; font-size: 11px; }
.section { color: #92b9ea; font-size: 10px; font-weight: bold; letter-spacing: 1px; }
.tile-icon { font-size: 20px; color: #e9eefb; }
.tile-name { font-size: 12px; color: #e9eefb; }
.tile.active .tile-icon,
.tile.active .tile-name,
.tile.active .subtle { color: #0f0d17; }
.card {
background-color: rgba(34, 28, 51, 0.72);
border-radius: 12px;
padding: 10px;
background-color: rgba(34, 28, 51, 0.66);
border: 1px solid rgba(146, 185, 234, 0.08);
border-radius: 14px;
padding: 12px;
}
.tile {
background-image: none;
background-color: rgba(34, 28, 51, 0.72);
border: 1px solid transparent;
border-radius: 12px;
background-color: rgba(38, 32, 58, 0.78);
border: 1px solid rgba(146, 185, 234, 0.10);
border-radius: 14px;
color: #e9eefb;
padding: 12px 8px;
padding: 14px 6px;
}
.tile:hover { background-color: rgba(58, 51, 80, 0.85); }
.tile:hover { background-color: rgba(58, 51, 80, 0.9); }
.tile.active {
background-color: #92b9ea;
color: #0f0d17;
@ -50,20 +66,24 @@ window.panel {
border: none;
border-radius: 10px;
color: #e9eefb;
padding: 9px 10px;
font-size: 14px;
padding: 10px 12px;
}
.rowbtn:hover { background-color: rgba(146, 185, 234, 0.17); }
.rowbtn:hover { background-color: rgba(146, 185, 234, 0.17); }
.rowbtn.chosen { background-color: rgba(146, 185, 234, 0.22); color: #cfe0f7; }
.pw {
background-image: none;
background-color: rgba(34, 28, 51, 0.72);
border: 1px solid transparent;
border-radius: 10px;
background-color: rgba(38, 32, 58, 0.78);
border: 1px solid rgba(146, 185, 234, 0.10);
border-radius: 12px;
color: #e9eefb;
padding: 8px;
font-size: 15px;
padding: 11px 8px;
}
.pw:hover { background-color: rgba(146, 185, 234, 0.20); }
.pw.danger:hover { background-color: rgba(207, 106, 128, 0.30); color: #ffe9ee; }
scale { min-height: 22px; }
.pw:hover { background-color: rgba(146, 185, 234, 0.22); }
.pw.danger:hover { background-color: rgba(207, 106, 128, 0.32); color: #ffe9ee; }
scale { min-height: 24px; }
scale > contents > trough {
background-color: rgba(61, 55, 82, 0.95);
border: none; border-radius: 6px; min-height: 7px;
@ -79,6 +99,8 @@ scale > contents > trough > slider {
min-width: 14px; min-height: 14px;
margin: -6px;
}
scrollbar { background-color: transparent; }
scrollbar slider { background-color: rgba(146, 185, 234, 0.25); border-radius: 8px; }
"""
ICON = {
@ -127,6 +149,45 @@ def mic_get():
return (round(float(m.group(1)) * 100) if m else 0, "MUTED" in out)
def _pactl_devices(kind):
"""kind: 'sinks' or 'sources'. Returns [(name, description, is_default)]."""
default = sh(f"pactl get-default-{'sink' if kind == 'sinks' else 'source'}")
out, name, desc, items = sh(f"pactl list {kind}"), None, None, []
for line in out.splitlines():
line = line.strip()
if line.startswith("Name:"):
name = line.split(":", 1)[1].strip()
elif line.startswith("Description:"):
desc = line.split(":", 1)[1].strip()
if name and not name.startswith("alsa_output.platform-snd_dummy"):
items.append((name, desc, name == default))
name = None
return items
def sink_inputs():
"""Per-application streams: [(index, app, volume_pct, muted)]."""
out, cur, items = sh("pactl list sink-inputs"), {}, []
for line in out.splitlines():
st = line.strip()
if st.startswith("Sink Input #"):
if cur.get("idx"):
items.append((cur["idx"], cur.get("app", "?"),
cur.get("vol", 0), cur.get("mute", False)))
cur = {"idx": st.split("#")[1]}
elif st.startswith("Volume:") and "vol" not in cur:
m = re.search(r"(\d+)%", st)
cur["vol"] = int(m.group(1)) if m else 0
elif st.startswith("Mute:"):
cur["mute"] = st.endswith("yes")
elif "application.name" in st:
cur["app"] = st.split("=", 1)[1].strip().strip('"')
if cur.get("idx"):
items.append((cur["idx"], cur.get("app", "?"),
cur.get("vol", 0), cur.get("mute", False)))
return items
def net_state():
if not has("nmcli"):
return ICON["net_off"], "no NetworkManager", False
@ -183,10 +244,13 @@ class Panel(Gtk.Window):
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_keep_above(True)
screen = self.get_screen()
visual = screen.get_rgba_visual()
# An RGBA visual is all that is needed for the css background to blend.
# set_app_paintable(True) would make GTK skip drawing it altogether,
# which is what left the panel fully see-through.
visual = self.get_screen().get_rgba_visual()
if visual:
self.set_visual(visual)
# the window must not paint, or it paints opaque over the rgba visual
self.set_app_paintable(True)
self.stack = Gtk.Stack()
@ -196,25 +260,26 @@ class Panel(Gtk.Window):
self.stack.add_named(self.build_main(), "main")
self.stack.add_named(self.build_list_view("Bluetooth", self.fill_bt), "bt")
self.stack.add_named(self.build_list_view("Network", self.fill_net), "net")
self.stack.add_named(self.build_audio_view(), "audio")
frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame.set_border_width(16)
frame.get_style_context().add_class("frame")
frame.set_border_width(20)
frame.add(self.stack)
self.add(frame)
self.connect("key-press-event", self.on_key)
self.connect("destroy", Gtk.main_quit)
# close when focus goes elsewhere, like a quick-settings popover
self.connect("focus-out-event", lambda *_: self.close())
self.refresh()
GLib.timeout_add_seconds(3, self.refresh_tick)
# ---------------------------------------------------------- construction
def build_main(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
head = Gtk.Box(spacing=10)
head.set_margin_bottom(2)
who = Gtk.Label(xalign=0)
who.set_markup(f"<b>{GLib.get_user_name()}</b>@{GLib.get_host_name()}")
who.get_style_context().add_class("title")
@ -274,6 +339,7 @@ class Panel(Gtk.Window):
extra = Gtk.Box(spacing=8, homogeneous=True)
for icon, tip, cmd in (
(ICON["vol"], "Audio devices & app volumes", "@audio"),
(ICON["clip"], "Clipboard history",
"sh ~/.config/scripts/rofi-menus/clipboard-manager.sh"),
(ICON["shot"], "Screenshot", "flameshot gui"),
@ -282,7 +348,10 @@ class Panel(Gtk.Window):
b = Gtk.Button(label=icon)
b.set_tooltip_text(tip)
b.get_style_context().add_class("pw")
b.connect("clicked", lambda _w, c=cmd: (spawn(c), self.close()))
if cmd == "@audio":
b.connect("clicked", lambda *_: self.go("audio"))
else:
b.connect("clicked", lambda _w, c=cmd: (spawn(c), self.close()))
extra.add(b)
box.add(extra)
return box
@ -292,8 +361,9 @@ class Panel(Gtk.Window):
b.get_style_context().add_class("tile")
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
ic = Gtk.Label(label=icon)
ic.get_style_context().add_class("tile-icon")
tx = Gtk.Label(label=label)
tx.get_style_context().add_class("subtle")
tx.get_style_context().add_class("tile-name")
sub = Gtk.Label(label="")
sub.get_style_context().add_class("subtle")
inner.add(ic)
@ -346,10 +416,130 @@ class Panel(Gtk.Window):
setattr(self, f"fill_{title.lower()}", filler)
return outer
def build_audio_view(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
head = Gtk.Box(spacing=8)
head.set_margin_bottom(2)
back = Gtk.Button(label=f"{ICON['back']} Audio")
back.get_style_context().add_class("rowbtn")
back.connect("clicked", lambda *_: self.go("main"))
head.pack_start(back, True, True, 0)
outer.add(head)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.set_min_content_height(300)
self.audio_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
scroller.add(self.audio_box)
outer.add(scroller)
return outer
def section(self, text):
lbl = Gtk.Label(label=text.upper(), xalign=0)
lbl.get_style_context().add_class("section")
lbl.set_margin_top(6)
lbl.set_margin_start(4)
lbl.set_margin_bottom(2)
return lbl
def device_row(self, name, desc, active, kind):
b = Gtk.Button()
b.get_style_context().add_class("rowbtn")
if active:
b.get_style_context().add_class("chosen")
h = Gtk.Box(spacing=10)
mark = Gtk.Label(label="󰄬" if active else "󰝥")
mark.get_style_context().add_class("icon")
h.add(mark)
t = Gtk.Label(label=desc, xalign=0)
t.set_ellipsize(3)
h.pack_start(t, True, True, 0)
b.add(h)
b.connect("clicked", lambda *_: self.set_device(kind, name))
return b
def app_row(self, idx, app, vol, muted):
wp = str(idx).startswith("@")
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lbl = Gtk.Label(label=app, xalign=0)
lbl.get_style_context().add_class("subtle")
row = Gtk.Box(spacing=10)
mb = Gtk.Button(label=ICON["vol_mute"] if muted else ICON["vol"])
mb.get_style_context().add_class("rowbtn")
mb.connect("clicked", lambda *_: (
sh(f"wpctl set-mute {idx} toggle" if wp
else f"pactl set-sink-input-mute {idx} toggle"),
self.fill_audio(), self.refresh()))
sc = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 130, 1)
sc.set_draw_value(False)
sc.set_hexpand(True)
sc.set_value(vol)
pct = Gtk.Label(label=f"{vol}%")
pct.get_style_context().add_class("subtle")
sc.connect("value-changed", lambda w: (
pct.set_text(f"{int(w.get_value())}%"),
spawn(f"wpctl set-volume {idx} {int(w.get_value())}%" if wp
else f"pactl set-sink-input-volume {idx} {int(w.get_value())}%")))
row.pack_start(mb, False, False, 0)
row.pack_start(sc, True, True, 0)
row.pack_end(pct, False, False, 0)
card.add(lbl)
card.add(row)
return card
def set_device(self, kind, name):
sh(f"pactl set-default-{kind} {name}")
if kind == "sink":
# follow existing streams over to the new output
for idx, *_ in sink_inputs():
sh(f"pactl move-sink-input {idx} {name}")
notify("Audio", f"{'Output' if kind == 'sink' else 'Input'} switched")
self.fill_audio()
self.refresh()
def fill_audio(self):
for c in self.audio_box.get_children():
self.audio_box.remove(c)
self.audio_box.add(self.section("Volume"))
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
card.get_style_context().add_class("card")
v, vm = vol_get()
m, mm = mic_get()
card.add(self.app_row("@DEFAULT_AUDIO_SINK@", "Output", v, vm))
card.add(self.app_row("@DEFAULT_AUDIO_SOURCE@", "Microphone", m, mm))
self.audio_box.add(card)
self.audio_box.add(self.section("Output"))
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.get_style_context().add_class("card")
for name, desc, active in _pactl_devices("sinks"):
card.add(self.device_row(name, desc, active, "sink"))
self.audio_box.add(card)
self.audio_box.add(self.section("Input"))
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.get_style_context().add_class("card")
for name, desc, active in _pactl_devices("sources"):
if ".monitor" in name:
continue
card.add(self.device_row(name, desc, active, "source"))
self.audio_box.add(card)
apps = sink_inputs()
if apps:
self.audio_box.add(self.section("Applications"))
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
card.get_style_context().add_class("card")
for idx, app, vol, muted in apps:
card.add(self.app_row(idx, app, vol, muted))
self.audio_box.add(card)
self.audio_box.show_all()
# -------------------------------------------------------------- actions
def go(self, name):
if name != "main":
{"bt": self.fill_bt, "net": self.fill_net}[name]()
{"bt": self.fill_bt, "net": self.fill_net, "audio": self.fill_audio}[name]()
self.stack.set_visible_child_name(name)
def on_key(self, _w, ev):
@ -426,7 +616,9 @@ class Panel(Gtk.Window):
b = Gtk.Button()
b.get_style_context().add_class("rowbtn")
h = Gtk.Box(spacing=12)
h.add(Gtk.Label(label=icon))
ic = Gtk.Label(label=icon)
ic.get_style_context().add_class("icon")
h.add(ic)
t = Gtk.Label(label=label, xalign=0)
h.pack_start(t, True, True, 0)
s = Gtk.Label(label=sub)