detached dd9c933

docs(examples): one Python status module for volume, Wi-Fi and battery with tooltips and menus

commit
dd9c933ec5cedb886400e6ad9087a7b413b71abb
author
brice · · 2026-10-09 07:06 UTC
parent
b7ee660
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +416 -99
M README.md +2 -2
@@ -254,7 +254,7 @@ for line in sys.stdin:
254254 token, item = map(int, args) # item is 1 or 2
255255 ```
256256
257-`examples/modules/volume.py` is a complete module: wheel for the volume, left click to mute, a right-click menu with a check entry and a submenu.
257+`examples/modules/status.py` is a complete module: volume, Wi-Fi and battery icons that follow their level, a tooltip on each with the numbers, and a right-click menu on each (mute and volume levels, Wi-Fi networks, power profiles).
258258
259259 `col` and `width` are the cells the popup points at, counted from the module's first cell, as in the lines the module reads. The JSON objects:
260260
@@ -304,7 +304,7 @@ The `examples/modules` directory has these:
304304 - `static.sh`: one line, then idle.
305305 - `clock.sh`: a clock with an icon.
306306 - `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow.
307-- `volume.py`: an interactive volume module with a right-click menu (needs `interactive = true` and `wpctl`).
307+- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`).
308308
309309 ## Layout
310310
@@ -254,7 +254,7 @@ for line in sys.stdin:
254 token, item = map(int, args) # item is 1 or 2254 token, item = map(int, args) # item is 1 or 2
255 ```255 ```
256 256
257-`examples/modules/volume.py` is a complete module: wheel for the volume, left click to mute, a right-click menu with a check entry and a submenu.257+`examples/modules/status.py` is a complete module: volume, Wi-Fi and battery icons that follow their level, a tooltip on each with the numbers, and a right-click menu on each (mute and volume levels, Wi-Fi networks, power profiles).
258 258
259 `col` and `width` are the cells the popup points at, counted from the module's first cell, as in the lines the module reads. The JSON objects:259 `col` and `width` are the cells the popup points at, counted from the module's first cell, as in the lines the module reads. The JSON objects:
260 260
@@ -304,7 +304,7 @@ The `examples/modules` directory has these:
304 - `static.sh`: one line, then idle.304 - `static.sh`: one line, then idle.
305 - `clock.sh`: a clock with an icon.305 - `clock.sh`: a clock with an icon.
306 - `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow.306 - `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow.
307-- `volume.py`: an interactive volume module with a right-click menu (needs `interactive = true` and `wpctl`).307+- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`).
308 308
309 ## Layout309 ## Layout
310 310
A examples/modules/status.py +414 -0
@@ -0,0 +1,414 @@
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+
10+Each 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+
16+Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
17+whose tool is missing is not shown. Edit the commands below to taste.
18+"""
19+
20+import json
21+import os
22+import re
23+import select
24+import shutil
25+import subprocess
26+import time
27+
28+TERMINAL = os.environ.get("TERMINAL", "kitty")
29+MIXER = ["pavucontrol"]
30+NETWORK_SETTINGS = ["nmtui"] # runs in TERMINAL; impala if you use iwd
31+VOLUME_STEP = 2 # percent per wheel step
32+TOOLTIP_DELAY = 0.4 # seconds of hover before a tooltip
33+REFRESH = 2.0 # seconds between two reads of volume and battery
34+WIFI_REFRESH = 10.0
35+
36+SINK = "@DEFAULT_AUDIO_SINK@"
37+
38+# Nerd Font (Material Design) icons, from empty to full.
39+VOLUME_ICONS = ["󰕿", "󰖀", "󰕾"]
40+VOLUME_MUTED = "󰝟"
41+WIFI_ICONS = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
42+WIFI_OFF, WIFI_DOWN, ETHERNET = "󰖪", "󰤮", "󰈀"
43+BATTERY_ICONS = ["󰂎", "󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
44+BATTERY_CHARGING = ["󰢟", "󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
45+
46+BOLD, RESET = "\033[1m", "\033[0m"
47+
48+
49+# --- helpers -----------------------------------------------------------------
50+
51+def run(*cmd):
52+ """Output of a command, or "" when it fails."""
53+ try:
54+ return subprocess.run(cmd, capture_output=True, text=True, timeout=3).stdout
55+ except (OSError, subprocess.SubprocessError):
56+ return ""
57+
58+
59+def launch(*cmd):
60+ """Start a program detached from the bar (its output never reaches the bar)."""
61+ try:
62+ subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
63+ stderr=subprocess.DEVNULL, start_new_session=True)
64+ except OSError:
65+ pass
66+
67+
68+def control(message):
69+ """Ask the bar for a popup: a control line is never shown as text."""
70+ print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
71+
72+
73+def level(icons, percent):
74+ """The icon of icons that matches percent (0..100)."""
75+ percent = max(0, min(100, percent))
76+ return icons[min(len(icons) - 1, percent * len(icons) // 101)]
77+
78+
79+def read(path):
80+ try:
81+ with open(path) as f:
82+ return f.read().strip()
83+ except OSError:
84+ return ""
85+
86+
87+def foreground(hex_color):
88+ h = hex_color.lstrip("#")
89+ return f"\033[38;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"
90+
91+
92+# --- volume ------------------------------------------------------------------
93+
94+class Volume:
95+ available = shutil.which("wpctl") is not None
96+
97+ def read(self):
98+ # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
99+ out = run("wpctl", "get-volume", SINK).split()
100+ self.percent = round(float(out[1]) * 100) if len(out) > 1 else 0
101+ self.muted = "[MUTED]" in out
102+
103+ def icon(self):
104+ if self.muted or self.percent == 0:
105+ return VOLUME_MUTED
106+ return level(VOLUME_ICONS, self.percent)
107+
108+ def tooltip(self):
109+ device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))
110+ state = "muted" if self.muted else f"{self.percent}%"
111+ return f"Volume {state}", device.group(1) if device else ""
112+
113+ def menu(self):
114+ items = [("Mute", "check", self.muted, lambda: self.mute())]
115+ steps = [(f"{p}%", "radio", abs(self.percent - p) < 5, lambda p=p: self.set(p))
116+ for p in (25, 50, 75, 100)]
117+ items += [None, ("Volume", steps), None, ("Mixer", lambda: launch(*MIXER))]
118+ return items
119+
120+ def click(self, button):
121+ if button == "left":
122+ self.mute()
123+ elif button == "middle":
124+ launch(*MIXER)
125+
126+ def scroll(self, direction, steps):
127+ sign = "+" if direction in ("up", "right") else "-"
128+ run("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
129+
130+ def mute(self):
131+ run("wpctl", "set-mute", SINK, "toggle")
132+
133+ def set(self, percent):
134+ run("wpctl", "set-volume", SINK, f"{percent}%")
135+
136+
137+# --- wifi --------------------------------------------------------------------
138+
139+class Wifi:
140+ available = shutil.which("nmcli") is not None
141+
142+ def read(self):
143+ self.enabled = run("nmcli", "-t", "-f", "WIFI", "radio").strip() == "enabled"
144+ self.ethernet = False
145+ self.ssid, self.signal, self.freq = "", 0, ""
146+ for line in run("nmcli", "-t", "-f", "TYPE,STATE", "device").splitlines():
147+ kind, _, state = line.partition(":")
148+ if kind == "ethernet" and state == "connected":
149+ self.ethernet = True
150+ self.networks = []
151+ seen = set()
152+ for line in run("nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,FREQ,SECURITY",
153+ "device", "wifi", "list", "--rescan", "no").splitlines():
154+ in_use, ssid, signal, freq, security = split_terse(line)
155+ if in_use == "*":
156+ self.ssid, self.signal, self.freq = ssid, int(signal or 0), freq
157+ if ssid and ssid not in seen:
158+ seen.add(ssid)
159+ self.networks.append((ssid, int(signal or 0), security))
160+ self.networks.sort(key=lambda n: -n[1])
161+
162+ def icon(self):
163+ if self.ethernet:
164+ return ETHERNET
165+ if not self.enabled:
166+ return WIFI_OFF
167+ return level(WIFI_ICONS, self.signal) if self.ssid else WIFI_DOWN
168+
169+ def tooltip(self):
170+ if self.ethernet:
171+ return "Ethernet", "connected"
172+ if not self.enabled:
173+ return "Wi-Fi off", ""
174+ if not self.ssid:
175+ return "Wi-Fi", "not connected"
176+ return self.ssid, f"Signal {self.signal}% · {self.freq}"
177+
178+ def menu(self):
179+ networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
180+ lambda ssid=ssid: self.connect(ssid))
181+ for ssid, signal, _ in self.networks[:15]]
182+ items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]
183+ if self.enabled and networks:
184+ items += [None, ("Networks", networks)]
185+ items += [None, ("Network settings", lambda: self.settings())]
186+ return items
187+
188+ def click(self, button):
189+ if button == "left":
190+ self.settings()
191+
192+ def scroll(self, direction, steps):
193+ pass
194+
195+ def toggle(self):
196+ run("nmcli", "radio", "wifi", "off" if self.enabled else "on")
197+
198+ def connect(self, ssid):
199+ # Uses the saved profile; a new secured network needs the settings TUI.
200+ if not run("nmcli", "device", "wifi", "connect", ssid):
201+ self.settings()
202+
203+ def settings(self):
204+ launch(TERMINAL, "-e", *NETWORK_SETTINGS)
205+
206+
207+def split_terse(line):
208+ """Split an nmcli -t line: fields are separated by ':', and '\\:' is a colon."""
209+ fields = [f.replace("\\:", ":").replace("\\\\", "\\")
210+ for f in re.split(r"(?<!\\):", line)]
211+ return (fields + [""] * 5)[:5]
212+
213+
214+# --- battery -----------------------------------------------------------------
215+
216+def find_battery():
217+ root = "/sys/class/power_supply"
218+ for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
219+ if read(f"{root}/{name}/type") == "Battery" and read(f"{root}/{name}/capacity"):
220+ return f"{root}/{name}"
221+ return ""
222+
223+
224+class Battery:
225+ path = find_battery()
226+ available = path != ""
227+
228+ def read(self):
229+ self.percent = int(read(f"{self.path}/capacity") or 0)
230+ self.status = read(f"{self.path}/status")
231+ power = int(read(f"{self.path}/power_now") or 0)
232+ if not power: # some batteries report a current instead of a power
233+ current = int(read(f"{self.path}/current_now") or 0)
234+ power = current * int(read(f"{self.path}/voltage_now") or 0) // 1_000_000
235+ self.watts = power / 1_000_000
236+ self.now = int(read(f"{self.path}/energy_now") or 0)
237+ self.full = int(read(f"{self.path}/energy_full") or 0)
238+
239+ def icon(self):
240+ icons = BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS
241+ icon = icons[min(10, self.percent // 10)]
242+ if self.status == "Discharging" and self.percent <= 20:
243+ color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))
244+ return f"{BOLD}{color}{icon}{RESET}"
245+ return icon
246+
247+ def tooltip(self):
248+ title = f"Battery {self.percent}%"
249+ body = self.status
250+ if self.watts and self.status == "Discharging":
251+ body += f" · {self.watts:.1f} W · {hours(self.now / (self.watts * 1e6))} left"
252+ elif self.watts and self.status == "Charging":
253+ body += f" · {self.watts:.1f} W · full in {hours((self.full - self.now) / (self.watts * 1e6))}"
254+ profile = run("powerprofilesctl", "get").strip()
255+ if profile:
256+ body += f"\nProfile: {profile}"
257+ return title, body
258+
259+ def menu(self):
260+ current = run("powerprofilesctl", "get").strip()
261+ profiles = re.findall(r"^\*?\s*([\w-]+):$", run("powerprofilesctl", "list"), re.M)
262+ return [(p, "radio", p == current, lambda p=p: run("powerprofilesctl", "set", p))
263+ for p in profiles]
264+
265+ def click(self, button):
266+ pass
267+
268+ def scroll(self, direction, steps):
269+ pass
270+
271+
272+def hours(h):
273+ return f"{int(h)}h{int(h * 60) % 60:02d}"
274+
275+
276+# --- the module --------------------------------------------------------------
277+
278+class Module:
279+ def __init__(self):
280+ self.parts = [p() for p in (Volume, Wifi, Battery) if p.available]
281+ self.cols = {} # column -> part
282+ self.last_frame = None
283+ self.hovered = None
284+ self.tooltip_at = None
285+ self.tooltip_shown = False
286+ self.actions = {} # menu item id -> function, for the open menu
287+ self.read(all_parts=True)
288+
289+ def read(self, all_parts=False):
290+ for part in self.parts:
291+ if all_parts or not isinstance(part, Wifi):
292+ part.read()
293+
294+ def draw(self):
295+ # One icon per part, separated by a space: part i is at column 2*i.
296+ self.cols = {2 * i: part for i, part in enumerate(self.parts)}
297+ frame = " ".join(part.icon() for part in self.parts)
298+ if frame != self.last_frame:
299+ self.last_frame = frame
300+ print(frame, flush=True)
301+ if self.tooltip_shown:
302+ self.show_tooltip() # the bar ignores an unchanged tooltip
303+
304+ def col_of(self, part):
305+ return next(c for c, p in self.cols.items() if p is part)
306+
307+ def show_tooltip(self):
308+ part = self.hovered
309+ if part is None:
310+ return
311+ title, body = part.tooltip()
312+ control({"type": "tooltip", "col": self.col_of(part), "width": 1,
313+ "title": title, "body": body})
314+ self.tooltip_shown = True
315+
316+ def hide_tooltip(self):
317+ if self.tooltip_shown:
318+ control({"type": "close"})
319+ self.tooltip_shown = False
320+ self.tooltip_at = None
321+
322+ def open_menu(self, part, token):
323+ part.read()
324+ self.actions = {}
325+ control({"type": "menu", "col": self.col_of(part), "width": 1,
326+ "click": token, "items": self.items(part.menu())})
327+
328+ def items(self, entries):
329+ """Turn (label, action), (label, kind, checked, action), (label, [entries])
330+ and None (a separator) into menu items, numbering the actions."""
331+ out = []
332+ for entry in entries:
333+ if entry is None:
334+ out.append({"id": 0, "kind": "separator"})
335+ elif isinstance(entry[1], list):
336+ out.append({"id": 0, "label": entry[0], "items": self.items(entry[1])})
337+ else:
338+ if len(entry) == 2: # (label, action)
339+ entry = (entry[0], "normal", False, entry[1])
340+ label, kind, checked, action = entry
341+ item_id = len(self.actions) + 1
342+ self.actions[item_id] = action
343+ out.append({"id": item_id, "label": label, "kind": kind, "checked": checked})
344+ return out
345+
346+ def handle(self, line):
347+ event, *args = line.split()
348+ part = self.cols.get(int(args[-1])) if event in ("hover", "scroll") and args else None
349+ if event == "hover":
350+ if part is not self.hovered:
351+ self.hide_tooltip()
352+ self.hovered = part
353+ self.tooltip_at = time.monotonic() + TOOLTIP_DELAY if part else None
354+ return
355+ if event == "leave":
356+ self.hovered = None
357+ self.hide_tooltip()
358+ return
359+ if event == "click":
360+ button, col, token = args
361+ self.hide_tooltip()
362+ part = self.cols.get(int(col))
363+ if part is None:
364+ return
365+ if button == "right":
366+ self.open_menu(part, int(token))
367+ return
368+ part.click(button)
369+ elif event == "scroll" and part is not None:
370+ direction, steps, _ = args
371+ part.scroll(direction, int(steps))
372+ elif event == "menu-activate":
373+ action = self.actions.pop(int(args[1]), None)
374+ self.actions = {}
375+ if action:
376+ action()
377+ else:
378+ return # menu-closed
379+ self.read(all_parts=True)
380+ self.draw()
381+
382+ def run(self):
383+ self.draw()
384+ pending = b""
385+ next_read = next_wifi = time.monotonic()
386+ while True:
387+ now = time.monotonic()
388+ deadlines = [next_read, next_wifi] + ([self.tooltip_at] if self.tooltip_at else [])
389+ ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))
390+ if ready:
391+ chunk = os.read(0, 4096)
392+ if not chunk:
393+ return # the bar is gone
394+ pending += chunk
395+ while b"\n" in pending:
396+ line, pending = pending.split(b"\n", 1)
397+ self.handle(line.decode(errors="replace"))
398+ now = time.monotonic()
399+ if self.tooltip_at and now >= self.tooltip_at:
400+ self.tooltip_at = None
401+ self.show_tooltip()
402+ if now >= next_wifi:
403+ next_wifi = now + WIFI_REFRESH
404+ next_read = now + REFRESH
405+ self.read(all_parts=True)
406+ self.draw()
407+ elif now >= next_read:
408+ next_read = now + REFRESH
409+ self.read()
410+ self.draw()
411+
412+
413+if __name__ == "__main__":
414+ Module().run()
@@ -0,0 +1,414 @@
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+
10+Each 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+
16+Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
17+whose tool is missing is not shown. Edit the commands below to taste.
18+"""
19+
20+import json
21+import os
22+import re
23+import select
24+import shutil
25+import subprocess
26+import time
27+
28+TERMINAL = os.environ.get("TERMINAL", "kitty")
29+MIXER = ["pavucontrol"]
30+NETWORK_SETTINGS = ["nmtui"] # runs in TERMINAL; impala if you use iwd
31+VOLUME_STEP = 2 # percent per wheel step
32+TOOLTIP_DELAY = 0.4 # seconds of hover before a tooltip
33+REFRESH = 2.0 # seconds between two reads of volume and battery
34+WIFI_REFRESH = 10.0
35+
36+SINK = "@DEFAULT_AUDIO_SINK@"
37+
38+# Nerd Font (Material Design) icons, from empty to full.
39+VOLUME_ICONS = ["󰕿", "󰖀", "󰕾"]
40+VOLUME_MUTED = "󰝟"
41+WIFI_ICONS = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
42+WIFI_OFF, WIFI_DOWN, ETHERNET = "󰖪", "󰤮", "󰈀"
43+BATTERY_ICONS = ["󰂎", "󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
44+BATTERY_CHARGING = ["󰢟", "󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
45+
46+BOLD, RESET = "\033[1m", "\033[0m"
47+
48+
49+# --- helpers -----------------------------------------------------------------
50+
51+def run(*cmd):
52+ """Output of a command, or "" when it fails."""
53+ try:
54+ return subprocess.run(cmd, capture_output=True, text=True, timeout=3).stdout
55+ except (OSError, subprocess.SubprocessError):
56+ return ""
57+
58+
59+def launch(*cmd):
60+ """Start a program detached from the bar (its output never reaches the bar)."""
61+ try:
62+ subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
63+ stderr=subprocess.DEVNULL, start_new_session=True)
64+ except OSError:
65+ pass
66+
67+
68+def control(message):
69+ """Ask the bar for a popup: a control line is never shown as text."""
70+ print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
71+
72+
73+def level(icons, percent):
74+ """The icon of icons that matches percent (0..100)."""
75+ percent = max(0, min(100, percent))
76+ return icons[min(len(icons) - 1, percent * len(icons) // 101)]
77+
78+
79+def read(path):
80+ try:
81+ with open(path) as f:
82+ return f.read().strip()
83+ except OSError:
84+ return ""
85+
86+
87+def foreground(hex_color):
88+ h = hex_color.lstrip("#")
89+ return f"\033[38;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"
90+
91+
92+# --- volume ------------------------------------------------------------------
93+
94+class Volume:
95+ available = shutil.which("wpctl") is not None
96+
97+ def read(self):
98+ # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
99+ out = run("wpctl", "get-volume", SINK).split()
100+ self.percent = round(float(out[1]) * 100) if len(out) > 1 else 0
101+ self.muted = "[MUTED]" in out
102+
103+ def icon(self):
104+ if self.muted or self.percent == 0:
105+ return VOLUME_MUTED
106+ return level(VOLUME_ICONS, self.percent)
107+
108+ def tooltip(self):
109+ device = re.search(r'node\.description = "(.*)"', run("wpctl", "inspect", SINK))
110+ state = "muted" if self.muted else f"{self.percent}%"
111+ return f"Volume {state}", device.group(1) if device else ""
112+
113+ def menu(self):
114+ items = [("Mute", "check", self.muted, lambda: self.mute())]
115+ steps = [(f"{p}%", "radio", abs(self.percent - p) < 5, lambda p=p: self.set(p))
116+ for p in (25, 50, 75, 100)]
117+ items += [None, ("Volume", steps), None, ("Mixer", lambda: launch(*MIXER))]
118+ return items
119+
120+ def click(self, button):
121+ if button == "left":
122+ self.mute()
123+ elif button == "middle":
124+ launch(*MIXER)
125+
126+ def scroll(self, direction, steps):
127+ sign = "+" if direction in ("up", "right") else "-"
128+ run("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
129+
130+ def mute(self):
131+ run("wpctl", "set-mute", SINK, "toggle")
132+
133+ def set(self, percent):
134+ run("wpctl", "set-volume", SINK, f"{percent}%")
135+
136+
137+# --- wifi --------------------------------------------------------------------
138+
139+class Wifi:
140+ available = shutil.which("nmcli") is not None
141+
142+ def read(self):
143+ self.enabled = run("nmcli", "-t", "-f", "WIFI", "radio").strip() == "enabled"
144+ self.ethernet = False
145+ self.ssid, self.signal, self.freq = "", 0, ""
146+ for line in run("nmcli", "-t", "-f", "TYPE,STATE", "device").splitlines():
147+ kind, _, state = line.partition(":")
148+ if kind == "ethernet" and state == "connected":
149+ self.ethernet = True
150+ self.networks = []
151+ seen = set()
152+ for line in run("nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,FREQ,SECURITY",
153+ "device", "wifi", "list", "--rescan", "no").splitlines():
154+ in_use, ssid, signal, freq, security = split_terse(line)
155+ if in_use == "*":
156+ self.ssid, self.signal, self.freq = ssid, int(signal or 0), freq
157+ if ssid and ssid not in seen:
158+ seen.add(ssid)
159+ self.networks.append((ssid, int(signal or 0), security))
160+ self.networks.sort(key=lambda n: -n[1])
161+
162+ def icon(self):
163+ if self.ethernet:
164+ return ETHERNET
165+ if not self.enabled:
166+ return WIFI_OFF
167+ return level(WIFI_ICONS, self.signal) if self.ssid else WIFI_DOWN
168+
169+ def tooltip(self):
170+ if self.ethernet:
171+ return "Ethernet", "connected"
172+ if not self.enabled:
173+ return "Wi-Fi off", ""
174+ if not self.ssid:
175+ return "Wi-Fi", "not connected"
176+ return self.ssid, f"Signal {self.signal}% · {self.freq}"
177+
178+ def menu(self):
179+ networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
180+ lambda ssid=ssid: self.connect(ssid))
181+ for ssid, signal, _ in self.networks[:15]]
182+ items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]
183+ if self.enabled and networks:
184+ items += [None, ("Networks", networks)]
185+ items += [None, ("Network settings", lambda: self.settings())]
186+ return items
187+
188+ def click(self, button):
189+ if button == "left":
190+ self.settings()
191+
192+ def scroll(self, direction, steps):
193+ pass
194+
195+ def toggle(self):
196+ run("nmcli", "radio", "wifi", "off" if self.enabled else "on")
197+
198+ def connect(self, ssid):
199+ # Uses the saved profile; a new secured network needs the settings TUI.
200+ if not run("nmcli", "device", "wifi", "connect", ssid):
201+ self.settings()
202+
203+ def settings(self):
204+ launch(TERMINAL, "-e", *NETWORK_SETTINGS)
205+
206+
207+def split_terse(line):
208+ """Split an nmcli -t line: fields are separated by ':', and '\\:' is a colon."""
209+ fields = [f.replace("\\:", ":").replace("\\\\", "\\")
210+ for f in re.split(r"(?<!\\):", line)]
211+ return (fields + [""] * 5)[:5]
212+
213+
214+# --- battery -----------------------------------------------------------------
215+
216+def find_battery():
217+ root = "/sys/class/power_supply"
218+ for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
219+ if read(f"{root}/{name}/type") == "Battery" and read(f"{root}/{name}/capacity"):
220+ return f"{root}/{name}"
221+ return ""
222+
223+
224+class Battery:
225+ path = find_battery()
226+ available = path != ""
227+
228+ def read(self):
229+ self.percent = int(read(f"{self.path}/capacity") or 0)
230+ self.status = read(f"{self.path}/status")
231+ power = int(read(f"{self.path}/power_now") or 0)
232+ if not power: # some batteries report a current instead of a power
233+ current = int(read(f"{self.path}/current_now") or 0)
234+ power = current * int(read(f"{self.path}/voltage_now") or 0) // 1_000_000
235+ self.watts = power / 1_000_000
236+ self.now = int(read(f"{self.path}/energy_now") or 0)
237+ self.full = int(read(f"{self.path}/energy_full") or 0)
238+
239+ def icon(self):
240+ icons = BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS
241+ icon = icons[min(10, self.percent // 10)]
242+ if self.status == "Discharging" and self.percent <= 20:
243+ color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))
244+ return f"{BOLD}{color}{icon}{RESET}"
245+ return icon
246+
247+ def tooltip(self):
248+ title = f"Battery {self.percent}%"
249+ body = self.status
250+ if self.watts and self.status == "Discharging":
251+ body += f" · {self.watts:.1f} W · {hours(self.now / (self.watts * 1e6))} left"
252+ elif self.watts and self.status == "Charging":
253+ body += f" · {self.watts:.1f} W · full in {hours((self.full - self.now) / (self.watts * 1e6))}"
254+ profile = run("powerprofilesctl", "get").strip()
255+ if profile:
256+ body += f"\nProfile: {profile}"
257+ return title, body
258+
259+ def menu(self):
260+ current = run("powerprofilesctl", "get").strip()
261+ profiles = re.findall(r"^\*?\s*([\w-]+):$", run("powerprofilesctl", "list"), re.M)
262+ return [(p, "radio", p == current, lambda p=p: run("powerprofilesctl", "set", p))
263+ for p in profiles]
264+
265+ def click(self, button):
266+ pass
267+
268+ def scroll(self, direction, steps):
269+ pass
270+
271+
272+def hours(h):
273+ return f"{int(h)}h{int(h * 60) % 60:02d}"
274+
275+
276+# --- the module --------------------------------------------------------------
277+
278+class Module:
279+ def __init__(self):
280+ self.parts = [p() for p in (Volume, Wifi, Battery) if p.available]
281+ self.cols = {} # column -> part
282+ self.last_frame = None
283+ self.hovered = None
284+ self.tooltip_at = None
285+ self.tooltip_shown = False
286+ self.actions = {} # menu item id -> function, for the open menu
287+ self.read(all_parts=True)
288+
289+ def read(self, all_parts=False):
290+ for part in self.parts:
291+ if all_parts or not isinstance(part, Wifi):
292+ part.read()
293+
294+ def draw(self):
295+ # One icon per part, separated by a space: part i is at column 2*i.
296+ self.cols = {2 * i: part for i, part in enumerate(self.parts)}
297+ frame = " ".join(part.icon() for part in self.parts)
298+ if frame != self.last_frame:
299+ self.last_frame = frame
300+ print(frame, flush=True)
301+ if self.tooltip_shown:
302+ self.show_tooltip() # the bar ignores an unchanged tooltip
303+
304+ def col_of(self, part):
305+ return next(c for c, p in self.cols.items() if p is part)
306+
307+ def show_tooltip(self):
308+ part = self.hovered
309+ if part is None:
310+ return
311+ title, body = part.tooltip()
312+ control({"type": "tooltip", "col": self.col_of(part), "width": 1,
313+ "title": title, "body": body})
314+ self.tooltip_shown = True
315+
316+ def hide_tooltip(self):
317+ if self.tooltip_shown:
318+ control({"type": "close"})
319+ self.tooltip_shown = False
320+ self.tooltip_at = None
321+
322+ def open_menu(self, part, token):
323+ part.read()
324+ self.actions = {}
325+ control({"type": "menu", "col": self.col_of(part), "width": 1,
326+ "click": token, "items": self.items(part.menu())})
327+
328+ def items(self, entries):
329+ """Turn (label, action), (label, kind, checked, action), (label, [entries])
330+ and None (a separator) into menu items, numbering the actions."""
331+ out = []
332+ for entry in entries:
333+ if entry is None:
334+ out.append({"id": 0, "kind": "separator"})
335+ elif isinstance(entry[1], list):
336+ out.append({"id": 0, "label": entry[0], "items": self.items(entry[1])})
337+ else:
338+ if len(entry) == 2: # (label, action)
339+ entry = (entry[0], "normal", False, entry[1])
340+ label, kind, checked, action = entry
341+ item_id = len(self.actions) + 1
342+ self.actions[item_id] = action
343+ out.append({"id": item_id, "label": label, "kind": kind, "checked": checked})
344+ return out
345+
346+ def handle(self, line):
347+ event, *args = line.split()
348+ part = self.cols.get(int(args[-1])) if event in ("hover", "scroll") and args else None
349+ if event == "hover":
350+ if part is not self.hovered:
351+ self.hide_tooltip()
352+ self.hovered = part
353+ self.tooltip_at = time.monotonic() + TOOLTIP_DELAY if part else None
354+ return
355+ if event == "leave":
356+ self.hovered = None
357+ self.hide_tooltip()
358+ return
359+ if event == "click":
360+ button, col, token = args
361+ self.hide_tooltip()
362+ part = self.cols.get(int(col))
363+ if part is None:
364+ return
365+ if button == "right":
366+ self.open_menu(part, int(token))
367+ return
368+ part.click(button)
369+ elif event == "scroll" and part is not None:
370+ direction, steps, _ = args
371+ part.scroll(direction, int(steps))
372+ elif event == "menu-activate":
373+ action = self.actions.pop(int(args[1]), None)
374+ self.actions = {}
375+ if action:
376+ action()
377+ else:
378+ return # menu-closed
379+ self.read(all_parts=True)
380+ self.draw()
381+
382+ def run(self):
383+ self.draw()
384+ pending = b""
385+ next_read = next_wifi = time.monotonic()
386+ while True:
387+ now = time.monotonic()
388+ deadlines = [next_read, next_wifi] + ([self.tooltip_at] if self.tooltip_at else [])
389+ ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))
390+ if ready:
391+ chunk = os.read(0, 4096)
392+ if not chunk:
393+ return # the bar is gone
394+ pending += chunk
395+ while b"\n" in pending:
396+ line, pending = pending.split(b"\n", 1)
397+ self.handle(line.decode(errors="replace"))
398+ now = time.monotonic()
399+ if self.tooltip_at and now >= self.tooltip_at:
400+ self.tooltip_at = None
401+ self.show_tooltip()
402+ if now >= next_wifi:
403+ next_wifi = now + WIFI_REFRESH
404+ next_read = now + REFRESH
405+ self.read(all_parts=True)
406+ self.draw()
407+ elif now >= next_read:
408+ next_read = now + REFRESH
409+ self.read()
410+ self.draw()
411+
412+
413+if __name__ == "__main__":
414+ Module().run()
D examples/modules/volume.py +0 -97
@@ -1,97 +0,0 @@
1-#!/usr/bin/env python3
2-"""Volume module with a right-click menu, for an interactive module:
3-
4- [[module]]
5- name = "volume"
6- zone = "right"
7- exec = "~/.config/neferbar/modules/volume.py"
8- interactive = true
9-
10-Wheel changes the volume, a left click mutes, a right click opens a menu.
11-Uses wpctl (WirePlumber).
12-"""
13-
14-import json
15-import subprocess
16-import sys
17-
18-SINK = "@DEFAULT_AUDIO_SINK@"
19-
20-# Menu item ids, chosen by this script.
21-MUTE, VOL_25, VOL_50, VOL_100 = 1, 2, 3, 4
22-
23-
24-def wpctl(*args):
25- return subprocess.run(["wpctl", *args], capture_output=True, text=True).stdout
26-
27-
28-def state():
29- # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
30- out = wpctl("get-volume", SINK).split()
31- volume = round(float(out[1]) * 100) if len(out) > 1 else 0
32- return volume, "[MUTED]" in out
33-
34-
35-def show():
36- volume, muted = state()
37- icon = "󰖁" if muted else "󰕾"
38- print(f"{icon} {volume}%", flush=True)
39-
40-
41-def control(message):
42- """Print a control line: the bar reads it as a popup request, not as text."""
43- print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
44-
45-
46-def open_menu(token):
47- _, muted = state()
48- control({
49- "type": "menu",
50- "col": 0,
51- "width": 1, # the icon
52- "click": token, # answers this click
53- "items": [
54- {"id": MUTE, "label": "Mute", "kind": "check", "checked": muted},
55- {"id": 0, "kind": "separator"},
56- {"id": 0, "label": "Set volume", "items": [
57- {"id": VOL_25, "label": "25%"},
58- {"id": VOL_50, "label": "50%"},
59- {"id": VOL_100, "label": "100%"},
60- ]},
61- ],
62- })
63-
64-
65-def activate(item):
66- if item == MUTE:
67- wpctl("set-mute", SINK, "toggle")
68- elif item in (VOL_25, VOL_50, VOL_100):
69- level = {VOL_25: "0.25", VOL_50: "0.5", VOL_100: "1.0"}[item]
70- wpctl("set-volume", SINK, level)
71-
72-
73-def main():
74- show()
75- # One event per line: "click right 0 7", "scroll up 1 0", "menu-activate 7 1", ...
76- for line in sys.stdin:
77- event, *args = line.split()
78- if event == "click":
79- button, _col, token = args
80- if button == "left":
81- wpctl("set-mute", SINK, "toggle")
82- elif button == "right":
83- open_menu(int(token))
84- elif event == "scroll":
85- direction, steps, _col = args
86- sign = "+" if direction == "up" else "-"
87- wpctl("set-volume", "-l", "1.0", SINK, f"{int(steps) * 2}%{sign}")
88- elif event == "menu-activate":
89- _token, item = args
90- activate(int(item))
91- else:
92- continue # hover, leave, menu-closed
93- show()
94-
95-
96-if __name__ == "__main__":
97- main()
@@ -1,97 +0,0 @@
1-#!/usr/bin/env python3
2-"""Volume module with a right-click menu, for an interactive module:
3-
4- [[module]]
5- name = "volume"
6- zone = "right"
7- exec = "~/.config/neferbar/modules/volume.py"
8- interactive = true
9-
10-Wheel changes the volume, a left click mutes, a right click opens a menu.
11-Uses wpctl (WirePlumber).
12-"""
13-
14-import json
15-import subprocess
16-import sys
17-
18-SINK = "@DEFAULT_AUDIO_SINK@"
19-
20-# Menu item ids, chosen by this script.
21-MUTE, VOL_25, VOL_50, VOL_100 = 1, 2, 3, 4
22-
23-
24-def wpctl(*args):
25- return subprocess.run(["wpctl", *args], capture_output=True, text=True).stdout
26-
27-
28-def state():
29- # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
30- out = wpctl("get-volume", SINK).split()
31- volume = round(float(out[1]) * 100) if len(out) > 1 else 0
32- return volume, "[MUTED]" in out
33-
34-
35-def show():
36- volume, muted = state()
37- icon = "󰖁" if muted else "󰕾"
38- print(f"{icon} {volume}%", flush=True)
39-
40-
41-def control(message):
42- """Print a control line: the bar reads it as a popup request, not as text."""
43- print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
44-
45-
46-def open_menu(token):
47- _, muted = state()
48- control({
49- "type": "menu",
50- "col": 0,
51- "width": 1, # the icon
52- "click": token, # answers this click
53- "items": [
54- {"id": MUTE, "label": "Mute", "kind": "check", "checked": muted},
55- {"id": 0, "kind": "separator"},
56- {"id": 0, "label": "Set volume", "items": [
57- {"id": VOL_25, "label": "25%"},
58- {"id": VOL_50, "label": "50%"},
59- {"id": VOL_100, "label": "100%"},
60- ]},
61- ],
62- })
63-
64-
65-def activate(item):
66- if item == MUTE:
67- wpctl("set-mute", SINK, "toggle")
68- elif item in (VOL_25, VOL_50, VOL_100):
69- level = {VOL_25: "0.25", VOL_50: "0.5", VOL_100: "1.0"}[item]
70- wpctl("set-volume", SINK, level)
71-
72-
73-def main():
74- show()
75- # One event per line: "click right 0 7", "scroll up 1 0", "menu-activate 7 1", ...
76- for line in sys.stdin:
77- event, *args = line.split()
78- if event == "click":
79- button, _col, token = args
80- if button == "left":
81- wpctl("set-mute", SINK, "toggle")
82- elif button == "right":
83- open_menu(int(token))
84- elif event == "scroll":
85- direction, steps, _col = args
86- sign = "+" if direction == "up" else "-"
87- wpctl("set-volume", "-l", "1.0", SINK, f"{int(steps) * 2}%{sign}")
88- elif event == "menu-activate":
89- _token, item = args
90- activate(int(item))
91- else:
92- continue # hover, leave, menu-closed
93- show()
94-
95-
96-if __name__ == "__main__":
97- main()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close