- GtkBox set_border_width shrinks the widget's own background, so the card was inset 30px while its contents sat flush against the card edge. Padding is css now: 12px transparent gutter outside, 20px inside. Measured on a virtual display rather than guessed. - the panel grabs the seat on map, like rofi, so it keeps keyboard and pointer focus wherever the cursor is; the grab is released on close - every control shows its key in the bottom-right: n b v d p w for the tiles, a c s t for the row beneath, l o z r q for power, m/i to mute, arrows for volume Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt
895 lines
34 KiB
Python
Executable file
895 lines
34 KiB
Python
Executable file
#!/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"""
|
|
/* 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,
|
|
.icon label, .pw label, .rowbtn label, .tile label {
|
|
font-family: "JetBrainsMono Nerd Font", "Symbols Nerd Font", monospace;
|
|
}
|
|
.pw label, .rowbtn label { color: #e9eefb; }
|
|
.pw:hover label { color: #e9eefb; }
|
|
|
|
window.panel { background-color: transparent; }
|
|
.frame {
|
|
background-color: rgba(15, 13, 23, 0.88);
|
|
border: 1px solid rgba(146, 185, 234, 0.16);
|
|
border-radius: 18px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.title { font-weight: bold; font-size: 15px; color: #e9eefb; }
|
|
.subtle { color: #8d85a4; font-size: 11px; }
|
|
.section { color: #92b9ea; font-size: 10px; font-weight: bold; letter-spacing: 1px; }
|
|
.hint {
|
|
color: #6f6889;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
}
|
|
.tile-icon { font-size: 20px; color: #e9eefb; }
|
|
.tile-name { font-size: 12px; color: #e9eefb; }
|
|
.tile.active .tile-icon { color: #92b9ea; }
|
|
.tile.active .tile-name { color: #e9eefb; }
|
|
.tile.active .subtle { color: #9dbdea; }
|
|
|
|
.card {
|
|
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(38, 32, 58, 0.78);
|
|
border: 1px solid rgba(146, 185, 234, 0.10);
|
|
border-radius: 14px;
|
|
color: #e9eefb;
|
|
padding: 14px 6px;
|
|
}
|
|
.tile:hover { background-color: rgba(58, 51, 80, 0.9); }
|
|
.tile.active {
|
|
background-color: rgba(146, 185, 234, 0.14);
|
|
border-color: rgba(146, 185, 234, 0.55);
|
|
}
|
|
.rowbtn {
|
|
background-image: none;
|
|
background-color: transparent;
|
|
border: none;
|
|
border-radius: 10px;
|
|
color: #e9eefb;
|
|
font-size: 14px;
|
|
padding: 10px 12px;
|
|
}
|
|
.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(38, 32, 58, 0.78);
|
|
border: 1px solid rgba(146, 185, 234, 0.10);
|
|
border-radius: 12px;
|
|
color: #e9eefb;
|
|
font-size: 15px;
|
|
padding: 11px 8px;
|
|
}
|
|
.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;
|
|
}
|
|
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;
|
|
}
|
|
scrollbar { background-color: transparent; }
|
|
scrollbar slider { background-color: rgba(146, 185, 234, 0.25); border-radius: 8px; }
|
|
"""
|
|
|
|
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 _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
|
|
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)
|
|
|
|
# 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.keymap = {}
|
|
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")
|
|
self.stack.add_named(self.build_audio_view(), "audio")
|
|
|
|
frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
frame.get_style_context().add_class("frame")
|
|
for setter in ("set_margin_top", "set_margin_bottom",
|
|
"set_margin_start", "set_margin_end"):
|
|
getattr(frame, setter)(12) # transparent gutter outside the card
|
|
frame.add(self.stack)
|
|
self.add(frame)
|
|
|
|
self.connect("key-press-event", self.on_key)
|
|
self.connect("map-event", self.on_map)
|
|
self.connect("destroy", Gtk.main_quit)
|
|
|
|
self.refresh()
|
|
GLib.timeout_add_seconds(3, self.refresh_tick)
|
|
|
|
# ---------------------------------------------------------- construction
|
|
def build_main(self):
|
|
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")
|
|
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 *_: self.shuffle_wall())
|
|
tiles = ((self.t_net, "n"), (self.t_bt, "b"), (self.t_vpn, "v"),
|
|
(self.t_dnd, "d"), (self.t_par, "p"), (self.t_wall, "w"))
|
|
for i, (w, k) in enumerate(tiles):
|
|
grid.attach(self.hint(w, k), i % 3, i // 3, 1, 1)
|
|
self.keymap[k] = (lambda _w=None, btn=w: btn.emit("clicked"))
|
|
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, key in (
|
|
(ICON["lock"], "Lock", self.do_lock, False, "l"),
|
|
(ICON["logout"], "Log out", lambda *_: spawn("bspc quit"), False, "o"),
|
|
(ICON["suspend"], "Suspend",
|
|
lambda *_: (self.do_lock(), spawn("systemctl suspend")), False, "z"),
|
|
(ICON["reboot"], "Reboot", lambda *_: spawn("systemctl reboot"), True, "r"),
|
|
(ICON["power"], "Shut down", lambda *_: spawn("systemctl poweroff"), True, "q"),
|
|
):
|
|
b = Gtk.Button(label=icon)
|
|
b.set_tooltip_text(f"{tip} ({key})")
|
|
b.get_style_context().add_class("pw")
|
|
if danger:
|
|
b.get_style_context().add_class("danger")
|
|
b.connect("clicked", act)
|
|
self.keymap[key] = act
|
|
prow.add(self.hint(b, key))
|
|
box.add(prow)
|
|
|
|
extra = Gtk.Box(spacing=8, homogeneous=True)
|
|
for icon, tip, cmd, key in (
|
|
(ICON["vol"], "Audio devices & app volumes", "@audio", "a"),
|
|
(ICON["clip"], "Clipboard history",
|
|
"sh ~/.config/scripts/rofi-menus/clipboard-manager.sh", "c"),
|
|
(ICON["shot"], "Screenshot", "flameshot gui", "s"),
|
|
(ICON["bar"], "Toggle bar", "sh ~/.config/scripts/toggle-bar.sh", "t"),
|
|
):
|
|
b = Gtk.Button(label=icon)
|
|
b.set_tooltip_text(f"{tip} ({key})")
|
|
b.get_style_context().add_class("pw")
|
|
if cmd == "@audio":
|
|
act = lambda *_: self.go("audio")
|
|
else:
|
|
act = lambda _w=None, c=cmd: (spawn(c), self.close())
|
|
b.connect("clicked", act)
|
|
self.keymap[key] = act
|
|
extra.add(self.hint(b, key))
|
|
box.add(extra)
|
|
return box
|
|
|
|
def hint(self, widget, letter):
|
|
"""Wrap a widget so a small key hint sits in its bottom-right."""
|
|
ov = Gtk.Overlay()
|
|
ov.add(widget)
|
|
lab = Gtk.Label(label=letter)
|
|
lab.get_style_context().add_class("hint")
|
|
lab.set_halign(Gtk.Align.END)
|
|
lab.set_valign(Gtk.Align.END)
|
|
lab.set_margin_end(7)
|
|
lab.set_margin_bottom(5)
|
|
ov.add_overlay(lab)
|
|
ov.set_overlay_pass_through(lab, True)
|
|
ov.btn = widget
|
|
return ov
|
|
|
|
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)
|
|
ic.get_style_context().add_class("tile-icon")
|
|
tx = Gtk.Label(label=label)
|
|
tx.get_style_context().add_class("tile-name")
|
|
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
|
|
|
|
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()
|
|
|
|
def on_map(self, *_):
|
|
"""Own the seat the way rofi does: keyboard and pointer both, so the
|
|
panel keeps focus wherever the cursor is or whatever gets clicked."""
|
|
self.present()
|
|
seat = Gdk.Display.get_default().get_default_seat()
|
|
gdkwin = self.get_window()
|
|
if seat and gdkwin:
|
|
seat.grab(gdkwin, Gdk.SeatCapabilities.ALL, True,
|
|
None, None, None, None)
|
|
return False
|
|
|
|
def release_grab(self):
|
|
seat = Gdk.Display.get_default().get_default_seat()
|
|
if seat:
|
|
seat.ungrab()
|
|
|
|
def close(self, *_):
|
|
self.release_grab()
|
|
super().close()
|
|
|
|
# -------------------------------------------------------------- actions
|
|
def go(self, name):
|
|
if name != "main":
|
|
{"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):
|
|
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
|
|
if self.stack.get_visible_child_name() != "main":
|
|
return False
|
|
name = Gdk.keyval_name(ev.keyval) or ""
|
|
if name in ("m", "M"):
|
|
self.toggle_mute()
|
|
return True
|
|
if name in ("i", "I"):
|
|
self.toggle_micmute()
|
|
return True
|
|
if name in ("Left", "Right", "Up", "Down"):
|
|
step = -5 if name in ("Left", "Down") else 5
|
|
spawn(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ "
|
|
f"{abs(step)}%{'-' if step < 0 else '+'} -l 1.3")
|
|
GLib.timeout_add(180, lambda: (self.refresh(), False)[1])
|
|
return True
|
|
act = self.keymap.get(name.lower())
|
|
if act:
|
|
act()
|
|
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 shuffle_wall(self):
|
|
spawn("sh ~/.config/scripts/set-wallpaper.sh")
|
|
notify("Wallpaper", "Shuffled")
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
# ~/.config/gtk-3.0/gtk.css loads at PRIORITY_USER (800) and styles plain
|
|
# `button`, which outranks this sheet at APPLICATION (600) and was undoing
|
|
# the tile/icon styling. Sit just above it.
|
|
Gtk.StyleContext.add_provider_for_screen(
|
|
Gdk.Screen.get_default(), provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_USER + 1)
|
|
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()
|