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:
parent
b4c6c7dbd5
commit
ddf4b5b8d9
5 changed files with 239 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
.config/scripts/__pycache__/control-panel.cpython-314.pyc
Normal file
BIN
.config/scripts/__pycache__/control-panel.cpython-314.pyc
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue