#!/usr/bin/env python3 """Sakura Line - control panel. A real GTK window: persistent, with live sliders and instant view switching. rofi has to relaunch for every menu, which is what made the old panel flicker. Escape steps back a view, or closes the panel from the main view. """ import os import re import shutil import subprocess import gi gi.require_version("Gtk", "3.0") 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; } .title { font-weight: bold; font-size: 15px; color: #e9eefb; } .subtle { color: #7f7796; font-size: 12px; } .section { color: #92b9ea; font-size: 11px; font-weight: bold; } .card { background-color: rgba(34, 28, 51, 0.72); border-radius: 12px; padding: 10px; } .tile { background-image: none; background-color: rgba(34, 28, 51, 0.72); border: 1px solid transparent; border-radius: 12px; color: #e9eefb; padding: 12px 8px; } .tile:hover { background-color: rgba(58, 51, 80, 0.85); } .tile.active { background-color: #92b9ea; color: #0f0d17; border-color: #92b9ea; } .rowbtn { background-image: none; background-color: transparent; border: none; border-radius: 10px; color: #e9eefb; padding: 9px 10px; } .rowbtn:hover { background-color: rgba(146, 185, 234, 0.17); } .pw { background-image: none; background-color: rgba(34, 28, 51, 0.72); border: 1px solid transparent; border-radius: 10px; color: #e9eefb; padding: 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; } scale > contents > trough { background-color: rgba(61, 55, 82, 0.95); border: none; border-radius: 6px; min-height: 7px; } scale > contents > trough > highlight { background-color: #92b9ea; border-radius: 6px; } scale > contents > trough > slider { background-image: none; background-color: #e9eefb; border: 2px solid #0f0d17; border-radius: 50%; min-width: 14px; min-height: 14px; margin: -6px; } """ ICON = { "net_eth": "󰈀", "net_wifi": "󰖩", "net_off": "󰖪", "bt_on": "󰂯", "bt_off": "󰂲", "bt_conn": "󰂱", "vol": "󰕾", "vol_mute": "󰝟", "mic": "󰍬", "mic_mute": "󰍭", "dnd_on": "󰂛", "dnd_off": "󰂚", "particles": "󰠹", "wall": "󰸉", "lock": "󰌾", "logout": "󰍃", "suspend": "󰒲", "reboot": "󰜉", "power": "󰐥", "back": "󰅁", "refresh": "󰑓", "vpn_on": "󰖂", "vpn_off": "󰕥", "clip": "󰅍", "shot": "󰹑", "bar": "󰍹", } def sh(cmd, timeout=6): """Run a command, return stdout ('' on any failure).""" try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout.strip() except Exception: return "" def spawn(cmd): subprocess.Popen(cmd, shell=True, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def has(binary): return shutil.which(binary) is not None def notify(title, body=""): spawn(f'notify-send -a "Control" {GLib.shell_quote(title)} {GLib.shell_quote(body)}') # ------------------------------------------------------------------ backends def vol_get(): out = sh("wpctl get-volume @DEFAULT_AUDIO_SINK@") m = re.search(r"([\d.]+)", out) return (round(float(m.group(1)) * 100) if m else 0, "MUTED" in out) def mic_get(): out = sh("wpctl get-volume @DEFAULT_AUDIO_SOURCE@") m = re.search(r"([\d.]+)", out) return (round(float(m.group(1)) * 100) if m else 0, "MUTED" in out) def net_state(): if not has("nmcli"): return ICON["net_off"], "no NetworkManager", False if "ethernet:connected" in sh("nmcli -t -f TYPE,STATE device status"): return ICON["net_eth"], "Ethernet", True if sh("nmcli -t -f WIFI radio") != "enabled": return ICON["net_off"], "Wi-Fi off", False for line in sh("nmcli -t -f NAME,TYPE connection show --active").splitlines(): if "wireless" in line: return ICON["net_wifi"], line.split(":")[0], True return ICON["net_wifi"], "not connected", False def bt_state(): if not has("bluetoothctl"): return ICON["bt_off"], "unavailable", False if "Powered: yes" not in sh("bluetoothctl show"): return ICON["bt_off"], "Off", False n = len([l for l in sh("bluetoothctl devices Connected").splitlines() if l.startswith("Device")]) return (ICON["bt_conn"], f"{n} connected", True) if n else (ICON["bt_on"], "On", True) def dnd_on(): return sh("dunstctl is-paused") == "true" def vpn_state(): ifs = [l.split(":")[0].split("@")[0] for l in sh("ip -br link show type wireguard").splitlines() if l.strip()] ifs = [l.split()[0] for l in sh("ip -br link show type wireguard").splitlines() if l.strip()] return (ICON["vpn_on"], ", ".join(ifs), True) if ifs else (ICON["vpn_off"], "off", False) def wg_units(): out = sh("systemctl list-units --all --type=service --no-legend 'wg-quick@*'") return [l.split()[0].replace("wg-quick@", "").replace(".service", "") for l in out.splitlines() if l.strip()] def particles_on(): return "running" in sh(f"{os.path.expanduser('~/.local/bin/particles')} --status || particles --status") # --------------------------------------------------------------------- panel class Panel(Gtk.Window): def __init__(self): super().__init__(title="Control Panel") self.set_name("control-panel") self.get_style_context().add_class("panel") self.set_default_size(420, -1) self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) self.set_decorated(False) self.set_skip_taskbar_hint(True) self.set_type_hint(Gdk.WindowTypeHint.DIALOG) self.set_keep_above(True) screen = self.get_screen() visual = screen.get_rgba_visual() if visual: self.set_visual(visual) self.set_app_paintable(True) self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) self.stack.set_transition_duration(140) 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") frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) frame.set_border_width(16) 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) head = Gtk.Box(spacing=10) who = Gtk.Label(xalign=0) who.set_markup(f"{GLib.get_user_name()}@{GLib.get_host_name()}") who.get_style_context().add_class("title") self.uptime = Gtk.Label(xalign=0) self.uptime.get_style_context().add_class("subtle") col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) col.add(who) col.add(self.uptime) head.pack_start(col, True, True, 0) box.add(head) # quick toggles grid = Gtk.Grid(column_spacing=10, row_spacing=10, column_homogeneous=True) self.t_net = self.tile(ICON["net_eth"], "Network", lambda *_: self.go("net")) self.t_bt = self.tile(ICON["bt_on"], "Bluetooth", lambda *_: self.go("bt")) self.t_vpn = self.tile(ICON["vpn_off"], "VPN", self.toggle_vpn) self.t_dnd = self.tile(ICON["dnd_off"], "Notify", self.toggle_dnd) self.t_par = self.tile(ICON["particles"], "Particles", self.toggle_particles) self.t_wall = self.tile(ICON["wall"], "Wallpaper", lambda *_: (spawn("sh ~/.config/scripts/set-wallpaper.sh"), notify("Wallpaper", "Shuffled"))) for i, w in enumerate((self.t_net, self.t_bt, self.t_vpn)): grid.attach(w, i, 0, 1, 1) for i, w in enumerate((self.t_dnd, self.t_par, self.t_wall)): grid.attach(w, i, 1, 1, 1) box.add(grid) # sliders card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) card.get_style_context().add_class("card") vol_row, self.vol_btn, self.vol_scale = self.slider_row( ICON["vol"], self.on_vol, self.toggle_mute) mic_row, self.mic_btn, self.mic_scale = self.slider_row( ICON["mic"], self.on_mic, self.toggle_micmute) card.add(vol_row) card.add(mic_row) box.add(card) # power row prow = Gtk.Box(spacing=8, homogeneous=True) for icon, tip, act, danger in ( (ICON["lock"], "Lock", self.do_lock, False), (ICON["logout"], "Log out", lambda *_: spawn("bspc quit"), False), (ICON["suspend"], "Suspend", lambda *_: (self.do_lock(), spawn("systemctl suspend")), False), (ICON["reboot"], "Reboot", lambda *_: spawn("systemctl reboot"), True), (ICON["power"], "Shut down", lambda *_: spawn("systemctl poweroff"), True), ): b = Gtk.Button(label=icon) b.set_tooltip_text(tip) b.get_style_context().add_class("pw") if danger: b.get_style_context().add_class("danger") b.connect("clicked", act) prow.add(b) box.add(prow) extra = Gtk.Box(spacing=8, homogeneous=True) for icon, tip, cmd in ( (ICON["clip"], "Clipboard history", "sh ~/.config/scripts/rofi-menus/clipboard-manager.sh"), (ICON["shot"], "Screenshot", "flameshot gui"), (ICON["bar"], "Toggle bar", "sh ~/.config/scripts/toggle-bar.sh"), ): 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())) extra.add(b) box.add(extra) return box def tile(self, icon, label, cb): b = Gtk.Button() b.get_style_context().add_class("tile") inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) ic = Gtk.Label(label=icon) tx = Gtk.Label(label=label) tx.get_style_context().add_class("subtle") sub = Gtk.Label(label="") sub.get_style_context().add_class("subtle") inner.add(ic) inner.add(tx) inner.add(sub) b.add(inner) b.connect("clicked", cb) b._icon, b._sub = ic, sub return b def slider_row(self, icon, on_change, on_click): row = Gtk.Box(spacing=10) btn = Gtk.Button(label=icon) btn.get_style_context().add_class("rowbtn") btn.connect("clicked", on_click) scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1) scale.set_draw_value(False) scale.set_hexpand(True) scale.connect("value-changed", on_change) pct = Gtk.Label(label="") pct.get_style_context().add_class("subtle") row.pack_start(btn, False, False, 0) row.pack_start(scale, True, True, 0) row.pack_start(pct, False, False, 0) scale._pct = pct return row, btn, scale def build_list_view(self, title, filler): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) head = Gtk.Box(spacing=8) back = Gtk.Button(label=f"{ICON['back']} {title}") back.get_style_context().add_class("rowbtn") back.connect("clicked", lambda *_: self.go("main")) head.pack_start(back, True, True, 0) rescan = Gtk.Button(label=ICON["refresh"]) rescan.get_style_context().add_class("rowbtn") rescan.connect("clicked", lambda *_: self.rescan(title)) head.pack_end(rescan, False, False, 0) outer.add(head) scroller = Gtk.ScrolledWindow() scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroller.set_min_content_height(260) listbox = Gtk.ListBox() listbox.set_selection_mode(Gtk.SelectionMode.NONE) listbox.get_style_context().add_class("card") scroller.add(listbox) outer.add(scroller) setattr(self, f"list_{title.lower()}", listbox) setattr(self, f"fill_{title.lower()}", filler) return outer # -------------------------------------------------------------- actions def go(self, name): if name != "main": {"bt": self.fill_bt, "net": self.fill_net}[name]() self.stack.set_visible_child_name(name) def on_key(self, _w, ev): if ev.keyval == Gdk.KEY_Escape: if self.stack.get_visible_child_name() != "main": self.go("main") # step back, no relaunch else: self.close() return True return False def on_vol(self, scale): v = int(scale.get_value()) scale._pct.set_text(f"{v}%") if not self._loading: spawn(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ {v}%") def on_mic(self, scale): v = int(scale.get_value()) scale._pct.set_text(f"{v}%") if not self._loading: spawn(f"wpctl set-volume @DEFAULT_AUDIO_SOURCE@ {v}%") def toggle_mute(self, *_): sh("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle") self.refresh() def toggle_micmute(self, *_): sh("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle") self.refresh() def toggle_dnd(self, *_): sh(f"dunstctl set-paused {'false' if dnd_on() else 'true'}") self.refresh() def toggle_particles(self, *_): spawn("particles || ~/.local/bin/particles") GLib.timeout_add(700, lambda: (self.refresh(), False)[1]) def toggle_vpn(self, *_): _, _, up = vpn_state() if up: for i in [l.split()[0] for l in sh("ip -br link show type wireguard").splitlines() if l.strip()]: spawn(f"systemctl stop wg-quick@{i}") notify("VPN", "Disconnecting") else: units = wg_units() if not units: notify("VPN", "No WireGuard interfaces configured") return spawn(f"systemctl start wg-quick@{units[0]}") notify("VPN", f"Connecting {units[0]}…") GLib.timeout_add_seconds(2, lambda: (self.refresh(), False)[1]) def do_lock(self, *_): for cmd in ("betterlockscreen -l blur", "i3lock -c 0f0d17", "xsecurelock", "slock"): if has(cmd.split()[0]): spawn(cmd) return notify("Lock", "No screen locker installed") def rescan(self, title): if title == "Bluetooth": spawn("bluetoothctl --timeout 20 scan on") notify("Bluetooth", "Scanning…") GLib.timeout_add_seconds(3, lambda: (self.fill_bt(), False)[1]) else: spawn("nmcli device wifi rescan") GLib.timeout_add_seconds(3, lambda: (self.fill_net(), False)[1]) def row(self, listbox, icon, label, sub, cb): r = Gtk.ListBoxRow() b = Gtk.Button() b.get_style_context().add_class("rowbtn") h = Gtk.Box(spacing=12) h.add(Gtk.Label(label=icon)) t = Gtk.Label(label=label, xalign=0) h.pack_start(t, True, True, 0) s = Gtk.Label(label=sub) s.get_style_context().add_class("subtle") h.pack_end(s, False, False, 0) b.add(h) b.connect("clicked", cb) r.add(b) listbox.add(r) def fill_bt(self): lb = self.list_bluetooth for c in lb.get_children(): lb.remove(c) powered = "Powered: yes" in sh("bluetoothctl show") self.row(lb, ICON["bt_on"] if powered else ICON["bt_off"], "Bluetooth", "on" if powered else "off", lambda *_: (sh(f"bluetoothctl power {'off' if powered else 'on'}"), self.fill_bt(), self.refresh())) if powered: for line in sh("bluetoothctl devices").splitlines(): parts = line.split(" ", 2) if len(parts) < 3: continue mac, name = parts[1], parts[2] info = sh(f"bluetoothctl info {mac}") conn = "Connected: yes" in info state = "connected" if conn else ("paired" if "Paired: yes" in info else "") self.row(lb, ICON["bt_conn"] if conn else ICON["bt_on"], name, state, lambda _w, m=mac, n=name, c=conn: self.bt_toggle(m, n, c)) lb.show_all() def bt_toggle(self, mac, name, connected): if connected: spawn(f"bluetoothctl disconnect {mac}") notify("Bluetooth", f"{name} disconnected") else: notify("Bluetooth", f"Connecting to {name}…") spawn(f"bluetoothctl trust {mac} && bluetoothctl connect {mac}") GLib.timeout_add_seconds(3, lambda: (self.fill_bt(), self.refresh(), False)[2]) def fill_net(self): lb = self.list_network for c in lb.get_children(): lb.remove(c) has_wifi = any(l.endswith(":wifi") for l in sh("nmcli -t -f DEVICE,TYPE device status").splitlines()) if has_wifi: on = sh("nmcli -t -f WIFI radio") == "enabled" self.row(lb, ICON["net_wifi"] if on else ICON["net_off"], "Wi-Fi", "on" if on else "off", lambda *_: (sh(f"nmcli radio wifi {'off' if on else 'on'}"), self.fill_net(), self.refresh())) if on: seen = set() for line in sh("nmcli -t -f IN-USE,SIGNAL,SSID device wifi list").splitlines(): p = line.split(":") if len(p) < 3 or not p[2] or p[2] in seen: continue seen.add(p[2]) self.row(lb, "󰸞" if p[0] == "*" else ICON["net_wifi"], p[2], f"{p[1]}%", lambda _w, s=p[2]: self.wifi_connect(s)) else: active = sh("nmcli -t -f NAME connection show --active").splitlines() for line in sh("nmcli -t -f NAME,TYPE connection show").splitlines(): p = line.split(":") if len(p) < 2 or p[1] in ("bridge", "loopback"): continue up = p[0] in active self.row(lb, ICON["net_eth"] if up else "󰅛", p[0], "up" if up else "", lambda _w, n=p[0], u=up: self.net_toggle(n, u)) lb.show_all() def wifi_connect(self, ssid): spawn(f"nmcli connection up id {GLib.shell_quote(ssid)} || " f"nmcli device wifi connect {GLib.shell_quote(ssid)}") notify("Wi-Fi", f"Connecting to {ssid}…") GLib.timeout_add_seconds(3, lambda: (self.fill_net(), self.refresh(), False)[2]) def net_toggle(self, name, up): spawn(f"nmcli connection {'down' if up else 'up'} id {GLib.shell_quote(name)}") GLib.timeout_add_seconds(2, lambda: (self.fill_net(), self.refresh(), False)[2]) # -------------------------------------------------------------- refresh def refresh_tick(self): self.refresh() return True def refresh(self): self._loading = True up = sh("uptime -p") or "" self.uptime.set_text(up.replace("up ", "up ")) icon, sub, on = net_state() self.t_net._icon.set_text(icon) self.t_net._sub.set_text(sub) self.style(self.t_net, on) icon, sub, on = bt_state() self.t_bt._icon.set_text(icon) self.t_bt._sub.set_text(sub) self.style(self.t_bt, on) paused = dnd_on() self.t_dnd._icon.set_text(ICON["dnd_on"] if paused else ICON["dnd_off"]) self.t_dnd._sub.set_text("silenced" if paused else "on") self.style(self.t_dnd, paused) icon, sub, on = vpn_state() self.t_vpn._icon.set_text(icon) self.t_vpn._sub.set_text(sub) self.style(self.t_vpn, on) pon = particles_on() self.t_par._sub.set_text("on" if pon else "off") self.style(self.t_par, pon) v, vm = vol_get() self.vol_scale.set_value(v) self.vol_scale._pct.set_text(f"{v}%") self.vol_btn.set_label(ICON["vol_mute"] if vm else ICON["vol"]) m, mm = mic_get() self.mic_scale.set_value(m) self.mic_scale._pct.set_text(f"{m}%") self.mic_btn.set_label(ICON["mic_mute"] if mm else ICON["mic"]) self._loading = False return True @staticmethod def style(widget, active): ctx = widget.get_style_context() (ctx.add_class if active else ctx.remove_class)("active") PIDFILE = os.path.join( os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "sakura-control-panel.pid") def toggle_off_if_running(): """A pidfile, not pgrep: matching on a command line also matches the shell that launched us. Returns True when an existing panel was closed.""" try: with open(PIDFILE) as fh: pid = int(fh.read().strip()) except (OSError, ValueError): return False if pid == os.getpid(): return False # the pidfile survives SIGTERM, so confirm the pid is really us before # signalling it - pids get recycled try: with open(f"/proc/{pid}/cmdline", "rb") as fh: if b"control-panel.py" not in fh.read(): return False except OSError: return False try: os.kill(pid, 15) return True except ProcessLookupError: return False except OSError: return False def main(): if toggle_off_if_running(): return try: with open(PIDFILE, "w") as fh: fh.write(str(os.getpid())) except OSError: pass provider = Gtk.CssProvider() provider.load_from_data(CSS) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) win = Panel() win._loading = True win.show_all() win.refresh() try: Gtk.main() finally: try: with open(PIDFILE) as fh: if int(fh.read().strip()) == os.getpid(): os.unlink(PIDFILE) except (OSError, ValueError): pass if __name__ == "__main__": main()