diff options
Diffstat (limited to 'bin/wb-icon')
| -rwxr-xr-x | bin/wb-icon | 171 |
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)) |
