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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 256 | 256 | ||
| 257 | 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 | 261 | | `type` | Fields | Effect | | |
| 262 | 262 | |---|---|---| | |
| @@ -266,9 +266,9 @@ for line in sys.stdin: | |||
| 266 | 266 | ||
| 267 | 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 | 273 | ### The bundled bar | |
| 274 | 274 | ||
| @@ -304,7 +304,7 @@ The `examples/modules` directory has these: | |||
| 304 | 304 | - `static.sh`: one line, then idle. | |
| 305 | 305 | - `clock.sh`: a clock with an icon. | |
| 306 | 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 | 309 | ## Layout | |
| 310 | 310 | ||
| @@ -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 bar | 273 | ### 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 | ## Layout | 309 | ## 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. | |||
| 14 | 14 | battery right: power profile | |
| 15 | 15 | ||
| 16 | 16 | 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 | 21 | import json | |
| @@ -52,7 +53,8 @@ BOLD, RESET = "\033[1m", "\033[0m" | |||
| 52 | 53 | def run(*cmd): | |
| 53 | 54 | """Output of a command, or "" when it fails.""" | |
| 54 | 55 | 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 | |
| 56 | 58 | except (OSError, subprocess.SubprocessError): | |
| 57 | 59 | return "" | |
| 58 | 60 | ||
| @@ -61,7 +63,8 @@ def act(*cmd): | |||
| 61 | 63 | """Run an action the user asked for. A failure goes to stderr, which the | |
| 62 | 64 | bar writes to its log.""" | |
| 63 | 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 | 68 | except (OSError, subprocess.SubprocessError) as err: | |
| 66 | 69 | print(f"{cmd[0]}: {err}", file=sys.stderr, flush=True) | |
| 67 | 70 | return False | |
| @@ -75,8 +78,8 @@ def launch(*cmd): | |||
| 75 | 78 | try: | |
| 76 | 79 | subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, | |
| 77 | 80 | 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) | |
| 80 | 83 | ||
| 81 | 84 | ||
| 82 | 85 | def control(message): | |
| @@ -98,14 +101,25 @@ def read(path): | |||
| 98 | 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 | 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 | 121 | 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" | |
| 109 | 123 | ||
| 110 | 124 | ||
| 111 | 125 | # The bar drops blank cells at the end of a module; one painted with the bar's | |
| @@ -121,7 +135,7 @@ class Volume: | |||
| 121 | 135 | def read(self): | |
| 122 | 136 | # "Volume: 0.42" or "Volume: 0.42 [MUTED]" | |
| 123 | 137 | 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 | |
| 125 | 139 | self.muted = "[MUTED]" in out | |
| 126 | 140 | ||
| 127 | 141 | def icon(self): | |
| @@ -152,7 +166,7 @@ class Volume: | |||
| 152 | 166 | ||
| 153 | 167 | def scroll(self, direction, steps): | |
| 154 | 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 | 171 | def mute(self): | |
| 158 | 172 | act("wpctl", "set-mute", SINK, "toggle") | |
| @@ -176,14 +190,19 @@ class Wifi: | |||
| 176 | 190 | self.ethernet = True | |
| 177 | 191 | self.networks = [] | |
| 178 | 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 | 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 | 201 | 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 | |
| 184 | 203 | if ssid and ssid not in seen: | |
| 185 | 204 | seen.add(ssid) | |
| 186 | - | self.networks.append((ssid, int(signal or 0), security)) | |
| 205 | + | self.networks.append((ssid, signal)) | |
| 187 | 206 | self.networks.sort(key=lambda n: -n[1]) | |
| 188 | 207 | ||
| 189 | 208 | def icon(self): | |
| @@ -209,7 +228,7 @@ class Wifi: | |||
| 209 | 228 | def menu(self): | |
| 210 | 229 | networks = [(f"{ssid} {signal}%", "radio", ssid == self.ssid, | |
| 211 | 230 | lambda ssid=ssid: self.connect(ssid)) | |
| 212 | - | for ssid, signal, _ in self.networks[:15]] | |
| 231 | + | for ssid, signal in self.networks[:15]] | |
| 213 | 232 | items = [("Wi-Fi", "check", self.enabled, lambda: self.toggle())] | |
| 214 | 233 | if self.enabled and networks: | |
| 215 | 234 | items += [None, ("Networks", networks)] | |
| @@ -228,20 +247,14 @@ class Wifi: | |||
| 228 | 247 | ||
| 229 | 248 | def connect(self, ssid): | |
| 230 | 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 | 252 | self.settings() | |
| 233 | 253 | ||
| 234 | 254 | def settings(self): | |
| 235 | 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 | 258 | # --- battery ----------------------------------------------------------------- | |
| 246 | 259 | ||
| 247 | 260 | def find_battery(): | |
| @@ -257,19 +270,18 @@ class Battery: | |||
| 257 | 270 | available = path != "" | |
| 258 | 271 | ||
| 259 | 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 | 275 | 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 | |
| 269 | 282 | ||
| 270 | 283 | 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) | |
| 273 | 285 | if self.status == "Discharging" and self.percent <= 20: | |
| 274 | 286 | color = foreground(os.environ.get("NEFERBAR_COLOR15", "#ffffff")) | |
| 275 | 287 | return f"{BOLD}{color}{icon}{RESET}" | |
| @@ -279,6 +291,7 @@ class Battery: | |||
| 279 | 291 | rows = [("Charge", f"{self.percent}%"), ("State", self.status)] | |
| 280 | 292 | if self.watts: | |
| 281 | 293 | rows.append(("Power", f"{self.watts:.1f} W")) | |
| 294 | + | if self.watts and self.now: | |
| 282 | 295 | if self.status == "Discharging": | |
| 283 | 296 | rows.append(("Time left", hours(self.now / (self.watts * 1e6)))) | |
| 284 | 297 | elif self.status == "Charging": | |
| @@ -318,6 +331,8 @@ class Module: | |||
| 318 | 331 | self.tooltip_at = None | |
| 319 | 332 | self.tooltip_shown = False | |
| 320 | 333 | self.actions = {} # menu item id -> function, for the open menu | |
| 334 | + | self.menu_token = None | |
| 335 | + | self.menu_part = None | |
| 321 | 336 | self.read(all_parts=True) | |
| 322 | 337 | ||
| 323 | 338 | def read(self, all_parts=False): | |
| @@ -356,10 +371,13 @@ class Module: | |||
| 356 | 371 | self.tooltip_at = None | |
| 357 | 372 | ||
| 358 | 373 | def open_menu(self, part, token): | |
| 359 | - | part.read() | |
| 360 | 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 | 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 | 382 | def items(self, entries): | |
| 365 | 383 | """Turn (label, action), (label, kind, checked, action), (label, [entries]) | |
| @@ -405,14 +423,21 @@ class Module: | |||
| 405 | 423 | elif event == "scroll" and part is not None: | |
| 406 | 424 | direction, steps, _ = args | |
| 407 | 425 | 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() | |
| 413 | 438 | else: | |
| 414 | - | return # menu-closed | |
| 415 | - | self.read(all_parts=True) | |
| 439 | + | return | |
| 440 | + | part.read() | |
| 416 | 441 | self.draw() | |
| 417 | 442 | ||
| 418 | 443 | def run(self): | |
| @@ -425,8 +450,8 @@ class Module: | |||
| 425 | 450 | ready, _, _ = select.select([0], [], [], max(0, min(deadlines) - now)) | |
| 426 | 451 | if ready: | |
| 427 | 452 | 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") | |
| 430 | 455 | pending += chunk | |
| 431 | 456 | while b"\n" in pending: | |
| 432 | 457 | 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 profile | 14 | battery right: power profile | ||
| 15 | 15 | ||||
| 16 | Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon | 16 | 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 json | 21 | 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).stdout | 56 | + | 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 the | 63 | """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 False | 70 | 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 | - | pass | 82 | + | 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's | 125 | # 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 0 | 138 | + | self.percent = round(number(out[1], float) * 100) if len(out) > 1 else 0 |
| 125 | self.muted = "[MUTED]" in out | 139 | 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 = True | 190 | 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), freq | 202 | + | 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 power | 277 | + | # 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_000 | 279 | + | self.watts = abs(value("power_now") or value("current_now") * volts) / 1_000_000 |
| 266 | - | self.watts = power / 1_000_000 | 280 | + | 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_ICONS | 284 | + | 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 = None | 331 | self.tooltip_at = None | ||
| 319 | self.tooltip_shown = False | 332 | self.tooltip_shown = False | ||
| 320 | self.actions = {} # menu item id -> function, for the open menu | 333 | 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 = None | 371 | 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, _ = args | 424 | 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-closed | 439 | + | 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 gone | 454 | + | sys.exit("status.py needs interactive = true") |
| 430 | pending += chunk | 455 | 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) | ||