detached da0887c

Merge pull request #4: feat: popups for modules, tray tooltips and menus

commit
da0887cf1b4cc8b4d2d0914f93c1623088333f83
author
bnema · · 2026-10-09 08:57 UTC
merge of
a7668cd + 8aba8bb · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.3.0
27 files · +4608 -46
M .gitignore +2 -0
@@ -2,3 +2,5 @@
22 /neferbar
33 /bin/
44 /dist/
5+go.work
6+go.work.sum
M README.md +63 -2
@@ -223,6 +223,53 @@ while read -r event button col token; do
223223 done
224224 ```
225225
226+### Popups
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.
229+
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+
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%"})
239+```
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/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+
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+
261+| `type` | Fields | Effect |
262+|---|---|---|
263+| `tooltip` | `col`, `width`, `title`, `body`, `rows` | shown only while the pointer is on the module: `title` in bold, then `body` (`\n` starts a new line, an empty line is kept), then `rows`, a table such as `[["Signal", "62%"], ["Band", "5 GHz"]]` whose columns line up |
264+| `menu` | `col`, `width`, `click`, `items` | opens a menu; `click` is the token of the `click` line it answers |
265+| `close` | none | closes the module's tooltip |
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.
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. A module opens at most one tooltip every 250 ms, and a tooltip never covers an open menu.
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, and never shown as text. A line that starts like a control line but has no BEL is an ordinary frame.
272+
226273 ### The bundled bar
227274
228275 `examples/bundle` is a ready-made bar: workspaces on the left, the focused app in the middle, the clock on the right, in your terminal's colors.
@@ -257,6 +304,7 @@ The `examples/modules` directory has these:
257304 - `static.sh`: one line, then idle.
258305 - `clock.sh`: a clock with an icon.
259306 - `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`, and optionally `pavucontrol` and `nmtui`).
260308
261309 ## Layout
262310
@@ -317,10 +365,23 @@ The icon takes the main color of the application's own icon, moved toward the ba
317365
318366 - a left click calls the application's `Activate` (for an application that only offers a menu, it asks for the menu instead);
319367 - a middle click calls `SecondaryActivate`;
320-- a right click calls `ContextMenu`, so the application shows its own menu;
368+- a right click opens the application's menu, drawn by the bar (see below); an application without a dbusmenu is asked to show its own with `ContextMenu`;
321369 - the wheel calls `Scroll`: down and right are positive.
322370
323-Without `interactive = true` the icons only show. The tray talks to each application on a second connection and gives up on a call after 2 seconds, so a frozen application never blocks the other icons. Tooltips and the bar's own menus are not supported yet.
371+Without `interactive = true` the icons only show. The tray talks to each application on a second connection and gives up on a call after 2 seconds, so a frozen application never blocks the other icons.
372+
373+**Menus.** For an application that publishes a dbusmenu (most do: Steam, Discord, nm-applet, the KDE applets), a right click opens its menu as a popup under the icon, or above it with `position = "bottom"`. A menu taller than 600 pixels scrolls. Check and radio entries show their state, disabled entries do nothing, and an entry with a submenu opens it in place, with a "‹ Back" entry on top. Choosing an entry sends the application a `clicked` event; dismissing the menu sends `closed`.
374+
375+| Key | Action |
376+|---|---|
377+| Down, Up, Tab, Shift+Tab | move between entries |
378+| Enter, Space | choose the highlighted entry |
379+| Left, BackSpace | go back out of a submenu |
380+| Escape | close the menu |
381+
382+A click outside the menu also closes it. Entries are text only: icons and menus that fill in their submenus lazily are not supported.
383+
384+Resting the pointer on an icon for half a second shows the application's **tooltip** (its title in bold, its description below, markup removed; the item's title when it has no tooltip). Leaving the icon or clicking closes it.
324385
325386 Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name, the text to show, or `""` to hide the item:
326387
A examples/modules/status.py +475 -0
@@ -0,0 +1,475 @@
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+
10+Each 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+
16+Needs wpctl (PipeWire), nmcli (NetworkManager) and powerprofilesctl; an icon
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.
19+"""
20+
21+import json
22+import os
23+import re
24+import select
25+import shutil
26+import subprocess
27+import sys
28+import time
29+
30+TERMINAL = os.environ.get("TERMINAL", "kitty")
31+MIXER = ["pavucontrol"]
32+NETWORK_SETTINGS = ["nmtui"] # runs in TERMINAL; impala if you use iwd
33+VOLUME_STEP = 2 # percent per wheel step
34+TOOLTIP_DELAY = 0.4 # seconds of hover before a tooltip
35+REFRESH = 5.0 # seconds between two reads of volume and battery
36+WIFI_REFRESH = 10.0
37+
38+SINK = "@DEFAULT_AUDIO_SINK@"
39+
40+# Nerd Font (Material Design) icons, from empty to full.
41+VOLUME_ICONS = ["󰕿", "󰖀", "󰕾"]
42+VOLUME_MUTED = "󰝟"
43+WIFI_ICONS = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
44+WIFI_OFF, WIFI_DOWN, ETHERNET = "󰖪", "󰤮", "󰈀"
45+BATTERY_ICONS = ["󰂎", "󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
46+BATTERY_CHARGING = ["󰢟", "󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
47+
48+BOLD, RESET = "\033[1m", "\033[0m"
49+
50+
51+# --- helpers -----------------------------------------------------------------
52+
53+def 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+
62+def 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+
76+def 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+
85+def 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+
90+def 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+
96+def read(path):
97+ try:
98+ with open(path) as f:
99+ return f.read().strip()
100+ except OSError:
101+ return ""
102+
103+
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):
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+
117+def foreground(hex_color):
118+ return f"\033[38;2;{rgb(hex_color)}m"
119+
120+
121+def 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.
127+GAP = background(os.environ.get("NEFERBAR_BACKGROUND", "#000000")) + " " + "\033[0m"
128+
129+
130+# --- volume ------------------------------------------------------------------
131+
132+class 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+
180+class 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+
260+def 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+
268+class 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+
319+def hours(h):
320+ return f"{int(h)}h{int(h * 60) % 60:02d}"
321+
322+
323+# --- the module --------------------------------------------------------------
324+
325+class 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+
474+if __name__ == "__main__":
475+ Module().run()
M go.mod +3 -1
@@ -5,7 +5,8 @@ go 1.27
55 require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/bnema/go-wayland-bindings v0.1.0
8- github.com/bnema/neferclient v0.3.0
8+ github.com/bnema/neferclient v0.4.0
9+ github.com/bnema/nefergui v0.7.0
910 github.com/bnema/purego-vulkan v0.6.0
1011 github.com/bnema/vev-vt v0.6.0
1112 github.com/bnema/wlturbo v0.6.2
@@ -17,5 +18,6 @@ require (
1718 require (
1819 github.com/bnema/purego v0.13.0-bnema.1 // indirect
1920 github.com/bnema/purego-xkbcommon v0.2.0 // indirect
21+ github.com/go-text/typesetting v0.3.5 // indirect
2022 golang.org/x/text v0.42.0 // indirect
2123 )
M go.sum +8 -2
@@ -2,8 +2,10 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
22 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
33 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=
44 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
5-github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=
6-github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
5+github.com/bnema/neferclient v0.4.0 h1:kWTbQkK3+7avVUAMvg9NLMNDocJKqLwEA71/1RDlWb4=
6+github.com/bnema/neferclient v0.4.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
7+github.com/bnema/nefergui v0.7.0 h1:RBcmMAofn+eIWpbBmLrjStuYxxy1D1W3HErtmtJxECE=
8+github.com/bnema/nefergui v0.7.0/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
79 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
810 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
911 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
@@ -16,6 +18,10 @@ github.com/bnema/wlturbo v0.6.2 h1:wV+NlplUoh6wG9E9ZKsPATMDSrTaiez3+T0xCDFZZjg=
1618 github.com/bnema/wlturbo v0.6.2/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
1719 github.com/bnema/zerobus v0.1.1 h1:N17A0XtvvVfneXHC1NHwSOL0fHbn7xDOP+rw3LZw8NE=
1820 github.com/bnema/zerobus v0.1.1/go.mod h1:QWo6NcbIyic3+gcqwB3IzZh8dIEdXFfw7WdhjNPITYM=
21+github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
22+github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
23+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
24+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
1925 github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
2026 github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
2127 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
M internal/bar/bar.go +92 -8
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/neferbar/internal/gpu"
2929 "github.com/bnema/neferbar/internal/layout"
3030 "github.com/bnema/neferbar/internal/module"
31+ "github.com/bnema/neferbar/internal/popup"
3132 "github.com/bnema/neferbar/internal/syncobj"
3233 "github.com/bnema/neferbar/internal/theme"
3334 )
@@ -119,6 +120,15 @@ type Bar struct {
119120 ptr pointerState // pointer events turned into module lines
120121 input inputRegion
121122
123+ pop *popup.Host // the tooltip or menu being shown, if any
124+ popC <-chan time.Time
125+ popCSS string // the stylesheet last written for the popups
126+ warmed bool
127+ spanBuf []layout.Span
128+ badCtl map[*module.Module]bool // modules whose invalid control line was logged
129+ tips tooltipLimiter
130+ ctlBuf []module.Control // the control lines of one wake-up, reused
131+
122132 Stats Stats
123133 fatal error
124134 closed bool
@@ -186,6 +196,7 @@ func (b *Bar) setTheme(th theme.Theme) {
186196 }
187197 b.dirty = true
188198 b.watchTheme()
199+ b.updatePopupStyle()
189200 }
190201
191202 // watchTheme watches every file the theme was read from, so changing the
@@ -323,12 +334,16 @@ func (b *Bar) syncModules(want []config.Module) {
323334 r.cancel()
324335 }
325336 b.runners = next
337+ if o := b.pop.Owner(); o != nil && !slices.ContainsFunc(next, func(r runner) bool { return r.m == o }) {
338+ b.closePopup() // its module was stopped or replaced
339+ }
326340 clear(b.stale)
327341 b.watchScripts()
328342 b.mods = b.mods[:0]
329343 for _, r := range next {
330344 b.mods = append(b.mods, r.m)
331345 }
346+ b.forgetModules(b.mods)
332347 if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
333348 b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
334349 }
@@ -353,6 +368,7 @@ func (b *Bar) applyConfig(next config.Config) {
353368 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
354369 next.Bar.Output = old.Bar.Output
355370 }
371+ b.closePopup() // it was built from the old settings
356372 b.cfg = next
357373 b.fonts = fonts
358374 if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
@@ -365,6 +381,7 @@ func (b *Bar) applyConfig(next config.Config) {
365381 b.log.Info("moving the bar", "to", next.Bar.Position)
366382 b.dropRenderer()
367383 _, h, _ := b.surf.Size()
384+ b.closePopup()
368385 if err = b.surf.Close(); err != nil {
369386 b.fail(err)
370387 return
@@ -389,6 +406,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
389406 if err != nil {
390407 return connectError(b.display, err)
391408 }
409+ b.pop = popup.NewHost(b.conn, b.log)
410+ b.updatePopupStyle()
392411 defer func() {
393412 err = errors.Join(err, b.shutdown())
394413 b.log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
@@ -417,6 +436,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
417436 if err = b.conn.Dispatch(b); err != nil {
418437 return fmt.Errorf("dispatch: %w", err)
419438 }
439+ case <-b.pop.Wake(): // the popup renderer wants a frame
440+ case <-b.popC: // a popup frame was waiting for the GPU
420441 case <-b.modCh:
421442 b.Stats.ModuleWake++
422443 case next := <-b.cfgCh:
@@ -435,6 +456,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
435456 if err = b.step(); err != nil {
436457 return err
437458 }
459+ b.stepPopup()
438460 }
439461 return nil
440462 }
@@ -637,6 +659,7 @@ func (b *Bar) reconcile() {
637659 b.askedH, b.askedScale = wantH, scale
638660 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
639661 b.dropRenderer()
662+ b.closePopup()
640663 if err := b.surf.Close(); err != nil {
641664 b.fail(err)
642665 return
@@ -664,6 +687,7 @@ func (b *Bar) reconcile() {
664687 // off. A new surface is mapped fresh and placed from its real size.
665688 b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w)
666689 b.dropRenderer()
690+ b.closePopup()
667691 if err := b.surf.Close(); err != nil {
668692 b.fail(err)
669693 return
@@ -751,6 +775,13 @@ func (b *Bar) shutdown() error {
751775 for _, r := range b.runners {
752776 r.m.Wait(3 * time.Second)
753777 }
778+ b.closePopup()
779+ // The warm-up renderer must be gone before the GPU state is, but a stuck
780+ // GPU library must not hold the shutdown.
781+ if !b.pop.WaitTimeout(warmupWait) {
782+ b.log.Warn("the popup warm-up did not end; shutting down anyway")
783+ }
784+ popup.RemoveCSS()
754785 b.dropRenderer()
755786 b.face.Close()
756787 if b.node != nil {
@@ -767,6 +798,9 @@ func (b *Bar) shutdown() error {
767798 // change before it rebuilds.
768799 const settleDelay = 60 * time.Millisecond
769800
801+// warmupWait is how long shutdown waits for the popup warm-up.
802+const warmupWait = 2 * time.Second
803+
770804 // geometryChanged is called when the compositor reports a new scale or size.
771805 // The first report builds at once, because there is nothing on screen yet.
772806 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.
@@ -783,6 +817,10 @@ func (b *Bar) geometryChanged() {
783817
784818 // Configure marks the surface presentable and rebuilds as needed.
785819 func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
820+ if b.pop.Is(id) {
821+ b.pop.Configure()
822+ return
823+ }
786824 if id != b.sid {
787825 return
788826 }
@@ -795,6 +833,10 @@ func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
795833
796834 // Scale follows the monitor's preferred scale.
797835 func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
836+ if b.pop.Is(id) {
837+ b.pop.Scale()
838+ return
839+ }
798840 if id == b.sid {
799841 b.geometryChanged()
800842 }
@@ -821,13 +863,43 @@ func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
821863
822864 // Frame allows the next Present.
823865 func (b *Bar) Frame(id neferclient.SurfaceID) {
866+ if b.pop.Is(id) {
867+ b.pop.Frame()
868+ return
869+ }
824870 if id == b.sid {
825871 b.canPresent = true
826872 }
827873 }
828874
875+// PopupDone is called when the compositor dismisses a popup of the bar: an
876+// outside click, a denied grab. A menu tells its script.
877+func (b *Bar) PopupDone(id neferclient.SurfaceID) {
878+ if b.pop.Is(id) {
879+ b.pop.PopupDone()
880+ }
881+}
882+
883+// Key goes to the popup that has the keyboard.
884+func (b *Bar) Key(ev *neferclient.KeyEvent) {
885+ if b.pop.Is(ev.Surface) {
886+ b.pop.Key(ev)
887+ }
888+}
889+
890+// KeyboardFocus tells a popup whether it has the keyboard.
891+func (b *Bar) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
892+ if b.pop.Is(id) {
893+ b.pop.KeyboardFocus(focused)
894+ }
895+}
896+
829897 // Closed ends the loop when the compositor closes the surface.
830898 func (b *Bar) Closed(id neferclient.SurfaceID) {
899+ if b.pop.Is(id) {
900+ b.closePopup()
901+ return
902+ }
831903 if id == b.sid {
832904 b.closed = true
833905 }
@@ -835,6 +907,10 @@ func (b *Bar) Closed(id neferclient.SurfaceID) {
835907
836908 // FDReady hands a slot back once the compositor released it.
837909 func (b *Bar) FDReady(id uint64) {
910+ if id >= popup.FDBase {
911+ b.pop.FDReady(id) // the release descriptor of a popup buffer
912+ return
913+ }
838914 if b.rend == nil {
839915 return
840916 }
@@ -859,6 +935,10 @@ func (b *Bar) syncInputRegion() {
859935
860936 // Pointer turns pointer events over the bar into lines for interactive modules.
861937 func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
938+ if b.pop.Is(ev.Surface) {
939+ b.pop.Pointer(ev)
940+ return
941+ }
862942 if ev.Surface != b.sid || b.lay == nil || b.face == nil {
863943 return
864944 }
@@ -871,12 +951,18 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
871951 col := cellColumn(ev.X, scale, cellW)
872952 switch ev.Kind {
873953 case neferclient.PointerEnter, neferclient.PointerMotion:
954+ before := b.ptr.hover
874955 b.ptr.motion(b.lay, col)
956+ if b.ptr.hover != before {
957+ b.closeTooltip() // the pointer moved to another module
958+ }
875959 case neferclient.PointerLeave:
876960 b.ptr.leave()
961+ b.closeTooltip()
877962 case neferclient.PointerButton:
878963 if ev.Pressed {
879- b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
964+ b.closePopup() // a press on the bar ends a tooltip or a menu
965+ b.ptr.button(b.lay, col, ev.Button, ev.Serial)
880966 }
881967 case neferclient.PointerAxis:
882968 delta := ev.DY
@@ -893,10 +979,8 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
893979 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
894980
895981 // Unused events.
896-func (b *Bar) OutputAdded(*neferclient.Output) {}
897-func (b *Bar) OutputRemoved(uint32) {}
898-func (b *Bar) Locked() {}
899-func (b *Bar) LockFinished() {}
900-func (b *Bar) Key(*neferclient.KeyEvent) {}
901-func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
902-func (b *Bar) SecretChanged(int) {}
982+func (b *Bar) OutputAdded(*neferclient.Output) {}
983+func (b *Bar) OutputRemoved(uint32) {}
984+func (b *Bar) Locked() {}
985+func (b *Bar) LockFinished() {}
986+func (b *Bar) SecretChanged(int) {}
M internal/bar/input.go +24 -7
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/neferbar/internal/layout"
1212 "github.com/bnema/neferbar/internal/module"
13+ "github.com/bnema/neferbar/internal/popup"
1314 )
1415
1516 // Evdev button codes.
@@ -24,9 +25,28 @@ const pxPerStep = 15
2425
2526 // press is the latest button press sent to a module.
2627 type press struct {
27- token uint32 // the number the module echoes to refer to this press
28- serial uint32 // the pointer serial of the press, for popup grabs
28+ m *module.Module // the module the press was sent to
29+ token uint32 // the number the module echoes to refer to this press
30+ serial uint32 // the pointer serial of the press, for popup grabs
2931 at time.Time
32+ used bool // a menu already answered this press
33+}
34+
35+// menuPressTTL is how long after a press a module may still answer it with a
36+// menu.
37+const menuPressTTL = 5 * time.Second
38+
39+// takeMenuPress accepts a menu that module m opens in answer to the press
40+// numbered token: it must be the latest press, sent to m, not older than
41+// menuPressTTL, and not answered yet. The serial of the press is what the
42+// popup grabs with. A press answers one menu.
43+func (p *pointerState) takeMenuPress(m *module.Module, token uint32, now time.Time) (serial uint32, ok bool) {
44+ pr := &p.press
45+ if m == nil || pr.m != m || token == 0 || pr.token != token || pr.used || pr.serial == 0 || now.Sub(pr.at) > menuPressTTL {
46+ return 0, false
47+ }
48+ pr.used = true
49+ return pr.serial, true
3050 }
3151
3252 // pointerState turns pointer events over the bar into the lines of the module
@@ -107,7 +127,7 @@ func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial u
107127 return
108128 }
109129 p.token++
110- p.press = press{token: p.token, serial: serial, at: time.Now()}
130+ p.press = press{m: m, token: p.token, serial: serial, at: time.Now()}
111131 b := append(p.line("click "), name...)
112132 b = appendInt(b, off)
113133 b = append(b, ' ')
@@ -204,10 +224,7 @@ func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
204224 // inputRect is the logical-pixel rectangle of a span of cells: rounded
205225 // outward, full surface height.
206226 func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
207- const eps = 1e-9
208- x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
209- x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
210- return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
227+ return popup.CellRect(start, width, cellW, scale, height)
211228 }
212229
213230 // inputKey is everything but the spans that the input rectangles depend on.
A internal/bar/popup.go +237 -0
@@ -0,0 +1,237 @@
1+package bar
2+
3+import (
4+ "slices"
5+ "time"
6+
7+ "github.com/bnema/neferbar/internal/layout"
8+ "github.com/bnema/neferbar/internal/module"
9+ "github.com/bnema/neferbar/internal/popup"
10+)
11+
12+// popupPoll is how long the loop waits before it asks again for a popup frame
13+// that is waiting for the GPU.
14+const popupPoll = 2 * time.Millisecond
15+
16+// updatePopupStyle writes the stylesheet of the popups when the bar's colors,
17+// font or size changed. Without a stylesheet no popup opens.
18+func (b *Bar) updatePopupStyle() {
19+ if b.pop == nil {
20+ return
21+ }
22+ css := popup.CSS(popup.Style{
23+ Font: b.cfg.Bar.Font, Size: b.cfg.Bar.Size * b.cfg.Bar.Scale,
24+ FG: b.fg, BG: b.bg, Accent: b.pal[b.cfg.Bar.Accent&15],
25+ })
26+ if css == b.popCSS {
27+ return
28+ }
29+ path, err := popup.WriteCSS(css)
30+ if err != nil {
31+ b.log.Warn("popups are off: cannot write their stylesheet", "err", err)
32+ b.pop.SetStyles("")
33+ b.popCSS = ""
34+ return
35+ }
36+ b.popCSS = css
37+ b.pop.SetStyles(path)
38+}
39+
40+// closePopup dismisses the tooltip or menu being shown.
41+func (b *Bar) closePopup() { b.pop.Close() }
42+
43+// closeTooltip dismisses the popup when it is a tooltip; a menu stays.
44+func (b *Bar) closeTooltip() {
45+ if b.pop.Kind() == popup.KindTooltip {
46+ b.pop.Close()
47+ }
48+}
49+
50+// stepPopup runs after every wake-up of the loop: it starts the GPU warm-up,
51+// carries out what the modules asked for, and draws the popup.
52+func (b *Bar) stepPopup() {
53+ if b.pop == nil {
54+ return
55+ }
56+ b.popC = nil
57+ if !b.warmed && b.fb != nil && b.popCSS != "" && b.anyInteractive() {
58+ b.warmed = true
59+ b.pop.Warm(b.fb)
60+ }
61+ for _, m := range b.mods {
62+ b.drainControl(m)
63+ }
64+ if b.pop.OwnerRestarted() {
65+ b.closePopup()
66+ }
67+ if !b.pop.Active() {
68+ return
69+ }
70+ b.pop.Draw()
71+ if b.pop.Pending() {
72+ b.popC = time.After(popupPoll)
73+ }
74+}
75+
76+func (b *Bar) anyInteractive() bool {
77+ for _, m := range b.mods {
78+ if m.Interactive {
79+ return true
80+ }
81+ }
82+ return false
83+}
84+
85+// drainControl takes the control lines module m printed since the last
86+// wake-up and carries them out. A script that prints a tooltip on every
87+// mouse move would otherwise open and close a popup (a renderer, a surface)
88+// for each one: of the tooltip and close lines of one wake-up only the last
89+// counts.
90+func (b *Bar) drainControl(m *module.Module) {
91+ raw, ok := m.TakeControl()
92+ if !ok {
93+ return
94+ }
95+ b.ctlBuf = b.ctlBuf[:0]
96+ for ; ok; raw, ok = m.TakeControl() {
97+ ctrl, err := module.ParseControl(raw)
98+ if err != nil {
99+ b.badControl(m, err)
100+ continue
101+ }
102+ b.ctlBuf = append(b.ctlBuf, ctrl)
103+ }
104+ b.ctlBuf = coalesceControl(b.ctlBuf)
105+ for i := range b.ctlBuf {
106+ b.control(m, b.ctlBuf[i])
107+ }
108+ clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up
109+}
110+
111+// badControl logs an invalid control line, once per module.
112+func (b *Bar) badControl(m *module.Module, err error) {
113+ if b.badCtl[m] {
114+ return
115+ }
116+ if b.badCtl == nil {
117+ b.badCtl = map[*module.Module]bool{}
118+ }
119+ b.badCtl[m] = true
120+ b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)
121+}
122+
123+// coalesceControl keeps, in order, every menu line and only the last of the
124+// tooltip and close lines. It works in place.
125+func coalesceControl(cs []module.Control) []module.Control {
126+ last := -1
127+ for i, c := range cs {
128+ if c.Type != module.ControlMenu {
129+ last = i
130+ }
131+ }
132+ i := 0
133+ return slices.DeleteFunc(cs, func(c module.Control) bool {
134+ drop := c.Type != module.ControlMenu && i != last
135+ i++
136+ return drop
137+ })
138+}
139+
140+// tooltipInterval is the shortest time between two tooltips opened for one
141+// module.
142+const tooltipInterval = 250 * time.Millisecond
143+
144+// tooltipLimiter rate-limits the tooltips of each module.
145+type tooltipLimiter struct {
146+ last map[*module.Module]time.Time
147+}
148+
149+// allow reports whether m may open a tooltip at now, and counts it if so.
150+func (l *tooltipLimiter) allow(m *module.Module, now time.Time) bool {
151+ if t, ok := l.last[m]; ok && now.Sub(t) < tooltipInterval {
152+ return false
153+ }
154+ if l.last == nil {
155+ l.last = map[*module.Module]time.Time{}
156+ }
157+ l.last[m] = now
158+ return true
159+}
160+
161+// forget drops what is kept for modules that are not in keep.
162+func (l *tooltipLimiter) forget(keep []*module.Module) {
163+ for m := range l.last {
164+ if !slices.Contains(keep, m) {
165+ delete(l.last, m)
166+ }
167+ }
168+}
169+
170+// forgetModules drops the per-module state of modules that were stopped.
171+func (b *Bar) forgetModules(keep []*module.Module) {
172+ for m := range b.badCtl {
173+ if !slices.Contains(keep, m) {
174+ delete(b.badCtl, m)
175+ }
176+ }
177+ b.tips.forget(keep)
178+}
179+
180+// control handles one control line of module m.
181+func (b *Bar) control(m *module.Module, ctrl module.Control) {
182+ if !m.Interactive {
183+ return // only modules that asked for the pointer may open popups
184+ }
185+ switch ctrl.Type {
186+ case module.ControlClose:
187+ if b.pop.Kind() == popup.KindTooltip && b.pop.Owner() == m {
188+ b.pop.Close()
189+ }
190+ case module.ControlTooltip:
191+ // Only for the module under the pointer, and never over a menu.
192+ if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
193+ return
194+ }
195+ if b.pop.ShowsTooltip(m, ctrl) || !b.tips.allow(m, time.Now()) {
196+ return // already shown, or the script asks too often
197+ }
198+ b.openPopup(m, popup.KindTooltip, ctrl, 0)
199+ case module.ControlMenu:
200+ serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
201+ if !ok {
202+ b.log.Debug("menu dropped: it does not answer the latest press", "module", m.Name, "click", ctrl.Click)
203+ return
204+ }
205+ b.openPopup(m, popup.KindMenu, ctrl, serial)
206+ }
207+}
208+
209+// openPopup shows ctrl as a popup of module m, anchored to the cells it names.
210+func (b *Bar) openPopup(m *module.Module, kind popup.Kind, ctrl module.Control, serial uint32) {
211+ if !b.configured || b.fb == nil || b.face == nil || b.lay == nil {
212+ return
213+ }
214+ b.spanBuf = b.lay.Spans(b.spanBuf[:0])
215+ var span layout.Span
216+ for _, s := range b.spanBuf {
217+ if s.M == m {
218+ span = s
219+ }
220+ }
221+ if span.M == nil {
222+ return // the module shows nothing now
223+ }
224+ _, h, scale := b.surf.Size()
225+ anchor, ok := popup.Anchor(span.Start, span.Width, ctrl.Col, ctrl.Width, b.face.CellW, scale, h)
226+ if !ok {
227+ b.log.Debug("popup dropped: its column is outside the module", "module", m.Name, "col", ctrl.Col)
228+ return
229+ }
230+ err := b.pop.Open(popup.Request{
231+ Kind: kind, Owner: m, Ctrl: ctrl, Anchor: anchor, Parent: b.surf,
232+ BottomBar: b.cfg.Bar.Position == "bottom", Feedback: b.fb, Serial: serial,
233+ })
234+ if err != nil {
235+ b.log.Warn("cannot open a popup", "module", m.Name, "err", err)
236+ }
237+}
A internal/bar/popup_test.go +220 -0
@@ -0,0 +1,220 @@
1+package bar
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "slices"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferbar/internal/module"
14+ "github.com/bnema/neferbar/internal/popup"
15+ "github.com/bnema/neferbar/internal/racecheck"
16+)
17+
18+func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
19+ r := newInputRig(t)
20+ var p pointerState
21+ p.button(r.lay, 1, btnRight, 77) // token 1, serial 77, module a
22+ now := p.press.at
23+
24+ if _, ok := p.takeMenuPress(r.a, 2, now); ok {
25+ t.Error("a token that is not the latest press was accepted")
26+ }
27+ if _, ok := p.takeMenuPress(r.a, 0, now); ok {
28+ t.Error("token 0 was accepted")
29+ }
30+ if _, ok := p.takeMenuPress(r.b, 1, now); ok {
31+ t.Error("another module answered the press")
32+ }
33+ if _, ok := p.takeMenuPress(nil, 1, now); ok {
34+ t.Error("no module answered the press")
35+ }
36+ if _, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL+time.Millisecond)); ok {
37+ t.Error("a press older than the limit was accepted")
38+ }
39+ serial, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL))
40+ if !ok || serial != 77 {
41+ t.Fatalf("takeMenuPress = %d, %v; want 77, true", serial, ok)
42+ }
43+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
44+ t.Error("one press opened two menus")
45+ }
46+
47+ // A newer press supersedes the old token.
48+ p.button(r.lay, 1, btnRight, 78)
49+ now = p.press.at
50+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
51+ t.Error("the superseded token was accepted")
52+ }
53+ if serial, ok = p.takeMenuPress(r.a, 2, now); !ok || serial != 78 {
54+ t.Errorf("latest press: %d, %v", serial, ok)
55+ }
56+
57+ // A press without a serial cannot grab.
58+ p.button(r.lay, 1, btnRight, 0)
59+ if _, ok = p.takeMenuPress(r.a, 3, p.press.at); ok {
60+ t.Error("a press without a serial was accepted")
61+ }
62+}
63+
64+// ctl parses the JSON of a control line, as the bar does.
65+func ctl(t testing.TB, js string) module.Control {
66+ t.Helper()
67+ c, err := module.ParseControl([]byte(js))
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ return c
72+}
73+
74+// feed prints control lines on module m's stdout, as its script would.
75+func feed(m *module.Module, lines ...string) {
76+ var sb strings.Builder
77+ for _, l := range lines {
78+ sb.WriteString("\x1b]777;neferbar;" + l + "\x07\n")
79+ }
80+ m.ReadFramesForTest(strings.NewReader(sb.String()))
81+}
82+
83+func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
84+ r := newInputRig(t)
85+ var logs bytes.Buffer
86+ b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
87+
88+ // An invalid line is logged once per module and otherwise ignored.
89+ feed(r.a, `{"type":"explode"}`, `{nope`)
90+ b.drainControl(r.a)
91+ if n := bytes.Count(logs.Bytes(), []byte("invalid control line")); n != 1 {
92+ t.Fatalf("logged %d times, want once:\n%s", n, logs.String())
93+ }
94+
95+ tooltip := ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"t"}`)
96+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)
97+ // None of these may reach openPopup: the bar has no surface, so reaching
98+ // it would return quietly, hence the checks go through the pointer state
99+ // the rules read.
100+ b.control(r.b, tooltip) // not interactive
101+ b.control(r.a, tooltip) // not hovered
102+ b.control(r.a, menu) // no press at all
103+ b.ptr.button(r.lay, 1, btnRight, 9)
104+ b.control(r.a, menu) // wrong token
105+ if b.ptr.press.used {
106+ t.Fatal("a refused menu consumed the press")
107+ }
108+ b.control(r.a, ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
109+ if !b.ptr.press.used {
110+ t.Fatal("the right token should have been accepted")
111+ }
112+}
113+
114+func TestCoalesceKeepsMenusAndTheLastTooltipOrClose(t *testing.T) {
115+ tip := func(title string) module.Control {
116+ return ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"`+title+`"}`)
117+ }
118+ closeC := ctl(t, `{"type":"close"}`)
119+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`)
120+ types := func(cs []module.Control) string {
121+ var sb strings.Builder
122+ for _, c := range cs {
123+ sb.WriteString(c.Type[:1] + c.Title + " ")
124+ }
125+ return strings.TrimSpace(sb.String())
126+ }
127+ for name, c := range map[string]struct {
128+ in []module.Control
129+ want string
130+ }{
131+ "none": {nil, ""},
132+ "one": {[]module.Control{tip("a")}, "ta"},
133+ "storm": {[]module.Control{tip("a"), tip("b"), tip("c")}, "tc"},
134+ "tooltip then close": {[]module.Control{tip("a"), closeC}, "c"},
135+ "close then tooltip": {[]module.Control{closeC, tip("z")}, "tz"},
136+ "menus are kept": {[]module.Control{tip("a"), menu, tip("b"), menu, closeC}, "m m c"},
137+ "only menus": {[]module.Control{menu, menu}, "m m"},
138+ } {
139+ if got := types(coalesceControl(slices.Clone(c.in))); got != c.want {
140+ t.Errorf("%s: got %q, want %q", name, got, c.want)
141+ }
142+ }
143+}
144+
145+func TestDrainAppliesOnlyTheLastTooltipOfAWakeUp(t *testing.T) {
146+ r := newInputRig(t)
147+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
148+ var seen []string
149+ b.ctlBuf = nil
150+ for i := range 50 {
151+ feed(r.a, fmt.Sprintf(`{"type":"tooltip","col":0,"width":1,"title":"t%d"}`, i))
152+ }
153+ // Count what survives by running the same steps drainControl does.
154+ raw, ok := r.a.TakeControl()
155+ for ; ok; raw, ok = r.a.TakeControl() {
156+ c, err := module.ParseControl(raw)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ b.ctlBuf = append(b.ctlBuf, c)
161+ }
162+ for _, c := range coalesceControl(b.ctlBuf) {
163+ seen = append(seen, c.Title)
164+ }
165+ // The queue holds 8 lines; of them one remains.
166+ if len(seen) != 1 || seen[0] != "t7" {
167+ t.Fatalf("applied %q, want just the last queued one", seen)
168+ }
169+}
170+
171+func TestTooltipRateLimit(t *testing.T) {
172+ r := newInputRig(t)
173+ var l tooltipLimiter
174+ t0 := time.Now()
175+ if !l.allow(r.a, t0) {
176+ t.Fatal("the first tooltip must pass")
177+ }
178+ if l.allow(r.a, t0.Add(tooltipInterval-time.Millisecond)) {
179+ t.Fatal("a second tooltip within the interval passed")
180+ }
181+ if !l.allow(r.b, t0.Add(time.Millisecond)) {
182+ t.Fatal("another module shares the limit")
183+ }
184+ if !l.allow(r.a, t0.Add(tooltipInterval)) {
185+ t.Fatal("the limit must lift after the interval")
186+ }
187+ // A refused tooltip does not push the window.
188+ l.allow(r.a, t0.Add(tooltipInterval+10*time.Millisecond))
189+ if !l.allow(r.a, t0.Add(2*tooltipInterval)) {
190+ t.Fatal("refusals extended the window")
191+ }
192+ l.forget([]*module.Module{r.b})
193+ if _, ok := l.last[r.a]; ok || len(l.last) != 1 {
194+ t.Fatalf("forget kept %v", l.last)
195+ }
196+}
197+
198+func TestStoppedModulesAreForgotten(t *testing.T) {
199+ r := newInputRig(t)
200+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
201+ b.badControl(r.a, errors.New("x"))
202+ b.badControl(r.b, errors.New("x"))
203+ b.tips.allow(r.a, time.Now())
204+ b.forgetModules([]*module.Module{r.b})
205+ if len(b.badCtl) != 1 || !b.badCtl[r.b] || len(b.tips.last) != 0 {
206+ t.Fatalf("badCtl %v, tips %v", b.badCtl, b.tips.last)
207+ }
208+}
209+
210+func TestStepPopupAllocsWithNoPopup(t *testing.T) {
211+ if racecheck.Enabled {
212+ t.Skip("the race detector allocates")
213+ }
214+ r := newInputRig(t)
215+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler), pop: &popup.Host{}, mods: []*module.Module{r.a, r.b}}
216+ b.stepPopup() // warm any lazily grown state
217+ if got := testing.AllocsPerRun(200, b.stepPopup); got > 0 {
218+ t.Errorf("stepPopup with no popup open allocates %.1f objects; want 0", got)
219+ }
220+}
A internal/module/control.go +216 -0
@@ -0,0 +1,216 @@
1+package module
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "unicode"
8+ "unicode/utf8"
9+)
10+
11+// Limits of a control line. ParseControl refuses what exceeds them.
12+const (
13+ // MaxMenuDepth is how many levels of submenus a menu may nest.
14+ MaxMenuDepth = 8
15+ // MaxMenuItems is how many items a whole menu may hold, submenus included.
16+ MaxMenuItems = 512
17+ // MaxLabel is the longest item label or tooltip title, in bytes; longer
18+ // ones are cut at a rune boundary.
19+ MaxLabel = 256
20+ // MaxText is the longest tooltip body, in bytes; longer ones are cut at a
21+ // rune boundary.
22+ MaxText = 1024
23+ // MaxRows and MaxCells bound the table of a tooltip; a cell is cut like a
24+ // label.
25+ MaxRows = 32
26+ MaxCells = 4
27+ // maxColumn bounds the column and width of a control line.
28+ maxColumn = 1 << 20
29+)
30+
31+// Control types.
32+const (
33+ ControlTooltip = "tooltip"
34+ ControlMenu = "menu"
35+ ControlClose = "close"
36+)
37+
38+// Menu item kinds.
39+const (
40+ KindNormal = "normal"
41+ KindSeparator = "separator"
42+ KindCheck = "check"
43+ KindRadio = "radio"
44+)
45+
46+// Control is one parsed control line of a script.
47+type Control struct {
48+ Type string
49+ Col int // first cell of the anchor inside the module
50+ Width int // cells of the anchor
51+ // Title and Body are the text of a tooltip, Rows its table: one entry
52+ // per row, one string per cell.
53+ Title, Body string
54+ Rows [][]string
55+ // Click is the token of the press a menu answers.
56+ Click uint32
57+ Items []MenuItem
58+}
59+
60+// MenuItem is one entry of a menu.
61+type MenuItem struct {
62+ ID int32
63+ Label string
64+ Kind string
65+ Enabled bool
66+ Checked bool
67+ Items []MenuItem // a submenu
68+}
69+
70+type wireControl struct {
71+ Type string `json:"type"`
72+ Col int `json:"col"`
73+ Width int `json:"width"`
74+ Title string `json:"title"`
75+ Body string `json:"body"`
76+ Rows [][]string `json:"rows"`
77+ Click uint32 `json:"click"`
78+ Items []wireItem `json:"items"`
79+}
80+
81+type wireItem struct {
82+ ID int32 `json:"id"`
83+ Label string `json:"label"`
84+ Kind string `json:"kind"`
85+ Enabled *bool `json:"enabled"`
86+ Checked bool `json:"checked"`
87+ Items []wireItem `json:"items"`
88+}
89+
90+// ParseControl reads the JSON of a control line (see TakeControl) and checks
91+// every limit. Control characters are removed from the texts, labels are cut
92+// to MaxLabel bytes, and an item without "enabled" is enabled.
93+func ParseControl(b []byte) (Control, error) {
94+ if len(b) > MaxControl {
95+ return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl)
96+ }
97+ var w wireControl
98+ if err := json.Unmarshal(b, &w); err != nil {
99+ return Control{}, fmt.Errorf("control line: %w", err)
100+ }
101+ if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn {
102+ return Control{}, errors.New("control line: column or width out of range")
103+ }
104+ c := Control{Type: w.Type, Col: w.Col, Width: w.Width}
105+ switch w.Type {
106+ case ControlClose:
107+ return c, nil
108+ case ControlTooltip:
109+ if w.Width == 0 {
110+ return Control{}, errors.New("tooltip: width must be positive")
111+ }
112+ c.Title = clean(w.Title, MaxLabel, false)
113+ c.Body = clean(w.Body, MaxText, true)
114+ if len(w.Rows) > MaxRows {
115+ return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116+ }
117+ if len(w.Rows) > 0 {
118+ c.Rows = make([][]string, 0, len(w.Rows))
119+ }
120+ text := c.Title != "" || c.Body != ""
121+ for _, row := range w.Rows {
122+ if len(row) > MaxCells {
123+ return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
124+ }
125+ cells := make([]string, len(row))
126+ for i, s := range row {
127+ cells[i] = clean(s, MaxLabel, false)
128+ text = text || cells[i] != ""
129+ }
130+ c.Rows = append(c.Rows, cells)
131+ }
132+ if !text {
133+ return Control{}, errors.New("tooltip: no text")
134+ }
135+ return c, nil
136+ case ControlMenu:
137+ if w.Width == 0 {
138+ return Control{}, errors.New("menu: width must be positive")
139+ }
140+ c.Click = w.Click
141+ count := 0
142+ items, err := convertItems(w.Items, 1, &count)
143+ if err != nil {
144+ return Control{}, fmt.Errorf("menu: %w", err)
145+ }
146+ if len(items) == 0 {
147+ return Control{}, errors.New("menu: no items")
148+ }
149+ c.Items = items
150+ return c, nil
151+ }
152+ return Control{}, fmt.Errorf("control line: unknown type %q", w.Type)
153+}
154+
155+func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) {
156+ if len(in) == 0 {
157+ return nil, nil
158+ }
159+ if depth > MaxMenuDepth {
160+ return nil, fmt.Errorf("more than %d levels", MaxMenuDepth)
161+ }
162+ out := make([]MenuItem, 0, len(in))
163+ for i := range in {
164+ if *count++; *count > MaxMenuItems {
165+ return nil, fmt.Errorf("more than %d items", MaxMenuItems)
166+ }
167+ w := &in[i]
168+ it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked}
169+ if w.ID < 0 {
170+ return nil, fmt.Errorf("item %d: negative id", w.ID)
171+ }
172+ switch w.Kind {
173+ case "":
174+ it.Kind = KindNormal
175+ case KindNormal, KindSeparator, KindCheck, KindRadio:
176+ default:
177+ return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind)
178+ }
179+ sub, err := convertItems(w.Items, depth+1, count)
180+ if err != nil {
181+ return nil, err
182+ }
183+ it.Items = sub
184+ out = append(out, it)
185+ }
186+ return out, nil
187+}
188+
189+// clean drops control characters (and anything Unicode calls a format or
190+// unassigned control, such as bidi overrides), keeps newlines when keepNL, and
191+// cuts the result to max bytes at a rune boundary.
192+func clean(s string, max int, keepNL bool) string {
193+ needs := len(s) > max
194+ for _, r := range s {
195+ if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
196+ needs = true
197+ break
198+ }
199+ }
200+ if !needs {
201+ return s
202+ }
203+ out := make([]byte, 0, min(len(s), max))
204+ for _, r := range s {
205+ switch {
206+ case r == '\n' && keepNL:
207+ case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r):
208+ continue
209+ }
210+ if len(out)+utf8.RuneLen(r) > max {
211+ break
212+ }
213+ out = utf8.AppendRune(out, r)
214+ }
215+ return string(out)
216+}
A internal/module/control_test.go +318 -0
@@ -0,0 +1,318 @@
1+package module
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+ "testing"
9+ "unicode"
10+ "unicode/utf8"
11+)
12+
13+// newInteractive is newTest for a module that may send control lines.
14+func newInteractive() (*Module, chan struct{}) {
15+ m, wake := newTest()
16+ m.Interactive = true
17+ return m, wake
18+}
19+
20+func TestParseControlValid(t *testing.T) {
21+ c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`))
22+ if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" {
23+ t.Fatalf("tooltip: %+v, %v", c, err)
24+ }
25+ c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[
26+ {"id":1,"label":"Open"},{"id":2,"kind":"separator"},
27+ {"id":3,"label":"Mute","kind":"check","checked":true},
28+ {"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]},
29+ {"id":6,"label":"Quit","enabled":false}]}`))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if c.Click != 9 || len(c.Items) != 5 {
34+ t.Fatalf("menu: %+v", c)
35+ }
36+ if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal {
37+ t.Fatalf("defaults: %+v", c.Items[0])
38+ }
39+ if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked {
40+ t.Fatalf("kinds: %+v", c.Items)
41+ }
42+ if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled {
43+ t.Fatalf("submenu/disabled: %+v", c.Items)
44+ }
45+ if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose {
46+ t.Fatalf("close: %+v, %v", c, err)
47+ }
48+}
49+
50+// nested builds a menu with depth levels of submenus.
51+func nested(depth int) string {
52+ s := `{"id":1,"label":"x"}`
53+ for i := 1; i < depth; i++ {
54+ s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s)
55+ }
56+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}`
57+}
58+
59+func TestParseControlLimits(t *testing.T) {
60+ if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil {
61+ t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err)
62+ }
63+ many := func(n int) string {
64+ return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}`
65+ }
66+ if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil {
67+ t.Fatalf("%d items must pass: %v", MaxMenuItems, err)
68+ }
69+ for name, in := range map[string]string{
70+ "too deep": nested(MaxMenuDepth + 1),
71+ "too many": many(MaxMenuItems + 1),
72+ "oversize": `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`,
73+ "unknown type": `{"type":"explode"}`,
74+ "no type": `{}`,
75+ "bad json": `{"type":`,
76+ "not an object": `[1]`,
77+ "neg col": `{"type":"tooltip","col":-1,"width":1,"title":"a"}`,
78+ "huge width": `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`,
79+ "zero width": `{"type":"tooltip","col":0,"width":0,"title":"a"}`,
80+ "empty tip": `{"type":"tooltip","col":0,"width":1}`,
81+ "bad kind": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`,
82+ "neg id": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`,
83+ "id overflow": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`,
84+ "no items": `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`,
85+ "neg click": `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`,
86+ } {
87+ if _, err := ParseControl([]byte(in)); err == nil {
88+ t.Errorf("%s: want an error", name)
89+ }
90+ }
91+}
92+
93+func TestParseControlCleansText(t *testing.T) {
94+ long := strings.Repeat("é", MaxLabel) // 2 bytes each
95+ c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`))
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ got := c.Items[0].Label
100+ if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") {
101+ t.Fatalf("label = %q (%d bytes)", got, len(got))
102+ }
103+ if strings.ContainsRune(got, '\uFFFD') {
104+ t.Fatalf("cut inside a rune: %q", got)
105+ }
106+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`))
107+ if err != nil || c.Title != "T" || c.Body != "a\nbc" {
108+ t.Fatalf("tooltip text: %+v, %v", c, err)
109+ }
110+ c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"rows":[["Sig\nnal","62\u001b%"],["Band"]]}`))
111+ if err != nil || len(c.Rows) != 2 || c.Rows[0][0] != "Signal" || c.Rows[0][1] != "62%" || c.Rows[1][0] != "Band" {
112+ t.Fatalf("tooltip rows: %+v, %v", c, err)
113+ }
114+}
115+
116+func TestParseControlTableLimits(t *testing.T) {
117+ rows := func(n int) string {
118+ return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}`
119+ }
120+ cells := func(n int) string {
121+ return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}`
122+ }
123+ for name, tc := range map[string]struct {
124+ in string
125+ ok bool
126+ }{
127+ "max rows": {rows(MaxRows), true},
128+ "too many rows": {rows(MaxRows + 1), false},
129+ "max cells": {cells(MaxCells), true},
130+ "too many cells": {cells(MaxCells + 1), false},
131+ "empty rows": {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false},
132+ "blank cells": {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false},
133+ } {
134+ if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok {
135+ t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok)
136+ }
137+ }
138+ long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each
139+ c, err := ParseControl([]byte(long))
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) {
144+ t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell))
145+ }
146+}
147+
148+func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) {
149+ m, _ := newInteractive()
150+ m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
151+ got, changed, _ := m.Take(nil)
152+ if !changed || string(got) != "two" {
153+ t.Fatalf("frame = %q changed=%v, want two", got, changed)
154+ }
155+ b, ok := m.TakeControl()
156+ if !ok || string(b) != `{"type":"close"}` {
157+ t.Fatalf("control = %q %v", b, ok)
158+ }
159+ if _, ok = m.TakeControl(); ok {
160+ t.Fatal("one control line expected")
161+ }
162+
163+ // The control line alone never becomes a frame.
164+ m, _ = newInteractive()
165+ m.readFrames(strings.NewReader("one\n"))
166+ m.Take(nil)
167+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n"))
168+ if _, changed, _ = m.Take(nil); changed {
169+ t.Fatal("a control line must not be published as a frame")
170+ }
171+}
172+
173+func TestUnterminatedControlIsAFrame(t *testing.T) {
174+ m, _ := newInteractive()
175+ m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n"))
176+ if _, ok := m.TakeControl(); ok {
177+ t.Fatal("a line without BEL is not a control line")
178+ }
179+ got, changed, _ := m.Take(nil)
180+ if !changed || string(got) != "\x1b]777;neferbar;{bad" {
181+ t.Fatalf("frame = %q changed=%v", got, changed)
182+ }
183+}
184+
185+func TestOversizeControlIsDropped(t *testing.T) {
186+ m, _ := newInteractive()
187+ m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n"))
188+ if _, ok := m.TakeControl(); ok {
189+ t.Fatal("an oversize control line must be dropped")
190+ }
191+ if got, _, _ := m.Take(nil); string(got) != "b" {
192+ t.Fatalf("frame = %q, want b", got)
193+ }
194+}
195+
196+func TestControlQueueDropsWhenFull(t *testing.T) {
197+ m, wake := newInteractive()
198+ var in strings.Builder
199+ for range 20 {
200+ in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")
201+ }
202+ m.readFrames(strings.NewReader(in.String()))
203+ n := 0
204+ for {
205+ if _, ok := m.TakeControl(); !ok {
206+ break
207+ }
208+ n++
209+ }
210+ if n != 8 || len(wake) != 1 {
211+ t.Fatalf("queued %d (want 8), wake %d", n, len(wake))
212+ }
213+}
214+
215+func TestControlLinesOfANonInteractiveModuleAreDropped(t *testing.T) {
216+ m, wake := newTest()
217+ m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n"))
218+ if _, ok := m.TakeControl(); ok {
219+ t.Fatal("a non-interactive module queued a control line")
220+ }
221+ if got, _, _ := m.Take(nil); string(got) != "two" {
222+ t.Fatalf("frame = %q, want two (the control line is still not a frame)", got)
223+ }
224+ if len(wake) != 1 { // from the frames only
225+ t.Fatalf("wake = %d", len(wake))
226+ }
227+}
228+
229+func TestOversizeControlWarnsOncePerRun(t *testing.T) {
230+ var logs bytes.Buffer
231+ m, _ := newInteractive()
232+ m.log = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
233+ big := "\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\n"
234+ m.readFrames(strings.NewReader(big + big + big))
235+ warns := func() int { return strings.Count(logs.String(), "level=WARN") }
236+ if warns() != 1 || strings.Count(logs.String(), "level=DEBUG") != 2 {
237+ t.Fatalf("want 1 warning and 2 debug lines:\n%s", logs.String())
238+ }
239+ // A new run of the script warns again; runOnce resets the flag.
240+ m.overLogged = false
241+ m.readFrames(strings.NewReader(big))
242+ if warns() != 2 {
243+ t.Fatalf("a new run must warn again:\n%s", logs.String())
244+ }
245+}
246+
247+func FuzzParseControl(f *testing.F) {
248+ for _, s := range []string{
249+ `{"type":"close"}`,
250+ `{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"a\nb"}`,
251+ `{"type":"menu","col":0,"width":2,"click":9,"items":[{"id":1,"label":"Open"},{"id":2,"kind":"separator"},{"id":3,"label":"Mute","kind":"check","checked":true},{"id":4,"label":"S","items":[{"id":5,"label":"A"}]},{"id":6,"enabled":false}]}`,
252+ nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
253+ `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
254+ `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
255+ `{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`,
256+ `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
257+ } {
258+ f.Add([]byte(s))
259+ }
260+ f.Fuzz(func(t *testing.T, b []byte) {
261+ c, err := ParseControl(b)
262+ if err != nil {
263+ return
264+ }
265+ // What is accepted respects every limit and carries no control rune.
266+ if c.Col < 0 || c.Col > maxColumn || c.Width < 0 || c.Width > maxColumn {
267+ t.Fatalf("column %d width %d", c.Col, c.Width)
268+ }
269+ bad := func(s string, max int) {
270+ if len(s) > max || !utf8.ValidString(s) || strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
271+ t.Fatalf("text %q", s)
272+ }
273+ }
274+ bad(c.Title, MaxLabel)
275+ bad(c.Body, MaxText)
276+ if len(c.Rows) > MaxRows {
277+ t.Fatalf("%d rows", len(c.Rows))
278+ }
279+ for _, row := range c.Rows {
280+ if len(row) > MaxCells {
281+ t.Fatalf("%d cells", len(row))
282+ }
283+ for _, cell := range row {
284+ bad(cell, MaxLabel)
285+ if strings.ContainsRune(cell, '\n') {
286+ t.Fatalf("cell %q", cell)
287+ }
288+ }
289+ }
290+ count := 0
291+ var walk func(items []MenuItem, depth int)
292+ walk = func(items []MenuItem, depth int) {
293+ if len(items) > 0 && depth > MaxMenuDepth {
294+ t.Fatalf("depth %d", depth)
295+ }
296+ for _, it := range items {
297+ count++
298+ bad(it.Label, MaxLabel)
299+ if strings.ContainsRune(it.Label, '\n') || it.ID < 0 {
300+ t.Fatalf("item %+v", it)
301+ }
302+ switch it.Kind {
303+ case KindNormal, KindSeparator, KindCheck, KindRadio:
304+ default:
305+ t.Fatalf("kind %q", it.Kind)
306+ }
307+ walk(it.Items, depth+1)
308+ }
309+ }
310+ walk(c.Items, 1)
311+ if count > MaxMenuItems {
312+ t.Fatalf("%d items", count)
313+ }
314+ if c.Type == ControlMenu && len(c.Items) == 0 {
315+ t.Fatal("an empty menu was accepted")
316+ }
317+ })
318+}
M internal/module/module.go +85 -14
@@ -23,7 +23,10 @@ import (
2323 const (
2424 // MaxFrame bounds one frame; longer lines are truncated.
2525 MaxFrame = 16 << 10
26- readSize = 32 << 10
26+ // MaxControl bounds one control line (see ParseControl); longer ones are
27+ // dropped.
28+ MaxControl = 64 << 10
29+ readSize = 32 << 10
2730
2831 minBackoff = 500 * time.Millisecond
2932 maxBackoff = 30 * time.Second
@@ -56,8 +59,13 @@ type Module struct {
5659 dirty bool
5760 failed bool // the script is not running; show an error marker
5861
59- input chan event // lines for the script's stdin
60- gen atomic.Uint64 // counts script starts; see Gen
62+ input chan event // lines for the script's stdin
63+ control chan []byte // control lines of the script, see TakeControl
64+ gen atomic.Uint64 // counts script starts; see Gen
65+
66+ // overLogged: an oversize control line was already logged at Warn in this
67+ // run of the script. Touched by runOnce and readFrames, which never overlap.
68+ overLogged bool
6169
6270 wake chan<- struct{}
6371 log *slog.Logger
@@ -77,7 +85,7 @@ type event struct {
7785 // New creates a module. wake receives a non-blocking signal after each frame.
7886 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
7987 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
80- pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)}
88+ pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)}
8189 }
8290
8391 // Gen counts the times the script was started. A value that changed means a
@@ -189,6 +197,7 @@ func (m *Module) Run(ctx context.Context) {
189197
190198 func (m *Module) runOnce(ctx context.Context) error {
191199 m.gen.Add(1)
200+ m.overLogged = false // readFrames of the previous run has ended
192201 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
193202 cmd.Stderr = &logWriter{log: m.log, name: m.Name}
194203 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
@@ -262,22 +271,79 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
262271 }
263272 }
264273
265-// readFrames splits r into frames and publishes each one.
274+// controlPrefix starts a control line: an OSC 777 sequence of the "neferbar"
275+// application, ended by controlEnd (BEL) before the newline.
276+const (
277+ controlPrefix = "\x1b]777;neferbar;"
278+ controlEnd = '\x07'
279+)
280+
281+// TakeControl returns the oldest control line the script printed, the JSON
282+// between the prefix and the BEL, or false when there is none. It never blocks.
283+func (m *Module) TakeControl() ([]byte, bool) {
284+ select {
285+ case b := <-m.control:
286+ return b, true
287+ default:
288+ return nil, false
289+ }
290+}
291+
292+// queueControl keeps a copy of the JSON of a control line of an interactive
293+// module. A full queue drops the
294+// line: the bar is not draining it.
295+func (m *Module) queueControl(json []byte) {
296+ if !m.Interactive {
297+ return // only interactive modules open popups: do not keep or parse the line
298+ }
299+ select {
300+ case m.control <- append([]byte(nil), json...):
301+ m.signal()
302+ default:
303+ m.log.Debug("module control queue full; line dropped", "module", m.Name)
304+ }
305+}
306+
307+// line handles one complete line: a control line, or else a frame.
308+func (m *Module) line(line []byte, over bool) {
309+ if bytes.HasPrefix(line, []byte(controlPrefix)) {
310+ switch {
311+ case over:
312+ // A script can repeat this at will: say it once per run.
313+ if !m.overLogged {
314+ m.overLogged = true
315+ m.log.Warn("module control line too long; dropped (shown once per run)", "module", m.Name, "max", MaxControl)
316+ } else {
317+ m.log.Debug("module control line too long; dropped", "module", m.Name, "max", MaxControl)
318+ }
319+ return
320+ case len(line) > 0 && line[len(line)-1] == controlEnd:
321+ m.queueControl(line[len(controlPrefix) : len(line)-1])
322+ return
323+ }
324+ // No BEL: not a control line; it is shown like any text.
325+ }
326+ m.publish(line[:min(len(line), MaxFrame)])
327+}
328+
329+// readFrames splits r into frames and publishes each one. Control lines go to
330+// the control queue instead.
266331 func (m *Module) readFrames(r io.Reader) {
267332 buf := make([]byte, readSize)
268- frame := make([]byte, 0, MaxFrame)
333+ line := make([]byte, 0, MaxFrame)
334+ over := false // the current line is longer than MaxControl
269335 for {
270336 n, err := r.Read(buf)
271337 chunk := buf[:n]
272338 for len(chunk) > 0 {
273339 i := bytes.IndexAny(chunk, "\n\f")
274340 if i < 0 {
275- frame = appendCapped(frame, chunk)
341+ line, over = appendCapped(line, chunk, over)
276342 break
277343 }
278- frame = appendCapped(frame, chunk[:i])
279- m.publish(frame)
280- frame = frame[:0]
344+ line, over = appendCapped(line, chunk[:i], over)
345+ m.line(line, over)
346+ line, over = line[:0], false
281347 chunk = chunk[i+1:]
282348 }
283349 if err != nil {
@@ -286,11 +352,13 @@ func (m *Module) readFrames(r io.Reader) {
286352 }
287353 }
288354
289-func appendCapped(dst, src []byte) []byte {
290- if room := MaxFrame - len(dst); len(src) > room {
291- src = src[:max(room, 0)]
355+// appendCapped appends src to dst up to MaxControl bytes and reports whether
356+// anything was left out.
357+func appendCapped(dst, src []byte, over bool) ([]byte, bool) {
358+ if room := MaxControl - len(dst); len(src) > room {
359+ src, over = src[:max(room, 0)], true
292360 }
293- return append(dst, src...)
361+ return append(dst, src...), over
294362 }
295363
296364 // logWriter forwards a script's stderr lines to the bar's log.
@@ -325,6 +393,9 @@ func DrainForTest(m *Module) []string {
325393 }
326394 }
327395
396+// ReadFramesForTest feeds r to the module as its script's stdout.
397+func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) }
398+
328399 // PublishForTest injects a frame as if the script had printed it.
329400 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
330401
A internal/popup/css.go +112 -0
@@ -0,0 +1,112 @@
1+package popup
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "unicode"
10+)
11+
12+// Inset is the space the popup stylesheet puts between the surface edge and
13+// the content: 4px of padding and a 1px border.
14+const Inset = 5
15+
16+// Style is what the popup stylesheet takes from the bar.
17+type Style struct {
18+ Font string
19+ Size float64 // font size in logical pixels
20+ FG, BG, Accent [3]uint8
21+}
22+
23+func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
24+
25+// cssString makes a font name safe inside a quoted CSS string: it drops the
26+// characters that could end the string or the declaration, and everything that
27+// does not print (control characters, bidi marks, unassigned runes).
28+func cssString(s string) string {
29+ return strings.Map(func(r rune) rune {
30+ switch {
31+ case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || !unicode.IsPrint(r):
32+ return -1
33+ }
34+ return r
35+ }, s)
36+}
37+
38+// CSS is the stylesheet of every popup.
39+func CSS(s Style) string {
40+ fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)
41+ size := strconv.FormatFloat(s.Size, 'f', -1, 64)
42+ var b strings.Builder
43+ fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: \"%s\"; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
44+ bg, fg, cssString(s.Font), size, ac)
45+ b.WriteString(".title { font-weight: bold; }\n")
46+ b.WriteString(".body { opacity: 0.85; }\n")
47+ b.WriteString(".table { column-gap: 1.5em; }\n")
48+ b.WriteString(".key { opacity: 0.7; }\n")
49+ b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")
50+ // nefergui paints the 1em check box centered in the left padding: leave
51+ // room on both sides of it so the label does not touch the box.
52+ b.WriteString("checkbox.item { padding-left: 2em; }\n")
53+ fmt.Fprintf(&b, "button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: %s; color: %s; }\n", ac, bg)
54+ b.WriteString("button.item:disabled, checkbox.item:disabled { opacity: 0.5; }\n")
55+ fmt.Fprintf(&b, "button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: %s; }\n", fg)
56+ fmt.Fprintf(&b, "separator { background-color: %s; opacity: 0.3; margin: 4px 0; }\n", fg)
57+ return b.String()
58+}
59+
60+// CSSPath is the stylesheet of this process, popup-<pid>.css in
61+// $XDG_RUNTIME_DIR/neferbar: two bars never overwrite each other's file.
62+func CSSPath() (string, error) {
63+ rt := os.Getenv("XDG_RUNTIME_DIR")
64+ if rt == "" {
65+ return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")
66+ }
67+ return filepath.Join(rt, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css"), nil
68+}
69+
70+// RemoveCSS deletes this process's stylesheet, if it was written.
71+func RemoveCSS() {
72+ if p, err := CSSPath(); err == nil {
73+ _ = os.Remove(p)
74+ }
75+}
76+
77+// WriteCSS stores css as CSSPath (directory 0700, file 0600, replaced
78+// atomically) and returns the path. nefergui reads its stylesheet from a path.
79+// Call RemoveCSS when done.
80+func WriteCSS(css string) (string, error) {
81+ path, err := CSSPath()
82+ if err != nil {
83+ return "", err
84+ }
85+ dir := filepath.Dir(path)
86+ if err := os.MkdirAll(dir, 0o700); err != nil {
87+ return "", fmt.Errorf("popup: %w", err)
88+ }
89+ if err := os.Chmod(dir, 0o700); err != nil {
90+ return "", fmt.Errorf("popup: %w", err)
91+ }
92+ f, err := os.CreateTemp(dir, "popup-*.css")
93+ if err != nil {
94+ return "", fmt.Errorf("popup: %w", err)
95+ }
96+ tmp := f.Name()
97+ _, err = f.WriteString(css)
98+ if cerr := f.Close(); err == nil {
99+ err = cerr
100+ }
101+ if err == nil {
102+ err = os.Chmod(tmp, 0o600)
103+ }
104+ if err == nil {
105+ err = os.Rename(tmp, path)
106+ }
107+ if err != nil {
108+ _ = os.Remove(tmp)
109+ return "", fmt.Errorf("popup: %w", err)
110+ }
111+ return path, nil
112+}
A internal/popup/geometry.go +70 -0
@@ -0,0 +1,70 @@
1+// Package popup draws the tooltips and menus that interactive modules ask for
2+// with control lines (see module.ParseControl). A Host owns at most one popup:
3+// an xdg_popup on the bar's layer surface, drawn by a nefergui renderer of its
4+// own.
5+package popup
6+
7+import (
8+ "math"
9+
10+ "github.com/bnema/neferclient"
11+)
12+
13+// Size limits of a popup, in logical pixels.
14+const (
15+ MaxMenuWidth = 480
16+ MaxTooltipWidth = 360
17+ // MaxHeight is the tallest popup; a taller menu scrolls.
18+ MaxHeight = 600
19+)
20+
21+// CellRect is the logical-pixel rectangle of width cells starting at cell
22+// start of a row of cellW-physical-pixel cells on a surface of the given
23+// scale, rounded outward, height pixels high.
24+func CellRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
25+ const eps = 1e-9
26+ x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
27+ x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
28+ return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
29+}
30+
31+// Anchor is the rectangle a popup attaches to: the cells col .. col+width-1
32+// of a module whose shown cells start at spanStart and number spanWidth. The
33+// request is clamped to the shown cells. It reports false when col lies
34+// outside them.
35+func Anchor(spanStart, spanWidth, col, width, cellW int, scale float64, height int32) (neferclient.Rect, bool) {
36+ if col < 0 || col >= spanWidth || width <= 0 || cellW <= 0 || !(scale > 0) {
37+ return neferclient.Rect{}, false
38+ }
39+ width = min(width, spanWidth-col)
40+ return CellRect(spanStart+col, width, cellW, scale, height), true
41+}
42+
43+// Edge is the anchor edge and the gravity that put a popup on the free side of
44+// a bar: below a bar at the top, above a bar at the bottom.
45+func Edge(bottomBar bool) neferclient.PopupEdge {
46+ if bottomBar {
47+ return neferclient.EdgeTop
48+ }
49+ return neferclient.EdgeBottom
50+}
51+
52+// Clamp rounds a measured size up to whole pixels and limits the height to
53+// MaxHeight; scroll reports that the content is taller than that.
54+func Clamp(w, h float64) (cw, ch int32, scroll bool) {
55+ cw = int32(math.Ceil(max(w, 1)))
56+ if h > MaxHeight {
57+ return cw, MaxHeight, true
58+ }
59+ return cw, int32(math.Ceil(max(h, 1))), false
60+}
61+
62+// Placement is where a popup of the given size goes for an anchor.
63+func Placement(anchor neferclient.Rect, bottomBar bool, w, h int32) neferclient.PopupPlacement {
64+ e := Edge(bottomBar)
65+ return neferclient.PopupPlacement{
66+ Anchor: anchor, Edge: e, Gravity: e,
67+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
68+ Width: w, Height: h,
69+ }
70+}
A internal/popup/host.go +614 -0
@@ -0,0 +1,614 @@
1+package popup
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "slices"
8+ "strconv"
9+ "time"
10+
11+ "github.com/bnema/neferclient"
12+ "github.com/bnema/nefergui"
13+
14+ "github.com/bnema/neferbar/internal/module"
15+)
16+
17+// FDBase is added to the id of a popup renderer's buffers when their release
18+// descriptors are watched: the bar's own slots use small ids, and
19+// Conn.WatchFD reports the same id for both through Handler.FDReady.
20+const FDBase = 1 << 48
21+
22+// Kind is what a popup shows.
23+type Kind uint8
24+
25+const (
26+ KindTooltip Kind = iota + 1
27+ KindMenu
28+)
29+
30+// guiMods are the modifier bits nefergui knows; neferclient uses the same order.
31+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
32+ neferclient.ModCapsLock | neferclient.ModNumLock
33+
34+// Request asks the host to open a popup.
35+type Request struct {
36+ Kind Kind
37+ Owner *module.Module
38+ // Ctrl is the control line: its title and body for a tooltip, its items
39+ // and token for a menu.
40+ Ctrl module.Control
41+ // Anchor is the part of Parent the popup attaches to, in logical pixels.
42+ Anchor neferclient.Rect
43+ Parent *neferclient.Surface
44+ BottomBar bool
45+ // Feedback gives the GPU and the formats of the renderer.
46+ Feedback *neferclient.Feedback
47+ // Serial is the pointer serial of the press that opened a menu; the menu
48+ // grabs the keyboard and the pointer with it.
49+ Serial uint32
50+}
51+
52+// watch is a release descriptor being watched.
53+type watch struct {
54+ fd int
55+ buffer uint64
56+}
57+
58+// Host owns at most one open popup. Everything runs on the bar's loop
59+// goroutine, like the Handler methods that feed it.
60+type Host struct {
61+ conn *neferclient.Conn
62+ seat *neferclient.Seat
63+ log *slog.Logger
64+
65+ styles string // path of the stylesheet; "" disables popups
66+
67+ warm chan struct{} // closed when the warm-up renderer is gone; nil before Warm
68+
69+ surf *neferclient.Surface
70+ sid neferclient.SurfaceID
71+ r *nefergui.Renderer
72+ out nefergui.Output
73+
74+ render func(*nefergui.Output) (bool, error) // Render with the model's view
75+ tip *tooltipModel
76+ menu *menuModel
77+
78+ kind Kind
79+ tipCtl module.Control // what the open tooltip shows
80+ owner *module.Module
81+ ownerGen uint64
82+ anchor neferclient.Rect
83+ bottomBar bool
84+
85+ watched []watch
86+ damage []neferclient.Rect
87+ configured bool
88+ canPresent bool
89+ haveAcquire bool
90+ inside bool // the pointer is over the popup
91+ cursor neferclient.CursorShape
92+}
93+
94+// NewHost makes a host for conn.
95+func NewHost(conn *neferclient.Conn, log *slog.Logger) *Host {
96+ return &Host{conn: conn, seat: conn.Seat(), log: log, cursor: neferclient.CursorDefault}
97+}
98+
99+// SetStyles sets the stylesheet path (see WriteCSS). An empty path disables
100+// popups. An open popup keeps the sheet it was built with.
101+func (h *Host) SetStyles(path string) { h.styles = path }
102+
103+func formats(fb *neferclient.Feedback) []nefergui.Format {
104+ out := make([]nefergui.Format, len(fb.Formats))
105+ for i, f := range fb.Formats {
106+ out[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
107+ }
108+ return out
109+}
110+
111+// Warm loads the GPU libraries on another goroutine so the first popup opens
112+// quickly: the first renderer of a process takes about half a second, later
113+// ones a fraction of that. It does nothing when called again.
114+func (h *Host) Warm(fb *neferclient.Feedback) {
115+ if h.warm != nil || fb == nil {
116+ return
117+ }
118+ h.warm = make(chan struct{})
119+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: formats(fb)}
120+ done := h.warm
121+ go func() {
122+ defer close(done)
123+ r, err := nefergui.NewRenderer(cfg)
124+ if err != nil {
125+ h.log.Debug("popup warm-up failed", "err", err)
126+ return
127+ }
128+ _ = r.Close()
129+ }()
130+}
131+
132+// WaitTimeout waits up to d for the warm-up renderer to be gone and reports
133+// whether it is. The bar calls it before it tears down its own GPU state, and
134+// goes on when the GPU libraries are stuck.
135+func (h *Host) WaitTimeout(d time.Duration) bool {
136+ if h == nil || h.warm == nil {
137+ return true
138+ }
139+ t := time.NewTimer(d)
140+ defer t.Stop()
141+ select {
142+ case <-h.warm:
143+ return true
144+ case <-t.C:
145+ return false
146+ }
147+}
148+
149+// waitWarm decides whether Open of a popup of the given kind goes on while the
150+// warm-up renderer may still be loading the GPU libraries. A tooltip does not
151+// wait: it is skipped, since a tooltip that shows up half a second late is
152+// worse than none (false). A menu was asked for with a click and waits (true).
153+func (h *Host) waitWarm(kind Kind) (proceed bool) {
154+ if h.warm == nil {
155+ return true
156+ }
157+ select {
158+ case <-h.warm:
159+ return true
160+ default:
161+ }
162+ if kind == KindTooltip {
163+ return false
164+ }
165+ <-h.warm // two renderers must not start at once
166+ return true
167+}
168+
169+// sameTooltip reports whether two tooltip controls show the same thing:
170+// showing the same one again is pointless.
171+func sameTooltip(a, b module.Control) bool {
172+ return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width &&
173+ slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string])
174+}
175+
176+// ShowsTooltip reports whether the open popup is a tooltip of owner that shows
177+// exactly what c says.
178+func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
179+ return h != nil && h.r != nil && h.showsTooltip(owner, c)
180+}
181+
182+func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
183+ return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c)
184+}
185+
186+// Active reports whether a popup is open.
187+func (h *Host) Active() bool { return h != nil && h.r != nil }
188+
189+// Kind is the kind of the open popup, 0 when none.
190+func (h *Host) Kind() Kind {
191+ if h == nil || h.r == nil {
192+ return 0
193+ }
194+ return h.kind
195+}
196+
197+// Owner is the module the open popup belongs to, nil when none.
198+func (h *Host) Owner() *module.Module {
199+ if h == nil || h.r == nil {
200+ return nil
201+ }
202+ return h.owner
203+}
204+
205+// OwnerRestarted reports whether the script behind the popup started again
206+// since the popup opened.
207+func (h *Host) OwnerRestarted() bool {
208+ return h.owner != nil && h.owner.Gen() != h.ownerGen
209+}
210+
211+// Is reports whether id is the open popup's surface.
212+func (h *Host) Is(id neferclient.SurfaceID) bool { return h != nil && h.r != nil && id == h.sid }
213+
214+// Wake is signaled when the popup renderer needs a Render from another
215+// goroutine; nil when no popup is open.
216+func (h *Host) Wake() <-chan struct{} {
217+ if h == nil || h.r == nil {
218+ return nil
219+ }
220+ return h.r.Wake()
221+}
222+
223+// Pending reports a built frame waiting for the GPU: call Draw again soon.
224+func (h *Host) Pending() bool { return h != nil && h.r != nil && h.r.Pending() }
225+
226+// measure sizes the model of the open popup.
227+func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err error) {
228+ var fw, fh float64
229+ switch h.kind {
230+ case KindTooltip:
231+ fw, fh, err = r.Measure(h.tip, tooltipView, MaxTooltipWidth)
232+ default:
233+ h.menu.scroll = false
234+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
235+ if err == nil && fh > MaxHeight {
236+ h.menu.scroll, h.menu.scrollH = true, menuContentHeight
237+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
238+ }
239+ }
240+ if err != nil {
241+ return 0, 0, false, err
242+ }
243+ w, ht, scroll = Clamp(fw, fh)
244+ return w, ht, scroll, nil
245+}
246+
247+// Open shows a popup, replacing any open one. It can take a while the first
248+// time (the GPU libraries load) and allocates freely: popups are rare.
249+func (h *Host) Open(req Request) error {
250+ h.Close()
251+ switch {
252+ case h.styles == "":
253+ return errors.New("popup: no stylesheet")
254+ case req.Parent == nil || req.Feedback == nil || req.Owner == nil:
255+ return errors.New("popup: incomplete request")
256+ case req.Kind != KindTooltip && req.Kind != KindMenu:
257+ return fmt.Errorf("popup: unknown kind %d", req.Kind)
258+ }
259+ if !h.waitWarm(req.Kind) {
260+ return nil // the GPU libraries are still loading: skip this tooltip
261+ }
262+ r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
263+ if err != nil {
264+ return fmt.Errorf("popup: %w", err)
265+ }
266+ h.r, h.kind = r, req.Kind
267+ h.tipCtl = req.Ctrl
268+ switch req.Kind {
269+ case KindTooltip:
270+ h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
271+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
272+ default:
273+ h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
274+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.menu, menuView) }
275+ }
276+ w, ht, _, err := h.measure(r)
277+ if err != nil {
278+ h.reset()
279+ _ = r.Close()
280+ return fmt.Errorf("popup: %w", err)
281+ }
282+ h.anchor, h.bottomBar = req.Anchor, req.BottomBar
283+ h.owner, h.ownerGen = req.Owner, req.Owner.Gen()
284+ grab := req.Kind == KindMenu
285+ surf, err := h.conn.NewPopup(neferclient.PopupConfig{
286+ Parent: req.Parent, Place: Placement(req.Anchor, req.BottomBar, w, ht), Grab: grab, Serial: req.Serial,
287+ })
288+ if err != nil {
289+ h.reset()
290+ _ = r.Close()
291+ return fmt.Errorf("popup: %w", err)
292+ }
293+ h.surf, h.sid = surf, surf.ID()
294+ if req.Kind == KindTooltip {
295+ // A tooltip is never clicked: the pointer goes through it.
296+ if err = surf.SetInputRegion([]neferclient.Rect{}); err != nil {
297+ h.Close()
298+ return fmt.Errorf("popup: %w", err)
299+ }
300+ }
301+ return nil
302+}
303+
304+// reset forgets everything about the open popup; the caller has closed its
305+// renderer and surface.
306+func (h *Host) reset() {
307+ *h = Host{conn: h.conn, seat: h.seat, log: h.log, styles: h.styles, warm: h.warm, cursor: neferclient.CursorDefault}
308+}
309+
310+// Close dismisses the open popup. A menu that was not used tells its script
311+// ("menu-closed"). It does nothing when no popup is open.
312+func (h *Host) Close() { h.end(true) }
313+
314+func (h *Host) end(notify bool) {
315+ if h == nil || h.r == nil {
316+ return
317+ }
318+ for _, w := range h.watched {
319+ _ = h.conn.UnwatchFD(w.fd)
320+ }
321+ if h.surf != nil {
322+ if err := h.surf.Close(); err != nil {
323+ h.log.Warn("popup: cannot close the surface", "err", err)
324+ }
325+ }
326+ _ = h.r.Close()
327+ if notify && h.menu != nil && h.owner != nil && !h.OwnerRestarted() {
328+ h.owner.Send(strconv.AppendUint([]byte("menu-closed "), uint64(h.menu.token), 10))
329+ }
330+ h.reset()
331+}
332+
333+// fail logs err and closes the popup: a broken popup never takes the bar down.
334+func (h *Host) fail(what string, err error) {
335+ h.log.Warn("popup: "+what, "err", err)
336+ h.Close()
337+}
338+
339+// PopupDone is the compositor dismissing the popup (outside click, denied
340+// grab, gone parent).
341+func (h *Host) PopupDone() { h.Close() }
342+
343+// Configure follows the size the compositor gave the popup.
344+func (h *Host) Configure() {
345+ if h.r == nil {
346+ return
347+ }
348+ if !h.configured { // later configures must not lift the frame-callback gate
349+ h.canPresent = true
350+ }
351+ h.configured = true
352+ h.resize()
353+}
354+
355+// Scale follows the popup's preferred scale.
356+func (h *Host) Scale() { h.resize() }
357+
358+func (h *Host) resize() {
359+ if h.r == nil {
360+ return
361+ }
362+ w, ht, scale := h.surf.Size()
363+ h.r.Resize(int(w), int(ht), scale)
364+}
365+
366+// Frame allows the next Present.
367+func (h *Host) Frame() {
368+ if h.r != nil {
369+ h.canPresent = true
370+ }
371+}
372+
373+// FDReady hands a buffer back once the compositor released it; id is the
374+// watched id, FDBase plus the renderer's buffer.
375+func (h *Host) FDReady(id uint64) {
376+ if h.r == nil || id < FDBase {
377+ return
378+ }
379+ if err := h.r.Released(id - FDBase); err != nil {
380+ h.fail("release buffer", err)
381+ }
382+}
383+
384+// Pointer feeds a pointer event over the popup to its renderer.
385+func (h *Host) Pointer(ev *neferclient.PointerEvent) {
386+ if h.r == nil {
387+ return
388+ }
389+ in := nefergui.Input{X: ev.X, Y: ev.Y}
390+ switch ev.Kind {
391+ case neferclient.PointerEnter:
392+ h.inside = true
393+ in.Kind = nefergui.InputPointerMotion
394+ h.applyCursor() // the shape is only valid after the enter
395+ case neferclient.PointerMotion:
396+ in.Kind = nefergui.InputPointerMotion
397+ case neferclient.PointerLeave:
398+ h.inside = false
399+ in.Kind = nefergui.InputPointerLeave
400+ case neferclient.PointerButton:
401+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
402+ if ev.Pressed {
403+ in.Kind = nefergui.InputPointerPress
404+ }
405+ case neferclient.PointerAxis:
406+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
407+ default:
408+ return
409+ }
410+ h.r.Input(&in)
411+}
412+
413+// Key feeds a key event to the popup. A menu maps its navigation keys first.
414+func (h *Host) Key(ev *neferclient.KeyEvent) {
415+ if h.r == nil {
416+ return
417+ }
418+ if h.menu != nil {
419+ switch act := mapKey(ev.Keysym, h.menu.inSubmenu()); act {
420+ case keyNext, keyPrevious:
421+ if ev.Pressed { // the repeats of a held arrow walk on
422+ mods := nefergui.Modifiers(0)
423+ if act == keyPrevious {
424+ mods = nefergui.ModShift
425+ }
426+ h.r.Input(&nefergui.Input{Kind: nefergui.InputKey, Keysym: keysymTab, Pressed: true, Modifiers: mods})
427+ }
428+ return
429+ case keyBack:
430+ if ev.Pressed && !ev.Repeat && h.menu.pop() {
431+ h.r.Invalidate()
432+ }
433+ return
434+ case keyClose:
435+ if ev.Pressed {
436+ h.Close()
437+ }
438+ return
439+ }
440+ }
441+ h.r.Input(&nefergui.Input{
442+ Kind: nefergui.InputKey,
443+ Keysym: ev.Keysym,
444+ Text: ev.Text, // valid only during the call, as for Input
445+ Pressed: ev.Pressed,
446+ Repeat: ev.Repeat,
447+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
448+ })
449+}
450+
451+// KeyboardFocus tells the renderer whether the popup has the keyboard.
452+func (h *Host) KeyboardFocus(focused bool) {
453+ if h.r == nil {
454+ return
455+ }
456+ kind := nefergui.InputFocusOut
457+ if focused {
458+ kind = nefergui.InputFocusIn
459+ }
460+ h.r.Input(&nefergui.Input{Kind: kind})
461+}
462+
463+// Draw renders and presents the popup when something changed, and carries out
464+// what the user chose in a menu.
465+func (h *Host) Draw() {
466+ if h.r == nil {
467+ return
468+ }
469+ if h.menu != nil && h.menu.changed {
470+ h.levelChanged()
471+ }
472+ if h.r == nil || !h.configured || !h.canPresent || h.surf.Feedback() == nil {
473+ return
474+ }
475+ ok, err := h.render(&h.out)
476+ if err != nil {
477+ h.fail("render", err)
478+ return
479+ }
480+ if h.menu != nil && h.menu.chosen >= 0 {
481+ // Chosen during the build: tell the script and go, without
482+ // "menu-closed" (the choice ends the menu).
483+ line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
484+ line = append(line, ' ')
485+ line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
486+ if !h.owner.Send(line) {
487+ h.log.Debug("popup: the choice was not delivered: the script's input queue is full or closed", "module", h.owner.Name)
488+ }
489+ h.end(false)
490+ return
491+ }
492+ if ok {
493+ if err = h.present(); err != nil {
494+ h.fail("present", err)
495+ return
496+ }
497+ h.applyCursor()
498+ }
499+ if h.menu != nil && h.menu.changed {
500+ h.levelChanged()
501+ }
502+}
503+
504+// levelChanged resizes the popup for the level of the menu now shown.
505+func (h *Host) levelChanged() {
506+ h.menu.changed = false
507+ w, ht, _, err := h.measure(h.r)
508+ if err != nil {
509+ h.fail("measure", err)
510+ return
511+ }
512+ if err = h.surf.Reposition(Placement(h.anchor, h.bottomBar, w, ht)); err != nil {
513+ // Without xdg_wm_base 3 the popup keeps its size: a level taller than
514+ // it scrolls inside it.
515+ h.log.Debug("popup: cannot resize the menu", "err", err)
516+ _, cur, _ := h.surf.Size()
517+ h.menu.fitTo(ht, cur)
518+ }
519+ h.r.Invalidate()
520+}
521+
522+// present hands the frame in h.out to the compositor, as nefergui's demo does.
523+func (h *Host) present() error {
524+ out := &h.out
525+ // A resize retired buffers: stop watching and destroy them first.
526+ for _, rt := range out.Retired {
527+ if err := h.conn.UnwatchFD(rt.ReleaseFD); err != nil {
528+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
529+ }
530+ for i, w := range h.watched {
531+ if w.fd == rt.ReleaseFD {
532+ h.watched = append(h.watched[:i], h.watched[i+1:]...)
533+ break
534+ }
535+ }
536+ if err := h.surf.DestroyBuffer(rt.Buffer); err != nil {
537+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
538+ }
539+ // The release timeline of a buffer is imported under the buffer's id.
540+ if err := h.surf.DestroyTimeline(rt.Buffer); err != nil {
541+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
542+ }
543+ }
544+ if out.NewBuffer {
545+ if out.Buffer >= FDBase {
546+ return fmt.Errorf("buffer id %d is out of range", out.Buffer)
547+ }
548+ buf := neferclient.Buffer{
549+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
550+ PlaneCount: out.PlaneCount,
551+ }
552+ for i, p := range out.Planes {
553+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
554+ }
555+ if err := h.surf.ImportBuffer(out.Buffer, &buf); err != nil {
556+ return fmt.Errorf("import buffer: %w", err)
557+ }
558+ // The release eventfd is stable per buffer: watch it once.
559+ if err := h.conn.WatchFD(out.ReleaseFD, FDBase|out.Buffer); err != nil {
560+ return fmt.Errorf("watch release fd: %w", err)
561+ }
562+ h.watched = append(h.watched, watch{fd: out.ReleaseFD, buffer: out.Buffer})
563+ }
564+ if out.NewTimelines {
565+ if !h.haveAcquire { // the acquire timeline is shared by every buffer
566+ if err := h.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
567+ return fmt.Errorf("import acquire timeline: %w", err)
568+ }
569+ h.haveAcquire = true
570+ }
571+ if err := h.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
572+ return fmt.Errorf("import release timeline: %w", err)
573+ }
574+ }
575+ h.damage = h.damage[:0]
576+ for _, r := range out.Damage {
577+ h.damage = append(h.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
578+ }
579+ err := h.surf.Present(&neferclient.Present{
580+ Buffer: out.Buffer,
581+ AcquireTimeline: out.Acquire.ID,
582+ ReleaseTimeline: out.Release.ID,
583+ AcquirePoint: out.AcquirePoint,
584+ ReleasePoint: out.ReleasePoint,
585+ Damage: h.damage,
586+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
587+ })
588+ if err != nil {
589+ return fmt.Errorf("present: %w", err)
590+ }
591+ h.canPresent = false // until Frame
592+ return nil
593+}
594+
595+// applyCursor shows the shape the hovered element asked for, while the
596+// pointer is over the popup.
597+func (h *Host) applyCursor() {
598+ switch h.out.Cursor {
599+ case nefergui.CursorPointer:
600+ h.cursor = neferclient.CursorPointer
601+ case nefergui.CursorText:
602+ h.cursor = neferclient.CursorText
603+ case nefergui.CursorNotAllowed:
604+ h.cursor = neferclient.CursorNotAllowed
605+ default:
606+ h.cursor = neferclient.CursorDefault
607+ }
608+ if !h.inside {
609+ return
610+ }
611+ if err := h.seat.SetCursor(h.cursor); err != nil {
612+ h.log.Debug("popup: cannot set the cursor", "err", err)
613+ }
614+}
A internal/popup/keys.go +42 -0
@@ -0,0 +1,42 @@
1+package popup
2+
3+// xkb keysyms the menu reacts to.
4+const (
5+ keysymBackSpace = 0xff08
6+ keysymTab = 0xff09
7+ keysymEscape = 0xff1b
8+ keysymLeft = 0xff51
9+ keysymUp = 0xff52
10+ keysymDown = 0xff54
11+)
12+
13+// keyAction is what a key press does in a menu.
14+type keyAction uint8
15+
16+const (
17+ keyForward keyAction = iota // pass the key to nefergui unchanged
18+ keyNext // move the focus to the next entry (Tab)
19+ keyPrevious // move the focus to the previous entry (Shift+Tab)
20+ keyBack // leave the submenu
21+ keyClose // dismiss the menu
22+)
23+
24+// mapKey says what a key does. Up and Down walk the entries, Left and
25+// BackSpace leave a submenu, Escape dismisses; everything else (Tab, Return,
26+// Space…) goes to nefergui. Left and BackSpace do nothing at the top level.
27+func mapKey(keysym uint32, inSubmenu bool) keyAction {
28+ switch keysym {
29+ case keysymDown:
30+ return keyNext
31+ case keysymUp:
32+ return keyPrevious
33+ case keysymEscape:
34+ return keyClose
35+ case keysymLeft, keysymBackSpace:
36+ if inSubmenu {
37+ return keyBack
38+ }
39+ return keyForward
40+ }
41+ return keyForward
42+}
A internal/popup/popup_test.go +345 -0
@@ -0,0 +1,345 @@
1+package popup
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strconv"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/neferclient"
13+
14+ "github.com/bnema/neferbar/internal/module"
15+)
16+
17+func TestCellRectRoundsOutward(t *testing.T) {
18+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
19+ got := CellRect(2, 1, 13, 1.6, 20)
20+ if want := (neferclient.Rect{X: 16, Y: 0, Width: 9, Height: 20}); got != want { // [16.25, 24.375) -> [16, 25)
21+ t.Fatalf("CellRect = %+v, want %+v", got, want)
22+ }
23+ got = CellRect(0, 4, 10, 1, 24)
24+ if want := (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 24}); got != want {
25+ t.Fatalf("CellRect = %+v, want %+v", got, want)
26+ }
27+}
28+
29+func TestAnchorIsClampedToTheShownCells(t *testing.T) {
30+ // The module shows cells 10..13 (4 cells) of the row; 10 px cells at scale 1.
31+ r, ok := Anchor(10, 4, 1, 2, 10, 1, 20)
32+ if !ok || r != (neferclient.Rect{X: 110, Width: 20, Height: 20}) {
33+ t.Fatalf("Anchor = %+v %v", r, ok)
34+ }
35+ // A width that runs past the shown cells is cut at the module's edge.
36+ r, ok = Anchor(10, 4, 3, 5, 10, 1, 20)
37+ if !ok || r.X != 130 || r.Width != 10 {
38+ t.Fatalf("clamped Anchor = %+v %v", r, ok)
39+ }
40+ for _, c := range [][2]int{{-1, 1}, {4, 1}, {0, 0}, {0, -2}} {
41+ if _, ok = Anchor(10, 4, c[0], c[1], 10, 1, 20); ok {
42+ t.Errorf("Anchor(col %d, width %d) must fail", c[0], c[1])
43+ }
44+ }
45+ if _, ok = Anchor(0, 4, 0, 1, 0, 1, 20); ok {
46+ t.Error("zero cell width must fail")
47+ }
48+ if _, ok = Anchor(0, 4, 0, 1, 10, 0, 20); ok {
49+ t.Error("zero scale must fail")
50+ }
51+}
52+
53+func TestEdgeFollowsTheBarPosition(t *testing.T) {
54+ if Edge(false) != neferclient.EdgeBottom || Edge(true) != neferclient.EdgeTop {
55+ t.Fatal("a top bar opens below, a bottom bar above")
56+ }
57+ p := Placement(neferclient.Rect{Width: 1, Height: 1}, true, 100, 50)
58+ if p.Edge != neferclient.EdgeTop || p.Gravity != neferclient.EdgeTop || p.Width != 100 || p.Height != 50 {
59+ t.Fatalf("placement = %+v", p)
60+ }
61+ want := neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY
62+ if p.Adjust != want {
63+ t.Fatalf("adjust = %v, want %v", p.Adjust, want)
64+ }
65+}
66+
67+func TestClamp(t *testing.T) {
68+ for _, c := range []struct {
69+ w, h float64
70+ cw, ch int32
71+ wantScroll bool
72+ }{
73+ {100.2, 50.1, 101, 51, false},
74+ {0, 0, 1, 1, false},
75+ {200, 600, 200, 600, false},
76+ {200, 600.5, 200, MaxHeight, true},
77+ {200, 5000, 200, MaxHeight, true},
78+ } {
79+ cw, ch, scroll := Clamp(c.w, c.h)
80+ if cw != c.cw || ch != c.ch || scroll != c.wantScroll {
81+ t.Errorf("Clamp(%v,%v) = %d,%d,%v; want %d,%d,%v", c.w, c.h, cw, ch, scroll, c.cw, c.ch, c.wantScroll)
82+ }
83+ }
84+}
85+
86+func TestCSS(t *testing.T) {
87+ got := CSS(Style{Font: `My "Nerd" Font`, Size: 14.5, FG: [3]uint8{0xcc, 0xcc, 0xcc}, BG: [3]uint8{0x10, 0x20, 0x30}, Accent: [3]uint8{0x35, 0x84, 0xe4}})
88+ want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
89+.title { font-weight: bold; }
90+.body { opacity: 0.85; }
91+.table { column-gap: 1.5em; }
92+.key { opacity: 0.7; }
93+button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
94+checkbox.item { padding-left: 2em; }
95+button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
96+button.item:disabled, checkbox.item:disabled { opacity: 0.5; }
97+button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: #cccccc; }
98+separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
99+`
100+ if got != want {
101+ t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
102+ }
103+ // Non-ASCII names stay as they are: %q would have turned them into escapes.
104+ if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) {
105+ t.Fatalf("font-family changed:\n%s", got)
106+ }
107+ // Runes that do not print are dropped: controls, bidi overrides, DEL, BOM.
108+ if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" {
109+ t.Fatalf("cssString = %q", got)
110+ }
111+ // A font name cannot break out of the declaration.
112+ evil := CSS(Style{Font: `x"; } body { `, Size: 10})
113+ if strings.Count(evil, "{") != strings.Count(want, "{") {
114+ t.Fatalf("a font name injected rules:\n%s", evil)
115+ }
116+}
117+
118+func TestWriteCSSPermissions(t *testing.T) {
119+ dir := t.TempDir()
120+ t.Setenv("XDG_RUNTIME_DIR", dir)
121+ path, err := WriteCSS("app { color: red; }\n")
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") {
126+ t.Fatalf("path = %s", path)
127+ }
128+ for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
129+ st, err := os.Stat(p)
130+ if err != nil || st.Mode().Perm() != want {
131+ t.Errorf("%s: mode %v err %v, want %v", p, st.Mode().Perm(), err, want)
132+ }
133+ }
134+ // A rewrite replaces the file and leaves no temporary behind.
135+ if _, err = WriteCSS("app { color: blue; }\n"); err != nil {
136+ t.Fatal(err)
137+ }
138+ if b, _ := os.ReadFile(path); string(b) != "app { color: blue; }\n" {
139+ t.Fatalf("content = %q", b)
140+ }
141+ if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
142+ t.Fatalf("directory holds %d entries, want 1", len(ents))
143+ }
144+ // Another bar's file is left alone; ours is removed.
145+ other := filepath.Join(filepath.Dir(path), "popup-1.css")
146+ if err = os.WriteFile(other, []byte("x"), 0o600); err != nil {
147+ t.Fatal(err)
148+ }
149+ RemoveCSS()
150+ if _, err = os.Stat(path); !os.IsNotExist(err) {
151+ t.Fatalf("RemoveCSS left %s (%v)", path, err)
152+ }
153+ if _, err = os.Stat(other); err != nil {
154+ t.Fatalf("RemoveCSS touched another bar's file: %v", err)
155+ }
156+ RemoveCSS() // twice is fine
157+ t.Setenv("XDG_RUNTIME_DIR", "")
158+ if _, err = WriteCSS("x"); err == nil {
159+ t.Fatal("no runtime dir must fail")
160+ }
161+}
162+
163+func TestTooltipLines(t *testing.T) {
164+ // A blank line in the body is kept; blank lines around it are not.
165+ m := newTooltip("Title", "\na\n\nb\n", nil)
166+ if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "", "b"}) {
167+ t.Fatalf("tooltip = %+v", m)
168+ }
169+ if m = newTooltip("", "", nil); len(m.lines) != 0 || m.cols != 0 {
170+ t.Fatalf("empty tooltip = %+v", m)
171+ }
172+}
173+
174+func TestTooltipTable(t *testing.T) {
175+ m := newTooltip("", "", [][]string{{"Signal", "62%"}, {"Band", "5 GHz", "ch 100"}, {"x"}})
176+ if m.cols != 3 || len(m.rows) != 3 {
177+ t.Fatalf("table = %+v", m)
178+ }
179+}
180+
181+func TestSameTooltipComparesRows(t *testing.T) {
182+ a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183+ for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} {
184+ if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) {
185+ t.Fatalf("%q is a different table", rows)
186+ }
187+ }
188+ if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
189+ t.Fatal("same table, same tooltip")
190+ }
191+}
192+
193+func sampleMenu() *menuModel {
194+ return newMenu([]module.MenuItem{
195+ {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
196+ {ID: 4, Label: "Settings", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
197+ {ID: 5, Label: "Advanced", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
198+ {ID: 8, Label: "Deep", Kind: module.KindNormal, Enabled: true},
199+ }},
200+ {ID: 6, Label: "Quiet", Kind: module.KindCheck, Enabled: true},
201+ }},
202+ }, 9)
203+}
204+
205+func TestMenuTransitions(t *testing.T) {
206+ m := sampleMenu()
207+ if m.chosen != -1 || m.inSubmenu() || len(m.level()) != 2 {
208+ t.Fatalf("start = %+v", m)
209+ }
210+ if m.pop() {
211+ t.Fatal("pop at the top level must do nothing")
212+ }
213+ if m.push(0) {
214+ t.Fatal("a leaf is not a submenu")
215+ }
216+ if m.push(7) || m.push(-1) {
217+ t.Fatal("out of range push must fail")
218+ }
219+ if m.changed {
220+ t.Fatal("failed moves must not flag a change")
221+ }
222+ if !m.push(1) || !m.inSubmenu() || !m.changed || m.level()[0].ID != 5 {
223+ t.Fatalf("after push = %+v", m)
224+ }
225+ m.changed = false
226+ if !m.push(0) || m.level()[0].ID != 8 || len(m.path) != 2 {
227+ t.Fatalf("after second push = %+v", m)
228+ }
229+ if !m.pop() {
230+ t.Fatalf("first pop = %+v", m)
231+ }
232+ if !m.pop() || m.inSubmenu() || len(m.level()) != 2 || !m.changed {
233+ t.Fatalf("after pops = %+v", m)
234+ }
235+ m.choose(6)
236+ if m.chosen != 6 {
237+ t.Fatalf("chosen = %d", m.chosen)
238+ }
239+}
240+
241+func TestMenuLevelKeysDiffer(t *testing.T) {
242+ m := sampleMenu()
243+ top := m.levelKey()
244+ m.push(1)
245+ if m.levelKey() == top {
246+ t.Fatal("levels must not share control keys")
247+ }
248+}
249+
250+func TestMapKey(t *testing.T) {
251+ for _, c := range []struct {
252+ sym uint32
253+ sub bool
254+ want keyAction
255+ comment string
256+ }{
257+ {keysymDown, false, keyNext, "Down walks forward"},
258+ {keysymUp, true, keyPrevious, "Up walks back"},
259+ {keysymEscape, false, keyClose, "Escape closes"},
260+ {keysymEscape, true, keyClose, "Escape closes from a submenu too"},
261+ {keysymLeft, true, keyBack, "Left leaves a submenu"},
262+ {keysymBackSpace, true, keyBack, "BackSpace leaves a submenu"},
263+ {keysymLeft, false, keyForward, "Left does nothing at the top"},
264+ {keysymBackSpace, false, keyForward, "BackSpace does nothing at the top"},
265+ {keysymTab, false, keyForward, "Tab goes to nefergui"},
266+ {0x20, false, keyForward, "Space goes to nefergui"},
267+ {0xff0d, true, keyForward, "Return goes to nefergui"},
268+ } {
269+ if got := mapKey(c.sym, c.sub); got != c.want {
270+ t.Errorf("%s: mapKey(%#x, %v) = %d, want %d", c.comment, c.sym, c.sub, got, c.want)
271+ }
272+ }
273+}
274+
275+func TestTooltipDoesNotWaitForTheWarmUpButMenusDo(t *testing.T) {
276+ h := &Host{}
277+ if !h.waitWarm(KindTooltip) || !h.waitWarm(KindMenu) {
278+ t.Fatal("without a warm-up nothing waits")
279+ }
280+ h.warm = make(chan struct{})
281+ if h.waitWarm(KindTooltip) {
282+ t.Fatal("a tooltip must be skipped while the warm-up runs")
283+ }
284+ if h.WaitTimeout(10 * time.Millisecond) {
285+ t.Fatal("WaitTimeout reported a running warm-up as done")
286+ }
287+ got := make(chan bool)
288+ go func() { got <- h.waitWarm(KindMenu) }()
289+ select {
290+ case <-got:
291+ t.Fatal("a menu must wait for the warm-up")
292+ case <-time.After(30 * time.Millisecond):
293+ }
294+ close(h.warm)
295+ if !<-got {
296+ t.Fatal("a menu goes on once the warm-up ended")
297+ }
298+ if !h.waitWarm(KindTooltip) || !h.WaitTimeout(time.Second) {
299+ t.Fatal("after the warm-up a tooltip goes on")
300+ }
301+}
302+
303+func TestShowsTooltip(t *testing.T) {
304+ m := &module.Module{}
305+ other := &module.Module{}
306+ c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
307+ h := &Host{kind: KindTooltip, owner: m, tipCtl: c}
308+ if !h.showsTooltip(m, c) {
309+ t.Fatal("the same tooltip of the same owner is shown")
310+ }
311+ for name, d := range map[string]module.Control{
312+ "title": {Col: 1, Width: 2, Title: "T2", Body: "B"}, "body": {Col: 1, Width: 2, Title: "T", Body: "B2"},
313+ "col": {Col: 2, Width: 2, Title: "T", Body: "B"}, "width": {Col: 1, Width: 3, Title: "T", Body: "B"},
314+ } {
315+ if h.showsTooltip(m, d) {
316+ t.Errorf("a tooltip with another %s counts as shown", name)
317+ }
318+ }
319+ if h.showsTooltip(other, c) {
320+ t.Error("another owner's tooltip counts as shown")
321+ }
322+ h.kind = KindMenu
323+ if h.showsTooltip(m, c) {
324+ t.Error("a menu counts as a tooltip")
325+ }
326+ if (*Host)(nil).ShowsTooltip(m, c) || (&Host{}).ShowsTooltip(m, c) {
327+ t.Error("no popup open must not match")
328+ }
329+}
330+
331+func TestFitToScrollsAMenuThatCannotBeResized(t *testing.T) {
332+ m := newMenu(nil, 1)
333+ m.fitTo(200, 300) // the new level fits: no scrolling
334+ if m.scroll {
335+ t.Fatal("a level that fits must not scroll")
336+ }
337+ m.fitTo(400, 300)
338+ if !m.scroll || m.scrollH != 300-2*Inset {
339+ t.Fatalf("scroll %v height %d", m.scroll, m.scrollH)
340+ }
341+ m.fitTo(400, 1)
342+ if m.scrollH < 1 {
343+ t.Fatalf("scroll height %d", m.scrollH)
344+ }
345+}
A internal/popup/view.go +186 -0
@@ -0,0 +1,186 @@
1+package popup
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/nefergui"
8+
9+ "github.com/bnema/neferbar/internal/module"
10+)
11+
12+// tooltipModel is what a tooltip shows.
13+type tooltipModel struct {
14+ title string
15+ lines []string // the body, one entry per line; an empty one is a blank line
16+ rows [][]string // the table
17+ cols int // cells of the longest row
18+}
19+
20+func newTooltip(title, body string, rows [][]string) *tooltipModel {
21+ m := &tooltipModel{title: title, rows: rows}
22+ if body = strings.Trim(body, "\n"); body != "" {
23+ m.lines = strings.Split(body, "\n")
24+ }
25+ for _, r := range rows {
26+ m.cols = max(m.cols, len(r))
27+ }
28+ return m
29+}
30+
31+// tooltipView draws the title in bold, the body, then the table. The table is
32+// laid out column by column, so every column is as wide as its widest cell
33+// and the cells of a row line up, as long as no cell wraps: a cell wider than
34+// the tooltip can be takes two lines and shifts the rest of its column.
35+func tooltipView(f *nefergui.Frame, m *tooltipModel) {
36+ col := f.Root().Column()
37+ if m.title != "" {
38+ col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))
39+ }
40+ for i, l := range m.lines {
41+ if l == "" {
42+ l = " " // keeps the height of a line
43+ }
44+ col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))
45+ }
46+ if m.cols == 0 {
47+ return
48+ }
49+ table := col.Row(nefergui.Class("table"), nefergui.Key("table"))
50+ for c := 0; c < m.cols; c++ {
51+ class := "value"
52+ if c == 0 && m.cols > 1 {
53+ class = "key"
54+ }
55+ cells := table.Column(nefergui.Class(class), nefergui.Key("c"+strconv.Itoa(c)))
56+ for r, row := range m.rows {
57+ s := " "
58+ if c < len(row) && row[c] != "" {
59+ s = row[c]
60+ }
61+ cells.Text(s, nefergui.Key("r"+strconv.Itoa(r)))
62+ }
63+ }
64+}
65+
66+// menuModel is a menu and where the user is in it. Submenus open in place:
67+// path holds the indexes of the submenus entered, and only the level at the
68+// end of the path is shown.
69+type menuModel struct {
70+ items []module.MenuItem
71+ path []int
72+ token uint32
73+ chosen int32 // the id of the activated item, -1 for none
74+ // changed is set when the level changed: the popup must be resized.
75+ changed bool
76+ // scroll wraps the items in a scrolling box of scrollH pixels: they are
77+ // taller than the popup.
78+ scroll bool
79+ scrollH int
80+}
81+
82+// fitTo is the fallback when the popup could not be resized for a level that
83+// needs wantH pixels: if the surface keeps curH, the items scroll inside it.
84+func (m *menuModel) fitTo(wantH, curH int32) {
85+ if wantH > curH {
86+ m.scroll, m.scrollH = true, max(int(curH)-2*Inset, 1)
87+ }
88+}
89+
90+func newMenu(items []module.MenuItem, token uint32) *menuModel {
91+ return &menuModel{items: items, token: token, chosen: -1}
92+}
93+
94+// level is the items of the submenu the path leads to.
95+func (m *menuModel) level() []module.MenuItem {
96+ items := m.items
97+ for _, i := range m.path {
98+ if i < 0 || i >= len(items) {
99+ return nil
100+ }
101+ items = items[i].Items
102+ }
103+ return items
104+}
105+
106+// inSubmenu reports whether the shown level is a submenu.
107+func (m *menuModel) inSubmenu() bool { return len(m.path) > 0 }
108+
109+// push enters the submenu at index i of the shown level.
110+func (m *menuModel) push(i int) bool {
111+ lv := m.level()
112+ if i < 0 || i >= len(lv) || len(lv[i].Items) == 0 || len(m.path) >= module.MaxMenuDepth {
113+ return false
114+ }
115+ m.path = append(m.path, i)
116+ m.changed = true
117+ return true
118+}
119+
120+// pop goes back to the parent level.
121+func (m *menuModel) pop() bool {
122+ if len(m.path) == 0 {
123+ return false
124+ }
125+ m.path = m.path[:len(m.path)-1]
126+ m.changed = true
127+ return true
128+}
129+
130+// choose records the activation of a leaf item.
131+func (m *menuModel) choose(id int32) { m.chosen = id }
132+
133+// levelKey prefixes the keys of a level, so a control keeps no hover or focus
134+// state from another level.
135+func (m *menuModel) levelKey() string {
136+ k := "l"
137+ for _, i := range m.path {
138+ k += "." + strconv.Itoa(i)
139+ }
140+ return k + "/"
141+}
142+
143+// menuContentHeight is the height of the box that scrolls in a popup of
144+// MaxHeight: the popup height minus the stylesheet's padding and border.
145+const menuContentHeight = MaxHeight - 2*Inset
146+
147+const (
148+ backLabel = "‹ Back"
149+ submenuMark = " ›"
150+)
151+
152+// menuView draws the shown level. Activations happen here, while the view
153+// runs: Back and submenu entries move the path, other entries record the id.
154+func menuView(f *nefergui.Frame, m *menuModel) {
155+ parent := f.Root()
156+ if m.scroll {
157+ parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(m.scrollH)+"px"))
158+ }
159+ col := parent.Column()
160+ if m.inSubmenu() {
161+ if col.Button(backLabel, nefergui.Key("back"), nefergui.Class("item")).Activated() {
162+ m.pop()
163+ }
164+ }
165+ prefix := m.levelKey()
166+ for i, it := range m.level() {
167+ key := nefergui.Key(prefix + strconv.Itoa(i))
168+ switch {
169+ case it.Kind == module.KindSeparator:
170+ col.Separator(key)
171+ case len(it.Items) > 0:
172+ if col.Button(it.Label+submenuMark, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
173+ m.push(i)
174+ }
175+ case it.Kind == module.KindCheck || it.Kind == module.KindRadio:
176+ checked := it.Checked
177+ if col.Checkbox(it.Label, &checked, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Changed() {
178+ m.choose(it.ID)
179+ }
180+ default:
181+ if col.Button(it.Label, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
182+ m.choose(it.ID)
183+ }
184+ }
185+ }
186+}
M internal/tray/control.go +267 -9
@@ -2,10 +2,13 @@ package tray
22
33 import (
44 "bufio"
5+ "bytes"
56 "context"
7+ "encoding/json"
68 "errors"
79 "io"
810 "log/slog"
11+ "slices"
912 "strconv"
1013 "strings"
1114 "sync"
@@ -22,6 +25,15 @@ const (
2225 maxInputLine = 256
2326 // maxSteps bounds the steps of a scroll line.
2427 maxSteps = 1 << 16
28+ // tooltipDelay is how long the pointer rests on an icon before its
29+ // tooltip is sent to the bar.
30+ tooltipDelay = 500 * time.Millisecond
31+)
32+
33+// Control lines to the bar: OSC 777 "neferbar", a JSON object, BEL, newline.
34+const (
35+ controlPrefix = "\x1b]777;neferbar;"
36+ controlSuffix = "\x07\n"
2537 )
2638
2739 // lockedWriter serializes writes from the main loop and the control loop to
@@ -187,6 +199,23 @@ type control struct {
187199 mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
188200 conn *zerobus.Conn
189201 done bool
202+
203+ // The tooltip and menu state belong to the run goroutine.
204+ tipTimer *time.Timer
205+ hov hover
206+ menu *lastMenu
207+
208+ // deadline, when set, ends every call of the sequence in progress
209+ // (openMenu): a call waits for the smaller of callTimeout and what is left.
210+ deadline time.Time
211+}
212+
213+// hover is the icon the pointer rests on, and whether its tooltip is shown.
214+type hover struct {
215+ active bool
216+ dest, path string
217+ col int
218+ shown bool
190219 }
191220
192221 // run serves lines until ctx ends.
@@ -194,10 +223,15 @@ func (c *control) run(ctx context.Context, lines <-chan string) {
194223 stop := context.AfterFunc(ctx, c.close)
195224 defer stop()
196225 defer c.close()
226+ c.tipTimer = time.NewTimer(time.Hour)
227+ c.tipTimer.Stop()
228+ defer c.tipTimer.Stop()
197229 for {
198230 select {
199231 case <-ctx.Done():
200232 return
233+ case <-c.tipTimer.C:
234+ c.showTooltip()
201235 case line := <-lines:
202236 cmd, ok := parseCommand(line)
203237 if !ok {
@@ -260,7 +294,22 @@ func (c *control) drop(conn *zerobus.Conn) {
260294
261295 func (c *control) handle(cmd command) {
262296 switch cmd.verb {
297+ case "hover":
298+ tg, ok := c.t.target(cmd.col)
299+ if !ok { // between icons
300+ c.endHover()
301+ return
302+ }
303+ if c.hov.active && c.hov.dest == tg.Dest && c.hov.path == tg.Path {
304+ return // still on the same icon
305+ }
306+ c.endHover()
307+ c.hov = hover{active: true, dest: tg.Dest, path: tg.Path, col: cmd.col}
308+ c.tipTimer.Reset(tooltipDelay)
309+ case "leave":
310+ c.endHover()
263311 case "click":
312+ c.endHover()
264313 tg, ok := c.t.target(cmd.col)
265314 if !ok {
266315 return
@@ -268,18 +317,22 @@ func (c *control) handle(cmd command) {
268317 switch cmd.button {
269318 case btnLeft:
270319 if tg.IsMenu {
271- c.contextMenu(tg)
320+ c.openMenu(tg, cmd.token)
272321 return
273322 }
274323 err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
275324 if isUnknownMethod(err) {
276- c.contextMenu(tg)
325+ c.openMenu(tg, cmd.token)
277326 }
278327 case btnMiddle:
279328 c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
280329 case btnRight:
281- c.contextMenu(tg)
330+ c.openMenu(tg, cmd.token)
282331 }
332+ case "menu-activate":
333+ c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id
334+ case "menu-closed":
335+ c.menuClosed(cmd.token)
283336 case "scroll":
284337 tg, ok := c.t.target(cmd.col)
285338 if !ok {
@@ -293,6 +346,69 @@ func (c *control) handle(cmd command) {
293346 }
294347 }
295348
349+// endHover stops the tooltip timer and, if the tooltip was sent, tells the bar
350+// to close it.
351+func (c *control) endHover() {
352+ c.tipTimer.Stop()
353+ if c.hov.shown {
354+ c.emit(map[string]string{"type": "close"})
355+ }
356+ c.hov = hover{}
357+}
358+
359+// showTooltip sends the tooltip of the hovered icon when the pointer is still
360+// on it and the application has something to say.
361+func (c *control) showTooltip() {
362+ if !c.hov.active || c.hov.shown {
363+ return
364+ }
365+ tg, ok := c.t.target(c.hov.col)
366+ if !ok || tg.Dest != c.hov.dest || tg.Path != c.hov.path {
367+ c.endHover()
368+ return
369+ }
370+ title, body := plainText(tg.TipTitle), plainText(tg.TipBody)
371+ if title == "" && body == "" {
372+ return
373+ }
374+ c.hov.shown = true
375+ c.emit(struct {
376+ Type string `json:"type"`
377+ Col int `json:"col"`
378+ Width int `json:"width"`
379+ Title string `json:"title"`
380+ Body string `json:"body"`
381+ }{"tooltip", tg.Start, tg.Width, title, body})
382+}
383+
384+// emit writes a control line for the bar.
385+func (c *control) emit(v any) {
386+ var buf bytes.Buffer
387+ buf.WriteString(controlPrefix)
388+ enc := json.NewEncoder(&buf)
389+ enc.SetEscapeHTML(false) // "&" and "<" stay readable; the bar parses JSON either way
390+ if err := enc.Encode(v); err != nil {
391+ c.log.Warn("tray: cannot encode a control line", "err", err)
392+ return
393+ }
394+ buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
395+ buf.WriteString(controlSuffix)
396+ c.write(buf.Bytes())
397+}
398+
399+// writeLine writes the control line whose JSON is b.
400+func (c *control) writeLine(b []byte) {
401+ line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix))
402+ line = append(append(append(line, controlPrefix...), b...), controlSuffix...)
403+ c.write(line)
404+}
405+
406+func (c *control) write(line []byte) {
407+ if _, err := c.t.opt.Out.Write(line); err != nil {
408+ c.log.Debug("tray: cannot write a control line", "err", err)
409+ }
410+}
411+
296412 // contextMenu asks the application to show its own menu.
297413 func (c *control) contextMenu(tg Target) {
298414 c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
@@ -307,6 +423,12 @@ func isUnknownMethod(err error) bool {
307423 // reply is returned as is; a transport failure or a timeout drops the
308424 // connection and is logged.
309425 func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
426+ return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil)
427+}
428+
429+// callOn is call for any interface. reply, if set, reads the body of a
430+// successful reply; it must not keep what it reads.
431+func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error {
310432 conn, err := c.conn2()
311433 if err != nil {
312434 if !errors.Is(err, zerobus.ErrClosed) {
@@ -314,12 +436,23 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
314436 }
315437 return err
316438 }
317- args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
318- timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
319- _, err = conn.Call()
439+ wait := callTimeout
440+ if !c.deadline.IsZero() {
441+ if wait = min(wait, time.Until(c.deadline)); wait <= 0 {
442+ return errBudget
443+ }
444+ }
445+ args(conn.NewCall(dest, path, iface, member, sig))
446+ timer := time.AfterFunc(wait, func() { _ = conn.Close() })
447+ m, err := conn.Call()
448+ if err == nil && reply != nil {
449+ reply(m)
450+ }
320451 expired := !timer.Stop()
321- var reply *zerobus.Error
322- if errors.As(err, &reply) && !expired {
452+ var refused *zerobus.Error
453+ if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired {
454+ // An error reply, or a name or path the library would not send:
455+ // the connection is fine.
323456 c.log.Debug("tray: the application refused", "method", member, "err", err)
324457 return err
325458 }
@@ -332,8 +465,133 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder
332465 stopping := c.done
333466 c.mu.Unlock()
334467 if !stopping {
335- c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
468+ c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err)
336469 }
337470 }
338471 return err
339472 }
473+
474+// errBudget is returned by a call that did not start because the time for its
475+// sequence ran out.
476+var errBudget = errors.New("tray: out of time")
477+
478+// menuBudget is how long opening a menu may take in all, whatever the number
479+// of calls it makes. A variable for the tests.
480+var menuBudget = 4 * time.Second
481+
482+// closeBudget is the time given to tell an application its menu will not be
483+// shown after all.
484+const closeBudget = time.Second
485+
486+// transportFailed reports whether err means the call did not complete, as
487+// opposed to the application answering with an error.
488+func transportFailed(err error) bool {
489+ var refused *zerobus.Error
490+ return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid)
491+}
492+
493+// openMenu shows the application's menu for the press numbered token: its
494+// dbusmenu when it has one, else it asks the application to show its own.
495+// The calls share one deadline (menuBudget). When they fail after the
496+// application was told "opened", it is told "closed" and the menu forgotten.
497+func (c *control) openMenu(tg Target, token uint32) {
498+ if tg.Menu == "" || tg.Menu == noMenuPath {
499+ c.contextMenu(tg)
500+ return
501+ }
502+ c.menu = nil // an older menu is superseded
503+ c.deadline = time.Now().Add(menuBudget)
504+ defer func() { c.deadline = time.Time{} }()
505+
506+ // Ignore refusals: many applications do not implement these.
507+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
508+ if err != nil && (transportFailed(err) || errors.Is(err, errBudget)) {
509+ return
510+ }
511+ err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
512+ if errors.Is(err, errBudget) {
513+ return // never sent
514+ }
515+ // From here on the application may believe its menu is open, even if the
516+ // call timed out: it may have been delivered.
517+ if (err != nil && transportFailed(err)) || !c.sendMenu(tg, token) {
518+ c.deadline = time.Now().Add(closeBudget)
519+ c.menuEvent(tg.Dest, tg.Menu, 0, "closed")
520+ c.menu = nil
521+ }
522+}
523+
524+// sendMenu reads the menu of tg and writes it for the bar. It reports false
525+// when there is no menu to show.
526+func (c *control) sendMenu(tg Target, token uint32) bool {
527+ var items []menuItem
528+ var decodeErr error
529+ err := c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
530+ e.Int32(0)
531+ e.Int32(-1)
532+ a := e.BeginArray('s')
533+ e.EndArray(a)
534+ }, func(m *zerobus.Message) {
535+ if m.Signature != "u"+layoutSig {
536+ decodeErr = errors.New("unexpected layout signature " + m.Signature)
537+ return
538+ }
539+ items, _, decodeErr = decodeLayout(m.Body())
540+ })
541+ if err != nil {
542+ return false
543+ }
544+ if decodeErr != nil {
545+ c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
546+ return false
547+ }
548+ if len(items) == 0 {
549+ c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
550+ return false
551+ }
552+ line, ids, err := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
553+ if err != nil {
554+ c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", err)
555+ return false
556+ }
557+ c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
558+ c.writeLine(line)
559+ return true
560+}
561+
562+// menuEvent sends a dbusmenu Event with no data.
563+func (c *control) menuEvent(dest, path string, id int32, event string) error {
564+ return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) {
565+ e.Int32(id)
566+ e.Str(event)
567+ e.Variant("i")
568+ e.Int32(0)
569+ e.Uint32(0)
570+ }, nil)
571+}
572+
573+// menuActivate carries out a choice of the bar: only for the menu it was last
574+// given, and only for an item that menu holds.
575+func (c *control) menuActivate(token uint32, id int32) {
576+ m := c.menu
577+ if m == nil || m.token != token {
578+ c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token)
579+ return
580+ }
581+ if _, found := slices.BinarySearch(m.ids, id); !found {
582+ c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id)
583+ return
584+ }
585+ c.menu = nil
586+ c.menuEvent(m.dest, m.path, id, "clicked")
587+}
588+
589+// menuClosed tells the application its menu is closed.
590+func (c *control) menuClosed(token uint32) {
591+ m := c.menu
592+ if m == nil || m.token != token {
593+ return
594+ }
595+ c.menu = nil
596+ c.menuEvent(m.dest, m.path, 0, "closed")
597+}
A internal/tray/dbusmenu.go +217 -0
@@ -0,0 +1,217 @@
1+package tray
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/zerobus"
10+)
11+
12+const (
13+ menuIface = "com.canonical.dbusmenu"
14+
15+ // The limits of the menu the bar accepts (module.ParseControl).
16+ maxMenuDepth = 8
17+ maxMenuItems = 512
18+ maxMenuLabel = 256
19+ // maxMenuLine keeps the control line under the bar's 64 KiB, with room
20+ // for the fields around the items.
21+ maxMenuLine = 60 << 10
22+
23+ layoutSig = "(ia{sv}av)"
24+
25+ // noMenuPath is what some toolkits put in Menu to say "no menu".
26+ noMenuPath = "/NO_DBUSMENU"
27+)
28+
29+// menuItem is one entry of the menu sent to the bar.
30+type menuItem struct {
31+ ID int32 `json:"id"`
32+ Label string `json:"label"`
33+ Kind string `json:"kind"`
34+ Enabled bool `json:"enabled"`
35+ Checked bool `json:"checked"`
36+ Items []menuItem `json:"items,omitempty"`
37+}
38+
39+// menuLine is the control line that opens a menu.
40+type menuLine struct {
41+ Type string `json:"type"`
42+ Col int `json:"col"`
43+ Width int `json:"width"`
44+ Click uint32 `json:"click"`
45+ Items []menuItem `json:"items"`
46+}
47+
48+// lastMenu is the menu the bar was last given: what a menu-activate or
49+// menu-closed line may refer to.
50+type lastMenu struct {
51+ token uint32
52+ dest string
53+ path string
54+ ids []int32 // the ids the bar can choose from, sorted
55+}
56+
57+// layoutDecoder reads the reply of GetLayout.
58+type layoutDecoder struct {
59+ r *zerobus.Reader
60+ count int
61+ ids []int32
62+}
63+
64+// decodeLayout reads a GetLayout reply body, "u(ia{sv}av)": the top level
65+// items, and the ids among them. Invisible items are dropped; the tree is
66+// cut at maxMenuDepth levels and maxMenuItems items. Children whose signature
67+// is not (ia{sv}av) are skipped.
68+func decodeLayout(r *zerobus.Reader) (items []menuItem, ids []int32, err error) {
69+ r.Uint32() // revision
70+ d := &layoutDecoder{r: r}
71+ root, _ := d.node(0)
72+ if err = r.Err(); err != nil {
73+ return nil, nil, err
74+ }
75+ slices.Sort(d.ids)
76+ return root.Items, d.ids, nil
77+}
78+
79+// node reads one (ia{sv}av) struct at the given depth (the root is 0). It
80+// reports false for an item that is not kept: invisible, or over a limit.
81+func (d *layoutDecoder) node(depth int) (menuItem, bool) {
82+ r := d.r
83+ r.Struct()
84+ it := menuItem{ID: r.Int32(), Kind: "normal", Enabled: true}
85+ visible := true
86+ toggle := ""
87+ checked := false
88+ end := r.Array('{')
89+ for r.More(end) {
90+ r.Struct()
91+ key := r.Str()
92+ sig := r.Variant()
93+ switch {
94+ case key == "label" && sig == "s":
95+ it.Label = cleanLabel(r.Str())
96+ case key == "enabled" && sig == "b":
97+ it.Enabled = r.Bool()
98+ case key == "visible" && sig == "b":
99+ visible = r.Bool()
100+ case key == "type" && sig == "s":
101+ if r.Str() == "separator" {
102+ it.Kind = "separator"
103+ }
104+ case key == "toggle-type" && sig == "s":
105+ toggle = r.Str()
106+ case key == "toggle-state" && sig == "i":
107+ checked = r.Int32() == 1
108+ default:
109+ r.Skip(sig)
110+ }
111+ }
112+ if it.Kind != "separator" {
113+ switch toggle {
114+ case "checkmark":
115+ it.Kind = "check"
116+ case "radio":
117+ it.Kind = "radio"
118+ }
119+ it.Checked = checked && it.Kind != "normal"
120+ }
121+ kids := r.Array('v')
122+ keep := depth == 0 || (visible && it.ID >= 0 && d.count < maxMenuItems)
123+ if depth > 0 && keep {
124+ d.count++
125+ d.ids = append(d.ids, it.ID)
126+ }
127+ if !keep || depth >= maxMenuDepth {
128+ r.SkipTo(kids)
129+ return it, keep
130+ }
131+ for r.More(kids) {
132+ sig := r.Variant()
133+ if sig != layoutSig {
134+ r.Skip(sig)
135+ continue
136+ }
137+ if child, ok := d.node(depth + 1); ok {
138+ it.Items = append(it.Items, child)
139+ }
140+ }
141+ return it, true
142+}
143+
144+// cleanLabel strips the mnemonic markers of a dbusmenu label: "_x" is "x" and
145+// "__" is "_". The control characters are the bar's to remove.
146+func cleanLabel(s string) string {
147+ if !strings.Contains(s, "_") {
148+ return cutRunes(s, maxMenuLabel)
149+ }
150+ var b strings.Builder
151+ for i := 0; i < len(s); i++ {
152+ if s[i] != '_' {
153+ b.WriteByte(s[i])
154+ continue
155+ }
156+ if i+1 < len(s) {
157+ i++
158+ b.WriteByte(s[i]) // "_x" and "__" both keep the second byte
159+ }
160+ }
161+ return cutRunes(b.String(), maxMenuLabel)
162+}
163+
164+// encodeMenu builds the control line for a menu. When the JSON would exceed
165+// the bar's limit, items are dropped from the end of the menu (in reading
166+// order) until it fits.
167+func encodeMenu(l menuLine) ([]byte, []int32, error) {
168+ total := countItems(l.Items)
169+ for {
170+ b, err := json.Marshal(l)
171+ if err != nil {
172+ return nil, nil, err
173+ }
174+ if len(b) <= maxMenuLine {
175+ var ids []int32
176+ collectIDs(l.Items, &ids)
177+ slices.Sort(ids)
178+ return b, ids, nil
179+ }
180+ if total <= 1 {
181+ return nil, nil, errors.New("tray: the menu is too large")
182+ }
183+ total = total * 3 / 4
184+ keep := total
185+ l.Items = keepFirst(l.Items, &keep)
186+ }
187+}
188+
189+func countItems(items []menuItem) int {
190+ n := len(items)
191+ for i := range items {
192+ n += countItems(items[i].Items)
193+ }
194+ return n
195+}
196+
197+func collectIDs(items []menuItem, ids *[]int32) {
198+ for i := range items {
199+ *ids = append(*ids, items[i].ID)
200+ collectIDs(items[i].Items, ids)
201+ }
202+}
203+
204+// keepFirst keeps the first *n items in reading order, parents before their
205+// children, and returns the trimmed list.
206+func keepFirst(items []menuItem, n *int) []menuItem {
207+ var out []menuItem
208+ for _, it := range items {
209+ if *n <= 0 {
210+ break
211+ }
212+ *n--
213+ it.Items = keepFirst(it.Items, n)
214+ out = append(out, it)
215+ }
216+ return out
217+}
A internal/tray/dbusmenu_test.go +509 -0
@@ -0,0 +1,509 @@
1+package tray
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerobus"
12+
13+ "github.com/bnema/neferbar/internal/module"
14+)
15+
16+// tprop is one property of a test menu node.
17+type tprop struct {
18+ k string
19+ v any // string, bool or int32
20+}
21+
22+// tnode is a node of the layout a fake application serves.
23+type tnode struct {
24+ id int32
25+ props []tprop
26+ kids []tnode
27+ // junk is written as an extra child with a signature that is not a layout node.
28+ junk bool
29+}
30+
31+func (n *tnode) encode(e *zerobus.Encoder) {
32+ e.Struct()
33+ e.Int32(n.id)
34+ a := e.BeginArray('{')
35+ for _, p := range n.props {
36+ e.Struct()
37+ e.Str(p.k)
38+ switch v := p.v.(type) {
39+ case string:
40+ e.Variant("s")
41+ e.Str(v)
42+ case bool:
43+ e.Variant("b")
44+ e.Bool(v)
45+ case int32:
46+ e.Variant("i")
47+ e.Int32(v)
48+ }
49+ }
50+ e.EndArray(a)
51+ kids := e.BeginArray('v')
52+ if n.junk {
53+ e.Variant("s")
54+ e.Str("not a node")
55+ }
56+ for i := range n.kids {
57+ e.Variant(layoutSig)
58+ n.kids[i].encode(e)
59+ }
60+ e.EndArray(kids)
61+}
62+
63+// menuCall answers a dbusmenu call and records it on mcalls.
64+func (f *fakeItem) menuCall(m *zerobus.Message) bool {
65+ r := m.Body()
66+ switch m.Member {
67+ case "AboutToShow":
68+ f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32())
69+ f.c.NewReply(m, "b").Bool(false)
70+ case "Event":
71+ id, name := r.Int32(), r.Str()
72+ r.Skip(r.Variant())
73+ f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name)
74+ f.c.NewReply(m, "")
75+ case "GetLayout":
76+ f.mcalls <- "GetLayout"
77+ if f.menu == nil {
78+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
79+ break
80+ }
81+ if f.badSig {
82+ f.c.NewReply(m, "s").Str("not a layout")
83+ break
84+ }
85+ e := f.c.NewReply(m, "u"+layoutSig)
86+ e.Uint32(1)
87+ f.menu.encode(e)
88+ default:
89+ return true
90+ }
91+ time.Sleep(f.mslow[m.Member]) // a slow application: the reply is built, not sent yet
92+ _, err := f.c.Send()
93+ return err == nil
94+}
95+
96+func expectMenuCall(t *testing.T, f *fakeItem, want string) {
97+ t.Helper()
98+ select {
99+ case got := <-f.mcalls:
100+ if got != want {
101+ t.Fatalf("menu got %s, want %s", got, want)
102+ }
103+ case <-time.After(5 * time.Second):
104+ t.Fatalf("menu never got %s", want)
105+ }
106+}
107+
108+func expectNoMenuCall(t *testing.T, f *fakeItem) {
109+ t.Helper()
110+ select {
111+ case got := <-f.mcalls:
112+ t.Fatalf("unexpected menu call %s", got)
113+ case <-time.After(150 * time.Millisecond):
114+ }
115+}
116+
117+func sampleLayout() *tnode {
118+ return &tnode{id: 0, kids: []tnode{
119+ {id: 1, props: []tprop{{"label", "_Open"}}},
120+ {id: 2, props: []tprop{{"type", "separator"}}},
121+ {id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}},
122+ {id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{
123+ {id: 5, props: []tprop{{"label", "Advanced"}}},
124+ }},
125+ {id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}},
126+ {id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}},
127+ {id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}},
128+ }, junk: true}
129+}
130+
131+// menuReady gives steam a dbusmenu and waits until the tray knows it: right
132+// clicks fall back to ContextMenu until then, and the first menu line proves it.
133+func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control {
134+ t.Helper()
135+ steam.run(t, func() {
136+ steam.menu = layout
137+ steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active")
138+ _, _ = steam.c.Send()
139+ })
140+ deadline := time.Now().Add(5 * time.Second)
141+ for time.Now().Before(deadline) {
142+ in.send(t, "click right 0 9")
143+ wait := time.After(600 * time.Millisecond)
144+ poll:
145+ for {
146+ select {
147+ case s := <-out.ch:
148+ if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) {
149+ c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07")))
150+ if err != nil {
151+ t.Fatalf("the bar would refuse the menu line %q: %v", j, err)
152+ }
153+ // Drain what the successful open recorded.
154+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
155+ expectMenuCall(t, steam, want)
156+ }
157+ return c
158+ }
159+ case <-steam.calls: // ContextMenu: the tray has not seen the menu yet
160+ break poll
161+ case <-wait:
162+ break poll
163+ }
164+ }
165+ for len(steam.mcalls) > 0 {
166+ <-steam.mcalls
167+ }
168+ }
169+ t.Fatal("the tray never produced a menu")
170+ return module.Control{}
171+}
172+
173+func TestRightClickOpensTheDbusmenu(t *testing.T) {
174+ steam, _, in, out := setup(t)
175+ c := menuReady(t, steam, in, out, sampleLayout())
176+
177+ if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 {
178+ t.Fatalf("control = %+v", c)
179+ }
180+ type flat struct {
181+ id int32
182+ label, kind string
183+ enabled, chk bool
184+ kids int
185+ }
186+ var got []flat
187+ for _, it := range c.Items {
188+ got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)})
189+ }
190+ want := []flat{
191+ {1, "Open", "normal", true, false, 0},
192+ {2, "", "separator", true, false, 0},
193+ {3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te"
194+ {4, "Settings", "normal", true, false, 1},
195+ {6, "Quit", "normal", false, false, 0},
196+ {8, "Radio", "radio", true, false, 0},
197+ }
198+ want[2].label = "Mu_te"
199+ if fmt.Sprint(got) != fmt.Sprint(want) {
200+ t.Fatalf("items = %+v\nwant %+v", got, want)
201+ }
202+ if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" {
203+ t.Fatalf("submenu = %+v", c.Items[3].Items)
204+ }
205+ expectNoCall(t, steam) // no ContextMenu: the bar draws the menu
206+
207+ // A choice reaches the application, once.
208+ in.send(t, "menu-activate 9 5")
209+ expectMenuCall(t, steam, "Event(5,clicked)")
210+ in.send(t, "menu-activate 9 5", "menu-closed 9")
211+ expectNoMenuCall(t, steam)
212+}
213+
214+func TestMenuClosedAndStaleTokens(t *testing.T) {
215+ steam, _, in, out := setup(t)
216+ menuReady(t, steam, in, out, sampleLayout())
217+
218+ in.send(t, "menu-activate 8 1") // another token
219+ in.send(t, "menu-closed 8")
220+ in.send(t, "menu-activate 9 99") // an id the menu does not hold
221+ in.send(t, "menu-activate 9 7") // an invisible item
222+ in.send(t, "menu-activate 9 70") // a child of an invisible item
223+ expectNoMenuCall(t, steam)
224+
225+ in.send(t, "menu-closed 9")
226+ expectMenuCall(t, steam, "Event(0,closed)")
227+ in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing
228+ expectNoMenuCall(t, steam)
229+}
230+
231+func TestNewerMenuReplacesTheOlder(t *testing.T) {
232+ steam, _, in, out := setup(t)
233+ menuReady(t, steam, in, out, sampleLayout())
234+ in.send(t, "click right 0 10")
235+ out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) })
236+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
237+ expectMenuCall(t, steam, want)
238+ }
239+ in.send(t, "menu-activate 9 1") // the first menu is gone
240+ expectNoMenuCall(t, steam)
241+ in.send(t, "menu-activate 10 1")
242+ expectMenuCall(t, steam, "Event(1,clicked)")
243+}
244+
245+func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) {
246+ steam, _, in, out := setup(t)
247+ menuReady(t, steam, in, out, sampleLayout())
248+ // ItemIsMenu is not served by the fake, so the left click calls Activate,
249+ // which it refuses: the fallback opens the menu.
250+ steam.run(t, func() { steam.unknown = "Activate" })
251+ in.send(t, "click left 0 12")
252+ expectCall(t, steam, "Activate(0,0)")
253+ out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) })
254+}
255+
256+func TestMenuLayoutLimitsAndDecoding(t *testing.T) {
257+ // Build a deep and wide layout and decode it through the real reply path.
258+ deep := tnode{id: 100, props: []tprop{{"label", "d"}}}
259+ for i := int32(1); i < 12; i++ {
260+ deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}}
261+ }
262+ root := tnode{id: 0, kids: []tnode{deep}}
263+ for i := int32(0); i < 600; i++ {
264+ root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}})
265+ }
266+ items, ids := decodeTestLayout(t, &root)
267+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
268+ t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems)
269+ }
270+ depth := 0
271+ for l := items; len(l) > 0; l = l[0].Items {
272+ depth++
273+ }
274+ if depth != maxMenuDepth {
275+ t.Fatalf("depth %d, want %d", depth, maxMenuDepth)
276+ }
277+ if got := len(items[len(items)-1].Label); got != 256 {
278+ t.Fatalf("label of %d bytes, want 256", got)
279+ }
280+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
281+ if err != nil || len(line) > maxMenuLine {
282+ t.Fatalf("encodeMenu: %d bytes, %v", len(line), err)
283+ }
284+ if _, err = module.ParseControl(line); err != nil {
285+ t.Fatalf("the bar refuses the trimmed menu: %v", err)
286+ }
287+}
288+
289+func TestMenuLabelMnemonics(t *testing.T) {
290+ for in, want := range map[string]string{
291+ "_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè",
292+ } {
293+ if got := cleanLabel(in); got != want {
294+ t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
295+ }
296+ }
297+}
298+
299+// decodeTestLayout encodes root as a GetLayout reply and decodes it again.
300+func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
301+ t.Helper()
302+ addr := privateBus(t)
303+ srv, cli := dial(t, addr), dial(t, addr)
304+ done := make(chan struct{})
305+ go func() {
306+ defer close(done)
307+ var m *zerobus.Message
308+ for { // the bus first sends signals such as NameAcquired
309+ var err error
310+ if m, err = srv.ReadMessage(); err != nil {
311+ return
312+ }
313+ if m.Type == zerobus.TypeMethodCall {
314+ break
315+ }
316+ }
317+ e := srv.NewReply(m, "u"+layoutSig)
318+ e.Uint32(1)
319+ root.encode(e)
320+ _, _ = srv.Send()
321+ }()
322+ cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "")
323+ m, err := cli.Call()
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ items, ids, err := decodeLayout(m.Body())
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ _ = srv.Close()
332+ <-done
333+ return items, ids
334+}
335+
336+// A slow application cannot hold the tray for more than the shared budget, and
337+// it is told the menu is closed when it was told it opened.
338+func TestSlowMenuUsesOneDeadlineAndIsClosed(t *testing.T) {
339+ old := menuBudget
340+ menuBudget = 1200 * time.Millisecond
341+ t.Cleanup(func() { menuBudget = old })
342+
343+ steam, _, in, out := setup(t)
344+ menuReady(t, steam, in, out, sampleLayout()) // the tray knows the menu now
345+ in.send(t, "menu-closed 9")
346+ expectMenuCall(t, steam, "Event(0,closed)")
347+
348+ // Every call takes 500 ms: each is under the 2 s call limit, and the
349+ // sequence runs out of its 1.2 s while GetLayout is waiting.
350+ steam.run(t, func() {
351+ steam.mslow = map[string]time.Duration{"AboutToShow": 500 * time.Millisecond, "Event": 500 * time.Millisecond, "GetLayout": 500 * time.Millisecond}
352+ })
353+ start := time.Now()
354+ in.send(t, "click right 0 20")
355+ expectMenuCall(t, steam, "AboutToShow(0)")
356+ expectMenuCall(t, steam, "Event(0,opened)")
357+ expectMenuCall(t, steam, "GetLayout")
358+ // GetLayout ran out of budget: the application hears "closed".
359+ expectMenuCall(t, steam, "Event(0,closed)")
360+ if d := time.Since(start); d > 3*time.Second {
361+ t.Fatalf("opening the menu took %v", d)
362+ }
363+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
364+ // The menu is forgotten: a late choice does nothing.
365+ in.send(t, "menu-activate 20 1")
366+ expectNoMenuCall(t, steam)
367+}
368+
369+// An application with an empty menu is also told that it closed.
370+func TestEmptyMenuIsClosedAgain(t *testing.T) {
371+ steam, _, in, out := setup(t)
372+ menuReady(t, steam, in, out, sampleLayout())
373+ in.send(t, "menu-closed 9")
374+ expectMenuCall(t, steam, "Event(0,closed)")
375+ steam.run(t, func() { steam.menu = &tnode{id: 0} })
376+ // The tray still thinks it has a menu path: the layout is just empty.
377+ in.send(t, "click right 0 21")
378+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
379+ expectMenuCall(t, steam, want)
380+ }
381+ in.send(t, "menu-activate 21 1")
382+ expectNoMenuCall(t, steam)
383+}
384+
385+// noControlLineFor fails when a control line of the given type arrives within d.
386+func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
387+ t.Helper()
388+ timeout := time.After(d)
389+ for {
390+ select {
391+ case s, ok := <-out.ch:
392+ if ok && strings.HasPrefix(s, controlPrefix) && strings.Contains(s, `"type":"`+typ+`"`) {
393+ t.Fatalf("unexpected %s line %q", typ, s)
394+ }
395+ case <-timeout:
396+ return
397+ }
398+ }
399+}
400+
401+// A reply that is not a layout makes no menu, and the application hears that
402+// the menu it was told to open is closed.
403+func TestUnexpectedLayoutSignature(t *testing.T) {
404+ steam, _, in, out := setup(t)
405+ menuReady(t, steam, in, out, sampleLayout())
406+ in.send(t, "menu-closed 9")
407+ expectMenuCall(t, steam, "Event(0,closed)")
408+ steam.run(t, func() { steam.badSig = true })
409+ in.send(t, "click right 0 30")
410+ for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
411+ expectMenuCall(t, steam, want)
412+ }
413+ noControlLineFor(t, out, "menu", 300*time.Millisecond)
414+ in.send(t, "menu-activate 30 1")
415+ expectNoMenuCall(t, steam)
416+}
417+
418+// decodeRaw sends body as the reply to one call on c and decodes it.
419+func decodeRaw(t testing.TB, c *zerobus.Conn, next chan<- reply, sig string, body []byte) ([]menuItem, []int32, error) {
420+ t.Helper()
421+ next <- reply{sig, body}
422+ c.NewCall("x.y", "/MenuBar", menuIface, "GetLayout", "")
423+ m, err := c.Call()
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ return decodeLayout(m.Body())
428+}
429+
430+// Bodies that are cut short or inconsistent must make an error, never a panic
431+// or an unbounded read.
432+func TestDecodeLayoutTruncatedAndMalformed(t *testing.T) {
433+ addr, next := rawBus(t)
434+ c := rawDial(t, addr)
435+ good := layoutBody(sampleLayout())
436+ if items, _, err := decodeRaw(t, c, next, "u"+layoutSig, good); err != nil || len(items) != 6 {
437+ t.Fatalf("the hand-built body does not decode like the encoder's: %d items, %v", len(items), err)
438+ }
439+ // Every strict prefix of a valid body.
440+ for n := 0; n < len(good); n++ {
441+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, good[:n]); err == nil {
442+ t.Fatalf("a body cut at %d of %d bytes decoded without error", n, len(good))
443+ }
444+ }
445+ // A child array that claims more bytes than the message has.
446+ bad := slices.Clone(good)
447+ // The children array of the root sits right after the root's properties
448+ // (empty): find it by decoding position: revision(4) + pad(4) + id(4) + props len(4) + pad + kids len.
449+ binary.LittleEndian.PutUint32(bad[16:], 1<<20)
450+ if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, bad); err == nil {
451+ t.Fatal("an oversized child array decoded without error")
452+ }
453+ // An item with a property of the wrong type: ignored, not an error.
454+ odd := &tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", int32(5)}, {"enabled", "yes"}, {"visible", int32(0)}}}}}
455+ items, _, err := decodeRaw(t, c, next, "u"+layoutSig, layoutBody(odd))
456+ if err != nil || len(items) != 1 || items[0].Label != "" || !items[0].Enabled {
457+ t.Fatalf("odd properties: %+v, %v", items, err)
458+ }
459+ // Garbage of every kind does not panic.
460+ for _, junk := range [][]byte{nil, {0}, make([]byte, 7), make([]byte, 64), slices.Repeat([]byte{0xff}, 100)} {
461+ decodeRaw(t, c, next, "u"+layoutSig, junk)
462+ }
463+}
464+
465+func FuzzDecodeLayout(f *testing.F) {
466+ addr, next := rawBus(f)
467+ c := rawDial(f, addr)
468+ f.Add(layoutBody(sampleLayout()))
469+ f.Add(layoutBody(&tnode{id: 0}))
470+ f.Add(layoutBody(&tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", "x"}, {"type", "separator"}}}}}))
471+ f.Add(layoutBody(sampleLayout())[:30])
472+ f.Add([]byte{})
473+ f.Fuzz(func(t *testing.T, body []byte) {
474+ if len(body) > 1<<16 {
475+ return
476+ }
477+ items, ids, err := decodeRaw(t, c, next, "u"+layoutSig, body)
478+ if err != nil {
479+ return
480+ }
481+ if n := countItems(items); n > maxMenuItems || len(ids) != n {
482+ t.Fatalf("%d items, %d ids", n, len(ids))
483+ }
484+ var walk func(l []menuItem, depth int)
485+ walk = func(l []menuItem, depth int) {
486+ if len(l) > 0 && depth > maxMenuDepth {
487+ t.Fatalf("depth %d", depth)
488+ }
489+ for _, it := range l {
490+ if len(it.Label) > maxMenuLabel || it.ID < 0 {
491+ t.Fatalf("item %+v", it)
492+ }
493+ walk(it.Items, depth+1)
494+ }
495+ }
496+ walk(items, 1)
497+ // Whatever decodes must be acceptable to the bar (an empty menu is
498+ // not sent at all).
499+ if len(items) == 0 {
500+ return
501+ }
502+ line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
503+ if err == nil {
504+ if _, err = module.ParseControl(line); err != nil {
505+ t.Fatalf("the bar refuses a decoded menu: %v", err)
506+ }
507+ }
508+ })
509+}
A internal/tray/markup.go +95 -0
@@ -0,0 +1,95 @@
1+package tray
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "unicode/utf8"
7+)
8+
9+// maxTipBytes bounds a tooltip text sent to the bar.
10+const maxTipBytes = 512
11+
12+// plainText turns the markup an application puts in a tooltip into plain
13+// text: tags are dropped, <br> becomes a newline, and the common entities are
14+// resolved. The result is cut to maxTipBytes at a rune boundary. A "<" with no
15+// closing ">" is kept as text.
16+func plainText(s string) string {
17+ var b strings.Builder
18+ for i := 0; i < len(s) && b.Len() < maxTipBytes+utf8.UTFMax; {
19+ switch c := s[i]; {
20+ case c == '<':
21+ end := strings.IndexByte(s[i:], '>')
22+ if end < 0 {
23+ b.WriteByte(c)
24+ i++
25+ continue
26+ }
27+ if isBreak(s[i+1 : i+end]) {
28+ b.WriteByte('\n')
29+ }
30+ i += end + 1
31+ case c == '&':
32+ if r, n, ok := entity(s[i:]); ok {
33+ b.WriteRune(r)
34+ i += n
35+ continue
36+ }
37+ b.WriteByte(c)
38+ i++
39+ default:
40+ b.WriteByte(c)
41+ i++
42+ }
43+ }
44+ return cutRunes(b.String(), maxTipBytes)
45+}
46+
47+// isBreak reports whether the inside of a tag is <br>, <br/> or <br />.
48+func isBreak(tag string) bool {
49+ tag = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(tag), "/"))
50+ return strings.EqualFold(tag, "br")
51+}
52+
53+// entity decodes the entity at the start of s ("&amp;", "&#65;", "&#x41;").
54+func entity(s string) (r rune, n int, ok bool) {
55+ end := strings.IndexByte(s, ';')
56+ if end < 2 || end > 10 {
57+ return 0, 0, false
58+ }
59+ switch name := s[1:end]; name {
60+ case "amp":
61+ return '&', end + 1, true
62+ case "lt":
63+ return '<', end + 1, true
64+ case "gt":
65+ return '>', end + 1, true
66+ case "quot":
67+ return '"', end + 1, true
68+ case "apos":
69+ return '\'', end + 1, true
70+ default:
71+ if name[0] != '#' {
72+ return 0, 0, false
73+ }
74+ num, base := name[1:], 10
75+ if len(num) > 0 && (num[0] == 'x' || num[0] == 'X') {
76+ num, base = num[1:], 16
77+ }
78+ v, err := strconv.ParseUint(num, base, 32)
79+ if err != nil || !utf8.ValidRune(rune(v)) || v == 0 {
80+ return 0, 0, false
81+ }
82+ return rune(v), end + 1, true
83+ }
84+}
85+
86+// cutRunes cuts s to at most max bytes without splitting a rune.
87+func cutRunes(s string, max int) string {
88+ if len(s) <= max {
89+ return s
90+ }
91+ for max > 0 && !utf8.RuneStart(s[max]) {
92+ max--
93+ }
94+ return s[:max]
95+}
A internal/tray/rawbus_test.go +190 -0
@@ -0,0 +1,190 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+)
13+
14+// wb builds D-Bus wire data by hand (little endian), so tests can send bodies
15+// that zerobus's own encoder refuses to produce: truncated or inconsistent.
16+type wb struct{ b []byte }
17+
18+func (w *wb) pad(n int) {
19+ for len(w.b)%n != 0 {
20+ w.b = append(w.b, 0)
21+ }
22+}
23+
24+func (w *wb) u32(v uint32) { w.pad(4); w.b = binary.LittleEndian.AppendUint32(w.b, v) }
25+func (w *wb) i32(v int32) { w.u32(uint32(v)) }
26+
27+func (w *wb) str(s string) {
28+ w.u32(uint32(len(s)))
29+ w.b = append(append(w.b, s...), 0)
30+}
31+
32+func (w *wb) sig(s string) { w.b = append(append(w.b, byte(len(s))), append([]byte(s), 0)...) }
33+
34+// lenPrefixed writes an array whose data starts aligned to align and is filled by fn.
35+func (w *wb) lenPrefixed(align int, fn func()) {
36+ w.u32(0)
37+ at := len(w.b) - 4
38+ w.pad(align)
39+ start := len(w.b)
40+ fn()
41+ binary.LittleEndian.PutUint32(w.b[at:], uint32(len(w.b)-start))
42+}
43+
44+// layoutBody is the body of a GetLayout reply for root: the revision, then the tree.
45+func layoutBody(root *tnode) []byte {
46+ var w wb
47+ w.u32(1)
48+ w.node(root)
49+ return w.b
50+}
51+
52+func (w *wb) node(n *tnode) {
53+ w.pad(8)
54+ w.i32(n.id)
55+ w.lenPrefixed(8, func() {
56+ for _, p := range n.props {
57+ w.pad(8)
58+ w.str(p.k)
59+ switch v := p.v.(type) {
60+ case string:
61+ w.sig("s")
62+ w.str(v)
63+ case bool:
64+ w.sig("b")
65+ if v {
66+ w.u32(1)
67+ } else {
68+ w.u32(0)
69+ }
70+ case int32:
71+ w.sig("i")
72+ w.i32(v)
73+ }
74+ }
75+ })
76+ w.lenPrefixed(1, func() {
77+ if n.junk {
78+ w.sig("s")
79+ w.str("not a node")
80+ }
81+ for i := range n.kids {
82+ w.sig(layoutSig)
83+ w.node(&n.kids[i])
84+ }
85+ })
86+}
87+
88+// frame is a method return of the given serials, with signature sig and body.
89+func frame(serial, replySerial uint32, sig string, body []byte) []byte {
90+ // Header field 5 (REPLY_SERIAL, u) and 8 (SIGNATURE, g).
91+ var fields wb
92+ fields.pad(8)
93+ fields.b = append(fields.b, 5)
94+ fields.sig("u")
95+ fields.u32(replySerial)
96+ if sig != "" {
97+ fields.pad(8)
98+ fields.b = append(fields.b, 8)
99+ fields.sig("g")
100+ fields.sig(sig)
101+ }
102+ h := []byte{'l', 2, 0, 1}
103+ h = binary.LittleEndian.AppendUint32(h, uint32(len(body)))
104+ h = binary.LittleEndian.AppendUint32(h, serial)
105+ h = binary.LittleEndian.AppendUint32(h, uint32(len(fields.b)))
106+ h = append(h, fields.b...)
107+ for len(h)%8 != 0 {
108+ h = append(h, 0)
109+ }
110+ return append(h, body...)
111+}
112+
113+// reply is what the raw bus answers to the next method call.
114+type reply struct {
115+ sig string
116+ body []byte
117+}
118+
119+// rawBus is a bus with one client that answers every method call after Hello
120+// with the next queued reply. It returns the address to dial.
121+func rawBus(t testing.TB) (addr string, next chan<- reply) {
122+ t.Helper()
123+ sock := filepath.Join(t.TempDir(), "bus")
124+ l, err := net.Listen("unix", sock)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ t.Cleanup(func() { _ = l.Close() })
129+ ch := make(chan reply, 16)
130+ go func() {
131+ c, err := l.Accept()
132+ if err != nil {
133+ return
134+ }
135+ defer c.Close()
136+ r := bufio.NewReader(c)
137+ if _, err = r.ReadString('\n'); err != nil { // \0AUTH EXTERNAL ...
138+ return
139+ }
140+ if _, err = io.WriteString(c, "OK 0123456789abcdef\r\n"); err != nil {
141+ return
142+ }
143+ if _, err = r.ReadString('\n'); err != nil { // BEGIN
144+ return
145+ }
146+ var hello wb
147+ hello.str(":1.7")
148+ serial := uint32(1)
149+ first := true
150+ for {
151+ var hdr [16]byte
152+ if _, err = io.ReadFull(r, hdr[:]); err != nil {
153+ return
154+ }
155+ fields := binary.LittleEndian.Uint32(hdr[12:])
156+ rest := (16+int(fields)+7)/8*8 - 16 + int(binary.LittleEndian.Uint32(hdr[4:]))
157+ if _, err = io.CopyN(io.Discard, r, int64(rest)); err != nil {
158+ return
159+ }
160+ callSerial := binary.LittleEndian.Uint32(hdr[8:])
161+ var out []byte
162+ if first {
163+ first = false
164+ out = frame(serial, callSerial, "s", hello.b)
165+ } else {
166+ rp, ok := <-ch
167+ if !ok {
168+ return
169+ }
170+ out = frame(serial, callSerial, rp.sig, rp.body)
171+ }
172+ serial++
173+ if _, err = c.Write(out); err != nil {
174+ return
175+ }
176+ }
177+ }()
178+ return "unix:path=" + sock, ch
179+}
180+
181+// rawDial connects to a rawBus.
182+func rawDial(t testing.TB, addr string) *zerobus.Conn {
183+ t.Helper()
184+ c, err := zerobus.Dial(addr)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ t.Cleanup(func() { _ = c.Close() })
189+ return c
190+}
M internal/tray/snapshot.go +3 -0
@@ -11,6 +11,9 @@ type Target struct {
1111 IsMenu bool // the item only offers a menu: Activate means "open it"
1212 Start int // first column of the icon in the line
1313 Width int // cells the icon takes
14+ // TipTitle and TipBody are the item's tooltip as it sent it, markup
15+ // included; the title falls back to the item's Title.
16+ TipTitle, TipBody string
1417 }
1518
1619 // snapshot is the list of targets of the last line printed. The main loop
A internal/tray/tooltip_test.go +149 -0
@@ -0,0 +1,149 @@
1+package tray
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+)
8+
9+func TestPlainText(t *testing.T) {
10+ for in, want := range map[string]string{
11+ "": "",
12+ "plain": "plain",
13+ "<b>bold</b> text": "bold text",
14+ "a<br>b": "a\nb",
15+ "a<br/>b<BR />c": "a\nb\nc",
16+ "<p style=\"x\">hi</p>": "hi",
17+ "Tom &amp; Jerry": "Tom & Jerry",
18+ "&lt;tag&gt; &quot;q&quot; &apos;s&apos;": "<tag> \"q\" 's'",
19+ "&#65;&#x42;&#X43;": "ABC",
20+ "&#0;&#xD800;&#99999999999;": "&#0;&#xD800;&#99999999999;",
21+ "&unknown; &amp": "&unknown; &amp",
22+ "1 < 2": "1 < 2",
23+ "<<b>x</b>": "x",
24+ "<img src='x>y'>z": "y'>z",
25+ "\u00e9&#233;": "éé",
26+ } {
27+ if got := plainText(in); got != want {
28+ t.Errorf("plainText(%q) = %q, want %q", in, got, want)
29+ }
30+ }
31+ long := plainText(strings.Repeat("é", 1000))
32+ if len(long) > maxTipBytes || len(long)%2 != 0 || strings.ContainsRune(long, '\uFFFD') {
33+ t.Errorf("long text: %d bytes", len(long))
34+ }
35+ if got := plainText(strings.Repeat("<b>", 100000) + "x"); got != "x" {
36+ t.Errorf("many tags = %q", got)
37+ }
38+}
39+
40+// controlLine waits for a control line with the given type and returns its JSON.
41+func controlLine(t *testing.T, out *lines, typ string, within time.Duration) string {
42+ t.Helper()
43+ timeout := time.After(within)
44+ for {
45+ select {
46+ case s, ok := <-out.ch:
47+ if !ok {
48+ t.Fatalf("output ended while waiting for a %s line", typ)
49+ }
50+ if j, found := strings.CutPrefix(s, controlPrefix); found && strings.HasSuffix(j, "\x07") &&
51+ strings.Contains(j, `"type":"`+typ+`"`) {
52+ return strings.TrimSuffix(j, "\x07")
53+ }
54+ case <-timeout:
55+ t.Fatalf("no %s control line within %v", typ, within)
56+ }
57+ }
58+}
59+
60+// noControlLine fails when a control line arrives within d.
61+func noControlLine(t *testing.T, out *lines, d time.Duration) {
62+ t.Helper()
63+ timeout := time.After(d)
64+ for {
65+ select {
66+ case s, ok := <-out.ch:
67+ if ok && strings.HasPrefix(s, controlPrefix) {
68+ t.Fatalf("unexpected control line %q", s)
69+ }
70+ case <-timeout:
71+ return
72+ }
73+ }
74+}
75+
76+func TestTooltipAfterHover(t *testing.T) {
77+ steam, _, in, out := setup(t)
78+ steam.setTip(t, "Steam", "<b>3</b> friends online<br>&amp; 1 game")
79+ // Wait for the refresh the NewToolTip signal triggers.
80+ deadline := time.Now().Add(5 * time.Second)
81+ var got string
82+ for time.Now().Before(deadline) {
83+ in.send(t, "hover 0")
84+ select {
85+ case s := <-out.ch:
86+ if strings.HasPrefix(s, controlPrefix) {
87+ got = s
88+ }
89+ case <-time.After(900 * time.Millisecond):
90+ }
91+ if got != "" {
92+ break
93+ }
94+ in.send(t, "leave")
95+ }
96+ want := controlPrefix + `{"type":"tooltip","col":0,"width":1,"title":"Steam","body":"3 friends online\n& 1 game"}` + "\x07"
97+ if got != want {
98+ t.Fatalf("tooltip line = %q\nwant %q", got, want)
99+ }
100+ // The pointer leaves: the tooltip closes.
101+ in.send(t, "leave")
102+ if j := controlLine(t, out, "close", time.Second); j != `{"type":"close"}` {
103+ t.Fatalf("close line = %q", j)
104+ }
105+}
106+
107+func TestTooltipNotSentWhenLeftEarlyOrOnClick(t *testing.T) {
108+ steam, obs, in, out := setup(t)
109+ steam.run(t, func() { steam.tip = &[2]string{"Steam", "x"} })
110+ obs.run(t, func() { obs.title = "Obsidian" })
111+ steam.setTip(t, "Steam", "x") // refresh both through the signal path
112+ obs.setTip(t, "", "")
113+ time.Sleep(200 * time.Millisecond)
114+
115+ in.send(t, "hover 0")
116+ time.Sleep(150 * time.Millisecond)
117+ in.send(t, "leave")
118+ noControlLine(t, out, 700*time.Millisecond)
119+
120+ // A click before the delay cancels the tooltip too, without a close line.
121+ in.send(t, "hover 0")
122+ time.Sleep(100 * time.Millisecond)
123+ in.send(t, "click left 0 1")
124+ expectCall(t, steam, "Activate(0,0)")
125+ noControlLine(t, out, 700*time.Millisecond)
126+
127+ // Moving between the gap and another icon restarts the delay.
128+ in.send(t, "hover 1", "hover 2")
129+ start := time.Now()
130+ j := controlLine(t, out, "tooltip", 2*time.Second)
131+ if d := time.Since(start); d < 400*time.Millisecond {
132+ t.Fatalf("the tooltip came after %v, want about 500ms", d)
133+ }
134+ // An item with no tooltip shows its Title.
135+ if !strings.Contains(j, `"title":"Obsidian"`) || !strings.Contains(j, `"col":2`) {
136+ t.Fatalf("tooltip = %q", j)
137+ }
138+ // A click after it was shown closes it.
139+ in.send(t, "click left 2 2")
140+ controlLine(t, out, "close", time.Second)
141+}
142+
143+func TestTooltipWithNothingToSayIsNotSent(t *testing.T) {
144+ _, _, in, out := setup(t) // fake items without ToolTip or Title
145+ in.send(t, "hover 0")
146+ noControlLine(t, out, 900*time.Millisecond)
147+ in.send(t, "leave")
148+ noControlLine(t, out, 100*time.Millisecond)
149+}
M internal/tray/tray.go +19 -2
@@ -76,6 +76,7 @@ type item struct {
7676 color RGB
7777 hasColor bool
7878 menu string // object path of its dbusmenu, "" when none
79+ tipTitle, tipBody string // ToolTip, raw (may hold markup); the title if the item has no tooltip
7980 isMenu bool // ItemIsMenu: Activate should open the menu
8081 loaded bool // GetAll answered at least once
8182 pending uint32 // serial of a GetAll in flight
@@ -334,7 +335,7 @@ func (t *Tray) signal(m *zerobus.Message) {
334335 }
335336 case itemIface:
336337 switch m.Member {
337- case "NewToolTip", "NewMenu", "NewOverlayIcon":
338+ case "NewMenu", "NewOverlayIcon":
338339 return // nothing the tray shows
339340 }
340341 for _, it := range t.items {
@@ -481,6 +482,7 @@ func (t *Tray) refresh(it *item) {
481482 func (t *Tray) load(it *item, r *zerobus.Reader) {
482483 was := *it
483484 var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
485+ tipSeen := false
484486 end := r.Array('{')
485487 for r.More(end) {
486488 r.Struct()
@@ -495,6 +497,13 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
495497 setStr(&it.iconName, r.Str())
496498 case sig == "o" && key == "Menu":
497499 setStr(&it.menu, r.ObjectPath())
500+ case sig == "(sa(iiay)ss)" && key == "ToolTip":
501+ r.Struct()
502+ r.Str() // icon name
503+ r.Skip("a(iiay)") // pixmaps
504+ setStr(&it.tipTitle, r.Str())
505+ setStr(&it.tipBody, r.Str())
506+ tipSeen = true
498507 case sig == "b" && key == "ItemIsMenu":
499508 it.isMenu = r.Bool()
500509 case sig == "s" && key == "Status":
@@ -512,6 +521,9 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
512521 *it = was
513522 return
514523 }
524+ if !tipSeen {
525+ it.tipTitle, it.tipBody = "", ""
526+ }
515527 it.loaded = true
516528 if it.status == attention && attentionPixmaps != nil {
517529 pixmaps = attentionPixmaps
@@ -741,7 +753,12 @@ func (t *Tray) render(force bool) error {
741753 for _, r := range it.icon {
742754 w += vt.RuneWidth(r)
743755 }
744- ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
756+ title, body := it.tipTitle, it.tipBody
757+ if title == "" && body == "" {
758+ title = it.title
759+ }
760+ ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w,
761+ TipTitle: title, TipBody: body})
745762 col += w
746763 }
747764 f = append(f, '\n')
M internal/tray/tray_test.go +47 -1
@@ -69,13 +69,21 @@ type fakeItem struct {
6969 unknown string // the method it answers with UnknownMethod
7070 slow string // the method it answers only after slowFor
7171 slowFor time.Duration // how long slow takes
72+ tip *[2]string // its ToolTip title and description, set through run
73+ title string // its Title property, set through run
74+
75+ // A dbusmenu at /MenuBar, set through run.
76+ menu *tnode
77+ mcalls chan string // the dbusmenu calls it received
78+ badSig bool // GetLayout answers with a string instead of a layout, set through run
79+ mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
7280 }
7381
7482 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
7583 c := dial(t, addr)
7684 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
7785 cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
78- calls: make(chan string, 32)}
86+ calls: make(chan string, 32), mcalls: make(chan string, 32)}
7987 go f.serve()
8088 return f
8189 }
@@ -106,6 +114,12 @@ func (f *fakeItem) serve() {
106114 (<-f.cmds)()
107115 continue
108116 }
117+ if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface {
118+ if !f.menuCall(m) {
119+ return
120+ }
121+ continue
122+ }
109123 if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
110124 if !f.action(m) {
111125 return
@@ -123,6 +137,29 @@ func (f *fakeItem) serve() {
123137 e.Variant("s")
124138 e.Str(f.cur[i])
125139 }
140+ if f.tip != nil {
141+ e.Struct()
142+ e.Str("ToolTip")
143+ e.Variant("(sa(iiay)ss)")
144+ e.Struct()
145+ e.Str("icon")
146+ tp := e.BeginArray('(')
147+ e.EndArray(tp)
148+ e.Str(f.tip[0])
149+ e.Str(f.tip[1])
150+ }
151+ if f.menu != nil {
152+ e.Struct()
153+ e.Str("Menu")
154+ e.Variant("o")
155+ e.ObjectPath("/MenuBar")
156+ }
157+ if f.title != "" {
158+ e.Struct()
159+ e.Str("Title")
160+ e.Variant("s")
161+ e.Str(f.title)
162+ }
126163 e.Struct()
127164 e.Str("IconPixmap")
128165 e.Variant("a(iiay)")
@@ -175,6 +212,15 @@ func (f *fakeItem) register(t *testing.T) {
175212 })
176213 }
177214
215+// setTip changes the item's tooltip and emits NewToolTip.
216+func (f *fakeItem) setTip(t *testing.T, title, body string) {
217+ f.run(t, func() {
218+ f.tip = &[2]string{title, body}
219+ f.c.NewSignal(itemPath, itemIface, "NewToolTip", "")
220+ _, _ = f.c.Send()
221+ })
222+}
223+
178224 // setStatus changes the item's status and emits NewStatus.
179225 func (f *fakeItem) setStatus(t *testing.T, status string) {
180226 f.run(t, func() {

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