detached a7668cd

Merge pull request #3: feat: interactive modules and tray clicks

commit
a7668cd14b57bbec623b03c2b7f4f26ece8ddea7
author
bnema · · 2026-10-09 04:11 UTC
merge of
e29d86a + 607bc4f · diff against the first parent
signature
unsigned
17 files · +1973 -15
M .github/workflows/ci.yml +7 -1
@@ -28,7 +28,13 @@ jobs:
2828 persist-credentials: false
2929 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
3030 - run: |
31- go install honnef.co/go/tools/cmd/staticcheck@latest
31+ # Build staticcheck against the latest x/tools so it can read the
32+ # export data of the Go release installed above.
33+ cd "$(mktemp -d)"
34+ go mod init tools
35+ go get -tool honnef.co/go/tools/cmd/staticcheck@latest
36+ go get golang.org/x/tools@latest
37+ go install tool
3238 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
3339 - run: make check
3440 - run: make race
@@ -28,7 +28,13 @@ jobs:
28 persist-credentials: false28 persist-credentials: false
29 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"29 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
30 - run: |30 - run: |
31- go install honnef.co/go/tools/cmd/staticcheck@latest31+ # Build staticcheck against the latest x/tools so it can read the
32+ # export data of the Go release installed above.
33+ cd "$(mktemp -d)"
34+ go mod init tools
35+ go get -tool honnef.co/go/tools/cmd/staticcheck@latest
36+ go get golang.org/x/tools@latest
37+ go install tool
32 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"38 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
33 - run: make check39 - run: make check
34 - run: make race40 - run: make race
M README.md +43 -1
@@ -189,6 +189,40 @@ done
189189
190190 neferbar draws at most as often as your monitor refreshes. If a script prints faster than that, only the latest frame is drawn. A script that prints nothing costs nothing.
191191
192+### Clicks and scrolling
193+
194+A module that sets `interactive = true` receives the pointer on its **standard input**, one line per event. Without the key, the module is click-through and its stdin is empty, as before.
195+
196+```toml
197+[[module]]
198+name = "volume"
199+zone = "right"
200+exec = "~/.config/neferbar/volume.sh"
201+interactive = true
202+```
203+
204+Only the cells the module shows take the pointer; the rest of the bar stays click-through. Columns count cells from the module's first cell, starting at 0.
205+
206+| Line | Sent when |
207+|---|---|
208+| `hover <col>` | the pointer moves to another cell of the module |
209+| `leave` | the pointer leaves the module |
210+| `click <left\|middle\|right> <col> <token>` | a button is pressed. `token` counts presses from 1 |
211+| `scroll <up\|down\|left\|right> <steps> <col>` | the wheel or touchpad scrolls. `steps` is at least 1 |
212+
213+Lines are ASCII and at most 127 bytes. A script that does not read its stdin loses the newest lines once 32 are waiting; it is never blocked or slowed down. A restarted script starts with an empty queue.
214+
215+```sh
216+#!/bin/sh
217+echo "vol"
218+while read -r event button col token; do
219+ case "$event $button" in
220+ "click left") pactl set-sink-mute @DEFAULT_SINK@ toggle ;;
221+ "scroll up") pactl set-sink-volume @DEFAULT_SINK@ +5% ;;
222+ esac
223+done
224+```
225+
192226 ### The bundled bar
193227
194228 `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.
@@ -268,6 +302,7 @@ A line is empty when no window has the focus. Control characters are removed fro
268302 name = "tray"
269303 zone = "right"
270304 exec = '"$NEFERBAR_BIN" tray'
305+interactive = true
271306 ```
272307
273308 Each icon is chosen without a list to maintain, from what the system already knows:
@@ -278,7 +313,14 @@ Each icon is chosen without a list to maintain, from what the system already kno
278313 4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279314 5. the first letter of its name, in a circle.
280315
281-The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
316+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. With `interactive = true`, the icons react to the pointer:
317+
318+- a left click calls the application's `Activate` (for an application that only offers a menu, it asks for the menu instead);
319+- a middle click calls `SecondaryActivate`;
320+- a right click calls `ContextMenu`, so the application shows its own menu;
321+- the wheel calls `Scroll`: down and right are positive.
322+
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.
282324
283325 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:
284326
@@ -189,6 +189,40 @@ done
189 189
190 neferbar draws at most as often as your monitor refreshes. If a script prints faster than that, only the latest frame is drawn. A script that prints nothing costs nothing.190 neferbar draws at most as often as your monitor refreshes. If a script prints faster than that, only the latest frame is drawn. A script that prints nothing costs nothing.
191 191
192+### Clicks and scrolling
193+
194+A module that sets `interactive = true` receives the pointer on its **standard input**, one line per event. Without the key, the module is click-through and its stdin is empty, as before.
195+
196+```toml
197+[[module]]
198+name = "volume"
199+zone = "right"
200+exec = "~/.config/neferbar/volume.sh"
201+interactive = true
202+```
203+
204+Only the cells the module shows take the pointer; the rest of the bar stays click-through. Columns count cells from the module's first cell, starting at 0.
205+
206+| Line | Sent when |
207+|---|---|
208+| `hover <col>` | the pointer moves to another cell of the module |
209+| `leave` | the pointer leaves the module |
210+| `click <left\|middle\|right> <col> <token>` | a button is pressed. `token` counts presses from 1 |
211+| `scroll <up\|down\|left\|right> <steps> <col>` | the wheel or touchpad scrolls. `steps` is at least 1 |
212+
213+Lines are ASCII and at most 127 bytes. A script that does not read its stdin loses the newest lines once 32 are waiting; it is never blocked or slowed down. A restarted script starts with an empty queue.
214+
215+```sh
216+#!/bin/sh
217+echo "vol"
218+while read -r event button col token; do
219+ case "$event $button" in
220+ "click left") pactl set-sink-mute @DEFAULT_SINK@ toggle ;;
221+ "scroll up") pactl set-sink-volume @DEFAULT_SINK@ +5% ;;
222+ esac
223+done
224+```
225+
192 ### The bundled bar226 ### The bundled bar
193 227
194 `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.228 `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.
@@ -268,6 +302,7 @@ A line is empty when no window has the focus. Control characters are removed fro
268 name = "tray"302 name = "tray"
269 zone = "right"303 zone = "right"
270 exec = '"$NEFERBAR_BIN" tray'304 exec = '"$NEFERBAR_BIN" tray'
305+interactive = true
271 ```306 ```
272 307
273 Each icon is chosen without a list to maintain, from what the system already knows:308 Each icon is chosen without a list to maintain, from what the system already knows:
@@ -278,7 +313,14 @@ Each icon is chosen without a list to maintain, from what the system already kno
278 4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;313 4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279 5. the first letter of its name, in a circle.314 5. the first letter of its name, in a circle.
280 315
281-The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.316+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. With `interactive = true`, the icons react to the pointer:
317+
318+- a left click calls the application's `Activate` (for an application that only offers a menu, it asks for the menu instead);
319+- a middle click calls `SecondaryActivate`;
320+- a right click calls `ContextMenu`, so the application shows its own menu;
321+- the wheel calls `Scroll`: down and right are positive.
322+
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.
282 324
283 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:325 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:
284 326
M cmd/neferbar/tray.go +2 -0
@@ -73,6 +73,8 @@ func runTray(ctx context.Context, args []string) error {
7373 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
7474 Out: os.Stdout,
7575 Log: log,
76+ In: os.Stdin,
77+ Dial: zerobus.SessionBus,
7678 }
7779 opt.Foreground, opt.Background, opt.Accent = trayColors(log)
7880
@@ -73,6 +73,8 @@ func runTray(ctx context.Context, args []string) error {
73 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),73 Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil),
74 Out: os.Stdout,74 Out: os.Stdout,
75 Log: log,75 Log: log,
76+ In: os.Stdin,
77+ Dial: zerobus.SessionBus,
76 }78 }
77 opt.Foreground, opt.Background, opt.Accent = trayColors(log)79 opt.Foreground, opt.Background, opt.Accent = trayColors(log)
78 80
M internal/bar/bar.go +70 -4
@@ -115,9 +115,13 @@ type Bar struct {
115115 // mappedW is the logical width the current layer surface was first drawn at.
116116 // It resets to 0 when the surface is recreated.
117117 mappedW int32
118- Stats Stats
119- fatal error
120- closed bool
118+
119+ ptr pointerState // pointer events turned into module lines
120+ input inputRegion
121+
122+ Stats Stats
123+ fatal error
124+ closed bool
121125 }
122126
123127 // New builds a bar from a validated config. cfgPath is the file the config came
@@ -311,6 +315,7 @@ func (b *Bar) syncModules(want []config.Module) {
311315 ctx, cancel := context.WithCancel(b.runCtx)
312316 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
313317 m.Env = b.env
318+ m.Interactive = w.Interactive
314319 m.Start(ctx)
315320 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
316321 }
@@ -324,6 +329,9 @@ func (b *Bar) syncModules(want []config.Module) {
324329 for _, r := range next {
325330 b.mods = append(b.mods, r.m)
326331 }
332+ if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
333+ b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
334+ }
327335 if b.lay != nil {
328336 b.lay.SetModules(b.mods)
329337 }
@@ -498,11 +506,20 @@ func (b *Bar) createSurface(h int32) error {
498506 }
499507 b.surf, b.sid = surf, surf.ID()
500508 b.mappedW = 0
509+ b.resetInput()
501510 b.fresh = true
502511 b.configured, b.canPresent = false, false
503512 return nil
504513 }
505514
515+// resetInput forgets the input state of the surface being replaced: the new
516+// surface starts click-through, and the module the pointer was over is told it
517+// left, since no leave will come from a surface that is gone.
518+func (b *Bar) resetInput() {
519+ b.input.reset()
520+ b.ptr.leave()
521+}
522+
506523 // step reparses changed modules and draws when something is ready.
507524 func (b *Bar) step() error {
508525 if b.lay != nil && b.lay.Update() {
@@ -524,6 +541,10 @@ func (b *Bar) draw() error {
524541 b.Stats.NoSlot++ // Released retries
525542 return nil
526543 }
544+ // The spans come from the row just drawn, and the input region follows
545+ // them only when the draw succeeded: between a layout change and the next
546+ // successful draw, the compositor may still route input by the older spans.
547+ b.syncInputRegion()
527548 if err = b.present(); err != nil {
528549 return err
529550 }
@@ -822,6 +843,52 @@ func (b *Bar) FDReady(id uint64) {
822843 }
823844 }
824845
846+// syncInputRegion makes the part of the bar that takes pointer input the cells
847+// of the interactive modules; the rest stays click-through.
848+func (b *Bar) syncInputRegion() {
849+ _, h, scale := b.surf.Size()
850+ rects, changed := b.input.update(b.lay, inputKey{cellW: b.face.CellW, scale: scale, height: h})
851+ if !changed {
852+ return
853+ }
854+ if err := b.surf.SetInputRegion(rects); err != nil {
855+ b.log.Warn("cannot set the input region", "err", err)
856+ b.input.reset() // the compositor kept the old one: the next draw tries again
857+ }
858+}
859+
860+// Pointer turns pointer events over the bar into lines for interactive modules.
861+func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
862+ if ev.Surface != b.sid || b.lay == nil || b.face == nil {
863+ return
864+ }
865+ _, _, scale := b.surf.Size()
866+ b.handlePointer(ev, scale, b.face.CellW)
867+}
868+
869+// handlePointer is Pointer for an event over the bar surface.
870+func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW int) {
871+ col := cellColumn(ev.X, scale, cellW)
872+ switch ev.Kind {
873+ case neferclient.PointerEnter, neferclient.PointerMotion:
874+ b.ptr.motion(b.lay, col)
875+ case neferclient.PointerLeave:
876+ b.ptr.leave()
877+ case neferclient.PointerButton:
878+ if ev.Pressed {
879+ b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
880+ }
881+ case neferclient.PointerAxis:
882+ delta := ev.DY
883+ if ev.Axis == neferclient.AxisHorizontal {
884+ delta = ev.DX
885+ }
886+ b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
887+ case neferclient.PointerAxisStop:
888+ b.ptr.wheel.stop(ev.Axis)
889+ }
890+}
891+
825892 // Error logs non-fatal connection errors.
826893 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
827894
@@ -830,7 +897,6 @@ func (b *Bar) OutputAdded(*neferclient.Output) {}
830897 func (b *Bar) OutputRemoved(uint32) {}
831898 func (b *Bar) Locked() {}
832899 func (b *Bar) LockFinished() {}
833-func (b *Bar) Pointer(*neferclient.PointerEvent) {}
834900 func (b *Bar) Key(*neferclient.KeyEvent) {}
835901 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
836902 func (b *Bar) SecretChanged(int) {}
@@ -115,9 +115,13 @@ type Bar struct {
115 // mappedW is the logical width the current layer surface was first drawn at.115 // mappedW is the logical width the current layer surface was first drawn at.
116 // It resets to 0 when the surface is recreated.116 // It resets to 0 when the surface is recreated.
117 mappedW int32117 mappedW int32
118- Stats Stats118+
119- fatal error119+ ptr pointerState // pointer events turned into module lines
120- closed bool120+ input inputRegion
121+
122+ Stats Stats
123+ fatal error
124+ closed bool
121 }125 }
122 126
123 // New builds a bar from a validated config. cfgPath is the file the config came127 // New builds a bar from a validated config. cfgPath is the file the config came
@@ -311,6 +315,7 @@ func (b *Bar) syncModules(want []config.Module) {
311 ctx, cancel := context.WithCancel(b.runCtx)315 ctx, cancel := context.WithCancel(b.runCtx)
312 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)316 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
313 m.Env = b.env317 m.Env = b.env
318+ m.Interactive = w.Interactive
314 m.Start(ctx)319 m.Start(ctx)
315 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})320 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
316 }321 }
@@ -324,6 +329,9 @@ func (b *Bar) syncModules(want []config.Module) {
324 for _, r := range next {329 for _, r := range next {
325 b.mods = append(b.mods, r.m)330 b.mods = append(b.mods, r.m)
326 }331 }
332+ if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
333+ b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
334+ }
327 if b.lay != nil {335 if b.lay != nil {
328 b.lay.SetModules(b.mods)336 b.lay.SetModules(b.mods)
329 }337 }
@@ -498,11 +506,20 @@ func (b *Bar) createSurface(h int32) error {
498 }506 }
499 b.surf, b.sid = surf, surf.ID()507 b.surf, b.sid = surf, surf.ID()
500 b.mappedW = 0508 b.mappedW = 0
509+ b.resetInput()
501 b.fresh = true510 b.fresh = true
502 b.configured, b.canPresent = false, false511 b.configured, b.canPresent = false, false
503 return nil512 return nil
504 }513 }
505 514
515+// resetInput forgets the input state of the surface being replaced: the new
516+// surface starts click-through, and the module the pointer was over is told it
517+// left, since no leave will come from a surface that is gone.
518+func (b *Bar) resetInput() {
519+ b.input.reset()
520+ b.ptr.leave()
521+}
522+
506 // step reparses changed modules and draws when something is ready.523 // step reparses changed modules and draws when something is ready.
507 func (b *Bar) step() error {524 func (b *Bar) step() error {
508 if b.lay != nil && b.lay.Update() {525 if b.lay != nil && b.lay.Update() {
@@ -524,6 +541,10 @@ func (b *Bar) draw() error {
524 b.Stats.NoSlot++ // Released retries541 b.Stats.NoSlot++ // Released retries
525 return nil542 return nil
526 }543 }
544+ // The spans come from the row just drawn, and the input region follows
545+ // them only when the draw succeeded: between a layout change and the next
546+ // successful draw, the compositor may still route input by the older spans.
547+ b.syncInputRegion()
527 if err = b.present(); err != nil {548 if err = b.present(); err != nil {
528 return err549 return err
529 }550 }
@@ -822,6 +843,52 @@ func (b *Bar) FDReady(id uint64) {
822 }843 }
823 }844 }
824 845
846+// syncInputRegion makes the part of the bar that takes pointer input the cells
847+// of the interactive modules; the rest stays click-through.
848+func (b *Bar) syncInputRegion() {
849+ _, h, scale := b.surf.Size()
850+ rects, changed := b.input.update(b.lay, inputKey{cellW: b.face.CellW, scale: scale, height: h})
851+ if !changed {
852+ return
853+ }
854+ if err := b.surf.SetInputRegion(rects); err != nil {
855+ b.log.Warn("cannot set the input region", "err", err)
856+ b.input.reset() // the compositor kept the old one: the next draw tries again
857+ }
858+}
859+
860+// Pointer turns pointer events over the bar into lines for interactive modules.
861+func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
862+ if ev.Surface != b.sid || b.lay == nil || b.face == nil {
863+ return
864+ }
865+ _, _, scale := b.surf.Size()
866+ b.handlePointer(ev, scale, b.face.CellW)
867+}
868+
869+// handlePointer is Pointer for an event over the bar surface.
870+func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW int) {
871+ col := cellColumn(ev.X, scale, cellW)
872+ switch ev.Kind {
873+ case neferclient.PointerEnter, neferclient.PointerMotion:
874+ b.ptr.motion(b.lay, col)
875+ case neferclient.PointerLeave:
876+ b.ptr.leave()
877+ case neferclient.PointerButton:
878+ if ev.Pressed {
879+ b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
880+ }
881+ case neferclient.PointerAxis:
882+ delta := ev.DY
883+ if ev.Axis == neferclient.AxisHorizontal {
884+ delta = ev.DX
885+ }
886+ b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
887+ case neferclient.PointerAxisStop:
888+ b.ptr.wheel.stop(ev.Axis)
889+ }
890+}
891+
825 // Error logs non-fatal connection errors.892 // Error logs non-fatal connection errors.
826 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }893 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
827 894
@@ -830,7 +897,6 @@ func (b *Bar) OutputAdded(*neferclient.Output) {}
830 func (b *Bar) OutputRemoved(uint32) {}897 func (b *Bar) OutputRemoved(uint32) {}
831 func (b *Bar) Locked() {}898 func (b *Bar) Locked() {}
832 func (b *Bar) LockFinished() {}899 func (b *Bar) LockFinished() {}
833-func (b *Bar) Pointer(*neferclient.PointerEvent) {}
834 func (b *Bar) Key(*neferclient.KeyEvent) {}900 func (b *Bar) Key(*neferclient.KeyEvent) {}
835 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}901 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
836 func (b *Bar) SecretChanged(int) {}902 func (b *Bar) SecretChanged(int) {}
A internal/bar/input.go +256 -0
@@ -0,0 +1,256 @@
1+package bar
2+
3+import (
4+ "math"
5+ "slices"
6+ "strconv"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+
11+ "github.com/bnema/neferbar/internal/layout"
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// Evdev button codes.
16+const (
17+ btnLeft = 0x110
18+ btnRight = 0x111
19+ btnMiddle = 0x112
20+)
21+
22+// pxPerStep is how far a continuous scroll (touchpad) moves for one step.
23+const pxPerStep = 15
24+
25+// press is the latest button press sent to a module.
26+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
29+ at time.Time
30+}
31+
32+// pointerState turns pointer events over the bar into the lines of the module
33+// protocol. It owns no Wayland object: the bar loop feeds it columns.
34+type pointerState struct {
35+ hover *module.Module // the interactive module under the pointer
36+ hoverOff int // its cell column
37+ hoverGen uint64 // hover.Gen() when the hover line was sent
38+ token uint32 // last click token
39+ press press
40+ wheel wheelAcc
41+ buf [64]byte // the line being built
42+}
43+
44+// send hands a line built in p.buf to m. A full queue drops the line: the
45+// script is not reading and pointer events are not worth waiting for.
46+func (p *pointerState) send(m *module.Module, line []byte) { m.Send(line) }
47+
48+// line starts a line in p.buf.
49+func (p *pointerState) line(verb string) []byte { return append(p.buf[:0], verb...) }
50+
51+func appendInt(b []byte, v int) []byte {
52+ b = append(b, ' ')
53+ return strconv.AppendInt(b, int64(v), 10)
54+}
55+
56+// forgetRestarted drops the hover of a module whose script restarted: the new
57+// process was never told about it, so it gets no leave, and the next motion
58+// sends it a hover.
59+func (p *pointerState) forgetRestarted() {
60+ if p.hover != nil && p.hover.Gen() != p.hoverGen {
61+ p.hover, p.hoverOff = nil, 0
62+ }
63+}
64+
65+// motion handles Enter and Motion at cell column col of the bar row.
66+func (p *pointerState) motion(lay *layout.Layout, col int) {
67+ p.forgetRestarted()
68+ m, off := interactiveAt(lay, col)
69+ if m == p.hover && (m == nil || off == p.hoverOff) {
70+ return
71+ }
72+ if p.hover != nil && m != p.hover {
73+ p.send(p.hover, p.line("leave"))
74+ }
75+ p.hover, p.hoverOff = m, off
76+ if m != nil {
77+ p.hoverGen = m.Gen()
78+ p.send(m, appendInt(p.line("hover"), off))
79+ }
80+}
81+
82+// leave handles the pointer leaving the bar.
83+func (p *pointerState) leave() {
84+ p.forgetRestarted()
85+ if p.hover != nil {
86+ p.send(p.hover, p.line("leave"))
87+ }
88+ p.hover, p.hoverOff = nil, 0
89+ p.wheel.reset()
90+}
91+
92+// button handles a button press at column col. serial is the press serial.
93+func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial uint32) {
94+ var name string
95+ switch code {
96+ case btnLeft:
97+ name = "left"
98+ case btnMiddle:
99+ name = "middle"
100+ case btnRight:
101+ name = "right"
102+ default:
103+ return
104+ }
105+ m, off := interactiveAt(lay, col)
106+ if m == nil {
107+ return
108+ }
109+ p.token++
110+ p.press = press{token: p.token, serial: serial, at: time.Now()}
111+ b := append(p.line("click "), name...)
112+ b = appendInt(b, off)
113+ b = append(b, ' ')
114+ b = strconv.AppendUint(b, uint64(p.token), 10)
115+ p.send(m, b)
116+}
117+
118+// axis handles one scroll event at column col.
119+func (p *pointerState) axis(lay *layout.Layout, col int, axis uint32, value120 int32, delta float64) {
120+ steps := p.wheel.add(axis, value120, delta)
121+ if steps == 0 {
122+ return
123+ }
124+ m, off := interactiveAt(lay, col)
125+ if m == nil {
126+ return
127+ }
128+ var dir string
129+ switch {
130+ case axis == neferclient.AxisVertical && steps > 0:
131+ dir = "down"
132+ case axis == neferclient.AxisVertical:
133+ dir = "up"
134+ case steps > 0:
135+ dir = "right"
136+ default:
137+ dir = "left"
138+ }
139+ if steps < 0 {
140+ steps = -steps
141+ }
142+ b := append(p.line("scroll "), dir...)
143+ b = appendInt(b, int(steps))
144+ b = appendInt(b, off)
145+ p.send(m, b)
146+}
147+
148+// interactiveAt is the interactive module at column col, and the column inside it.
149+func interactiveAt(lay *layout.Layout, col int) (*module.Module, int) {
150+ m, off, ok := lay.At(col)
151+ if !ok || !m.Interactive {
152+ return nil, 0
153+ }
154+ return m, off
155+}
156+
157+// wheelAcc counts scroll steps. A wheel reports notches in 1/120 units; a
158+// touchpad reports pixels. A compositor may send both for a wheel, so once a
159+// 1/120 value was seen the pixels are ignored until the pointer leaves.
160+type wheelAcc struct {
161+ wheel bool
162+ v120 [2]int32
163+ px [2]float64
164+}
165+
166+func (a *wheelAcc) reset() { *a = wheelAcc{} }
167+
168+// stop ends a touchpad scroll on axis: the pixels left over do not carry into
169+// the next gesture.
170+func (a *wheelAcc) stop(axis uint32) {
171+ if axis <= 1 {
172+ a.px[axis] = 0
173+ }
174+}
175+
176+// cellColumn is the cell column under logical x on a surface of the given
177+// scale, for cells cellW physical pixels wide.
178+func cellColumn(x, scale float64, cellW int) int {
179+ return int(math.Floor(x * scale / float64(cellW)))
180+}
181+
182+// add accumulates one event and returns the whole steps it completed:
183+// positive for down or right, negative for up or left.
184+func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
185+ if axis > 1 {
186+ return 0
187+ }
188+ if value120 != 0 {
189+ a.wheel = true
190+ a.v120[axis] += value120
191+ steps := a.v120[axis] / 120
192+ a.v120[axis] -= steps * 120
193+ return steps
194+ }
195+ if a.wheel {
196+ return 0
197+ }
198+ a.px[axis] += delta
199+ steps := int32(a.px[axis] / pxPerStep)
200+ a.px[axis] -= float64(steps) * pxPerStep
201+ return steps
202+}
203+
204+// inputRect is the logical-pixel rectangle of a span of cells: rounded
205+// outward, full surface height.
206+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}
211+}
212+
213+// inputKey is everything but the spans that the input rectangles depend on.
214+type inputKey struct {
215+ cellW int
216+ scale float64
217+ height int32
218+}
219+
220+// inputRegion tracks the spans the compositor was last told about.
221+type inputRegion struct {
222+ spans, last []layout.Span
223+ rects []neferclient.Rect
224+ key inputKey
225+}
226+
227+// reset forgets what the compositor knows: a new surface starts click-through.
228+func (r *inputRegion) reset() {
229+ r.last = r.last[:0]
230+ r.key = inputKey{}
231+}
232+
233+// update collects the spans of the interactive modules and reports whether the
234+// input rectangles differ from the last ones; if so rects holds the new ones
235+// (non-nil, possibly empty). It allocates only while the slices grow.
236+func (r *inputRegion) update(lay *layout.Layout, key inputKey) (rects []neferclient.Rect, changed bool) {
237+ r.spans = lay.Spans(r.spans[:0])
238+ r.spans = slices.DeleteFunc(r.spans, func(s layout.Span) bool { return !s.M.Interactive })
239+ if key == r.key && slices.Equal(r.spans, r.last) {
240+ return nil, false
241+ }
242+ if len(r.spans) == 0 && len(r.last) == 0 {
243+ r.key = key
244+ return nil, false
245+ }
246+ r.rects = r.rects[:0]
247+ for _, s := range r.spans {
248+ r.rects = append(r.rects, inputRect(s.Start, s.Width, key.cellW, key.scale, key.height))
249+ }
250+ if r.rects == nil {
251+ r.rects = []neferclient.Rect{}
252+ }
253+ r.last = append(r.last[:0], r.spans...)
254+ r.key = key
255+ return r.rects, true
256+}
@@ -0,0 +1,256 @@
1+package bar
2+
3+import (
4+ "math"
5+ "slices"
6+ "strconv"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+
11+ "github.com/bnema/neferbar/internal/layout"
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// Evdev button codes.
16+const (
17+ btnLeft = 0x110
18+ btnRight = 0x111
19+ btnMiddle = 0x112
20+)
21+
22+// pxPerStep is how far a continuous scroll (touchpad) moves for one step.
23+const pxPerStep = 15
24+
25+// press is the latest button press sent to a module.
26+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
29+ at time.Time
30+}
31+
32+// pointerState turns pointer events over the bar into the lines of the module
33+// protocol. It owns no Wayland object: the bar loop feeds it columns.
34+type pointerState struct {
35+ hover *module.Module // the interactive module under the pointer
36+ hoverOff int // its cell column
37+ hoverGen uint64 // hover.Gen() when the hover line was sent
38+ token uint32 // last click token
39+ press press
40+ wheel wheelAcc
41+ buf [64]byte // the line being built
42+}
43+
44+// send hands a line built in p.buf to m. A full queue drops the line: the
45+// script is not reading and pointer events are not worth waiting for.
46+func (p *pointerState) send(m *module.Module, line []byte) { m.Send(line) }
47+
48+// line starts a line in p.buf.
49+func (p *pointerState) line(verb string) []byte { return append(p.buf[:0], verb...) }
50+
51+func appendInt(b []byte, v int) []byte {
52+ b = append(b, ' ')
53+ return strconv.AppendInt(b, int64(v), 10)
54+}
55+
56+// forgetRestarted drops the hover of a module whose script restarted: the new
57+// process was never told about it, so it gets no leave, and the next motion
58+// sends it a hover.
59+func (p *pointerState) forgetRestarted() {
60+ if p.hover != nil && p.hover.Gen() != p.hoverGen {
61+ p.hover, p.hoverOff = nil, 0
62+ }
63+}
64+
65+// motion handles Enter and Motion at cell column col of the bar row.
66+func (p *pointerState) motion(lay *layout.Layout, col int) {
67+ p.forgetRestarted()
68+ m, off := interactiveAt(lay, col)
69+ if m == p.hover && (m == nil || off == p.hoverOff) {
70+ return
71+ }
72+ if p.hover != nil && m != p.hover {
73+ p.send(p.hover, p.line("leave"))
74+ }
75+ p.hover, p.hoverOff = m, off
76+ if m != nil {
77+ p.hoverGen = m.Gen()
78+ p.send(m, appendInt(p.line("hover"), off))
79+ }
80+}
81+
82+// leave handles the pointer leaving the bar.
83+func (p *pointerState) leave() {
84+ p.forgetRestarted()
85+ if p.hover != nil {
86+ p.send(p.hover, p.line("leave"))
87+ }
88+ p.hover, p.hoverOff = nil, 0
89+ p.wheel.reset()
90+}
91+
92+// button handles a button press at column col. serial is the press serial.
93+func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial uint32) {
94+ var name string
95+ switch code {
96+ case btnLeft:
97+ name = "left"
98+ case btnMiddle:
99+ name = "middle"
100+ case btnRight:
101+ name = "right"
102+ default:
103+ return
104+ }
105+ m, off := interactiveAt(lay, col)
106+ if m == nil {
107+ return
108+ }
109+ p.token++
110+ p.press = press{token: p.token, serial: serial, at: time.Now()}
111+ b := append(p.line("click "), name...)
112+ b = appendInt(b, off)
113+ b = append(b, ' ')
114+ b = strconv.AppendUint(b, uint64(p.token), 10)
115+ p.send(m, b)
116+}
117+
118+// axis handles one scroll event at column col.
119+func (p *pointerState) axis(lay *layout.Layout, col int, axis uint32, value120 int32, delta float64) {
120+ steps := p.wheel.add(axis, value120, delta)
121+ if steps == 0 {
122+ return
123+ }
124+ m, off := interactiveAt(lay, col)
125+ if m == nil {
126+ return
127+ }
128+ var dir string
129+ switch {
130+ case axis == neferclient.AxisVertical && steps > 0:
131+ dir = "down"
132+ case axis == neferclient.AxisVertical:
133+ dir = "up"
134+ case steps > 0:
135+ dir = "right"
136+ default:
137+ dir = "left"
138+ }
139+ if steps < 0 {
140+ steps = -steps
141+ }
142+ b := append(p.line("scroll "), dir...)
143+ b = appendInt(b, int(steps))
144+ b = appendInt(b, off)
145+ p.send(m, b)
146+}
147+
148+// interactiveAt is the interactive module at column col, and the column inside it.
149+func interactiveAt(lay *layout.Layout, col int) (*module.Module, int) {
150+ m, off, ok := lay.At(col)
151+ if !ok || !m.Interactive {
152+ return nil, 0
153+ }
154+ return m, off
155+}
156+
157+// wheelAcc counts scroll steps. A wheel reports notches in 1/120 units; a
158+// touchpad reports pixels. A compositor may send both for a wheel, so once a
159+// 1/120 value was seen the pixels are ignored until the pointer leaves.
160+type wheelAcc struct {
161+ wheel bool
162+ v120 [2]int32
163+ px [2]float64
164+}
165+
166+func (a *wheelAcc) reset() { *a = wheelAcc{} }
167+
168+// stop ends a touchpad scroll on axis: the pixels left over do not carry into
169+// the next gesture.
170+func (a *wheelAcc) stop(axis uint32) {
171+ if axis <= 1 {
172+ a.px[axis] = 0
173+ }
174+}
175+
176+// cellColumn is the cell column under logical x on a surface of the given
177+// scale, for cells cellW physical pixels wide.
178+func cellColumn(x, scale float64, cellW int) int {
179+ return int(math.Floor(x * scale / float64(cellW)))
180+}
181+
182+// add accumulates one event and returns the whole steps it completed:
183+// positive for down or right, negative for up or left.
184+func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
185+ if axis > 1 {
186+ return 0
187+ }
188+ if value120 != 0 {
189+ a.wheel = true
190+ a.v120[axis] += value120
191+ steps := a.v120[axis] / 120
192+ a.v120[axis] -= steps * 120
193+ return steps
194+ }
195+ if a.wheel {
196+ return 0
197+ }
198+ a.px[axis] += delta
199+ steps := int32(a.px[axis] / pxPerStep)
200+ a.px[axis] -= float64(steps) * pxPerStep
201+ return steps
202+}
203+
204+// inputRect is the logical-pixel rectangle of a span of cells: rounded
205+// outward, full surface height.
206+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}
211+}
212+
213+// inputKey is everything but the spans that the input rectangles depend on.
214+type inputKey struct {
215+ cellW int
216+ scale float64
217+ height int32
218+}
219+
220+// inputRegion tracks the spans the compositor was last told about.
221+type inputRegion struct {
222+ spans, last []layout.Span
223+ rects []neferclient.Rect
224+ key inputKey
225+}
226+
227+// reset forgets what the compositor knows: a new surface starts click-through.
228+func (r *inputRegion) reset() {
229+ r.last = r.last[:0]
230+ r.key = inputKey{}
231+}
232+
233+// update collects the spans of the interactive modules and reports whether the
234+// input rectangles differ from the last ones; if so rects holds the new ones
235+// (non-nil, possibly empty). It allocates only while the slices grow.
236+func (r *inputRegion) update(lay *layout.Layout, key inputKey) (rects []neferclient.Rect, changed bool) {
237+ r.spans = lay.Spans(r.spans[:0])
238+ r.spans = slices.DeleteFunc(r.spans, func(s layout.Span) bool { return !s.M.Interactive })
239+ if key == r.key && slices.Equal(r.spans, r.last) {
240+ return nil, false
241+ }
242+ if len(r.spans) == 0 && len(r.last) == 0 {
243+ r.key = key
244+ return nil, false
245+ }
246+ r.rects = r.rects[:0]
247+ for _, s := range r.spans {
248+ r.rects = append(r.rects, inputRect(s.Start, s.Width, key.cellW, key.scale, key.height))
249+ }
250+ if r.rects == nil {
251+ r.rects = []neferclient.Rect{}
252+ }
253+ r.last = append(r.last[:0], r.spans...)
254+ r.key = key
255+ return r.rects, true
256+}
A internal/bar/input_test.go +341 -0
@@ -0,0 +1,341 @@
1+package bar
2+
3+import (
4+ "log/slog"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferclient"
9+
10+ "github.com/bnema/neferbar/internal/layout"
11+ "github.com/bnema/neferbar/internal/module"
12+ "github.com/bnema/neferbar/internal/racecheck"
13+)
14+
15+// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16+// at 4-7, both in the left zone.
17+type inputRig struct {
18+ lay *layout.Layout
19+ a, b *module.Module
20+}
21+
22+func newInputRig(t testing.TB) *inputRig {
23+ t.Helper()
24+ wake := make(chan struct{}, 1)
25+ log := slog.New(slog.DiscardHandler)
26+ a := module.New("a", module.Left, "true", wake, log)
27+ b := module.New("b", module.Left, "true", wake, log)
28+ a.Interactive = true
29+ lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30+ module.PublishForTest(a, []byte("AAAA"))
31+ module.PublishForTest(b, []byte("BBBB"))
32+ lay.Update()
33+ lay.Compose()
34+ return &inputRig{lay: lay, a: a, b: b}
35+}
36+
37+// drain returns the lines queued on m and empties its queue.
38+func drain(m *module.Module) []string {
39+ return module.DrainForTest(m)
40+}
41+
42+func TestHoverAndLeave(t *testing.T) {
43+ r := newInputRig(t)
44+ var p pointerState
45+ p.motion(r.lay, 2)
46+ p.motion(r.lay, 2) // same column: nothing
47+ p.motion(r.lay, 3)
48+ p.motion(r.lay, 5) // over the non-interactive module: leaves a
49+ p.motion(r.lay, 6) // still nothing
50+ p.motion(r.lay, 1)
51+ p.leave()
52+ p.leave()
53+ want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54+ if got := drain(r.a); !slices.Equal(got, want) {
55+ t.Fatalf("lines = %q, want %q", got, want)
56+ }
57+ if got := drain(r.b); len(got) != 0 {
58+ t.Fatalf("a non-interactive module got %q", got)
59+ }
60+}
61+
62+func TestClickTokensAndButtons(t *testing.T) {
63+ r := newInputRig(t)
64+ var p pointerState
65+ p.button(r.lay, 1, btnLeft, 7)
66+ p.button(r.lay, 2, btnMiddle, 8)
67+ p.button(r.lay, 3, btnRight, 9)
68+ p.button(r.lay, 3, 0x113, 10) // another button: ignored
69+ p.button(r.lay, 5, btnLeft, 11)
70+ p.button(r.lay, 20, btnLeft, 12) // empty bar space
71+ want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72+ if got := drain(r.a); !slices.Equal(got, want) {
73+ t.Fatalf("lines = %q, want %q", got, want)
74+ }
75+ if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76+ t.Fatalf("press = %+v", p.press)
77+ }
78+}
79+
80+func TestScrollLines(t *testing.T) {
81+ r := newInputRig(t)
82+ var p pointerState
83+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84+ p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85+ p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86+ p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87+ p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88+ p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89+ want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90+ if got := drain(r.a); !slices.Equal(got, want) {
91+ t.Fatalf("lines = %q, want %q", got, want)
92+ }
93+}
94+
95+func TestWheelAccumulator(t *testing.T) {
96+ var a wheelAcc
97+ for _, c := range []struct {
98+ axis uint32
99+ v120 int32
100+ d float64
101+ want int32
102+ }{
103+ {neferclient.AxisVertical, 120, 0, 1},
104+ {neferclient.AxisVertical, 240, 0, 2},
105+ {neferclient.AxisVertical, -120, 0, -1},
106+ {neferclient.AxisVertical, 60, 0, 0},
107+ {neferclient.AxisVertical, 60, 0, 1}, // two half notches
108+ {neferclient.AxisHorizontal, 120, 0, 1},
109+ {neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110+ } {
111+ if got := a.add(c.axis, c.v120, c.d); got != c.want {
112+ t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113+ }
114+ }
115+ a.reset()
116+ for _, c := range []struct {
117+ d float64
118+ want int32
119+ }{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120+ if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121+ t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122+ }
123+ }
124+ if a.add(2, 120, 0) != 0 {
125+ t.Error("an unknown axis must be ignored")
126+ }
127+}
128+
129+func TestInputRect(t *testing.T) {
130+ // Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131+ // 16.25..40.625, rounded outward.
132+ got := inputRect(2, 3, 13, 1.6, 24)
133+ want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134+ if got != want {
135+ t.Fatalf("rect = %+v, want %+v", got, want)
136+ }
137+ if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138+ t.Fatalf("exact rect = %+v", got)
139+ }
140+ if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141+ t.Fatalf("scale 2 rect = %+v", got)
142+ }
143+}
144+
145+func TestInputRegionUpdates(t *testing.T) {
146+ r := newInputRig(t)
147+ var in inputRegion
148+ key := inputKey{cellW: 10, scale: 1, height: 20}
149+ rects, changed := in.update(r.lay, key)
150+ want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151+ if !changed || !slices.Equal(rects, want) {
152+ t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153+ }
154+ if _, changed = in.update(r.lay, key); changed {
155+ t.Fatal("same spans must not change the region")
156+ }
157+ if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158+ t.Fatal("a scale change must change the region")
159+ }
160+ in.reset()
161+ if _, changed = in.update(r.lay, key); !changed {
162+ t.Fatal("a new surface needs the region again")
163+ }
164+ // No interactive module left: back to an empty, non-nil region.
165+ r.a.Interactive = false
166+ rects, changed = in.update(r.lay, key)
167+ if !changed || rects == nil || len(rects) != 0 {
168+ t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169+ }
170+ if _, changed = in.update(r.lay, key); changed {
171+ t.Fatal("staying empty must not change the region")
172+ }
173+}
174+
175+func TestPointerAllocs(t *testing.T) {
176+ if racecheck.Enabled {
177+ t.Skip("the race detector allocates")
178+ }
179+ r := newInputRig(t)
180+ var p pointerState
181+ cols := [...]int{1, 2, 3, 5, 9}
182+ i := 0
183+ if got := testing.AllocsPerRun(200, func() {
184+ p.motion(r.lay, cols[i%len(cols)])
185+ i++
186+ }); got > 0 {
187+ t.Errorf("motion allocates %.1f objects; want 0", got)
188+ }
189+ if got := testing.AllocsPerRun(200, func() {
190+ p.button(r.lay, 1, btnLeft, 1)
191+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192+ p.leave()
193+ }); got > 0 {
194+ t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195+ }
196+ var in inputRegion
197+ key := inputKey{cellW: 10, scale: 1, height: 20}
198+ in.update(r.lay, key)
199+ if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200+ t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201+ }
202+}
203+
204+func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205+ r := newInputRig(t)
206+ b := &Bar{lay: r.lay}
207+ b.ptr.motion(r.lay, 2)
208+ b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209+ b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210+ drain(r.a)
211+
212+ b.resetInput()
213+
214+ if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215+ t.Fatalf("lines = %q, want one leave", got)
216+ }
217+ if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218+ t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219+ }
220+ if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221+ t.Fatal("the new surface needs its input region again")
222+ }
223+}
224+
225+// A failed SetInputRegion resets the tracking, so the next draw tries again.
226+func TestFailedInputRegionIsRetried(t *testing.T) {
227+ r := newInputRig(t)
228+ var in inputRegion
229+ key := inputKey{cellW: 10, scale: 1, height: 20}
230+ if _, changed := in.update(r.lay, key); !changed {
231+ t.Fatal("first update must change")
232+ }
233+ in.reset() // what syncInputRegion does when the request fails
234+ if _, changed := in.update(r.lay, key); !changed {
235+ t.Fatal("after a failure the same region must be sent again")
236+ }
237+}
238+
239+func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240+ r := newInputRig(t)
241+ b := &Bar{lay: r.lay, sid: 1}
242+ axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243+ b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244+ }
245+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246+ axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248+ if got := drain(r.a); len(got) != 0 {
249+ t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250+ }
251+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253+ t.Fatalf("lines = %q, want one step", got)
254+ }
255+ // Stopping the other axis leaves this one alone.
256+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257+ axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260+ t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261+ }
262+ var a wheelAcc
263+ a.stop(7) // unknown axis: no panic
264+}
265+
266+func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267+ r := newInputRig(t)
268+ var p pointerState
269+ p.motion(r.lay, 1)
270+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271+ t.Fatalf("lines = %q", got)
272+ }
273+ // The script restarts: the new process knows nothing of the hover.
274+ module.SetGenForTest(r.a, 7)
275+ p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276+ if got := drain(r.a); len(got) != 0 {
277+ t.Fatalf("lines = %q, want no leave for the new process", got)
278+ }
279+ // The same move onto the module again sends it a hover.
280+ p.motion(r.lay, 1)
281+ module.SetGenForTest(r.a, 8)
282+ p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284+ t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285+ }
286+
287+ // leave after a restart sends nothing to the new process.
288+ p.motion(r.lay, 2)
289+ drain(r.a)
290+ module.SetGenForTest(r.a, 9)
291+ p.leave()
292+ if got := drain(r.a); len(got) != 0 {
293+ t.Fatalf("lines = %q, want none", got)
294+ }
295+ if p.hover != nil {
296+ t.Fatal("hover not cleared")
297+ }
298+
299+ // Without a restart nothing changes.
300+ p.motion(r.lay, 2)
301+ p.motion(r.lay, 6)
302+ if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303+ t.Fatalf("lines = %q", got)
304+ }
305+ if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306+ t.Errorf("motion allocates %.1f", got)
307+ }
308+}
309+
310+func TestCellColumnAtFractionalScale(t *testing.T) {
311+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312+ for _, c := range []struct {
313+ x float64
314+ want int
315+ }{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316+ if got := cellColumn(c.x, 1.6, 13); got != c.want {
317+ t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318+ }
319+ }
320+}
321+
322+func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323+ r := newInputRig(t) // a: columns 0-3, b: 4-7
324+ b := &Bar{lay: r.lay, sid: 5}
325+ // Another surface (a popup): ignored before the bar's size is even read.
326+ b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327+ if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328+ t.Fatalf("a foreign surface moved the hover: %q", got)
329+ }
330+ // Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332+ // Logical x 33 is column 4: the non-interactive module, so a leave.
333+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337+ want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338+ if got := drain(r.a); !slices.Equal(got, want) {
339+ t.Fatalf("lines = %q, want %q", got, want)
340+ }
341+}
@@ -0,0 +1,341 @@
1+package bar
2+
3+import (
4+ "log/slog"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferclient"
9+
10+ "github.com/bnema/neferbar/internal/layout"
11+ "github.com/bnema/neferbar/internal/module"
12+ "github.com/bnema/neferbar/internal/racecheck"
13+)
14+
15+// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16+// at 4-7, both in the left zone.
17+type inputRig struct {
18+ lay *layout.Layout
19+ a, b *module.Module
20+}
21+
22+func newInputRig(t testing.TB) *inputRig {
23+ t.Helper()
24+ wake := make(chan struct{}, 1)
25+ log := slog.New(slog.DiscardHandler)
26+ a := module.New("a", module.Left, "true", wake, log)
27+ b := module.New("b", module.Left, "true", wake, log)
28+ a.Interactive = true
29+ lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30+ module.PublishForTest(a, []byte("AAAA"))
31+ module.PublishForTest(b, []byte("BBBB"))
32+ lay.Update()
33+ lay.Compose()
34+ return &inputRig{lay: lay, a: a, b: b}
35+}
36+
37+// drain returns the lines queued on m and empties its queue.
38+func drain(m *module.Module) []string {
39+ return module.DrainForTest(m)
40+}
41+
42+func TestHoverAndLeave(t *testing.T) {
43+ r := newInputRig(t)
44+ var p pointerState
45+ p.motion(r.lay, 2)
46+ p.motion(r.lay, 2) // same column: nothing
47+ p.motion(r.lay, 3)
48+ p.motion(r.lay, 5) // over the non-interactive module: leaves a
49+ p.motion(r.lay, 6) // still nothing
50+ p.motion(r.lay, 1)
51+ p.leave()
52+ p.leave()
53+ want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54+ if got := drain(r.a); !slices.Equal(got, want) {
55+ t.Fatalf("lines = %q, want %q", got, want)
56+ }
57+ if got := drain(r.b); len(got) != 0 {
58+ t.Fatalf("a non-interactive module got %q", got)
59+ }
60+}
61+
62+func TestClickTokensAndButtons(t *testing.T) {
63+ r := newInputRig(t)
64+ var p pointerState
65+ p.button(r.lay, 1, btnLeft, 7)
66+ p.button(r.lay, 2, btnMiddle, 8)
67+ p.button(r.lay, 3, btnRight, 9)
68+ p.button(r.lay, 3, 0x113, 10) // another button: ignored
69+ p.button(r.lay, 5, btnLeft, 11)
70+ p.button(r.lay, 20, btnLeft, 12) // empty bar space
71+ want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72+ if got := drain(r.a); !slices.Equal(got, want) {
73+ t.Fatalf("lines = %q, want %q", got, want)
74+ }
75+ if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76+ t.Fatalf("press = %+v", p.press)
77+ }
78+}
79+
80+func TestScrollLines(t *testing.T) {
81+ r := newInputRig(t)
82+ var p pointerState
83+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84+ p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85+ p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86+ p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87+ p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88+ p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89+ want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90+ if got := drain(r.a); !slices.Equal(got, want) {
91+ t.Fatalf("lines = %q, want %q", got, want)
92+ }
93+}
94+
95+func TestWheelAccumulator(t *testing.T) {
96+ var a wheelAcc
97+ for _, c := range []struct {
98+ axis uint32
99+ v120 int32
100+ d float64
101+ want int32
102+ }{
103+ {neferclient.AxisVertical, 120, 0, 1},
104+ {neferclient.AxisVertical, 240, 0, 2},
105+ {neferclient.AxisVertical, -120, 0, -1},
106+ {neferclient.AxisVertical, 60, 0, 0},
107+ {neferclient.AxisVertical, 60, 0, 1}, // two half notches
108+ {neferclient.AxisHorizontal, 120, 0, 1},
109+ {neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110+ } {
111+ if got := a.add(c.axis, c.v120, c.d); got != c.want {
112+ t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113+ }
114+ }
115+ a.reset()
116+ for _, c := range []struct {
117+ d float64
118+ want int32
119+ }{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120+ if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121+ t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122+ }
123+ }
124+ if a.add(2, 120, 0) != 0 {
125+ t.Error("an unknown axis must be ignored")
126+ }
127+}
128+
129+func TestInputRect(t *testing.T) {
130+ // Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131+ // 16.25..40.625, rounded outward.
132+ got := inputRect(2, 3, 13, 1.6, 24)
133+ want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134+ if got != want {
135+ t.Fatalf("rect = %+v, want %+v", got, want)
136+ }
137+ if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138+ t.Fatalf("exact rect = %+v", got)
139+ }
140+ if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141+ t.Fatalf("scale 2 rect = %+v", got)
142+ }
143+}
144+
145+func TestInputRegionUpdates(t *testing.T) {
146+ r := newInputRig(t)
147+ var in inputRegion
148+ key := inputKey{cellW: 10, scale: 1, height: 20}
149+ rects, changed := in.update(r.lay, key)
150+ want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151+ if !changed || !slices.Equal(rects, want) {
152+ t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153+ }
154+ if _, changed = in.update(r.lay, key); changed {
155+ t.Fatal("same spans must not change the region")
156+ }
157+ if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158+ t.Fatal("a scale change must change the region")
159+ }
160+ in.reset()
161+ if _, changed = in.update(r.lay, key); !changed {
162+ t.Fatal("a new surface needs the region again")
163+ }
164+ // No interactive module left: back to an empty, non-nil region.
165+ r.a.Interactive = false
166+ rects, changed = in.update(r.lay, key)
167+ if !changed || rects == nil || len(rects) != 0 {
168+ t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169+ }
170+ if _, changed = in.update(r.lay, key); changed {
171+ t.Fatal("staying empty must not change the region")
172+ }
173+}
174+
175+func TestPointerAllocs(t *testing.T) {
176+ if racecheck.Enabled {
177+ t.Skip("the race detector allocates")
178+ }
179+ r := newInputRig(t)
180+ var p pointerState
181+ cols := [...]int{1, 2, 3, 5, 9}
182+ i := 0
183+ if got := testing.AllocsPerRun(200, func() {
184+ p.motion(r.lay, cols[i%len(cols)])
185+ i++
186+ }); got > 0 {
187+ t.Errorf("motion allocates %.1f objects; want 0", got)
188+ }
189+ if got := testing.AllocsPerRun(200, func() {
190+ p.button(r.lay, 1, btnLeft, 1)
191+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192+ p.leave()
193+ }); got > 0 {
194+ t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195+ }
196+ var in inputRegion
197+ key := inputKey{cellW: 10, scale: 1, height: 20}
198+ in.update(r.lay, key)
199+ if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200+ t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201+ }
202+}
203+
204+func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205+ r := newInputRig(t)
206+ b := &Bar{lay: r.lay}
207+ b.ptr.motion(r.lay, 2)
208+ b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209+ b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210+ drain(r.a)
211+
212+ b.resetInput()
213+
214+ if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215+ t.Fatalf("lines = %q, want one leave", got)
216+ }
217+ if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218+ t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219+ }
220+ if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221+ t.Fatal("the new surface needs its input region again")
222+ }
223+}
224+
225+// A failed SetInputRegion resets the tracking, so the next draw tries again.
226+func TestFailedInputRegionIsRetried(t *testing.T) {
227+ r := newInputRig(t)
228+ var in inputRegion
229+ key := inputKey{cellW: 10, scale: 1, height: 20}
230+ if _, changed := in.update(r.lay, key); !changed {
231+ t.Fatal("first update must change")
232+ }
233+ in.reset() // what syncInputRegion does when the request fails
234+ if _, changed := in.update(r.lay, key); !changed {
235+ t.Fatal("after a failure the same region must be sent again")
236+ }
237+}
238+
239+func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240+ r := newInputRig(t)
241+ b := &Bar{lay: r.lay, sid: 1}
242+ axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243+ b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244+ }
245+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246+ axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248+ if got := drain(r.a); len(got) != 0 {
249+ t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250+ }
251+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253+ t.Fatalf("lines = %q, want one step", got)
254+ }
255+ // Stopping the other axis leaves this one alone.
256+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257+ axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260+ t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261+ }
262+ var a wheelAcc
263+ a.stop(7) // unknown axis: no panic
264+}
265+
266+func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267+ r := newInputRig(t)
268+ var p pointerState
269+ p.motion(r.lay, 1)
270+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271+ t.Fatalf("lines = %q", got)
272+ }
273+ // The script restarts: the new process knows nothing of the hover.
274+ module.SetGenForTest(r.a, 7)
275+ p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276+ if got := drain(r.a); len(got) != 0 {
277+ t.Fatalf("lines = %q, want no leave for the new process", got)
278+ }
279+ // The same move onto the module again sends it a hover.
280+ p.motion(r.lay, 1)
281+ module.SetGenForTest(r.a, 8)
282+ p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284+ t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285+ }
286+
287+ // leave after a restart sends nothing to the new process.
288+ p.motion(r.lay, 2)
289+ drain(r.a)
290+ module.SetGenForTest(r.a, 9)
291+ p.leave()
292+ if got := drain(r.a); len(got) != 0 {
293+ t.Fatalf("lines = %q, want none", got)
294+ }
295+ if p.hover != nil {
296+ t.Fatal("hover not cleared")
297+ }
298+
299+ // Without a restart nothing changes.
300+ p.motion(r.lay, 2)
301+ p.motion(r.lay, 6)
302+ if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303+ t.Fatalf("lines = %q", got)
304+ }
305+ if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306+ t.Errorf("motion allocates %.1f", got)
307+ }
308+}
309+
310+func TestCellColumnAtFractionalScale(t *testing.T) {
311+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312+ for _, c := range []struct {
313+ x float64
314+ want int
315+ }{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316+ if got := cellColumn(c.x, 1.6, 13); got != c.want {
317+ t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318+ }
319+ }
320+}
321+
322+func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323+ r := newInputRig(t) // a: columns 0-3, b: 4-7
324+ b := &Bar{lay: r.lay, sid: 5}
325+ // Another surface (a popup): ignored before the bar's size is even read.
326+ b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327+ if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328+ t.Fatalf("a foreign surface moved the hover: %q", got)
329+ }
330+ // Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332+ // Logical x 33 is column 4: the non-interactive module, so a leave.
333+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337+ want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338+ if got := drain(r.a); !slices.Equal(got, want) {
339+ t.Fatalf("lines = %q, want %q", got, want)
340+ }
341+}
M internal/config/config.go +2 -0
@@ -37,6 +37,8 @@ type Module struct {
3737 Name string `toml:"name"`
3838 Zone string `toml:"zone"` // left | center | right
3939 Exec string `toml:"exec"`
40+ // Interactive: the module receives pointer events on stdin; see README.
41+ Interactive bool `toml:"interactive"`
4042 }
4143
4244 // Tray configures "neferbar tray".
@@ -37,6 +37,8 @@ type Module struct {
37 Name string `toml:"name"`37 Name string `toml:"name"`
38 Zone string `toml:"zone"` // left | center | right38 Zone string `toml:"zone"` // left | center | right
39 Exec string `toml:"exec"`39 Exec string `toml:"exec"`
40+ // Interactive: the module receives pointer events on stdin; see README.
41+ Interactive bool `toml:"interactive"`
40 }42 }
41 43
42 // Tray configures "neferbar tray".44 // Tray configures "neferbar tray".
M internal/config/config_test.go +10 -0
@@ -84,3 +84,13 @@ func TestPositionDefaultsToTop(t *testing.T) {
8484 }
8585 }
8686 }
87+
88+func TestInteractiveKey(t *testing.T) {
89+ c, err := Load(write(t, "[[module]]\nzone = \"left\"\nexec = \"a\"\ninteractive = true\n[[module]]\nzone = \"left\"\nexec = \"b\"\n"), true)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if !c.Module[0].Interactive || c.Module[1].Interactive {
94+ t.Fatalf("interactive = %v, %v; want true, false", c.Module[0].Interactive, c.Module[1].Interactive)
95+ }
96+}
@@ -84,3 +84,13 @@ func TestPositionDefaultsToTop(t *testing.T) {
84 }84 }
85 }85 }
86 }86 }
87+
88+func TestInteractiveKey(t *testing.T) {
89+ c, err := Load(write(t, "[[module]]\nzone = \"left\"\nexec = \"a\"\ninteractive = true\n[[module]]\nzone = \"left\"\nexec = \"b\"\n"), true)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if !c.Module[0].Interactive || c.Module[1].Interactive {
94+ t.Fatalf("interactive = %v, %v; want true, false", c.Module[0].Interactive, c.Module[1].Interactive)
95+ }
96+}
M internal/layout/layout.go +42 -3
@@ -18,6 +18,16 @@ type Source struct {
1818 input []byte // reset + erase + frame, reused for each Write
1919 cells []gpu.Cell
2020 failed bool
21+
22+ // start and shown are where Compose put the module's cells in the row, and
23+ // how many of them survived clipping.
24+ start, shown int
25+}
26+
27+// Span is the part of the row a module occupies after Compose.
28+type Span struct {
29+ M *module.Module
30+ Start, Width int
2131 }
2232
2333 // Layout owns the sources and the composed row.
@@ -208,6 +218,7 @@ func (l *Layout) Compose() []gpu.Cell {
208218 }
209219 var left, center, right int
210220 for _, s := range l.sources {
221+ s.start, s.shown = 0, 0
211222 n := len(s.cells)
212223 switch s.M.Zone {
213224 case module.Left:
@@ -224,14 +235,18 @@ func (l *Layout) Compose() []gpu.Cell {
224235 x := l.cols - right
225236 for _, s := range l.sources {
226237 if s.M.Zone == module.Right {
227- x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
238+ s.start = x
239+ s.shown = copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
240+ x += s.shown
228241 }
229242 }
230243 // Left zone from the left edge, clipped to its budget.
231244 x, limit := 0, left
232245 for _, s := range l.sources {
233246 if s.M.Zone == module.Left && x < limit {
234- x += copy(l.row[x:limit], s.cells)
247+ s.start = x
248+ s.shown = copy(l.row[x:limit], s.cells)
249+ x += s.shown
235250 }
236251 }
237252 // Center zone: on the middle of the bar, and only as far from it as the
@@ -245,9 +260,33 @@ func (l *Layout) Compose() []gpu.Cell {
245260 x, limit = start, start+center
246261 for _, s := range l.sources {
247262 if s.M.Zone == module.Center && x < limit {
248- x += copy(l.row[x:limit], s.cells)
263+ s.start = x
264+ s.shown = copy(l.row[x:limit], s.cells)
265+ x += s.shown
249266 }
250267 }
251268 }
252269 return l.row
253270 }
271+
272+// Spans appends the span of every module that has cells in the row composed
273+// last, in module order.
274+func (l *Layout) Spans(dst []Span) []Span {
275+ for _, s := range l.sources {
276+ if s.shown > 0 {
277+ dst = append(dst, Span{M: s.M, Start: s.start, Width: s.shown})
278+ }
279+ }
280+ return dst
281+}
282+
283+// At returns the module shown at column col of the row composed last, and the
284+// column's offset inside the module.
285+func (l *Layout) At(col int) (m *module.Module, offset int, ok bool) {
286+ for _, s := range l.sources {
287+ if col >= s.start && col < s.start+s.shown {
288+ return s.M, col - s.start, true
289+ }
290+ }
291+ return nil, 0, false
292+}
@@ -18,6 +18,16 @@ type Source struct {
18 input []byte // reset + erase + frame, reused for each Write18 input []byte // reset + erase + frame, reused for each Write
19 cells []gpu.Cell19 cells []gpu.Cell
20 failed bool20 failed bool
21+
22+ // start and shown are where Compose put the module's cells in the row, and
23+ // how many of them survived clipping.
24+ start, shown int
25+}
26+
27+// Span is the part of the row a module occupies after Compose.
28+type Span struct {
29+ M *module.Module
30+ Start, Width int
21 }31 }
22 32
23 // Layout owns the sources and the composed row.33 // Layout owns the sources and the composed row.
@@ -208,6 +218,7 @@ func (l *Layout) Compose() []gpu.Cell {
208 }218 }
209 var left, center, right int219 var left, center, right int
210 for _, s := range l.sources {220 for _, s := range l.sources {
221+ s.start, s.shown = 0, 0
211 n := len(s.cells)222 n := len(s.cells)
212 switch s.M.Zone {223 switch s.M.Zone {
213 case module.Left:224 case module.Left:
@@ -224,14 +235,18 @@ func (l *Layout) Compose() []gpu.Cell {
224 x := l.cols - right235 x := l.cols - right
225 for _, s := range l.sources {236 for _, s := range l.sources {
226 if s.M.Zone == module.Right {237 if s.M.Zone == module.Right {
227- x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])238+ s.start = x
239+ s.shown = copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
240+ x += s.shown
228 }241 }
229 }242 }
230 // Left zone from the left edge, clipped to its budget.243 // Left zone from the left edge, clipped to its budget.
231 x, limit := 0, left244 x, limit := 0, left
232 for _, s := range l.sources {245 for _, s := range l.sources {
233 if s.M.Zone == module.Left && x < limit {246 if s.M.Zone == module.Left && x < limit {
234- x += copy(l.row[x:limit], s.cells)247+ s.start = x
248+ s.shown = copy(l.row[x:limit], s.cells)
249+ x += s.shown
235 }250 }
236 }251 }
237 // Center zone: on the middle of the bar, and only as far from it as the252 // Center zone: on the middle of the bar, and only as far from it as the
@@ -245,9 +260,33 @@ func (l *Layout) Compose() []gpu.Cell {
245 x, limit = start, start+center260 x, limit = start, start+center
246 for _, s := range l.sources {261 for _, s := range l.sources {
247 if s.M.Zone == module.Center && x < limit {262 if s.M.Zone == module.Center && x < limit {
248- x += copy(l.row[x:limit], s.cells)263+ s.start = x
264+ s.shown = copy(l.row[x:limit], s.cells)
265+ x += s.shown
249 }266 }
250 }267 }
251 }268 }
252 return l.row269 return l.row
253 }270 }
271+
272+// Spans appends the span of every module that has cells in the row composed
273+// last, in module order.
274+func (l *Layout) Spans(dst []Span) []Span {
275+ for _, s := range l.sources {
276+ if s.shown > 0 {
277+ dst = append(dst, Span{M: s.M, Start: s.start, Width: s.shown})
278+ }
279+ }
280+ return dst
281+}
282+
283+// At returns the module shown at column col of the row composed last, and the
284+// column's offset inside the module.
285+func (l *Layout) At(col int) (m *module.Module, offset int, ok bool) {
286+ for _, s := range l.sources {
287+ if col >= s.start && col < s.start+s.shown {
288+ return s.M, col - s.start, true
289+ }
290+ }
291+ return nil, 0, false
292+}
M internal/layout/layout_test.go +150 -0
@@ -221,3 +221,153 @@ func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
221221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222222 }
223223 }
224+
225+func spanOf(spans []Span, m *module.Module) (Span, bool) {
226+ for _, s := range spans {
227+ if s.M == m {
228+ return s, true
229+ }
230+ }
231+ return Span{}, false
232+}
233+
234+func TestSpansPerZone(t *testing.T) {
235+ l, m := newTestLayout(20)
236+ publish(m[0], "LEFT")
237+ publish(m[1], "mid")
238+ publish(m[2], "RIGHT")
239+ l.Update()
240+ row := rowText(l)
241+ spans := l.Spans(nil)
242+ if len(spans) != 3 {
243+ t.Fatalf("spans = %+v, want 3", spans)
244+ }
245+ for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246+ s, ok := spanOf(spans, m[i])
247+ if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248+ t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249+ }
250+ }
251+ // Spans come in module order.
252+ if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253+ t.Errorf("spans out of order: %+v", spans)
254+ }
255+}
256+
257+func TestSpansTwoModulesInOneZone(t *testing.T) {
258+ wake := make(chan struct{}, 1)
259+ log := slog.New(slog.DiscardHandler)
260+ a := module.New("a", module.Left, "true", wake, log)
261+ b := module.New("b", module.Left, "true", wake, log)
262+ l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263+ publish(a, "AA")
264+ publish(b, "BBB")
265+ l.Update()
266+ l.Compose()
267+ sa, _ := spanOf(l.Spans(nil), a)
268+ sb, _ := spanOf(l.Spans(nil), b)
269+ if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270+ t.Fatalf("spans = %+v %+v", sa, sb)
271+ }
272+ for col, want := range map[int]struct {
273+ m *module.Module
274+ off int
275+ ok bool
276+ }{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277+ gm, off, ok := l.At(col)
278+ if gm != want.m || off != want.off || ok != want.ok {
279+ t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280+ }
281+ }
282+}
283+
284+func TestSpansAreClipped(t *testing.T) {
285+ // The right zone wins over the left one: left is cut to the room left.
286+ l, m := newTestLayout(8)
287+ publish(m[0], "AAAAA")
288+ publish(m[2], "ZZZZZ")
289+ l.Update()
290+ l.Compose()
291+ spans := l.Spans(nil)
292+ if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293+ t.Errorf("left span = %+v, want {0,3}", s)
294+ }
295+ if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296+ t.Errorf("right span = %+v, want {3,5}", s)
297+ }
298+ if _, _, ok := l.At(3); !ok {
299+ t.Error("column 3 belongs to the right module")
300+ }
301+ if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302+ t.Errorf("At(3) = %v, %d", gm, off)
303+ }
304+
305+ // The center is cut first, and may vanish entirely.
306+ l, m = newTestLayout(12)
307+ publish(m[0], "AAAAA")
308+ publish(m[1], "CCCCCCCC")
309+ publish(m[2], "ZZZZZ")
310+ l.Update()
311+ l.Compose()
312+ if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313+ t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314+ }
315+ l, m = newTestLayout(10)
316+ publish(m[0], "AAAAA")
317+ publish(m[1], "CC")
318+ publish(m[2], "ZZZZZ")
319+ l.Update()
320+ l.Compose()
321+ if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322+ t.Error("a fully clipped module must have no span")
323+ }
324+ if _, _, ok := l.At(5); !ok {
325+ // column 5 is the first of the right zone
326+ t.Error("At(5) must find the right module")
327+ }
328+}
329+
330+func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331+ l, m := newTestLayout(10)
332+ publish(m[0], "")
333+ publish(m[2], "R")
334+ l.Update()
335+ l.Compose()
336+ if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337+ t.Error("an empty module has no span")
338+ }
339+ if len(l.Spans(nil)) != 1 {
340+ t.Errorf("spans = %+v", l.Spans(nil))
341+ }
342+ // A module that empties out loses its span at the next Compose.
343+ publish(m[2], "")
344+ l.Update()
345+ l.Compose()
346+ if got := l.Spans(nil); len(got) != 0 {
347+ t.Errorf("spans after emptying = %+v", got)
348+ }
349+ if _, _, ok := l.At(9); ok {
350+ t.Error("At must not find an emptied module")
351+ }
352+}
353+
354+func TestComposeAndSpansAllocs(t *testing.T) {
355+ if racecheck.Enabled {
356+ t.Skip("the race detector allocates")
357+ }
358+ l, m := newTestLayout(40)
359+ publish(m[0], "left")
360+ publish(m[1], "center")
361+ publish(m[2], "right")
362+ l.Update()
363+ if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364+ t.Errorf("Compose allocates %.1f objects; want 0", got)
365+ }
366+ buf := make([]Span, 0, 8)
367+ if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368+ t.Errorf("Spans allocates %.1f objects; want 0", got)
369+ }
370+ if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371+ t.Errorf("At allocates %.1f objects; want 0", got)
372+ }
373+}
@@ -221,3 +221,153 @@ func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222 }222 }
223 }223 }
224+
225+func spanOf(spans []Span, m *module.Module) (Span, bool) {
226+ for _, s := range spans {
227+ if s.M == m {
228+ return s, true
229+ }
230+ }
231+ return Span{}, false
232+}
233+
234+func TestSpansPerZone(t *testing.T) {
235+ l, m := newTestLayout(20)
236+ publish(m[0], "LEFT")
237+ publish(m[1], "mid")
238+ publish(m[2], "RIGHT")
239+ l.Update()
240+ row := rowText(l)
241+ spans := l.Spans(nil)
242+ if len(spans) != 3 {
243+ t.Fatalf("spans = %+v, want 3", spans)
244+ }
245+ for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246+ s, ok := spanOf(spans, m[i])
247+ if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248+ t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249+ }
250+ }
251+ // Spans come in module order.
252+ if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253+ t.Errorf("spans out of order: %+v", spans)
254+ }
255+}
256+
257+func TestSpansTwoModulesInOneZone(t *testing.T) {
258+ wake := make(chan struct{}, 1)
259+ log := slog.New(slog.DiscardHandler)
260+ a := module.New("a", module.Left, "true", wake, log)
261+ b := module.New("b", module.Left, "true", wake, log)
262+ l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263+ publish(a, "AA")
264+ publish(b, "BBB")
265+ l.Update()
266+ l.Compose()
267+ sa, _ := spanOf(l.Spans(nil), a)
268+ sb, _ := spanOf(l.Spans(nil), b)
269+ if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270+ t.Fatalf("spans = %+v %+v", sa, sb)
271+ }
272+ for col, want := range map[int]struct {
273+ m *module.Module
274+ off int
275+ ok bool
276+ }{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277+ gm, off, ok := l.At(col)
278+ if gm != want.m || off != want.off || ok != want.ok {
279+ t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280+ }
281+ }
282+}
283+
284+func TestSpansAreClipped(t *testing.T) {
285+ // The right zone wins over the left one: left is cut to the room left.
286+ l, m := newTestLayout(8)
287+ publish(m[0], "AAAAA")
288+ publish(m[2], "ZZZZZ")
289+ l.Update()
290+ l.Compose()
291+ spans := l.Spans(nil)
292+ if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293+ t.Errorf("left span = %+v, want {0,3}", s)
294+ }
295+ if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296+ t.Errorf("right span = %+v, want {3,5}", s)
297+ }
298+ if _, _, ok := l.At(3); !ok {
299+ t.Error("column 3 belongs to the right module")
300+ }
301+ if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302+ t.Errorf("At(3) = %v, %d", gm, off)
303+ }
304+
305+ // The center is cut first, and may vanish entirely.
306+ l, m = newTestLayout(12)
307+ publish(m[0], "AAAAA")
308+ publish(m[1], "CCCCCCCC")
309+ publish(m[2], "ZZZZZ")
310+ l.Update()
311+ l.Compose()
312+ if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313+ t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314+ }
315+ l, m = newTestLayout(10)
316+ publish(m[0], "AAAAA")
317+ publish(m[1], "CC")
318+ publish(m[2], "ZZZZZ")
319+ l.Update()
320+ l.Compose()
321+ if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322+ t.Error("a fully clipped module must have no span")
323+ }
324+ if _, _, ok := l.At(5); !ok {
325+ // column 5 is the first of the right zone
326+ t.Error("At(5) must find the right module")
327+ }
328+}
329+
330+func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331+ l, m := newTestLayout(10)
332+ publish(m[0], "")
333+ publish(m[2], "R")
334+ l.Update()
335+ l.Compose()
336+ if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337+ t.Error("an empty module has no span")
338+ }
339+ if len(l.Spans(nil)) != 1 {
340+ t.Errorf("spans = %+v", l.Spans(nil))
341+ }
342+ // A module that empties out loses its span at the next Compose.
343+ publish(m[2], "")
344+ l.Update()
345+ l.Compose()
346+ if got := l.Spans(nil); len(got) != 0 {
347+ t.Errorf("spans after emptying = %+v", got)
348+ }
349+ if _, _, ok := l.At(9); ok {
350+ t.Error("At must not find an emptied module")
351+ }
352+}
353+
354+func TestComposeAndSpansAllocs(t *testing.T) {
355+ if racecheck.Enabled {
356+ t.Skip("the race detector allocates")
357+ }
358+ l, m := newTestLayout(40)
359+ publish(m[0], "left")
360+ publish(m[1], "center")
361+ publish(m[2], "right")
362+ l.Update()
363+ if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364+ t.Errorf("Compose allocates %.1f objects; want 0", got)
365+ }
366+ buf := make([]Span, 0, 8)
367+ if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368+ t.Errorf("Spans allocates %.1f objects; want 0", got)
369+ }
370+ if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371+ t.Errorf("At allocates %.1f objects; want 0", got)
372+ }
373+}
M internal/module/module.go +107 -1
@@ -15,6 +15,7 @@ import (
1515 "path/filepath"
1616 "strings"
1717 "sync"
18+ "sync/atomic"
1819 "syscall"
1920 "time"
2021 )
@@ -46,22 +47,62 @@ type Module struct {
4647 Exec string
4748 // Env is added to the script's environment, as KEY=value entries.
4849 Env []string
50+ // Interactive gives the script a stdin that carries Send lines. Set it
51+ // before Start.
52+ Interactive bool
4953
5054 mu sync.Mutex
5155 pending []byte // latest complete frame
5256 dirty bool
5357 failed bool // the script is not running; show an error marker
5458
59+ input chan event // lines for the script's stdin
60+ gen atomic.Uint64 // counts script starts; see Gen
61+
5562 wake chan<- struct{}
5663 log *slog.Logger
5764
5865 done sync.WaitGroup // Start adds one; Run releases it when its process is gone
5966 }
6067
68+// maxLine is the longest line Send accepts, without its newline.
69+const maxLine = 127
70+
71+// event is one stdin line, newline included.
72+type event struct {
73+ b [maxLine + 1]byte
74+ n uint8
75+}
76+
6177 // New creates a module. wake receives a non-blocking signal after each frame.
6278 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
6379 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
64- pending: make([]byte, 0, MaxFrame)}
80+ pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)}
81+}
82+
83+// Gen counts the times the script was started. A value that changed means a
84+// new process, which knows nothing of the pointer state sent to the old one.
85+// It does not allocate.
86+func (m *Module) Gen() uint64 { return m.gen.Load() }
87+
88+// Send queues line (without a newline) for the script's stdin. It never
89+// blocks and does not allocate. It reports false when the module is not
90+// interactive, the line is longer than 127 bytes, or 32 lines are already
91+// waiting (the script is not reading).
92+func (m *Module) Send(line []byte) bool {
93+ if !m.Interactive || len(line) > maxLine {
94+ return false
95+ }
96+ var ev event
97+ n := copy(ev.b[:], line)
98+ ev.b[n] = '\n'
99+ ev.n = uint8(n + 1)
100+ select {
101+ case m.input <- ev:
102+ return true
103+ default:
104+ return false
105+ }
65106 }
66107
67108 // Take copies the pending frame into dst (reusing its capacity) and clears the
@@ -147,6 +188,7 @@ func (m *Module) Run(ctx context.Context) {
147188 }
148189
149190 func (m *Module) runOnce(ctx context.Context) error {
191+ m.gen.Add(1)
150192 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
151193 cmd.Stderr = &logWriter{log: m.log, name: m.Name}
152194 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
@@ -162,10 +204,31 @@ func (m *Module) runOnce(ctx context.Context) error {
162204 if err != nil {
163205 return err
164206 }
207+ var in io.WriteCloser
208+ if m.Interactive {
209+ if in, err = cmd.StdinPipe(); err != nil {
210+ return err
211+ }
212+ // Events queued while no script was running are stale.
213+ for drained := false; !drained; {
214+ select {
215+ case <-m.input:
216+ default:
217+ drained = true
218+ }
219+ }
220+ }
165221 if err = cmd.Start(); err != nil {
166222 return err
167223 }
224+ var stopWriter func()
225+ if in != nil {
226+ stopWriter = m.pumpInput(in)
227+ }
168228 m.readFrames(out)
229+ if stopWriter != nil {
230+ stopWriter()
231+ }
169232 err = cmd.Wait()
170233 // The script may have started children that kept running (a "while" loop
171234 // in a subshell, a "sleep"). They share the process group: end them too.
@@ -173,6 +236,32 @@ func (m *Module) runOnce(ctx context.Context) error {
173236 return err
174237 }
175238
239+// pumpInput writes queued events to w until the returned stop function is
240+// called or a write fails. stop waits for the writer and closes w.
241+func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
242+ done := make(chan struct{})
243+ exited := make(chan struct{})
244+ go func() {
245+ defer close(exited)
246+ for {
247+ select {
248+ case ev := <-m.input:
249+ if _, err := w.Write(ev.b[:ev.n]); err != nil {
250+ return
251+ }
252+ case <-done:
253+ return
254+ }
255+ }
256+ }()
257+ return func() {
258+ close(done)
259+ // A write blocked on a script that does not read ends when w closes.
260+ _ = w.Close()
261+ <-exited
262+ }
263+}
264+
176265 // readFrames splits r into frames and publishes each one.
177266 func (m *Module) readFrames(r io.Reader) {
178267 buf := make([]byte, readSize)
@@ -219,6 +308,23 @@ func (w *logWriter) Write(p []byte) (int, error) {
219308 return len(p), nil
220309 }
221310
311+// SetGenForTest makes the module look as if its script was restarted.
312+func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
313+
314+// DrainForTest empties the queue of Send lines and returns them without their
315+// newlines.
316+func DrainForTest(m *Module) []string {
317+ var out []string
318+ for {
319+ select {
320+ case ev := <-m.input:
321+ out = append(out, string(ev.b[:ev.n-1]))
322+ default:
323+ return out
324+ }
325+ }
326+}
327+
222328 // PublishForTest injects a frame as if the script had printed it.
223329 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224330
@@ -15,6 +15,7 @@ import (
15 "path/filepath"15 "path/filepath"
16 "strings"16 "strings"
17 "sync"17 "sync"
18+ "sync/atomic"
18 "syscall"19 "syscall"
19 "time"20 "time"
20 )21 )
@@ -46,22 +47,62 @@ type Module struct {
46 Exec string47 Exec string
47 // Env is added to the script's environment, as KEY=value entries.48 // Env is added to the script's environment, as KEY=value entries.
48 Env []string49 Env []string
50+ // Interactive gives the script a stdin that carries Send lines. Set it
51+ // before Start.
52+ Interactive bool
49 53
50 mu sync.Mutex54 mu sync.Mutex
51 pending []byte // latest complete frame55 pending []byte // latest complete frame
52 dirty bool56 dirty bool
53 failed bool // the script is not running; show an error marker57 failed bool // the script is not running; show an error marker
54 58
59+ input chan event // lines for the script's stdin
60+ gen atomic.Uint64 // counts script starts; see Gen
61+
55 wake chan<- struct{}62 wake chan<- struct{}
56 log *slog.Logger63 log *slog.Logger
57 64
58 done sync.WaitGroup // Start adds one; Run releases it when its process is gone65 done sync.WaitGroup // Start adds one; Run releases it when its process is gone
59 }66 }
60 67
68+// maxLine is the longest line Send accepts, without its newline.
69+const maxLine = 127
70+
71+// event is one stdin line, newline included.
72+type event struct {
73+ b [maxLine + 1]byte
74+ n uint8
75+}
76+
61 // New creates a module. wake receives a non-blocking signal after each frame.77 // New creates a module. wake receives a non-blocking signal after each frame.
62 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {78 func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
63 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,79 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
64- pending: make([]byte, 0, MaxFrame)}80+ pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)}
81+}
82+
83+// Gen counts the times the script was started. A value that changed means a
84+// new process, which knows nothing of the pointer state sent to the old one.
85+// It does not allocate.
86+func (m *Module) Gen() uint64 { return m.gen.Load() }
87+
88+// Send queues line (without a newline) for the script's stdin. It never
89+// blocks and does not allocate. It reports false when the module is not
90+// interactive, the line is longer than 127 bytes, or 32 lines are already
91+// waiting (the script is not reading).
92+func (m *Module) Send(line []byte) bool {
93+ if !m.Interactive || len(line) > maxLine {
94+ return false
95+ }
96+ var ev event
97+ n := copy(ev.b[:], line)
98+ ev.b[n] = '\n'
99+ ev.n = uint8(n + 1)
100+ select {
101+ case m.input <- ev:
102+ return true
103+ default:
104+ return false
105+ }
65 }106 }
66 107
67 // Take copies the pending frame into dst (reusing its capacity) and clears the108 // Take copies the pending frame into dst (reusing its capacity) and clears the
@@ -147,6 +188,7 @@ func (m *Module) Run(ctx context.Context) {
147 }188 }
148 189
149 func (m *Module) runOnce(ctx context.Context) error {190 func (m *Module) runOnce(ctx context.Context) error {
191+ m.gen.Add(1)
150 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)192 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
151 cmd.Stderr = &logWriter{log: m.log, name: m.Name}193 cmd.Stderr = &logWriter{log: m.log, name: m.Name}
152 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}194 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
@@ -162,10 +204,31 @@ func (m *Module) runOnce(ctx context.Context) error {
162 if err != nil {204 if err != nil {
163 return err205 return err
164 }206 }
207+ var in io.WriteCloser
208+ if m.Interactive {
209+ if in, err = cmd.StdinPipe(); err != nil {
210+ return err
211+ }
212+ // Events queued while no script was running are stale.
213+ for drained := false; !drained; {
214+ select {
215+ case <-m.input:
216+ default:
217+ drained = true
218+ }
219+ }
220+ }
165 if err = cmd.Start(); err != nil {221 if err = cmd.Start(); err != nil {
166 return err222 return err
167 }223 }
224+ var stopWriter func()
225+ if in != nil {
226+ stopWriter = m.pumpInput(in)
227+ }
168 m.readFrames(out)228 m.readFrames(out)
229+ if stopWriter != nil {
230+ stopWriter()
231+ }
169 err = cmd.Wait()232 err = cmd.Wait()
170 // The script may have started children that kept running (a "while" loop233 // The script may have started children that kept running (a "while" loop
171 // in a subshell, a "sleep"). They share the process group: end them too.234 // in a subshell, a "sleep"). They share the process group: end them too.
@@ -173,6 +236,32 @@ func (m *Module) runOnce(ctx context.Context) error {
173 return err236 return err
174 }237 }
175 238
239+// pumpInput writes queued events to w until the returned stop function is
240+// called or a write fails. stop waits for the writer and closes w.
241+func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
242+ done := make(chan struct{})
243+ exited := make(chan struct{})
244+ go func() {
245+ defer close(exited)
246+ for {
247+ select {
248+ case ev := <-m.input:
249+ if _, err := w.Write(ev.b[:ev.n]); err != nil {
250+ return
251+ }
252+ case <-done:
253+ return
254+ }
255+ }
256+ }()
257+ return func() {
258+ close(done)
259+ // A write blocked on a script that does not read ends when w closes.
260+ _ = w.Close()
261+ <-exited
262+ }
263+}
264+
176 // readFrames splits r into frames and publishes each one.265 // readFrames splits r into frames and publishes each one.
177 func (m *Module) readFrames(r io.Reader) {266 func (m *Module) readFrames(r io.Reader) {
178 buf := make([]byte, readSize)267 buf := make([]byte, readSize)
@@ -219,6 +308,23 @@ func (w *logWriter) Write(p []byte) (int, error) {
219 return len(p), nil308 return len(p), nil
220 }309 }
221 310
311+// SetGenForTest makes the module look as if its script was restarted.
312+func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
313+
314+// DrainForTest empties the queue of Send lines and returns them without their
315+// newlines.
316+func DrainForTest(m *Module) []string {
317+ var out []string
318+ for {
319+ select {
320+ case ev := <-m.input:
321+ out = append(out, string(ev.b[:ev.n-1]))
322+ default:
323+ return out
324+ }
325+ }
326+}
327+
222 // PublishForTest injects a frame as if the script had printed it.328 // PublishForTest injects a frame as if the script had printed it.
223 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }329 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224 330
M internal/module/module_test.go +160 -0
@@ -131,3 +131,163 @@ func TestScriptDir(t *testing.T) {
131131 }
132132 }
133133 }
134+
135+func TestSendReachesTheScript(t *testing.T) {
136+ wake := make(chan struct{}, 1)
137+ m := New("t", Left, `while read l; do printf '%s\n' "$l"; done`, wake, slog.New(slog.DiscardHandler))
138+ m.Interactive = true
139+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
140+ defer cancel()
141+ m.Start(ctx)
142+ defer m.Wait(5 * time.Second)
143+ defer cancel()
144+ deadline := time.After(5 * time.Second)
145+ for {
146+ // The script may start after the first Send: resend until it answers.
147+ m.Send([]byte("hover 3"))
148+ select {
149+ case <-wake:
150+ if b, ch, failed := m.Take(nil); ch && !failed && string(b) == "hover 3" {
151+ return
152+ }
153+ case <-time.After(50 * time.Millisecond):
154+ case <-deadline:
155+ t.Fatal("the script never echoed the line")
156+ }
157+ }
158+}
159+
160+func TestSendRefusals(t *testing.T) {
161+ m, _ := newTest()
162+ if m.Send([]byte("hover 1")) {
163+ t.Fatal("a non-interactive module must refuse Send")
164+ }
165+ m.Interactive = true
166+ if m.Send(make([]byte, 128)) {
167+ t.Fatal("a line over 127 bytes must be refused")
168+ }
169+ if !m.Send(make([]byte, 127)) {
170+ t.Fatal("a 127-byte line must be accepted")
171+ }
172+}
173+
174+func TestSendNeverBlocks(t *testing.T) {
175+ m, _ := newTest()
176+ m.Interactive = true
177+ done := make(chan int)
178+ go func() {
179+ ok := 0
180+ for i := 0; i < 33; i++ {
181+ if m.Send([]byte("hover 1")) {
182+ ok++
183+ }
184+ }
185+ done <- ok
186+ }()
187+ select {
188+ case ok := <-done:
189+ if ok != 32 {
190+ t.Fatalf("%d sends accepted, want 32 (the queue size)", ok)
191+ }
192+ case <-time.After(2 * time.Second):
193+ t.Fatal("Send blocked on a full queue")
194+ }
195+}
196+
197+// A script that never reads its stdin must not hold the module back.
198+func TestUnreadStdinDoesNotBlockStop(t *testing.T) {
199+ m := New("t", Left, "echo up; sleep 30", make(chan struct{}, 1), slog.New(slog.DiscardHandler))
200+ m.Interactive = true
201+ ctx, cancel := context.WithCancel(context.Background())
202+ m.Start(ctx)
203+ for i := 0; i < 40; i++ {
204+ m.Send([]byte(strings.Repeat("x", 100)))
205+ time.Sleep(5 * time.Millisecond)
206+ }
207+ cancel()
208+ start := time.Now()
209+ m.Wait(5 * time.Second)
210+ if time.Since(start) > 4*time.Second {
211+ t.Fatal("stopping took too long")
212+ }
213+}
214+
215+func TestSendAllocs(t *testing.T) {
216+ if racecheck.Enabled {
217+ t.Skip("the race detector allocates")
218+ }
219+ m, _ := newTest()
220+ m.Interactive = true
221+ line := []byte("hover 12")
222+ if got := testing.AllocsPerRun(100, func() {
223+ m.Send(line)
224+ <-m.input
225+ }); got > 0 {
226+ t.Errorf("Send allocates %.1f objects; want 0", got)
227+ }
228+}
229+
230+// A script that never reads its stdin blocks the writer once the pipe is
231+// full; stop must still return, because closing the pipe ends the Write.
232+func TestPumpInputStopUnblocksAWrite(t *testing.T) {
233+ r, w, err := os.Pipe() // r is never read
234+ if err != nil {
235+ t.Fatal(err)
236+ }
237+ defer r.Close()
238+ m, _ := newTest()
239+ m.Interactive = true
240+ stop := m.pumpInput(w)
241+
242+ // Push well over the 64 KiB a pipe holds, until the queue stays full: then
243+ // the writer is stuck in Write.
244+ line := []byte(strings.Repeat("x", 100))
245+ sent := 0
246+ deadline := time.Now().Add(5 * time.Second)
247+ for full := 0; full < 50; {
248+ if time.Now().After(deadline) {
249+ t.Fatalf("the writer never blocked after %d lines", sent)
250+ }
251+ if m.Send(line) {
252+ sent++
253+ full = 0
254+ } else {
255+ full++
256+ time.Sleep(time.Millisecond)
257+ }
258+ }
259+ if sent*101 <= 64<<10 {
260+ t.Fatalf("only %d bytes went in; the pipe was not full", sent*101)
261+ }
262+
263+ done := make(chan struct{})
264+ go func() { stop(); close(done) }()
265+ select {
266+ case <-done:
267+ case <-time.After(time.Second):
268+ t.Fatal("stop did not return: the blocked Write was not released")
269+ }
270+}
271+
272+func TestGenCountsStarts(t *testing.T) {
273+ wake := make(chan struct{}, 1)
274+ m := New("t", Left, "printf 'hi\\n'", wake, slog.New(slog.DiscardHandler))
275+ if m.Gen() != 0 {
276+ t.Fatalf("Gen before start = %d", m.Gen())
277+ }
278+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
279+ defer cancel()
280+ go m.Run(ctx)
281+ deadline := time.After(4 * time.Second)
282+ for m.Gen() < 2 { // the script exits at once and is restarted
283+ select {
284+ case <-wake:
285+ case <-time.After(20 * time.Millisecond):
286+ case <-deadline:
287+ t.Fatalf("Gen = %d, want at least 2 after a restart", m.Gen())
288+ }
289+ }
290+ if allocs := testing.AllocsPerRun(100, func() { m.Gen() }); allocs != 0 {
291+ t.Errorf("Gen allocates %v", allocs)
292+ }
293+}
@@ -131,3 +131,163 @@ func TestScriptDir(t *testing.T) {
131 }131 }
132 }132 }
133 }133 }
134+
135+func TestSendReachesTheScript(t *testing.T) {
136+ wake := make(chan struct{}, 1)
137+ m := New("t", Left, `while read l; do printf '%s\n' "$l"; done`, wake, slog.New(slog.DiscardHandler))
138+ m.Interactive = true
139+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
140+ defer cancel()
141+ m.Start(ctx)
142+ defer m.Wait(5 * time.Second)
143+ defer cancel()
144+ deadline := time.After(5 * time.Second)
145+ for {
146+ // The script may start after the first Send: resend until it answers.
147+ m.Send([]byte("hover 3"))
148+ select {
149+ case <-wake:
150+ if b, ch, failed := m.Take(nil); ch && !failed && string(b) == "hover 3" {
151+ return
152+ }
153+ case <-time.After(50 * time.Millisecond):
154+ case <-deadline:
155+ t.Fatal("the script never echoed the line")
156+ }
157+ }
158+}
159+
160+func TestSendRefusals(t *testing.T) {
161+ m, _ := newTest()
162+ if m.Send([]byte("hover 1")) {
163+ t.Fatal("a non-interactive module must refuse Send")
164+ }
165+ m.Interactive = true
166+ if m.Send(make([]byte, 128)) {
167+ t.Fatal("a line over 127 bytes must be refused")
168+ }
169+ if !m.Send(make([]byte, 127)) {
170+ t.Fatal("a 127-byte line must be accepted")
171+ }
172+}
173+
174+func TestSendNeverBlocks(t *testing.T) {
175+ m, _ := newTest()
176+ m.Interactive = true
177+ done := make(chan int)
178+ go func() {
179+ ok := 0
180+ for i := 0; i < 33; i++ {
181+ if m.Send([]byte("hover 1")) {
182+ ok++
183+ }
184+ }
185+ done <- ok
186+ }()
187+ select {
188+ case ok := <-done:
189+ if ok != 32 {
190+ t.Fatalf("%d sends accepted, want 32 (the queue size)", ok)
191+ }
192+ case <-time.After(2 * time.Second):
193+ t.Fatal("Send blocked on a full queue")
194+ }
195+}
196+
197+// A script that never reads its stdin must not hold the module back.
198+func TestUnreadStdinDoesNotBlockStop(t *testing.T) {
199+ m := New("t", Left, "echo up; sleep 30", make(chan struct{}, 1), slog.New(slog.DiscardHandler))
200+ m.Interactive = true
201+ ctx, cancel := context.WithCancel(context.Background())
202+ m.Start(ctx)
203+ for i := 0; i < 40; i++ {
204+ m.Send([]byte(strings.Repeat("x", 100)))
205+ time.Sleep(5 * time.Millisecond)
206+ }
207+ cancel()
208+ start := time.Now()
209+ m.Wait(5 * time.Second)
210+ if time.Since(start) > 4*time.Second {
211+ t.Fatal("stopping took too long")
212+ }
213+}
214+
215+func TestSendAllocs(t *testing.T) {
216+ if racecheck.Enabled {
217+ t.Skip("the race detector allocates")
218+ }
219+ m, _ := newTest()
220+ m.Interactive = true
221+ line := []byte("hover 12")
222+ if got := testing.AllocsPerRun(100, func() {
223+ m.Send(line)
224+ <-m.input
225+ }); got > 0 {
226+ t.Errorf("Send allocates %.1f objects; want 0", got)
227+ }
228+}
229+
230+// A script that never reads its stdin blocks the writer once the pipe is
231+// full; stop must still return, because closing the pipe ends the Write.
232+func TestPumpInputStopUnblocksAWrite(t *testing.T) {
233+ r, w, err := os.Pipe() // r is never read
234+ if err != nil {
235+ t.Fatal(err)
236+ }
237+ defer r.Close()
238+ m, _ := newTest()
239+ m.Interactive = true
240+ stop := m.pumpInput(w)
241+
242+ // Push well over the 64 KiB a pipe holds, until the queue stays full: then
243+ // the writer is stuck in Write.
244+ line := []byte(strings.Repeat("x", 100))
245+ sent := 0
246+ deadline := time.Now().Add(5 * time.Second)
247+ for full := 0; full < 50; {
248+ if time.Now().After(deadline) {
249+ t.Fatalf("the writer never blocked after %d lines", sent)
250+ }
251+ if m.Send(line) {
252+ sent++
253+ full = 0
254+ } else {
255+ full++
256+ time.Sleep(time.Millisecond)
257+ }
258+ }
259+ if sent*101 <= 64<<10 {
260+ t.Fatalf("only %d bytes went in; the pipe was not full", sent*101)
261+ }
262+
263+ done := make(chan struct{})
264+ go func() { stop(); close(done) }()
265+ select {
266+ case <-done:
267+ case <-time.After(time.Second):
268+ t.Fatal("stop did not return: the blocked Write was not released")
269+ }
270+}
271+
272+func TestGenCountsStarts(t *testing.T) {
273+ wake := make(chan struct{}, 1)
274+ m := New("t", Left, "printf 'hi\\n'", wake, slog.New(slog.DiscardHandler))
275+ if m.Gen() != 0 {
276+ t.Fatalf("Gen before start = %d", m.Gen())
277+ }
278+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
279+ defer cancel()
280+ go m.Run(ctx)
281+ deadline := time.After(4 * time.Second)
282+ for m.Gen() < 2 { // the script exits at once and is restarted
283+ select {
284+ case <-wake:
285+ case <-time.After(20 * time.Millisecond):
286+ case <-deadline:
287+ t.Fatalf("Gen = %d, want at least 2 after a restart", m.Gen())
288+ }
289+ }
290+ if allocs := testing.AllocsPerRun(100, func() { m.Gen() }); allocs != 0 {
291+ t.Errorf("Gen allocates %v", allocs)
292+ }
293+}
A internal/tray/control.go +339 -0
@@ -0,0 +1,339 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerobus"
15+)
16+
17+const (
18+ // callTimeout is how long the control loop waits for an application. A
19+ // frozen one must not keep the next click from working.
20+ callTimeout = 2 * time.Second
21+ // maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
22+ maxInputLine = 256
23+ // maxSteps bounds the steps of a scroll line.
24+ maxSteps = 1 << 16
25+)
26+
27+// lockedWriter serializes writes from the main loop and the control loop to
28+// the shared output.
29+type lockedWriter struct {
30+ mu sync.Mutex
31+ w io.Writer
32+}
33+
34+func (l *lockedWriter) Write(p []byte) (int, error) {
35+ l.mu.Lock()
36+ defer l.mu.Unlock()
37+ return l.w.Write(p)
38+}
39+
40+// button is a mouse button named in a click line.
41+type button uint8
42+
43+const (
44+ btnLeft button = iota + 1
45+ btnMiddle
46+ btnRight
47+)
48+
49+// command is one parsed line from the bar.
50+type command struct {
51+ verb string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
52+ button button // click
53+ vert bool // scroll: vertical axis
54+ delta int32 // scroll: signed steps, down and right positive
55+ col int
56+ token uint32 // click: the press token
57+}
58+
59+// parseCommand reads one line of the protocol. Every field must be present,
60+// numbers must be plain non-negative decimals, and nothing may follow.
61+func parseCommand(line string) (command, bool) {
62+ f := strings.Fields(line)
63+ if len(f) == 0 {
64+ return command{}, false
65+ }
66+ // Plain decimal digits only: ParseUint takes no sign, and the bit size
67+ // keeps the value in range on 32-bit platforms, where int is 32 bits.
68+ num := func(s string, bits int, limit uint64) (uint64, bool) {
69+ n, err := strconv.ParseUint(s, 10, bits)
70+ return n, err == nil && n <= limit
71+ }
72+ // Columns are bounded by 1<<20; tokens use the full uint32.
73+ col := func(s string) (int, bool) { n, ok := num(s, 31, 1<<20); return int(n), ok }
74+ c := command{verb: f[0]}
75+ switch f[0] {
76+ case "click":
77+ if len(f) != 4 {
78+ return command{}, false
79+ }
80+ switch f[1] {
81+ case "left":
82+ c.button = btnLeft
83+ case "middle":
84+ c.button = btnMiddle
85+ case "right":
86+ c.button = btnRight
87+ default:
88+ return command{}, false
89+ }
90+ cl, ok1 := col(f[2])
91+ tok, ok2 := num(f[3], 32, 1<<32-1)
92+ if !ok1 || !ok2 {
93+ return command{}, false
94+ }
95+ c.col, c.token = cl, uint32(tok)
96+ case "scroll":
97+ if len(f) != 4 {
98+ return command{}, false
99+ }
100+ sign := int32(1)
101+ switch f[1] {
102+ case "down":
103+ c.vert = true
104+ case "up":
105+ c.vert, sign = true, -1
106+ case "right":
107+ case "left":
108+ sign = -1
109+ default:
110+ return command{}, false
111+ }
112+ steps, ok1 := num(f[2], 31, maxSteps)
113+ cl, ok2 := col(f[3])
114+ if !ok1 || !ok2 || steps == 0 {
115+ return command{}, false
116+ }
117+ c.delta, c.col = sign*int32(steps), cl
118+ case "hover":
119+ cl, ok := col(f[len(f)-1])
120+ if len(f) != 2 || !ok {
121+ return command{}, false
122+ }
123+ c.col = cl
124+ case "leave":
125+ if len(f) != 1 {
126+ return command{}, false
127+ }
128+ case "menu-activate":
129+ if len(f) != 3 {
130+ return command{}, false
131+ }
132+ tok, ok1 := num(f[1], 32, 1<<32-1)
133+ id, ok2 := num(f[2], 31, 1<<31-1)
134+ if !ok1 || !ok2 {
135+ return command{}, false
136+ }
137+ c.token, c.col = uint32(tok), int(id) // col carries the menu item id
138+ case "menu-closed":
139+ tok, ok := num(f[len(f)-1], 32, 1<<32-1)
140+ if len(f) != 2 || !ok {
141+ return command{}, false
142+ }
143+ c.token = uint32(tok)
144+ default:
145+ return command{}, false
146+ }
147+ return c, true
148+}
149+
150+// readCommands sends the lines of r to out until r ends or ctx is done. A
151+// line longer than maxInputLine is dropped whole.
152+func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
153+ br := bufio.NewReaderSize(r, maxInputLine)
154+ skipping := false
155+ for {
156+ b, err := br.ReadSlice('\n')
157+ switch {
158+ case errors.Is(err, bufio.ErrBufferFull):
159+ skipping = true // the rest of this line is dropped too
160+ continue
161+ case err != nil && len(b) == 0:
162+ return
163+ }
164+ if skipping {
165+ skipping = false
166+ log.Debug("tray: input line too long")
167+ } else if err == nil {
168+ select {
169+ case out <- strings.TrimRight(string(b), "\r\n"):
170+ case <-ctx.Done():
171+ return
172+ }
173+ }
174+ if err != nil {
175+ return
176+ }
177+ }
178+}
179+
180+// control performs the actions the bar asks for. It owns a second bus
181+// connection, so a slow application never holds up the tray's own loop.
182+type control struct {
183+ t *Tray
184+ dial func() (*zerobus.Conn, error)
185+ log *slog.Logger
186+
187+ mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
188+ conn *zerobus.Conn
189+ done bool
190+}
191+
192+// run serves lines until ctx ends.
193+func (c *control) run(ctx context.Context, lines <-chan string) {
194+ stop := context.AfterFunc(ctx, c.close)
195+ defer stop()
196+ defer c.close()
197+ for {
198+ select {
199+ case <-ctx.Done():
200+ return
201+ case line := <-lines:
202+ cmd, ok := parseCommand(line)
203+ if !ok {
204+ c.log.Debug("tray: ignoring input line", "line", line)
205+ continue
206+ }
207+ c.handle(cmd)
208+ }
209+ }
210+}
211+
212+func (c *control) close() {
213+ c.mu.Lock()
214+ defer c.mu.Unlock()
215+ c.done = true
216+ if c.conn != nil {
217+ _ = c.conn.Close()
218+ c.conn = nil
219+ }
220+}
221+
222+// conn2 returns the control connection, dialing it when needed. The dial runs
223+// outside the lock, so shutdown never waits for it: if shutdown began meanwhile
224+// the new connection is closed and ErrClosed is returned.
225+func (c *control) conn2() (*zerobus.Conn, error) {
226+ c.mu.Lock()
227+ if c.done {
228+ c.mu.Unlock()
229+ return nil, zerobus.ErrClosed
230+ }
231+ if conn := c.conn; conn != nil {
232+ c.mu.Unlock()
233+ return conn, nil
234+ }
235+ c.mu.Unlock()
236+
237+ conn, err := c.dial()
238+ if err != nil {
239+ return nil, err
240+ }
241+ c.mu.Lock()
242+ defer c.mu.Unlock()
243+ if c.done {
244+ _ = conn.Close()
245+ return nil, zerobus.ErrClosed
246+ }
247+ c.conn = conn
248+ return conn, nil
249+}
250+
251+// drop forgets conn after a transport error; the next call dials again.
252+func (c *control) drop(conn *zerobus.Conn) {
253+ c.mu.Lock()
254+ defer c.mu.Unlock()
255+ _ = conn.Close()
256+ if c.conn == conn {
257+ c.conn = nil
258+ }
259+}
260+
261+func (c *control) handle(cmd command) {
262+ switch cmd.verb {
263+ case "click":
264+ tg, ok := c.t.target(cmd.col)
265+ if !ok {
266+ return
267+ }
268+ switch cmd.button {
269+ case btnLeft:
270+ if tg.IsMenu {
271+ c.contextMenu(tg)
272+ return
273+ }
274+ err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
275+ if isUnknownMethod(err) {
276+ c.contextMenu(tg)
277+ }
278+ case btnMiddle:
279+ c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
280+ case btnRight:
281+ c.contextMenu(tg)
282+ }
283+ case "scroll":
284+ tg, ok := c.t.target(cmd.col)
285+ if !ok {
286+ return
287+ }
288+ orientation := "horizontal"
289+ if cmd.vert {
290+ orientation = "vertical"
291+ }
292+ c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
293+ }
294+}
295+
296+// contextMenu asks the application to show its own menu.
297+func (c *control) contextMenu(tg Target) {
298+ c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
299+}
300+
301+func isUnknownMethod(err error) bool {
302+ var e *zerobus.Error
303+ return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
304+}
305+
306+// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
307+// reply is returned as is; a transport failure or a timeout drops the
308+// connection and is logged.
309+func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
310+ conn, err := c.conn2()
311+ if err != nil {
312+ if !errors.Is(err, zerobus.ErrClosed) {
313+ c.log.Warn("tray: cannot reach the session bus", "err", err)
314+ }
315+ return err
316+ }
317+ args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
318+ timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
319+ _, err = conn.Call()
320+ expired := !timer.Stop()
321+ var reply *zerobus.Error
322+ if errors.As(err, &reply) && !expired {
323+ c.log.Debug("tray: the application refused", "method", member, "err", err)
324+ return err
325+ }
326+ if err != nil || expired {
327+ c.drop(conn)
328+ if err == nil {
329+ err = zerobus.ErrClosed
330+ }
331+ c.mu.Lock()
332+ stopping := c.done
333+ c.mu.Unlock()
334+ if !stopping {
335+ c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
336+ }
337+ }
338+ return err
339+}
@@ -0,0 +1,339 @@
1+package tray
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "io"
8+ "log/slog"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerobus"
15+)
16+
17+const (
18+ // callTimeout is how long the control loop waits for an application. A
19+ // frozen one must not keep the next click from working.
20+ callTimeout = 2 * time.Second
21+ // maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
22+ maxInputLine = 256
23+ // maxSteps bounds the steps of a scroll line.
24+ maxSteps = 1 << 16
25+)
26+
27+// lockedWriter serializes writes from the main loop and the control loop to
28+// the shared output.
29+type lockedWriter struct {
30+ mu sync.Mutex
31+ w io.Writer
32+}
33+
34+func (l *lockedWriter) Write(p []byte) (int, error) {
35+ l.mu.Lock()
36+ defer l.mu.Unlock()
37+ return l.w.Write(p)
38+}
39+
40+// button is a mouse button named in a click line.
41+type button uint8
42+
43+const (
44+ btnLeft button = iota + 1
45+ btnMiddle
46+ btnRight
47+)
48+
49+// command is one parsed line from the bar.
50+type command struct {
51+ verb string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
52+ button button // click
53+ vert bool // scroll: vertical axis
54+ delta int32 // scroll: signed steps, down and right positive
55+ col int
56+ token uint32 // click: the press token
57+}
58+
59+// parseCommand reads one line of the protocol. Every field must be present,
60+// numbers must be plain non-negative decimals, and nothing may follow.
61+func parseCommand(line string) (command, bool) {
62+ f := strings.Fields(line)
63+ if len(f) == 0 {
64+ return command{}, false
65+ }
66+ // Plain decimal digits only: ParseUint takes no sign, and the bit size
67+ // keeps the value in range on 32-bit platforms, where int is 32 bits.
68+ num := func(s string, bits int, limit uint64) (uint64, bool) {
69+ n, err := strconv.ParseUint(s, 10, bits)
70+ return n, err == nil && n <= limit
71+ }
72+ // Columns are bounded by 1<<20; tokens use the full uint32.
73+ col := func(s string) (int, bool) { n, ok := num(s, 31, 1<<20); return int(n), ok }
74+ c := command{verb: f[0]}
75+ switch f[0] {
76+ case "click":
77+ if len(f) != 4 {
78+ return command{}, false
79+ }
80+ switch f[1] {
81+ case "left":
82+ c.button = btnLeft
83+ case "middle":
84+ c.button = btnMiddle
85+ case "right":
86+ c.button = btnRight
87+ default:
88+ return command{}, false
89+ }
90+ cl, ok1 := col(f[2])
91+ tok, ok2 := num(f[3], 32, 1<<32-1)
92+ if !ok1 || !ok2 {
93+ return command{}, false
94+ }
95+ c.col, c.token = cl, uint32(tok)
96+ case "scroll":
97+ if len(f) != 4 {
98+ return command{}, false
99+ }
100+ sign := int32(1)
101+ switch f[1] {
102+ case "down":
103+ c.vert = true
104+ case "up":
105+ c.vert, sign = true, -1
106+ case "right":
107+ case "left":
108+ sign = -1
109+ default:
110+ return command{}, false
111+ }
112+ steps, ok1 := num(f[2], 31, maxSteps)
113+ cl, ok2 := col(f[3])
114+ if !ok1 || !ok2 || steps == 0 {
115+ return command{}, false
116+ }
117+ c.delta, c.col = sign*int32(steps), cl
118+ case "hover":
119+ cl, ok := col(f[len(f)-1])
120+ if len(f) != 2 || !ok {
121+ return command{}, false
122+ }
123+ c.col = cl
124+ case "leave":
125+ if len(f) != 1 {
126+ return command{}, false
127+ }
128+ case "menu-activate":
129+ if len(f) != 3 {
130+ return command{}, false
131+ }
132+ tok, ok1 := num(f[1], 32, 1<<32-1)
133+ id, ok2 := num(f[2], 31, 1<<31-1)
134+ if !ok1 || !ok2 {
135+ return command{}, false
136+ }
137+ c.token, c.col = uint32(tok), int(id) // col carries the menu item id
138+ case "menu-closed":
139+ tok, ok := num(f[len(f)-1], 32, 1<<32-1)
140+ if len(f) != 2 || !ok {
141+ return command{}, false
142+ }
143+ c.token = uint32(tok)
144+ default:
145+ return command{}, false
146+ }
147+ return c, true
148+}
149+
150+// readCommands sends the lines of r to out until r ends or ctx is done. A
151+// line longer than maxInputLine is dropped whole.
152+func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
153+ br := bufio.NewReaderSize(r, maxInputLine)
154+ skipping := false
155+ for {
156+ b, err := br.ReadSlice('\n')
157+ switch {
158+ case errors.Is(err, bufio.ErrBufferFull):
159+ skipping = true // the rest of this line is dropped too
160+ continue
161+ case err != nil && len(b) == 0:
162+ return
163+ }
164+ if skipping {
165+ skipping = false
166+ log.Debug("tray: input line too long")
167+ } else if err == nil {
168+ select {
169+ case out <- strings.TrimRight(string(b), "\r\n"):
170+ case <-ctx.Done():
171+ return
172+ }
173+ }
174+ if err != nil {
175+ return
176+ }
177+ }
178+}
179+
180+// control performs the actions the bar asks for. It owns a second bus
181+// connection, so a slow application never holds up the tray's own loop.
182+type control struct {
183+ t *Tray
184+ dial func() (*zerobus.Conn, error)
185+ log *slog.Logger
186+
187+ mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
188+ conn *zerobus.Conn
189+ done bool
190+}
191+
192+// run serves lines until ctx ends.
193+func (c *control) run(ctx context.Context, lines <-chan string) {
194+ stop := context.AfterFunc(ctx, c.close)
195+ defer stop()
196+ defer c.close()
197+ for {
198+ select {
199+ case <-ctx.Done():
200+ return
201+ case line := <-lines:
202+ cmd, ok := parseCommand(line)
203+ if !ok {
204+ c.log.Debug("tray: ignoring input line", "line", line)
205+ continue
206+ }
207+ c.handle(cmd)
208+ }
209+ }
210+}
211+
212+func (c *control) close() {
213+ c.mu.Lock()
214+ defer c.mu.Unlock()
215+ c.done = true
216+ if c.conn != nil {
217+ _ = c.conn.Close()
218+ c.conn = nil
219+ }
220+}
221+
222+// conn2 returns the control connection, dialing it when needed. The dial runs
223+// outside the lock, so shutdown never waits for it: if shutdown began meanwhile
224+// the new connection is closed and ErrClosed is returned.
225+func (c *control) conn2() (*zerobus.Conn, error) {
226+ c.mu.Lock()
227+ if c.done {
228+ c.mu.Unlock()
229+ return nil, zerobus.ErrClosed
230+ }
231+ if conn := c.conn; conn != nil {
232+ c.mu.Unlock()
233+ return conn, nil
234+ }
235+ c.mu.Unlock()
236+
237+ conn, err := c.dial()
238+ if err != nil {
239+ return nil, err
240+ }
241+ c.mu.Lock()
242+ defer c.mu.Unlock()
243+ if c.done {
244+ _ = conn.Close()
245+ return nil, zerobus.ErrClosed
246+ }
247+ c.conn = conn
248+ return conn, nil
249+}
250+
251+// drop forgets conn after a transport error; the next call dials again.
252+func (c *control) drop(conn *zerobus.Conn) {
253+ c.mu.Lock()
254+ defer c.mu.Unlock()
255+ _ = conn.Close()
256+ if c.conn == conn {
257+ c.conn = nil
258+ }
259+}
260+
261+func (c *control) handle(cmd command) {
262+ switch cmd.verb {
263+ case "click":
264+ tg, ok := c.t.target(cmd.col)
265+ if !ok {
266+ return
267+ }
268+ switch cmd.button {
269+ case btnLeft:
270+ if tg.IsMenu {
271+ c.contextMenu(tg)
272+ return
273+ }
274+ err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
275+ if isUnknownMethod(err) {
276+ c.contextMenu(tg)
277+ }
278+ case btnMiddle:
279+ c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
280+ case btnRight:
281+ c.contextMenu(tg)
282+ }
283+ case "scroll":
284+ tg, ok := c.t.target(cmd.col)
285+ if !ok {
286+ return
287+ }
288+ orientation := "horizontal"
289+ if cmd.vert {
290+ orientation = "vertical"
291+ }
292+ c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
293+ }
294+}
295+
296+// contextMenu asks the application to show its own menu.
297+func (c *control) contextMenu(tg Target) {
298+ c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
299+}
300+
301+func isUnknownMethod(err error) bool {
302+ var e *zerobus.Error
303+ return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
304+}
305+
306+// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
307+// reply is returned as is; a transport failure or a timeout drops the
308+// connection and is logged.
309+func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
310+ conn, err := c.conn2()
311+ if err != nil {
312+ if !errors.Is(err, zerobus.ErrClosed) {
313+ c.log.Warn("tray: cannot reach the session bus", "err", err)
314+ }
315+ return err
316+ }
317+ args(conn.NewCall(tg.Dest, tg.Path, itemIface, member, sig))
318+ timer := time.AfterFunc(callTimeout, func() { _ = conn.Close() })
319+ _, err = conn.Call()
320+ expired := !timer.Stop()
321+ var reply *zerobus.Error
322+ if errors.As(err, &reply) && !expired {
323+ c.log.Debug("tray: the application refused", "method", member, "err", err)
324+ return err
325+ }
326+ if err != nil || expired {
327+ c.drop(conn)
328+ if err == nil {
329+ err = zerobus.ErrClosed
330+ }
331+ c.mu.Lock()
332+ stopping := c.done
333+ c.mu.Unlock()
334+ if !stopping {
335+ c.log.Warn("tray: application did not answer", "method", member, "dest", tg.Dest, "err", err)
336+ }
337+ }
338+ return err
339+}
A internal/tray/control_test.go +226 -0
@@ -0,0 +1,226 @@
1+package tray
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerobus"
11+)
12+
13+func TestParseCommand(t *testing.T) {
14+ good := map[string]command{
15+ "click left 0 1": {verb: "click", button: btnLeft, col: 0, token: 1},
16+ "click left 0 4294967295": {verb: "click", button: btnLeft, token: 1<<32 - 1},
17+ "click left 1048576 1": {verb: "click", button: btnLeft, col: 1 << 20, token: 1},
18+ "menu-activate 4294967295 2147483647": {verb: "menu-activate", token: 1<<32 - 1, col: 1<<31 - 1},
19+ "click middle 3 42": {verb: "click", button: btnMiddle, col: 3, token: 42},
20+ "click right 12 7": {verb: "click", button: btnRight, col: 12, token: 7},
21+ "scroll up 2 0": {verb: "scroll", vert: true, delta: -2},
22+ "scroll down 1 4": {verb: "scroll", vert: true, delta: 1, col: 4},
23+ "scroll left 3 1": {verb: "scroll", delta: -3, col: 1},
24+ "scroll right 5 2": {verb: "scroll", delta: 5, col: 2},
25+ "hover 4": {verb: "hover", col: 4},
26+ "leave": {verb: "leave"},
27+ "menu-activate 9 17": {verb: "menu-activate", token: 9, col: 17},
28+ "menu-closed 9": {verb: "menu-closed", token: 9},
29+ " click left 1 2 ": {verb: "click", button: btnLeft, col: 1, token: 2},
30+ }
31+ for line, want := range good {
32+ got, ok := parseCommand(line)
33+ if !ok || got != want {
34+ t.Errorf("parseCommand(%q) = %+v, %v; want %+v", line, got, ok, want)
35+ }
36+ }
37+ for _, line := range []string{
38+ "", " ", "garbage", "click", "click left", "click left 0", "click left 0 1 2", "click up 0 1",
39+ "click left -1 1", "click left x 1", "click left 0 -3", "click left +1 1", "click left 0 4294967296",
40+ "scroll up", "scroll up 0 0", "scroll up -1 0", "scroll up 2", "scroll diagonal 1 0", "scroll up 1000000 0",
41+ "hover", "hover x", "hover -1", "hover 1 2", "leave now", "menu-activate 1", "menu-activate 1 -2",
42+ "menu-closed", "menu-closed x", "menu-closed 1 2", "CLICK left 0 1",
43+ // Signs are refused, and so is anything past 31 bits for columns and steps.
44+ "click left 0 +1", "click left +0 1", "click left 0 -0", "scroll up +2 0", "scroll up 2 +0", "hover +1",
45+ "menu-activate +1 2", "menu-activate 1 +2", "menu-closed +1", "menu-closed -1",
46+ "click left 2147483648 1", "scroll up 1 2147483648", "hover 2147483648", "hover 1048577",
47+ "menu-activate 1 2147483648", "click left 0 0x1", "click left 0 1_0", "click left 0 1e3",
48+ } {
49+ if got, ok := parseCommand(line); ok {
50+ t.Errorf("parseCommand(%q) = %+v, want it refused", line, got)
51+ }
52+ }
53+}
54+
55+// barInput is the pipe the test writes the bar's lines to.
56+type barInput struct{ w *io.PipeWriter }
57+
58+func (b barInput) send(t *testing.T, lines ...string) {
59+ t.Helper()
60+ for _, l := range lines {
61+ if _, err := io.WriteString(b.w, l+"\n"); err != nil {
62+ t.Fatal(err)
63+ }
64+ }
65+}
66+
67+// setup runs a tray with input on a private bus, with two items: steam ("S",
68+// column 0) and obsidian ("O", column 2).
69+func setup(t *testing.T) (steam, obs *fakeItem, in barInput, out *lines) {
70+ t.Helper()
71+ addr := privateBus(t)
72+ pr, pw := io.Pipe()
73+ t.Cleanup(func() { _ = pw.Close() })
74+ in = barInput{pw}
75+ out, _, _ = startTrayWith(t, addr, pr, func() (*zerobus.Conn, error) { return zerobus.Dial(addr) })
76+ waitWatcher(t, addr)
77+ steam = newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
78+ obs = newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
79+ steam.register(t)
80+ out.waitFor(t, "steam", func(s string) bool { return strings.Contains(s, "S") })
81+ obs.register(t)
82+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "S") && strings.Contains(s, "O") })
83+ return steam, obs, in, out
84+}
85+
86+func expectCall(t *testing.T, f *fakeItem, want string) {
87+ t.Helper()
88+ select {
89+ case got := <-f.calls:
90+ if got != want {
91+ t.Fatalf("item got %s, want %s", got, want)
92+ }
93+ case <-time.After(5 * time.Second):
94+ t.Fatalf("item never got %s", want)
95+ }
96+}
97+
98+func expectNoCall(t *testing.T, f *fakeItem) {
99+ t.Helper()
100+ select {
101+ case got := <-f.calls:
102+ t.Fatalf("unexpected call %s", got)
103+ case <-time.After(150 * time.Millisecond):
104+ }
105+}
106+
107+func TestClicksAndScroll(t *testing.T) {
108+ steam, obs, in, _ := setup(t)
109+
110+ in.send(t, "click left 0 1")
111+ expectCall(t, steam, "Activate(0,0)")
112+ in.send(t, "click middle 0 2")
113+ expectCall(t, steam, "SecondaryActivate(0,0)")
114+ in.send(t, "click right 0 3")
115+ expectCall(t, steam, "ContextMenu(0,0)")
116+
117+ // The second icon starts at column 2, after one space.
118+ in.send(t, "click left 2 4")
119+ expectCall(t, obs, "Activate(0,0)")
120+ expectNoCall(t, steam)
121+
122+ in.send(t, "scroll up 2 0")
123+ expectCall(t, steam, "Scroll(-2,vertical)")
124+ in.send(t, "scroll down 3 0")
125+ expectCall(t, steam, "Scroll(3,vertical)")
126+ in.send(t, "scroll right 1 2")
127+ expectCall(t, obs, "Scroll(1,horizontal)")
128+ in.send(t, "scroll left 4 2")
129+ expectCall(t, obs, "Scroll(-4,horizontal)")
130+}
131+
132+func TestClickOutsideIconsAndGarbageAreIgnored(t *testing.T) {
133+ steam, obs, in, _ := setup(t)
134+ in.send(t,
135+ "click left 1 1", // the space between the icons
136+ "click left 9 2", // past the last icon
137+ "garbage",
138+ "click left x 3",
139+ "click left 0",
140+ "click left 0 4 extra",
141+ "click left -1 5",
142+ "scroll up 0 0",
143+ "\x1b[31m not a command",
144+ strings.Repeat("click left 0 1 ", 40), // longer than the line limit
145+ "hover 0", "leave", // accepted by the parser, nothing to do yet
146+ )
147+ expectNoCall(t, steam)
148+ expectNoCall(t, obs)
149+ // The loop still works afterwards.
150+ in.send(t, "click left 0 9")
151+ expectCall(t, steam, "Activate(0,0)")
152+}
153+
154+func TestActivateUnknownMethodFallsBackToContextMenu(t *testing.T) {
155+ steam, _, in, _ := setup(t)
156+ steam.run(t, func() { steam.unknown = "Activate" })
157+ in.send(t, "click left 0 1")
158+ expectCall(t, steam, "Activate(0,0)")
159+ expectCall(t, steam, "ContextMenu(0,0)")
160+}
161+
162+func TestItemIsMenuLeftClickOpensTheMenu(t *testing.T) {
163+ var buf strings.Builder
164+ tr := &Tray{opt: Options{Out: &buf}}
165+ tr.items = []*item{{loaded: true, icon: "X", dest: ":1.1", path: "/p", isMenu: true, menu: "/m"}}
166+ if err := tr.render(true); err != nil {
167+ t.Fatal(err)
168+ }
169+ tg, ok := tr.target(0)
170+ if !ok || !tg.IsMenu || tg.Menu != "/m" {
171+ t.Fatalf("target = %+v, %v", tg, ok)
172+ }
173+}
174+
175+// A frozen application must not block the tray: the call is abandoned after
176+// two seconds and the next click, on another item, still goes through.
177+func TestFrozenApplicationDoesNotBlockNextClick(t *testing.T) {
178+ steam, obs, in, _ := setup(t)
179+ steam.run(t, func() { steam.slow, steam.slowFor = "Activate", 4*time.Second })
180+ start := time.Now()
181+ in.send(t, "click left 0 1", "click left 2 2")
182+ expectCall(t, steam, "Activate(0,0)")
183+ expectCall(t, obs, "Activate(0,0)")
184+ if d := time.Since(start); d < 1500*time.Millisecond || d > 3500*time.Millisecond {
185+ t.Fatalf("the second click arrived after %v, want about 2s (the watchdog)", d)
186+ }
187+ // And again on the dropped item: the connection was redialed.
188+ in.send(t, "click middle 2 3")
189+ expectCall(t, obs, "SecondaryActivate(0,0)")
190+}
191+
192+// If shutdown begins while the control connection is being dialed, the new
193+// connection is closed and not kept.
194+func TestConn2ClosesADialThatRacesShutdown(t *testing.T) {
195+ addr := privateBus(t)
196+ dialing, release := make(chan struct{}), make(chan struct{})
197+ var dialed *zerobus.Conn
198+ c := &control{dial: func() (*zerobus.Conn, error) {
199+ close(dialing)
200+ <-release
201+ conn, err := zerobus.Dial(addr)
202+ dialed = conn
203+ return conn, err
204+ }}
205+ errc := make(chan error, 1)
206+ go func() { _, err := c.conn2(); errc <- err }()
207+ <-dialing
208+ closed := make(chan struct{})
209+ go func() { c.close(); close(closed) }()
210+ select {
211+ case <-closed: // close must not wait for the dial
212+ case <-time.After(time.Second):
213+ t.Fatal("close waited for the dial")
214+ }
215+ close(release)
216+ if err := <-errc; !errors.Is(err, zerobus.ErrClosed) {
217+ t.Fatalf("conn2 = %v, want ErrClosed", err)
218+ }
219+ if c.conn != nil {
220+ t.Fatal("the connection was kept after shutdown")
221+ }
222+ dialed.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
223+ if _, err := dialed.Call(); err == nil {
224+ t.Fatal("the connection dialed during shutdown is still open")
225+ }
226+}
@@ -0,0 +1,226 @@
1+package tray
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerobus"
11+)
12+
13+func TestParseCommand(t *testing.T) {
14+ good := map[string]command{
15+ "click left 0 1": {verb: "click", button: btnLeft, col: 0, token: 1},
16+ "click left 0 4294967295": {verb: "click", button: btnLeft, token: 1<<32 - 1},
17+ "click left 1048576 1": {verb: "click", button: btnLeft, col: 1 << 20, token: 1},
18+ "menu-activate 4294967295 2147483647": {verb: "menu-activate", token: 1<<32 - 1, col: 1<<31 - 1},
19+ "click middle 3 42": {verb: "click", button: btnMiddle, col: 3, token: 42},
20+ "click right 12 7": {verb: "click", button: btnRight, col: 12, token: 7},
21+ "scroll up 2 0": {verb: "scroll", vert: true, delta: -2},
22+ "scroll down 1 4": {verb: "scroll", vert: true, delta: 1, col: 4},
23+ "scroll left 3 1": {verb: "scroll", delta: -3, col: 1},
24+ "scroll right 5 2": {verb: "scroll", delta: 5, col: 2},
25+ "hover 4": {verb: "hover", col: 4},
26+ "leave": {verb: "leave"},
27+ "menu-activate 9 17": {verb: "menu-activate", token: 9, col: 17},
28+ "menu-closed 9": {verb: "menu-closed", token: 9},
29+ " click left 1 2 ": {verb: "click", button: btnLeft, col: 1, token: 2},
30+ }
31+ for line, want := range good {
32+ got, ok := parseCommand(line)
33+ if !ok || got != want {
34+ t.Errorf("parseCommand(%q) = %+v, %v; want %+v", line, got, ok, want)
35+ }
36+ }
37+ for _, line := range []string{
38+ "", " ", "garbage", "click", "click left", "click left 0", "click left 0 1 2", "click up 0 1",
39+ "click left -1 1", "click left x 1", "click left 0 -3", "click left +1 1", "click left 0 4294967296",
40+ "scroll up", "scroll up 0 0", "scroll up -1 0", "scroll up 2", "scroll diagonal 1 0", "scroll up 1000000 0",
41+ "hover", "hover x", "hover -1", "hover 1 2", "leave now", "menu-activate 1", "menu-activate 1 -2",
42+ "menu-closed", "menu-closed x", "menu-closed 1 2", "CLICK left 0 1",
43+ // Signs are refused, and so is anything past 31 bits for columns and steps.
44+ "click left 0 +1", "click left +0 1", "click left 0 -0", "scroll up +2 0", "scroll up 2 +0", "hover +1",
45+ "menu-activate +1 2", "menu-activate 1 +2", "menu-closed +1", "menu-closed -1",
46+ "click left 2147483648 1", "scroll up 1 2147483648", "hover 2147483648", "hover 1048577",
47+ "menu-activate 1 2147483648", "click left 0 0x1", "click left 0 1_0", "click left 0 1e3",
48+ } {
49+ if got, ok := parseCommand(line); ok {
50+ t.Errorf("parseCommand(%q) = %+v, want it refused", line, got)
51+ }
52+ }
53+}
54+
55+// barInput is the pipe the test writes the bar's lines to.
56+type barInput struct{ w *io.PipeWriter }
57+
58+func (b barInput) send(t *testing.T, lines ...string) {
59+ t.Helper()
60+ for _, l := range lines {
61+ if _, err := io.WriteString(b.w, l+"\n"); err != nil {
62+ t.Fatal(err)
63+ }
64+ }
65+}
66+
67+// setup runs a tray with input on a private bus, with two items: steam ("S",
68+// column 0) and obsidian ("O", column 2).
69+func setup(t *testing.T) (steam, obs *fakeItem, in barInput, out *lines) {
70+ t.Helper()
71+ addr := privateBus(t)
72+ pr, pw := io.Pipe()
73+ t.Cleanup(func() { _ = pw.Close() })
74+ in = barInput{pw}
75+ out, _, _ = startTrayWith(t, addr, pr, func() (*zerobus.Conn, error) { return zerobus.Dial(addr) })
76+ waitWatcher(t, addr)
77+ steam = newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
78+ obs = newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
79+ steam.register(t)
80+ out.waitFor(t, "steam", func(s string) bool { return strings.Contains(s, "S") })
81+ obs.register(t)
82+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "S") && strings.Contains(s, "O") })
83+ return steam, obs, in, out
84+}
85+
86+func expectCall(t *testing.T, f *fakeItem, want string) {
87+ t.Helper()
88+ select {
89+ case got := <-f.calls:
90+ if got != want {
91+ t.Fatalf("item got %s, want %s", got, want)
92+ }
93+ case <-time.After(5 * time.Second):
94+ t.Fatalf("item never got %s", want)
95+ }
96+}
97+
98+func expectNoCall(t *testing.T, f *fakeItem) {
99+ t.Helper()
100+ select {
101+ case got := <-f.calls:
102+ t.Fatalf("unexpected call %s", got)
103+ case <-time.After(150 * time.Millisecond):
104+ }
105+}
106+
107+func TestClicksAndScroll(t *testing.T) {
108+ steam, obs, in, _ := setup(t)
109+
110+ in.send(t, "click left 0 1")
111+ expectCall(t, steam, "Activate(0,0)")
112+ in.send(t, "click middle 0 2")
113+ expectCall(t, steam, "SecondaryActivate(0,0)")
114+ in.send(t, "click right 0 3")
115+ expectCall(t, steam, "ContextMenu(0,0)")
116+
117+ // The second icon starts at column 2, after one space.
118+ in.send(t, "click left 2 4")
119+ expectCall(t, obs, "Activate(0,0)")
120+ expectNoCall(t, steam)
121+
122+ in.send(t, "scroll up 2 0")
123+ expectCall(t, steam, "Scroll(-2,vertical)")
124+ in.send(t, "scroll down 3 0")
125+ expectCall(t, steam, "Scroll(3,vertical)")
126+ in.send(t, "scroll right 1 2")
127+ expectCall(t, obs, "Scroll(1,horizontal)")
128+ in.send(t, "scroll left 4 2")
129+ expectCall(t, obs, "Scroll(-4,horizontal)")
130+}
131+
132+func TestClickOutsideIconsAndGarbageAreIgnored(t *testing.T) {
133+ steam, obs, in, _ := setup(t)
134+ in.send(t,
135+ "click left 1 1", // the space between the icons
136+ "click left 9 2", // past the last icon
137+ "garbage",
138+ "click left x 3",
139+ "click left 0",
140+ "click left 0 4 extra",
141+ "click left -1 5",
142+ "scroll up 0 0",
143+ "\x1b[31m not a command",
144+ strings.Repeat("click left 0 1 ", 40), // longer than the line limit
145+ "hover 0", "leave", // accepted by the parser, nothing to do yet
146+ )
147+ expectNoCall(t, steam)
148+ expectNoCall(t, obs)
149+ // The loop still works afterwards.
150+ in.send(t, "click left 0 9")
151+ expectCall(t, steam, "Activate(0,0)")
152+}
153+
154+func TestActivateUnknownMethodFallsBackToContextMenu(t *testing.T) {
155+ steam, _, in, _ := setup(t)
156+ steam.run(t, func() { steam.unknown = "Activate" })
157+ in.send(t, "click left 0 1")
158+ expectCall(t, steam, "Activate(0,0)")
159+ expectCall(t, steam, "ContextMenu(0,0)")
160+}
161+
162+func TestItemIsMenuLeftClickOpensTheMenu(t *testing.T) {
163+ var buf strings.Builder
164+ tr := &Tray{opt: Options{Out: &buf}}
165+ tr.items = []*item{{loaded: true, icon: "X", dest: ":1.1", path: "/p", isMenu: true, menu: "/m"}}
166+ if err := tr.render(true); err != nil {
167+ t.Fatal(err)
168+ }
169+ tg, ok := tr.target(0)
170+ if !ok || !tg.IsMenu || tg.Menu != "/m" {
171+ t.Fatalf("target = %+v, %v", tg, ok)
172+ }
173+}
174+
175+// A frozen application must not block the tray: the call is abandoned after
176+// two seconds and the next click, on another item, still goes through.
177+func TestFrozenApplicationDoesNotBlockNextClick(t *testing.T) {
178+ steam, obs, in, _ := setup(t)
179+ steam.run(t, func() { steam.slow, steam.slowFor = "Activate", 4*time.Second })
180+ start := time.Now()
181+ in.send(t, "click left 0 1", "click left 2 2")
182+ expectCall(t, steam, "Activate(0,0)")
183+ expectCall(t, obs, "Activate(0,0)")
184+ if d := time.Since(start); d < 1500*time.Millisecond || d > 3500*time.Millisecond {
185+ t.Fatalf("the second click arrived after %v, want about 2s (the watchdog)", d)
186+ }
187+ // And again on the dropped item: the connection was redialed.
188+ in.send(t, "click middle 2 3")
189+ expectCall(t, obs, "SecondaryActivate(0,0)")
190+}
191+
192+// If shutdown begins while the control connection is being dialed, the new
193+// connection is closed and not kept.
194+func TestConn2ClosesADialThatRacesShutdown(t *testing.T) {
195+ addr := privateBus(t)
196+ dialing, release := make(chan struct{}), make(chan struct{})
197+ var dialed *zerobus.Conn
198+ c := &control{dial: func() (*zerobus.Conn, error) {
199+ close(dialing)
200+ <-release
201+ conn, err := zerobus.Dial(addr)
202+ dialed = conn
203+ return conn, err
204+ }}
205+ errc := make(chan error, 1)
206+ go func() { _, err := c.conn2(); errc <- err }()
207+ <-dialing
208+ closed := make(chan struct{})
209+ go func() { c.close(); close(closed) }()
210+ select {
211+ case <-closed: // close must not wait for the dial
212+ case <-time.After(time.Second):
213+ t.Fatal("close waited for the dial")
214+ }
215+ close(release)
216+ if err := <-errc; !errors.Is(err, zerobus.ErrClosed) {
217+ t.Fatalf("conn2 = %v, want ErrClosed", err)
218+ }
219+ if c.conn != nil {
220+ t.Fatal("the connection was kept after shutdown")
221+ }
222+ dialed.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
223+ if _, err := dialed.Call(); err == nil {
224+ t.Fatal("the connection dialed during shutdown is still open")
225+ }
226+}
A internal/tray/snapshot.go +43 -0
@@ -0,0 +1,43 @@
1+package tray
2+
3+import "sync"
4+
5+// Target is what a click on the tray line reaches: one visible item and the
6+// columns of its icon in the printed line.
7+type Target struct {
8+ Dest string // where to send calls: the owner's unique name, else the registered name
9+ Path string // the object path of the StatusNotifierItem
10+ Menu string // the object path of its dbusmenu, "" when it has none
11+ IsMenu bool // the item only offers a menu: Activate means "open it"
12+ Start int // first column of the icon in the line
13+ Width int // cells the icon takes
14+}
15+
16+// snapshot is the list of targets of the last line printed. The main loop
17+// writes it; the control goroutine reads it.
18+type snapshot struct {
19+ mu sync.Mutex
20+ targets []Target
21+}
22+
23+// publish replaces the targets with a copy of ts.
24+func (s *snapshot) publish(ts []Target) {
25+ s.mu.Lock()
26+ s.targets = append(s.targets[:0], ts...)
27+ s.mu.Unlock()
28+}
29+
30+// at returns the target whose icon covers col.
31+func (s *snapshot) at(col int) (Target, bool) {
32+ s.mu.Lock()
33+ defer s.mu.Unlock()
34+ for _, t := range s.targets {
35+ if col >= t.Start && col < t.Start+t.Width {
36+ return t, true
37+ }
38+ }
39+ return Target{}, false
40+}
41+
42+// target returns the visible item whose icon covers column col of the line.
43+func (t *Tray) target(col int) (Target, bool) { return t.snap.at(col) }
@@ -0,0 +1,43 @@
1+package tray
2+
3+import "sync"
4+
5+// Target is what a click on the tray line reaches: one visible item and the
6+// columns of its icon in the printed line.
7+type Target struct {
8+ Dest string // where to send calls: the owner's unique name, else the registered name
9+ Path string // the object path of the StatusNotifierItem
10+ Menu string // the object path of its dbusmenu, "" when it has none
11+ IsMenu bool // the item only offers a menu: Activate means "open it"
12+ Start int // first column of the icon in the line
13+ Width int // cells the icon takes
14+}
15+
16+// snapshot is the list of targets of the last line printed. The main loop
17+// writes it; the control goroutine reads it.
18+type snapshot struct {
19+ mu sync.Mutex
20+ targets []Target
21+}
22+
23+// publish replaces the targets with a copy of ts.
24+func (s *snapshot) publish(ts []Target) {
25+ s.mu.Lock()
26+ s.targets = append(s.targets[:0], ts...)
27+ s.mu.Unlock()
28+}
29+
30+// at returns the target whose icon covers col.
31+func (s *snapshot) at(col int) (Target, bool) {
32+ s.mu.Lock()
33+ defer s.mu.Unlock()
34+ for _, t := range s.targets {
35+ if col >= t.Start && col < t.Start+t.Width {
36+ return t, true
37+ }
38+ }
39+ return Target{}, false
40+}
41+
42+// target returns the visible item whose icon covers column col of the line.
43+func (t *Tray) target(col int) (Target, bool) { return t.snap.at(col) }
M internal/tray/tray.go +63 -3
@@ -19,7 +19,9 @@ import (
1919 "slices"
2020 "strconv"
2121 "strings"
22+ "sync"
2223
24+ vt "github.com/bnema/vev-vt"
2325 "github.com/bnema/zerobus"
2426 )
2527
@@ -46,6 +48,11 @@ type Options struct {
4648 Foreground, Background, Accent RGB
4749 Out io.Writer // one line per change
4850 Log *slog.Logger
51+ // In carries the bar's pointer lines (see the README); nil ignores input.
52+ In io.Reader
53+ // Dial opens the connection that runs the actions In asks for, apart from
54+ // the one that serves the tray. Required with In.
55+ Dial func() (*zerobus.Conn, error)
4956 }
5057
5158 type status uint8
@@ -68,6 +75,8 @@ type item struct {
6875 resolved bool
6976 color RGB
7077 hasColor bool
78+ menu string // object path of its dbusmenu, "" when none
79+ isMenu bool // ItemIsMenu: Activate should open the menu
7180 loaded bool // GetAll answered at least once
7281 pending uint32 // serial of a GetAll in flight
7382 stale bool // a change arrived while GetAll was in flight
@@ -100,6 +109,9 @@ type Tray struct {
100109 last []byte
101110 hist histogram
102111 err error // first fatal error from dispatch
112+
113+ snap snapshot // what the last line printed holds, for the control loop
114+ targets []Target // scratch for the next snapshot
103115 }
104116
105117 // Run serves the tray on c until ctx ends or the bus connection fails. It
@@ -114,6 +126,23 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
114126 defer stop()
115127 defer func() { _ = c.Close() }()
116128
129+ if opt.In != nil {
130+ if opt.Dial == nil {
131+ return fmt.Errorf("tray: Options.In needs Options.Dial")
132+ }
133+ t.opt.Out = &lockedWriter{w: opt.Out}
134+ cctx, cancel := context.WithCancel(ctx)
135+ lines := make(chan string, 16)
136+ ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
137+ var wg sync.WaitGroup
138+ wg.Add(1)
139+ go func() { defer wg.Done(); ctl.run(cctx, lines) }()
140+ // The reader may sit in a blocking Read of stdin that nothing can
141+ // interrupt; it is not waited for, and ends with the process.
142+ go readCommands(cctx, opt.In, lines, t.opt.Log)
143+ defer func() { cancel(); wg.Wait() }()
144+ }
145+
117146 // Calls made from here on only go to the bus, which always answers.
118147 // Handle sees what arrives meanwhile.
119148 c.Handle = t.dispatch
@@ -464,6 +493,10 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
464493 setStr(&it.title, r.Str())
465494 case sig == "s" && key == "IconName":
466495 setStr(&it.iconName, r.Str())
496+ case sig == "o" && key == "Menu":
497+ setStr(&it.menu, r.ObjectPath())
498+ case sig == "b" && key == "ItemIsMenu":
499+ it.isMenu = r.Bool()
467500 case sig == "s" && key == "Status":
468501 it.status = parseStatus(r.Str())
469502 case sig == "a(iiay)" && key == "IconPixmap":
@@ -665,15 +698,20 @@ func (t *Tray) renderOrFail() {
665698 }
666699 }
667700
668-// render writes the line of visible items when it differs from the last one.
701+// render writes the line of visible items when it differs from the last one,
702+// and publishes where each icon sits for the control loop. The targets are
703+// compared too: an item may change its menu without changing its icon.
669704 func (t *Tray) render(force bool) error {
670705 f := t.frame[:0]
706+ ts := t.targets[:0]
707+ col := 0
671708 for _, it := range t.items {
672709 if !it.loaded || it.status == passive || it.icon == "" {
673710 continue
674711 }
675712 if len(f) > 0 {
676713 f = append(f, ' ')
714+ col++
677715 }
678716 c, bold := t.opt.Foreground, false
679717 switch {
@@ -695,10 +733,25 @@ func (t *Tray) render(force bool) error {
695733 f = append(f, 'm')
696734 f = append(f, it.icon...)
697735 f = append(f, "\x1b[0m"...)
736+ dest := it.dest
737+ if it.owner != "" {
738+ dest = it.owner
739+ }
740+ w := 0
741+ for _, r := range it.icon {
742+ w += vt.RuneWidth(r)
743+ }
744+ ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
745+ col += w
698746 }
699747 f = append(f, '\n')
700- t.frame = f
701- if !force && bytes.Equal(f, t.last) {
748+ t.frame, t.targets = f, ts
749+ sameFrame := bytes.Equal(f, t.last)
750+ if !force && sameFrame && t.sameTargets(ts) {
751+ return nil
752+ }
753+ t.snap.publish(ts) // before the line: whoever sees the line finds its targets
754+ if !force && sameFrame {
702755 return nil
703756 }
704757 t.frame, t.last = t.last, t.frame
@@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error {
708761 return nil
709762 }
710763
764+// sameTargets reports whether the published snapshot equals ts.
765+func (t *Tray) sameTargets(ts []Target) bool {
766+ t.snap.mu.Lock()
767+ defer t.snap.mu.Unlock()
768+ return slices.Equal(t.snap.targets, ts)
769+}
770+
711771 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712772 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713773 <node>
@@ -19,7 +19,9 @@ import (
19 "slices"19 "slices"
20 "strconv"20 "strconv"
21 "strings"21 "strings"
22+ "sync"
22 23
24+ vt "github.com/bnema/vev-vt"
23 "github.com/bnema/zerobus"25 "github.com/bnema/zerobus"
24 )26 )
25 27
@@ -46,6 +48,11 @@ type Options struct {
46 Foreground, Background, Accent RGB48 Foreground, Background, Accent RGB
47 Out io.Writer // one line per change49 Out io.Writer // one line per change
48 Log *slog.Logger50 Log *slog.Logger
51+ // In carries the bar's pointer lines (see the README); nil ignores input.
52+ In io.Reader
53+ // Dial opens the connection that runs the actions In asks for, apart from
54+ // the one that serves the tray. Required with In.
55+ Dial func() (*zerobus.Conn, error)
49 }56 }
50 57
51 type status uint858 type status uint8
@@ -68,6 +75,8 @@ type item struct {
68 resolved bool75 resolved bool
69 color RGB76 color RGB
70 hasColor bool77 hasColor bool
78+ menu string // object path of its dbusmenu, "" when none
79+ isMenu bool // ItemIsMenu: Activate should open the menu
71 loaded bool // GetAll answered at least once80 loaded bool // GetAll answered at least once
72 pending uint32 // serial of a GetAll in flight81 pending uint32 // serial of a GetAll in flight
73 stale bool // a change arrived while GetAll was in flight82 stale bool // a change arrived while GetAll was in flight
@@ -100,6 +109,9 @@ type Tray struct {
100 last []byte109 last []byte
101 hist histogram110 hist histogram
102 err error // first fatal error from dispatch111 err error // first fatal error from dispatch
112+
113+ snap snapshot // what the last line printed holds, for the control loop
114+ targets []Target // scratch for the next snapshot
103 }115 }
104 116
105 // Run serves the tray on c until ctx ends or the bus connection fails. It117 // Run serves the tray on c until ctx ends or the bus connection fails. It
@@ -114,6 +126,23 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
114 defer stop()126 defer stop()
115 defer func() { _ = c.Close() }()127 defer func() { _ = c.Close() }()
116 128
129+ if opt.In != nil {
130+ if opt.Dial == nil {
131+ return fmt.Errorf("tray: Options.In needs Options.Dial")
132+ }
133+ t.opt.Out = &lockedWriter{w: opt.Out}
134+ cctx, cancel := context.WithCancel(ctx)
135+ lines := make(chan string, 16)
136+ ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
137+ var wg sync.WaitGroup
138+ wg.Add(1)
139+ go func() { defer wg.Done(); ctl.run(cctx, lines) }()
140+ // The reader may sit in a blocking Read of stdin that nothing can
141+ // interrupt; it is not waited for, and ends with the process.
142+ go readCommands(cctx, opt.In, lines, t.opt.Log)
143+ defer func() { cancel(); wg.Wait() }()
144+ }
145+
117 // Calls made from here on only go to the bus, which always answers.146 // Calls made from here on only go to the bus, which always answers.
118 // Handle sees what arrives meanwhile.147 // Handle sees what arrives meanwhile.
119 c.Handle = t.dispatch148 c.Handle = t.dispatch
@@ -464,6 +493,10 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
464 setStr(&it.title, r.Str())493 setStr(&it.title, r.Str())
465 case sig == "s" && key == "IconName":494 case sig == "s" && key == "IconName":
466 setStr(&it.iconName, r.Str())495 setStr(&it.iconName, r.Str())
496+ case sig == "o" && key == "Menu":
497+ setStr(&it.menu, r.ObjectPath())
498+ case sig == "b" && key == "ItemIsMenu":
499+ it.isMenu = r.Bool()
467 case sig == "s" && key == "Status":500 case sig == "s" && key == "Status":
468 it.status = parseStatus(r.Str())501 it.status = parseStatus(r.Str())
469 case sig == "a(iiay)" && key == "IconPixmap":502 case sig == "a(iiay)" && key == "IconPixmap":
@@ -665,15 +698,20 @@ func (t *Tray) renderOrFail() {
665 }698 }
666 }699 }
667 700
668-// render writes the line of visible items when it differs from the last one.701+// render writes the line of visible items when it differs from the last one,
702+// and publishes where each icon sits for the control loop. The targets are
703+// compared too: an item may change its menu without changing its icon.
669 func (t *Tray) render(force bool) error {704 func (t *Tray) render(force bool) error {
670 f := t.frame[:0]705 f := t.frame[:0]
706+ ts := t.targets[:0]
707+ col := 0
671 for _, it := range t.items {708 for _, it := range t.items {
672 if !it.loaded || it.status == passive || it.icon == "" {709 if !it.loaded || it.status == passive || it.icon == "" {
673 continue710 continue
674 }711 }
675 if len(f) > 0 {712 if len(f) > 0 {
676 f = append(f, ' ')713 f = append(f, ' ')
714+ col++
677 }715 }
678 c, bold := t.opt.Foreground, false716 c, bold := t.opt.Foreground, false
679 switch {717 switch {
@@ -695,10 +733,25 @@ func (t *Tray) render(force bool) error {
695 f = append(f, 'm')733 f = append(f, 'm')
696 f = append(f, it.icon...)734 f = append(f, it.icon...)
697 f = append(f, "\x1b[0m"...)735 f = append(f, "\x1b[0m"...)
736+ dest := it.dest
737+ if it.owner != "" {
738+ dest = it.owner
739+ }
740+ w := 0
741+ for _, r := range it.icon {
742+ w += vt.RuneWidth(r)
743+ }
744+ ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w})
745+ col += w
698 }746 }
699 f = append(f, '\n')747 f = append(f, '\n')
700- t.frame = f748+ t.frame, t.targets = f, ts
701- if !force && bytes.Equal(f, t.last) {749+ sameFrame := bytes.Equal(f, t.last)
750+ if !force && sameFrame && t.sameTargets(ts) {
751+ return nil
752+ }
753+ t.snap.publish(ts) // before the line: whoever sees the line finds its targets
754+ if !force && sameFrame {
702 return nil755 return nil
703 }756 }
704 t.frame, t.last = t.last, t.frame757 t.frame, t.last = t.last, t.frame
@@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error {
708 return nil761 return nil
709 }762 }
710 763
764+// sameTargets reports whether the published snapshot equals ts.
765+func (t *Tray) sameTargets(ts []Target) bool {
766+ t.snap.mu.Lock()
767+ defer t.snap.mu.Unlock()
768+ return slices.Equal(t.snap.targets, ts)
769+}
770+
711 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"771 const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
712 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">772 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
713 <node>773 <node>
M internal/tray/tray_test.go +112 -2
@@ -3,9 +3,11 @@ package tray
33 import (
44 "bufio"
55 "context"
6+ "fmt"
67 "io"
78 "log/slog"
89 "os/exec"
10+ "slices"
911 "strings"
1012 "testing"
1113 "time"
@@ -60,12 +62,20 @@ type fakeItem struct {
6062 cur [3]string // id, icon name, status
6163 pixel [4]byte // the one ARGB pixel of its icon
6264 closed chan struct{}
65+
66+ // calls records the StatusNotifierItem actions it received, as
67+ // "Method(args)". The fields below are set through run.
68+ calls chan string
69+ unknown string // the method it answers with UnknownMethod
70+ slow string // the method it answers only after slowFor
71+ slowFor time.Duration // how long slow takes
6372 }
6473
6574 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
6675 c := dial(t, addr)
6776 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
68- cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})}
77+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
78+ calls: make(chan string, 32)}
6979 go f.serve()
7080 return f
7181 }
@@ -96,6 +106,12 @@ func (f *fakeItem) serve() {
96106 (<-f.cmds)()
97107 continue
98108 }
109+ if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
110+ if !f.action(m) {
111+ return
112+ }
113+ continue
114+ }
99115 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100116 continue
101117 }
@@ -123,6 +139,34 @@ func (f *fakeItem) serve() {
123139 }
124140 }
125141
142+// action records and answers one StatusNotifierItem method call. It reports
143+// false when the connection is gone.
144+func (f *fakeItem) action(m *zerobus.Message) bool {
145+ r := m.Body()
146+ var rec string
147+ switch m.Member {
148+ case "Activate", "SecondaryActivate", "ContextMenu":
149+ x, y := r.Int32(), r.Int32()
150+ rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
151+ case "Scroll":
152+ delta, orientation := r.Int32(), r.Str()
153+ rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
154+ default:
155+ return true
156+ }
157+ f.calls <- rec
158+ if m.Member == f.slow {
159+ time.Sleep(f.slowFor)
160+ }
161+ if m.Member == f.unknown {
162+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
163+ } else {
164+ f.c.NewReply(m, "")
165+ }
166+ _, err := f.c.Send()
167+ return err == nil
168+}
169+
126170 // register registers the item with the watcher, by bus name.
127171 func (f *fakeItem) register(t *testing.T) {
128172 f.run(t, func() {
@@ -185,6 +229,12 @@ func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string
185229 // startTray runs a tray on the bus. Cancel it and receive from the channel
186230 // to stop it; the test cleanup does both when the test did not.
187231 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
232+ return startTrayWith(t, addr, nil, nil)
233+}
234+
235+// startTrayWith is startTray with the bar's input and a dialer for the
236+// control connection.
237+func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
188238 pr, pw := io.Pipe()
189239 ctx, cancel := context.WithCancel(context.Background())
190240 done := make(chan error, 1)
@@ -193,7 +243,7 @@ func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan er
193243 opt := Options{
194244 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195245 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196- Out: pw,
246+ Out: pw, In: in, Dial: dialer,
197247 }
198248 c := dial(t, addr)
199249 go func() {
@@ -540,3 +590,63 @@ func TestBetterPixmapSize(t *testing.T) {
540590 }
541591 }
542592 }
593+
594+func TestRenderPublishesTargets(t *testing.T) {
595+ var buf strings.Builder
596+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
597+ tr.items = []*item{
598+ {loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
599+ {loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
600+ {loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
601+ }
602+ if err := tr.render(false); err != nil {
603+ t.Fatal(err)
604+ }
605+ want := []Target{
606+ {Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
607+ {Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
608+ }
609+ if got := tr.snap.targets; !slices.Equal(got, want) {
610+ t.Fatalf("targets = %+v, want %+v", got, want)
611+ }
612+ for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
613+ if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
614+ t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
615+ }
616+ }
617+ for _, col := range []int{-1, 1, 3} {
618+ if _, ok := tr.target(col); ok {
619+ t.Errorf("target(%d) found an icon in the gap or past the line", col)
620+ }
621+ }
622+ // A hidden item leaves no target, and a wide icon moves the next one.
623+ tr.items[0].icon = "界"
624+ if err := tr.render(false); err != nil {
625+ t.Fatal(err)
626+ }
627+ if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
628+ t.Fatalf("targets after a wide icon = %+v", got)
629+ }
630+}
631+
632+// A change of menu path alone republishes the targets without a new line.
633+func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
634+ var buf strings.Builder
635+ tr := &Tray{opt: Options{Out: &buf}}
636+ tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
637+ for range 2 {
638+ if err := tr.render(false); err != nil {
639+ t.Fatal(err)
640+ }
641+ }
642+ tr.items[0].menu = "/menu"
643+ if err := tr.render(false); err != nil {
644+ t.Fatal(err)
645+ }
646+ if n := strings.Count(buf.String(), "\n"); n != 1 {
647+ t.Fatalf("%d lines written, want 1", n)
648+ }
649+ if tg, _ := tr.target(0); tg.Menu != "/menu" {
650+ t.Fatalf("target = %+v", tg)
651+ }
652+}
@@ -3,9 +3,11 @@ package tray
3 import (3 import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6+ "fmt"
6 "io"7 "io"
7 "log/slog"8 "log/slog"
8 "os/exec"9 "os/exec"
10+ "slices"
9 "strings"11 "strings"
10 "testing"12 "testing"
11 "time"13 "time"
@@ -60,12 +62,20 @@ type fakeItem struct {
60 cur [3]string // id, icon name, status62 cur [3]string // id, icon name, status
61 pixel [4]byte // the one ARGB pixel of its icon63 pixel [4]byte // the one ARGB pixel of its icon
62 closed chan struct{}64 closed chan struct{}
65+
66+ // calls records the StatusNotifierItem actions it received, as
67+ // "Method(args)". The fields below are set through run.
68+ calls chan string
69+ unknown string // the method it answers with UnknownMethod
70+ slow string // the method it answers only after slowFor
71+ slowFor time.Duration // how long slow takes
63 }72 }
64 73
65 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {74 func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
66 c := dial(t, addr)75 c := dial(t, addr)
67 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),76 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
68- cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})}77+ cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
78+ calls: make(chan string, 32)}
69 go f.serve()79 go f.serve()
70 return f80 return f
71 }81 }
@@ -96,6 +106,12 @@ func (f *fakeItem) serve() {
96 (<-f.cmds)()106 (<-f.cmds)()
97 continue107 continue
98 }108 }
109+ if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
110+ if !f.action(m) {
111+ return
112+ }
113+ continue
114+ }
99 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {115 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
100 continue116 continue
101 }117 }
@@ -123,6 +139,34 @@ func (f *fakeItem) serve() {
123 }139 }
124 }140 }
125 141
142+// action records and answers one StatusNotifierItem method call. It reports
143+// false when the connection is gone.
144+func (f *fakeItem) action(m *zerobus.Message) bool {
145+ r := m.Body()
146+ var rec string
147+ switch m.Member {
148+ case "Activate", "SecondaryActivate", "ContextMenu":
149+ x, y := r.Int32(), r.Int32()
150+ rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
151+ case "Scroll":
152+ delta, orientation := r.Int32(), r.Str()
153+ rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
154+ default:
155+ return true
156+ }
157+ f.calls <- rec
158+ if m.Member == f.slow {
159+ time.Sleep(f.slowFor)
160+ }
161+ if m.Member == f.unknown {
162+ f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
163+ } else {
164+ f.c.NewReply(m, "")
165+ }
166+ _, err := f.c.Send()
167+ return err == nil
168+}
169+
126 // register registers the item with the watcher, by bus name.170 // register registers the item with the watcher, by bus name.
127 func (f *fakeItem) register(t *testing.T) {171 func (f *fakeItem) register(t *testing.T) {
128 f.run(t, func() {172 f.run(t, func() {
@@ -185,6 +229,12 @@ func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string
185 // startTray runs a tray on the bus. Cancel it and receive from the channel229 // startTray runs a tray on the bus. Cancel it and receive from the channel
186 // to stop it; the test cleanup does both when the test did not.230 // to stop it; the test cleanup does both when the test did not.
187 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {231 func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
232+ return startTrayWith(t, addr, nil, nil)
233+}
234+
235+// startTrayWith is startTray with the bar's input and a dialer for the
236+// control connection.
237+func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
188 pr, pw := io.Pipe()238 pr, pw := io.Pipe()
189 ctx, cancel := context.WithCancel(context.Background())239 ctx, cancel := context.WithCancel(context.Background())
190 done := make(chan error, 1)240 done := make(chan error, 1)
@@ -193,7 +243,7 @@ func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan er
193 opt := Options{243 opt := Options{
194 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),244 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
195 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},245 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
196- Out: pw,246+ Out: pw, In: in, Dial: dialer,
197 }247 }
198 c := dial(t, addr)248 c := dial(t, addr)
199 go func() {249 go func() {
@@ -540,3 +590,63 @@ func TestBetterPixmapSize(t *testing.T) {
540 }590 }
541 }591 }
542 }592 }
593+
594+func TestRenderPublishesTargets(t *testing.T) {
595+ var buf strings.Builder
596+ tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
597+ tr.items = []*item{
598+ {loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
599+ {loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
600+ {loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
601+ }
602+ if err := tr.render(false); err != nil {
603+ t.Fatal(err)
604+ }
605+ want := []Target{
606+ {Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
607+ {Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
608+ }
609+ if got := tr.snap.targets; !slices.Equal(got, want) {
610+ t.Fatalf("targets = %+v, want %+v", got, want)
611+ }
612+ for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
613+ if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
614+ t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
615+ }
616+ }
617+ for _, col := range []int{-1, 1, 3} {
618+ if _, ok := tr.target(col); ok {
619+ t.Errorf("target(%d) found an icon in the gap or past the line", col)
620+ }
621+ }
622+ // A hidden item leaves no target, and a wide icon moves the next one.
623+ tr.items[0].icon = "界"
624+ if err := tr.render(false); err != nil {
625+ t.Fatal(err)
626+ }
627+ if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
628+ t.Fatalf("targets after a wide icon = %+v", got)
629+ }
630+}
631+
632+// A change of menu path alone republishes the targets without a new line.
633+func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
634+ var buf strings.Builder
635+ tr := &Tray{opt: Options{Out: &buf}}
636+ tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
637+ for range 2 {
638+ if err := tr.render(false); err != nil {
639+ t.Fatal(err)
640+ }
641+ }
642+ tr.items[0].menu = "/menu"
643+ if err := tr.render(false); err != nil {
644+ t.Fatal(err)
645+ }
646+ if n := strings.Count(buf.String(), "\n"); n != 1 {
647+ t.Fatalf("%d lines written, want 1", n)
648+ }
649+ if tg, _ := tr.target(0); tg.Menu != "/menu" {
650+ t.Fatalf("target = %+v", tg)
651+ }
652+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close