detached b7ee660

docs(examples): Python volume module with a right-click menu

commit
b7ee660767fbd1d8eadaea9c72e2f7ff65137c0f
author
brice · · 2026-10-09 06:53 UTC
parent
718835e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +124 -3
M README.md +27 -3
@@ -227,12 +227,35 @@ done
227227
228228 An interactive module can ask the bar for a **tooltip** or a **menu** by printing a *control line* instead of a frame. The bar draws it as a popup under the module (above it when `position = "bottom"`), with your theme's colors and font. Only modules with `interactive = true` can open popups, and there is one popup at a time.
229229
230-A control line is the escape sequence `ESC ] 777 ; neferbar ; <json> BEL`, then a newline:
230+A control line is the escape sequence `ESC ] 777 ; neferbar ; <json> BEL`, then a newline. A language with a JSON library makes it easy; in Python:
231231
232-```sh
233-printf '\033]777;neferbar;{"type":"tooltip","col":0,"width":1,"title":"Volume","body":"42%%"}\007\n'
232+```python
233+import json
234+
235+def control(message):
236+ print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
237+
238+control({"type": "tooltip", "col": 0, "width": 1, "title": "Volume", "body": "42%"})
234239 ```
235240
241+A right-click menu answers the `click` line it opens for:
242+
243+```python
244+for line in sys.stdin:
245+ event, *args = line.split()
246+ if event == "click" and args[0] == "right":
247+ token = int(args[2])
248+ control({"type": "menu", "col": 0, "width": 1, "click": token, "items": [
249+ {"id": 1, "label": "Mute", "kind": "check", "checked": False},
250+ {"id": 0, "kind": "separator"},
251+ {"id": 2, "label": "Settings"},
252+ ]})
253+ elif event == "menu-activate":
254+ token, item = map(int, args) # item is 1 or 2
255+```
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.
258+
236259 `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:
237260
238261 | `type` | Fields | Effect |
@@ -281,6 +304,7 @@ The `examples/modules` directory has these:
281304 - `static.sh`: one line, then idle.
282305 - `clock.sh`: a clock with an icon.
283306 - `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`).
284308
285309 ## Layout
286310
@@ -227,12 +227,35 @@ done
227 227
228 An interactive module can ask the bar for a **tooltip** or a **menu** by printing a *control line* instead of a frame. The bar draws it as a popup under the module (above it when `position = "bottom"`), with your theme's colors and font. Only modules with `interactive = true` can open popups, and there is one popup at a time.228 An interactive module can ask the bar for a **tooltip** or a **menu** by printing a *control line* instead of a frame. The bar draws it as a popup under the module (above it when `position = "bottom"`), with your theme's colors and font. Only modules with `interactive = true` can open popups, and there is one popup at a time.
229 229
230-A control line is the escape sequence `ESC ] 777 ; neferbar ; <json> BEL`, then a newline:230+A control line is the escape sequence `ESC ] 777 ; neferbar ; <json> BEL`, then a newline. A language with a JSON library makes it easy; in Python:
231 231
232-```sh232+```python
233-printf '\033]777;neferbar;{"type":"tooltip","col":0,"width":1,"title":"Volume","body":"42%%"}\007\n'233+import json
234+
235+def control(message):
236+ print(f"\033]777;neferbar;{json.dumps(message)}\007", flush=True)
237+
238+control({"type": "tooltip", "col": 0, "width": 1, "title": "Volume", "body": "42%"})
234 ```239 ```
235 240
241+A right-click menu answers the `click` line it opens for:
242+
243+```python
244+for line in sys.stdin:
245+ event, *args = line.split()
246+ if event == "click" and args[0] == "right":
247+ token = int(args[2])
248+ control({"type": "menu", "col": 0, "width": 1, "click": token, "items": [
249+ {"id": 1, "label": "Mute", "kind": "check", "checked": False},
250+ {"id": 0, "kind": "separator"},
251+ {"id": 2, "label": "Settings"},
252+ ]})
253+ elif event == "menu-activate":
254+ token, item = map(int, args) # item is 1 or 2
255+```
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.
258+
236 `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:
237 260
238 | `type` | Fields | Effect |261 | `type` | Fields | Effect |
@@ -281,6 +304,7 @@ The `examples/modules` directory has these:
281 - `static.sh`: one line, then idle.304 - `static.sh`: one line, then idle.
282 - `clock.sh`: a clock with an icon.305 - `clock.sh`: a clock with an icon.
283 - `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`).
284 308
285 ## Layout309 ## Layout
286 310
A examples/modules/volume.py +97 -0
@@ -0,0 +1,97 @@
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()
@@ -0,0 +1,97 @@
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