main 7a3b92c

status.py RawHistoryFolder

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

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