feat(core): add keyboard hint mode
- commit
- a329128cfdeaae0b7a75fe177430550aaa6e35a2
- author
- brice · · 2026-10-19 09:54 UTC
- parent
- 1824dbd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +733 -6
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/outputkit/capture/excluded.go +2 -2
| @@ -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 | ||
| @@ -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 | ||||
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 +21 -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,8 @@ 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 | |
| 160 | 168 | // switcher is the column switcher in progress: the screen it is on and | |
| 161 | 169 | // the timer that shows its cards (switcher.go). | |
| 162 | 170 | switcher struct { | |
| @@ -275,7 +283,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 275 | 283 | } | |
| 276 | 284 | switch Action(a) { | |
| 277 | 285 | 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: | |
| 286 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint: | |
| 279 | 287 | default: | |
| 280 | 288 | return fmt.Errorf("invalid action %q", a) | |
| 281 | 289 | } | |
| @@ -634,6 +642,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 634 | 642 | return errSecurityChanged | |
| 635 | 643 | } | |
| 636 | 644 | c.syncSecurity() | |
| 645 | + | c.hintValidate() | |
| 637 | 646 | if c.security.Protected { | |
| 638 | 647 | return c.publishProtected(ctx) | |
| 639 | 648 | } | |
| @@ -1206,6 +1215,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1206 | 1215 | return nil | |
| 1207 | 1216 | } | |
| 1208 | 1217 | continue | |
| 1218 | + | case res, ok := <-c.ch.Hints: | |
| 1219 | + | if !ok { | |
| 1220 | + | c.ch.Hints = nil | |
| 1221 | + | continue | |
| 1222 | + | } | |
| 1223 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1224 | + | return nil | |
| 1225 | + | } | |
| 1226 | + | if c.hintResult(res) && c.publish(ctx) != nil { | |
| 1227 | + | return nil | |
| 1228 | + | } | |
| 1209 | 1229 | case ev, ok := <-c.ch.Output: | |
| 1210 | 1230 | if !ok { | |
| 1211 | 1231 | 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,8 @@ 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 | |||
| 160 | // switcher is the column switcher in progress: the screen it is on and | 168 | // switcher is the column switcher in progress: the screen it is on and | ||
| 161 | // the timer that shows its cards (switcher.go). | 169 | // the timer that shows its cards (switcher.go). | ||
| 162 | switcher struct { | 170 | switcher struct { | ||
| @@ -275,7 +283,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 275 | } | 283 | } | ||
| 276 | switch Action(a) { | 284 | 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, | 285 | 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: | 286 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint: |
| 279 | default: | 287 | default: | ||
| 280 | return fmt.Errorf("invalid action %q", a) | 288 | return fmt.Errorf("invalid action %q", a) | ||
| 281 | } | 289 | } | ||
| @@ -634,6 +642,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 634 | return errSecurityChanged | 642 | return errSecurityChanged | ||
| 635 | } | 643 | } | ||
| 636 | c.syncSecurity() | 644 | c.syncSecurity() | ||
| 645 | + | c.hintValidate() | |||
| 637 | if c.security.Protected { | 646 | if c.security.Protected { | ||
| 638 | return c.publishProtected(ctx) | 647 | return c.publishProtected(ctx) | ||
| 639 | } | 648 | } | ||
| @@ -1206,6 +1215,17 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1206 | return nil | 1215 | return nil | ||
| 1207 | } | 1216 | } | ||
| 1208 | continue | 1217 | continue | ||
| 1218 | + | case res, ok := <-c.ch.Hints: | |||
| 1219 | + | if !ok { | |||
| 1220 | + | c.ch.Hints = nil | |||
| 1221 | + | continue | |||
| 1222 | + | } | |||
| 1223 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1224 | + | return nil | |||
| 1225 | + | } | |||
| 1226 | + | if c.hintResult(res) && c.publish(ctx) != nil { | |||
| 1227 | + | return nil | |||
| 1228 | + | } | |||
| 1209 | case ev, ok := <-c.ch.Output: | 1229 | case ev, ok := <-c.ch.Output: | ||
| 1210 | if !ok { | 1230 | if !ok { | ||
| 1211 | c.ch.Output = nil | 1231 | c.ch.Output = nil | ||
A internal/core/hint.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 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 { | |
| 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 | + | // hintResult handles a scanner result and reports whether to publish. | |
| 72 | + | func (c *Core) hintResult(res ports.HintScanResult) bool { | |
| 73 | + | h := &c.hint | |
| 74 | + | if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | if res.Err != nil || c.security.Protected { | |
| 78 | + | c.hintCancel() | |
| 79 | + | return true | |
| 80 | + | } | |
| 81 | + | i := c.screenIndex(h.output) | |
| 82 | + | if i < 0 { | |
| 83 | + | c.hintCancel() | |
| 84 | + | return true | |
| 85 | + | } | |
| 86 | + | sc := c.screens[i] | |
| 87 | + | var kept []hintTarget | |
| 88 | + | for _, t := range res.Targets { | |
| 89 | + | x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2 | |
| 90 | + | y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2 | |
| 91 | + | id, _, _ := c.hit(x, y) | |
| 92 | + | if id == 0 { | |
| 93 | + | continue | |
| 94 | + | } | |
| 95 | + | if s, _ := c.screenOf(c.popupRoot(id)); s == nil { | |
| 96 | + | // A layer surface, not a managed window. | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect}) | |
| 100 | + | } | |
| 101 | + | if len(kept) == 0 { | |
| 102 | + | c.hintCancel() | |
| 103 | + | return true | |
| 104 | + | } | |
| 105 | + | slices.SortStableFunc(kept, func(a, b hintTarget) int { | |
| 106 | + | return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | |
| 107 | + | }) | |
| 108 | + | if len(kept) > hintMaxTargets { | |
| 109 | + | kept = kept[:hintMaxTargets] | |
| 110 | + | } | |
| 111 | + | n := len(hintAlphabet) | |
| 112 | + | for i := range kept { | |
| 113 | + | if len(kept) <= n { | |
| 114 | + | kept[i].label = string(hintAlphabet[i]) | |
| 115 | + | } else { | |
| 116 | + | kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]}) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | h.phase, h.targets, h.typed = hintShown, kept, "" | |
| 120 | + | return true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // hintCancel ends hint mode. | |
| 124 | + | func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} } | |
| 125 | + | ||
| 126 | + | // hintValidate ends hint mode when what it was started on is gone: the | |
| 127 | + | // session became protected, the output went, another workspace is on screen | |
| 128 | + | // there, or the overview opened. It runs at the start of every publish. | |
| 129 | + | func (c *Core) hintValidate() { | |
| 130 | + | h := &c.hint | |
| 131 | + | if h.phase == hintIdle { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | i := c.screenIndex(h.output) | |
| 135 | + | if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open { | |
| 136 | + | c.hintCancel() | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // hintKey handles a key while hint mode is active: the key never goes on. | |
| 141 | + | // A press is the whole job; releases take the usual swallow path. | |
| 142 | + | func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |
| 143 | + | if c.hint.phase == hintIdle || !key.Pressed { | |
| 144 | + | return false, nil | |
| 145 | + | } | |
| 146 | + | c.pressed[heldKey(key)] = true | |
| 147 | + | h := &c.hint | |
| 148 | + | if h.phase == hintPending || keyName(key.Keysym) == "Escape" { | |
| 149 | + | c.hintCancel() | |
| 150 | + | return true, c.publish(ctx) | |
| 151 | + | } | |
| 152 | + | if keyName(key.Keysym) == "BackSpace" { | |
| 153 | + | if h.typed != "" { | |
| 154 | + | h.typed = h.typed[:len(h.typed)-1] | |
| 155 | + | } | |
| 156 | + | return true, c.publish(ctx) | |
| 157 | + | } | |
| 158 | + | name := key.Base | |
| 159 | + | if name == "" { | |
| 160 | + | name = key.Keysym | |
| 161 | + | } | |
| 162 | + | name = keyName(name) | |
| 163 | + | if len(name) != 1 || name[0] < 'a' || name[0] > 'z' { | |
| 164 | + | c.hintCancel() | |
| 165 | + | return true, c.publish(ctx) | |
| 166 | + | } | |
| 167 | + | typed := h.typed + name | |
| 168 | + | var match *hintTarget | |
| 169 | + | any := false | |
| 170 | + | for i := range h.targets { | |
| 171 | + | if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed { | |
| 172 | + | any = true | |
| 173 | + | if h.targets[i].label == typed { | |
| 174 | + | match = &h.targets[i] | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if !any { | |
| 179 | + | return true, nil | |
| 180 | + | } | |
| 181 | + | if match == nil { | |
| 182 | + | h.typed = typed | |
| 183 | + | return true, c.publish(ctx) | |
| 184 | + | } | |
| 185 | + | return true, c.hintClick(ctx, *match) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // hintClick ends hint mode and left-clicks the target's centre, provided the | |
| 189 | + | // same window is still there. | |
| 190 | + | func (c *Core) hintClick(ctx context.Context, t hintTarget) error { | |
| 191 | + | c.hintCancel() | |
| 192 | + | if err := c.publish(ctx); err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | if c.security.Protected { | |
| 196 | + | return nil | |
| 197 | + | } | |
| 198 | + | if id, _, _ := c.hit(t.x, t.y); id != t.window { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime}) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // hintLabels are the labels to draw on sc: those still matching what was | |
| 211 | + | // typed. Nil unless sc is the hint output. | |
| 212 | + | func (c *Core) hintLabels(sc *screen) []ports.HintLabel { | |
| 213 | + | h := &c.hint | |
| 214 | + | if h.phase != hintShown || sc.name() != h.output { | |
| 215 | + | return nil | |
| 216 | + | } | |
| 217 | + | var out []ports.HintLabel | |
| 218 | + | for _, t := range h.targets { | |
| 219 | + | if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed { | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)}) | |
| 223 | + | } | |
| 224 | + | return out | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 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 { | |||
| 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 | + | // hintResult handles a scanner result and reports whether to publish. | |||
| 72 | + | func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 73 | + | h := &c.hint | |||
| 74 | + | if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | if res.Err != nil || c.security.Protected { | |||
| 78 | + | c.hintCancel() | |||
| 79 | + | return true | |||
| 80 | + | } | |||
| 81 | + | i := c.screenIndex(h.output) | |||
| 82 | + | if i < 0 { | |||
| 83 | + | c.hintCancel() | |||
| 84 | + | return true | |||
| 85 | + | } | |||
| 86 | + | sc := c.screens[i] | |||
| 87 | + | var kept []hintTarget | |||
| 88 | + | for _, t := range res.Targets { | |||
| 89 | + | x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2 | |||
| 90 | + | y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2 | |||
| 91 | + | id, _, _ := c.hit(x, y) | |||
| 92 | + | if id == 0 { | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | if s, _ := c.screenOf(c.popupRoot(id)); s == nil { | |||
| 96 | + | // A layer surface, not a managed window. | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect}) | |||
| 100 | + | } | |||
| 101 | + | if len(kept) == 0 { | |||
| 102 | + | c.hintCancel() | |||
| 103 | + | return true | |||
| 104 | + | } | |||
| 105 | + | slices.SortStableFunc(kept, func(a, b hintTarget) int { | |||
| 106 | + | return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | |||
| 107 | + | }) | |||
| 108 | + | if len(kept) > hintMaxTargets { | |||
| 109 | + | kept = kept[:hintMaxTargets] | |||
| 110 | + | } | |||
| 111 | + | n := len(hintAlphabet) | |||
| 112 | + | for i := range kept { | |||
| 113 | + | if len(kept) <= n { | |||
| 114 | + | kept[i].label = string(hintAlphabet[i]) | |||
| 115 | + | } else { | |||
| 116 | + | kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]}) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | h.phase, h.targets, h.typed = hintShown, kept, "" | |||
| 120 | + | return true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // hintCancel ends hint mode. | |||
| 124 | + | func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} } | |||
| 125 | + | ||||
| 126 | + | // hintValidate ends hint mode when what it was started on is gone: the | |||
| 127 | + | // session became protected, the output went, another workspace is on screen | |||
| 128 | + | // there, or the overview opened. It runs at the start of every publish. | |||
| 129 | + | func (c *Core) hintValidate() { | |||
| 130 | + | h := &c.hint | |||
| 131 | + | if h.phase == hintIdle { | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | i := c.screenIndex(h.output) | |||
| 135 | + | if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open { | |||
| 136 | + | c.hintCancel() | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // hintKey handles a key while hint mode is active: the key never goes on. | |||
| 141 | + | // A press is the whole job; releases take the usual swallow path. | |||
| 142 | + | func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |||
| 143 | + | if c.hint.phase == hintIdle || !key.Pressed { | |||
| 144 | + | return false, nil | |||
| 145 | + | } | |||
| 146 | + | c.pressed[heldKey(key)] = true | |||
| 147 | + | h := &c.hint | |||
| 148 | + | if h.phase == hintPending || keyName(key.Keysym) == "Escape" { | |||
| 149 | + | c.hintCancel() | |||
| 150 | + | return true, c.publish(ctx) | |||
| 151 | + | } | |||
| 152 | + | if keyName(key.Keysym) == "BackSpace" { | |||
| 153 | + | if h.typed != "" { | |||
| 154 | + | h.typed = h.typed[:len(h.typed)-1] | |||
| 155 | + | } | |||
| 156 | + | return true, c.publish(ctx) | |||
| 157 | + | } | |||
| 158 | + | name := key.Base | |||
| 159 | + | if name == "" { | |||
| 160 | + | name = key.Keysym | |||
| 161 | + | } | |||
| 162 | + | name = keyName(name) | |||
| 163 | + | if len(name) != 1 || name[0] < 'a' || name[0] > 'z' { | |||
| 164 | + | c.hintCancel() | |||
| 165 | + | return true, c.publish(ctx) | |||
| 166 | + | } | |||
| 167 | + | typed := h.typed + name | |||
| 168 | + | var match *hintTarget | |||
| 169 | + | any := false | |||
| 170 | + | for i := range h.targets { | |||
| 171 | + | if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed { | |||
| 172 | + | any = true | |||
| 173 | + | if h.targets[i].label == typed { | |||
| 174 | + | match = &h.targets[i] | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | if !any { | |||
| 179 | + | return true, nil | |||
| 180 | + | } | |||
| 181 | + | if match == nil { | |||
| 182 | + | h.typed = typed | |||
| 183 | + | return true, c.publish(ctx) | |||
| 184 | + | } | |||
| 185 | + | return true, c.hintClick(ctx, *match) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // hintClick ends hint mode and left-clicks the target's centre, provided the | |||
| 189 | + | // same window is still there. | |||
| 190 | + | func (c *Core) hintClick(ctx context.Context, t hintTarget) error { | |||
| 191 | + | c.hintCancel() | |||
| 192 | + | if err := c.publish(ctx); err != nil { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | if c.security.Protected { | |||
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | if id, _, _ := c.hit(t.x, t.y); id != t.window { | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil { | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime}) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // hintLabels are the labels to draw on sc: those still matching what was | |||
| 211 | + | // typed. Nil unless sc is the hint output. | |||
| 212 | + | func (c *Core) hintLabels(sc *screen) []ports.HintLabel { | |||
| 213 | + | h := &c.hint | |||
| 214 | + | if h.phase != hintShown || sc.name() != h.output { | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | var out []ports.HintLabel | |||
| 218 | + | for _, t := range h.targets { | |||
| 219 | + | if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed { | |||
| 220 | + | continue | |||
| 221 | + | } | |||
| 222 | + | out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)}) | |||
| 223 | + | } | |||
| 224 | + | return out | |||
| 225 | + | } | |||
A internal/core/hint_test.go +426 -0
| @@ -0,0 +1,426 @@ | |||
| 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", ports.ModSuper) | |
| 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 | + | } | |
| @@ -0,0 +1,426 @@ | |||||
| 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", ports.ModSuper) | |||
| 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 | + | } | |||
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/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. | ||
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 | } | ||