docs(examples): Python volume module with a right-click menu
- commit
- b7ee660767fbd1d8eadaea9c72e2f7ff65137c0f
- author
- brice · · 2026-10-09 06:53 UTC
- parent
- 718835e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +124 -3
M README.md +27 -3
| @@ -227,12 +227,35 @@ done | |||
| 227 | 227 | ||
| 228 | 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 | - | ```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%"}) | |
| 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 | 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 | 261 | | `type` | Fields | Effect | | |
| @@ -281,6 +304,7 @@ The `examples/modules` directory has these: | |||
| 281 | 304 | - `static.sh`: one line, then idle. | |
| 282 | 305 | - `clock.sh`: a clock with an icon. | |
| 283 | 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 | 309 | ## Layout | |
| 286 | 310 | ||
| @@ -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 | - | ```sh | 232 | + | ```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 | ## Layout | 309 | ## 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() | |||