aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 12:58:31 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 12:58:31 +0200
commite996b6626c85f8cefb0f357d128091e350ee0ec0 (patch)
treee74b93bb7a7c0451f7e247d541b2b428b096df19 /bin
downloadwaybar-theme-udt-e996b6626c85f8cefb0f357d128091e350ee0ec0.tar.gz
waybar-theme-udt-e996b6626c85f8cefb0f357d128091e350ee0ec0.zip
feat: a waybar bar for DP-3, themed by udt and drawn with icons
Builds the bar from scratch as a consumer of unified-desktop-theme: the palette comes from there, the layout and the modules live here. No font glyphs anywhere. Every symbol is a real icon from the icon theme GTK already uses, which needs all three of waybar's mechanisms because no single one covers everything: the icon-theme config key for the taskbar and tray, `image` modules driving wb-icon/wb-lang for volume, microphone, presentation mode and the keyboard flag, and CSS background-image for the workspaces, the clock pair and the launcher. The workspace icons have to be CSS because format-icons takes text rather than paths, and faking the strip with eight custom modules would have cost click-to-switch and the active and urgent states. The language flag goes the other way: hyprland/language styles as #language with no per-language class, so the flag could not be selected, and it is an image module. Icons keep their own colours: 2466 of the theme's 5169 panel icons carry a hardcoded gradient and are not recolourable, which is the intended look here. The two icons the theme lacks are shipped instead, and the monochrome Slackware mark takes the udt accent at install time. vms_dots.sh and privacy_dots.sh lived only in ~/bin, in no repository at all. They are versioned here now, symlinked back, and their hardcoded state colours read from the palette. Installs alongside the existing bar rather than over it, so DP-1 keeps working while this one is tried on DP-3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'bin')
-rwxr-xr-xbin/privacy_dots.sh199
-rwxr-xr-xbin/vms_dots.sh89
-rwxr-xr-xbin/wb-icon171
-rwxr-xr-xbin/wb-idle45
-rwxr-xr-xbin/wb-lang46
5 files changed, 550 insertions, 0 deletions
diff --git a/bin/privacy_dots.sh b/bin/privacy_dots.sh
new file mode 100755
index 0000000..2074631
--- /dev/null
+++ b/bin/privacy_dots.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+# A dot per active capture: microphone, camera, location, screen sharing.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# dependencies: pipewire (pw-dump), jq, psmisc (fuser)
+#
+# Shows nothing when everything is off, which is the point: the pill only
+# appears when something is actually capturing.
+#
+# The colours come from the palette, not from here: install.sh writes
+# ~/.config/waybar-udt/dots.colors from the selected udt scheme, and this
+# sources it. The defaults below are only what a missing file falls back to.
+set -euo pipefail
+
+JQ_BIN="${JQ:-jq}"
+PW_DUMP_CMD="${PW_DUMP:-pw-dump}"
+
+# Palette. Sourced, with fallbacks, so this script works standalone.
+DOT_SUCCESS="#a6da95"
+DOT_WARNING="#eed49f"
+DOT_CRITICAL="#ed8796"
+DOT_INFO="#8bd5ca"
+DOT_ACCENT="#b7bdf8"
+DOT_FAINT="#6e738d"
+colors="$HOME/.config/waybar-udt/dots.colors"
+# shellcheck source=/dev/null
+[[ -r "$colors" ]] && . "$colors"
+
+mic=0
+cam=0
+loc=0
+scr=0
+
+mic_app=""
+cam_app=""
+loc_app=""
+scr_app=""
+
+# mic & camera
+if command -v "$PW_DUMP_CMD" >/dev/null 2>&1 && command -v "$JQ_BIN" >/dev/null 2>&1; then
+ dump="$($PW_DUMP_CMD 2>/dev/null || true)"
+
+ mic="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Audio/Source" or .info.props."media.class"=="Audio/Source/Virtual"))
+ | select((.info.state=="running") or (.state=="running"))
+ ] | (if length>0 then 1 else 0 end)
+ ' 2>/dev/null || echo 0
+ )"
+
+ if [[ "$mic" -eq 1 ]]; then
+ mic_app="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Stream/Input/Audio"))
+ | select((.info.state=="running") or (.state=="running"))
+ | .info.props["node.name"]
+ ] | unique | join(", ")
+ ' 2>/dev/null || echo ""
+ )"
+ fi
+
+ if command -v fuser >/dev/null 2>&1; then
+ cam=0
+ for dev in /dev/video*; do
+ if [ -e "$dev" ] && fuser "$dev" >/dev/null 2>&1; then
+ cam=1
+ break
+ fi
+ done
+ else
+ cam=0
+ fi
+
+ if command -v fuser >/dev/null 2>&1; then
+ for dev in /dev/video*; do
+ if [ -e "$dev" ] && fuser "$dev" >/dev/null 2>&1; then
+ pids=$(fuser "$dev" 2>/dev/null)
+ for pid in $pids; do
+ pname=$(ps -p "$pid" -o comm=)
+ if [[ -n "$pname" ]]; then
+ cam_app+="$pname, "
+ fi
+ done
+ fi
+ done
+ cam_app="${cam_app%, }"
+ fi
+
+fi
+
+# location
+if command -v gdbus >/dev/null 2>&1; then
+ if pids=$(pgrep -x geoclue); then
+ loc=1
+ for pid in $pids; do
+ pname=$(ps -p "$pid" -o comm=)
+ [[ -n "$pname" ]] && loc_app+="$pname, "
+ done
+ loc_app="${loc_app%, }"
+ else
+ loc=0
+ fi
+fi
+
+# screen sharing
+if command -v "$PW_DUMP_CMD" >/dev/null 2>&1 && command -v "$JQ_BIN" >/dev/null 2>&1; then
+ if [[ -z "${dump:-}" ]]; then
+ dump="$($PW_DUMP_CMD 2>/dev/null || true)"
+ fi
+
+ scr="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -e '
+ [ .[]
+ | select(.info?.props?)
+ | select(
+ (.info.props["media.name"]? // "")
+ | test("^(xdph-streaming|gsr-default)")
+ )
+ ]
+ | (if length > 0 then true else false end)
+ ' >/dev/null && echo 1 || echo 0
+ )"
+fi
+
+if [[ "$scr" -eq 1 ]]; then
+ scr_app="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Stream/Input/Video") or (.info.props."media.name"=="gsr-default_output"))
+ | select((.info.state=="running") or (.state=="running"))
+ | .info.props["media.name"]
+ ] | unique | join(", ")
+ ' 2>/dev/null || echo ""
+ )"
+fi
+
+# output. Capture is a state worth noticing, so these are the alert roles:
+# the microphone is the quiet one, the camera and the screen are not.
+dot() {
+ local on="$1" color="$2"
+ if [[ "$on" -eq 1 ]]; then
+ printf '<span foreground="%s">●</span>' "$color"
+ else
+ printf ''
+ fi
+}
+
+dots=()
+mic_dot="$(dot "$mic" "$DOT_SUCCESS")"; [[ -n "$mic_dot" ]] && dots+=("$mic_dot")
+cam_dot="$(dot "$cam" "$DOT_WARNING")"; [[ -n "$cam_dot" ]] && dots+=("$cam_dot")
+loc_dot="$(dot "$loc" "$DOT_INFO")"; [[ -n "$loc_dot" ]] && dots+=("$loc_dot")
+scr_dot="$(dot "$scr" "$DOT_CRITICAL")"; [[ -n "$scr_dot" ]] && dots+=("$scr_dot")
+
+text="${dots[*]}"
+
+if [[ -n "$mic_app" ]]; then
+ mic_status="Mic: $mic_app"
+else
+ mic_status="Mic: off"
+fi
+
+if [[ -n "$cam_app" ]]; then
+ cam_status="Cam: $cam_app"
+else
+ cam_status="Cam: off"
+fi
+
+if [[ -n "$loc_app" ]]; then
+ loc_status="Location: $loc_app"
+else
+ loc_status="Location: off"
+fi
+
+if [[ -n "$scr_app" ]]; then
+ scr_status="Screen sharing: $scr_app"
+else
+ scr_status="Screen sharing: off"
+fi
+
+tooltip="$mic_status | $cam_status | $loc_status | $scr_status"
+
+classes="privacydot"
+[[ $mic -eq 1 ]] && classes="$classes mic-on" || classes="$classes mic-off"
+[[ $cam -eq 1 ]] && classes="$classes cam-on" || classes="$classes cam-off"
+[[ $loc -eq 1 ]] && classes="$classes loc-on" || classes="$classes loc-off"
+[[ $scr -eq 1 ]] && classes="$classes scr-on" || classes="$classes scr-off"
+
+$JQ_BIN -c -n --arg text "$text" --arg tooltip "$tooltip" --arg class "$classes" \
+ '{text:$text, tooltip:$tooltip, class:$class}'
diff --git a/bin/vms_dots.sh b/bin/vms_dots.sh
new file mode 100755
index 0000000..bf95070
--- /dev/null
+++ b/bin/vms_dots.sh
@@ -0,0 +1,89 @@
+#! /bin/bash
+# One dot per defined VM, coloured by state.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# dependencies: libvirt (virsh), jq
+#
+# The colours come from the palette, not from here: install.sh writes
+# ~/.config/waybar-udt/dots.colors from the selected udt scheme, and this
+# sources it. The defaults below are only what a missing file falls back to,
+# so the bar still draws if the theme has not been installed yet.
+set -euo pipefail
+
+JQ_BIN="${JQ:-jq}"
+
+# Palette. Sourced, with fallbacks, so this script works standalone.
+DOT_SUCCESS="#a6da95"
+DOT_WARNING="#eed49f"
+DOT_CRITICAL="#ed8796"
+DOT_INFO="#8bd5ca"
+DOT_ACCENT="#b7bdf8"
+DOT_FAINT="#6e738d"
+colors="$HOME/.config/waybar-udt/dots.colors"
+# shellcheck source=/dev/null
+[[ -r "$colors" ]] && . "$colors"
+
+# Require virsh
+if ! command -v virsh >/dev/null 2>&1; then
+ $JQ_BIN -cn '{text: "", tooltip: "virsh not found", class: "vmdot"}'
+ exit 0
+fi
+
+# Collect all defined VMs and their states.
+# virsh list --all columns: Id Name State (state may be multi-word, e.g. "shut off")
+declare -A vm_states
+while IFS= read -r line; do
+ # Skip header lines and separator lines
+ [[ "$line" =~ ^[[:space:]]*Id[[:space:]] ]] && continue
+ [[ "$line" =~ ^[-[:space:]]+$ ]] && continue
+ [[ -z "${line// }" ]] && continue
+
+ # Fields: id (may be "-"), name, state (rest of line)
+ read -r _id name state_rest <<< "$line"
+ vm_states["$name"]="$state_rest"
+done < <(virsh list --all 2>/dev/null)
+
+if [[ ${#vm_states[@]} -eq 0 ]]; then
+ $JQ_BIN -cn '{text: "", tooltip: "No VMs defined", class: "vmdot"}'
+ exit 0
+fi
+
+# libvirt state → palette role.
+declare -A state_color=(
+ ["running"]="$DOT_SUCCESS"
+ ["idle"]="$DOT_WARNING" # running but idle
+ ["paused"]="$DOT_WARNING"
+ ["pmsuspended"]="$DOT_ACCENT" # PM sleep / ACPI S3
+ ["saved"]="$DOT_INFO" # managed save
+ ["crashed"]="$DOT_CRITICAL"
+ ["shut off"]="$DOT_FAINT"
+)
+fallback_color="$DOT_FAINT" # any unknown future state
+
+dots=()
+tooltip_parts=()
+classes="vmdot"
+
+# Sort VM names for stable ordering
+mapfile -t sorted_names < <(printf '%s\n' "${!vm_states[@]}" | sort)
+
+for name in "${sorted_names[@]}"; do
+ state="${vm_states[$name]}"
+ color="${state_color[$state]:-$fallback_color}"
+ # Derive a CSS-safe class token (spaces → hyphens)
+ state_class="${state// /-}"
+
+ dots+=("<span foreground=\"${color}\">●</span>")
+ classes="$classes vm-${state_class}"
+ tooltip_parts+=("${name}: ${state}")
+done
+
+text="${dots[*]}"
+tooltip="$(IFS=' | '; echo "${tooltip_parts[*]}")"
+
+$JQ_BIN -cn \
+ --arg text "$text" \
+ --arg tooltip "$tooltip" \
+ --arg class "$classes" \
+ '{text: $text, tooltip: $tooltip, class: $class}'
diff --git a/bin/wb-icon b/bin/wb-icon
new file mode 100755
index 0000000..9c29c60
--- /dev/null
+++ b/bin/wb-icon
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+# Resolve a module state to an icon path from the desktop's icon theme.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's `image` module runs a command and reads "$path\n$tooltip" from its
+# stdout. That is the only way a themed icon reaches a module that is not the
+# taskbar or the tray, so volume, microphone and presentation mode all come
+# through here.
+#
+# wb-icon volume # reads wpctl, prints the matching icon
+# wb-icon mic
+# wb-icon idle <activated|deactivated>
+# wb-icon --selftest # every name this bar asks for must resolve
+#
+# Icons keep their own colours: this desktop's icon theme draws them with
+# baked-in gradients, so they are not recolourable and are not meant to be.
+# Only the two icons this repo ships are monochrome.
+
+import subprocess
+import sys
+
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk # noqa: E402 (must follow require_version)
+
+# The icon theme to draw from. Kept in one place because changing it is a
+# single edit, and because --selftest needs the same value the modules use.
+THEME = "Material-Black-Plum-Suru"
+
+SIZE = 22
+
+# State to icon name. Every name here is checked by --selftest, so a theme that
+# drops one fails at install time rather than rendering an empty pill.
+ICONS = {
+ "volume": {
+ "muted": "audio-volume-muted",
+ "low": "audio-volume-low",
+ "medium": "audio-volume-medium",
+ "high": "audio-volume-high",
+ },
+ "mic": {
+ "muted": "microphone-sensitivity-muted",
+ "on": "audio-input-microphone",
+ },
+ "idle": {
+ "activated": "x-office-presentation",
+ "deactivated": "preferences-desktop-screensaver",
+ },
+}
+
+
+def lookup(name):
+ """Absolute path of an icon in THEME, or None."""
+ theme = Gtk.IconTheme.new()
+ theme.set_custom_theme(THEME)
+ info = theme.lookup_icon(name, SIZE, 0)
+ return info.get_filename() if info else None
+
+
+def wpctl(node):
+ """(volume percent, muted) for a wireplumber node, or (None, False).
+
+ wpctl prints e.g. "Volume: 0.62" or "Volume: 0.62 [MUTED]".
+ """
+ try:
+ out = subprocess.run(
+ ["wpctl", "get-volume", node],
+ capture_output=True, text=True, timeout=2,
+ ).stdout
+ except (OSError, subprocess.SubprocessError):
+ return None, False
+ if "Volume:" not in out:
+ return None, False
+ muted = "MUTED" in out
+ try:
+ return round(float(out.split("Volume:")[1].split()[0]) * 100), muted
+ except (IndexError, ValueError):
+ return None, muted
+
+
+def volume_state(pct, muted):
+ if muted or pct == 0:
+ return "muted"
+ if pct < 34:
+ return "low"
+ if pct < 67:
+ return "medium"
+ return "high"
+
+
+def emit(name, tooltip):
+ """Print what the image module expects: a path, then a tooltip."""
+ path = lookup(name)
+ if not path:
+ # An unresolved icon is a broken bar, not a warning: say so on stderr
+ # and print nothing, so the module stays empty rather than showing a
+ # stale icon.
+ print(f"wb-icon: no icon named {name!r} in {THEME}", file=sys.stderr)
+ return 1
+ print(path)
+ print(tooltip)
+ return 0
+
+
+def selftest():
+ """Every icon this bar asks for, including the ones set in CSS."""
+ # The workspace icons live in styles/modules.css rather than here, because
+ # waybar's format-icons takes text and not paths. They are still this
+ # bar's icons, so they are checked here too.
+ workspace_icons = [
+ "web-browser", "utilities-terminal", "text-editor", "network-server",
+ "document-edit", "applications-graphics", "internet-chat",
+ "input-gaming",
+ ]
+ clock_icons = ["x-office-calendar", "clock"]
+
+ names = sorted(
+ {n for group in ICONS.values() for n in group.values()}
+ | set(workspace_icons) | set(clock_icons)
+ )
+ missing = [n for n in names if not lookup(n)]
+ for n in names:
+ print(f" {'ok ' if n not in missing else 'MISS'} {n}")
+ if missing:
+ print(f"\n{len(missing)} icon(s) missing from {THEME}", file=sys.stderr)
+ return 1
+ print(f"\nall {len(names)} icons resolve in {THEME}")
+ return 0
+
+
+def main(argv):
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
+ print(__doc__ or "usage: wb-icon <volume|mic|idle|--selftest> [state]")
+ return 0
+
+ what = argv[1]
+
+ if what == "--selftest":
+ return selftest()
+
+ if what == "volume":
+ pct, muted = wpctl("@DEFAULT_AUDIO_SINK@")
+ if pct is None:
+ return emit(ICONS["volume"]["muted"], "Volume: unavailable")
+ state = volume_state(pct, muted)
+ label = "muted" if state == "muted" else f"{pct}%"
+ return emit(ICONS["volume"][state], f"Volume: {label}")
+
+ if what == "mic":
+ pct, muted = wpctl("@DEFAULT_AUDIO_SOURCE@")
+ if pct is None:
+ return emit(ICONS["mic"]["muted"], "Microphone: unavailable")
+ state = "muted" if muted or pct == 0 else "on"
+ label = "muted" if state == "muted" else f"{pct}%"
+ return emit(ICONS["mic"][state], f"Microphone: {label}")
+
+ if what == "idle":
+ state = argv[2] if len(argv) > 2 else "deactivated"
+ state = state if state in ICONS["idle"] else "deactivated"
+ on = state == "activated"
+ return emit(ICONS["idle"][state],
+ f"Presentation mode: {'on' if on else 'off'}")
+
+ print(f"wb-icon: unknown subject {what!r}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/bin/wb-idle b/bin/wb-idle
new file mode 100755
index 0000000..05c4a8c
--- /dev/null
+++ b/bin/wb-idle
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Presentation mode: keep the screen awake, and say so with a themed icon.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's own idle_inhibitor module draws its state with format-icons, which
+# is text. This drives the same idea through hypridle and reports the state as
+# an icon path for an `image` module instead.
+#
+# wb-idle status # print "$path\n$tooltip" for the image module
+# wb-idle toggle # flip the inhibitor, then refresh the module
+#
+# The inhibitor is hypridle's own pause, so nothing else has to be running.
+set -euo pipefail
+
+state_file="${XDG_RUNTIME_DIR:-/tmp}/wb-idle.state"
+
+active() { [[ -f "$state_file" ]]; }
+
+case "${1:-status}" in
+toggle)
+ if active; then
+ rm -f "$state_file"
+ # Resume idling. Guarded: hypridle may not be running, and that is
+ # not an error worth failing a click over.
+ pkill -USR2 -x hypridle 2>/dev/null || true
+ else
+ touch "$state_file"
+ pkill -USR1 -x hypridle 2>/dev/null || true
+ fi
+ # Refresh the module now rather than waiting for its interval.
+ pkill -RTMIN+9 -x waybar 2>/dev/null || true
+ ;;
+status)
+ if active; then
+ wb-icon idle activated
+ else
+ wb-icon idle deactivated
+ fi
+ ;;
+*)
+ echo "usage: wb-idle <status|toggle>" >&2
+ exit 2
+ ;;
+esac
diff --git a/bin/wb-lang b/bin/wb-lang
new file mode 100755
index 0000000..5a0cd5d
--- /dev/null
+++ b/bin/wb-lang
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Keyboard layout, as a flag.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's hyprland/language module styles as #language and nothing else: it
+# has no per-language CSS class, so a flag cannot be selected by a selector.
+# format-<lang> exists but takes text, not a path. So the flag arrives the
+# same way every other icon on this bar does, through an `image` module.
+#
+# wb-lang # print "$path\n$tooltip" for the image module
+#
+# The flags live next to this bar's config because the icon theme ships 13 and
+# neither Italian nor British is among them.
+set -euo pipefail
+
+flags="$HOME/.config/waybar-udt"
+
+# hyprctl names the layout in full ("Italian", "English (US)"), which is what
+# the keymap reports; match on that rather than on the short code, because the
+# short code is not in this output.
+layout="$(hyprctl devices -j 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ d = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+for k in d.get("keyboards", []):
+ if k.get("name") == "2.4g-dongle":
+ print(k.get("active_keymap", ""))
+ break' 2>/dev/null || true)"
+
+case "$layout" in
+Italian*) flag="flag-it.svg"; name="Italian" ;;
+English*) flag="flag-gb.svg"; name="English" ;;
+"") flag="flag-it.svg"; name="unknown" ;;
+*) flag="flag-gb.svg"; name="$layout" ;;
+esac
+
+if [[ ! -r "$flags/$flag" ]]; then
+ echo "wb-lang: missing $flags/$flag" >&2
+ exit 1
+fi
+
+echo "$flags/$flag"
+echo "Layout: $name"