detached ed1707c

Merge 8ed066e88236b9117029013111a00c42ddb99f4a into e8a5ba67e7808a959cf5c165fe3c21f2d7598db2

commit
ed1707cea3a6b75358a38ff23d65e43da8f3bec7
author
ermite · · 2026-10-19 10:45 UTC
merge of
e8a5ba6 + 8ed066e · diff against the first parent
signature
unsigned
42 files · +1879 -24
M .hexcheck.yaml +1 -1
@@ -25,7 +25,7 @@ allow:
2525 ruleSettings:
2626 missing-generated-mock-for-port:
2727 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
28- excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go]
28+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go]
2929 suspicious-business-logic-in-adapter:
3030 # app is the composition root; starting core and wiring channels is its job.
3131 excludePaths: [internal/app/app.go, internal/app/session.go]
@@ -25,7 +25,7 @@ allow:
25 ruleSettings:25 ruleSettings:
26 missing-generated-mock-for-port:26 missing-generated-mock-for-port:
27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
28- excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go]28+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go]
29 suspicious-business-logic-in-adapter:29 suspicious-business-logic-in-adapter:
30 # app is the composition root; starting core and wiring channels is its job.30 # app is the composition root; starting core and wiring channels is its job.
31 excludePaths: [internal/app/app.go, internal/app/session.go]31 excludePaths: [internal/app/app.go, internal/app/session.go]
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M README.md +1 -0
@@ -281,6 +281,7 @@ bind.cmd+shift+f = toggle-fullscreen
281281 bind.cmd+s = toggle-stash-visible
282282 bind.cmd+shift+s = toggle-window-stash
283283 bind.cmd+o = toggle-overview
284+# bind.cmd+g = hint
284285 bind.cmd+tab = switch-column-next
285286 bind.cmd+shift+tab = switch-column-prev
286287
@@ -281,6 +281,7 @@ bind.cmd+shift+f = toggle-fullscreen
281 bind.cmd+s = toggle-stash-visible281 bind.cmd+s = toggle-stash-visible
282 bind.cmd+shift+s = toggle-window-stash282 bind.cmd+shift+s = toggle-window-stash
283 bind.cmd+o = toggle-overview283 bind.cmd+o = toggle-overview
284+# bind.cmd+g = hint
284 bind.cmd+tab = switch-column-next285 bind.cmd+tab = switch-column-next
285 bind.cmd+shift+tab = switch-column-prev286 bind.cmd+shift+tab = switch-column-prev
286 287
M docs/config.md +14 -0
@@ -55,6 +55,7 @@ See [examples/config](../examples/config) for every key with its default.
5555 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
5656 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
5757 | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
58+| `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) |
5859 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
5960 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
6061 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
@@ -191,6 +192,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
191192 | `toggle-window-stash` | Move the focused window to the [stash](#stash), or back to its column |
192193 | `toggle-stash-visible` | Show or hide the stash |
193194 | `toggle-overview` | Open or close the [overview](#overview) |
195+| `hint` | Label the links and buttons on the focused screen, then click the one you type ([hint mode](#hint-mode)) |
194196 | `switch-column-next` / `-prev` | Switch to the previously used column. Hold `cmd` and press again to go further back |
195197 | `maximize-column` | Toggle full width for the focused column |
196198 | `cycle-column-width` | Grow the focused column by one [cell](#column-widths), then back to equal widths |
@@ -295,3 +297,15 @@ A workspace's stash shows as a pile of cards. Selecting the pile unfolds it into
295297 With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards; where the carousel does not wrap round, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
296298
297299 Otherwise, left from the first column unfolds the stash. Up/down walk the cards, the focus-workspace binds change workspace, and right goes back to the columns.
300+
301+### Hint mode
302+
303+`hint` labels the links, URLs and buttons visible on the focused screen, so you can click one from the keyboard. No bind is set by default; for example `bind.cmd+g = hint`.
304+
305+- Each target gets a label of lowercase letters. With up to 26 targets the labels are one letter, with more they are two.
306+- Type a label to click its target: the pointer moves to the center of the target and presses the left button. With two-letter labels, the labels that do not match what you typed disappear and the typed letters are dimmed.
307+- `BackSpace` removes the last typed letter. `Escape` cancels, and so does any other key, which no window receives.
308+- Only the visible area of windows is scanned: a target covered by another window or outside the screen has no label. Targets on other screens are not labeled.
309+- `hint.targets = links` labels links and URLs only, not buttons.
310+- `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens.
311+- No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels.
@@ -55,6 +55,7 @@ See [examples/config](../examples/config) for every key with its default.
55 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |55 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
56 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |56 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
57 | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |57 | `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
58+| `hint.targets` | `clickable` | What [hint mode](#hint-mode) labels: `clickable` (links, URLs and buttons) or `links` (links and URLs only) |
58 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |59 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
59 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |60 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
60 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |61 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
@@ -191,6 +192,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
191 | `toggle-window-stash` | Move the focused window to the [stash](#stash), or back to its column |192 | `toggle-window-stash` | Move the focused window to the [stash](#stash), or back to its column |
192 | `toggle-stash-visible` | Show or hide the stash |193 | `toggle-stash-visible` | Show or hide the stash |
193 | `toggle-overview` | Open or close the [overview](#overview) |194 | `toggle-overview` | Open or close the [overview](#overview) |
195+| `hint` | Label the links and buttons on the focused screen, then click the one you type ([hint mode](#hint-mode)) |
194 | `switch-column-next` / `-prev` | Switch to the previously used column. Hold `cmd` and press again to go further back |196 | `switch-column-next` / `-prev` | Switch to the previously used column. Hold `cmd` and press again to go further back |
195 | `maximize-column` | Toggle full width for the focused column |197 | `maximize-column` | Toggle full width for the focused column |
196 | `cycle-column-width` | Grow the focused column by one [cell](#column-widths), then back to equal widths |198 | `cycle-column-width` | Grow the focused column by one [cell](#column-widths), then back to equal widths |
@@ -295,3 +297,15 @@ A workspace's stash shows as a pile of cards. Selecting the pile unfolds it into
295 With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards; where the carousel does not wrap round, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.297 With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards; where the carousel does not wrap round, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
296 298
297 Otherwise, left from the first column unfolds the stash. Up/down walk the cards, the focus-workspace binds change workspace, and right goes back to the columns.299 Otherwise, left from the first column unfolds the stash. Up/down walk the cards, the focus-workspace binds change workspace, and right goes back to the columns.
300+
301+### Hint mode
302+
303+`hint` labels the links, URLs and buttons visible on the focused screen, so you can click one from the keyboard. No bind is set by default; for example `bind.cmd+g = hint`.
304+
305+- Each target gets a label of lowercase letters. With up to 26 targets the labels are one letter, with more they are two.
306+- Type a label to click its target: the pointer moves to the center of the target and presses the left button. With two-letter labels, the labels that do not match what you typed disappear and the typed letters are dimmed.
307+- `BackSpace` removes the last typed letter. `Escape` cancels, and so does any other key, which no window receives.
308+- Only the visible area of windows is scanned: a target covered by another window or outside the screen has no label. Targets on other screens are not labeled.
309+- `hint.targets = links` labels links and URLs only, not buttons.
310+- `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens.
311+- No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels.
M examples/config +5 -0
@@ -94,6 +94,9 @@ focus.flash-width = 2
9494 focus.flash-opacity = 0.6
9595 focus.flash-duration = 200ms
9696 focus.flash-color =
97+# Hint mode labels the links, URLs and buttons on the focused screen:
98+# clickable (links, URLs and buttons) or links (links and URLs only).
99+# hint.targets = clickable
97100
98101 # Named workspaces are outside the numbered list and require a bind. The bind
99102 # shows one; pressing it again returns to the previous workspace.
@@ -195,6 +198,8 @@ bind.cmd+shift+f = toggle-fullscreen
195198 bind.cmd+s = toggle-stash-visible
196199 bind.cmd+shift+s = toggle-window-stash
197200 bind.cmd+o = toggle-overview
201+# Label the links and buttons on screen and click one by typing its label.
202+# bind.cmd+g = hint
198203 bind.cmd+q = close-window
199204 bind.ctrl+alt+backspace = quit
200205 bind.ctrl+cmd+space = spawn fuzzel
@@ -94,6 +94,9 @@ focus.flash-width = 2
94 focus.flash-opacity = 0.694 focus.flash-opacity = 0.6
95 focus.flash-duration = 200ms95 focus.flash-duration = 200ms
96 focus.flash-color =96 focus.flash-color =
97+# Hint mode labels the links, URLs and buttons on the focused screen:
98+# clickable (links, URLs and buttons) or links (links and URLs only).
99+# hint.targets = clickable
97 100
98 # Named workspaces are outside the numbered list and require a bind. The bind101 # Named workspaces are outside the numbered list and require a bind. The bind
99 # shows one; pressing it again returns to the previous workspace.102 # shows one; pressing it again returns to the previous workspace.
@@ -195,6 +198,8 @@ bind.cmd+shift+f = toggle-fullscreen
195 bind.cmd+s = toggle-stash-visible198 bind.cmd+s = toggle-stash-visible
196 bind.cmd+shift+s = toggle-window-stash199 bind.cmd+shift+s = toggle-window-stash
197 bind.cmd+o = toggle-overview200 bind.cmd+o = toggle-overview
201+# Label the links and buttons on screen and click one by typing its label.
202+# bind.cmd+g = hint
198 bind.cmd+q = close-window203 bind.cmd+q = close-window
199 bind.ctrl+alt+backspace = quit204 bind.ctrl+alt+backspace = quit
200 bind.ctrl+cmd+space = spawn fuzzel205 bind.ctrl+cmd+space = spawn fuzzel
M go.mod +5 -0
@@ -15,14 +15,19 @@ require (
1515 github.com/godbus/dbus/v5 v5.2.2
1616 github.com/rs/zerolog v1.35.1
1717 github.com/stretchr/testify v1.12.1
18+ golang.org/x/image v0.46.0
1819 golang.org/x/sys v0.48.0
1920 golang.org/x/term v0.46.0
2021 )
2122
2223 require (
24+ bnema.dev/hintscan v0.0.0-00010101000000-000000000000
2325 github.com/mattn/go-colorable v0.1.15 // indirect
2426 github.com/mattn/go-isatty v0.0.22 // indirect
2527 github.com/stretchr/objx v0.5.3 // indirect
2628 go.yaml.in/yaml/v3 v3.0.5 // indirect
29+ golang.org/x/text v0.42.0 // indirect
2730 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
2831 )
32+
33+replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner
@@ -15,14 +15,19 @@ require (
15 github.com/godbus/dbus/v5 v5.2.215 github.com/godbus/dbus/v5 v5.2.2
16 github.com/rs/zerolog v1.35.116 github.com/rs/zerolog v1.35.1
17 github.com/stretchr/testify v1.12.117 github.com/stretchr/testify v1.12.1
18+ golang.org/x/image v0.46.0
18 golang.org/x/sys v0.48.019 golang.org/x/sys v0.48.0
19 golang.org/x/term v0.46.020 golang.org/x/term v0.46.0
20 )21 )
21 22
22 require (23 require (
24+ bnema.dev/hintscan v0.0.0-00010101000000-000000000000
23 github.com/mattn/go-colorable v0.1.15 // indirect25 github.com/mattn/go-colorable v0.1.15 // indirect
24 github.com/mattn/go-isatty v0.0.22 // indirect26 github.com/mattn/go-isatty v0.0.22 // indirect
25 github.com/stretchr/objx v0.5.3 // indirect27 github.com/stretchr/objx v0.5.3 // indirect
26 go.yaml.in/yaml/v3 v3.0.5 // indirect28 go.yaml.in/yaml/v3 v3.0.5 // indirect
29+ golang.org/x/text v0.42.0 // indirect
27 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect30 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
28 )31 )
32+
33+replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner
M go.sum +4 -0
@@ -30,9 +30,13 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
3030 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
3131 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
3232 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
33+golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
34+golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
3335 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
3436 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
3537 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
3638 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
39+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
40+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
3741 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
3842 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
@@ -30,9 +30,13 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
30 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=30 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
31 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=31 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
32 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=32 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
33+golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
34+golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
33 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=35 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
34 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=36 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
35 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=37 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
36 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=38 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
39+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
40+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
37 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=41 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
38 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=42 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
M internal/adapters/config/config.go +10 -0
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114114 "move-workspace-prev": true,
115115 "move-workspace-next": true,
116116 "toggle-floating": true,
117+ "hint": true,
117118 }
118119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -847,6 +848,15 @@ func set(c *ports.Config, key, v string) error {
847848 return err
848849 }
849850 c.Focus.Flash = b
851+ case "hint.targets":
852+ switch v {
853+ case "clickable":
854+ c.Hint.LinksOnly = false
855+ case "links":
856+ c.Hint.LinksOnly = true
857+ default:
858+ return fmt.Errorf("must be clickable or links")
859+ }
850860 case "focus.flash-width":
851861 n, err := strconv.Atoi(v)
852862 if err != nil || n < 1 || n > 8 {
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114 "move-workspace-prev": true,114 "move-workspace-prev": true,
115 "move-workspace-next": true,115 "move-workspace-next": true,
116 "toggle-floating": true,116 "toggle-floating": true,
117+ "hint": true,
117 }118 }
118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -847,6 +848,15 @@ func set(c *ports.Config, key, v string) error {
847 return err848 return err
848 }849 }
849 c.Focus.Flash = b850 c.Focus.Flash = b
851+ case "hint.targets":
852+ switch v {
853+ case "clickable":
854+ c.Hint.LinksOnly = false
855+ case "links":
856+ c.Hint.LinksOnly = true
857+ default:
858+ return fmt.Errorf("must be clickable or links")
859+ }
850 case "focus.flash-width":860 case "focus.flash-width":
851 n, err := strconv.Atoi(v)861 n, err := strconv.Atoi(v)
852 if err != nil || n < 1 || n > 8 {862 if err != nil || n < 1 || n > 8 {
M internal/adapters/config/config_test.go +23 -0
@@ -953,3 +953,26 @@ func TestLoadRejectsFIFO(t *testing.T) {
953953 t.Fatal("Load blocked on a FIFO")
954954 }
955955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
@@ -953,3 +953,26 @@ func TestLoadRejectsFIFO(t *testing.T) {
953 t.Fatal("Load blocked on a FIFO")953 t.Fatal("Load blocked on a FIFO")
954 }954 }
955 }955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
M internal/adapters/drm/overlay.go +3 -0
@@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7070 if s.Flash != nil {
7171 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
7272 }
73+ if len(s.HintLabels) > 0 {
74+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "hint_labels"
75+ }
7376 if s.Transform != 0 {
7477 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
7578 }
@@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
70 if s.Flash != nil {70 if s.Flash != nil {
71 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"71 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
72 }72 }
73+ if len(s.HintLabels) > 0 {
74+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "hint_labels"
75+ }
73 if s.Transform != 0 {76 if s.Transform != 0 {
74 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"77 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
75 }78 }
M internal/adapters/drm/scanout.go +4 -0
@@ -94,6 +94,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9494 if s.Flash != nil {
9595 return ports.SurfaceContent{}, "focus_flash"
9696 }
97+ // Hint labels are composed over the window.
98+ if len(s.HintLabels) > 0 {
99+ return ports.SurfaceContent{}, "hint_labels"
100+ }
97101 if s.Transform != 0 {
98102 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
99103 return ports.SurfaceContent{}, "output_transform"
@@ -94,6 +94,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
94 if s.Flash != nil {94 if s.Flash != nil {
95 return ports.SurfaceContent{}, "focus_flash"95 return ports.SurfaceContent{}, "focus_flash"
96 }96 }
97+ // Hint labels are composed over the window.
98+ if len(s.HintLabels) > 0 {
99+ return ports.SurfaceContent{}, "hint_labels"
100+ }
97 if s.Transform != 0 {101 if s.Transform != 0 {
98 // The scanout buffer would have to be pre-rotated: rotated outputs compose.102 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
99 return ports.SurfaceContent{}, "output_transform"103 return ports.SurfaceContent{}, "output_transform"
A internal/adapters/hintlabel/hintlabel.go +131 -0
@@ -0,0 +1,131 @@
1+// Package hintlabel rasterizes the keyboard hint labels on the CPU: a small
2+// box with the label text in Go Mono, as premultiplied BGRA pixels for the
3+// renderer to upload and draw.
4+package hintlabel
5+
6+import (
7+ "image"
8+ "image/color"
9+ "image/draw"
10+ "math"
11+ "sync"
12+
13+ "golang.org/x/image/font"
14+ "golang.org/x/image/font/gofont/gomono"
15+ "golang.org/x/image/font/opentype"
16+ "golang.org/x/image/math/fixed"
17+)
18+
19+const (
20+ // fontSize is the text size in logical pixels.
21+ fontSize = 13
22+ // padX and padY are the padding around the text in logical pixels.
23+ padX, padY = 3, 1
24+ // maxEntries bounds the cache: it is cleared when it grows past it.
25+ maxEntries = 1024
26+)
27+
28+var (
29+ background = color.RGBA{0xf9, 0xe2, 0xaf, 0xff}
30+ ink = color.RGBA{0x1e, 0x1e, 0x2e, 0xff}
31+ typedInk = color.RGBA{0x7f, 0x84, 0x9c, 0xff}
32+
33+ parseOnce sync.Once
34+ parsed *opentype.Font
35+ parseErr error
36+)
37+
38+type key struct {
39+ text string
40+ typed int
41+ scale float64
42+}
43+
44+type entry struct {
45+ pix []byte
46+ w, h int
47+}
48+
49+// Raster draws and caches labels. It is not safe for concurrent use: the
50+// renderer owns it.
51+type Raster struct {
52+ faces map[float64]font.Face
53+ cache map[key]entry
54+}
55+
56+// Label returns the label box for text, whose first typed characters are
57+// drawn dimmed, as premultiplied BGRA at scale physical pixels per logical
58+// pixel. The slice is owned by the cache and stays valid until the cache is
59+// cleared by a later call: do not modify it. Empty text, or a font that does
60+// not load, yields a nil slice.
61+func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {
62+ if text == "" {
63+ return nil, 0, 0
64+ }
65+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
66+ scale = 1
67+ }
68+ typed = max(0, min(typed, len(text)))
69+ k := key{text, typed, scale}
70+ if e, ok := r.cache[k]; ok {
71+ return e.pix, e.w, e.h
72+ }
73+ face := r.face(scale)
74+ if face == nil {
75+ return nil, 0, 0
76+ }
77+ if r.cache == nil || len(r.cache) >= maxEntries {
78+ r.cache = map[key]entry{}
79+ }
80+ e := render(face, text, typed, scale)
81+ r.cache[k] = e
82+ return e.pix, e.w, e.h
83+}
84+
85+func (r *Raster) face(scale float64) font.Face {
86+ if f, ok := r.faces[scale]; ok {
87+ return f
88+ }
89+ parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) })
90+ if parseErr != nil {
91+ return nil
92+ }
93+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})
94+ if err != nil {
95+ return nil
96+ }
97+ if r.faces == nil || len(r.faces) >= 16 {
98+ for _, old := range r.faces {
99+ _ = old.Close()
100+ }
101+ r.faces = map[float64]font.Face{}
102+ }
103+ r.faces[scale] = f
104+ return f
105+}
106+
107+// render draws the box. Colours are opaque, so the result is premultiplied
108+// by construction.
109+func render(face font.Face, text string, typed int, scale float64) entry {
110+ m := face.Metrics()
111+ px := int(math.Round(padX * scale))
112+ py := int(math.Round(padY * scale))
113+ border := 1
114+ adv := font.MeasureString(face, text).Ceil()
115+ w := adv + 2*px + 2*border
116+ h := m.Height.Ceil() + 2*py + 2*border
117+ img := image.NewRGBA(image.Rect(0, 0, w, h))
118+ draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)
119+ draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)
120+ d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}}
121+ d.Src = image.NewUniform(typedInk)
122+ d.DrawString(text[:typed])
123+ d.Src = image.NewUniform(ink)
124+ d.DrawString(text[typed:])
125+ // RGBA to BGRA in place; alpha is 0xff everywhere.
126+ pix := img.Pix
127+ for i := 0; i+3 < len(pix); i += 4 {
128+ pix[i], pix[i+2] = pix[i+2], pix[i]
129+ }
130+ return entry{pix: pix, w: w, h: h}
131+}
@@ -0,0 +1,131 @@
1+// Package hintlabel rasterizes the keyboard hint labels on the CPU: a small
2+// box with the label text in Go Mono, as premultiplied BGRA pixels for the
3+// renderer to upload and draw.
4+package hintlabel
5+
6+import (
7+ "image"
8+ "image/color"
9+ "image/draw"
10+ "math"
11+ "sync"
12+
13+ "golang.org/x/image/font"
14+ "golang.org/x/image/font/gofont/gomono"
15+ "golang.org/x/image/font/opentype"
16+ "golang.org/x/image/math/fixed"
17+)
18+
19+const (
20+ // fontSize is the text size in logical pixels.
21+ fontSize = 13
22+ // padX and padY are the padding around the text in logical pixels.
23+ padX, padY = 3, 1
24+ // maxEntries bounds the cache: it is cleared when it grows past it.
25+ maxEntries = 1024
26+)
27+
28+var (
29+ background = color.RGBA{0xf9, 0xe2, 0xaf, 0xff}
30+ ink = color.RGBA{0x1e, 0x1e, 0x2e, 0xff}
31+ typedInk = color.RGBA{0x7f, 0x84, 0x9c, 0xff}
32+
33+ parseOnce sync.Once
34+ parsed *opentype.Font
35+ parseErr error
36+)
37+
38+type key struct {
39+ text string
40+ typed int
41+ scale float64
42+}
43+
44+type entry struct {
45+ pix []byte
46+ w, h int
47+}
48+
49+// Raster draws and caches labels. It is not safe for concurrent use: the
50+// renderer owns it.
51+type Raster struct {
52+ faces map[float64]font.Face
53+ cache map[key]entry
54+}
55+
56+// Label returns the label box for text, whose first typed characters are
57+// drawn dimmed, as premultiplied BGRA at scale physical pixels per logical
58+// pixel. The slice is owned by the cache and stays valid until the cache is
59+// cleared by a later call: do not modify it. Empty text, or a font that does
60+// not load, yields a nil slice.
61+func (r *Raster) Label(text string, typed int, scale float64) (pix []byte, w, h int) {
62+ if text == "" {
63+ return nil, 0, 0
64+ }
65+ if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
66+ scale = 1
67+ }
68+ typed = max(0, min(typed, len(text)))
69+ k := key{text, typed, scale}
70+ if e, ok := r.cache[k]; ok {
71+ return e.pix, e.w, e.h
72+ }
73+ face := r.face(scale)
74+ if face == nil {
75+ return nil, 0, 0
76+ }
77+ if r.cache == nil || len(r.cache) >= maxEntries {
78+ r.cache = map[key]entry{}
79+ }
80+ e := render(face, text, typed, scale)
81+ r.cache[k] = e
82+ return e.pix, e.w, e.h
83+}
84+
85+func (r *Raster) face(scale float64) font.Face {
86+ if f, ok := r.faces[scale]; ok {
87+ return f
88+ }
89+ parseOnce.Do(func() { parsed, parseErr = opentype.Parse(gomono.TTF) })
90+ if parseErr != nil {
91+ return nil
92+ }
93+ f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: fontSize * scale, DPI: 72, Hinting: font.HintingFull})
94+ if err != nil {
95+ return nil
96+ }
97+ if r.faces == nil || len(r.faces) >= 16 {
98+ for _, old := range r.faces {
99+ _ = old.Close()
100+ }
101+ r.faces = map[float64]font.Face{}
102+ }
103+ r.faces[scale] = f
104+ return f
105+}
106+
107+// render draws the box. Colours are opaque, so the result is premultiplied
108+// by construction.
109+func render(face font.Face, text string, typed int, scale float64) entry {
110+ m := face.Metrics()
111+ px := int(math.Round(padX * scale))
112+ py := int(math.Round(padY * scale))
113+ border := 1
114+ adv := font.MeasureString(face, text).Ceil()
115+ w := adv + 2*px + 2*border
116+ h := m.Height.Ceil() + 2*py + 2*border
117+ img := image.NewRGBA(image.Rect(0, 0, w, h))
118+ draw.Draw(img, img.Bounds(), image.NewUniform(ink), image.Point{}, draw.Src)
119+ draw.Draw(img, img.Bounds().Inset(border), image.NewUniform(background), image.Point{}, draw.Src)
120+ d := font.Drawer{Dst: img, Face: face, Dot: fixed.Point26_6{X: fixed.I(border + px), Y: fixed.I(border+py) + m.Ascent}}
121+ d.Src = image.NewUniform(typedInk)
122+ d.DrawString(text[:typed])
123+ d.Src = image.NewUniform(ink)
124+ d.DrawString(text[typed:])
125+ // RGBA to BGRA in place; alpha is 0xff everywhere.
126+ pix := img.Pix
127+ for i := 0; i+3 < len(pix); i += 4 {
128+ pix[i], pix[i+2] = pix[i+2], pix[i]
129+ }
130+ return entry{pix: pix, w: w, h: h}
131+}
A internal/adapters/hintlabel/hintlabel_test.go +60 -0
@@ -0,0 +1,60 @@
1+package hintlabel
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestHintLabelSize(t *testing.T) {
10+ var r Raster
11+ _, w1, h1 := r.Label("f", 0, 1)
12+ _, w2, h2 := r.Label("fj", 0, 1)
13+ require.Greater(t, w1, 0)
14+ require.Greater(t, w2, w1, "a longer label is wider")
15+ require.Equal(t, h1, h2)
16+ _, w3, h3 := r.Label("fj", 0, 2)
17+ // The 1 px border does not scale: compare the boxes without it.
18+ require.InDelta(t, 2*(w2-2), w3-2, 1)
19+ require.InDelta(t, 2*(h2-2), h3-2, 1)
20+}
21+
22+func TestHintLabelCache(t *testing.T) {
23+ var r Raster
24+ a, w, h := r.Label("fj", 1, 1)
25+ b, _, _ := r.Label("fj", 1, 1)
26+ require.Len(t, a, w*h*4)
27+ require.Same(t, &a[0], &b[0], "same key returns the cached slice")
28+ c, _, _ := r.Label("fj", 0, 1)
29+ require.NotSame(t, &a[0], &c[0], "typed count is part of the key")
30+}
31+
32+func TestHintLabelPremultiplied(t *testing.T) {
33+ var r Raster
34+ pix, w, h := r.Label("ab", 1, 1.5)
35+ require.Len(t, pix, w*h*4)
36+ for i := 0; i < len(pix); i += 4 {
37+ a := pix[i+3]
38+ require.GreaterOrEqual(t, a, pix[i])
39+ require.GreaterOrEqual(t, a, pix[i+1])
40+ require.GreaterOrEqual(t, a, pix[i+2])
41+ }
42+}
43+
44+func TestHintLabelTypedIsDimmed(t *testing.T) {
45+ var r Raster
46+ plain, _, _ := r.Label("fj", 0, 1)
47+ dim, _, _ := r.Label("fj", 1, 1)
48+ require.NotEqual(t, plain, dim)
49+}
50+
51+func TestHintLabelEmptyAndCacheBound(t *testing.T) {
52+ var r Raster
53+ pix, w, h := r.Label("", 0, 1)
54+ require.Nil(t, pix)
55+ require.Zero(t, w+h)
56+ for i := 0; i < maxEntries+10; i++ {
57+ r.Label("a", 0, 1+float64(i)/100000)
58+ }
59+ require.LessOrEqual(t, len(r.cache), maxEntries)
60+}
@@ -0,0 +1,60 @@
1+package hintlabel
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestHintLabelSize(t *testing.T) {
10+ var r Raster
11+ _, w1, h1 := r.Label("f", 0, 1)
12+ _, w2, h2 := r.Label("fj", 0, 1)
13+ require.Greater(t, w1, 0)
14+ require.Greater(t, w2, w1, "a longer label is wider")
15+ require.Equal(t, h1, h2)
16+ _, w3, h3 := r.Label("fj", 0, 2)
17+ // The 1 px border does not scale: compare the boxes without it.
18+ require.InDelta(t, 2*(w2-2), w3-2, 1)
19+ require.InDelta(t, 2*(h2-2), h3-2, 1)
20+}
21+
22+func TestHintLabelCache(t *testing.T) {
23+ var r Raster
24+ a, w, h := r.Label("fj", 1, 1)
25+ b, _, _ := r.Label("fj", 1, 1)
26+ require.Len(t, a, w*h*4)
27+ require.Same(t, &a[0], &b[0], "same key returns the cached slice")
28+ c, _, _ := r.Label("fj", 0, 1)
29+ require.NotSame(t, &a[0], &c[0], "typed count is part of the key")
30+}
31+
32+func TestHintLabelPremultiplied(t *testing.T) {
33+ var r Raster
34+ pix, w, h := r.Label("ab", 1, 1.5)
35+ require.Len(t, pix, w*h*4)
36+ for i := 0; i < len(pix); i += 4 {
37+ a := pix[i+3]
38+ require.GreaterOrEqual(t, a, pix[i])
39+ require.GreaterOrEqual(t, a, pix[i+1])
40+ require.GreaterOrEqual(t, a, pix[i+2])
41+ }
42+}
43+
44+func TestHintLabelTypedIsDimmed(t *testing.T) {
45+ var r Raster
46+ plain, _, _ := r.Label("fj", 0, 1)
47+ dim, _, _ := r.Label("fj", 1, 1)
48+ require.NotEqual(t, plain, dim)
49+}
50+
51+func TestHintLabelEmptyAndCacheBound(t *testing.T) {
52+ var r Raster
53+ pix, w, h := r.Label("", 0, 1)
54+ require.Nil(t, pix)
55+ require.Zero(t, w+h)
56+ for i := 0; i < maxEntries+10; i++ {
57+ r.Label("a", 0, 1+float64(i)/100000)
58+ }
59+ require.LessOrEqual(t, len(r.cache), maxEntries)
60+}
A internal/adapters/hints/hints.go +181 -0
@@ -0,0 +1,181 @@
1+// Package hints finds clickable targets for hint mode: it captures the last
2+// frame of an output through the capture pipeline and scans it with hintscan.
3+package hints
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ "time"
11+
12+ "bnema.dev/hintscan"
13+
14+ "github.com/bnema/neferwl/internal/adapters/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+// ErrBusy answers a request that arrives while another one is in flight.
19+var ErrBusy = errors.New("hints: scan in progress")
20+
21+// ErrTimeout is the error of a capture that is not answered in time.
22+var ErrTimeout = errors.New("hints: capture timed out")
23+
24+// ErrBadRequest is the error of a request without a usable buffer size.
25+var ErrBadRequest = errors.New("hints: invalid output size")
26+
27+const (
28+ captureTimeout = time.Second
29+ // formatXRGB8888 is the capture format: opaque BGRA in memory.
30+ formatXRGB8888 = 1
31+ maxPixels = 1 << 28
32+)
33+
34+// Run serves requests until ctx is done. It handles one request at a time;
35+// a request that arrives meanwhile is answered with ErrBusy. Every request
36+// gets exactly one result on results.
37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {
38+ return run(ctx, requests, captures, captured, results, captureTimeout)
39+}
40+
41+type worker struct {
42+ scanner *hintscan.Scanner
43+ buf []byte
44+ dst []hintscan.Target
45+ n uint64
46+}
47+
48+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {
49+ w := &worker{scanner: hintscan.NewScanner()}
50+ for {
51+ select {
52+ case <-ctx.Done():
53+ return nil
54+ case req := <-requests:
55+ res := w.serve(ctx, req, requests, captures, captured, results, timeout)
56+ if !send(ctx, results, res) {
57+ return nil
58+ }
59+ }
60+ }
61+}
62+
63+func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
64+ select {
65+ case results <- res:
66+ return true
67+ case <-ctx.Done():
68+ return false
69+ }
70+}
71+
72+func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) ports.HintScanResult {
73+ log := logging.For(ctx, "hints")
74+ res := ports.HintScanResult{Seq: req.Seq, Output: req.Output}
75+ fail := func(err error) ports.HintScanResult {
76+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
77+ res.Err = err
78+ return res
79+ }
80+
81+ if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
82+ return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
83+ }
84+ size := req.Width * req.Height * 4
85+ if cap(w.buf) < size {
86+ w.buf = make([]byte, size)
87+ }
88+ buf := w.buf[:size]
89+
90+ w.n++
91+ id := ports.CaptureIDInternal | w.n
92+ stride := req.Width * 4
93+ start := time.Now()
94+ creq := ports.CaptureRequest{
95+ ID: id,
96+ Output: req.Output,
97+ Region: image.Rect(0, 0, req.Width, req.Height),
98+ Mapped: buf,
99+ Width: req.Width,
100+ Height: req.Height,
101+ Stride: stride,
102+ Format: formatXRGB8888,
103+ Indicate: false,
104+ }
105+
106+ timer := time.NewTimer(timeout)
107+ defer timer.Stop()
108+
109+ select {
110+ case captures <- creq:
111+ case <-timer.C:
112+ return fail(ErrTimeout)
113+ case <-ctx.Done():
114+ return fail(ctx.Err())
115+ }
116+
117+ for {
118+ select {
119+ case done := <-captured:
120+ if done.ID != id {
121+ continue
122+ }
123+ if done.Err != nil {
124+ return fail(fmt.Errorf("hints: capture: %w", done.Err))
125+ }
126+ return w.scan(ctx, req, buf, stride, time.Since(start), res)
127+ case other := <-requests:
128+ // The buffer is in use until the capture is done: answer now.
129+ busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
130+ if !send(ctx, results, busy) {
131+ return fail(ctx.Err())
132+ }
133+ case <-timer.C:
134+ // The output may still write into buf: do not reuse it.
135+ w.buf = nil
136+ return fail(ErrTimeout)
137+ case <-ctx.Done():
138+ w.buf = nil
139+ return fail(ctx.Err())
140+ }
141+ }
142+}
143+
144+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
145+ log := logging.For(ctx, "hints")
146+ kinds := hintscan.Clickable
147+ if req.LinksOnly {
148+ kinds = hintscan.KindSet(1 << hintscan.KindLink)
149+ }
150+ frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}
151+
152+ t0 := time.Now()
153+ targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)
154+ scanDur := time.Since(t0)
155+ if err != nil {
156+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
157+ res.Err = err
158+ return res
159+ }
160+ w.dst = targets
161+
162+ out := make([]ports.HintTarget, 0, len(targets))
163+ for _, t := range targets {
164+ kind := ports.HintLink
165+ if t.Kind == hintscan.KindButton {
166+ kind = ports.HintButton
167+ }
168+ out = append(out, ports.HintTarget{
169+ Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
170+ Kind: kind,
171+ })
172+ }
173+ res.Targets = out
174+ log.Info().
175+ Str("output", req.Output).
176+ Int("targets", len(out)).
177+ Dur("scan", scanDur).
178+ Dur("capture", captureDur).
179+ Msg("hint scan")
180+ return res
181+}
@@ -0,0 +1,181 @@
1+// Package hints finds clickable targets for hint mode: it captures the last
2+// frame of an output through the capture pipeline and scans it with hintscan.
3+package hints
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ "time"
11+
12+ "bnema.dev/hintscan"
13+
14+ "github.com/bnema/neferwl/internal/adapters/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+// ErrBusy answers a request that arrives while another one is in flight.
19+var ErrBusy = errors.New("hints: scan in progress")
20+
21+// ErrTimeout is the error of a capture that is not answered in time.
22+var ErrTimeout = errors.New("hints: capture timed out")
23+
24+// ErrBadRequest is the error of a request without a usable buffer size.
25+var ErrBadRequest = errors.New("hints: invalid output size")
26+
27+const (
28+ captureTimeout = time.Second
29+ // formatXRGB8888 is the capture format: opaque BGRA in memory.
30+ formatXRGB8888 = 1
31+ maxPixels = 1 << 28
32+)
33+
34+// Run serves requests until ctx is done. It handles one request at a time;
35+// a request that arrives meanwhile is answered with ErrBusy. Every request
36+// gets exactly one result on results.
37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {
38+ return run(ctx, requests, captures, captured, results, captureTimeout)
39+}
40+
41+type worker struct {
42+ scanner *hintscan.Scanner
43+ buf []byte
44+ dst []hintscan.Target
45+ n uint64
46+}
47+
48+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {
49+ w := &worker{scanner: hintscan.NewScanner()}
50+ for {
51+ select {
52+ case <-ctx.Done():
53+ return nil
54+ case req := <-requests:
55+ res := w.serve(ctx, req, requests, captures, captured, results, timeout)
56+ if !send(ctx, results, res) {
57+ return nil
58+ }
59+ }
60+ }
61+}
62+
63+func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
64+ select {
65+ case results <- res:
66+ return true
67+ case <-ctx.Done():
68+ return false
69+ }
70+}
71+
72+func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) ports.HintScanResult {
73+ log := logging.For(ctx, "hints")
74+ res := ports.HintScanResult{Seq: req.Seq, Output: req.Output}
75+ fail := func(err error) ports.HintScanResult {
76+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
77+ res.Err = err
78+ return res
79+ }
80+
81+ if req.Width <= 0 || req.Height <= 0 || req.Width*req.Height > maxPixels {
82+ return fail(fmt.Errorf("%w: %dx%d", ErrBadRequest, req.Width, req.Height))
83+ }
84+ size := req.Width * req.Height * 4
85+ if cap(w.buf) < size {
86+ w.buf = make([]byte, size)
87+ }
88+ buf := w.buf[:size]
89+
90+ w.n++
91+ id := ports.CaptureIDInternal | w.n
92+ stride := req.Width * 4
93+ start := time.Now()
94+ creq := ports.CaptureRequest{
95+ ID: id,
96+ Output: req.Output,
97+ Region: image.Rect(0, 0, req.Width, req.Height),
98+ Mapped: buf,
99+ Width: req.Width,
100+ Height: req.Height,
101+ Stride: stride,
102+ Format: formatXRGB8888,
103+ Indicate: false,
104+ }
105+
106+ timer := time.NewTimer(timeout)
107+ defer timer.Stop()
108+
109+ select {
110+ case captures <- creq:
111+ case <-timer.C:
112+ return fail(ErrTimeout)
113+ case <-ctx.Done():
114+ return fail(ctx.Err())
115+ }
116+
117+ for {
118+ select {
119+ case done := <-captured:
120+ if done.ID != id {
121+ continue
122+ }
123+ if done.Err != nil {
124+ return fail(fmt.Errorf("hints: capture: %w", done.Err))
125+ }
126+ return w.scan(ctx, req, buf, stride, time.Since(start), res)
127+ case other := <-requests:
128+ // The buffer is in use until the capture is done: answer now.
129+ busy := ports.HintScanResult{Seq: other.Seq, Output: other.Output, Err: ErrBusy}
130+ if !send(ctx, results, busy) {
131+ return fail(ctx.Err())
132+ }
133+ case <-timer.C:
134+ // The output may still write into buf: do not reuse it.
135+ w.buf = nil
136+ return fail(ErrTimeout)
137+ case <-ctx.Done():
138+ w.buf = nil
139+ return fail(ctx.Err())
140+ }
141+ }
142+}
143+
144+func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte, stride int, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
145+ log := logging.For(ctx, "hints")
146+ kinds := hintscan.Clickable
147+ if req.LinksOnly {
148+ kinds = hintscan.KindSet(1 << hintscan.KindLink)
149+ }
150+ frame := hintscan.Frame{Pix: buf, Stride: stride, Width: req.Width, Height: req.Height, Order: hintscan.OrderBGRA, Scale: req.Scale}
151+
152+ t0 := time.Now()
153+ targets, err := w.scanner.Scan(ctx, frame, hintscan.Options{Kinds: kinds}, w.dst)
154+ scanDur := time.Since(t0)
155+ if err != nil {
156+ log.Info().Err(err).Str("output", req.Output).Msg("hint scan failed")
157+ res.Err = err
158+ return res
159+ }
160+ w.dst = targets
161+
162+ out := make([]ports.HintTarget, 0, len(targets))
163+ for _, t := range targets {
164+ kind := ports.HintLink
165+ if t.Kind == hintscan.KindButton {
166+ kind = ports.HintButton
167+ }
168+ out = append(out, ports.HintTarget{
169+ Rect: ports.Rect{X: t.Rect.Min.X, Y: t.Rect.Min.Y, W: t.Rect.Dx(), H: t.Rect.Dy()},
170+ Kind: kind,
171+ })
172+ }
173+ res.Targets = out
174+ log.Info().
175+ Str("output", req.Output).
176+ Int("targets", len(out)).
177+ Dur("scan", scanDur).
178+ Dur("capture", captureDur).
179+ Msg("hint scan")
180+ return res
181+}
A internal/adapters/hints/hints_test.go +149 -0
@@ -0,0 +1,149 @@
1+package hints
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/draw"
8+ _ "image/png"
9+ "os"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+type harness struct {
19+ requests chan ports.HintScanRequest
20+ captures chan ports.CaptureRequest
21+ captured chan ports.CaptureDone
22+ results chan ports.HintScanResult
23+}
24+
25+func start(t *testing.T, timeout time.Duration) *harness {
26+ t.Helper()
27+ h := &harness{
28+ requests: make(chan ports.HintScanRequest, 1),
29+ captures: make(chan ports.CaptureRequest, 4),
30+ captured: make(chan ports.CaptureDone, 1),
31+ results: make(chan ports.HintScanResult, 4),
32+ }
33+ ctx, cancel := context.WithCancel(context.Background())
34+ exited := make(chan struct{})
35+ go func() {
36+ defer close(exited)
37+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)
38+ }()
39+ t.Cleanup(func() { cancel(); <-exited })
40+ return h
41+}
42+
43+func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
44+ t.Helper()
45+ f, err := os.Open("testdata/browser.png")
46+ require.NoError(t, err)
47+ defer f.Close()
48+ img, _, err := image.Decode(f)
49+ require.NoError(t, err)
50+ b := img.Bounds()
51+ rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
52+ draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src)
53+ for i := 0; i < len(rgba.Pix); i += 4 {
54+ rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i]
55+ rgba.Pix[i+3] = 0xff
56+ }
57+ return rgba.Pix, b.Dx(), b.Dy()
58+}
59+
60+func TestHintScan(t *testing.T) {
61+ pix, w, hgt := loadBGRA(t)
62+ h := start(t, 5*time.Second)
63+
64+ // Plays the output owner.
65+ go func() {
66+ creq := <-h.captures
67+ copy(creq.Mapped, pix)
68+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
69+ }()
70+
71+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
72+ res := <-h.results
73+ require.NoError(t, res.Err)
74+ require.Equal(t, uint64(7), res.Seq)
75+ require.Equal(t, "HEADLESS-1", res.Output)
76+ var links int
77+ for _, tg := range res.Targets {
78+ require.Positive(t, tg.Rect.W)
79+ require.Positive(t, tg.Rect.H)
80+ if tg.Kind == ports.HintLink {
81+ links++
82+ }
83+ }
84+ require.GreaterOrEqual(t, links, 1)
85+}
86+
87+func TestHintRequestShape(t *testing.T) {
88+ h := start(t, 5*time.Second)
89+ got := make(chan ports.CaptureRequest, 1)
90+ go func() {
91+ creq := <-h.captures
92+ got <- creq
93+ h.captured <- ports.CaptureDone{ID: creq.ID + 1} // another ID: ignored
94+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
95+ }()
96+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
97+ res := <-h.results
98+ require.NoError(t, res.Err)
99+ creq := <-got
100+ require.NotZero(t, creq.ID&ports.CaptureIDInternal)
101+ require.False(t, creq.Indicate)
102+ require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
103+ require.Len(t, creq.Mapped, 64*32*4)
104+ require.Equal(t, 64*4, creq.Stride)
105+ require.Equal(t, uint32(1), creq.Format)
106+}
107+
108+func TestHintCaptureError(t *testing.T) {
109+ h := start(t, 5*time.Second)
110+ boom := errors.New("boom")
111+ go func() {
112+ creq := <-h.captures
113+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
114+ }()
115+ h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
116+ res := <-h.results
117+ require.ErrorIs(t, res.Err, boom)
118+ require.Equal(t, uint64(3), res.Seq)
119+ require.Empty(t, res.Targets)
120+}
121+
122+func TestHintTimeout(t *testing.T) {
123+ h := start(t, 50*time.Millisecond)
124+ h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
125+ res := <-h.results
126+ require.ErrorIs(t, res.Err, ErrTimeout)
127+ require.Equal(t, uint64(4), res.Seq)
128+}
129+
130+func TestHintBusy(t *testing.T) {
131+ h := start(t, 5*time.Second)
132+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
133+ creq := <-h.captures
134+ h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
135+ res := <-h.results
136+ require.ErrorIs(t, res.Err, ErrBusy)
137+ require.Equal(t, uint64(2), res.Seq)
138+ h.captured <- ports.CaptureDone{ID: creq.ID}
139+ res = <-h.results
140+ require.NoError(t, res.Err)
141+ require.Equal(t, uint64(1), res.Seq)
142+}
143+
144+func TestHintBadSize(t *testing.T) {
145+ h := start(t, time.Second)
146+ h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
147+ res := <-h.results
148+ require.ErrorIs(t, res.Err, ErrBadRequest)
149+}
@@ -0,0 +1,149 @@
1+package hints
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/draw"
8+ _ "image/png"
9+ "os"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+type harness struct {
19+ requests chan ports.HintScanRequest
20+ captures chan ports.CaptureRequest
21+ captured chan ports.CaptureDone
22+ results chan ports.HintScanResult
23+}
24+
25+func start(t *testing.T, timeout time.Duration) *harness {
26+ t.Helper()
27+ h := &harness{
28+ requests: make(chan ports.HintScanRequest, 1),
29+ captures: make(chan ports.CaptureRequest, 4),
30+ captured: make(chan ports.CaptureDone, 1),
31+ results: make(chan ports.HintScanResult, 4),
32+ }
33+ ctx, cancel := context.WithCancel(context.Background())
34+ exited := make(chan struct{})
35+ go func() {
36+ defer close(exited)
37+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)
38+ }()
39+ t.Cleanup(func() { cancel(); <-exited })
40+ return h
41+}
42+
43+func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
44+ t.Helper()
45+ f, err := os.Open("testdata/browser.png")
46+ require.NoError(t, err)
47+ defer f.Close()
48+ img, _, err := image.Decode(f)
49+ require.NoError(t, err)
50+ b := img.Bounds()
51+ rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
52+ draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src)
53+ for i := 0; i < len(rgba.Pix); i += 4 {
54+ rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i]
55+ rgba.Pix[i+3] = 0xff
56+ }
57+ return rgba.Pix, b.Dx(), b.Dy()
58+}
59+
60+func TestHintScan(t *testing.T) {
61+ pix, w, hgt := loadBGRA(t)
62+ h := start(t, 5*time.Second)
63+
64+ // Plays the output owner.
65+ go func() {
66+ creq := <-h.captures
67+ copy(creq.Mapped, pix)
68+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
69+ }()
70+
71+ h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
72+ res := <-h.results
73+ require.NoError(t, res.Err)
74+ require.Equal(t, uint64(7), res.Seq)
75+ require.Equal(t, "HEADLESS-1", res.Output)
76+ var links int
77+ for _, tg := range res.Targets {
78+ require.Positive(t, tg.Rect.W)
79+ require.Positive(t, tg.Rect.H)
80+ if tg.Kind == ports.HintLink {
81+ links++
82+ }
83+ }
84+ require.GreaterOrEqual(t, links, 1)
85+}
86+
87+func TestHintRequestShape(t *testing.T) {
88+ h := start(t, 5*time.Second)
89+ got := make(chan ports.CaptureRequest, 1)
90+ go func() {
91+ creq := <-h.captures
92+ got <- creq
93+ h.captured <- ports.CaptureDone{ID: creq.ID + 1} // another ID: ignored
94+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
95+ }()
96+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
97+ res := <-h.results
98+ require.NoError(t, res.Err)
99+ creq := <-got
100+ require.NotZero(t, creq.ID&ports.CaptureIDInternal)
101+ require.False(t, creq.Indicate)
102+ require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
103+ require.Len(t, creq.Mapped, 64*32*4)
104+ require.Equal(t, 64*4, creq.Stride)
105+ require.Equal(t, uint32(1), creq.Format)
106+}
107+
108+func TestHintCaptureError(t *testing.T) {
109+ h := start(t, 5*time.Second)
110+ boom := errors.New("boom")
111+ go func() {
112+ creq := <-h.captures
113+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
114+ }()
115+ h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
116+ res := <-h.results
117+ require.ErrorIs(t, res.Err, boom)
118+ require.Equal(t, uint64(3), res.Seq)
119+ require.Empty(t, res.Targets)
120+}
121+
122+func TestHintTimeout(t *testing.T) {
123+ h := start(t, 50*time.Millisecond)
124+ h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
125+ res := <-h.results
126+ require.ErrorIs(t, res.Err, ErrTimeout)
127+ require.Equal(t, uint64(4), res.Seq)
128+}
129+
130+func TestHintBusy(t *testing.T) {
131+ h := start(t, 5*time.Second)
132+ h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
133+ creq := <-h.captures
134+ h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
135+ res := <-h.results
136+ require.ErrorIs(t, res.Err, ErrBusy)
137+ require.Equal(t, uint64(2), res.Seq)
138+ h.captured <- ports.CaptureDone{ID: creq.ID}
139+ res = <-h.results
140+ require.NoError(t, res.Err)
141+ require.Equal(t, uint64(1), res.Seq)
142+}
143+
144+func TestHintBadSize(t *testing.T) {
145+ h := start(t, time.Second)
146+ h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
147+ res := <-h.results
148+ require.ErrorIs(t, res.Err, ErrBadRequest)
149+}
A internal/adapters/hints/testdata/browser.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/outputkit/capture/excluded.go +5 -5
@@ -10,7 +10,7 @@ import (
1010
1111 // A request with Exclude leaves out the exclusion's HUD layers and popups;
1212 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it is never the source of a
13+// (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a
1414 // capture image. Requests of the displayed frame are then served from the
1515 // frame rendered without it first (SubmitPlain, ExcludedScene).
1616
@@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool {
6464 return false
6565 }
6666
67-// plain returns s without its capture state and indicators, Seq 0 so the
67+// plain returns s without its capture state, indicators and hint labels, Seq 0 so the
6868 // renderer redraws the whole target instead of reusing a displayed frame.
6969 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0
70+ s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
7171 return s
7272 }
7373
@@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene {
8585 // the plain render reads client buffers even when the requests are rejected,
8686 // so the caller owns it (hold or wait it) whatever happened, error included.
8787 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {
88- if len(normal) == 0 || len(s.CaptureIndicators) == 0 {
88+ if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 {
8989 return false, nil, nil
9090 }
9191 done, err = r.Render(plain(s), surfaces)
@@ -164,7 +164,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
164164 }
165165 clear(reqs[n:])
166166 reqs = reqs[:n]
167- excl := excludedShown(s) || len(s.CaptureIndicators) > 0
167+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0
168168 group := func(q ports.CaptureRequest) int {
169169 switch {
170170 case q.OffScreen:
@@ -10,7 +10,7 @@ import (
10 10
11 // A request with Exclude leaves out the exclusion's HUD layers and popups;11 // A request with Exclude leaves out the exclusion's HUD layers and popups;
12 // every other capture shows them. No capture shows the capture indicator12 // every other capture shows them. No capture shows the capture indicator
13-// (Scene.CaptureIndicators): a scene that carries it is never the source of a13+// (Scene.CaptureIndicators): a scene that carries it, or hint labels, is never the source of a
14 // capture image. Requests of the displayed frame are then served from the14 // capture image. Requests of the displayed frame are then served from the
15 // frame rendered without it first (SubmitPlain, ExcludedScene).15 // frame rendered without it first (SubmitPlain, ExcludedScene).
16 16
@@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool {
64 return false64 return false
65 }65 }
66 66
67-// plain returns s without its capture state and indicators, Seq 0 so the67+// plain returns s without its capture state, indicators and hint labels, Seq 0 so the
68 // renderer redraws the whole target instead of reusing a displayed frame.68 // renderer redraws the whole target instead of reusing a displayed frame.
69 func plain(s ports.Scene) ports.Scene {69 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 070+ s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
71 return s71 return s
72 }72 }
73 73
@@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene {
85 // the plain render reads client buffers even when the requests are rejected,85 // the plain render reads client buffers even when the requests are rejected,
86 // so the caller owns it (hold or wait it) whatever happened, error included.86 // so the caller owns it (hold or wait it) whatever happened, error included.
87 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {87 func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) {
88- if len(normal) == 0 || len(s.CaptureIndicators) == 0 {88+ if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 {
89 return false, nil, nil89 return false, nil, nil
90 }90 }
91 done, err = r.Render(plain(s), surfaces)91 done, err = r.Render(plain(s), surfaces)
@@ -164,7 +164,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
164 }164 }
165 clear(reqs[n:])165 clear(reqs[n:])
166 reqs = reqs[:n]166 reqs = reqs[:n]
167- excl := excludedShown(s) || len(s.CaptureIndicators) > 0167+ excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0
168 group := func(q ports.CaptureRequest) int {168 group := func(q ports.CaptureRequest) int {
169 switch {169 switch {
170 case q.OffScreen:170 case q.OffScreen:
M internal/adapters/outputkit/capture/indicator_test.go +40 -0
@@ -112,3 +112,43 @@ func TestSubmitPlainRejectsProtectedEpoch(t *testing.T) {
112112 _, statErr := file.Stat()
113113 require.ErrorIs(t, statErr, os.ErrClosed)
114114 }
115+
116+// Hint labels are never captured either: a scene with only labels is served
117+// from the plain frame, and an Exclude request is ordered to the excluded scene.
118+func TestSubmitPlainServesHintLabelScenesWithoutLabels(t *testing.T) {
119+ replies := make(chan ports.CaptureDone, 1)
120+ p := NewPipeline(context.Background(), replies)
121+ r := portsmocks.NewMockRenderer(t)
122+ frame := portsmocks.NewMockCaptureFrame(t)
123+ frame.EXPECT().Done().Return((*os.File)(nil))
124+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(nil).Once()
125+ var rendered []ports.Scene
126+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
127+ rendered = append(rendered, s)
128+ return nil, nil
129+ }).Once()
130+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
131+ r.EXPECT().EndCapture(frame).Return().Once()
132+ s := excludedTestScene()
133+ s.Capture = nil
134+ s.HintLabels = []ports.HintLabel{{Text: "f"}}
135+ reqs := []ports.CaptureRequest{pipelineRequest(t, 1)}
136+ require.False(t, reqs[0].Indicate)
137+ took, done, err := p.SubmitPlain(r, s, nil, reqs)
138+ require.NoError(t, err)
139+ require.Nil(t, done)
140+ require.True(t, took)
141+ require.Len(t, rendered, 1)
142+ require.Nil(t, rendered[0].HintLabels)
143+ require.Zero(t, rendered[0].Seq)
144+ require.Equal(t, uint64(1), awaitCapture(t, replies).ID)
145+ p.Close(r)
146+ frame.Calls = nil
147+
148+ withSession := s
149+ withSession.Capture = &ports.SceneCapture{Session: 7}
150+ normal, excluded, _ := p.Split(withSession, []ports.CaptureRequest{{ID: 2, Exclude: true, Session: 7}})
151+ require.Empty(t, normal)
152+ require.Equal(t, []uint64{2}, ids(excluded), "labels force the Exclude request to the excluded scene")
153+ require.Nil(t, p.ExcludedScene(s).HintLabels)
154+}
@@ -112,3 +112,43 @@ func TestSubmitPlainRejectsProtectedEpoch(t *testing.T) {
112 _, statErr := file.Stat()112 _, statErr := file.Stat()
113 require.ErrorIs(t, statErr, os.ErrClosed)113 require.ErrorIs(t, statErr, os.ErrClosed)
114 }114 }
115+
116+// Hint labels are never captured either: a scene with only labels is served
117+// from the plain frame, and an Exclude request is ordered to the excluded scene.
118+func TestSubmitPlainServesHintLabelScenesWithoutLabels(t *testing.T) {
119+ replies := make(chan ports.CaptureDone, 1)
120+ p := NewPipeline(context.Background(), replies)
121+ r := portsmocks.NewMockRenderer(t)
122+ frame := portsmocks.NewMockCaptureFrame(t)
123+ frame.EXPECT().Done().Return((*os.File)(nil))
124+ frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).Return(nil).Once()
125+ var rendered []ports.Scene
126+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
127+ rendered = append(rendered, s)
128+ return nil, nil
129+ }).Once()
130+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
131+ r.EXPECT().EndCapture(frame).Return().Once()
132+ s := excludedTestScene()
133+ s.Capture = nil
134+ s.HintLabels = []ports.HintLabel{{Text: "f"}}
135+ reqs := []ports.CaptureRequest{pipelineRequest(t, 1)}
136+ require.False(t, reqs[0].Indicate)
137+ took, done, err := p.SubmitPlain(r, s, nil, reqs)
138+ require.NoError(t, err)
139+ require.Nil(t, done)
140+ require.True(t, took)
141+ require.Len(t, rendered, 1)
142+ require.Nil(t, rendered[0].HintLabels)
143+ require.Zero(t, rendered[0].Seq)
144+ require.Equal(t, uint64(1), awaitCapture(t, replies).ID)
145+ p.Close(r)
146+ frame.Calls = nil
147+
148+ withSession := s
149+ withSession.Capture = &ports.SceneCapture{Session: 7}
150+ normal, excluded, _ := p.Split(withSession, []ports.CaptureRequest{{ID: 2, Exclude: true, Session: 7}})
151+ require.Empty(t, normal)
152+ require.Equal(t, []uint64{2}, ids(excluded), "labels force the Exclude request to the excluded scene")
153+ require.Nil(t, p.ExcludedScene(s).HintLabels)
154+}
M internal/adapters/vulkan/damage.go +3 -1
@@ -238,9 +238,11 @@ func (tg *target) hold(s ports.Scene, d *damageRegion) {
238238 tg.heldLayers = append(tg.heldLayers[:0], s.Layers...)
239239 tg.heldHints = append(tg.heldHints[:0], s.DropHints...)
240240 tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...)
241+ tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...)
241242 tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
242243 tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers
243244 tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators
245+ tg.scene.HintLabels = tg.heldLabels
244246 // Only whether they are set matters (any capture redraws everything):
245247 // sentinels, so no pointer into the caller's capture state is kept.
246248 tg.scene.Capture, tg.scene.CaptureScene = nil, nil
@@ -275,7 +277,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
275277 old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
276278 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277279 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278- !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
280+ !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || !slices.Equal(old.HintLabels, cur.HintLabels) ||
279281 len(old.Windows) != len(cur.Windows) {
280282 return image.Rectangle{}, false
281283 }
@@ -238,9 +238,11 @@ func (tg *target) hold(s ports.Scene, d *damageRegion) {
238 tg.heldLayers = append(tg.heldLayers[:0], s.Layers...)238 tg.heldLayers = append(tg.heldLayers[:0], s.Layers...)
239 tg.heldHints = append(tg.heldHints[:0], s.DropHints...)239 tg.heldHints = append(tg.heldHints[:0], s.DropHints...)
240 tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...)240 tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...)
241+ tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...)
241 tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn242 tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
242 tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers243 tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers
243 tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators244 tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators
245+ tg.scene.HintLabels = tg.heldLabels
244 // Only whether they are set matters (any capture redraws everything):246 // Only whether they are set matters (any capture redraws everything):
245 // sentinels, so no pointer into the caller's capture state is kept.247 // sentinels, so no pointer into the caller's capture state is kept.
246 tg.scene.Capture, tg.scene.CaptureScene = nil, nil248 tg.scene.Capture, tg.scene.CaptureScene = nil, nil
@@ -275,7 +277,7 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
275 old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||277 old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||278 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||279 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278- !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||280+ !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || !slices.Equal(old.HintLabels, cur.HintLabels) ||
279 len(old.Windows) != len(cur.Windows) {281 len(old.Windows) != len(cur.Windows) {
280 return image.Rectangle{}, false282 return image.Rectangle{}, false
281 }283 }
M internal/adapters/vulkan/damage_delta_test.go +2 -2
@@ -17,9 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- // 23 with Animating, which sceneDelta intentionally ignores: it only
20+ // 24 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 23
22+ const fields = 24
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -17,9 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18 // lists the fields that force a full redraw.18 // lists the fields that force a full redraw.
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20- // 23 with Animating, which sceneDelta intentionally ignores: it only20+ // 24 with Animating, which sceneDelta intentionally ignores: it only
21 // schedules the next frame, it draws nothing.21 // schedules the next frame, it draws nothing.
22- const fields = 2322+ const fields = 24
23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25 }25 }
A internal/adapters/vulkan/hint_labels.go +44 -0
@@ -0,0 +1,44 @@
1+package vulkan
2+
3+import (
4+ "image"
5+)
6+
7+// hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
8+// everything, after the capture indicators. Each label is rasterized on the CPU
9+// (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU
10+// buffer is cached under a key naming the label, so a label that stays the
11+// same uploads once. Captures are rendered from scenes without labels.
12+func (w *sceneWalk) hintLabels() {
13+ if len(w.s.HintLabels) == 0 {
14+ return
15+ }
16+ r := w.r
17+ for _, l := range w.s.HintLabels {
18+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)
19+ if lw <= 0 || lh <= 0 {
20+ continue
21+ }
22+ // Inside the output, whatever the target's position.
23+ x := min(max(w.phys(l.Rect.X), w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X))
24+ y := min(max(w.phys(l.Rect.Y), w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y))
25+ rect := image.Rect(x, y, x+lw, y+lh)
26+ if rect.Intersect(w.bounds).Empty() {
27+ continue
28+ }
29+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}
30+ st := shmState{w: lw, h: lh, seq: 1}
31+ c := r.shmCached(key, st)
32+ if c == nil {
33+ var err error
34+ if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil {
35+ continue
36+ }
37+ }
38+ dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true)
39+ dr.pc.color[3] = 1
40+ dr.shm = c
41+ dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0}
42+ w.draws = append(w.draws, dr)
43+ }
44+}
@@ -0,0 +1,44 @@
1+package vulkan
2+
3+import (
4+ "image"
5+)
6+
7+// hintLabels draws the keyboard hint labels (ports.Scene.HintLabels) over
8+// everything, after the capture indicators. Each label is rasterized on the CPU
9+// (hintlabel) at the scene's scale and drawn from the SHM copy path: the GPU
10+// buffer is cached under a key naming the label, so a label that stays the
11+// same uploads once. Captures are rendered from scenes without labels.
12+func (w *sceneWalk) hintLabels() {
13+ if len(w.s.HintLabels) == 0 {
14+ return
15+ }
16+ r := w.r
17+ for _, l := range w.s.HintLabels {
18+ pix, lw, lh := r.labels.Label(l.Text, l.Typed, w.scale)
19+ if lw <= 0 || lh <= 0 {
20+ continue
21+ }
22+ // Inside the output, whatever the target's position.
23+ x := min(max(w.phys(l.Rect.X), w.bounds.Min.X), max(w.bounds.Max.X-lw, w.bounds.Min.X))
24+ y := min(max(w.phys(l.Rect.Y), w.bounds.Min.Y), max(w.bounds.Max.Y-lh, w.bounds.Min.Y))
25+ rect := image.Rect(x, y, x+lw, y+lh)
26+ if rect.Intersect(w.bounds).Empty() {
27+ continue
28+ }
29+ key := shmKey{label: l.Text, typed: l.Typed, scale: w.scale}
30+ st := shmState{w: lw, h: lh, seq: 1}
31+ c := r.shmCached(key, st)
32+ if c == nil {
33+ var err error
34+ if c, err = r.shmCopyFor(key, st, pix, 0, lw*4, nil); err != nil {
35+ continue
36+ }
37+ }
38+ dr := r.contentDraw(rect, rect, lw, lh, [4]float32{}, 0, modeBuffer, true)
39+ dr.pc.color[3] = 1
40+ dr.shm = c
41+ dr.pc.buf = [4]uint32{0, uint32(lw), uint32(lh), 0}
42+ w.draws = append(w.draws, dr)
43+ }
44+}
A internal/adapters/vulkan/hint_labels_test.go +97 -0
@@ -0,0 +1,97 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestHintLabelsDrawnAfterIndicators(t *testing.T) {
13+ r, err := New(256, 256)
14+ if err != nil {
15+ t.Skipf("Vulkan unavailable: %v", err)
16+ }
17+ defer r.Close()
18+ for _, scale := range []int{1, 2} {
19+ s := ports.Scene{Scale: float64(scale),
20+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}},
21+ HintLabels: []ports.HintLabel{
22+ {Rect: ports.Rect{X: 10, Y: 12}, Text: "f"},
23+ {Rect: ports.Rect{X: 30, Y: 40}, Text: "jk", Typed: 1},
24+ }}
25+ bare := s
26+ bare.HintLabels = nil
27+ before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256))))
28+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256)))
29+ require.Len(t, ds, before+2, "one draw per label, after the indicator's")
30+ for i, want := range []image.Point{{10 * scale, 12 * scale}, {30 * scale, 40 * scale}} {
31+ d := ds[before+i]
32+ require.NotNil(t, d.shm)
33+ require.Equal(t, int32(want.X), d.pc.rect[0])
34+ require.Equal(t, int32(want.Y), d.pc.rect[1])
35+ require.Greater(t, d.pc.rect[2], d.pc.rect[0])
36+ require.Greater(t, d.pc.rect[3], d.pc.rect[1])
37+ require.Equal(t, float32(1), d.pc.color[3])
38+ }
39+ }
40+}
41+
42+func TestHintLabelsAbsentDrawNothing(t *testing.T) {
43+ r := &Renderer{width: 64, height: 64}
44+ s := ports.Scene{Scale: 1}
45+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
46+}
47+
48+func TestHintLabelsClampedInsideOutput(t *testing.T) {
49+ r, err := New(128, 128)
50+ if err != nil {
51+ t.Skipf("Vulkan unavailable: %v", err)
52+ }
53+ defer r.Close()
54+ s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
55+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
56+ require.Len(t, ds, 1)
57+ require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
58+ require.LessOrEqual(t, ds[0].pc.rect[3], int32(128))
59+}
60+
61+func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
62+ r, err := New(128, 128)
63+ if err != nil {
64+ t.Skipf("Vulkan unavailable: %v", err)
65+ }
66+ defer r.Close()
67+ s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}}
68+ require.NoError(t, render(r, s, nil))
69+ first := r.copied
70+ require.Positive(t, first)
71+ s.Seq = 2
72+ require.NoError(t, render(r, s, nil))
73+ require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+}
75+
76+func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
77+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
78+ base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100}
79+ with := base
80+ with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}}
81+ _, ok := sceneDelta(base, with, phys, 1<<30)
82+ require.False(t, ok, "labels appearing redraw the output")
83+ typed := with
84+ typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}}
85+ _, ok = sceneDelta(with, typed, phys, 1<<30)
86+ require.False(t, ok)
87+ _, ok = sceneDelta(with, with, phys, 1<<30)
88+ require.True(t, ok, "same labels, no damage")
89+}
90+
91+func TestHintLabelsHeldByTarget(t *testing.T) {
92+ s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
93+ tg := &target{}
94+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
95+ s.HintLabels[0].Text = "j"
96+ require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97+}
@@ -0,0 +1,97 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestHintLabelsDrawnAfterIndicators(t *testing.T) {
13+ r, err := New(256, 256)
14+ if err != nil {
15+ t.Skipf("Vulkan unavailable: %v", err)
16+ }
17+ defer r.Close()
18+ for _, scale := range []int{1, 2} {
19+ s := ports.Scene{Scale: float64(scale),
20+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}},
21+ HintLabels: []ports.HintLabel{
22+ {Rect: ports.Rect{X: 10, Y: 12}, Text: "f"},
23+ {Rect: ports.Rect{X: 30, Y: 40}, Text: "jk", Typed: 1},
24+ }}
25+ bare := s
26+ bare.HintLabels = nil
27+ before := len(r.draws(bare, nil, newDamage(&target{}, bare, image.Rect(0, 0, 256, 256))))
28+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 256, 256)))
29+ require.Len(t, ds, before+2, "one draw per label, after the indicator's")
30+ for i, want := range []image.Point{{10 * scale, 12 * scale}, {30 * scale, 40 * scale}} {
31+ d := ds[before+i]
32+ require.NotNil(t, d.shm)
33+ require.Equal(t, int32(want.X), d.pc.rect[0])
34+ require.Equal(t, int32(want.Y), d.pc.rect[1])
35+ require.Greater(t, d.pc.rect[2], d.pc.rect[0])
36+ require.Greater(t, d.pc.rect[3], d.pc.rect[1])
37+ require.Equal(t, float32(1), d.pc.color[3])
38+ }
39+ }
40+}
41+
42+func TestHintLabelsAbsentDrawNothing(t *testing.T) {
43+ r := &Renderer{width: 64, height: 64}
44+ s := ports.Scene{Scale: 1}
45+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
46+}
47+
48+func TestHintLabelsClampedInsideOutput(t *testing.T) {
49+ r, err := New(128, 128)
50+ if err != nil {
51+ t.Skipf("Vulkan unavailable: %v", err)
52+ }
53+ defer r.Close()
54+ s := ports.Scene{Scale: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 127, Y: 127}, Text: "ff"}}}
55+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
56+ require.Len(t, ds, 1)
57+ require.LessOrEqual(t, ds[0].pc.rect[2], int32(128))
58+ require.LessOrEqual(t, ds[0].pc.rect[3], int32(128))
59+}
60+
61+func TestHintLabelsRenderAndUploadOnce(t *testing.T) {
62+ r, err := New(128, 128)
63+ if err != nil {
64+ t.Skipf("Vulkan unavailable: %v", err)
65+ }
66+ defer r.Close()
67+ s := ports.Scene{Scale: 1, Seq: 1, HintLabels: []ports.HintLabel{{Rect: ports.Rect{X: 4, Y: 4}, Text: "fj"}}}
68+ require.NoError(t, render(r, s, nil))
69+ first := r.copied
70+ require.Positive(t, first)
71+ s.Seq = 2
72+ require.NoError(t, render(r, s, nil))
73+ require.Equal(t, first, r.copied, "an unchanged label is not uploaded again")
74+}
75+
76+func TestHintSceneDeltaOnlyHintLabelsChange(t *testing.T) {
77+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
78+ base := ports.Scene{Scale: 1, OutputWidth: 100, OutputHeight: 100}
79+ with := base
80+ with.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f"}}
81+ _, ok := sceneDelta(base, with, phys, 1<<30)
82+ require.False(t, ok, "labels appearing redraw the output")
83+ typed := with
84+ typed.HintLabels = []ports.HintLabel{{Rect: ports.Rect{X: 1, Y: 1}, Text: "f", Typed: 1}}
85+ _, ok = sceneDelta(with, typed, phys, 1<<30)
86+ require.False(t, ok)
87+ _, ok = sceneDelta(with, with, phys, 1<<30)
88+ require.True(t, ok, "same labels, no damage")
89+}
90+
91+func TestHintLabelsHeldByTarget(t *testing.T) {
92+ s := ports.Scene{Seq: 1, HintLabels: []ports.HintLabel{{Text: "f"}}}
93+ tg := &target{}
94+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
95+ s.HintLabels[0].Text = "j"
96+ require.Equal(t, "f", tg.scene.HintLabels[0].Text, "the held scene owns its labels")
97+}
M internal/adapters/vulkan/renderer.go +3 -0
@@ -9,6 +9,7 @@ import (
99 "strconv"
1010 "unsafe"
1111
12+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
1213 "github.com/bnema/neferwl/internal/ports"
1314 "github.com/bnema/zerowrap"
1415
@@ -85,6 +86,8 @@ type Renderer struct {
8586 // the bytes copied into them, for tests.
8687 shm map[shmKey]*shmSurface
8788 copied int
89+ // labels rasterizes the hint labels (hint_labels.go); the zero value works.
90+ labels hintlabel.Raster
8891 // shmRects is scratch for shmCopyFor.
8992 shmRects []image.Rectangle
9093 // marks date the frames for Trim's wall-clock eviction (trim.go).
@@ -9,6 +9,7 @@ import (
9 "strconv"9 "strconv"
10 "unsafe"10 "unsafe"
11 11
12+ "github.com/bnema/neferwl/internal/adapters/hintlabel"
12 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 15
@@ -85,6 +86,8 @@ type Renderer struct {
85 // the bytes copied into them, for tests.86 // the bytes copied into them, for tests.
86 shm map[shmKey]*shmSurface87 shm map[shmKey]*shmSurface
87 copied int88 copied int
89+ // labels rasterizes the hint labels (hint_labels.go); the zero value works.
90+ labels hintlabel.Raster
88 // shmRects is scratch for shmCopyFor.91 // shmRects is scratch for shmCopyFor.
89 shmRects []image.Rectangle92 shmRects []image.Rectangle
90 // marks date the frames for Trim's wall-clock eviction (trim.go).93 // marks date the frames for Trim's wall-clock eviction (trim.go).
M internal/adapters/vulkan/scene.go +4 -3
@@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
6060 w.popups(true)
6161 w.focusFlash()
6262 w.captureIndicators()
63+ w.hintLabels()
6364 r.scratchDraws = w.draws
6465 return w.draws
6566 }
@@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
331332 for i := range content.Children {
332333 if ch := &content.Children[i]; ch.Below {
333334 cx, cy := at(ch)
334- w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false)
335+ w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, covers, false)
335336 }
336337 }
337- w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true)
338+ w.surface(content, ox, oy, clip, shmKey{win: id, surface: content.Surface}, content.Version, covers, true)
338339 for i := range content.Children {
339340 if ch := &content.Children[i]; !ch.Below {
340341 cx, cy := at(ch)
341- w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false)
342+ w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, nil, false)
342343 }
343344 }
344345 }
@@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
60 w.popups(true)60 w.popups(true)
61 w.focusFlash()61 w.focusFlash()
62 w.captureIndicators()62 w.captureIndicators()
63+ w.hintLabels()
63 r.scratchDraws = w.draws64 r.scratchDraws = w.draws
64 return w.draws65 return w.draws
65 }66 }
@@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
331 for i := range content.Children {332 for i := range content.Children {
332 if ch := &content.Children[i]; ch.Below {333 if ch := &content.Children[i]; ch.Below {
333 cx, cy := at(ch)334 cx, cy := at(ch)
334- w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false)335+ w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, covers, false)
335 }336 }
336 }337 }
337- w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true)338+ w.surface(content, ox, oy, clip, shmKey{win: id, surface: content.Surface}, content.Version, covers, true)
338 for i := range content.Children {339 for i := range content.Children {
339 if ch := &content.Children[i]; !ch.Below {340 if ch := &content.Children[i]; !ch.Below {
340 cx, cy := at(ch)341 cx, cy := at(ch)
341- w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false)342+ w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{win: id, surface: ch.Surface}, ch.Version, nil, false)
342 }343 }
343 }344 }
344 }345 }
M internal/adapters/vulkan/shm.go +6 -0
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154154 type shmKey struct {
155155 win ports.WindowID
156156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157163 }
158164
159165 // shmState is what a GPU buffer holds: a w×h client buffer at a content
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154 type shmKey struct {154 type shmKey struct {
155 win ports.WindowID155 win ports.WindowID
156 surface uint64156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157 }163 }
158 164
159 // shmState is what a GPU buffer holds: a w×h client buffer at a content165 // shmState is what a GPU buffer holds: a w×h client buffer at a content
M internal/adapters/vulkan/targets.go +1 -0
@@ -43,6 +43,7 @@ type target struct {
4343 heldLayers []ports.SceneLayer
4444 heldHints []ports.Rect
4545 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
4647 windows map[ports.WindowID]heldWindow
4748 }
4849
@@ -43,6 +43,7 @@ type target struct {
43 heldLayers []ports.SceneLayer43 heldLayers []ports.SceneLayer
44 heldHints []ports.Rect44 heldHints []ports.Rect
45 heldIndicators []ports.CaptureIndicator45 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
46 windows map[ports.WindowID]heldWindow47 windows map[ports.WindowID]heldWindow
47 }48 }
48 49
A internal/app/captured.go +41 -0
@@ -0,0 +1,41 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// routeCaptured demultiplexes capture replies from the outputs: replies whose
11+// ID carries ports.CaptureIDInternal go to internal, all others to wayland.
12+// Both sends give way to ctx. A nil internal means no internal consumer is
13+// wired: internal replies are dropped (logged at debug) instead of blocking
14+// forever, since nothing would ever receive them. It returns when ctx is done
15+// or in is closed.
16+func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) {
17+ log := logging.For(ctx, "app")
18+ for {
19+ select {
20+ case <-ctx.Done():
21+ return
22+ case done, ok := <-in:
23+ if !ok {
24+ return
25+ }
26+ dst := wayland
27+ if done.ID&ports.CaptureIDInternal != 0 {
28+ if internal == nil {
29+ log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer")
30+ continue
31+ }
32+ dst = internal
33+ }
34+ select {
35+ case dst <- done:
36+ case <-ctx.Done():
37+ return
38+ }
39+ }
40+ }
41+}
@@ -0,0 +1,41 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// routeCaptured demultiplexes capture replies from the outputs: replies whose
11+// ID carries ports.CaptureIDInternal go to internal, all others to wayland.
12+// Both sends give way to ctx. A nil internal means no internal consumer is
13+// wired: internal replies are dropped (logged at debug) instead of blocking
14+// forever, since nothing would ever receive them. It returns when ctx is done
15+// or in is closed.
16+func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) {
17+ log := logging.For(ctx, "app")
18+ for {
19+ select {
20+ case <-ctx.Done():
21+ return
22+ case done, ok := <-in:
23+ if !ok {
24+ return
25+ }
26+ dst := wayland
27+ if done.ID&ports.CaptureIDInternal != 0 {
28+ if internal == nil {
29+ log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer")
30+ continue
31+ }
32+ dst = internal
33+ }
34+ select {
35+ case dst <- done:
36+ case <-ctx.Done():
37+ return
38+ }
39+ }
40+ }
41+}
A internal/app/captured_test.go +72 -0
@@ -0,0 +1,72 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func TestCapturedDemux(t *testing.T) {
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ in := make(chan ports.CaptureDone, 4)
17+ wl := make(chan ports.CaptureDone, 4)
18+ internal := make(chan ports.CaptureDone, 4)
19+ exited := make(chan struct{})
20+ go func() { routeCaptured(ctx, in, wl, internal); close(exited) }()
21+
22+ in <- ports.CaptureDone{ID: 7}
23+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3}
24+ require.Equal(t, uint64(7), recvDone(t, wl).ID)
25+ require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID)
26+ require.Empty(t, wl)
27+ require.Empty(t, internal)
28+
29+ cancel()
30+ select {
31+ case <-exited:
32+ case <-time.After(2 * time.Second):
33+ t.Fatal("routeCaptured did not return on cancel")
34+ }
35+}
36+
37+func TestCapturedDemuxCancelWhileBlocked(t *testing.T) {
38+ ctx, cancel := context.WithCancel(context.Background())
39+ in := make(chan ports.CaptureDone, 1)
40+ wl := make(chan ports.CaptureDone) // never read
41+ exited := make(chan struct{})
42+ go func() { routeCaptured(ctx, in, wl, nil); close(exited) }()
43+ in <- ports.CaptureDone{ID: 1}
44+ cancel()
45+ select {
46+ case <-exited:
47+ case <-time.After(2 * time.Second):
48+ t.Fatal("routeCaptured blocked on a full destination after cancel")
49+ }
50+}
51+
52+func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) {
53+ ctx, cancel := context.WithCancel(context.Background())
54+ defer cancel()
55+ in := make(chan ports.CaptureDone, 2)
56+ wl := make(chan ports.CaptureDone, 2)
57+ go routeCaptured(ctx, in, wl, nil)
58+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1}
59+ in <- ports.CaptureDone{ID: 2}
60+ require.Equal(t, uint64(2), recvDone(t, wl).ID)
61+}
62+
63+func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone {
64+ t.Helper()
65+ select {
66+ case d := <-ch:
67+ return d
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no capture reply")
70+ return ports.CaptureDone{}
71+ }
72+}
@@ -0,0 +1,72 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func TestCapturedDemux(t *testing.T) {
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ in := make(chan ports.CaptureDone, 4)
17+ wl := make(chan ports.CaptureDone, 4)
18+ internal := make(chan ports.CaptureDone, 4)
19+ exited := make(chan struct{})
20+ go func() { routeCaptured(ctx, in, wl, internal); close(exited) }()
21+
22+ in <- ports.CaptureDone{ID: 7}
23+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3}
24+ require.Equal(t, uint64(7), recvDone(t, wl).ID)
25+ require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID)
26+ require.Empty(t, wl)
27+ require.Empty(t, internal)
28+
29+ cancel()
30+ select {
31+ case <-exited:
32+ case <-time.After(2 * time.Second):
33+ t.Fatal("routeCaptured did not return on cancel")
34+ }
35+}
36+
37+func TestCapturedDemuxCancelWhileBlocked(t *testing.T) {
38+ ctx, cancel := context.WithCancel(context.Background())
39+ in := make(chan ports.CaptureDone, 1)
40+ wl := make(chan ports.CaptureDone) // never read
41+ exited := make(chan struct{})
42+ go func() { routeCaptured(ctx, in, wl, nil); close(exited) }()
43+ in <- ports.CaptureDone{ID: 1}
44+ cancel()
45+ select {
46+ case <-exited:
47+ case <-time.After(2 * time.Second):
48+ t.Fatal("routeCaptured blocked on a full destination after cancel")
49+ }
50+}
51+
52+func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) {
53+ ctx, cancel := context.WithCancel(context.Background())
54+ defer cancel()
55+ in := make(chan ports.CaptureDone, 2)
56+ wl := make(chan ports.CaptureDone, 2)
57+ go routeCaptured(ctx, in, wl, nil)
58+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1}
59+ in <- ports.CaptureDone{ID: 2}
60+ require.Equal(t, uint64(2), recvDone(t, wl).ID)
61+}
62+
63+func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone {
64+ t.Helper()
65+ select {
66+ case d := <-ch:
67+ return d
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no capture reply")
70+ return ports.CaptureDone{}
71+ }
72+}
M internal/app/session.go +9 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/neferwl/internal/adapters/config"
1515 "github.com/bnema/neferwl/internal/adapters/drm"
1616 "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/hints"
1718 "github.com/bnema/neferwl/internal/adapters/launcher"
1819 "github.com/bnema/neferwl/internal/adapters/libinput"
1920 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -340,6 +341,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
340341 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341342 }
342343 }()
344+ workers.Add(1)
345+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
346+ workers.Add(1)
347+ go func() {
348+ defer workers.Done()
349+ // Run only returns when ctx is done: nothing to report.
350+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults)
351+ }()
343352 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344353 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345354 workers.Add(1)
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/neferwl/internal/adapters/config"14 "github.com/bnema/neferwl/internal/adapters/config"
15 "github.com/bnema/neferwl/internal/adapters/drm"15 "github.com/bnema/neferwl/internal/adapters/drm"
16 "github.com/bnema/neferwl/internal/adapters/headlessinput"16 "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/hints"
17 "github.com/bnema/neferwl/internal/adapters/launcher"18 "github.com/bnema/neferwl/internal/adapters/launcher"
18 "github.com/bnema/neferwl/internal/adapters/libinput"19 "github.com/bnema/neferwl/internal/adapters/libinput"
19 "github.com/bnema/neferwl/internal/adapters/logging"20 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -340,6 +341,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
340 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")341 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341 }342 }
342 }()343 }()
344+ workers.Add(1)
345+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
346+ workers.Add(1)
347+ go func() {
348+ defer workers.Done()
349+ // Run only returns when ctx is done: nothing to report.
350+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults)
351+ }()
343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()352 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344 go func() { defer workers.Done(); done <- c.Run(ctx) }()353 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345 workers.Add(1)354 workers.Add(1)
M internal/app/wiring.go +27 -6
@@ -46,10 +46,20 @@ type pipes struct {
4646 // move running slides.
4747 frames chan ports.OutputFrame
4848
49+ // Hint mode: core asks the hints adapter for a scan and gets the result.
50+ hintScans chan ports.HintScanRequest
51+ hintResults chan ports.HintScanResult
52+
4953 captures chan ports.CaptureRequest
50- // Every admitted capture owns one reply slot until wayland consumes it.
51- // Keep this capacity tied to admission so output routing never waits.
52- captured chan ports.CaptureDone
54+ // Every admitted capture owns one reply slot until routeCaptured takes
55+ // it. Keep this capacity tied to admission so output routing never waits.
56+ // The outputs reply on capturedAll; routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers).
59+ capturedAll chan ports.CaptureDone
60+ captured chan ports.CaptureDone
61+ // hintCaptured carries internal capture replies to the hints adapter.
62+ hintCaptured chan ports.CaptureDone
5363
5464 outputFormats chan ports.OutputFormats
5565 outputHeads chan ports.OutputHeads
@@ -97,8 +107,13 @@ func newPipes(realSession bool) *pipes {
97107
98108 frames: make(chan ports.OutputFrame, 8),
99109
100- captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
101- captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
110+ hintScans: make(chan ports.HintScanRequest, 1),
111+ hintResults: make(chan ports.HintScanResult, 1),
112+
113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
116+ hintCaptured: make(chan ports.CaptureDone, 1),
102117
103118 outputFormats: make(chan ports.OutputFormats, 8),
104119 outputHeads: make(chan ports.OutputHeads, 8),
@@ -136,9 +151,15 @@ func (p *pipes) core() core.Channels {
136151 ConfigErrors: p.configErrors,
137152 Scales: p.scales,
138153 Frames: p.frames,
154+ HintScans: p.hintScans,
155+ Hints: p.hintResults,
139156 }
140157 }
141158
159+// internalCaptured is the channel internal capture replies are routed to: the
160+// hints adapter reads it.
161+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
162+
142163 // wayland is the wayland server's side.
143164 func (p *pipes) wayland() wayland.Channels {
144165 return wayland.Channels{
@@ -175,7 +196,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175196 content: content,
176197 frames: p.frames,
177198 captures: p.captures,
178- captured: p.captured,
199+ captured: p.capturedAll,
179200 formats: p.outputFormats,
180201 heads: p.outputHeads,
181202 leaseRequests: p.leaseRequests,
@@ -46,10 +46,20 @@ type pipes struct {
46 // move running slides.46 // move running slides.
47 frames chan ports.OutputFrame47 frames chan ports.OutputFrame
48 48
49+ // Hint mode: core asks the hints adapter for a scan and gets the result.
50+ hintScans chan ports.HintScanRequest
51+ hintResults chan ports.HintScanResult
52+
49 captures chan ports.CaptureRequest53 captures chan ports.CaptureRequest
50- // Every admitted capture owns one reply slot until wayland consumes it.54+ // Every admitted capture owns one reply slot until routeCaptured takes
51- // Keep this capacity tied to admission so output routing never waits.55+ // it. Keep this capacity tied to admission so output routing never waits.
52- captured chan ports.CaptureDone56+ // The outputs reply on capturedAll; routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers).
59+ capturedAll chan ports.CaptureDone
60+ captured chan ports.CaptureDone
61+ // hintCaptured carries internal capture replies to the hints adapter.
62+ hintCaptured chan ports.CaptureDone
53 63
54 outputFormats chan ports.OutputFormats64 outputFormats chan ports.OutputFormats
55 outputHeads chan ports.OutputHeads65 outputHeads chan ports.OutputHeads
@@ -97,8 +107,13 @@ func newPipes(realSession bool) *pipes {
97 107
98 frames: make(chan ports.OutputFrame, 8),108 frames: make(chan ports.OutputFrame, 8),
99 109
100- captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),110+ hintScans: make(chan ports.HintScanRequest, 1),
101- captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),111+ hintResults: make(chan ports.HintScanResult, 1),
112+
113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
116+ hintCaptured: make(chan ports.CaptureDone, 1),
102 117
103 outputFormats: make(chan ports.OutputFormats, 8),118 outputFormats: make(chan ports.OutputFormats, 8),
104 outputHeads: make(chan ports.OutputHeads, 8),119 outputHeads: make(chan ports.OutputHeads, 8),
@@ -136,9 +151,15 @@ func (p *pipes) core() core.Channels {
136 ConfigErrors: p.configErrors,151 ConfigErrors: p.configErrors,
137 Scales: p.scales,152 Scales: p.scales,
138 Frames: p.frames,153 Frames: p.frames,
154+ HintScans: p.hintScans,
155+ Hints: p.hintResults,
139 }156 }
140 }157 }
141 158
159+// internalCaptured is the channel internal capture replies are routed to: the
160+// hints adapter reads it.
161+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
162+
142 // wayland is the wayland server's side.163 // wayland is the wayland server's side.
143 func (p *pipes) wayland() wayland.Channels {164 func (p *pipes) wayland() wayland.Channels {
144 return wayland.Channels{165 return wayland.Channels{
@@ -175,7 +196,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175 content: content,196 content: content,
176 frames: p.frames,197 frames: p.frames,
177 captures: p.captures,198 captures: p.captures,
178- captured: p.captured,199+ captured: p.capturedAll,
179 formats: p.outputFormats,200 formats: p.outputFormats,
180 heads: p.outputHeads,201 heads: p.outputHeads,
181 leaseRequests: p.leaseRequests,202 leaseRequests: p.leaseRequests,
M internal/core/action.go +6 -0
@@ -63,6 +63,9 @@ const (
6363 // command key is held (switcher.go).
6464 ActionSwitchColumnNext Action = "switch-column-next"
6565 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
6669 )
6770
6871 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -302,6 +305,9 @@ func (c *Core) dispatchAction(a Action) Effect {
302305 return Effect{}
303306 }
304307 switch a {
308+ case ActionHint:
309+ c.hintRequest()
310+ return Effect{}
305311 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
306312 if i := c.neighbor(monitorDirection(a)); i >= 0 {
307313 c.focusScreen = i
@@ -63,6 +63,9 @@ const (
63 // command key is held (switcher.go).63 // command key is held (switcher.go).
64 ActionSwitchColumnNext Action = "switch-column-next"64 ActionSwitchColumnNext Action = "switch-column-next"
65 ActionSwitchColumnPrev Action = "switch-column-prev"65 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
66 )69 )
67 70
68 // monitorDirection returns the direction of the neighbor screen targeted by71 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -302,6 +305,9 @@ func (c *Core) dispatchAction(a Action) Effect {
302 return Effect{}305 return Effect{}
303 }306 }
304 switch a {307 switch a {
308+ case ActionHint:
309+ c.hintRequest()
310+ return Effect{}
305 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:311 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
306 if i := c.neighbor(monitorDirection(a)); i >= 0 {312 if i := c.neighbor(monitorDirection(a)); i >= 0 {
307 c.focusScreen = i313 c.focusScreen = i
M internal/core/core.go +24 -1
@@ -47,6 +47,12 @@ type Channels struct {
4747 // animating scene (Scene.Animating): a running slide moves one step per
4848 // report. Without them it moves on a timer.
4949 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
5056 }
5157
5258 // Options are the services and policy core is constructed with.
@@ -157,6 +163,11 @@ type Core struct {
157163 // capt is the capture state (capture.go, capindicator.go).
158164 capt captureState
159165 flash focusFlash
166+ // hint is keyboard hint mode (hint.go).
167+ hint hintState
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
160171 // switcher is the column switcher in progress: the screen it is on and
161172 // the timer that shows its cards (switcher.go).
162173 switcher struct {
@@ -275,7 +286,7 @@ func (c *Core) apply(cfg ports.Config) error {
275286 }
276287 switch Action(a) {
277288 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
278- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:
289+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279290 default:
280291 return fmt.Errorf("invalid action %q", a)
281292 }
@@ -634,6 +645,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634645 return errSecurityChanged
635646 }
636647 c.syncSecurity()
648+ c.hintValidate()
637649 if c.security.Protected {
638650 return c.publishProtected(ctx)
639651 }
@@ -1206,6 +1218,17 @@ func (c *Core) Run(ctx context.Context) error {
12061218 return nil
12071219 }
12081220 continue
1221+ case res, ok := <-c.ch.Hints:
1222+ if !ok {
1223+ c.ch.Hints = nil
1224+ continue
1225+ }
1226+ if c.securityCheckpoint(ctx) != nil {
1227+ return nil
1228+ }
1229+ if c.hintResult(res) && c.publish(ctx) != nil {
1230+ return nil
1231+ }
12091232 case ev, ok := <-c.ch.Output:
12101233 if !ok {
12111234 c.ch.Output = nil
@@ -47,6 +47,12 @@ type Channels struct {
47 // animating scene (Scene.Animating): a running slide moves one step per47 // animating scene (Scene.Animating): a running slide moves one step per
48 // report. Without them it moves on a timer.48 // report. Without them it moves on a timer.
49 Frames <-chan ports.OutputFrame49 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
50 }56 }
51 57
52 // Options are the services and policy core is constructed with.58 // Options are the services and policy core is constructed with.
@@ -157,6 +163,11 @@ type Core struct {
157 // capt is the capture state (capture.go, capindicator.go).163 // capt is the capture state (capture.go, capindicator.go).
158 capt captureState164 capt captureState
159 flash focusFlash165 flash focusFlash
166+ // hint is keyboard hint mode (hint.go).
167+ hint hintState
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
160 // switcher is the column switcher in progress: the screen it is on and171 // switcher is the column switcher in progress: the screen it is on and
161 // the timer that shows its cards (switcher.go).172 // the timer that shows its cards (switcher.go).
162 switcher struct {173 switcher struct {
@@ -275,7 +286,7 @@ func (c *Core) apply(cfg ports.Config) error {
275 }286 }
276 switch Action(a) {287 switch Action(a) {
277 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,288 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
278- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:289+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279 default:290 default:
280 return fmt.Errorf("invalid action %q", a)291 return fmt.Errorf("invalid action %q", a)
281 }292 }
@@ -634,6 +645,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634 return errSecurityChanged645 return errSecurityChanged
635 }646 }
636 c.syncSecurity()647 c.syncSecurity()
648+ c.hintValidate()
637 if c.security.Protected {649 if c.security.Protected {
638 return c.publishProtected(ctx)650 return c.publishProtected(ctx)
639 }651 }
@@ -1206,6 +1218,17 @@ func (c *Core) Run(ctx context.Context) error {
1206 return nil1218 return nil
1207 }1219 }
1208 continue1220 continue
1221+ case res, ok := <-c.ch.Hints:
1222+ if !ok {
1223+ c.ch.Hints = nil
1224+ continue
1225+ }
1226+ if c.securityCheckpoint(ctx) != nil {
1227+ return nil
1228+ }
1229+ if c.hintResult(res) && c.publish(ctx) != nil {
1230+ return nil
1231+ }
1209 case ev, ok := <-c.ch.Output:1232 case ev, ok := <-c.ch.Output:
1210 if !ok {1233 if !ok {
1211 c.ch.Output = nil1234 c.ch.Output = nil
M internal/core/drag.go +1 -1
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
9090 // startDrag starts a cmd + button drag on the window under the pointer.
9191 // It reports whether it did: the press is then not forwarded.
9292 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
9494 return false, nil
9595 }
9696 if v.Button != btnLeft && v.Button != btnRight {
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
90 // startDrag starts a cmd + button drag on the window under the pointer.90 // startDrag starts a cmd + button drag on the window under the pointer.
91 // It reports whether it did: the press is then not forwarded.91 // It reports whether it did: the press is then not forwarded.
92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
94 return false, nil94 return false, nil
95 }95 }
96 if v.Button != btnLeft && v.Button != btnRight {96 if v.Button != btnLeft && v.Button != btnRight {
A internal/core/hint.go +250 -0
@@ -0,0 +1,250 @@
1+package core
2+
3+import (
4+ "cmp"
5+ "context"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Hint mode: the hint action asks the scanner (HintScans) for the clickable
12+// targets in the last frame of the focused output, labels the ones that sit
13+// over a managed window, grabs the keyboard for the label letters, and clicks
14+// the centre of the target whose label was typed. Everything here runs on the
15+// owner goroutine.
16+
17+// hintAlphabet orders the label letters: home row first.
18+const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
19+
20+// hintMaxTargets is the most targets labelled: every two-letter label.
21+const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
22+
23+type hintPhase uint8
24+
25+const (
26+ hintIdle hintPhase = iota
27+ hintPending
28+ hintShown
29+)
30+
31+// hintTarget is a labelled click target. x and y are global logical
32+// coordinates of its centre.
33+type hintTarget struct {
34+ x, y float64
35+ window WindowID
36+ label string
37+ rect ports.Rect
38+}
39+
40+type hintState struct {
41+ phase hintPhase
42+ seq uint64 // last request sequence number
43+ output string
44+ // ws is the workspace on the hint output when hint mode began: another
45+ // one on screen ends it.
46+ ws *Workspace
47+ targets []hintTarget
48+ typed string
49+}
50+
51+// hintRequest starts hint mode on the focused screen. It does nothing
52+// without a scanner, while the session is protected, while hint mode is
53+// active, or on an output with a non-normal transform.
54+func (c *Core) hintRequest() {
55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
56+ return
57+ }
58+ sc := c.cur()
59+ if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
60+ return
61+ }
62+ req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale, LinksOnly: c.cfg.Hint.LinksOnly}
63+ select {
64+ case c.ch.HintScans <- req:
65+ default:
66+ return
67+ }
68+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
69+}
70+
71+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
80+// hintResult handles a scanner result and reports whether to publish.
81+func (c *Core) hintResult(res ports.HintScanResult) bool {
82+ h := &c.hint
83+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
84+ return false
85+ }
86+ if res.Err != nil || c.security.Protected {
87+ c.hintCancel()
88+ return true
89+ }
90+ i := c.screenIndex(h.output)
91+ if i < 0 {
92+ c.hintCancel()
93+ return true
94+ }
95+ sc := c.screens[i]
96+ var kept []hintTarget
97+ for _, t := range res.Targets {
98+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
99+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
100+ id, _, _ := c.hit(x, y)
101+ if id == 0 {
102+ continue
103+ }
104+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
105+ // A layer surface, not a managed window.
106+ continue
107+ }
108+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
109+ }
110+ if len(kept) == 0 {
111+ c.hintCancel()
112+ return true
113+ }
114+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
115+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
116+ })
117+ if len(kept) > hintMaxTargets {
118+ kept = kept[:hintMaxTargets]
119+ }
120+ n := len(hintAlphabet)
121+ for i := range kept {
122+ if len(kept) <= n {
123+ kept[i].label = string(hintAlphabet[i])
124+ } else {
125+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
126+ }
127+ }
128+ h.phase, h.targets, h.typed = hintShown, kept, ""
129+ return true
130+}
131+
132+// hintCancel ends hint mode.
133+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
134+
135+// hintValidate ends hint mode when what it was started on is gone: the
136+// session became protected, the output went, another workspace is on screen
137+// there, or the overview opened. It runs at the start of every publish.
138+func (c *Core) hintValidate() {
139+ h := &c.hint
140+ if h.phase == hintIdle {
141+ return
142+ }
143+ i := c.screenIndex(h.output)
144+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
145+ c.hintCancel()
146+ }
147+}
148+
149+// hintKey handles a key while hint mode is active: the key never goes on.
150+// A press is the whole job; releases take the usual swallow path.
151+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
152+ if c.hint.phase == hintIdle || !key.Pressed {
153+ return false, nil
154+ }
155+ c.pressed[heldKey(key)] = true
156+ h := &c.hint
157+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
158+ c.hintCancel()
159+ return true, c.publish(ctx)
160+ }
161+ if keyName(key.Keysym) == "BackSpace" {
162+ if h.typed != "" {
163+ h.typed = h.typed[:len(h.typed)-1]
164+ }
165+ return true, c.publish(ctx)
166+ }
167+ name := key.Base
168+ if name == "" {
169+ name = key.Keysym
170+ }
171+ name = keyName(name)
172+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
173+ c.hintCancel()
174+ return true, c.publish(ctx)
175+ }
176+ typed := h.typed + name
177+ var match *hintTarget
178+ any := false
179+ for i := range h.targets {
180+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
181+ any = true
182+ if h.targets[i].label == typed {
183+ match = &h.targets[i]
184+ }
185+ }
186+ }
187+ if !any {
188+ return true, nil
189+ }
190+ if match == nil {
191+ h.typed = typed
192+ return true, c.publish(ctx)
193+ }
194+ return true, c.hintClick(ctx, *match)
195+}
196+
197+// hintClick ends hint mode and left-clicks the target's centre, provided the
198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
201+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
202+ c.hintCancel()
203+ if err := c.publish(ctx); err != nil {
204+ return err
205+ }
206+ if !c.hintClickable(t) {
207+ return nil
208+ }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
211+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
212+ return err
213+ }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
219+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
220+ return err
221+ }
222+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
223+}
224+
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
235+// hintLabels are the labels to draw on sc: those still matching what was
236+// typed. Nil unless sc is the hint output.
237+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
238+ h := &c.hint
239+ if h.phase != hintShown || sc.name() != h.output {
240+ return nil
241+ }
242+ var out []ports.HintLabel
243+ for _, t := range h.targets {
244+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
245+ continue
246+ }
247+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
248+ }
249+ return out
250+}
@@ -0,0 +1,250 @@
1+package core
2+
3+import (
4+ "cmp"
5+ "context"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Hint mode: the hint action asks the scanner (HintScans) for the clickable
12+// targets in the last frame of the focused output, labels the ones that sit
13+// over a managed window, grabs the keyboard for the label letters, and clicks
14+// the centre of the target whose label was typed. Everything here runs on the
15+// owner goroutine.
16+
17+// hintAlphabet orders the label letters: home row first.
18+const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
19+
20+// hintMaxTargets is the most targets labelled: every two-letter label.
21+const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
22+
23+type hintPhase uint8
24+
25+const (
26+ hintIdle hintPhase = iota
27+ hintPending
28+ hintShown
29+)
30+
31+// hintTarget is a labelled click target. x and y are global logical
32+// coordinates of its centre.
33+type hintTarget struct {
34+ x, y float64
35+ window WindowID
36+ label string
37+ rect ports.Rect
38+}
39+
40+type hintState struct {
41+ phase hintPhase
42+ seq uint64 // last request sequence number
43+ output string
44+ // ws is the workspace on the hint output when hint mode began: another
45+ // one on screen ends it.
46+ ws *Workspace
47+ targets []hintTarget
48+ typed string
49+}
50+
51+// hintRequest starts hint mode on the focused screen. It does nothing
52+// without a scanner, while the session is protected, while hint mode is
53+// active, or on an output with a non-normal transform.
54+func (c *Core) hintRequest() {
55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
56+ return
57+ }
58+ sc := c.cur()
59+ if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
60+ return
61+ }
62+ req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale, LinksOnly: c.cfg.Hint.LinksOnly}
63+ select {
64+ case c.ch.HintScans <- req:
65+ default:
66+ return
67+ }
68+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
69+}
70+
71+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
80+// hintResult handles a scanner result and reports whether to publish.
81+func (c *Core) hintResult(res ports.HintScanResult) bool {
82+ h := &c.hint
83+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
84+ return false
85+ }
86+ if res.Err != nil || c.security.Protected {
87+ c.hintCancel()
88+ return true
89+ }
90+ i := c.screenIndex(h.output)
91+ if i < 0 {
92+ c.hintCancel()
93+ return true
94+ }
95+ sc := c.screens[i]
96+ var kept []hintTarget
97+ for _, t := range res.Targets {
98+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
99+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
100+ id, _, _ := c.hit(x, y)
101+ if id == 0 {
102+ continue
103+ }
104+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
105+ // A layer surface, not a managed window.
106+ continue
107+ }
108+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
109+ }
110+ if len(kept) == 0 {
111+ c.hintCancel()
112+ return true
113+ }
114+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
115+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
116+ })
117+ if len(kept) > hintMaxTargets {
118+ kept = kept[:hintMaxTargets]
119+ }
120+ n := len(hintAlphabet)
121+ for i := range kept {
122+ if len(kept) <= n {
123+ kept[i].label = string(hintAlphabet[i])
124+ } else {
125+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
126+ }
127+ }
128+ h.phase, h.targets, h.typed = hintShown, kept, ""
129+ return true
130+}
131+
132+// hintCancel ends hint mode.
133+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
134+
135+// hintValidate ends hint mode when what it was started on is gone: the
136+// session became protected, the output went, another workspace is on screen
137+// there, or the overview opened. It runs at the start of every publish.
138+func (c *Core) hintValidate() {
139+ h := &c.hint
140+ if h.phase == hintIdle {
141+ return
142+ }
143+ i := c.screenIndex(h.output)
144+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
145+ c.hintCancel()
146+ }
147+}
148+
149+// hintKey handles a key while hint mode is active: the key never goes on.
150+// A press is the whole job; releases take the usual swallow path.
151+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
152+ if c.hint.phase == hintIdle || !key.Pressed {
153+ return false, nil
154+ }
155+ c.pressed[heldKey(key)] = true
156+ h := &c.hint
157+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
158+ c.hintCancel()
159+ return true, c.publish(ctx)
160+ }
161+ if keyName(key.Keysym) == "BackSpace" {
162+ if h.typed != "" {
163+ h.typed = h.typed[:len(h.typed)-1]
164+ }
165+ return true, c.publish(ctx)
166+ }
167+ name := key.Base
168+ if name == "" {
169+ name = key.Keysym
170+ }
171+ name = keyName(name)
172+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
173+ c.hintCancel()
174+ return true, c.publish(ctx)
175+ }
176+ typed := h.typed + name
177+ var match *hintTarget
178+ any := false
179+ for i := range h.targets {
180+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
181+ any = true
182+ if h.targets[i].label == typed {
183+ match = &h.targets[i]
184+ }
185+ }
186+ }
187+ if !any {
188+ return true, nil
189+ }
190+ if match == nil {
191+ h.typed = typed
192+ return true, c.publish(ctx)
193+ }
194+ return true, c.hintClick(ctx, *match)
195+}
196+
197+// hintClick ends hint mode and left-clicks the target's centre, provided the
198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
201+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
202+ c.hintCancel()
203+ if err := c.publish(ctx); err != nil {
204+ return err
205+ }
206+ if !c.hintClickable(t) {
207+ return nil
208+ }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
211+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
212+ return err
213+ }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
219+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
220+ return err
221+ }
222+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
223+}
224+
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
235+// hintLabels are the labels to draw on sc: those still matching what was
236+// typed. Nil unless sc is the hint output.
237+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
238+ h := &c.hint
239+ if h.phase != hintShown || sc.name() != h.output {
240+ return nil
241+ }
242+ var out []ports.HintLabel
243+ for _, t := range h.targets {
244+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
245+ continue
246+ }
247+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
248+ }
249+ return out
250+}
A internal/core/hint_test.go +577 -0
@@ -0,0 +1,577 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+type hintHarness struct {
16+ t *testing.T
17+ input chan ports.InputEvent
18+ client chan ports.ClientEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ scans chan ports.HintScanRequest
23+ hints chan ports.HintScanResult
24+ win ports.Rect // window 1, output-local
25+ // state is the security state input is stamped with.
26+ state ports.SecurityState
27+}
28+
29+// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
30+func newHint(t *testing.T, opts core.Options) *hintHarness {
31+ t.Helper()
32+ cfg := scrollDefaults()
33+ cfg.Border.Width = 0
34+ cfg.Layout.Gaps = 20
35+ cfg.Binds["Cmd+g"] = "hint"
36+ h := &hintHarness{
37+ t: t,
38+ input: make(chan ports.InputEvent, 32),
39+ client: make(chan ports.ClientEvent, 32),
40+ output: make(chan ports.OutputEvent, 8),
41+ commands: make(chan ports.ClientCommand, 256),
42+ scenes: make(chan []ports.Scene, 1),
43+ scans: make(chan ports.HintScanRequest, 1),
44+ // Unbuffered: a send returns once core took the result.
45+ hints: make(chan ports.HintScanResult),
46+ }
47+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
48+ c, err := core.New(cfg, ch, opts)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); receive(t, done) })
56+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
57+ scene(t, h.scenes)
58+ h.client <- ports.WindowMapped{ID: 1}
59+ s := scene(t, h.scenes)
60+ h.win = s.Windows[0].Rect
61+ h.drain()
62+ return h
63+}
64+
65+func (h *hintHarness) drain() {
66+ for {
67+ select {
68+ case <-h.commands:
69+ default:
70+ return
71+ }
72+ }
73+}
74+
75+// press and release send one key event.
76+func (h *hintHarness) press(sym string, mods ports.Mods) {
77+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
78+}
79+
80+func (h *hintHarness) release(sym string, mods ports.Mods) {
81+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
82+}
83+
84+// send gives core an input event stamped with the current security state.
85+func (h *hintHarness) send(ev ports.InputEvent) {
86+ h.input <- ports.SecurityInput{State: h.state, Event: ev}
87+}
88+
89+// start presses the hint bind and returns the request.
90+func (h *hintHarness) start() ports.HintScanRequest {
91+ h.t.Helper()
92+ h.press("g", ports.ModSuper)
93+ req := receive(h.t, h.scans)
94+ h.release("g", 0) // the user lets go of the whole chord
95+ return req
96+}
97+
98+// show starts hint mode and answers with targets, returning the next scene.
99+func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
100+ h.t.Helper()
101+ req := h.start()
102+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
103+ return h.labelled(len(targets), 0)
104+}
105+
106+// until returns the first scene, from the next one on, that satisfies ok:
107+// the bind press and other input publish scenes before the one waited for.
108+func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
109+ h.t.Helper()
110+ for {
111+ if s := scene(h.t, h.scenes); ok(s) {
112+ return s
113+ }
114+ }
115+}
116+
117+// labelled waits for a scene with n labels, all typed with typed letters.
118+func (h *hintHarness) labelled(n, typed int) ports.Scene {
119+ h.t.Helper()
120+ return h.until(func(s ports.Scene) bool {
121+ return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
122+ })
123+}
124+
125+// inside is a target of the given size inside window 1.
126+func (h *hintHarness) inside(i int) ports.HintTarget {
127+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
128+}
129+
130+func TestHintRequestOnBind(t *testing.T) {
131+ h := newHint(t, core.Options{})
132+ req := h.start()
133+ if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 {
134+ t.Fatalf("request %+v", req)
135+ }
136+ // A second bind while pending does not ask again; the pending phase
137+ // cancels on any key.
138+ h.press("g", ports.ModSuper)
139+ select {
140+ case r := <-h.scans:
141+ t.Fatalf("second request %+v", r)
142+ case <-time.After(50 * time.Millisecond):
143+ }
144+}
145+
146+func TestHintRequestNilScansIsNoop(t *testing.T) {
147+ cfg := scrollDefaults()
148+ cfg.Binds["Cmd+g"] = "hint"
149+ input := make(chan ports.InputEvent, 4)
150+ client := make(chan ports.ClientEvent, 4)
151+ output := make(chan ports.OutputEvent, 4)
152+ commands := make(chan ports.ClientCommand, 32)
153+ scenes := make(chan []ports.Scene, 1)
154+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ ctx, cancel := context.WithCancel(context.Background())
159+ done := make(chan error, 1)
160+ go func() { done <- c.Run(ctx) }()
161+ defer func() { cancel(); receive(t, done) }()
162+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
163+ scene(t, scenes)
164+ input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true}
165+ if s := scene(t, scenes); len(s.HintLabels) != 0 {
166+ t.Fatal(s.HintLabels)
167+ }
168+}
169+
170+func TestHintRefusedWhenProtected(t *testing.T) {
171+ var state atomic.Pointer[ports.SecurityState]
172+ state.Store(&ports.SecurityState{})
173+ gate := portsmocks.NewMockSessionSecurity(t)
174+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
175+ h := newHint(t, core.Options{Security: gate})
176+ // Hint mode shown, then the session locks: the labels go away.
177+ h.show(h.inside(0))
178+ state.Store(&ports.SecurityState{Generation: 2, Protected: true})
179+ h.state = *state.Load()
180+ h.client <- ports.WindowMapped{ID: 2}
181+ s := h.until(func(s ports.Scene) bool { return s.Security.Protected })
182+ if len(s.HintLabels) != 0 {
183+ t.Fatalf("labels while protected %+v", s.HintLabels)
184+ }
185+ // And a new request is refused while it lasts.
186+ h.press("g", ports.ModSuper)
187+ select {
188+ case r := <-h.scans:
189+ t.Fatalf("request while protected %+v", r)
190+ case <-time.After(100 * time.Millisecond):
191+ }
192+}
193+
194+func TestHintResultShowsLabels(t *testing.T) {
195+ h := newHint(t, core.Options{})
196+ var targets []ports.HintTarget
197+ for i := 0; i < 26; i++ {
198+ targets = append(targets, h.inside(i))
199+ }
200+ s := h.show(targets...)
201+ if len(s.HintLabels) != 26 {
202+ t.Fatalf("%d labels", len(s.HintLabels))
203+ }
204+ seen := map[string]bool{}
205+ for _, l := range s.HintLabels {
206+ if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] {
207+ t.Fatalf("label %+v", l)
208+ }
209+ seen[l.Text] = true
210+ }
211+ // Sorted by position: the first target gets the first letter.
212+ if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y {
213+ t.Fatalf("first %+v", s.HintLabels[0])
214+ }
215+}
216+
217+func TestHintTwoLetterLabels(t *testing.T) {
218+ h := newHint(t, core.Options{})
219+ var targets []ports.HintTarget
220+ for i := 0; i < 27; i++ {
221+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
222+ }
223+ s := h.show(targets...)
224+ if len(s.HintLabels) != 27 {
225+ t.Fatalf("%d labels", len(s.HintLabels))
226+ }
227+ seen := map[string]bool{}
228+ for _, l := range s.HintLabels {
229+ if len(l.Text) != 2 || seen[l.Text] {
230+ t.Fatalf("label %+v", l)
231+ }
232+ seen[l.Text] = true
233+ }
234+ if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" {
235+ t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text)
236+ }
237+}
238+
239+func TestHintDropsTargetsOutsideWindows(t *testing.T) {
240+ h := newHint(t, core.Options{})
241+ in := h.inside(0)
242+ // In the gap around the window, over the background.
243+ out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}}
244+ if h.win.X < 8 || h.win.Y < 8 {
245+ t.Fatalf("window %+v leaves no gap", h.win)
246+ }
247+ req := h.start()
248+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}}
249+ s := h.labelled(1, 0)
250+ if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X {
251+ t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win)
252+ }
253+}
254+
255+func TestHintTypingFilters(t *testing.T) {
256+ h := newHint(t, core.Options{})
257+ var targets []ports.HintTarget
258+ for i := 0; i < 27; i++ {
259+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
260+ }
261+ h.show(targets...)
262+ h.press("f", 0)
263+ s := h.labelled(26, 1)
264+ if len(s.HintLabels) != 26 {
265+ t.Fatalf("%d labels after f", len(s.HintLabels))
266+ }
267+ for _, l := range s.HintLabels {
268+ if l.Typed != 1 || l.Text[0] != 'f' {
269+ t.Fatalf("label %+v", l)
270+ }
271+ }
272+ h.press("BackSpace", 0)
273+ h.labelled(27, 0)
274+ // Only the 27th target starts with j: its label is "jf".
275+ h.press("j", 0)
276+ s = h.labelled(1, 1)
277+ if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" {
278+ t.Fatalf("after j %+v", s.HintLabels)
279+ }
280+}
281+
282+func TestHintBackspace(t *testing.T) {
283+ h := newHint(t, core.Options{})
284+ var targets []ports.HintTarget
285+ for i := 0; i < 27; i++ {
286+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
287+ }
288+ h.show(targets...)
289+ h.press("f", 0)
290+ h.labelled(26, 1)
291+ h.press("BackSpace", 0)
292+ s := h.labelled(27, 0)
293+ if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
294+ t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
295+ }
296+}
297+
298+func TestHintClick(t *testing.T) {
299+ h := newHint(t, core.Options{})
300+ tg := h.inside(0)
301+ h.show(tg)
302+ h.drain()
303+ h.press("f", 0)
304+ s := h.labelled(0, 0)
305+ if len(s.HintLabels) != 0 {
306+ t.Fatalf("labels after click %+v", s.HintLabels)
307+ }
308+ cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X)
309+ cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y)
310+ var gotMotion, gotPress, gotRelease, gotFocus bool
311+ for len(h.commands) > 0 || !gotRelease {
312+ var cmd ports.ClientCommand
313+ select {
314+ case cmd = <-h.commands:
315+ case <-time.After(time.Second):
316+ t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease)
317+ }
318+ switch v := cmd.(type) {
319+ case ports.PointerFocus:
320+ if v.ID == 1 && v.X == cx && v.Y == cy {
321+ gotFocus = true
322+ }
323+ case ports.PointerMotionTo:
324+ if v.ID == 1 && v.X == cx && v.Y == cy {
325+ gotMotion = true
326+ }
327+ case ports.PointerButtonTo:
328+ if v.ID != 1 || v.Button != 0x110 {
329+ t.Fatalf("button %+v", v)
330+ }
331+ if v.Pressed {
332+ gotPress = true
333+ } else {
334+ gotRelease = gotPress
335+ }
336+ }
337+ }
338+ if !gotFocus || !gotMotion {
339+ t.Fatalf("focus %v motion %v", gotFocus, gotMotion)
340+ }
341+}
342+
343+func TestHintEscapeCancels(t *testing.T) {
344+ h := newHint(t, core.Options{})
345+ h.show(h.inside(0))
346+ h.drain()
347+ h.press("Escape", 0)
348+ s := h.labelled(0, 0)
349+ if len(s.HintLabels) != 0 {
350+ t.Fatalf("labels %+v", s.HintLabels)
351+ }
352+ h.release("Escape", 0)
353+ // A later key goes to the window again: the Escape release did not.
354+ h.press("x", 0)
355+ cmd := command(t, h.commands)
356+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
357+ t.Fatalf("command %#v", cmd)
358+ }
359+ for len(h.commands) > 0 {
360+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
361+ t.Fatal("button sent")
362+ }
363+ }
364+}
365+
366+func TestHintKeysNotForwarded(t *testing.T) {
367+ h := newHint(t, core.Options{})
368+ h.show(h.inside(0), h.inside(1))
369+ h.drain()
370+ // A letter that matches nothing is swallowed and does not cancel.
371+ h.press("q", 0)
372+ h.release("q", 0)
373+ // Any other key cancels and is swallowed.
374+ h.press("Return", 0)
375+ s := h.labelled(0, 0)
376+ if len(s.HintLabels) != 0 {
377+ t.Fatalf("labels %+v", s.HintLabels)
378+ }
379+ h.release("Return", 0)
380+ h.press("x", 0)
381+ cmd := command(t, h.commands)
382+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
383+ t.Fatalf("first forwarded command %#v", cmd)
384+ }
385+}
386+
387+func TestHintStaleSeqIgnored(t *testing.T) {
388+ h := newHint(t, core.Options{})
389+ req := h.start()
390+ h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}}
391+ // A matching result afterwards shows: the stale one changed nothing.
392+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}}
393+ s := h.labelled(2, 0)
394+ if len(s.HintLabels) != 2 {
395+ t.Fatalf("labels %+v", s.HintLabels)
396+ }
397+}
398+
399+func TestHintErrorCancels(t *testing.T) {
400+ h := newHint(t, core.Options{})
401+ req := h.start()
402+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")}
403+ h.press("x", 0)
404+ cmd := command(t, h.commands)
405+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
406+ t.Fatalf("command %#v", cmd)
407+ }
408+}
409+
410+func TestHintWindowMovedAborts(t *testing.T) {
411+ h := newHint(t, core.Options{})
412+ tg := h.inside(0)
413+ h.show(tg)
414+ // The window closes before the label is typed: the click must not go
415+ // anywhere.
416+ h.client <- ports.WindowUnmapped{ID: 1}
417+ h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
418+ h.drain()
419+ h.press("f", 0)
420+ time.Sleep(50 * time.Millisecond)
421+ for len(h.commands) > 0 {
422+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
423+ t.Fatal("button sent to a moved window")
424+ }
425+ }
426+}
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}
@@ -0,0 +1,577 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+type hintHarness struct {
16+ t *testing.T
17+ input chan ports.InputEvent
18+ client chan ports.ClientEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ scans chan ports.HintScanRequest
23+ hints chan ports.HintScanResult
24+ win ports.Rect // window 1, output-local
25+ // state is the security state input is stamped with.
26+ state ports.SecurityState
27+}
28+
29+// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
30+func newHint(t *testing.T, opts core.Options) *hintHarness {
31+ t.Helper()
32+ cfg := scrollDefaults()
33+ cfg.Border.Width = 0
34+ cfg.Layout.Gaps = 20
35+ cfg.Binds["Cmd+g"] = "hint"
36+ h := &hintHarness{
37+ t: t,
38+ input: make(chan ports.InputEvent, 32),
39+ client: make(chan ports.ClientEvent, 32),
40+ output: make(chan ports.OutputEvent, 8),
41+ commands: make(chan ports.ClientCommand, 256),
42+ scenes: make(chan []ports.Scene, 1),
43+ scans: make(chan ports.HintScanRequest, 1),
44+ // Unbuffered: a send returns once core took the result.
45+ hints: make(chan ports.HintScanResult),
46+ }
47+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
48+ c, err := core.New(cfg, ch, opts)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); receive(t, done) })
56+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
57+ scene(t, h.scenes)
58+ h.client <- ports.WindowMapped{ID: 1}
59+ s := scene(t, h.scenes)
60+ h.win = s.Windows[0].Rect
61+ h.drain()
62+ return h
63+}
64+
65+func (h *hintHarness) drain() {
66+ for {
67+ select {
68+ case <-h.commands:
69+ default:
70+ return
71+ }
72+ }
73+}
74+
75+// press and release send one key event.
76+func (h *hintHarness) press(sym string, mods ports.Mods) {
77+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
78+}
79+
80+func (h *hintHarness) release(sym string, mods ports.Mods) {
81+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
82+}
83+
84+// send gives core an input event stamped with the current security state.
85+func (h *hintHarness) send(ev ports.InputEvent) {
86+ h.input <- ports.SecurityInput{State: h.state, Event: ev}
87+}
88+
89+// start presses the hint bind and returns the request.
90+func (h *hintHarness) start() ports.HintScanRequest {
91+ h.t.Helper()
92+ h.press("g", ports.ModSuper)
93+ req := receive(h.t, h.scans)
94+ h.release("g", 0) // the user lets go of the whole chord
95+ return req
96+}
97+
98+// show starts hint mode and answers with targets, returning the next scene.
99+func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
100+ h.t.Helper()
101+ req := h.start()
102+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
103+ return h.labelled(len(targets), 0)
104+}
105+
106+// until returns the first scene, from the next one on, that satisfies ok:
107+// the bind press and other input publish scenes before the one waited for.
108+func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
109+ h.t.Helper()
110+ for {
111+ if s := scene(h.t, h.scenes); ok(s) {
112+ return s
113+ }
114+ }
115+}
116+
117+// labelled waits for a scene with n labels, all typed with typed letters.
118+func (h *hintHarness) labelled(n, typed int) ports.Scene {
119+ h.t.Helper()
120+ return h.until(func(s ports.Scene) bool {
121+ return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
122+ })
123+}
124+
125+// inside is a target of the given size inside window 1.
126+func (h *hintHarness) inside(i int) ports.HintTarget {
127+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
128+}
129+
130+func TestHintRequestOnBind(t *testing.T) {
131+ h := newHint(t, core.Options{})
132+ req := h.start()
133+ if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 {
134+ t.Fatalf("request %+v", req)
135+ }
136+ // A second bind while pending does not ask again; the pending phase
137+ // cancels on any key.
138+ h.press("g", ports.ModSuper)
139+ select {
140+ case r := <-h.scans:
141+ t.Fatalf("second request %+v", r)
142+ case <-time.After(50 * time.Millisecond):
143+ }
144+}
145+
146+func TestHintRequestNilScansIsNoop(t *testing.T) {
147+ cfg := scrollDefaults()
148+ cfg.Binds["Cmd+g"] = "hint"
149+ input := make(chan ports.InputEvent, 4)
150+ client := make(chan ports.ClientEvent, 4)
151+ output := make(chan ports.OutputEvent, 4)
152+ commands := make(chan ports.ClientCommand, 32)
153+ scenes := make(chan []ports.Scene, 1)
154+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ ctx, cancel := context.WithCancel(context.Background())
159+ done := make(chan error, 1)
160+ go func() { done <- c.Run(ctx) }()
161+ defer func() { cancel(); receive(t, done) }()
162+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
163+ scene(t, scenes)
164+ input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true}
165+ if s := scene(t, scenes); len(s.HintLabels) != 0 {
166+ t.Fatal(s.HintLabels)
167+ }
168+}
169+
170+func TestHintRefusedWhenProtected(t *testing.T) {
171+ var state atomic.Pointer[ports.SecurityState]
172+ state.Store(&ports.SecurityState{})
173+ gate := portsmocks.NewMockSessionSecurity(t)
174+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
175+ h := newHint(t, core.Options{Security: gate})
176+ // Hint mode shown, then the session locks: the labels go away.
177+ h.show(h.inside(0))
178+ state.Store(&ports.SecurityState{Generation: 2, Protected: true})
179+ h.state = *state.Load()
180+ h.client <- ports.WindowMapped{ID: 2}
181+ s := h.until(func(s ports.Scene) bool { return s.Security.Protected })
182+ if len(s.HintLabels) != 0 {
183+ t.Fatalf("labels while protected %+v", s.HintLabels)
184+ }
185+ // And a new request is refused while it lasts.
186+ h.press("g", ports.ModSuper)
187+ select {
188+ case r := <-h.scans:
189+ t.Fatalf("request while protected %+v", r)
190+ case <-time.After(100 * time.Millisecond):
191+ }
192+}
193+
194+func TestHintResultShowsLabels(t *testing.T) {
195+ h := newHint(t, core.Options{})
196+ var targets []ports.HintTarget
197+ for i := 0; i < 26; i++ {
198+ targets = append(targets, h.inside(i))
199+ }
200+ s := h.show(targets...)
201+ if len(s.HintLabels) != 26 {
202+ t.Fatalf("%d labels", len(s.HintLabels))
203+ }
204+ seen := map[string]bool{}
205+ for _, l := range s.HintLabels {
206+ if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] {
207+ t.Fatalf("label %+v", l)
208+ }
209+ seen[l.Text] = true
210+ }
211+ // Sorted by position: the first target gets the first letter.
212+ if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y {
213+ t.Fatalf("first %+v", s.HintLabels[0])
214+ }
215+}
216+
217+func TestHintTwoLetterLabels(t *testing.T) {
218+ h := newHint(t, core.Options{})
219+ var targets []ports.HintTarget
220+ for i := 0; i < 27; i++ {
221+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
222+ }
223+ s := h.show(targets...)
224+ if len(s.HintLabels) != 27 {
225+ t.Fatalf("%d labels", len(s.HintLabels))
226+ }
227+ seen := map[string]bool{}
228+ for _, l := range s.HintLabels {
229+ if len(l.Text) != 2 || seen[l.Text] {
230+ t.Fatalf("label %+v", l)
231+ }
232+ seen[l.Text] = true
233+ }
234+ if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" {
235+ t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text)
236+ }
237+}
238+
239+func TestHintDropsTargetsOutsideWindows(t *testing.T) {
240+ h := newHint(t, core.Options{})
241+ in := h.inside(0)
242+ // In the gap around the window, over the background.
243+ out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}}
244+ if h.win.X < 8 || h.win.Y < 8 {
245+ t.Fatalf("window %+v leaves no gap", h.win)
246+ }
247+ req := h.start()
248+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}}
249+ s := h.labelled(1, 0)
250+ if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X {
251+ t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win)
252+ }
253+}
254+
255+func TestHintTypingFilters(t *testing.T) {
256+ h := newHint(t, core.Options{})
257+ var targets []ports.HintTarget
258+ for i := 0; i < 27; i++ {
259+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
260+ }
261+ h.show(targets...)
262+ h.press("f", 0)
263+ s := h.labelled(26, 1)
264+ if len(s.HintLabels) != 26 {
265+ t.Fatalf("%d labels after f", len(s.HintLabels))
266+ }
267+ for _, l := range s.HintLabels {
268+ if l.Typed != 1 || l.Text[0] != 'f' {
269+ t.Fatalf("label %+v", l)
270+ }
271+ }
272+ h.press("BackSpace", 0)
273+ h.labelled(27, 0)
274+ // Only the 27th target starts with j: its label is "jf".
275+ h.press("j", 0)
276+ s = h.labelled(1, 1)
277+ if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" {
278+ t.Fatalf("after j %+v", s.HintLabels)
279+ }
280+}
281+
282+func TestHintBackspace(t *testing.T) {
283+ h := newHint(t, core.Options{})
284+ var targets []ports.HintTarget
285+ for i := 0; i < 27; i++ {
286+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
287+ }
288+ h.show(targets...)
289+ h.press("f", 0)
290+ h.labelled(26, 1)
291+ h.press("BackSpace", 0)
292+ s := h.labelled(27, 0)
293+ if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
294+ t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
295+ }
296+}
297+
298+func TestHintClick(t *testing.T) {
299+ h := newHint(t, core.Options{})
300+ tg := h.inside(0)
301+ h.show(tg)
302+ h.drain()
303+ h.press("f", 0)
304+ s := h.labelled(0, 0)
305+ if len(s.HintLabels) != 0 {
306+ t.Fatalf("labels after click %+v", s.HintLabels)
307+ }
308+ cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X)
309+ cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y)
310+ var gotMotion, gotPress, gotRelease, gotFocus bool
311+ for len(h.commands) > 0 || !gotRelease {
312+ var cmd ports.ClientCommand
313+ select {
314+ case cmd = <-h.commands:
315+ case <-time.After(time.Second):
316+ t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease)
317+ }
318+ switch v := cmd.(type) {
319+ case ports.PointerFocus:
320+ if v.ID == 1 && v.X == cx && v.Y == cy {
321+ gotFocus = true
322+ }
323+ case ports.PointerMotionTo:
324+ if v.ID == 1 && v.X == cx && v.Y == cy {
325+ gotMotion = true
326+ }
327+ case ports.PointerButtonTo:
328+ if v.ID != 1 || v.Button != 0x110 {
329+ t.Fatalf("button %+v", v)
330+ }
331+ if v.Pressed {
332+ gotPress = true
333+ } else {
334+ gotRelease = gotPress
335+ }
336+ }
337+ }
338+ if !gotFocus || !gotMotion {
339+ t.Fatalf("focus %v motion %v", gotFocus, gotMotion)
340+ }
341+}
342+
343+func TestHintEscapeCancels(t *testing.T) {
344+ h := newHint(t, core.Options{})
345+ h.show(h.inside(0))
346+ h.drain()
347+ h.press("Escape", 0)
348+ s := h.labelled(0, 0)
349+ if len(s.HintLabels) != 0 {
350+ t.Fatalf("labels %+v", s.HintLabels)
351+ }
352+ h.release("Escape", 0)
353+ // A later key goes to the window again: the Escape release did not.
354+ h.press("x", 0)
355+ cmd := command(t, h.commands)
356+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
357+ t.Fatalf("command %#v", cmd)
358+ }
359+ for len(h.commands) > 0 {
360+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
361+ t.Fatal("button sent")
362+ }
363+ }
364+}
365+
366+func TestHintKeysNotForwarded(t *testing.T) {
367+ h := newHint(t, core.Options{})
368+ h.show(h.inside(0), h.inside(1))
369+ h.drain()
370+ // A letter that matches nothing is swallowed and does not cancel.
371+ h.press("q", 0)
372+ h.release("q", 0)
373+ // Any other key cancels and is swallowed.
374+ h.press("Return", 0)
375+ s := h.labelled(0, 0)
376+ if len(s.HintLabels) != 0 {
377+ t.Fatalf("labels %+v", s.HintLabels)
378+ }
379+ h.release("Return", 0)
380+ h.press("x", 0)
381+ cmd := command(t, h.commands)
382+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
383+ t.Fatalf("first forwarded command %#v", cmd)
384+ }
385+}
386+
387+func TestHintStaleSeqIgnored(t *testing.T) {
388+ h := newHint(t, core.Options{})
389+ req := h.start()
390+ h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}}
391+ // A matching result afterwards shows: the stale one changed nothing.
392+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}}
393+ s := h.labelled(2, 0)
394+ if len(s.HintLabels) != 2 {
395+ t.Fatalf("labels %+v", s.HintLabels)
396+ }
397+}
398+
399+func TestHintErrorCancels(t *testing.T) {
400+ h := newHint(t, core.Options{})
401+ req := h.start()
402+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")}
403+ h.press("x", 0)
404+ cmd := command(t, h.commands)
405+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
406+ t.Fatalf("command %#v", cmd)
407+ }
408+}
409+
410+func TestHintWindowMovedAborts(t *testing.T) {
411+ h := newHint(t, core.Options{})
412+ tg := h.inside(0)
413+ h.show(tg)
414+ // The window closes before the label is typed: the click must not go
415+ // anywhere.
416+ h.client <- ports.WindowUnmapped{ID: 1}
417+ h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
418+ h.drain()
419+ h.press("f", 0)
420+ time.Sleep(50 * time.Millisecond)
421+ for len(h.commands) > 0 {
422+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
423+ t.Fatal("button sent to a moved window")
424+ }
425+ }
426+}
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}
M internal/core/input.go +4 -0
@@ -277,6 +277,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
277277 }
278278 return c.rehit(ctx)
279279 }
280+ // Hint mode takes every key press before binds and windows.
281+ if handled, err := c.hintKey(ctx, key); handled || err != nil {
282+ return err
283+ }
280284 // The overview takes its keys before any window; others still
281285 // run binds, and are not forwarded.
282286 // A launcher or a menu holding the keyboard gets them first.
@@ -277,6 +277,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
277 }277 }
278 return c.rehit(ctx)278 return c.rehit(ctx)
279 }279 }
280+ // Hint mode takes every key press before binds and windows.
281+ if handled, err := c.hintKey(ctx, key); handled || err != nil {
282+ return err
283+ }
280 // The overview takes its keys before any window; others still284 // The overview takes its keys before any window; others still
281 // run binds, and are not forwarded.285 // run binds, and are not forwarded.
282 // A launcher or a menu holding the keyboard gets them first.286 // A launcher or a menu holding the keyboard gets them first.
M internal/core/scene.go +1 -0
@@ -123,6 +123,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
123123 }
124124 scene.Flash = c.flashFor(sc)
125125 scene.CaptureIndicators = c.captureIndicators(sc)
126+ scene.HintLabels = c.hintLabels(sc)
126127 scene.Capture = c.captureSceneFor(sc, capture)
127128 // The springs of this frame are advanced: a scene shown while one still
128129 // runs wakes core when it flips; the one a spring settles on does not.
@@ -123,6 +123,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
123 }123 }
124 scene.Flash = c.flashFor(sc)124 scene.Flash = c.flashFor(sc)
125 scene.CaptureIndicators = c.captureIndicators(sc)125 scene.CaptureIndicators = c.captureIndicators(sc)
126+ scene.HintLabels = c.hintLabels(sc)
126 scene.Capture = c.captureSceneFor(sc, capture)127 scene.Capture = c.captureSceneFor(sc, capture)
127 // The springs of this frame are advanced: a scene shown while one still128 // The springs of this frame are advanced: a scene shown while one still
128 // runs wakes core when it flips; the one a spring settles on does not.129 // runs wakes core when it flips; the one a spring settles on does not.
M internal/ports/capture_messages.go +6 -0
@@ -65,3 +65,9 @@ type CaptureDone struct {
6565 Err error
6666 Time time.Time
6767 }
68+
69+// CaptureIDInternal is the bit set in the ID of captures requested by the
70+// compositor itself (hint scanning) rather than by a Wayland client. Wayland
71+// IDs start at 1 and increment, so they never set it. Replies carrying it are
72+// demultiplexed away from the Wayland consumer.
73+const CaptureIDInternal uint64 = 1 << 63
@@ -65,3 +65,9 @@ type CaptureDone struct {
65 Err error65 Err error
66 Time time.Time66 Time time.Time
67 }67 }
68+
69+// CaptureIDInternal is the bit set in the ID of captures requested by the
70+// compositor itself (hint scanning) rather than by a Wayland client. Wayland
71+// IDs start at 1 and increment, so they never set it. Replies carrying it are
72+// demultiplexed away from the Wayland consumer.
73+const CaptureIDInternal uint64 = 1 << 63
M internal/ports/config.go +6 -0
@@ -161,6 +161,12 @@ type Config struct {
161161 FlashDuration time.Duration
162162 FlashColor string
163163 }
164+ // Hint configures keyboard hint mode (action hint).
165+ Hint struct {
166+ // LinksOnly limits the targets to links and URLs; false also
167+ // includes buttons (hint.targets = clickable).
168+ LinksOnly bool
169+ }
164170 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165171 Workspaces []WorkspaceConfig
166172 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
@@ -161,6 +161,12 @@ type Config struct {
161 FlashDuration time.Duration161 FlashDuration time.Duration
162 FlashColor string162 FlashColor string
163 }163 }
164+ // Hint configures keyboard hint mode (action hint).
165+ Hint struct {
166+ // LinksOnly limits the targets to links and URLs; false also
167+ // includes buttons (hint.targets = clickable).
168+ LinksOnly bool
169+ }
164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.170 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165 Workspaces []WorkspaceConfig171 Workspaces []WorkspaceConfig
166 // Rules are the window rules (rule.<name>.* keys), in first-seen order.172 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
A internal/ports/hint_messages.go +49 -0
@@ -0,0 +1,49 @@
1+package ports
2+
3+// HintKind is the kind of a clickable hint target.
4+type HintKind uint8
5+
6+const (
7+ // HintLink is a link or URL.
8+ HintLink HintKind = iota
9+ // HintButton is a button or control.
10+ HintButton
11+)
12+
13+// HintScanRequest asks the hint scanner to find clickable targets in the last
14+// frame of Output. Core sends it and expects one HintScanResult echoing Seq.
15+type HintScanRequest struct {
16+ Seq uint64 // core-chosen, echoed back
17+ Output string
18+ // Width and Height are the output buffer size in physical pixels
19+ // (OutputInfo.Width/Height).
20+ Width, Height int
21+ Scale float64 // screen scale
22+ LinksOnly bool
23+}
24+
25+// HintTarget is a clickable region found in a frame.
26+type HintTarget struct {
27+ Rect Rect // logical, output-local
28+ Kind HintKind
29+}
30+
31+// HintScanResult answers a HintScanRequest. Core owns Targets.
32+type HintScanResult struct {
33+ Seq uint64
34+ Output string
35+ Targets []HintTarget
36+ Err error
37+}
38+
39+// HintLabel is a label drawn over a hint target.
40+type HintLabel struct {
41+ // Rect is the label box in logical output-local coordinates, placed at
42+ // the target's top-left. W and H may be 0: the renderer sizes the box
43+ // from Text.
44+ Rect Rect
45+ // Text is the full label, lowercase a–z.
46+ Text string
47+ // Typed is the number of leading characters already typed (drawn dimmed).
48+ Typed int
49+}
@@ -0,0 +1,49 @@
1+package ports
2+
3+// HintKind is the kind of a clickable hint target.
4+type HintKind uint8
5+
6+const (
7+ // HintLink is a link or URL.
8+ HintLink HintKind = iota
9+ // HintButton is a button or control.
10+ HintButton
11+)
12+
13+// HintScanRequest asks the hint scanner to find clickable targets in the last
14+// frame of Output. Core sends it and expects one HintScanResult echoing Seq.
15+type HintScanRequest struct {
16+ Seq uint64 // core-chosen, echoed back
17+ Output string
18+ // Width and Height are the output buffer size in physical pixels
19+ // (OutputInfo.Width/Height).
20+ Width, Height int
21+ Scale float64 // screen scale
22+ LinksOnly bool
23+}
24+
25+// HintTarget is a clickable region found in a frame.
26+type HintTarget struct {
27+ Rect Rect // logical, output-local
28+ Kind HintKind
29+}
30+
31+// HintScanResult answers a HintScanRequest. Core owns Targets.
32+type HintScanResult struct {
33+ Seq uint64
34+ Output string
35+ Targets []HintTarget
36+ Err error
37+}
38+
39+// HintLabel is a label drawn over a hint target.
40+type HintLabel struct {
41+ // Rect is the label box in logical output-local coordinates, placed at
42+ // the target's top-left. W and H may be 0: the renderer sizes the box
43+ // from Text.
44+ Rect Rect
45+ // Text is the full label, lowercase a–z.
46+ Text string
47+ // Typed is the number of leading characters already typed (drawn dimmed).
48+ Typed int
49+}
M internal/ports/scene.go +6 -1
@@ -69,6 +69,10 @@ type Scene struct {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 CaptureIndicators []CaptureIndicator
72+ // HintLabels are the keyboard hint labels of this output, drawn over
73+ // everything, after CaptureIndicators; nil when hint mode is not shown
74+ // here. Captures never contain them.
75+ HintLabels []HintLabel
7276 // Flash is the focus flash drawn over everything of this output; nil
7377 // draws nothing.
7478 Flash *FocusFlash
@@ -112,7 +116,8 @@ func (s Scene) SameAs(o Scene) bool {
112116 }
113117 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
114118 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
119+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120+ slices.Equal(s.HintLabels, o.HintLabels)
116121 }
117122
118123 // Shows reports whether the scene draws the surface of id: only its
@@ -69,6 +69,10 @@ type Scene struct {
69 // drawn over everything; nil when none. Captures never hold them: the69 // drawn over everything; nil when none. Captures never hold them: the
70 // capture pipeline serves requests from a scene without them.70 // capture pipeline serves requests from a scene without them.
71 CaptureIndicators []CaptureIndicator71 CaptureIndicators []CaptureIndicator
72+ // HintLabels are the keyboard hint labels of this output, drawn over
73+ // everything, after CaptureIndicators; nil when hint mode is not shown
74+ // here. Captures never contain them.
75+ HintLabels []HintLabel
72 // Flash is the focus flash drawn over everything of this output; nil76 // Flash is the focus flash drawn over everything of this output; nil
73 // draws nothing.77 // draws nothing.
74 Flash *FocusFlash78 Flash *FocusFlash
@@ -112,7 +116,8 @@ func (s Scene) SameAs(o Scene) bool {
112 }116 }
113 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&117 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
114 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&118 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)119+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120+ slices.Equal(s.HintLabels, o.HintLabels)
116 }121 }
117 122
118 // Shows reports whether the scene draws the surface of id: only its123 // Shows reports whether the scene draws the surface of id: only its
M internal/ports/scene_same_test.go +3 -2
@@ -25,6 +25,7 @@ func sameSceneBase() Scene {
2525
2626 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
2727 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
28+ HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
2829 }
2930 }
3031
@@ -89,9 +90,9 @@ func TestSceneSameAs(t *testing.T) {
8990 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
9091 // update Scene.SameAs, then this count.
9192 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it
93+ // 24 with Animating, which SameAs intentionally does not compare: it
9394 // only schedules the next frame, it draws nothing.
94- const fields = 23
95+ const fields = 24
9596 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9697 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9798 }
@@ -25,6 +25,7 @@ func sameSceneBase() Scene {
25 25
26 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},26 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
28+ HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
28 }29 }
29 }30 }
30 31
@@ -89,9 +90,9 @@ func TestSceneSameAs(t *testing.T) {
89 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:90 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
90 // update Scene.SameAs, then this count.91 // update Scene.SameAs, then this count.
91 func TestSceneSameAsCoversEveryField(t *testing.T) {92 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it93+ // 24 with Animating, which SameAs intentionally does not compare: it
93 // only schedules the next frame, it draws nothing.94 // only schedules the next frame, it draws nothing.
94- const fields = 2395+ const fields = 24
95 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),96 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
96 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")97 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
97 }98 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close