diff --git a/.config/bspwm/bspwmrc b/.config/bspwm/bspwmrc index c4e06fa..29cb690 100755 --- a/.config/bspwm/bspwmrc +++ b/.config/bspwm/bspwmrc @@ -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 diff --git a/.config/bspwm/picom.conf b/.config/bspwm/picom.conf index 37df756..bc9113d 100644 --- a/.config/bspwm/picom.conf +++ b/.config/bspwm/picom.conf @@ -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'", +]; diff --git a/.config/polybar/config.ini b/.config/polybar/config.ini index 0b16ca5..4b85bbc 100755 --- a/.config/polybar/config.ini +++ b/.config/polybar/config.ini @@ -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 diff --git a/.config/scripts/__pycache__/control-panel.cpython-314.pyc b/.config/scripts/__pycache__/control-panel.cpython-314.pyc new file mode 100644 index 0000000..3073720 Binary files /dev/null and b/.config/scripts/__pycache__/control-panel.cpython-314.pyc differ diff --git a/.config/scripts/control-panel.py b/.config/scripts/control-panel.py index e01d46d..70efaff 100755 --- a/.config/scripts/control-panel.py +++ b/.config/scripts/control-panel.py @@ -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"{GLib.get_user_name()}@{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)