diff --git a/.config/bspwm/picom.conf b/.config/bspwm/picom.conf index ccec51f..37df756 100644 --- a/.config/bspwm/picom.conf +++ b/.config/bspwm/picom.conf @@ -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'", +]; diff --git a/.config/bspwm/sxhkdrc b/.config/bspwm/sxhkdrc index a469ffd..1afad3a 100755 --- a/.config/bspwm/sxhkdrc +++ b/.config/bspwm/sxhkdrc @@ -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 diff --git a/.config/gtk-3.0/gtk.css b/.config/gtk-3.0/gtk.css index 6f163a7..92f5cd7 100644 --- a/.config/gtk-3.0/gtk.css +++ b/.config/gtk-3.0/gtk.css @@ -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; } diff --git a/.config/gtk-4.0/gtk.css b/.config/gtk-4.0/gtk.css index 6f163a7..92f5cd7 100644 --- a/.config/gtk-4.0/gtk.css +++ b/.config/gtk-4.0/gtk.css @@ -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; } diff --git a/.config/polybar/config.ini b/.config/polybar/config.ini index 2856bc4..0b16ca5 100755 --- a/.config/polybar/config.ini +++ b/.config/polybar/config.ini @@ -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 diff --git a/.config/rice/README.md b/.config/rice/README.md index de1d78d..cc963cc 100644 --- a/.config/rice/README.md +++ b/.config/rice/README.md @@ -36,21 +36,23 @@ the dotfiles checkout is moved to `~/.rice-backup-/` 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 | diff --git a/.config/rice/packages.txt b/.config/rice/packages.txt index 705f293..5948924 100644 --- a/.config/rice/packages.txt +++ b/.config/rice/packages.txt @@ -46,10 +46,10 @@ bluez-utils pamixer playerctl libnotify +python-gobject xclip flameshot i3lock -rofimoji aur:rofi-greenclip # --- apps --- diff --git a/.config/rofi/control.rasi b/.config/rofi/control.rasi index 0d6737b..18acebb 100644 --- a/.config/rofi/control.rasi +++ b/.config/rofi/control.rasi @@ -57,7 +57,7 @@ entry { } listview { - lines: 14; + lines: 13; columns: 1; fixed-height: false; scrollbar: false; diff --git a/.config/scripts/control-center.sh b/.config/scripts/control-center.sh deleted file mode 100755 index b66e562..0000000 --- a/.config/scripts/control-center.sh +++ /dev/null @@ -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 [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@ 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 diff --git a/.config/scripts/control-panel.py b/.config/scripts/control-panel.py new file mode 100755 index 0000000..e01d46d --- /dev/null +++ b/.config/scripts/control-panel.py @@ -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"{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() diff --git a/.config/scripts/rofi-menus/rofimoji.sh b/.config/scripts/rofi-menus/rofimoji.sh deleted file mode 100755 index 4dfc70d..0000000 --- a/.config/scripts/rofi-menus/rofimoji.sh +++ /dev/null @@ -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