1#!/usr/bin/env python3
2"""Volume, Wi-Fi and battery icons with tooltips and right-click menus.
3
4 [[module]]
5 name = "status"
6 zone = "right"
7 exec = "~/.config/neferbar/modules/status.py"
8 interactive = true
9
10Each icon changes with its level; the numbers are in the tooltips.
11
12 volume wheel: volume left: mute middle: mixer right: menu
13 wifi left: settings right: networks
14 battery right: power profile
15
16Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
17whose tool is missing is not shown. The mixer (pavucontrol) and the network
18settings (nmtui in $TERMINAL) are optional. Edit the commands below to taste.
19"""
20
21import json
22import os
23import re
24import select
25import shutil
26import subprocess
27import sys
28import time
29
30TERMINAL = os.environ.get("TERMINAL", "kitty")
31MIXER = ["pavucontrol"]
32NETWORK_SETTINGS = ["nmtui"] # runs in TERMINAL; impala if you use iwd
33VOLUME_STEP = 2 # percent per wheel step
34TOOLTIP_DELAY = 0.4 # seconds of hover before a tooltip
35REFRESH = 5.0 # seconds between two reads of volume and battery
36WIFI_REFRESH = 10.0
37
38SINK = "@DEFAULT_AUDIO_SINK@"
39
40# Nerd Font (Material Design) icons, from empty to full.
41VOLUME_ICONS = ["", "", ""]
42VOLUME_MUTED = ""
43WIFI_ICONS = ["", "", "", "", ""]
44WIFI_OFF, WIFI_DOWN, ETHERNET = "", "", ""
45BATTERY_ICONS = ["", "", "", "", "", "", "", "", "", "", ""]
46BATTERY_CHARGING = ["", "", "", "", "", "", "", "", "", "", ""]
47
48BOLD, RESET = "\033[1m", "\033[0m"
49
50
51# --- helpers -----------------------------------------------------------------
52
53def run(*cmd):
54 """Output of a command, or "" when it fails."""
55 try:
56 return subprocess.run(cmd, capture_output=True, text=True, errors="replace",
57 timeout=3).stdout
58 except (OSError, subprocess.SubprocessError):
59 return ""
60
61
62def act(*cmd):
63 """Run an action the user asked for. A failure goes to stderr, which the
64 bar writes to its log."""
65 try:
66 done = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
67 timeout=10)
68 except (OSError, subprocess.SubprocessError) as err:
69 print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
70 return False
71 if done.returncode != 0:
72 print(f"{' '.join(cmd)}: {done.stderr.strip()}", file=sys.stderr, flush=True)
73 return done.returncode == 0
74
75
76def launch(*cmd):
77 """Start a program detached from the bar (its output never reaches the bar)."""
78 try:
79 subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
80 stderr=subprocess.DEVNULL, start_new_session=True)
81 except OSError as err:
82 print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
83
84
85def control(message):
86 """Ask the bar for a popup: a control line is never shown as text."""
87 print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
88
89
90def level(icons, percent):
91 """The icon of icons that matches percent (0..100)."""
92 percent = max(0, min(100, percent))
93 return icons[min(len(icons) - 1, percent * len(icons) // 101)]
94
95
96def read(path):
97 try:
98 with open(path) as f:
99 return f.read().strip()
100 except OSError:
101 return ""
102
103
104def number(s, kind=int):
105 """s as a number, 0 when it is not one."""
106 try:
107 return kind(s)
108 except ValueError:
109 return 0
110
111
112def rgb(hex_color):
113 h = hex_color.lstrip("#")
114 return f"{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}"
115
116
117def foreground(hex_color):
118 return f"\033[38;2;{rgb(hex_color)}m"
119
120
121def background(hex_color):
122 return f"\033[48;2;{rgb(hex_color)}m"
123
124
125# The bar drops blank cells at the end of a module; one painted with the bar's
126# own background is kept, and keeps the last icon off the next module.
127GAP = background(os.environ.get("NEFERBAR_BACKGROUND", "#000000")) + " " + "\033[0m"
128
129
130# --- volume ------------------------------------------------------------------
131
132class Volume:
133 available = shutil.which("wpctl") is not None
134
135 def read(self):
136 # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
137 out = run("wpctl", "get-volume", SINK).split()
138 self.percent = round(number(out[1], float) * 100) if len(out) > 1 else 0
139 self.muted = "[MUTED]" in out
140
141 def icon(self):
142 if self.muted or self.percent == 0:
143 return VOLUME_MUTED
144 return level(VOLUME_ICONS, self.percent)
145
146 def tooltip(self):
147 device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))
148 return "Volume", [
149 ("Level", f"{self.percent}%"),
150 ("Muted", "yes" if self.muted else "no"),
151 ("Output", device.group(1) if device else "?"),
152 ]
153
154 def menu(self):
155 items = [("Mute", "check", self.muted, lambda: self.mute())]
156 steps = [(f"{p}%", "radio", abs(self.percent - p) < 5, lambda p=p: self.set(p))
157 for p in (25, 50, 75, 100)]
158 items += [None, ("Volume", steps), None, ("Mixer", lambda: launch(*MIXER))]
159 return items
160
161 def click(self, button):
162 if button == "left":
163 self.mute()
164 elif button == "middle":
165 launch(*MIXER)
166
167 def scroll(self, direction, steps):
168 sign = "+" if direction in ("up", "right") else "-"
169 act("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
170
171 def mute(self):
172 act("wpctl", "set-mute", SINK, "toggle")
173
174 def set(self, percent):
175 act("wpctl", "set-volume", SINK, f"{percent}%")
176
177
178# --- wifi --------------------------------------------------------------------
179
180class Wifi:
181 available = shutil.which("nmcli") is not None
182
183 def read(self):
184 self.enabled = run("nmcli", "-t", "-f", "WIFI", "radio").strip() == "enabled"
185 self.ethernet = False
186 self.ssid, self.signal, self.freq = "", 0, ""
187 for line in run("nmcli", "-t", "-f", "TYPE,STATE", "device").splitlines():
188 kind, _, state = line.partition(":")
189 if kind == "ethernet" and state == "connected":
190 self.ethernet = True
191 self.networks = []
192 seen = set()
193 # SSID comes last: it is the only field that can hold a ':'.
194 for line in run("nmcli", "-t", "-f", "IN-USE,SIGNAL,FREQ,SSID",
195 "device", "wifi", "list", "--rescan", "no").splitlines():
196 fields = line.split(":", 3)
197 if len(fields) < 4:
198 continue
199 in_use, signal, freq = fields[0], number(fields[1]), fields[2]
200 ssid = re.sub(r"\\(.)", r"\1", fields[3]) # nmcli writes ':' as '\:'
201 if in_use == "*":
202 self.ssid, self.signal, self.freq = ssid, signal, freq
203 if ssid and ssid not in seen:
204 seen.add(ssid)
205 self.networks.append((ssid, signal))
206 self.networks.sort(key=lambda n: -n[1])
207
208 def icon(self):
209 if self.ethernet:
210 return ETHERNET
211 if not self.enabled:
212 return WIFI_OFF
213 return level(WIFI_ICONS, self.signal) if self.ssid else WIFI_DOWN
214
215 def tooltip(self):
216 if self.ethernet:
217 return "Ethernet", [("State", "connected")]
218 if not self.enabled:
219 return "Wi-Fi", [("State", "off")]
220 if not self.ssid:
221 return "Wi-Fi", [("State", "not connected")]
222 return "Wi-Fi", [
223 ("Network", self.ssid),
224 ("Signal", f"{self.signal}%"),
225 ("Frequency", self.freq),
226 ]
227
228 def menu(self):
229 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
230 lambda ssid=ssid: self.connect(ssid))
231 for ssid, signal in self.networks[:15]]
232 items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]
233 if self.enabled and networks:
234 items += [None, ("Networks", networks)]
235 items += [None, ("Network settings", lambda: self.settings())]
236 return items
237
238 def click(self, button):
239 if button == "left":
240 self.settings()
241
242 def scroll(self, direction, steps):
243 pass
244
245 def toggle(self):
246 act("nmcli", "radio", "wifi", "off" if self.enabled else "on")
247
248 def connect(self, ssid):
249 # Uses the saved profile; a new secured network needs the settings TUI.
250 # The module waits for nmcli (at most 8 s) before it reads events again.
251 if not act("nmcli", "--wait", "8", "device", "wifi", "connect", ssid):
252 self.settings()
253
254 def settings(self):
255 launch(TERMINAL, "-e", *NETWORK_SETTINGS)
256
257
258# --- battery -----------------------------------------------------------------
259
260def find_battery():
261 root = "/sys/class/power_supply"
262 for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
263 if read(f"{root}/{name}/type") == "Battery" and read(f"{root}/{name}/capacity"):
264 return f"{root}/{name}"
265 return ""
266
267
268class Battery:
269 path = find_battery()
270 available = path != ""
271
272 def read(self):
273 value = lambda name: number(read(f"{self.path}/{name}"))
274 self.percent = value("capacity")
275 self.status = read(f"{self.path}/status")
276 # Some batteries report a current and a charge (µA, µAh) instead of a
277 # power and an energy (µW, µWh); some report them negative.
278 volts = value("voltage_now") / 1_000_000
279 self.watts = abs(value("power_now") or value("current_now") * volts) / 1_000_000
280 self.now = value("energy_now") or value("charge_now") * volts
281 self.full = value("energy_full") or value("charge_full") * volts
282
283 def icon(self):
284 icon = level(BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS, self.percent)
285 if self.status == "Discharging" and self.percent <= 20:
286 color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))
287 return f"{BOLD}{color}{icon}{RESET}"
288 return icon
289
290 def tooltip(self):
291 rows = [("Charge", f"{self.percent}%"), ("State", self.status)]
292 if self.watts:
293 rows.append(("Power", f"{self.watts:.1f} W"))
294 if self.watts and self.now:
295 if self.status == "Discharging":
296 rows.append(("Time left", hours(self.now / (self.watts * 1e6))))
297 elif self.status == "Charging":
298 rows.append(("Full in", hours((self.full - self.now) / (self.watts * 1e6))))
299 profile = run("powerprofilesctl", "get").strip()
300 if profile:
301 rows.append(("Profile", profile))
302 return "Battery", rows
303
304 def menu(self):
305 current = run("powerprofilesctl", "get").strip()
306 profiles = re.findall(r"^\*?\s*([\w-]+):$", run("powerprofilesctl", "list"), re.M)
307 # Switching profiles needs an active session (polkit): run the bar
308 # from the compositor, not from an ssh shell.
309 return [(p, "radio", p == current, lambda p=p: act("powerprofilesctl", "set", p))
310 for p in profiles]
311
312 def click(self, button):
313 pass
314
315 def scroll(self, direction, steps):
316 pass
317
318
319def hours(h):
320 return f"{int(h)}h{int(h * 60) % 60:02d}"
321
322
323# --- the module --------------------------------------------------------------
324
325class Module:
326 def __init__(self):
327 self.parts = [p() for p in (Volume, Wifi, Battery) if p.available]
328 self.cols = {} # column -> part
329 self.last_frame = None
330 self.hovered = None
331 self.tooltip_at = None
332 self.tooltip_shown = False
333 self.actions = {} # menu item id -> function, for the open menu
334 self.menu_token = None
335 self.menu_part = None
336 self.read(all_parts=True)
337
338 def read(self, all_parts=False):
339 for part in self.parts:
340 if all_parts or not isinstance(part, Wifi):
341 part.read()
342
343 def draw(self):
344 # One icon per part, separated by a space: part i is at column 2*i.
345 self.cols = {2 * i: part for i, part in enumerate(self.parts)}
346 frame = " ".join(part.icon() for part in self.parts) + GAP
347 if frame != self.last_frame:
348 self.last_frame = frame
349 print(frame, flush=True)
350 if self.tooltip_shown:
351 self.show_tooltip() # the bar ignores an unchanged tooltip
352
353 def col_of(self, part):
354 return next(c for c, p in self.cols.items() if p is part)
355
356 def show_tooltip(self):
357 part = self.hovered
358 if part is None:
359 return
360 # A tooltip has a bold title, then a "body" (text, "\n" for a new
361 # line) and/or "rows", a table whose columns line up.
362 title, rows = part.tooltip()
363 control({"type": "tooltip", "col": self.col_of(part), "width": 1,
364 "title": title, "rows": rows})
365 self.tooltip_shown = True
366
367 def hide_tooltip(self):
368 if self.tooltip_shown:
369 control({"type": "close"})
370 self.tooltip_shown = False
371 self.tooltip_at = None
372
373 def open_menu(self, part, token):
374 self.actions = {}
375 items = self.items(part.menu())
376 if not items:
377 return # the bar refuses an empty menu
378 self.menu_token, self.menu_part = token, part
379 control({"type": "menu", "col": self.col_of(part), "width": 1,
380 "click": token, "items": items})
381
382 def items(self, entries):
383 """Turn (label, action), (label, kind, checked, action), (label, [entries])
384 and None (a separator) into menu items, numbering the actions."""
385 out = []
386 for entry in entries:
387 if entry is None:
388 out.append({"id": 0, "kind": "separator"})
389 elif isinstance(entry[1], list):
390 out.append({"id": 0, "label": entry[0], "items": self.items(entry[1])})
391 else:
392 if len(entry) == 2: # (label, action)
393 entry = (entry[0], "normal", False, entry[1])
394 label, kind, checked, action = entry
395 item_id = len(self.actions) + 1
396 self.actions[item_id] = action
397 out.append({"id": item_id, "label": label, "kind": kind, "checked": checked})
398 return out
399
400 def handle(self, line):
401 event, *args = line.split()
402 part = self.cols.get(int(args[-1])) if event in ("hover", "scroll") and args else None
403 if event == "hover":
404 if part is not self.hovered:
405 self.hide_tooltip()
406 self.hovered = part
407 self.tooltip_at = time.monotonic() + TOOLTIP_DELAY if part else None
408 return
409 if event == "leave":
410 self.hovered = None
411 self.hide_tooltip()
412 return
413 if event == "click":
414 button, col, token = args
415 self.hide_tooltip()
416 part = self.cols.get(int(col))
417 if part is None:
418 return
419 if button == "right":
420 self.open_menu(part, int(token))
421 return
422 part.click(button)
423 elif event == "scroll" and part is not None:
424 direction, steps, _ = args
425 part.scroll(direction, int(steps))
426 if self.tooltip_shown:
427 # The bar opens one tooltip per 250 ms: show the new level
428 # once the wheel stops, in case the last one was dropped.
429 self.tooltip_at = time.monotonic() + TOOLTIP_DELAY
430 elif event in ("menu-activate", "menu-closed"):
431 if int(args[0]) != self.menu_token:
432 return # an older menu
433 action = self.actions.get(int(args[1])) if event == "menu-activate" else None
434 part, self.actions, self.menu_token, self.menu_part = self.menu_part, {}, None, None
435 if action is None:
436 return
437 action()
438 else:
439 return
440 part.read()
441 self.draw()
442
443 def run(self):
444 self.draw()
445 pending = b""
446 next_read = next_wifi = time.monotonic()
447 while True:
448 now = time.monotonic()
449 deadlines = [next_read, next_wifi] + ([self.tooltip_at] if self.tooltip_at else [])
450 ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))
451 if ready:
452 chunk = os.read(0, 4096)
453 if not chunk: # stdin is empty when the module is not interactive
454 sys.exit("status.py needs interactive = true")
455 pending += chunk
456 while b"\n" in pending:
457 line, pending = pending.split(b"\n", 1)
458 self.handle(line.decode(errors="replace"))
459 now = time.monotonic()
460 if self.tooltip_at and now >= self.tooltip_at:
461 self.tooltip_at = None
462 self.show_tooltip()
463 if now >= next_wifi:
464 next_wifi = now + WIFI_REFRESH
465 next_read = now + REFRESH
466 self.read(all_parts=True)
467 self.draw()
468 elif now >= next_read:
469 next_read = now + REFRESH
470 self.read()
471 self.draw()
472
473
474if __name__ == "__main__":
475 Module().run()