Merge pull request #3: feat: interactive modules and tray clicks
17 files · +1973 -15
M .github/workflows/ci.yml +7 -1
| @@ -28,7 +28,13 @@ jobs: | |||
| 28 | 28 | persist-credentials: false | |
| 29 | 29 | - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| 30 | 30 | - 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 | |
| 32 | 38 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| 33 | 39 | - run: make check | |
| 34 | 40 | - run: make race | |
| @@ -28,7 +28,13 @@ jobs: | |||||
| 28 | persist-credentials: false | 28 | 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@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 | |||
| 32 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | 38 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | ||
| 33 | - run: make check | 39 | - run: make check | ||
| 34 | - run: make race | 40 | - run: make race | ||
M README.md +43 -1
| @@ -189,6 +189,40 @@ done | |||
| 189 | 189 | ||
| 190 | 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 | 226 | ### The bundled bar | |
| 193 | 227 | ||
| 194 | 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 | 302 | name = "tray" | |
| 269 | 303 | zone = "right" | |
| 270 | 304 | exec = '"$NEFERBAR_BIN" tray' | |
| 305 | + | interactive = true | |
| 271 | 306 | ``` | |
| 272 | 307 | ||
| 273 | 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 | 313 | 4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser; | |
| 279 | 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 | 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 | ||
| @@ -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 bar | 226 | ### 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 { | |||
| 73 | 73 | Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil), | |
| 74 | 74 | Out: os.Stdout, | |
| 75 | 75 | Log: log, | |
| 76 | + | In: os.Stdin, | |
| 77 | + | Dial: zerobus.SessionBus, | |
| 76 | 78 | } | |
| 77 | 79 | opt.Foreground, opt.Background, opt.Accent = trayColors(log) | |
| 78 | 80 | ||
| @@ -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 { | |||
| 115 | 115 | // mappedW is the logical width the current layer surface was first drawn at. | |
| 116 | 116 | // It resets to 0 when the surface is recreated. | |
| 117 | 117 | 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 | |
| 121 | 125 | } | |
| 122 | 126 | ||
| 123 | 127 | // 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 | 315 | ctx, cancel := context.WithCancel(b.runCtx) | |
| 312 | 316 | m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log) | |
| 313 | 317 | m.Env = b.env | |
| 318 | + | m.Interactive = w.Interactive | |
| 314 | 319 | m.Start(ctx) | |
| 315 | 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 | 329 | for _, r := range next { | |
| 325 | 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 | 335 | if b.lay != nil { | |
| 328 | 336 | b.lay.SetModules(b.mods) | |
| 329 | 337 | } | |
| @@ -498,11 +506,20 @@ func (b *Bar) createSurface(h int32) error { | |||
| 498 | 506 | } | |
| 499 | 507 | b.surf, b.sid = surf, surf.ID() | |
| 500 | 508 | b.mappedW = 0 | |
| 509 | + | b.resetInput() | |
| 501 | 510 | b.fresh = true | |
| 502 | 511 | b.configured, b.canPresent = false, false | |
| 503 | 512 | 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 | 523 | // step reparses changed modules and draws when something is ready. | |
| 507 | 524 | func (b *Bar) step() error { | |
| 508 | 525 | if b.lay != nil && b.lay.Update() { | |
| @@ -524,6 +541,10 @@ func (b *Bar) draw() error { | |||
| 524 | 541 | b.Stats.NoSlot++ // Released retries | |
| 525 | 542 | 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 | 548 | if err = b.present(); err != nil { | |
| 528 | 549 | 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 | 892 | // Error logs non-fatal connection errors. | |
| 826 | 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 | 897 | func (b *Bar) OutputRemoved(uint32) {} | |
| 831 | 898 | func (b *Bar) Locked() {} | |
| 832 | 899 | func (b *Bar) LockFinished() {} | |
| 833 | - | func (b *Bar) Pointer(*neferclient.PointerEvent) {} | |
| 834 | 900 | func (b *Bar) Key(*neferclient.KeyEvent) {} | |
| 835 | 901 | func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {} | |
| 836 | 902 | 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 int32 | 117 | mappedW int32 | ||
| 118 | - | Stats Stats | 118 | + | |
| 119 | - | fatal error | 119 | + | ptr pointerState // pointer events turned into module lines |
| 120 | - | closed bool | 120 | + | 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 came | 127 | // 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.env | 317 | 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 = 0 | 508 | b.mappedW = 0 | ||
| 509 | + | b.resetInput() | |||
| 501 | b.fresh = true | 510 | b.fresh = true | ||
| 502 | b.configured, b.canPresent = false, false | 511 | b.configured, b.canPresent = false, false | ||
| 503 | return nil | 512 | 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 retries | 541 | b.Stats.NoSlot++ // Released retries | ||
| 525 | return nil | 542 | 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 err | 549 | 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 { | |||
| 37 | 37 | Name string `toml:"name"` | |
| 38 | 38 | Zone string `toml:"zone"` // left | center | right | |
| 39 | 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 | 44 | // 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 | right | 38 | 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) { | |||
| 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 | + | } | |
| @@ -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 { | |||
| 18 | 18 | input []byte // reset + erase + frame, reused for each Write | |
| 19 | 19 | cells []gpu.Cell | |
| 20 | 20 | 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 | 33 | // Layout owns the sources and the composed row. | |
| @@ -208,6 +218,7 @@ func (l *Layout) Compose() []gpu.Cell { | |||
| 208 | 218 | } | |
| 209 | 219 | var left, center, right int | |
| 210 | 220 | for _, s := range l.sources { | |
| 221 | + | s.start, s.shown = 0, 0 | |
| 211 | 222 | n := len(s.cells) | |
| 212 | 223 | switch s.M.Zone { | |
| 213 | 224 | case module.Left: | |
| @@ -224,14 +235,18 @@ func (l *Layout) Compose() []gpu.Cell { | |||
| 224 | 235 | x := l.cols - right | |
| 225 | 236 | for _, s := range l.sources { | |
| 226 | 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 | 243 | // Left zone from the left edge, clipped to its budget. | |
| 231 | 244 | x, limit := 0, left | |
| 232 | 245 | for _, s := range l.sources { | |
| 233 | 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 | 252 | // 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 | 260 | x, limit = start, start+center | |
| 246 | 261 | for _, s := range l.sources { | |
| 247 | 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 | 269 | 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 | + | } | |
| @@ -18,6 +18,16 @@ type Source struct { | |||||
| 18 | input []byte // reset + erase + frame, reused for each Write | 18 | input []byte // reset + erase + frame, reused for each Write | ||
| 19 | cells []gpu.Cell | 19 | cells []gpu.Cell | ||
| 20 | failed bool | 20 | 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 int | 219 | 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 - right | 235 | 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, left | 244 | 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 the | 252 | // 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+center | 260 | 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.row | 269 | 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) { | |||
| 221 | 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 | + | } | |
| @@ -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 ( | |||
| 15 | 15 | "path/filepath" | |
| 16 | 16 | "strings" | |
| 17 | 17 | "sync" | |
| 18 | + | "sync/atomic" | |
| 18 | 19 | "syscall" | |
| 19 | 20 | "time" | |
| 20 | 21 | ) | |
| @@ -46,22 +47,62 @@ type Module struct { | |||
| 46 | 47 | Exec string | |
| 47 | 48 | // Env is added to the script's environment, as KEY=value entries. | |
| 48 | 49 | Env []string | |
| 50 | + | // Interactive gives the script a stdin that carries Send lines. Set it | |
| 51 | + | // before Start. | |
| 52 | + | Interactive bool | |
| 49 | 53 | ||
| 50 | 54 | mu sync.Mutex | |
| 51 | 55 | pending []byte // latest complete frame | |
| 52 | 56 | dirty bool | |
| 53 | 57 | 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 | 62 | wake chan<- struct{} | |
| 56 | 63 | log *slog.Logger | |
| 57 | 64 | ||
| 58 | 65 | 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 | 77 | // New creates a module. wake receives a non-blocking signal after each frame. | |
| 62 | 78 | func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module { | |
| 63 | 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 | 108 | // 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 | 190 | func (m *Module) runOnce(ctx context.Context) error { | |
| 191 | + | m.gen.Add(1) | |
| 150 | 192 | cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec) | |
| 151 | 193 | cmd.Stderr = &logWriter{log: m.log, name: m.Name} | |
| 152 | 194 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM} | |
| @@ -162,10 +204,31 @@ func (m *Module) runOnce(ctx context.Context) error { | |||
| 162 | 204 | if err != nil { | |
| 163 | 205 | 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 | 221 | if err = cmd.Start(); err != nil { | |
| 166 | 222 | return err | |
| 167 | 223 | } | |
| 224 | + | var stopWriter func() | |
| 225 | + | if in != nil { | |
| 226 | + | stopWriter = m.pumpInput(in) | |
| 227 | + | } | |
| 168 | 228 | m.readFrames(out) | |
| 229 | + | if stopWriter != nil { | |
| 230 | + | stopWriter() | |
| 231 | + | } | |
| 169 | 232 | err = cmd.Wait() | |
| 170 | 233 | // The script may have started children that kept running (a "while" loop | |
| 171 | 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 | 236 | 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 | 265 | // readFrames splits r into frames and publishes each one. | |
| 177 | 266 | func (m *Module) readFrames(r io.Reader) { | |
| 178 | 267 | buf := make([]byte, readSize) | |
| @@ -219,6 +308,23 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 219 | 308 | 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 | 328 | // PublishForTest injects a frame as if the script had printed it. | |
| 223 | 329 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | |
| 224 | 330 | ||
| @@ -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 string | 47 | 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 []string | 49 | 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.Mutex | 54 | mu sync.Mutex | ||
| 51 | pending []byte // latest complete frame | 55 | pending []byte // latest complete frame | ||
| 52 | dirty bool | 56 | dirty bool | ||
| 53 | failed bool // the script is not running; show an error marker | 57 | 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.Logger | 63 | log *slog.Logger | ||
| 57 | 64 | ||||
| 58 | done sync.WaitGroup // Start adds one; Run releases it when its process is gone | 65 | 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 the | 108 | // 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 err | 205 | 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 err | 222 | 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" loop | 233 | // 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 err | 236 | 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), nil | 308 | 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) { | |||
| 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 | + | } | |
| @@ -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 ( | |||
| 19 | 19 | "slices" | |
| 20 | 20 | "strconv" | |
| 21 | 21 | "strings" | |
| 22 | + | "sync" | |
| 22 | 23 | ||
| 24 | + | vt "github.com/bnema/vev-vt" | |
| 23 | 25 | "github.com/bnema/zerobus" | |
| 24 | 26 | ) | |
| 25 | 27 | ||
| @@ -46,6 +48,11 @@ type Options struct { | |||
| 46 | 48 | Foreground, Background, Accent RGB | |
| 47 | 49 | Out io.Writer // one line per change | |
| 48 | 50 | 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 | 58 | type status uint8 | |
| @@ -68,6 +75,8 @@ type item struct { | |||
| 68 | 75 | resolved bool | |
| 69 | 76 | color RGB | |
| 70 | 77 | hasColor bool | |
| 78 | + | menu string // object path of its dbusmenu, "" when none | |
| 79 | + | isMenu bool // ItemIsMenu: Activate should open the menu | |
| 71 | 80 | loaded bool // GetAll answered at least once | |
| 72 | 81 | pending uint32 // serial of a GetAll in flight | |
| 73 | 82 | stale bool // a change arrived while GetAll was in flight | |
| @@ -100,6 +109,9 @@ type Tray struct { | |||
| 100 | 109 | last []byte | |
| 101 | 110 | hist histogram | |
| 102 | 111 | 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 | 117 | // 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 | 126 | defer stop() | |
| 115 | 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 | 146 | // Calls made from here on only go to the bus, which always answers. | |
| 118 | 147 | // Handle sees what arrives meanwhile. | |
| 119 | 148 | c.Handle = t.dispatch | |
| @@ -464,6 +493,10 @@ func (t *Tray) load(it *item, r *zerobus.Reader) { | |||
| 464 | 493 | setStr(&it.title, r.Str()) | |
| 465 | 494 | case sig == "s" && key == "IconName": | |
| 466 | 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 | 500 | case sig == "s" && key == "Status": | |
| 468 | 501 | it.status = parseStatus(r.Str()) | |
| 469 | 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 | 704 | func (t *Tray) render(force bool) error { | |
| 670 | 705 | f := t.frame[:0] | |
| 706 | + | ts := t.targets[:0] | |
| 707 | + | col := 0 | |
| 671 | 708 | for _, it := range t.items { | |
| 672 | 709 | if !it.loaded || it.status == passive || it.icon == "" { | |
| 673 | 710 | continue | |
| 674 | 711 | } | |
| 675 | 712 | if len(f) > 0 { | |
| 676 | 713 | f = append(f, ' ') | |
| 714 | + | col++ | |
| 677 | 715 | } | |
| 678 | 716 | c, bold := t.opt.Foreground, false | |
| 679 | 717 | switch { | |
| @@ -695,10 +733,25 @@ func (t *Tray) render(force bool) error { | |||
| 695 | 733 | f = append(f, 'm') | |
| 696 | 734 | f = append(f, it.icon...) | |
| 697 | 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 | 747 | 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 { | |
| 702 | 755 | return nil | |
| 703 | 756 | } | |
| 704 | 757 | t.frame, t.last = t.last, t.frame | |
| @@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error { | |||
| 708 | 761 | 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 | 771 | const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" | |
| 712 | 772 | "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> | |
| 713 | 773 | <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 RGB | 48 | Foreground, Background, Accent RGB | ||
| 47 | Out io.Writer // one line per change | 49 | Out io.Writer // one line per change | ||
| 48 | Log *slog.Logger | 50 | 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 uint8 | 58 | type status uint8 | ||
| @@ -68,6 +75,8 @@ type item struct { | |||||
| 68 | resolved bool | 75 | resolved bool | ||
| 69 | color RGB | 76 | color RGB | ||
| 70 | hasColor bool | 77 | 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 once | 80 | loaded bool // GetAll answered at least once | ||
| 72 | pending uint32 // serial of a GetAll in flight | 81 | pending uint32 // serial of a GetAll in flight | ||
| 73 | stale bool // a change arrived while GetAll was in flight | 82 | stale bool // a change arrived while GetAll was in flight | ||
| @@ -100,6 +109,9 @@ type Tray struct { | |||||
| 100 | last []byte | 109 | last []byte | ||
| 101 | hist histogram | 110 | hist histogram | ||
| 102 | err error // first fatal error from dispatch | 111 | 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. It | 117 | // 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.dispatch | 148 | 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 | continue | 710 | 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, false | 716 | 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 = f | 748 | + | 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 nil | 755 | return nil | ||
| 703 | } | 756 | } | ||
| 704 | t.frame, t.last = t.last, t.frame | 757 | t.frame, t.last = t.last, t.frame | ||
| @@ -708,6 +761,13 @@ func (t *Tray) render(force bool) error { | |||||
| 708 | return nil | 761 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | + | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "log/slog" | |
| 8 | 9 | "os/exec" | |
| 10 | + | "slices" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| @@ -60,12 +62,20 @@ type fakeItem struct { | |||
| 60 | 62 | cur [3]string // id, icon name, status | |
| 61 | 63 | pixel [4]byte // the one ARGB pixel of its icon | |
| 62 | 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 | 74 | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | |
| 66 | 75 | c := dial(t, addr) | |
| 67 | 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 | 79 | go f.serve() | |
| 70 | 80 | return f | |
| 71 | 81 | } | |
| @@ -96,6 +106,12 @@ func (f *fakeItem) serve() { | |||
| 96 | 106 | (<-f.cmds)() | |
| 97 | 107 | continue | |
| 98 | 108 | } | |
| 109 | + | if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface { | |
| 110 | + | if !f.action(m) { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | continue | |
| 114 | + | } | |
| 99 | 115 | if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" { | |
| 100 | 116 | 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 | 170 | // register registers the item with the watcher, by bus name. | |
| 127 | 171 | func (f *fakeItem) register(t *testing.T) { | |
| 128 | 172 | f.run(t, func() { | |
| @@ -185,6 +229,12 @@ func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string | |||
| 185 | 229 | // startTray runs a tray on the bus. Cancel it and receive from the channel | |
| 186 | 230 | // to stop it; the test cleanup does both when the test did not. | |
| 187 | 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 | 238 | pr, pw := io.Pipe() | |
| 189 | 239 | ctx, cancel := context.WithCancel(context.Background()) | |
| 190 | 240 | done := make(chan error, 1) | |
| @@ -193,7 +243,7 @@ func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan er | |||
| 193 | 243 | opt := Options{ | |
| 194 | 244 | Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), | |
| 195 | 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 | 248 | c := dial(t, addr) | |
| 199 | 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 | + | } | |
| @@ -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, status | 62 | cur [3]string // id, icon name, status | ||
| 61 | pixel [4]byte // the one ARGB pixel of its icon | 63 | 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 f | 80 | return f | ||
| 71 | } | 81 | } | ||
| @@ -96,6 +106,12 @@ func (f *fakeItem) serve() { | |||||
| 96 | (<-f.cmds)() | 106 | (<-f.cmds)() | ||
| 97 | continue | 107 | 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 | continue | 116 | 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 channel | 229 | // 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 | + | } | |||