detached 8aba8bb

fix(examples): harden status.py parsing, menu tokens and battery units

commit
8aba8bb310e15f4e13c2131d89c736f3ccd566c6
author
brice · · 2026-10-09 08:29 UTC
parent
43af492
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +74 -49
M README.md +4 -4
@@ -256,7 +256,7 @@ for line in sys.stdin:
256256
257257 `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
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; `width` is at least 1. The JSON objects:
260260
261261 | `type` | Fields | Effect |
262262 |---|---|---|
@@ -266,9 +266,9 @@ for line in sys.stdin:
266266
267267 A menu item is `{"id": 3, "label": "Mute", "kind": "check", "enabled": true, "checked": true, "items": [...]}`. `id` is a number you choose (0 or more), `kind` is `normal` (default), `separator`, `check` or `radio`, `enabled` defaults to true, and `items` makes it a submenu, which opens in place with a "‹ Back" entry. When the user picks an item the module reads `menu-activate <token> <id>`; when the menu closes any other way (Escape, a click elsewhere) it reads `menu-closed <token>`. The token is the `click` value of the menu.
268268
269-The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed.
269+The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed. A module opens at most one tooltip every 250 ms, and a tooltip never covers an open menu.
270270
271-Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
271+Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
272272
273273 ### The bundled bar
274274
@@ -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-- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`).
307+- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`, and optionally `pavucontrol` and `nmtui`).
308308
309309 ## Layout
310310
@@ -256,7 +256,7 @@ for line in sys.stdin:
256 256
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).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; `width` is at least 1. The JSON objects:
260 260
261 | `type` | Fields | Effect |261 | `type` | Fields | Effect |
262 |---|---|---|262 |---|---|---|
@@ -266,9 +266,9 @@ for line in sys.stdin:
266 266
267 A menu item is `{"id": 3, "label": "Mute", "kind": "check", "enabled": true, "checked": true, "items": [...]}`. `id` is a number you choose (0 or more), `kind` is `normal` (default), `separator`, `check` or `radio`, `enabled` defaults to true, and `items` makes it a submenu, which opens in place with a "‹ Back" entry. When the user picks an item the module reads `menu-activate <token> <id>`; when the menu closes any other way (Escape, a click elsewhere) it reads `menu-closed <token>`. The token is the `click` value of the menu.267 A menu item is `{"id": 3, "label": "Mute", "kind": "check", "enabled": true, "checked": true, "items": [...]}`. `id` is a number you choose (0 or more), `kind` is `normal` (default), `separator`, `check` or `radio`, `enabled` defaults to true, and `items` makes it a submenu, which opens in place with a "‹ Back" entry. When the user picks an item the module reads `menu-activate <token> <id>`; when the menu closes any other way (Escape, a click elsewhere) it reads `menu-closed <token>`. The token is the `click` value of the menu.
268 268
269-The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed.269+The bar opens a menu only for the latest click on the same module, at most 5 seconds old, and only once per click. A tooltip appears only while the module is hovered, and closes when the pointer leaves the module or a button is pressed. A module opens at most one tooltip every 250 ms, and a tooltip never covers an open menu.
270 270
271-Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once per module, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.271+Limits: a control line is at most 64 KiB, a menu has at most 512 items in 8 levels, labels, titles and table cells are cut at 256 bytes, a tooltip body at 1024 bytes, a table has at most 32 rows of 4 cells. Control characters are removed from the texts. A control line that breaks a rule is ignored, logged once, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
272 272
273 ### The bundled bar273 ### The bundled bar
274 274
@@ -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-- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`).307+- `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`, and optionally `pavucontrol` and `nmtui`).
308 308
309 ## Layout309 ## Layout
310 310
M examples/modules/status.py +70 -45
@@ -14,7 +14,8 @@ Each icon changes with its level; the numbers are in the tooltips.
1414 battery right: power profile
1515
1616 Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
17-whose tool is missing is not shown. Edit the commands below to taste.
17+whose tool is missing is not shown. The mixer (pavucontrol) and the network
18+settings (nmtui in $TERMINAL) are optional. Edit the commands below to taste.
1819 """
1920
2021 import json
@@ -52,7 +53,8 @@ BOLD, RESET = "\033[1m", "\033[0m"
5253 def run(*cmd):
5354 """Output of a command, or "" when it fails."""
5455 try:
55- return subprocess.run(cmd, capture_output=True, text=True, timeout=3).stdout
56+ return subprocess.run(cmd, capture_output=True, text=True, errors="replace",
57+ timeout=3).stdout
5658 except (OSError, subprocess.SubprocessError):
5759 return ""
5860
@@ -61,7 +63,8 @@ def act(*cmd):
6163 """Run an action the user asked for. A failure goes to stderr, which the
6264 bar writes to its log."""
6365 try:
64- done = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
66+ done = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
67+ timeout=10)
6568 except (OSError, subprocess.SubprocessError) as err:
6669 print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
6770 return False
@@ -75,8 +78,8 @@ def launch(*cmd):
7578 try:
7679 subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
7780 stderr=subprocess.DEVNULL, start_new_session=True)
78- except OSError:
79- pass
81+ except OSError as err:
82+ print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
8083
8184
8285 def control(message):
@@ -98,14 +101,25 @@ def read(path):
98101 return ""
99102
100103
101-def foreground(hex_color):
104+def 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+
112+def rgb(hex_color):
102113 h = hex_color.lstrip("#")
103- return f"\033[38;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"
114+ return f"{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}"
115+
116+
117+def foreground(hex_color):
118+ return f"\033[38;2;{rgb(hex_color)}m"
104119
105120
106121 def background(hex_color):
107- h = hex_color.lstrip("#")
108- return f"\033[48;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"
122+ return f"\033[48;2;{rgb(hex_color)}m"
109123
110124
111125 # The bar drops blank cells at the end of a module; one painted with the bar's
@@ -121,7 +135,7 @@ class Volume:
121135 def read(self):
122136 # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
123137 out = run("wpctl", "get-volume", SINK).split()
124- self.percent = round(float(out[1]) * 100) if len(out) > 1 else 0
138+ self.percent = round(number(out[1], float) * 100) if len(out) > 1 else 0
125139 self.muted = "[MUTED]" in out
126140
127141 def icon(self):
@@ -152,7 +166,7 @@ class Volume:
152166
153167 def scroll(self, direction, steps):
154168 sign = "+" if direction in ("up", "right") else "-"
155- run("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
169+ act("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
156170
157171 def mute(self):
158172 act("wpctl", "set-mute", SINK, "toggle")
@@ -176,14 +190,19 @@ class Wifi:
176190 self.ethernet = True
177191 self.networks = []
178192 seen = set()
179- for line in run("nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,FREQ,SECURITY",
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",
180195 "device", "wifi", "list", "--rescan", "no").splitlines():
181- in_use, ssid, signal, freq, security = split_terse(line)
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 '\:'
182201 if in_use == "*":
183- self.ssid, self.signal, self.freq = ssid, int(signal or 0), freq
202+ self.ssid, self.signal, self.freq = ssid, signal, freq
184203 if ssid and ssid not in seen:
185204 seen.add(ssid)
186- self.networks.append((ssid, int(signal or 0), security))
205+ self.networks.append((ssid, signal))
187206 self.networks.sort(key=lambda n: -n[1])
188207
189208 def icon(self):
@@ -209,7 +228,7 @@ class Wifi:
209228 def menu(self):
210229 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
211230 lambda ssid=ssid: self.connect(ssid))
212- for ssid, signal, _ in self.networks[:15]]
231+ for ssid, signal in self.networks[:15]]
213232 items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]
214233 if self.enabled and networks:
215234 items += [None, ("Networks", networks)]
@@ -228,20 +247,14 @@ class Wifi:
228247
229248 def connect(self, ssid):
230249 # Uses the saved profile; a new secured network needs the settings TUI.
231- if not act("nmcli", "device", "wifi", "connect", ssid):
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):
232252 self.settings()
233253
234254 def settings(self):
235255 launch(TERMINAL, "-e", *NETWORK_SETTINGS)
236256
237257
238-def split_terse(line):
239- """Split an nmcli -t line: fields are separated by ':', and '\\:' is a colon."""
240- fields = [f.replace("\\:", ":").replace("\\\\", "\\")
241- for f in re.split(r"(?<!\\):", line)]
242- return (fields + [""] * 5)[:5]
243-
244-
245258 # --- battery -----------------------------------------------------------------
246259
247260 def find_battery():
@@ -257,19 +270,18 @@ class Battery:
257270 available = path != ""
258271
259272 def read(self):
260- self.percent = int(read(f"{self.path}/capacity") or 0)
273+ value = lambda name: number(read(f"{self.path}/{name}"))
274+ self.percent = value("capacity")
261275 self.status = read(f"{self.path}/status")
262- power = int(read(f"{self.path}/power_now") or 0)
263- if not power: # some batteries report a current instead of a power
264- current = int(read(f"{self.path}/current_now") or 0)
265- power = current * int(read(f"{self.path}/voltage_now") or 0) // 1_000_000
266- self.watts = power / 1_000_000
267- self.now = int(read(f"{self.path}/energy_now") or 0)
268- self.full = int(read(f"{self.path}/energy_full") or 0)
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
269282
270283 def icon(self):
271- icons = BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS
272- icon = icons[min(10, self.percent // 10)]
284+ icon = level(BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS, self.percent)
273285 if self.status == "Discharging" and self.percent <= 20:
274286 color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))
275287 return f"{BOLD}{color}{icon}{RESET}"
@@ -279,6 +291,7 @@ class Battery:
279291 rows = [("Charge", f"{self.percent}%"), ("State", self.status)]
280292 if self.watts:
281293 rows.append(("Power", f"{self.watts:.1f} W"))
294+ if self.watts and self.now:
282295 if self.status == "Discharging":
283296 rows.append(("Time left", hours(self.now / (self.watts * 1e6))))
284297 elif self.status == "Charging":
@@ -318,6 +331,8 @@ class Module:
318331 self.tooltip_at = None
319332 self.tooltip_shown = False
320333 self.actions = {} # menu item id -> function, for the open menu
334+ self.menu_token = None
335+ self.menu_part = None
321336 self.read(all_parts=True)
322337
323338 def read(self, all_parts=False):
@@ -356,10 +371,13 @@ class Module:
356371 self.tooltip_at = None
357372
358373 def open_menu(self, part, token):
359- part.read()
360374 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
361379 control({"type": "menu", "col": self.col_of(part), "width": 1,
362- "click": token, "items": self.items(part.menu())})
380+ "click": token, "items": items})
363381
364382 def items(self, entries):
365383 """Turn (label, action), (label, kind, checked, action), (label, [entries])
@@ -405,14 +423,21 @@ class Module:
405423 elif event == "scroll" and part is not None:
406424 direction, steps, _ = args
407425 part.scroll(direction, int(steps))
408- elif event == "menu-activate":
409- action = self.actions.pop(int(args[1]), None)
410- self.actions = {}
411- if action:
412- action()
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()
413438 else:
414- return # menu-closed
415- self.read(all_parts=True)
439+ return
440+ part.read()
416441 self.draw()
417442
418443 def run(self):
@@ -425,8 +450,8 @@ class Module:
425450 ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))
426451 if ready:
427452 chunk = os.read(0, 4096)
428- if not chunk:
429- return # the bar is gone
453+ if not chunk: # stdin is empty when the module is not interactive
454+ sys.exit("status.py needs interactive = true")
430455 pending += chunk
431456 while b"\n" in pending:
432457 line, pending = pending.split(b"\n", 1)
@@ -14,7 +14,8 @@ Each icon changes with its level; the numbers are in the tooltips.
14 battery right: power profile14 battery right: power profile
15 15
16 Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon16 Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
17-whose tool is missing is not shown. Edit the commands below to taste.17+whose tool is missing is not shown. The mixer (pavucontrol) and the network
18+settings (nmtui in $TERMINAL) are optional. Edit the commands below to taste.
18 """19 """
19 20
20 import json21 import json
@@ -52,7 +53,8 @@ BOLD, RESET = "\033[1m", "\033[0m"
52 def run(*cmd):53 def run(*cmd):
53 """Output of a command, or "" when it fails."""54 """Output of a command, or "" when it fails."""
54 try:55 try:
55- return subprocess.run(cmd, capture_output=True, text=True, timeout=3).stdout56+ return subprocess.run(cmd, capture_output=True, text=True, errors="replace",
57+ timeout=3).stdout
56 except (OSError, subprocess.SubprocessError):58 except (OSError, subprocess.SubprocessError):
57 return ""59 return ""
58 60
@@ -61,7 +63,8 @@ def act(*cmd):
61 """Run an action the user asked for. A failure goes to stderr, which the63 """Run an action the user asked for. A failure goes to stderr, which the
62 bar writes to its log."""64 bar writes to its log."""
63 try:65 try:
64- done = subprocess.run(cmd, capture_output=True, text=True, timeout=10)66+ done = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
67+ timeout=10)
65 except (OSError, subprocess.SubprocessError) as err:68 except (OSError, subprocess.SubprocessError) as err:
66 print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)69 print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
67 return False70 return False
@@ -75,8 +78,8 @@ def launch(*cmd):
75 try:78 try:
76 subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,79 subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
77 stderr=subprocess.DEVNULL, start_new_session=True)80 stderr=subprocess.DEVNULL, start_new_session=True)
78- except OSError:81+ except OSError as err:
79- pass82+ print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True)
80 83
81 84
82 def control(message):85 def control(message):
@@ -98,14 +101,25 @@ def read(path):
98 return ""101 return ""
99 102
100 103
101-def foreground(hex_color):104+def 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+
112+def rgb(hex_color):
102 h = hex_color.lstrip("#")113 h = hex_color.lstrip("#")
103- return f"\033[38;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"114+ return f"{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}"
115+
116+
117+def foreground(hex_color):
118+ return f"\033[38;2;{rgb(hex_color)}m"
104 119
105 120
106 def background(hex_color):121 def background(hex_color):
107- h = hex_color.lstrip("#")122+ return f"\033[48;2;{rgb(hex_color)}m"
108- return f"\033[48;2;{int(h[0:2], 16)};{int(h[2:4], 16)};{int(h[4:6], 16)}m"
109 123
110 124
111 # The bar drops blank cells at the end of a module; one painted with the bar's125 # The bar drops blank cells at the end of a module; one painted with the bar's
@@ -121,7 +135,7 @@ class Volume:
121 def read(self):135 def read(self):
122 # "Volume: 0.42" or "Volume: 0.42 [MUTED]"136 # "Volume: 0.42" or "Volume: 0.42 [MUTED]"
123 out = run("wpctl", "get-volume", SINK).split()137 out = run("wpctl", "get-volume", SINK).split()
124- self.percent = round(float(out[1]) * 100) if len(out) > 1 else 0138+ self.percent = round(number(out[1], float) * 100) if len(out) > 1 else 0
125 self.muted = "[MUTED]" in out139 self.muted = "[MUTED]" in out
126 140
127 def icon(self):141 def icon(self):
@@ -152,7 +166,7 @@ class Volume:
152 166
153 def scroll(self, direction, steps):167 def scroll(self, direction, steps):
154 sign = "+" if direction in ("up", "right") else "-"168 sign = "+" if direction in ("up", "right") else "-"
155- run("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")169+ act("wpctl", "set-volume", "-l", "1.0", SINK, f"{steps * VOLUME_STEP}%{sign}")
156 170
157 def mute(self):171 def mute(self):
158 act("wpctl", "set-mute", SINK, "toggle")172 act("wpctl", "set-mute", SINK, "toggle")
@@ -176,14 +190,19 @@ class Wifi:
176 self.ethernet = True190 self.ethernet = True
177 self.networks = []191 self.networks = []
178 seen = set()192 seen = set()
179- for line in run("nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,FREQ,SECURITY",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",
180 "device", "wifi", "list", "--rescan", "no").splitlines():195 "device", "wifi", "list", "--rescan", "no").splitlines():
181- in_use, ssid, signal, freq, security = split_terse(line)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 '\:'
182 if in_use == "*":201 if in_use == "*":
183- self.ssid, self.signal, self.freq = ssid, int(signal or 0), freq202+ self.ssid, self.signal, self.freq = ssid, signal, freq
184 if ssid and ssid not in seen:203 if ssid and ssid not in seen:
185 seen.add(ssid)204 seen.add(ssid)
186- self.networks.append((ssid, int(signal or 0), security))205+ self.networks.append((ssid, signal))
187 self.networks.sort(key=lambda n: -n[1])206 self.networks.sort(key=lambda n: -n[1])
188 207
189 def icon(self):208 def icon(self):
@@ -209,7 +228,7 @@ class Wifi:
209 def menu(self):228 def menu(self):
210 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,229 networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid,
211 lambda ssid=ssid: self.connect(ssid))230 lambda ssid=ssid: self.connect(ssid))
212- for ssid, signal, _ in self.networks[:15]]231+ for ssid, signal in self.networks[:15]]
213 items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]232 items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())]
214 if self.enabled and networks:233 if self.enabled and networks:
215 items += [None, ("Networks", networks)]234 items += [None, ("Networks", networks)]
@@ -228,20 +247,14 @@ class Wifi:
228 247
229 def connect(self, ssid):248 def connect(self, ssid):
230 # Uses the saved profile; a new secured network needs the settings TUI.249 # Uses the saved profile; a new secured network needs the settings TUI.
231- if not act("nmcli", "device", "wifi", "connect", ssid):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):
232 self.settings()252 self.settings()
233 253
234 def settings(self):254 def settings(self):
235 launch(TERMINAL, "-e", *NETWORK_SETTINGS)255 launch(TERMINAL, "-e", *NETWORK_SETTINGS)
236 256
237 257
238-def split_terse(line):
239- """Split an nmcli -t line: fields are separated by ':', and '\\:' is a colon."""
240- fields = [f.replace("\\:", ":").replace("\\\\", "\\")
241- for f in re.split(r"(?<!\\):", line)]
242- return (fields + [""] * 5)[:5]
243-
244-
245 # --- battery -----------------------------------------------------------------258 # --- battery -----------------------------------------------------------------
246 259
247 def find_battery():260 def find_battery():
@@ -257,19 +270,18 @@ class Battery:
257 available = path != ""270 available = path != ""
258 271
259 def read(self):272 def read(self):
260- self.percent = int(read(f"{self.path}/capacity") or 0)273+ value = lambda name: number(read(f"{self.path}/{name}"))
274+ self.percent = value("capacity")
261 self.status = read(f"{self.path}/status")275 self.status = read(f"{self.path}/status")
262- power = int(read(f"{self.path}/power_now") or 0)276+ # Some batteries report a current and a charge (µA, µAh) instead of a
263- if not power: # some batteries report a current instead of a power277+ # power and an energy (µW, µWh); some report them negative.
264- current = int(read(f"{self.path}/current_now") or 0)278+ volts = value("voltage_now") / 1_000_000
265- power = current * int(read(f"{self.path}/voltage_now") or 0) // 1_000_000279+ self.watts = abs(value("power_now") or value("current_now") * volts) / 1_000_000
266- self.watts = power / 1_000_000280+ self.now = value("energy_now") or value("charge_now") * volts
267- self.now = int(read(f"{self.path}/energy_now") or 0)281+ self.full = value("energy_full") or value("charge_full") * volts
268- self.full = int(read(f"{self.path}/energy_full") or 0)
269 282
270 def icon(self):283 def icon(self):
271- icons = BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS284+ icon = level(BATTERY_CHARGING if self.status == "Charging" else BATTERY_ICONS, self.percent)
272- icon = icons[min(10, self.percent // 10)]
273 if self.status == "Discharging" and self.percent <= 20:285 if self.status == "Discharging" and self.percent <= 20:
274 color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))286 color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff"))
275 return f"{BOLD}{color}{icon}{RESET}"287 return f"{BOLD}{color}{icon}{RESET}"
@@ -279,6 +291,7 @@ class Battery:
279 rows = [("Charge", f"{self.percent}%"), ("State", self.status)]291 rows = [("Charge", f"{self.percent}%"), ("State", self.status)]
280 if self.watts:292 if self.watts:
281 rows.append(("Power", f"{self.watts:.1f} W"))293 rows.append(("Power", f"{self.watts:.1f} W"))
294+ if self.watts and self.now:
282 if self.status == "Discharging":295 if self.status == "Discharging":
283 rows.append(("Time left", hours(self.now / (self.watts * 1e6))))296 rows.append(("Time left", hours(self.now / (self.watts * 1e6))))
284 elif self.status == "Charging":297 elif self.status == "Charging":
@@ -318,6 +331,8 @@ class Module:
318 self.tooltip_at = None331 self.tooltip_at = None
319 self.tooltip_shown = False332 self.tooltip_shown = False
320 self.actions = {} # menu item id -> function, for the open menu333 self.actions = {} # menu item id -> function, for the open menu
334+ self.menu_token = None
335+ self.menu_part = None
321 self.read(all_parts=True)336 self.read(all_parts=True)
322 337
323 def read(self, all_parts=False):338 def read(self, all_parts=False):
@@ -356,10 +371,13 @@ class Module:
356 self.tooltip_at = None371 self.tooltip_at = None
357 372
358 def open_menu(self, part, token):373 def open_menu(self, part, token):
359- part.read()
360 self.actions = {}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
361 control({"type": "menu", "col": self.col_of(part), "width": 1,379 control({"type": "menu", "col": self.col_of(part), "width": 1,
362- "click": token, "items": self.items(part.menu())})380+ "click": token, "items": items})
363 381
364 def items(self, entries):382 def items(self, entries):
365 """Turn (label, action), (label, kind, checked, action), (label, [entries])383 """Turn (label, action), (label, kind, checked, action), (label, [entries])
@@ -405,14 +423,21 @@ class Module:
405 elif event == "scroll" and part is not None:423 elif event == "scroll" and part is not None:
406 direction, steps, _ = args424 direction, steps, _ = args
407 part.scroll(direction, int(steps))425 part.scroll(direction, int(steps))
408- elif event == "menu-activate":426+ if self.tooltip_shown:
409- action = self.actions.pop(int(args[1]), None)427+ # The bar opens one tooltip per 250 ms: show the new level
410- self.actions = {}428+ # once the wheel stops, in case the last one was dropped.
411- if action:429+ self.tooltip_at = time.monotonic() + TOOLTIP_DELAY
412- action()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()
413 else:438 else:
414- return # menu-closed439+ return
415- self.read(all_parts=True)440+ part.read()
416 self.draw()441 self.draw()
417 442
418 def run(self):443 def run(self):
@@ -425,8 +450,8 @@ class Module:
425 ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))450 ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now))
426 if ready:451 if ready:
427 chunk = os.read(0, 4096)452 chunk = os.read(0, 4096)
428- if not chunk:453+ if not chunk: # stdin is empty when the module is not interactive
429- return # the bar is gone454+ sys.exit("status.py needs interactive = true")
430 pending += chunk455 pending += chunk
431 while b"\n" in pending:456 while b"\n" in pending:
432 line, pending = pending.split(b"\n", 1)457 line, pending = pending.split(b"\n", 1)

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