aboutsummaryrefslogtreecommitdiffstats
path: root/bin/wb-icon
diff options
context:
space:
mode:
Diffstat (limited to 'bin/wb-icon')
-rwxr-xr-xbin/wb-icon171
1 files changed, 171 insertions, 0 deletions
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))