1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
|
#!/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 --path NAME # absolute path of one icon, for install.sh
# 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: whatever the desktop is set to. The appearance
# drawer switches it through gsettings, so reading that key is what keeps this
# bar following a switch instead of silently pointing at the old theme.
#
# The fallback only matters when gsettings is unreadable; GTK falls back to
# hicolor on its own if the named theme is missing.
FALLBACK_THEME = "Adwaita"
def icon_theme():
"""The desktop's icon theme name, per gsettings."""
try:
out = subprocess.run(
["gsettings", "get", "org.gnome.desktop.interface", "icon-theme"],
capture_output=True, text=True, timeout=2,
).stdout.strip()
except (OSError, subprocess.SubprocessError):
return FALLBACK_THEME
# gsettings quotes its answer: 'Material-Black-Plum-Suru'
return out.strip("'\"") or FALLBACK_THEME
THEME = icon_theme()
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.
GTK resolves the theme's own layout, so this works across icon themes
that disagree about subdirectory and file extension: the Suru themes keep
web-browser under categories/22/*.svg, elementary-xfce under apps/22/*.png.
Nothing outside this function may assume a path shape.
"""
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
"input-gaming", # held on by a running game
]
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 <volume|mic|--path NAME|--theme|--selftest>")
return 0
what = argv[1]
if what == "--selftest":
return selftest()
# install.sh bakes icon paths into the generated stylesheet, because CSS
# cannot look an icon up by name. It asks here so that the theme is
# resolved in exactly one place.
if what == "--path":
if len(argv) < 3:
print("wb-icon: --path needs an icon name", file=sys.stderr)
return 2
path = lookup(argv[2])
if not path:
print(f"wb-icon: no icon named {argv[2]!r} in {THEME}",
file=sys.stderr)
return 1
print(path)
return 0
if what == "--theme":
print(THEME)
return 0
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))
|