replace the rofi control centre with a real GTK panel
rofi spawns a process per menu, so every volume nudge and every step back destroyed and recreated the window. That is the flicker, and no tuning fixes it. The panel is now one GTK window: a GtkStack for views, real GtkScale sliders wired to wpctl, status on a timer, and a pidfile so super+x toggles it shut. Escape steps back inside the same window. Also: picom no longer fades or animates rofi, ::selection is not valid GTK css, and the network/bluetooth lists no longer print their payload column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt
This commit is contained in:
parent
2e6d7a2116
commit
b4c6c7dbd5
11 changed files with 654 additions and 334 deletions
|
|
@ -59,3 +59,12 @@ rounded-corners-exclude = [
|
|||
# never round a fullscreen game
|
||||
"_NET_WM_STATE@:32a *= '_NET_WM_STATE_FULLSCREEN'",
|
||||
];
|
||||
|
||||
# The control centre redraws by relaunching rofi, so the open/close zoom and
|
||||
# fade read as flicker. Exclude rofi from both.
|
||||
fade-exclude = [
|
||||
"class_g = 'Rofi'",
|
||||
];
|
||||
animation-exclude = [
|
||||
"class_g = 'Rofi'",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
# System binding
|
||||
super + {Return,v,w,x,b,period}
|
||||
super + {Return,v,w,x,b}
|
||||
{st, \
|
||||
sh ~/.config/scripts/rofi-menus/clipboard-manager.sh, \
|
||||
rofi -show drun, \
|
||||
sh ~/.config/scripts/control-center.sh, \
|
||||
sh ~/.config/scripts/toggle-bar.sh, \
|
||||
sh ~/.config/scripts/rofi-menus/rofimoji.sh}
|
||||
python3 ~/.config/scripts/control-panel.py, \
|
||||
sh ~/.config/scripts/toggle-bar.sh}
|
||||
|
||||
# Screenshots
|
||||
super + alt + p
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ switch:checked, check:checked, radio:checked {
|
|||
background-color: @sakura_accent;
|
||||
color: #0f0d17;
|
||||
}
|
||||
selection, ::selection {
|
||||
selection {
|
||||
background-color: @sakura_accent;
|
||||
color: #0f0d17;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ switch:checked, check:checked, radio:checked {
|
|||
background-color: @sakura_accent;
|
||||
color: #0f0d17;
|
||||
}
|
||||
selection, ::selection {
|
||||
selection {
|
||||
background-color: @sakura_accent;
|
||||
color: #0f0d17;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,4 +170,4 @@ type = custom/text
|
|||
content = ""
|
||||
content-foreground = ${colors.accent}
|
||||
content-padding = 1
|
||||
click-left = sh $HOME/.config/scripts/control-center.sh
|
||||
click-left = python3 $HOME/.config/scripts/control-panel.py
|
||||
|
|
|
|||
|
|
@ -36,21 +36,23 @@ the dotfiles checkout is moved to `~/.rice-backup-<timestamp>/` first.
|
|||
|
||||
## Control centre
|
||||
|
||||
`super + x`, or the gear at the right of the bar, opens one centred panel that
|
||||
drives everything — no separate menus:
|
||||
`super + x`, or the gear at the right of the bar, opens one centred GTK panel
|
||||
that drives everything. Press it again to close. It is a real window, so the
|
||||
sliders are live and stepping back between views never redraws the panel:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Network | Wi-Fi on/off, rescan, pick a network (password prompt inline) |
|
||||
| Bluetooth | power on/off, scan, connect/disconnect paired devices |
|
||||
| Audio | volume, mute, mic mute, switch output sink |
|
||||
| VPN | WireGuard interfaces up/down via `systemctl wg-quick@…` |
|
||||
| Display | switch resolution/refresh on the connected output |
|
||||
| Notifications | do-not-disturb, replay history, clear |
|
||||
| Clipboard / Emoji / Screenshot | greenclip, rofimoji, flameshot |
|
||||
| Wallpaper / Particles / Bar | shuffle, toggle the particle layer, hide the bar |
|
||||
| Audio | live volume and microphone sliders; click the icon to mute |
|
||||
| VPN | WireGuard up/down via `systemctl wg-quick@…` |
|
||||
| Notifications | do-not-disturb toggle |
|
||||
| Clipboard / Screenshot / Bar | greenclip, flameshot, hide the bar |
|
||||
| Wallpaper / Particles | shuffle, toggle the particle layer |
|
||||
| Power | lock, log out, suspend, reboot, shut down |
|
||||
|
||||
Escape steps back from a subview; Escape on the main view closes it.
|
||||
|
||||
## Keybindings
|
||||
|
||||
| | |
|
||||
|
|
@ -59,7 +61,6 @@ drives everything — no separate menus:
|
|||
| `super + w` | app launcher (rofi drun) |
|
||||
| `super + x` | control centre |
|
||||
| `super + v` | clipboard history |
|
||||
| `super + period` | emoji picker |
|
||||
| `super + b` | toggle the bar |
|
||||
| `super + alt + p` | screenshot (flameshot) |
|
||||
| `super + q` | close window |
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ bluez-utils
|
|||
pamixer
|
||||
playerctl
|
||||
libnotify
|
||||
python-gobject
|
||||
xclip
|
||||
flameshot
|
||||
i3lock
|
||||
rofimoji
|
||||
aur:rofi-greenclip
|
||||
|
||||
# --- apps ---
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ entry {
|
|||
}
|
||||
|
||||
listview {
|
||||
lines: 14;
|
||||
lines: 13;
|
||||
columns: 1;
|
||||
fixed-height: false;
|
||||
scrollbar: false;
|
||||
|
|
|
|||
|
|
@ -1,315 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sakura Line - control centre
|
||||
#
|
||||
# One centred panel for network, bluetooth, audio, vpn, session and desktop
|
||||
# toggles. Submenus reuse the same window, so it reads as a single panel
|
||||
# rather than a pile of separate menus.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
RASI="$HOME/.config/rofi/control.rasi"
|
||||
# 1 = close the panel entirely, 0 = fall back to the main list
|
||||
CC_EXIT=0
|
||||
menu() { # menu <prompt> [rofi args...]
|
||||
local prompt="$1"; shift
|
||||
rofi -dmenu -i -theme "$RASI" -p "$prompt" "$@"
|
||||
}
|
||||
notify() { notify-send -a "Control" "$@" >/dev/null 2>&1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
# sxhkd inherits a bare PATH, so ~/.local/bin needs adding back by hand
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# ------------------------------------------------------------------ status
|
||||
wifi_status() {
|
||||
have nmcli || { echo "unavailable"; return; }
|
||||
if [ "$(nmcli -t -f WIFI radio 2>/dev/null)" != "enabled" ]; then echo "off"; return; fi
|
||||
local eth ssid
|
||||
eth=$(nmcli -t -f TYPE,STATE device status 2>/dev/null | grep -c '^ethernet:connected')
|
||||
[ "$eth" -gt 0 ] && { echo "ethernet"; return; }
|
||||
ssid=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | awk -F: '$2 ~ /wireless/ {print $1; exit}')
|
||||
[ -n "$ssid" ] && echo "$ssid" || echo "on, not connected"
|
||||
}
|
||||
bt_status() {
|
||||
have bluetoothctl || { echo "unavailable"; return; }
|
||||
bluetoothctl show 2>/dev/null | grep -q "Powered: yes" || { echo "off"; return; }
|
||||
local n
|
||||
n=$(bluetoothctl devices Connected 2>/dev/null | grep -c '^Device')
|
||||
[ "${n:-0}" -gt 0 ] && echo "$n connected" || echo "on"
|
||||
}
|
||||
vol_status() {
|
||||
have wpctl || { echo "unavailable"; return; }
|
||||
local out
|
||||
out=$(wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null) || { echo "unavailable"; return; }
|
||||
grep -q MUTED <<<"$out" && { echo "muted"; return; }
|
||||
awk '{printf "%d%%", $2*100}' <<<"$out"
|
||||
}
|
||||
mic_status() {
|
||||
have wpctl || { echo "unavailable"; return; }
|
||||
local out
|
||||
out=$(wpctl get-volume @DEFAULT_AUDIO_SOURCE@ 2>/dev/null) || { echo "unavailable"; return; }
|
||||
grep -q MUTED <<<"$out" && echo "muted" || awk '{printf "%d%%", $2*100}' <<<"$out"
|
||||
}
|
||||
vpn_status() {
|
||||
local ifs
|
||||
ifs=$(ip -br link show type wireguard 2>/dev/null | awk '{print $1}' | paste -sd, -)
|
||||
[ -n "$ifs" ] && echo "$ifs" || echo "off"
|
||||
}
|
||||
dnd_status() {
|
||||
have dunstctl || { echo "unavailable"; return; }
|
||||
[ "$(dunstctl is-paused 2>/dev/null)" = true ] && echo "paused" || echo "on"
|
||||
}
|
||||
display_status() {
|
||||
have xrandr || { echo "unavailable"; return; }
|
||||
xrandr --current 2>/dev/null | awk '/\*/{print $1; exit}'
|
||||
}
|
||||
bar_status() { pgrep -x polybar >/dev/null 2>&1 && echo "shown" || echo "hidden"; }
|
||||
particles_status() {
|
||||
have particles || { echo "not installed"; return; }
|
||||
particles --status 2>/dev/null | grep -q running && echo "on" || echo "off"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- submenus
|
||||
wifi_menu() {
|
||||
local radio choice ssid pass
|
||||
radio=$(nmcli -t -f WIFI radio 2>/dev/null)
|
||||
{
|
||||
[ "$radio" = enabled ] && echo " Turn Wi-Fi off" || echo " Turn Wi-Fi on"
|
||||
echo " Rescan"
|
||||
if [ "$radio" = enabled ]; then
|
||||
nmcli -t -f IN-USE,SIGNAL,SECURITY,SSID device wifi list 2>/dev/null |
|
||||
awk -F: 'length($4){
|
||||
icon = ($1=="*") ? "" : (($3=="" || $3=="--") ? "" : "")
|
||||
printf "%s %s\t%s%%\n", icon, $4, $2 }' | sort -u -k1,1
|
||||
fi
|
||||
} > /tmp/.cc-wifi.$$
|
||||
choice=$(menu "Wi-Fi" < /tmp/.cc-wifi.$$); rm -f /tmp/.cc-wifi.$$
|
||||
[ -z "$choice" ] && return
|
||||
case "$choice" in
|
||||
*"Turn Wi-Fi off") nmcli radio wifi off; notify "Wi-Fi" "Turned off" ;;
|
||||
*"Turn Wi-Fi on") nmcli radio wifi on; notify "Wi-Fi" "Turned on" ;;
|
||||
*Rescan) nmcli device wifi rescan >/dev/null 2>&1; notify "Wi-Fi" "Rescanning"; wifi_menu ;;
|
||||
*) ssid=$(sed 's/^[^ ]* //; s/\t.*$//' <<<"$choice")
|
||||
if nmcli -t -f NAME connection show 2>/dev/null | grep -qx "$ssid"; then
|
||||
nmcli connection up id "$ssid" >/dev/null 2>&1 \
|
||||
&& notify "Wi-Fi" "Connected to $ssid" || notify -u critical "Wi-Fi" "Failed: $ssid"
|
||||
else
|
||||
pass=$(menu "Password for $ssid" -password)
|
||||
[ -z "$pass" ] && return
|
||||
nmcli device wifi connect "$ssid" password "$pass" >/dev/null 2>&1 \
|
||||
&& notify "Wi-Fi" "Connected to $ssid" || notify -u critical "Wi-Fi" "Failed: $ssid"
|
||||
fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bt_menu() {
|
||||
local powered choice mac
|
||||
powered=$(bluetoothctl show 2>/dev/null | grep -q "Powered: yes" && echo yes || echo no)
|
||||
{
|
||||
[ "$powered" = yes ] && echo " Turn Bluetooth off" || echo " Turn Bluetooth on"
|
||||
if [ "$powered" = yes ]; then
|
||||
echo " Scan for devices"
|
||||
bluetoothctl devices 2>/dev/null | while read -r _ mac name; do
|
||||
if bluetoothctl info "$mac" 2>/dev/null | grep -q "Connected: yes"; then
|
||||
printf " %s\t%s\n" "$name" "$mac"
|
||||
else
|
||||
printf " %s\t%s\n" "$name" "$mac"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
} > /tmp/.cc-bt.$$
|
||||
choice=$(menu "Bluetooth" < /tmp/.cc-bt.$$); rm -f /tmp/.cc-bt.$$
|
||||
[ -z "$choice" ] && return
|
||||
case "$choice" in
|
||||
*"Turn Bluetooth off") bluetoothctl power off >/dev/null; notify "Bluetooth" "Turned off" ;;
|
||||
*"Turn Bluetooth on") bluetoothctl power on >/dev/null; notify "Bluetooth" "Turned on" ;;
|
||||
*"Scan for devices") notify "Bluetooth" "Scanning for 10s"
|
||||
bluetoothctl --timeout 10 scan on >/dev/null 2>&1; bt_menu ;;
|
||||
*) mac=$(awk -F'\t' '{print $2}' <<<"$choice")
|
||||
[ -z "$mac" ] && return
|
||||
if bluetoothctl info "$mac" 2>/dev/null | grep -q "Connected: yes"; then
|
||||
bluetoothctl disconnect "$mac" >/dev/null && notify "Bluetooth" "Disconnected"
|
||||
else
|
||||
notify "Bluetooth" "Connecting..."
|
||||
bluetoothctl connect "$mac" >/dev/null 2>&1 \
|
||||
&& notify "Bluetooth" "Connected" || notify -u critical "Bluetooth" "Connection failed"
|
||||
fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
audio_menu() {
|
||||
local choice sink
|
||||
{
|
||||
echo " Toggle mute $(vol_status)"
|
||||
echo " Volume down -5%"
|
||||
echo " Volume up +5%"
|
||||
echo " Toggle mic mute $(mic_status)"
|
||||
pactl list short sinks 2>/dev/null | while read -r id name _; do
|
||||
printf " %s\t%s\n" "$(pactl list sinks 2>/dev/null | grep -A2 "Name: $name" | awk -F': ' '/Description/{print $2; exit}')" "$name"
|
||||
done
|
||||
} > /tmp/.cc-audio.$$
|
||||
choice=$(menu "Audio" < /tmp/.cc-audio.$$); rm -f /tmp/.cc-audio.$$
|
||||
[ -z "$choice" ] && return
|
||||
case "$choice" in
|
||||
*"Toggle mute") wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle; audio_menu ;;
|
||||
*"Volume down"*) wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-; audio_menu ;;
|
||||
*"Volume up"*) wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ -l 1.0; audio_menu ;;
|
||||
*"Toggle mic mute") wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle; audio_menu ;;
|
||||
*) sink=$(awk -F'\t' '{print $2}' <<<"$choice")
|
||||
[ -n "$sink" ] && pactl set-default-sink "$sink" && notify "Audio" "Output switched" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
vpn_menu() {
|
||||
local choice iface up
|
||||
{
|
||||
for iface in $(ip -br link show type wireguard 2>/dev/null | awk '{print $1}'); do
|
||||
printf " %s\tdown\n" "$iface"
|
||||
done
|
||||
# wg-quick@<name> units systemd knows about, minus the ones already up
|
||||
systemctl list-units --all --type=service --no-legend 'wg-quick@*' 2>/dev/null |
|
||||
awk '{print $1}' | sed 's/^wg-quick@//; s/\.service$//' | while read -r n; do
|
||||
[ -z "$n" ] && continue
|
||||
ip link show "$n" >/dev/null 2>&1 || printf " %s\tup\n" "$n"
|
||||
done
|
||||
} > /tmp/.cc-vpn.$$
|
||||
if [ ! -s /tmp/.cc-vpn.$$ ]; then
|
||||
echo " No WireGuard interfaces found" > /tmp/.cc-vpn.$$
|
||||
echo " Start one by name…" >> /tmp/.cc-vpn.$$
|
||||
fi
|
||||
choice=$(menu "VPN" < /tmp/.cc-vpn.$$); rm -f /tmp/.cc-vpn.$$
|
||||
[ -z "$choice" ] && return
|
||||
case "$choice" in
|
||||
*"No WireGuard"*) return ;;
|
||||
*"Start one by name"*)
|
||||
iface=$(menu "Interface name (e.g. wg0)")
|
||||
[ -z "$iface" ] && return
|
||||
systemctl start "wg-quick@${iface}" && notify "VPN" "$iface up" \
|
||||
|| notify -u critical "VPN" "Could not start $iface" ;;
|
||||
*) iface=$(sed 's/^[^ ]* //; s/\t.*$//' <<<"$choice")
|
||||
up=$(awk -F'\t' '{print $2}' <<<"$choice")
|
||||
if [ "$up" = down ]; then
|
||||
systemctl stop "wg-quick@${iface}" && notify "VPN" "$iface down" \
|
||||
|| notify -u critical "VPN" "Could not stop $iface"
|
||||
else
|
||||
systemctl start "wg-quick@${iface}" && notify "VPN" "$iface up" \
|
||||
|| notify -u critical "VPN" "Could not start $iface"
|
||||
fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
lock_screen() {
|
||||
if have betterlockscreen; then betterlockscreen -l blur
|
||||
elif have i3lock; then i3lock -c 0f0d17
|
||||
elif have xsecurelock; then xsecurelock &
|
||||
elif have slock; then slock
|
||||
else notify -u critical "Lock" "No screen locker installed (try i3lock)"; return 1
|
||||
fi
|
||||
}
|
||||
|
||||
power_menu() {
|
||||
local choice
|
||||
choice=$(printf '%s\n' \
|
||||
" Lock" \
|
||||
" Log out" \
|
||||
" Suspend" \
|
||||
" Reboot" \
|
||||
" Shut down" | menu "Power")
|
||||
[ -z "$choice" ] && return # escape here goes back to the panel
|
||||
CC_EXIT=1
|
||||
case "$choice" in
|
||||
*Lock) lock_screen ;;
|
||||
*"Log out") bspc quit ;;
|
||||
*Suspend) lock_screen; systemctl suspend ;;
|
||||
*Reboot) systemctl reboot ;;
|
||||
*"Shut down") systemctl poweroff ;;
|
||||
esac
|
||||
}
|
||||
|
||||
notif_menu() {
|
||||
local choice paused
|
||||
paused=$(dunstctl is-paused 2>/dev/null)
|
||||
choice=$(printf '%s\n' \
|
||||
"$([ "$paused" = true ] && echo " Resume notifications" || echo " Do not disturb")" \
|
||||
" Show last notification" \
|
||||
" Show all history" \
|
||||
" Clear all" | menu "Notifications")
|
||||
case "$choice" in
|
||||
*"Do not disturb") dunstctl set-paused true; notify "Notifications" "Paused" ;;
|
||||
*"Resume notifications") dunstctl set-paused false; notify "Notifications" "Resumed" ;;
|
||||
*"Show last notification") dunstctl history-pop ;;
|
||||
*"Show all history") for _ in $(seq 6); do dunstctl history-pop; done ;;
|
||||
*"Clear all") dunstctl close-all ;;
|
||||
esac
|
||||
}
|
||||
|
||||
display_menu() {
|
||||
local out choice mode
|
||||
out=$(xrandr --current 2>/dev/null | awk '/ connected/{print $1; exit}')
|
||||
[ -z "$out" ] && { notify -u critical "Display" "No output found"; return; }
|
||||
choice=$(xrandr --current 2>/dev/null | sed -n "/^$out connected/,/^[^ ]/p" |
|
||||
awk '/^ /{printf " %s\t%s\n", $1, $2}' | head -12 | menu "Display ($out)")
|
||||
[ -z "$choice" ] && return
|
||||
mode=$(sed 's/^[^ ]* //; s/\t.*$//' <<<"$choice")
|
||||
xrandr --output "$out" --mode "$mode" && notify "Display" "$out set to $mode"
|
||||
}
|
||||
|
||||
shot_menu() {
|
||||
local choice
|
||||
have flameshot || { notify -u critical "Screenshot" "flameshot is not installed"; return; }
|
||||
choice=$(printf '%s\n' \
|
||||
" Region" \
|
||||
" Full screen" \
|
||||
" Region, delayed 3s" | menu "Screenshot")
|
||||
[ -z "$choice" ] && return
|
||||
CC_EXIT=1
|
||||
case "$choice" in
|
||||
*Region) flameshot gui ;;
|
||||
*"Full screen") flameshot full -c -p "$HOME/pictures" 2>/dev/null || flameshot full -c ;;
|
||||
*"delayed 3s") flameshot gui -d 3000 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- main panel
|
||||
main_menu() {
|
||||
local choice
|
||||
choice=$(printf '%s\n' \
|
||||
" Network $(wifi_status)" \
|
||||
" Bluetooth $(bt_status)" \
|
||||
" Audio $(vol_status)" \
|
||||
" Microphone $(mic_status)" \
|
||||
" VPN $(vpn_status)" \
|
||||
" Display $(display_status)" \
|
||||
" Notifications $(dnd_status)" \
|
||||
" Clipboard" \
|
||||
" Emoji" \
|
||||
" Screenshot" \
|
||||
" Wallpaper shuffle" \
|
||||
" Particles $(particles_status)" \
|
||||
" Bar $(bar_status)" \
|
||||
" Power" | menu "Control")
|
||||
[ -z "$choice" ] && { CC_EXIT=1; return; }
|
||||
case "$choice" in
|
||||
*) wifi_menu ;;
|
||||
*) bt_menu ;;
|
||||
*) audio_menu ;;
|
||||
*) have wpctl && wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
|
||||
notify "Microphone" "Now $(mic_status)" ;;
|
||||
*) vpn_menu ;;
|
||||
*) display_menu ;;
|
||||
*) notif_menu ;;
|
||||
*) CC_EXIT=1; sh "$HOME/.config/scripts/rofi-menus/clipboard-manager.sh" ;;
|
||||
*) CC_EXIT=1; sh "$HOME/.config/scripts/rofi-menus/rofimoji.sh" ;;
|
||||
*) shot_menu ;;
|
||||
*) sh "$HOME/.config/scripts/set-wallpaper.sh" >/dev/null 2>&1
|
||||
notify "Wallpaper" "Shuffled" ;;
|
||||
*) if have particles; then particles >/dev/null 2>&1
|
||||
notify "Particles" "Now $(particles_status)"
|
||||
else notify -u critical "Particles" "Not installed"; fi ;;
|
||||
*) sh "$HOME/.config/scripts/toggle-bar.sh" ;;
|
||||
*) power_menu ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while [ "$CC_EXIT" -eq 0 ]; do
|
||||
main_menu
|
||||
done
|
||||
627
.config/scripts/control-panel.py
Executable file
627
.config/scripts/control-panel.py
Executable file
|
|
@ -0,0 +1,627 @@
|
|||
#!/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"<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 *_: (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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
rofimoji --selector-args="-theme ~/.config/rofi/emoji.rasi -kb-row-left Left -kb-row-right Right -kb-move-char-back Control+b -kb-move-char-forward Control+f" --hidden-descriptions --action copy --max-recent 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue