Merge 8ed066e88236b9117029013111a00c42ddb99f4a into e8a5ba67e7808a959cf5c165fe3c21f2d7598db2
42 files · +1879 -24
M .hexcheck.yaml +1 -1
| @@ -25,7 +25,7 @@ allow: | |||
| 25 | 25 | ruleSettings: | |
| 26 | 26 | missing-generated-mock-for-port: | |
| 27 | 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 | 29 | suspicious-business-logic-in-adapter: | |
| 30 | 30 | # app is the composition root; starting core and wiring channels is its job. | |
| 31 | 31 | 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 | |||
| 61 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 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|xkb | |
| 64 | + | ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | 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 shared | 61 | # 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|xkb | 64 | + | 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 | |||
| 281 | 281 | bind.cmd+s = toggle-stash-visible | |
| 282 | 282 | bind.cmd+shift+s = toggle-window-stash | |
| 283 | 283 | bind.cmd+o = toggle-overview | |
| 284 | + | # bind.cmd+g = hint | |
| 284 | 285 | bind.cmd+tab = switch-column-next | |
| 285 | 286 | bind.cmd+shift+tab = switch-column-prev | |
| 286 | 287 | ||
| @@ -281,6 +281,7 @@ bind.cmd+shift+f = toggle-fullscreen | |||||
| 281 | bind.cmd+s = toggle-stash-visible | 281 | bind.cmd+s = toggle-stash-visible | ||
| 282 | bind.cmd+shift+s = toggle-window-stash | 282 | bind.cmd+shift+s = toggle-window-stash | ||
| 283 | bind.cmd+o = toggle-overview | 283 | bind.cmd+o = toggle-overview | ||
| 284 | + | # bind.cmd+g = hint | |||
| 284 | bind.cmd+tab = switch-column-next | 285 | bind.cmd+tab = switch-column-next | ||
| 285 | bind.cmd+shift+tab = switch-column-prev | 286 | 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. | |||
| 55 | 55 | | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` | | |
| 56 | 56 | | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` | | |
| 57 | 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 | 59 | | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace | | |
| 59 | 60 | | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` | | |
| 60 | 61 | | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout | | |
| @@ -191,6 +192,7 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |||
| 191 | 192 | | `toggle-window-stash` | Move the focused window to the [stash](#stash), or back to its column | | |
| 192 | 193 | | `toggle-stash-visible` | Show or hide the stash | | |
| 193 | 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 | 196 | | `switch-column-next` / `-prev` | Switch to the previously used column. Hold `cmd` and press again to go further back | | |
| 195 | 197 | | `maximize-column` | Toggle full width for the focused column | | |
| 196 | 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 | 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 | 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. | |
| @@ -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 | |||
| 94 | 94 | focus.flash-opacity = 0.6 | |
| 95 | 95 | focus.flash-duration = 200ms | |
| 96 | 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 | 101 | # Named workspaces are outside the numbered list and require a bind. The bind | |
| 99 | 102 | # shows one; pressing it again returns to the previous workspace. | |
| @@ -195,6 +198,8 @@ bind.cmd+shift+f = toggle-fullscreen | |||
| 195 | 198 | bind.cmd+s = toggle-stash-visible | |
| 196 | 199 | bind.cmd+shift+s = toggle-window-stash | |
| 197 | 200 | 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 | 203 | bind.cmd+q = close-window | |
| 199 | 204 | bind.ctrl+alt+backspace = quit | |
| 200 | 205 | bind.ctrl+cmd+space = spawn fuzzel | |
| @@ -94,6 +94,9 @@ focus.flash-width = 2 | |||||
| 94 | focus.flash-opacity = 0.6 | 94 | focus.flash-opacity = 0.6 | ||
| 95 | focus.flash-duration = 200ms | 95 | 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 bind | 101 | # 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-visible | 198 | bind.cmd+s = toggle-stash-visible | ||
| 196 | bind.cmd+shift+s = toggle-window-stash | 199 | bind.cmd+shift+s = toggle-window-stash | ||
| 197 | bind.cmd+o = toggle-overview | 200 | 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-window | 203 | bind.cmd+q = close-window | ||
| 199 | bind.ctrl+alt+backspace = quit | 204 | bind.ctrl+alt+backspace = quit | ||
| 200 | bind.ctrl+cmd+space = spawn fuzzel | 205 | bind.ctrl+cmd+space = spawn fuzzel | ||
M go.mod +5 -0
| @@ -15,14 +15,19 @@ require ( | |||
| 15 | 15 | github.com/godbus/dbus/v5 v5.2.2 | |
| 16 | 16 | github.com/rs/zerolog v1.35.1 | |
| 17 | 17 | github.com/stretchr/testify v1.12.1 | |
| 18 | + | golang.org/x/image v0.46.0 | |
| 18 | 19 | golang.org/x/sys v0.48.0 | |
| 19 | 20 | golang.org/x/term v0.46.0 | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 23 | require ( | |
| 24 | + | bnema.dev/hintscan v0.0.0-00010101000000-000000000000 | |
| 23 | 25 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 24 | 26 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 25 | 27 | github.com/stretchr/objx v0.5.3 // indirect | |
| 26 | 28 | go.yaml.in/yaml/v3 v3.0.5 // indirect | |
| 29 | + | golang.org/x/text v0.42.0 // indirect | |
| 27 | 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 28 | 31 | ) | |
| 32 | + | ||
| 33 | + | replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner | |
| @@ -15,14 +15,19 @@ require ( | |||||
| 15 | github.com/godbus/dbus/v5 v5.2.2 | 15 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 16 | github.com/rs/zerolog v1.35.1 | 16 | github.com/rs/zerolog v1.35.1 | ||
| 17 | github.com/stretchr/testify v1.12.1 | 17 | github.com/stretchr/testify v1.12.1 | ||
| 18 | + | golang.org/x/image v0.46.0 | |||
| 18 | golang.org/x/sys v0.48.0 | 19 | golang.org/x/sys v0.48.0 | ||
| 19 | golang.org/x/term v0.46.0 | 20 | 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 // indirect | 25 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 24 | github.com/mattn/go-isatty v0.0.22 // indirect | 26 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
| 25 | github.com/stretchr/objx v0.5.3 // indirect | 27 | github.com/stretchr/objx v0.5.3 // indirect | ||
| 26 | go.yaml.in/yaml/v3 v3.0.5 // indirect | 28 | 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 // indirect | 30 | 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 generated file: collapsed
M internal/adapters/config/config.go +10 -0
| @@ -114,6 +114,7 @@ var actions = map[string]bool{ | |||
| 114 | 114 | "move-workspace-prev": true, | |
| 115 | 115 | "move-workspace-next": true, | |
| 116 | 116 | "toggle-floating": true, | |
| 117 | + | "hint": true, | |
| 117 | 118 | } | |
| 118 | 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 | 120 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| @@ -847,6 +848,15 @@ func set(c *ports.Config, key, v string) error { | |||
| 847 | 848 | return err | |
| 848 | 849 | } | |
| 849 | 850 | 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 | 860 | case "focus.flash-width": | |
| 851 | 861 | n, err := strconv.Atoi(v) | |
| 852 | 862 | 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 err | 848 | return err | ||
| 848 | } | 849 | } | ||
| 849 | c.Focus.Flash = b | 850 | 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) { | |||
| 953 | 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 | + | } | |
| @@ -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 | |||
| 70 | 70 | if s.Flash != nil { | |
| 71 | 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 | 76 | if s.Transform != 0 { | |
| 74 | 77 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | |
| 75 | 78 | } | |
| @@ -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 | |||
| 94 | 94 | if s.Flash != nil { | |
| 95 | 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 | 101 | if s.Transform != 0 { | |
| 98 | 102 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 99 | 103 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | // A request with Exclude leaves out the exclusion's HUD layers and popups; | |
| 12 | 12 | // 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 | |
| 14 | 14 | // capture image. Requests of the displayed frame are then served from the | |
| 15 | 15 | // frame rendered without it first (SubmitPlain, ExcludedScene). | |
| 16 | 16 | ||
| @@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool { | |||
| 64 | 64 | return false | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 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 | |
| 68 | 68 | // renderer redraws the whole target instead of reusing a displayed frame. | |
| 69 | 69 | 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 | |
| 71 | 71 | return s | |
| 72 | 72 | } | |
| 73 | 73 | ||
| @@ -85,7 +85,7 @@ func plain(s ports.Scene) ports.Scene { | |||
| 85 | 85 | // the plain render reads client buffers even when the requests are rejected, | |
| 86 | 86 | // so the caller owns it (hold or wait it) whatever happened, error included. | |
| 87 | 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 | 89 | return false, nil, nil | |
| 90 | 90 | } | |
| 91 | 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 | 165 | clear(reqs[n:]) | |
| 166 | 166 | reqs = reqs[:n] | |
| 167 | - | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 | |
| 167 | + | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 | |
| 168 | 168 | group := func(q ports.CaptureRequest) int { | |
| 169 | 169 | switch { | |
| 170 | 170 | 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 indicator | 12 | // 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 |
| 14 | // capture image. Requests of the displayed frame are then served from the | 14 | // 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 false | 64 | return false | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 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 |
| 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, 0 | 70 | + | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 |
| 71 | return s | 71 | 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, nil | 89 | 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) > 0 | 167 | + | 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) { | |||
| 112 | 112 | _, statErr := file.Stat() | |
| 113 | 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 | + | } | |
| @@ -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) { | |||
| 238 | 238 | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | |
| 239 | 239 | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | |
| 240 | 240 | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | |
| 241 | + | tg.heldLabels = append(tg.heldLabels[:0], s.HintLabels...) | |
| 241 | 242 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | |
| 242 | 243 | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | |
| 243 | 244 | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | |
| 245 | + | tg.scene.HintLabels = tg.heldLabels | |
| 244 | 246 | // Only whether they are set matters (any capture redraws everything): | |
| 245 | 247 | // sentinels, so no pointer into the caller's capture state is kept. | |
| 246 | 248 | 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 | 277 | old.WorkspaceClip != cur.WorkspaceClip || old.TileInset != cur.TileInset || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 276 | 278 | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 277 | 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 | 281 | len(old.Windows) != len(cur.Windows) { | |
| 280 | 282 | return image.Rectangle{}, false | |
| 281 | 283 | } | |
| @@ -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.drawn | 242 | 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.heldLayers | 243 | 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.heldIndicators | 244 | 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, nil | 248 | 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{}, false | 282 | return image.Rectangle{}, false | ||
| 281 | } | 283 | } | ||
M internal/adapters/vulkan/damage_delta_test.go +2 -2
| @@ -17,9 +17,9 @@ import ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | 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 | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 23 | |
| 22 | + | const fields = 24 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 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 | } | |
| @@ -17,9 +17,9 @@ import ( | |||||
| 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | 17 | // 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 only | 20 | + | // 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 = 23 | 22 | + | 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 ( | |||
| 9 | 9 | "strconv" | |
| 10 | 10 | "unsafe" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/hintlabel" | |
| 12 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | ||
| @@ -85,6 +86,8 @@ type Renderer struct { | |||
| 85 | 86 | // the bytes copied into them, for tests. | |
| 86 | 87 | shm map[shmKey]*shmSurface | |
| 87 | 88 | copied int | |
| 89 | + | // labels rasterizes the hint labels (hint_labels.go); the zero value works. | |
| 90 | + | labels hintlabel.Raster | |
| 88 | 91 | // shmRects is scratch for shmCopyFor. | |
| 89 | 92 | shmRects []image.Rectangle | |
| 90 | 93 | // 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]*shmSurface | 87 | shm map[shmKey]*shmSurface | ||
| 87 | copied int | 88 | 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.Rectangle | 92 | 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 | |||
| 60 | 60 | w.popups(true) | |
| 61 | 61 | w.focusFlash() | |
| 62 | 62 | w.captureIndicators() | |
| 63 | + | w.hintLabels() | |
| 63 | 64 | r.scratchDraws = w.draws | |
| 64 | 65 | return w.draws | |
| 65 | 66 | } | |
| @@ -331,14 +332,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 331 | 332 | for i := range content.Children { | |
| 332 | 333 | if ch := &content.Children[i]; ch.Below { | |
| 333 | 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 | 339 | for i := range content.Children { | |
| 339 | 340 | if ch := &content.Children[i]; !ch.Below { | |
| 340 | 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 | } | |
| @@ -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.draws | 64 | r.scratchDraws = w.draws | ||
| 64 | return w.draws | 65 | 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) { | |||
| 154 | 154 | type shmKey struct { | |
| 155 | 155 | win ports.WindowID | |
| 156 | 156 | 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 | 165 | // 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.WindowID | 155 | win ports.WindowID | ||
| 156 | surface uint64 | 156 | 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 content | 165 | // 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 { | |||
| 43 | 43 | heldLayers []ports.SceneLayer | |
| 44 | 44 | heldHints []ports.Rect | |
| 45 | 45 | heldIndicators []ports.CaptureIndicator | |
| 46 | + | heldLabels []ports.HintLabel | |
| 46 | 47 | windows map[ports.WindowID]heldWindow | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -43,6 +43,7 @@ type target struct { | |||||
| 43 | heldLayers []ports.SceneLayer | 43 | heldLayers []ports.SceneLayer | ||
| 44 | heldHints []ports.Rect | 44 | heldHints []ports.Rect | ||
| 45 | heldIndicators []ports.CaptureIndicator | 45 | heldIndicators []ports.CaptureIndicator | ||
| 46 | + | heldLabels []ports.HintLabel | |||
| 46 | windows map[ports.WindowID]heldWindow | 47 | 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 ( | |||
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/drm" | |
| 16 | 16 | "github.com/bnema/neferwl/internal/adapters/headlessinput" | |
| 17 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/adapters/launcher" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/libinput" | |
| 19 | 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 | 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 | 352 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | |
| 344 | 353 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 345 | 354 | 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 { | |||
| 46 | 46 | // move running slides. | |
| 47 | 47 | 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 | 53 | 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 | |
| 53 | 63 | ||
| 54 | 64 | outputFormats chan ports.OutputFormats | |
| 55 | 65 | outputHeads chan ports.OutputHeads | |
| @@ -97,8 +107,13 @@ func newPipes(realSession bool) *pipes { | |||
| 97 | 107 | ||
| 98 | 108 | frames: make(chan ports.OutputFrame, 8), | |
| 99 | 109 | ||
| 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), | |
| 102 | 117 | ||
| 103 | 118 | outputFormats: make(chan ports.OutputFormats, 8), | |
| 104 | 119 | outputHeads: make(chan ports.OutputHeads, 8), | |
| @@ -136,9 +151,15 @@ func (p *pipes) core() core.Channels { | |||
| 136 | 151 | ConfigErrors: p.configErrors, | |
| 137 | 152 | Scales: p.scales, | |
| 138 | 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 | 163 | // wayland is the wayland server's side. | |
| 143 | 164 | func (p *pipes) wayland() wayland.Channels { | |
| 144 | 165 | return wayland.Channels{ | |
| @@ -175,7 +196,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta | |||
| 175 | 196 | content: content, | |
| 176 | 197 | frames: p.frames, | |
| 177 | 198 | captures: p.captures, | |
| 178 | - | captured: p.captured, | |
| 199 | + | captured: p.capturedAll, | |
| 179 | 200 | formats: p.outputFormats, | |
| 180 | 201 | heads: p.outputHeads, | |
| 181 | 202 | leaseRequests: p.leaseRequests, | |
| @@ -46,10 +46,20 @@ type pipes struct { | |||||
| 46 | // move running slides. | 46 | // move running slides. | ||
| 47 | frames chan ports.OutputFrame | 47 | 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.CaptureRequest | 53 | 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.CaptureDone | 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 | |||
| 53 | 63 | ||||
| 54 | outputFormats chan ports.OutputFormats | 64 | outputFormats chan ports.OutputFormats | ||
| 55 | outputHeads chan ports.OutputHeads | 65 | 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 ( | |||
| 63 | 63 | // command key is held (switcher.go). | |
| 64 | 64 | ActionSwitchColumnNext Action = "switch-column-next" | |
| 65 | 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 | 71 | // monitorDirection returns the direction of the neighbor screen targeted by | |
| @@ -302,6 +305,9 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 302 | 305 | return Effect{} | |
| 303 | 306 | } | |
| 304 | 307 | switch a { | |
| 308 | + | case ActionHint: | |
| 309 | + | c.hintRequest() | |
| 310 | + | return Effect{} | |
| 305 | 311 | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: | |
| 306 | 312 | if i := c.neighbor(monitorDirection(a)); i >= 0 { | |
| 307 | 313 | 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 by | 71 | // 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 = i | 313 | c.focusScreen = i | ||
M internal/core/core.go +24 -1
| @@ -47,6 +47,12 @@ type Channels struct { | |||
| 47 | 47 | // animating scene (Scene.Animating): a running slide moves one step per | |
| 48 | 48 | // report. Without them it moves on a timer. | |
| 49 | 49 | 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 | 58 | // Options are the services and policy core is constructed with. | |
| @@ -157,6 +163,11 @@ type Core struct { | |||
| 157 | 163 | // capt is the capture state (capture.go, capindicator.go). | |
| 158 | 164 | capt captureState | |
| 159 | 165 | 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 | 171 | // switcher is the column switcher in progress: the screen it is on and | |
| 161 | 172 | // the timer that shows its cards (switcher.go). | |
| 162 | 173 | switcher struct { | |
| @@ -275,7 +286,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 275 | 286 | } | |
| 276 | 287 | switch Action(a) { | |
| 277 | 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 | 290 | default: | |
| 280 | 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 | 645 | return errSecurityChanged | |
| 635 | 646 | } | |
| 636 | 647 | c.syncSecurity() | |
| 648 | + | c.hintValidate() | |
| 637 | 649 | if c.security.Protected { | |
| 638 | 650 | return c.publishProtected(ctx) | |
| 639 | 651 | } | |
| @@ -1206,6 +1218,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1206 | 1218 | return nil | |
| 1207 | 1219 | } | |
| 1208 | 1220 | 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 | 1232 | case ev, ok := <-c.ch.Output: | |
| 1210 | 1233 | if !ok { | |
| 1211 | 1234 | c.ch.Output = nil | |
| @@ -47,6 +47,12 @@ type Channels struct { | |||||
| 47 | // animating scene (Scene.Animating): a running slide moves one step per | 47 | // 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.OutputFrame | 49 | 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 captureState | 164 | capt captureState | ||
| 159 | flash focusFlash | 165 | 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 and | 171 | // 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 errSecurityChanged | 645 | 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 nil | 1218 | return nil | ||
| 1207 | } | 1219 | } | ||
| 1208 | continue | 1220 | 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 = nil | 1234 | c.ch.Output = nil | ||
M internal/core/drag.go +1 -1
| @@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) { | |||
| 90 | 90 | // startDrag starts a cmd + button drag on the window under the pointer. | |
| 91 | 91 | // It reports whether it did: the press is then not forwarded. | |
| 92 | 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 | 94 | return false, nil | |
| 95 | 95 | } | |
| 96 | 96 | 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, nil | 94 | 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 { | |||
| 277 | 277 | } | |
| 278 | 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 | 284 | // The overview takes its keys before any window; others still | |
| 281 | 285 | // run binds, and are not forwarded. | |
| 282 | 286 | // 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 still | 284 | // 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 | |||
| 123 | 123 | } | |
| 124 | 124 | scene.Flash = c.flashFor(sc) | |
| 125 | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 126 | + | scene.HintLabels = c.hintLabels(sc) | |
| 126 | 127 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 127 | 128 | // The springs of this frame are advanced: a scene shown while one still | |
| 128 | 129 | // 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 still | 128 | // 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 { | |||
| 65 | 65 | Err error | |
| 66 | 66 | 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 | |
| @@ -65,3 +65,9 @@ type CaptureDone struct { | |||||
| 65 | Err error | 65 | Err error | ||
| 66 | Time time.Time | 66 | 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 { | |||
| 161 | 161 | FlashDuration time.Duration | |
| 162 | 162 | 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 | 170 | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 165 | 171 | Workspaces []WorkspaceConfig | |
| 166 | 172 | // Rules are the window rules (rule.<name>.* keys), in first-seen order. | |
| @@ -161,6 +161,12 @@ type Config struct { | |||||
| 161 | FlashDuration time.Duration | 161 | FlashDuration time.Duration | ||
| 162 | FlashColor string | 162 | 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 []WorkspaceConfig | 171 | 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 { | |||
| 69 | 69 | // drawn over everything; nil when none. Captures never hold them: the | |
| 70 | 70 | // capture pipeline serves requests from a scene without them. | |
| 71 | 71 | 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 | 76 | // Flash is the focus flash drawn over everything of this output; nil | |
| 73 | 77 | // draws nothing. | |
| 74 | 78 | Flash *FocusFlash | |
| @@ -112,7 +116,8 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 112 | 116 | } | |
| 113 | 117 | return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) && | |
| 114 | 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 | 123 | // 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: the | 69 | // 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 []CaptureIndicator | 71 | 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; nil | 76 | // Flash is the focus flash drawn over everything of this output; nil | ||
| 73 | // draws nothing. | 77 | // draws nothing. | ||
| 74 | Flash *FocusFlash | 78 | 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 its | 123 | // 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 { | |||
| 25 | 25 | ||
| 26 | 26 | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |
| 27 | 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 | 90 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 90 | 91 | // update Scene.SameAs, then this count. | |
| 91 | 92 | 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 | |
| 93 | 94 | // only schedules the next frame, it draws nothing. | |
| 94 | - | const fields = 23 | |
| 95 | + | const fields = 24 | |
| 95 | 96 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 96 | 97 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 97 | 98 | } | |
| @@ -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: it | 93 | + | // 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 = 23 | 95 | + | 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 | } | ||