#!/usr/bin/env python3 # Resolve a module state to an icon path from the desktop's icon theme. # Copyright (C) 2026 Danilo M. # 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 --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", }, } 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"] # Presentation mode is a custom module: statusctl emits the state as a # class and modules.css picks the icon, so these names appear nowhere in # this file. They are still the bar's icons, so they are checked here. presentation_icons = [ "preferences-desktop-screensaver", # off "x-office-presentation", # on "dialog-warning", # no state file ] mail_icons = ["mail-unread"] names = sorted( {n for group in ICONS.values() for n in group.values()} | set(workspace_icons) | set(clock_icons) | set(presentation_icons) | set(mail_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 [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}") print(f"wb-icon: unknown subject {what!r}", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))