Merge pull request #4: feat: popups for modules, tray tooltips and menus
27 files · +4608 -46
M .gitignore +2 -0
| @@ -2,3 +2,5 @@ | |||
| 2 | 2 | /neferbar | |
| 3 | 3 | /bin/ | |
| 4 | 4 | /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 | |||
| 223 | 223 | done | |
| 224 | 224 | ``` | |
| 225 | 225 | ||
| 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 | + | ||
| 226 | 273 | ### The bundled bar | |
| 227 | 274 | ||
| 228 | 275 | `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: | |||
| 257 | 304 | - `static.sh`: one line, then idle. | |
| 258 | 305 | - `clock.sh`: a clock with an icon. | |
| 259 | 306 | - `rainbow.sh [fps] [width]`: a 60 fps scrolling rainbow. | |
| 307 | + | - `status.py`: volume, Wi-Fi and battery with tooltips and right-click menus (needs `interactive = true`; uses `wpctl`, `nmcli` and `powerprofilesctl`, and optionally `pavucontrol` and `nmtui`). | |
| 260 | 308 | ||
| 261 | 309 | ## Layout | |
| 262 | 310 | ||
| @@ -317,10 +365,23 @@ The icon takes the main color of the application's own icon, moved toward the ba | |||
| 317 | 365 | ||
| 318 | 366 | - a left click calls the application's `Activate` (for an application that only offers a menu, it asks for the menu instead); | |
| 319 | 367 | - 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`; | |
| 321 | 369 | - the wheel calls `Scroll`: down and right are positive. | |
| 322 | 370 | ||
| 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. | |
| 324 | 385 | ||
| 325 | 386 | 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: | |
| 326 | 387 | ||
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 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 | |
| 7 | 7 | 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 | |
| 9 | 10 | github.com/bnema/purego-vulkan v0.6.0 | |
| 10 | 11 | github.com/bnema/vev-vt v0.6.0 | |
| 11 | 12 | github.com/bnema/wlturbo v0.6.2 | |
| @@ -17,5 +18,6 @@ require ( | |||
| 17 | 18 | require ( | |
| 18 | 19 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 19 | 20 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | |
| 21 | + | github.com/go-text/typesetting v0.3.5 // indirect | |
| 20 | 22 | golang.org/x/text v0.42.0 // indirect | |
| 21 | 23 | ) | |
M go.sum +8 -2 generated file: collapsed
M internal/bar/bar.go +92 -8
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "github.com/bnema/neferbar/internal/gpu" | |
| 29 | 29 | "github.com/bnema/neferbar/internal/layout" | |
| 30 | 30 | "github.com/bnema/neferbar/internal/module" | |
| 31 | + | "github.com/bnema/neferbar/internal/popup" | |
| 31 | 32 | "github.com/bnema/neferbar/internal/syncobj" | |
| 32 | 33 | "github.com/bnema/neferbar/internal/theme" | |
| 33 | 34 | ) | |
| @@ -119,6 +120,15 @@ type Bar struct { | |||
| 119 | 120 | ptr pointerState // pointer events turned into module lines | |
| 120 | 121 | input inputRegion | |
| 121 | 122 | ||
| 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 | + | ||
| 122 | 132 | Stats Stats | |
| 123 | 133 | fatal error | |
| 124 | 134 | closed bool | |
| @@ -186,6 +196,7 @@ func (b *Bar) setTheme(th theme.Theme) { | |||
| 186 | 196 | } | |
| 187 | 197 | b.dirty = true | |
| 188 | 198 | b.watchTheme() | |
| 199 | + | b.updatePopupStyle() | |
| 189 | 200 | } | |
| 190 | 201 | ||
| 191 | 202 | // watchTheme watches every file the theme was read from, so changing the | |
| @@ -323,12 +334,16 @@ func (b *Bar) syncModules(want []config.Module) { | |||
| 323 | 334 | r.cancel() | |
| 324 | 335 | } | |
| 325 | 336 | 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 | + | } | |
| 326 | 340 | clear(b.stale) | |
| 327 | 341 | b.watchScripts() | |
| 328 | 342 | b.mods = b.mods[:0] | |
| 329 | 343 | for _, r := range next { | |
| 330 | 344 | b.mods = append(b.mods, r.m) | |
| 331 | 345 | } | |
| 346 | + | b.forgetModules(b.mods) | |
| 332 | 347 | if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) { | |
| 333 | 348 | b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped | |
| 334 | 349 | } | |
| @@ -353,6 +368,7 @@ func (b *Bar) applyConfig(next config.Config) { | |||
| 353 | 368 | b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output) | |
| 354 | 369 | next.Bar.Output = old.Bar.Output | |
| 355 | 370 | } | |
| 371 | + | b.closePopup() // it was built from the old settings | |
| 356 | 372 | b.cfg = next | |
| 357 | 373 | b.fonts = fonts | |
| 358 | 374 | 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) { | |||
| 365 | 381 | b.log.Info("moving the bar", "to", next.Bar.Position) | |
| 366 | 382 | b.dropRenderer() | |
| 367 | 383 | _, h, _ := b.surf.Size() | |
| 384 | + | b.closePopup() | |
| 368 | 385 | if err = b.surf.Close(); err != nil { | |
| 369 | 386 | b.fail(err) | |
| 370 | 387 | return | |
| @@ -389,6 +406,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 389 | 406 | if err != nil { | |
| 390 | 407 | return connectError(b.display, err) | |
| 391 | 408 | } | |
| 409 | + | b.pop = popup.NewHost(b.conn, b.log) | |
| 410 | + | b.updatePopupStyle() | |
| 392 | 411 | defer func() { | |
| 393 | 412 | err = errors.Join(err, b.shutdown()) | |
| 394 | 413 | 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) { | |||
| 417 | 436 | if err = b.conn.Dispatch(b); err != nil { | |
| 418 | 437 | return fmt.Errorf("dispatch: %w", err) | |
| 419 | 438 | } | |
| 439 | + | case <-b.pop.Wake(): // the popup renderer wants a frame | |
| 440 | + | case <-b.popC: // a popup frame was waiting for the GPU | |
| 420 | 441 | case <-b.modCh: | |
| 421 | 442 | b.Stats.ModuleWake++ | |
| 422 | 443 | case next := <-b.cfgCh: | |
| @@ -435,6 +456,7 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 435 | 456 | if err = b.step(); err != nil { | |
| 436 | 457 | return err | |
| 437 | 458 | } | |
| 459 | + | b.stepPopup() | |
| 438 | 460 | } | |
| 439 | 461 | return nil | |
| 440 | 462 | } | |
| @@ -637,6 +659,7 @@ func (b *Bar) reconcile() { | |||
| 637 | 659 | b.askedH, b.askedScale = wantH, scale | |
| 638 | 660 | b.log.Info("recreating layer surface", "height", wantH, "scale", scale) | |
| 639 | 661 | b.dropRenderer() | |
| 662 | + | b.closePopup() | |
| 640 | 663 | if err := b.surf.Close(); err != nil { | |
| 641 | 664 | b.fail(err) | |
| 642 | 665 | return | |
| @@ -664,6 +687,7 @@ func (b *Bar) reconcile() { | |||
| 664 | 687 | // off. A new surface is mapped fresh and placed from its real size. | |
| 665 | 688 | b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w) | |
| 666 | 689 | b.dropRenderer() | |
| 690 | + | b.closePopup() | |
| 667 | 691 | if err := b.surf.Close(); err != nil { | |
| 668 | 692 | b.fail(err) | |
| 669 | 693 | return | |
| @@ -751,6 +775,13 @@ func (b *Bar) shutdown() error { | |||
| 751 | 775 | for _, r := range b.runners { | |
| 752 | 776 | r.m.Wait(3 * time.Second) | |
| 753 | 777 | } | |
| 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() | |
| 754 | 785 | b.dropRenderer() | |
| 755 | 786 | b.face.Close() | |
| 756 | 787 | if b.node != nil { | |
| @@ -767,6 +798,9 @@ func (b *Bar) shutdown() error { | |||
| 767 | 798 | // change before it rebuilds. | |
| 768 | 799 | const settleDelay = 60 * time.Millisecond | |
| 769 | 800 | ||
| 801 | + | // warmupWait is how long shutdown waits for the popup warm-up. | |
| 802 | + | const warmupWait = 2 * time.Second | |
| 803 | + | ||
| 770 | 804 | // geometryChanged is called when the compositor reports a new scale or size. | |
| 771 | 805 | // The first report builds at once, because there is nothing on screen yet. | |
| 772 | 806 | // Later ones wait for the dust to settle, and nothing is drawn meanwhile. | |
| @@ -783,6 +817,10 @@ func (b *Bar) geometryChanged() { | |||
| 783 | 817 | ||
| 784 | 818 | // Configure marks the surface presentable and rebuilds as needed. | |
| 785 | 819 | func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) { | |
| 820 | + | if b.pop.Is(id) { | |
| 821 | + | b.pop.Configure() | |
| 822 | + | return | |
| 823 | + | } | |
| 786 | 824 | if id != b.sid { | |
| 787 | 825 | return | |
| 788 | 826 | } | |
| @@ -795,6 +833,10 @@ func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) { | |||
| 795 | 833 | ||
| 796 | 834 | // Scale follows the monitor's preferred scale. | |
| 797 | 835 | func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) { | |
| 836 | + | if b.pop.Is(id) { | |
| 837 | + | b.pop.Scale() | |
| 838 | + | return | |
| 839 | + | } | |
| 798 | 840 | if id == b.sid { | |
| 799 | 841 | b.geometryChanged() | |
| 800 | 842 | } | |
| @@ -821,13 +863,43 @@ func (b *Bar) FeedbackDone(id neferclient.SurfaceID) { | |||
| 821 | 863 | ||
| 822 | 864 | // Frame allows the next Present. | |
| 823 | 865 | func (b *Bar) Frame(id neferclient.SurfaceID) { | |
| 866 | + | if b.pop.Is(id) { | |
| 867 | + | b.pop.Frame() | |
| 868 | + | return | |
| 869 | + | } | |
| 824 | 870 | if id == b.sid { | |
| 825 | 871 | b.canPresent = true | |
| 826 | 872 | } | |
| 827 | 873 | } | |
| 828 | 874 | ||
| 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 | + | ||
| 829 | 897 | // Closed ends the loop when the compositor closes the surface. | |
| 830 | 898 | func (b *Bar) Closed(id neferclient.SurfaceID) { | |
| 899 | + | if b.pop.Is(id) { | |
| 900 | + | b.closePopup() | |
| 901 | + | return | |
| 902 | + | } | |
| 831 | 903 | if id == b.sid { | |
| 832 | 904 | b.closed = true | |
| 833 | 905 | } | |
| @@ -835,6 +907,10 @@ func (b *Bar) Closed(id neferclient.SurfaceID) { | |||
| 835 | 907 | ||
| 836 | 908 | // FDReady hands a slot back once the compositor released it. | |
| 837 | 909 | 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 | + | } | |
| 838 | 914 | if b.rend == nil { | |
| 839 | 915 | return | |
| 840 | 916 | } | |
| @@ -859,6 +935,10 @@ func (b *Bar) syncInputRegion() { | |||
| 859 | 935 | ||
| 860 | 936 | // Pointer turns pointer events over the bar into lines for interactive modules. | |
| 861 | 937 | func (b *Bar) Pointer(ev *neferclient.PointerEvent) { | |
| 938 | + | if b.pop.Is(ev.Surface) { | |
| 939 | + | b.pop.Pointer(ev) | |
| 940 | + | return | |
| 941 | + | } | |
| 862 | 942 | if ev.Surface != b.sid || b.lay == nil || b.face == nil { | |
| 863 | 943 | return | |
| 864 | 944 | } | |
| @@ -871,12 +951,18 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i | |||
| 871 | 951 | col := cellColumn(ev.X, scale, cellW) | |
| 872 | 952 | switch ev.Kind { | |
| 873 | 953 | case neferclient.PointerEnter, neferclient.PointerMotion: | |
| 954 | + | before := b.ptr.hover | |
| 874 | 955 | b.ptr.motion(b.lay, col) | |
| 956 | + | if b.ptr.hover != before { | |
| 957 | + | b.closeTooltip() // the pointer moved to another module | |
| 958 | + | } | |
| 875 | 959 | case neferclient.PointerLeave: | |
| 876 | 960 | b.ptr.leave() | |
| 961 | + | b.closeTooltip() | |
| 877 | 962 | case neferclient.PointerButton: | |
| 878 | 963 | 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) | |
| 880 | 966 | } | |
| 881 | 967 | case neferclient.PointerAxis: | |
| 882 | 968 | delta := ev.DY | |
| @@ -893,10 +979,8 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i | |||
| 893 | 979 | func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) } | |
| 894 | 980 | ||
| 895 | 981 | // 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/neferbar/internal/layout" | |
| 12 | 12 | "github.com/bnema/neferbar/internal/module" | |
| 13 | + | "github.com/bnema/neferbar/internal/popup" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // Evdev button codes. | |
| @@ -24,9 +25,28 @@ const pxPerStep = 15 | |||
| 24 | 25 | ||
| 25 | 26 | // press is the latest button press sent to a module. | |
| 26 | 27 | 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 | |
| 29 | 31 | 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 | |
| 30 | 50 | } | |
| 31 | 51 | ||
| 32 | 52 | // 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 | |||
| 107 | 127 | return | |
| 108 | 128 | } | |
| 109 | 129 | 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()} | |
| 111 | 131 | b := append(p.line("click "), name...) | |
| 112 | 132 | b = appendInt(b, off) | |
| 113 | 133 | b = append(b, ' ') | |
| @@ -204,10 +224,7 @@ func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 { | |||
| 204 | 224 | // inputRect is the logical-pixel rectangle of a span of cells: rounded | |
| 205 | 225 | // outward, full surface height. | |
| 206 | 226 | 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) | |
| 211 | 228 | } | |
| 212 | 229 | ||
| 213 | 230 | // 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 ( | |||
| 23 | 23 | const ( | |
| 24 | 24 | // MaxFrame bounds one frame; longer lines are truncated. | |
| 25 | 25 | 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 | |
| 27 | 30 | ||
| 28 | 31 | minBackoff = 500 * time.Millisecond | |
| 29 | 32 | maxBackoff = 30 * time.Second | |
| @@ -56,8 +59,13 @@ type Module struct { | |||
| 56 | 59 | dirty bool | |
| 57 | 60 | failed bool // the script is not running; show an error marker | |
| 58 | 61 | ||
| 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 | |
| 61 | 69 | ||
| 62 | 70 | wake chan<- struct{} | |
| 63 | 71 | log *slog.Logger | |
| @@ -77,7 +85,7 @@ type event struct { | |||
| 77 | 85 | // New creates a module. wake receives a non-blocking signal after each frame. | |
| 78 | 86 | func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module { | |
| 79 | 87 | 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)} | |
| 81 | 89 | } | |
| 82 | 90 | ||
| 83 | 91 | // 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) { | |||
| 189 | 197 | ||
| 190 | 198 | func (m *Module) runOnce(ctx context.Context) error { | |
| 191 | 199 | m.gen.Add(1) | |
| 200 | + | m.overLogged = false // readFrames of the previous run has ended | |
| 192 | 201 | cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec) | |
| 193 | 202 | cmd.Stderr = &logWriter{log: m.log, name: m.Name} | |
| 194 | 203 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM} | |
| @@ -262,22 +271,79 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) { | |||
| 262 | 271 | } | |
| 263 | 272 | } | |
| 264 | 273 | ||
| 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. | |
| 266 | 331 | func (m *Module) readFrames(r io.Reader) { | |
| 267 | 332 | 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 | |
| 269 | 335 | for { | |
| 270 | 336 | n, err := r.Read(buf) | |
| 271 | 337 | chunk := buf[:n] | |
| 272 | 338 | for len(chunk) > 0 { | |
| 273 | 339 | i := bytes.IndexAny(chunk, "\n\f") | |
| 274 | 340 | if i < 0 { | |
| 275 | - | frame = appendCapped(frame, chunk) | |
| 341 | + | line, over = appendCapped(line, chunk, over) | |
| 276 | 342 | break | |
| 277 | 343 | } | |
| 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 | |
| 281 | 347 | chunk = chunk[i+1:] | |
| 282 | 348 | } | |
| 283 | 349 | if err != nil { | |
| @@ -286,11 +352,13 @@ func (m *Module) readFrames(r io.Reader) { | |||
| 286 | 352 | } | |
| 287 | 353 | } | |
| 288 | 354 | ||
| 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 | |
| 292 | 360 | } | |
| 293 | - | return append(dst, src...) | |
| 361 | + | return append(dst, src...), over | |
| 294 | 362 | } | |
| 295 | 363 | ||
| 296 | 364 | // logWriter forwards a script's stderr lines to the bar's log. | |
| @@ -325,6 +393,9 @@ func DrainForTest(m *Module) []string { | |||
| 325 | 393 | } | |
| 326 | 394 | } | |
| 327 | 395 | ||
| 396 | + | // ReadFramesForTest feeds r to the module as its script's stdout. | |
| 397 | + | func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) } | |
| 398 | + | ||
| 328 | 399 | // PublishForTest injects a frame as if the script had printed it. | |
| 329 | 400 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | |
| 330 | 401 | ||
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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 7 | + | "encoding/json" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "io" | |
| 8 | 10 | "log/slog" | |
| 11 | + | "slices" | |
| 9 | 12 | "strconv" | |
| 10 | 13 | "strings" | |
| 11 | 14 | "sync" | |
| @@ -22,6 +25,15 @@ const ( | |||
| 22 | 25 | maxInputLine = 256 | |
| 23 | 26 | // maxSteps bounds the steps of a scroll line. | |
| 24 | 27 | 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" | |
| 25 | 37 | ) | |
| 26 | 38 | ||
| 27 | 39 | // lockedWriter serializes writes from the main loop and the control loop to | |
| @@ -187,6 +199,23 @@ type control struct { | |||
| 187 | 199 | mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine | |
| 188 | 200 | conn *zerobus.Conn | |
| 189 | 201 | 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 | |
| 190 | 219 | } | |
| 191 | 220 | ||
| 192 | 221 | // run serves lines until ctx ends. | |
| @@ -194,10 +223,15 @@ func (c *control) run(ctx context.Context, lines <-chan string) { | |||
| 194 | 223 | stop := context.AfterFunc(ctx, c.close) | |
| 195 | 224 | defer stop() | |
| 196 | 225 | defer c.close() | |
| 226 | + | c.tipTimer = time.NewTimer(time.Hour) | |
| 227 | + | c.tipTimer.Stop() | |
| 228 | + | defer c.tipTimer.Stop() | |
| 197 | 229 | for { | |
| 198 | 230 | select { | |
| 199 | 231 | case <-ctx.Done(): | |
| 200 | 232 | return | |
| 233 | + | case <-c.tipTimer.C: | |
| 234 | + | c.showTooltip() | |
| 201 | 235 | case line := <-lines: | |
| 202 | 236 | cmd, ok := parseCommand(line) | |
| 203 | 237 | if !ok { | |
| @@ -260,7 +294,22 @@ func (c *control) drop(conn *zerobus.Conn) { | |||
| 260 | 294 | ||
| 261 | 295 | func (c *control) handle(cmd command) { | |
| 262 | 296 | 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() | |
| 263 | 311 | case "click": | |
| 312 | + | c.endHover() | |
| 264 | 313 | tg, ok := c.t.target(cmd.col) | |
| 265 | 314 | if !ok { | |
| 266 | 315 | return | |
| @@ -268,18 +317,22 @@ func (c *control) handle(cmd command) { | |||
| 268 | 317 | switch cmd.button { | |
| 269 | 318 | case btnLeft: | |
| 270 | 319 | if tg.IsMenu { | |
| 271 | - | c.contextMenu(tg) | |
| 320 | + | c.openMenu(tg, cmd.token) | |
| 272 | 321 | return | |
| 273 | 322 | } | |
| 274 | 323 | err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | |
| 275 | 324 | if isUnknownMethod(err) { | |
| 276 | - | c.contextMenu(tg) | |
| 325 | + | c.openMenu(tg, cmd.token) | |
| 277 | 326 | } | |
| 278 | 327 | case btnMiddle: | |
| 279 | 328 | c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | |
| 280 | 329 | case btnRight: | |
| 281 | - | c.contextMenu(tg) | |
| 330 | + | c.openMenu(tg, cmd.token) | |
| 282 | 331 | } | |
| 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) | |
| 283 | 336 | case "scroll": | |
| 284 | 337 | tg, ok := c.t.target(cmd.col) | |
| 285 | 338 | if !ok { | |
| @@ -293,6 +346,69 @@ func (c *control) handle(cmd command) { | |||
| 293 | 346 | } | |
| 294 | 347 | } | |
| 295 | 348 | ||
| 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 | + | ||
| 296 | 412 | // contextMenu asks the application to show its own menu. | |
| 297 | 413 | func (c *control) contextMenu(tg Target) { | |
| 298 | 414 | c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) }) | |
| @@ -307,6 +423,12 @@ func isUnknownMethod(err error) bool { | |||
| 307 | 423 | // reply is returned as is; a transport failure or a timeout drops the | |
| 308 | 424 | // connection and is logged. | |
| 309 | 425 | 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 { | |
| 310 | 432 | conn, err := c.conn2() | |
| 311 | 433 | if err != nil { | |
| 312 | 434 | if !errors.Is(err, zerobus.ErrClosed) { | |
| @@ -314,12 +436,23 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||
| 314 | 436 | } | |
| 315 | 437 | return err | |
| 316 | 438 | } | |
| 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 | + | } | |
| 320 | 451 | 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. | |
| 323 | 456 | c.log.Debug("tray: the application refused", "method", member, "err", err) | |
| 324 | 457 | return err | |
| 325 | 458 | } | |
| @@ -332,8 +465,133 @@ func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder | |||
| 332 | 465 | stopping := c.done | |
| 333 | 466 | c.mu.Unlock() | |
| 334 | 467 | 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) | |
| 336 | 469 | } | |
| 337 | 470 | } | |
| 338 | 471 | return err | |
| 339 | 472 | } | |
| 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 ("&", "A", "A"). | |
| 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 { | |||
| 11 | 11 | IsMenu bool // the item only offers a menu: Activate means "open it" | |
| 12 | 12 | Start int // first column of the icon in the line | |
| 13 | 13 | 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 | |
| 14 | 17 | } | |
| 15 | 18 | ||
| 16 | 19 | // 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 & Jerry": "Tom & Jerry", | |
| 18 | + | "<tag> "q" 's'": "<tag> \"q\" 's'", | |
| 19 | + | "ABC": "ABC", | |
| 20 | + | "���": "���", | |
| 21 | + | "&unknown; &": "&unknown; &", | |
| 22 | + | "1 < 2": "1 < 2", | |
| 23 | + | "<<b>x</b>": "x", | |
| 24 | + | "<img src='x>y'>z": "y'>z", | |
| 25 | + | "\u00e9é": "éé", | |
| 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>& 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 { | |||
| 76 | 76 | color RGB | |
| 77 | 77 | hasColor bool | |
| 78 | 78 | 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 | |
| 79 | 80 | isMenu bool // ItemIsMenu: Activate should open the menu | |
| 80 | 81 | loaded bool // GetAll answered at least once | |
| 81 | 82 | pending uint32 // serial of a GetAll in flight | |
| @@ -334,7 +335,7 @@ func (t *Tray) signal(m *zerobus.Message) { | |||
| 334 | 335 | } | |
| 335 | 336 | case itemIface: | |
| 336 | 337 | switch m.Member { | |
| 337 | - | case "NewToolTip", "NewMenu", "NewOverlayIcon": | |
| 338 | + | case "NewMenu", "NewOverlayIcon": | |
| 338 | 339 | return // nothing the tray shows | |
| 339 | 340 | } | |
| 340 | 341 | for _, it := range t.items { | |
| @@ -481,6 +482,7 @@ func (t *Tray) refresh(it *item) { | |||
| 481 | 482 | func (t *Tray) load(it *item, r *zerobus.Reader) { | |
| 482 | 483 | was := *it | |
| 483 | 484 | var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind | |
| 485 | + | tipSeen := false | |
| 484 | 486 | end := r.Array('{') | |
| 485 | 487 | for r.More(end) { | |
| 486 | 488 | r.Struct() | |
| @@ -495,6 +497,13 @@ func (t *Tray) load(it *item, r *zerobus.Reader) { | |||
| 495 | 497 | setStr(&it.iconName, r.Str()) | |
| 496 | 498 | case sig == "o" && key == "Menu": | |
| 497 | 499 | 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 | |
| 498 | 507 | case sig == "b" && key == "ItemIsMenu": | |
| 499 | 508 | it.isMenu = r.Bool() | |
| 500 | 509 | case sig == "s" && key == "Status": | |
| @@ -512,6 +521,9 @@ func (t *Tray) load(it *item, r *zerobus.Reader) { | |||
| 512 | 521 | *it = was | |
| 513 | 522 | return | |
| 514 | 523 | } | |
| 524 | + | if !tipSeen { | |
| 525 | + | it.tipTitle, it.tipBody = "", "" | |
| 526 | + | } | |
| 515 | 527 | it.loaded = true | |
| 516 | 528 | if it.status == attention && attentionPixmaps != nil { | |
| 517 | 529 | pixmaps = attentionPixmaps | |
| @@ -741,7 +753,12 @@ func (t *Tray) render(force bool) error { | |||
| 741 | 753 | for _, r := range it.icon { | |
| 742 | 754 | w += vt.RuneWidth(r) | |
| 743 | 755 | } | |
| 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}) | |
| 745 | 762 | col += w | |
| 746 | 763 | } | |
| 747 | 764 | f = append(f, '\n') | |
M internal/tray/tray_test.go +47 -1
| @@ -69,13 +69,21 @@ type fakeItem struct { | |||
| 69 | 69 | unknown string // the method it answers with UnknownMethod | |
| 70 | 70 | slow string // the method it answers only after slowFor | |
| 71 | 71 | 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 | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 74 | 82 | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | |
| 75 | 83 | c := dial(t, addr) | |
| 76 | 84 | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | |
| 77 | 85 | 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)} | |
| 79 | 87 | go f.serve() | |
| 80 | 88 | return f | |
| 81 | 89 | } | |
| @@ -106,6 +114,12 @@ func (f *fakeItem) serve() { | |||
| 106 | 114 | (<-f.cmds)() | |
| 107 | 115 | continue | |
| 108 | 116 | } | |
| 117 | + | if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface { | |
| 118 | + | if !f.menuCall(m) { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | continue | |
| 122 | + | } | |
| 109 | 123 | if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface { | |
| 110 | 124 | if !f.action(m) { | |
| 111 | 125 | return | |
| @@ -123,6 +137,29 @@ func (f *fakeItem) serve() { | |||
| 123 | 137 | e.Variant("s") | |
| 124 | 138 | e.Str(f.cur[i]) | |
| 125 | 139 | } | |
| 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 | + | } | |
| 126 | 163 | e.Struct() | |
| 127 | 164 | e.Str("IconPixmap") | |
| 128 | 165 | e.Variant("a(iiay)") | |
| @@ -175,6 +212,15 @@ func (f *fakeItem) register(t *testing.T) { | |||
| 175 | 212 | }) | |
| 176 | 213 | } | |
| 177 | 214 | ||
| 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 | + | ||
| 178 | 224 | // setStatus changes the item's status and emits NewStatus. | |
| 179 | 225 | func (f *fakeItem) setStatus(t *testing.T, status string) { | |
| 180 | 226 | f.run(t, func() { | |