feat(core): add keyboard hint mode
- commit
- 40c7803b65c654ecb98e83863e6cce24786f89c4
- author
- brice · · 2026-10-19 16:03 UTC
- committer
- brice · · 2026-10-19 17:36 UTC
- parent
- 689e994
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +1256 -10
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 | |
| @@ -303,6 +306,9 @@ func (c *Core) dispatchAction(a Action) Effect { | |||
| 303 | 306 | return Effect{} | |
| 304 | 307 | } | |
| 305 | 308 | switch a { | |
| 309 | + | case ActionHint: | |
| 310 | + | c.hintRequest() | |
| 311 | + | return Effect{} | |
| 306 | 312 | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: | |
| 307 | 313 | if i := c.neighbor(monitorDirection(a)); i >= 0 { | |
| 308 | 314 | 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 | ||
| @@ -303,6 +306,9 @@ func (c *Core) dispatchAction(a Action) Effect { | |||||
| 303 | return Effect{} | 306 | return Effect{} | ||
| 304 | } | 307 | } | ||
| 305 | switch a { | 308 | switch a { | ||
| 309 | + | case ActionHint: | |||
| 310 | + | c.hintRequest() | |||
| 311 | + | return Effect{} | |||
| 306 | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: | 312 | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown: | ||
| 307 | if i := c.neighbor(monitorDirection(a)); i >= 0 { | 313 | if i := c.neighbor(monitorDirection(a)); i >= 0 { | ||
| 308 | c.focusScreen = i | 314 | c.focusScreen = i | ||
M internal/core/core.go +30 -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. | |
| @@ -57,6 +63,9 @@ type Options struct { | |||
| 57 | 63 | Clock ports.Clock | |
| 58 | 64 | // Terminal enables automatic terminal opening according to terminal.auto-open. | |
| 59 | 65 | Terminal bool | |
| 66 | + | // HintDisabled, when set, is called the first time the hint action runs | |
| 67 | + | // without a scanner (hint mode off). It must not block. | |
| 68 | + | HintDisabled func() | |
| 60 | 69 | } | |
| 61 | 70 | ||
| 62 | 71 | // fullscreenGrace is how long after mapping a window's fullscreen request | |
| @@ -157,6 +166,14 @@ type Core struct { | |||
| 157 | 166 | // capt is the capture state (capture.go, capindicator.go). | |
| 158 | 167 | capt captureState | |
| 159 | 168 | flash focusFlash | |
| 169 | + | // hint is keyboard hint mode (hint.go). | |
| 170 | + | hint hintState | |
| 171 | + | // keyState is the real modifier state of the latest key event, swallowed | |
| 172 | + | // keys included. | |
| 173 | + | keyState ports.ModState | |
| 174 | + | // synthetic is set while a hint click runs through the pointer handlers: | |
| 175 | + | // compositor modifier+button actions (window drag) do not apply to it. | |
| 176 | + | synthetic bool | |
| 160 | 177 | // switcher is the column switcher in progress: the screen it is on and | |
| 161 | 178 | // the timer that shows its cards (switcher.go). | |
| 162 | 179 | switcher struct { | |
| @@ -275,7 +292,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 275 | 292 | } | |
| 276 | 293 | switch Action(a) { | |
| 277 | 294 | 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: | |
| 295 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint: | |
| 279 | 296 | default: | |
| 280 | 297 | return fmt.Errorf("invalid action %q", a) | |
| 281 | 298 | } | |
| @@ -634,6 +651,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 634 | 651 | return errSecurityChanged | |
| 635 | 652 | } | |
| 636 | 653 | c.syncSecurity() | |
| 654 | + | c.hintValidate() | |
| 637 | 655 | if c.security.Protected { | |
| 638 | 656 | return c.publishProtected(ctx) | |
| 639 | 657 | } | |
| @@ -1206,6 +1224,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1206 | 1224 | return nil | |
| 1207 | 1225 | } | |
| 1208 | 1226 | continue | |
| 1227 | + | case res, ok := <-c.ch.Hints: | |
| 1228 | + | if !ok { | |
| 1229 | + | c.ch.Hints = nil | |
| 1230 | + | continue | |
| 1231 | + | } | |
| 1232 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1233 | + | return nil | |
| 1234 | + | } | |
| 1235 | + | if c.hintResult(res) && c.publish(ctx) != nil { | |
| 1236 | + | return nil | |
| 1237 | + | } | |
| 1209 | 1238 | case ev, ok := <-c.ch.Output: | |
| 1210 | 1239 | if !ok { | |
| 1211 | 1240 | 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. | ||
| @@ -57,6 +63,9 @@ type Options struct { | |||||
| 57 | Clock ports.Clock | 63 | Clock ports.Clock | ||
| 58 | // Terminal enables automatic terminal opening according to terminal.auto-open. | 64 | // Terminal enables automatic terminal opening according to terminal.auto-open. | ||
| 59 | Terminal bool | 65 | Terminal bool | ||
| 66 | + | // HintDisabled, when set, is called the first time the hint action runs | |||
| 67 | + | // without a scanner (hint mode off). It must not block. | |||
| 68 | + | HintDisabled func() | |||
| 60 | } | 69 | } | ||
| 61 | 70 | ||||
| 62 | // fullscreenGrace is how long after mapping a window's fullscreen request | 71 | // fullscreenGrace is how long after mapping a window's fullscreen request | ||
| @@ -157,6 +166,14 @@ type Core struct { | |||||
| 157 | // capt is the capture state (capture.go, capindicator.go). | 166 | // capt is the capture state (capture.go, capindicator.go). | ||
| 158 | capt captureState | 167 | capt captureState | ||
| 159 | flash focusFlash | 168 | flash focusFlash | ||
| 169 | + | // hint is keyboard hint mode (hint.go). | |||
| 170 | + | hint hintState | |||
| 171 | + | // keyState is the real modifier state of the latest key event, swallowed | |||
| 172 | + | // keys included. | |||
| 173 | + | keyState ports.ModState | |||
| 174 | + | // synthetic is set while a hint click runs through the pointer handlers: | |||
| 175 | + | // compositor modifier+button actions (window drag) do not apply to it. | |||
| 176 | + | synthetic bool | |||
| 160 | // switcher is the column switcher in progress: the screen it is on and | 177 | // switcher is the column switcher in progress: the screen it is on and | ||
| 161 | // the timer that shows its cards (switcher.go). | 178 | // the timer that shows its cards (switcher.go). | ||
| 162 | switcher struct { | 179 | switcher struct { | ||
| @@ -275,7 +292,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 275 | } | 292 | } | ||
| 276 | switch Action(a) { | 293 | 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, | 294 | 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: | 295 | + | ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint: |
| 279 | default: | 296 | default: | ||
| 280 | return fmt.Errorf("invalid action %q", a) | 297 | return fmt.Errorf("invalid action %q", a) | ||
| 281 | } | 298 | } | ||
| @@ -634,6 +651,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 634 | return errSecurityChanged | 651 | return errSecurityChanged | ||
| 635 | } | 652 | } | ||
| 636 | c.syncSecurity() | 653 | c.syncSecurity() | ||
| 654 | + | c.hintValidate() | |||
| 637 | if c.security.Protected { | 655 | if c.security.Protected { | ||
| 638 | return c.publishProtected(ctx) | 656 | return c.publishProtected(ctx) | ||
| 639 | } | 657 | } | ||
| @@ -1206,6 +1224,17 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1206 | return nil | 1224 | return nil | ||
| 1207 | } | 1225 | } | ||
| 1208 | continue | 1226 | continue | ||
| 1227 | + | case res, ok := <-c.ch.Hints: | |||
| 1228 | + | if !ok { | |||
| 1229 | + | c.ch.Hints = nil | |||
| 1230 | + | continue | |||
| 1231 | + | } | |||
| 1232 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1233 | + | return nil | |||
| 1234 | + | } | |||
| 1235 | + | if c.hintResult(res) && c.publish(ctx) != nil { | |||
| 1236 | + | return nil | |||
| 1237 | + | } | |||
| 1209 | case ev, ok := <-c.ch.Output: | 1238 | case ev, ok := <-c.ch.Output: | ||
| 1210 | if !ok { | 1239 | if !ok { | ||
| 1211 | c.ch.Output = nil | 1240 | 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 +361 -0
| @@ -0,0 +1,361 @@ | |||
| 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 | + | text bool // found by OCR | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type hintState struct { | |
| 42 | + | phase hintPhase | |
| 43 | + | seq uint64 // last request sequence number | |
| 44 | + | output string | |
| 45 | + | // ws is the workspace on the hint output when hint mode began: another | |
| 46 | + | // one on screen ends it. | |
| 47 | + | ws *Workspace | |
| 48 | + | geom hintGeom | |
| 49 | + | windows []ports.HintWindow // visible windows at request time | |
| 50 | + | targets []hintTarget | |
| 51 | + | typed string | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // hintGeom is the output geometry a scan was requested on: a result or a | |
| 55 | + | // label set is only good for the same one. | |
| 56 | + | type hintGeom struct { | |
| 57 | + | x, y int | |
| 58 | + | w, h int // logical size | |
| 59 | + | width, height int // buffer size, physical pixels | |
| 60 | + | scale float64 | |
| 61 | + | transform ports.BufferTransform | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (sc *screen) hintGeom() hintGeom { | |
| 65 | + | r := sc.rect() | |
| 66 | + | return hintGeom{x: sc.x, y: sc.y, w: r.W, h: r.H, width: sc.info.Width, height: sc.info.Height, scale: sc.scale, transform: sc.transform} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // hintRequest starts hint mode on the focused screen. It does nothing | |
| 70 | + | // (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is | |
| 71 | + | // active, or on an output with a non-normal transform. | |
| 72 | + | func (c *Core) hintRequest() { | |
| 73 | + | if c.ch.HintScans == nil { | |
| 74 | + | if c.opts.HintDisabled != nil { | |
| 75 | + | c.opts.HintDisabled() | |
| 76 | + | c.opts.HintDisabled = nil | |
| 77 | + | } | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | sc := c.cur() | |
| 84 | + | if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | wins := c.hintWindows(sc) | |
| 88 | + | 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, Windows: wins} | |
| 89 | + | select { | |
| 90 | + | case c.ch.HintScans <- req: | |
| 91 | + | default: | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)} | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // hintWindows lists the windows drawn on sc, bottom to top, with their client | |
| 98 | + | // area (placements are output-local). | |
| 99 | + | func (c *Core) hintWindows(sc *screen) []ports.HintWindow { | |
| 100 | + | view := sc.mon.viewport() | |
| 101 | + | var out []ports.HintWindow | |
| 102 | + | for _, p := range sc.shownLayout() { | |
| 103 | + | if p.Hidden || p.Preview > 0 || !view.shows(p) { | |
| 104 | + | continue | |
| 105 | + | } | |
| 106 | + | r := c.clientRect(p) | |
| 107 | + | if r.W <= 0 || r.H <= 0 { | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | w := ports.HintWindow{ID: p.ID, Rect: r, Focused: p.Focused} | |
| 111 | + | rec := c.windows.lookup(p.ID) | |
| 112 | + | w.PID, w.AppID = rec.client.PID, rec.client.AppID | |
| 113 | + | out = append(out, w) | |
| 114 | + | } | |
| 115 | + | return out | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // hintPointerBusy reports whether the pointer is in use by something a | |
| 119 | + | // synthetic click would disturb: a pointer constraint, a grab, a held | |
| 120 | + | // button (swallowed ones included: they stay in c.buttons), a window or data | |
| 121 | + | // drag, or the column switcher. | |
| 122 | + | func (c *Core) hintPointerBusy() bool { | |
| 123 | + | return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone || | |
| 124 | + | c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // hintResult handles a scanner result and reports whether to publish. | |
| 128 | + | func (c *Core) hintResult(res ports.HintScanResult) bool { | |
| 129 | + | h := &c.hint | |
| 130 | + | if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | if res.Err != nil || c.security.Protected { | |
| 134 | + | c.hintCancel() | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | i := c.screenIndex(h.output) | |
| 138 | + | if i < 0 { | |
| 139 | + | c.hintCancel() | |
| 140 | + | return true | |
| 141 | + | } | |
| 142 | + | sc := c.screens[i] | |
| 143 | + | if sc.hintGeom() != h.geom { | |
| 144 | + | c.hintCancel() | |
| 145 | + | return true | |
| 146 | + | } | |
| 147 | + | out := sc.rect() | |
| 148 | + | var kept []hintTarget | |
| 149 | + | for _, t := range res.Targets { | |
| 150 | + | x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2 | |
| 151 | + | y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2 | |
| 152 | + | if x < float64(out.X) || y < float64(out.Y) || x >= float64(out.X+out.W) || y >= float64(out.Y+out.H) { | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | id, _, _ := c.hit(x, y) | |
| 156 | + | if id == 0 { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | root := c.popupRoot(id) | |
| 160 | + | if s, _ := c.screenOf(root); s != sc { | |
| 161 | + | // A layer surface, or a window of another output. | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | if !c.hintOwned(sc, t, root) { | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text}) | |
| 168 | + | } | |
| 169 | + | if len(kept) == 0 { | |
| 170 | + | c.hintCancel() | |
| 171 | + | return true | |
| 172 | + | } | |
| 173 | + | slices.SortStableFunc(kept, func(a, b hintTarget) int { | |
| 174 | + | return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | |
| 175 | + | }) | |
| 176 | + | if len(kept) > hintMaxTargets { | |
| 177 | + | kept = kept[:hintMaxTargets] | |
| 178 | + | } | |
| 179 | + | n := len(hintAlphabet) | |
| 180 | + | for i := range kept { | |
| 181 | + | if len(kept) <= n { | |
| 182 | + | kept[i].label = string(hintAlphabet[i]) | |
| 183 | + | } else { | |
| 184 | + | kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]}) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | h.phase, h.targets, h.typed = hintShown, kept, "" | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // hintOwned reports whether the window under a target is the one it was | |
| 192 | + | // scanned on. An accessibility target names its window; an OCR target only | |
| 193 | + | // counts over a window that was visible when the scan was requested and has | |
| 194 | + | // not moved or resized since. | |
| 195 | + | func (c *Core) hintOwned(sc *screen, t ports.HintTarget, root WindowID) bool { | |
| 196 | + | if t.Window != 0 { | |
| 197 | + | return root == t.Window | |
| 198 | + | } | |
| 199 | + | for _, w := range c.hint.windows { | |
| 200 | + | if w.ID != root { | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | for _, p := range sc.shownLayout() { | |
| 204 | + | if p.ID == root { | |
| 205 | + | return c.clientRect(p) == w.Rect | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // hintCancel ends hint mode. | |
| 213 | + | func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} } | |
| 214 | + | ||
| 215 | + | // hintValidate ends hint mode when what it was started on is gone: the | |
| 216 | + | // session became protected, the output went or changed geometry, another workspace is on screen | |
| 217 | + | // there, or the overview opened. It runs at the start of every publish. | |
| 218 | + | func (c *Core) hintValidate() { | |
| 219 | + | h := &c.hint | |
| 220 | + | if h.phase == hintIdle { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | i := c.screenIndex(h.output) | |
| 224 | + | if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open { | |
| 225 | + | c.hintCancel() | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | if c.screens[i].hintGeom() != h.geom { | |
| 229 | + | c.hintCancel() | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // hintKey handles a key while hint mode is active: the key never goes on. | |
| 234 | + | // A press is the whole job; releases take the usual swallow path. | |
| 235 | + | func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |
| 236 | + | if c.hint.phase == hintIdle || !key.Pressed { | |
| 237 | + | return false, nil | |
| 238 | + | } | |
| 239 | + | c.pressed[heldKey(key)] = true | |
| 240 | + | h := &c.hint | |
| 241 | + | if h.phase == hintPending || keyName(key.Keysym) == "Escape" { | |
| 242 | + | c.hintCancel() | |
| 243 | + | return true, c.publish(ctx) | |
| 244 | + | } | |
| 245 | + | if keyName(key.Keysym) == "BackSpace" { | |
| 246 | + | if h.typed != "" { | |
| 247 | + | h.typed = h.typed[:len(h.typed)-1] | |
| 248 | + | } | |
| 249 | + | return true, c.publish(ctx) | |
| 250 | + | } | |
| 251 | + | name := key.Base | |
| 252 | + | if name == "" { | |
| 253 | + | name = key.Keysym | |
| 254 | + | } | |
| 255 | + | name = keyName(name) | |
| 256 | + | if len(name) != 1 || name[0] < 'a' || name[0] > 'z' { | |
| 257 | + | c.hintCancel() | |
| 258 | + | return true, c.publish(ctx) | |
| 259 | + | } | |
| 260 | + | typed := h.typed + name | |
| 261 | + | var match *hintTarget | |
| 262 | + | any := false | |
| 263 | + | for i := range h.targets { | |
| 264 | + | if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed { | |
| 265 | + | any = true | |
| 266 | + | if h.targets[i].label == typed { | |
| 267 | + | match = &h.targets[i] | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | if !any { | |
| 272 | + | return true, nil | |
| 273 | + | } | |
| 274 | + | if match == nil { | |
| 275 | + | h.typed = typed | |
| 276 | + | return true, c.publish(ctx) | |
| 277 | + | } | |
| 278 | + | return true, c.hintClick(ctx, *match) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // hintClick ends hint mode and left-clicks the target's centre, provided the | |
| 282 | + | // pointer is free and the same window is still under it. The click is | |
| 283 | + | // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may | |
| 284 | + | // still be down) from turning it into a window drag, without touching c.mods. | |
| 285 | + | // | |
| 286 | + | // A target found by OCR is clicked with hint.text-click held (terminals open | |
| 287 | + | // a link on a modified click). Every click gives the window the keyboard | |
| 288 | + | // first, then sends exactly the modifiers to hold (none for other targets), | |
| 289 | + | // and the real modifier state is always sent back afterwards. | |
| 290 | + | func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) { | |
| 291 | + | c.hintCancel() | |
| 292 | + | if err := c.publish(ctx); err != nil { | |
| 293 | + | return err | |
| 294 | + | } | |
| 295 | + | if !c.hintClickable(t) { | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | // Every click states its modifiers (possibly none): the bind's Cmd may | |
| 299 | + | // still be down and the client must not see it on a plain click. | |
| 300 | + | if err := c.clickFocus(ctx, t.window); err != nil { | |
| 301 | + | return err | |
| 302 | + | } | |
| 303 | + | if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: c.hintMods(t)}); err != nil { | |
| 304 | + | return err | |
| 305 | + | } | |
| 306 | + | defer func() { | |
| 307 | + | if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true, State: c.keyState}); err == nil { | |
| 308 | + | err = e | |
| 309 | + | } | |
| 310 | + | }() | |
| 311 | + | c.synthetic = true | |
| 312 | + | defer func() { c.synthetic = false }() | |
| 313 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil { | |
| 314 | + | return err | |
| 315 | + | } | |
| 316 | + | // The motion may have been bent (constraint) or the layout changed: | |
| 317 | + | // press only where the target is. | |
| 318 | + | if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) { | |
| 319 | + | return nil | |
| 320 | + | } | |
| 321 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil { | |
| 322 | + | return err | |
| 323 | + | } | |
| 324 | + | return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime}) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // hintMods are the modifiers to hold for a click on t, read at click time so | |
| 328 | + | // a reloaded configuration applies. | |
| 329 | + | func (c *Core) hintMods(t hintTarget) ports.Mods { | |
| 330 | + | if !t.text { | |
| 331 | + | return 0 | |
| 332 | + | } | |
| 333 | + | return c.cfg.Hint.TextClick | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // hintClickable reports whether a click on t may go out now: unprotected, | |
| 337 | + | // pointer free, and the selected window is still under the target's centre. | |
| 338 | + | func (c *Core) hintClickable(t hintTarget) bool { | |
| 339 | + | if c.security.Protected || c.hintPointerBusy() { | |
| 340 | + | return false | |
| 341 | + | } | |
| 342 | + | id, _, _ := c.hit(t.x, t.y) | |
| 343 | + | return id == t.window | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // hintLabels are the labels to draw on sc: those still matching what was | |
| 347 | + | // typed. Nil unless sc is the hint output. | |
| 348 | + | func (c *Core) hintLabels(sc *screen) []ports.HintLabel { | |
| 349 | + | h := &c.hint | |
| 350 | + | if h.phase != hintShown || sc.name() != h.output { | |
| 351 | + | return nil | |
| 352 | + | } | |
| 353 | + | var out []ports.HintLabel | |
| 354 | + | for _, t := range h.targets { | |
| 355 | + | if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed { | |
| 356 | + | continue | |
| 357 | + | } | |
| 358 | + | out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)}) | |
| 359 | + | } | |
| 360 | + | return out | |
| 361 | + | } | |
| @@ -0,0 +1,361 @@ | |||||
| 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 | + | text bool // found by OCR | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type hintState struct { | |||
| 42 | + | phase hintPhase | |||
| 43 | + | seq uint64 // last request sequence number | |||
| 44 | + | output string | |||
| 45 | + | // ws is the workspace on the hint output when hint mode began: another | |||
| 46 | + | // one on screen ends it. | |||
| 47 | + | ws *Workspace | |||
| 48 | + | geom hintGeom | |||
| 49 | + | windows []ports.HintWindow // visible windows at request time | |||
| 50 | + | targets []hintTarget | |||
| 51 | + | typed string | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // hintGeom is the output geometry a scan was requested on: a result or a | |||
| 55 | + | // label set is only good for the same one. | |||
| 56 | + | type hintGeom struct { | |||
| 57 | + | x, y int | |||
| 58 | + | w, h int // logical size | |||
| 59 | + | width, height int // buffer size, physical pixels | |||
| 60 | + | scale float64 | |||
| 61 | + | transform ports.BufferTransform | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (sc *screen) hintGeom() hintGeom { | |||
| 65 | + | r := sc.rect() | |||
| 66 | + | return hintGeom{x: sc.x, y: sc.y, w: r.W, h: r.H, width: sc.info.Width, height: sc.info.Height, scale: sc.scale, transform: sc.transform} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // hintRequest starts hint mode on the focused screen. It does nothing | |||
| 70 | + | // (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is | |||
| 71 | + | // active, or on an output with a non-normal transform. | |||
| 72 | + | func (c *Core) hintRequest() { | |||
| 73 | + | if c.ch.HintScans == nil { | |||
| 74 | + | if c.opts.HintDisabled != nil { | |||
| 75 | + | c.opts.HintDisabled() | |||
| 76 | + | c.opts.HintDisabled = nil | |||
| 77 | + | } | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | sc := c.cur() | |||
| 84 | + | if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | wins := c.hintWindows(sc) | |||
| 88 | + | 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, Windows: wins} | |||
| 89 | + | select { | |||
| 90 | + | case c.ch.HintScans <- req: | |||
| 91 | + | default: | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)} | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // hintWindows lists the windows drawn on sc, bottom to top, with their client | |||
| 98 | + | // area (placements are output-local). | |||
| 99 | + | func (c *Core) hintWindows(sc *screen) []ports.HintWindow { | |||
| 100 | + | view := sc.mon.viewport() | |||
| 101 | + | var out []ports.HintWindow | |||
| 102 | + | for _, p := range sc.shownLayout() { | |||
| 103 | + | if p.Hidden || p.Preview > 0 || !view.shows(p) { | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 106 | + | r := c.clientRect(p) | |||
| 107 | + | if r.W <= 0 || r.H <= 0 { | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | w := ports.HintWindow{ID: p.ID, Rect: r, Focused: p.Focused} | |||
| 111 | + | rec := c.windows.lookup(p.ID) | |||
| 112 | + | w.PID, w.AppID = rec.client.PID, rec.client.AppID | |||
| 113 | + | out = append(out, w) | |||
| 114 | + | } | |||
| 115 | + | return out | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // hintPointerBusy reports whether the pointer is in use by something a | |||
| 119 | + | // synthetic click would disturb: a pointer constraint, a grab, a held | |||
| 120 | + | // button (swallowed ones included: they stay in c.buttons), a window or data | |||
| 121 | + | // drag, or the column switcher. | |||
| 122 | + | func (c *Core) hintPointerBusy() bool { | |||
| 123 | + | return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone || | |||
| 124 | + | c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // hintResult handles a scanner result and reports whether to publish. | |||
| 128 | + | func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 129 | + | h := &c.hint | |||
| 130 | + | if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output { | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | if res.Err != nil || c.security.Protected { | |||
| 134 | + | c.hintCancel() | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | i := c.screenIndex(h.output) | |||
| 138 | + | if i < 0 { | |||
| 139 | + | c.hintCancel() | |||
| 140 | + | return true | |||
| 141 | + | } | |||
| 142 | + | sc := c.screens[i] | |||
| 143 | + | if sc.hintGeom() != h.geom { | |||
| 144 | + | c.hintCancel() | |||
| 145 | + | return true | |||
| 146 | + | } | |||
| 147 | + | out := sc.rect() | |||
| 148 | + | var kept []hintTarget | |||
| 149 | + | for _, t := range res.Targets { | |||
| 150 | + | x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2 | |||
| 151 | + | y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2 | |||
| 152 | + | if x < float64(out.X) || y < float64(out.Y) || x >= float64(out.X+out.W) || y >= float64(out.Y+out.H) { | |||
| 153 | + | continue | |||
| 154 | + | } | |||
| 155 | + | id, _, _ := c.hit(x, y) | |||
| 156 | + | if id == 0 { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | root := c.popupRoot(id) | |||
| 160 | + | if s, _ := c.screenOf(root); s != sc { | |||
| 161 | + | // A layer surface, or a window of another output. | |||
| 162 | + | continue | |||
| 163 | + | } | |||
| 164 | + | if !c.hintOwned(sc, t, root) { | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text}) | |||
| 168 | + | } | |||
| 169 | + | if len(kept) == 0 { | |||
| 170 | + | c.hintCancel() | |||
| 171 | + | return true | |||
| 172 | + | } | |||
| 173 | + | slices.SortStableFunc(kept, func(a, b hintTarget) int { | |||
| 174 | + | return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | |||
| 175 | + | }) | |||
| 176 | + | if len(kept) > hintMaxTargets { | |||
| 177 | + | kept = kept[:hintMaxTargets] | |||
| 178 | + | } | |||
| 179 | + | n := len(hintAlphabet) | |||
| 180 | + | for i := range kept { | |||
| 181 | + | if len(kept) <= n { | |||
| 182 | + | kept[i].label = string(hintAlphabet[i]) | |||
| 183 | + | } else { | |||
| 184 | + | kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]}) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | h.phase, h.targets, h.typed = hintShown, kept, "" | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // hintOwned reports whether the window under a target is the one it was | |||
| 192 | + | // scanned on. An accessibility target names its window; an OCR target only | |||
| 193 | + | // counts over a window that was visible when the scan was requested and has | |||
| 194 | + | // not moved or resized since. | |||
| 195 | + | func (c *Core) hintOwned(sc *screen, t ports.HintTarget, root WindowID) bool { | |||
| 196 | + | if t.Window != 0 { | |||
| 197 | + | return root == t.Window | |||
| 198 | + | } | |||
| 199 | + | for _, w := range c.hint.windows { | |||
| 200 | + | if w.ID != root { | |||
| 201 | + | continue | |||
| 202 | + | } | |||
| 203 | + | for _, p := range sc.shownLayout() { | |||
| 204 | + | if p.ID == root { | |||
| 205 | + | return c.clientRect(p) == w.Rect | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | return false | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // hintCancel ends hint mode. | |||
| 213 | + | func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} } | |||
| 214 | + | ||||
| 215 | + | // hintValidate ends hint mode when what it was started on is gone: the | |||
| 216 | + | // session became protected, the output went or changed geometry, another workspace is on screen | |||
| 217 | + | // there, or the overview opened. It runs at the start of every publish. | |||
| 218 | + | func (c *Core) hintValidate() { | |||
| 219 | + | h := &c.hint | |||
| 220 | + | if h.phase == hintIdle { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | i := c.screenIndex(h.output) | |||
| 224 | + | if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open { | |||
| 225 | + | c.hintCancel() | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | if c.screens[i].hintGeom() != h.geom { | |||
| 229 | + | c.hintCancel() | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // hintKey handles a key while hint mode is active: the key never goes on. | |||
| 234 | + | // A press is the whole job; releases take the usual swallow path. | |||
| 235 | + | func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |||
| 236 | + | if c.hint.phase == hintIdle || !key.Pressed { | |||
| 237 | + | return false, nil | |||
| 238 | + | } | |||
| 239 | + | c.pressed[heldKey(key)] = true | |||
| 240 | + | h := &c.hint | |||
| 241 | + | if h.phase == hintPending || keyName(key.Keysym) == "Escape" { | |||
| 242 | + | c.hintCancel() | |||
| 243 | + | return true, c.publish(ctx) | |||
| 244 | + | } | |||
| 245 | + | if keyName(key.Keysym) == "BackSpace" { | |||
| 246 | + | if h.typed != "" { | |||
| 247 | + | h.typed = h.typed[:len(h.typed)-1] | |||
| 248 | + | } | |||
| 249 | + | return true, c.publish(ctx) | |||
| 250 | + | } | |||
| 251 | + | name := key.Base | |||
| 252 | + | if name == "" { | |||
| 253 | + | name = key.Keysym | |||
| 254 | + | } | |||
| 255 | + | name = keyName(name) | |||
| 256 | + | if len(name) != 1 || name[0] < 'a' || name[0] > 'z' { | |||
| 257 | + | c.hintCancel() | |||
| 258 | + | return true, c.publish(ctx) | |||
| 259 | + | } | |||
| 260 | + | typed := h.typed + name | |||
| 261 | + | var match *hintTarget | |||
| 262 | + | any := false | |||
| 263 | + | for i := range h.targets { | |||
| 264 | + | if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed { | |||
| 265 | + | any = true | |||
| 266 | + | if h.targets[i].label == typed { | |||
| 267 | + | match = &h.targets[i] | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | if !any { | |||
| 272 | + | return true, nil | |||
| 273 | + | } | |||
| 274 | + | if match == nil { | |||
| 275 | + | h.typed = typed | |||
| 276 | + | return true, c.publish(ctx) | |||
| 277 | + | } | |||
| 278 | + | return true, c.hintClick(ctx, *match) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // hintClick ends hint mode and left-clicks the target's centre, provided the | |||
| 282 | + | // pointer is free and the same window is still under it. The click is | |||
| 283 | + | // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may | |||
| 284 | + | // still be down) from turning it into a window drag, without touching c.mods. | |||
| 285 | + | // | |||
| 286 | + | // A target found by OCR is clicked with hint.text-click held (terminals open | |||
| 287 | + | // a link on a modified click). Every click gives the window the keyboard | |||
| 288 | + | // first, then sends exactly the modifiers to hold (none for other targets), | |||
| 289 | + | // and the real modifier state is always sent back afterwards. | |||
| 290 | + | func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) { | |||
| 291 | + | c.hintCancel() | |||
| 292 | + | if err := c.publish(ctx); err != nil { | |||
| 293 | + | return err | |||
| 294 | + | } | |||
| 295 | + | if !c.hintClickable(t) { | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | // Every click states its modifiers (possibly none): the bind's Cmd may | |||
| 299 | + | // still be down and the client must not see it on a plain click. | |||
| 300 | + | if err := c.clickFocus(ctx, t.window); err != nil { | |||
| 301 | + | return err | |||
| 302 | + | } | |||
| 303 | + | if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: c.hintMods(t)}); err != nil { | |||
| 304 | + | return err | |||
| 305 | + | } | |||
| 306 | + | defer func() { | |||
| 307 | + | if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true, State: c.keyState}); err == nil { | |||
| 308 | + | err = e | |||
| 309 | + | } | |||
| 310 | + | }() | |||
| 311 | + | c.synthetic = true | |||
| 312 | + | defer func() { c.synthetic = false }() | |||
| 313 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil { | |||
| 314 | + | return err | |||
| 315 | + | } | |||
| 316 | + | // The motion may have been bent (constraint) or the layout changed: | |||
| 317 | + | // press only where the target is. | |||
| 318 | + | if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) { | |||
| 319 | + | return nil | |||
| 320 | + | } | |||
| 321 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil { | |||
| 322 | + | return err | |||
| 323 | + | } | |||
| 324 | + | return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime}) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | // hintMods are the modifiers to hold for a click on t, read at click time so | |||
| 328 | + | // a reloaded configuration applies. | |||
| 329 | + | func (c *Core) hintMods(t hintTarget) ports.Mods { | |||
| 330 | + | if !t.text { | |||
| 331 | + | return 0 | |||
| 332 | + | } | |||
| 333 | + | return c.cfg.Hint.TextClick | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // hintClickable reports whether a click on t may go out now: unprotected, | |||
| 337 | + | // pointer free, and the selected window is still under the target's centre. | |||
| 338 | + | func (c *Core) hintClickable(t hintTarget) bool { | |||
| 339 | + | if c.security.Protected || c.hintPointerBusy() { | |||
| 340 | + | return false | |||
| 341 | + | } | |||
| 342 | + | id, _, _ := c.hit(t.x, t.y) | |||
| 343 | + | return id == t.window | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // hintLabels are the labels to draw on sc: those still matching what was | |||
| 347 | + | // typed. Nil unless sc is the hint output. | |||
| 348 | + | func (c *Core) hintLabels(sc *screen) []ports.HintLabel { | |||
| 349 | + | h := &c.hint | |||
| 350 | + | if h.phase != hintShown || sc.name() != h.output { | |||
| 351 | + | return nil | |||
| 352 | + | } | |||
| 353 | + | var out []ports.HintLabel | |||
| 354 | + | for _, t := range h.targets { | |||
| 355 | + | if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed { | |||
| 356 | + | continue | |||
| 357 | + | } | |||
| 358 | + | out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)}) | |||
| 359 | + | } | |||
| 360 | + | return out | |||
| 361 | + | } | |||
A internal/core/hint_test.go +834 -0
| @@ -0,0 +1,834 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferwl/internal/core" | |
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type hintHarness struct { | |
| 17 | + | t *testing.T | |
| 18 | + | input chan ports.InputEvent | |
| 19 | + | client chan ports.ClientEvent | |
| 20 | + | output chan ports.OutputEvent | |
| 21 | + | commands chan ports.ClientCommand | |
| 22 | + | scenes chan []ports.Scene | |
| 23 | + | scans chan ports.HintScanRequest | |
| 24 | + | hints chan ports.HintScanResult | |
| 25 | + | win ports.Rect // window 1, output-local | |
| 26 | + | // state is the security state input is stamped with. | |
| 27 | + | state ports.SecurityState | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped. | |
| 31 | + | func newHint(t *testing.T, opts core.Options) *hintHarness { | |
| 32 | + | t.Helper() | |
| 33 | + | return newHintMods(t, opts, 0) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // newHintMods is newHint with hint.text-click set to textClick. | |
| 37 | + | func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness { | |
| 38 | + | t.Helper() | |
| 39 | + | cfg := scrollDefaults() | |
| 40 | + | cfg.Hint.TextClick = textClick | |
| 41 | + | cfg.Border.Width = 0 | |
| 42 | + | cfg.Layout.Gaps = 20 | |
| 43 | + | cfg.Binds["Cmd+g"] = "hint" | |
| 44 | + | h := &hintHarness{ | |
| 45 | + | t: t, | |
| 46 | + | input: make(chan ports.InputEvent, 32), | |
| 47 | + | client: make(chan ports.ClientEvent, 32), | |
| 48 | + | output: make(chan ports.OutputEvent, 8), | |
| 49 | + | commands: make(chan ports.ClientCommand, 256), | |
| 50 | + | scenes: make(chan []ports.Scene, 1), | |
| 51 | + | scans: make(chan ports.HintScanRequest, 1), | |
| 52 | + | // Unbuffered: a send returns once core took the result. | |
| 53 | + | hints: make(chan ports.HintScanResult), | |
| 54 | + | } | |
| 55 | + | ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints} | |
| 56 | + | c, err := core.New(cfg, ch, opts) | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 61 | + | done := make(chan error, 1) | |
| 62 | + | go func() { done <- c.Run(ctx) }() | |
| 63 | + | t.Cleanup(func() { cancel(); receive(t, done) }) | |
| 64 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |
| 65 | + | scene(t, h.scenes) | |
| 66 | + | h.client <- ports.WindowMapped{ID: 1, AppID: "org.test.app", PID: 4242} | |
| 67 | + | s := scene(t, h.scenes) | |
| 68 | + | h.win = s.Windows[0].Rect | |
| 69 | + | h.drain() | |
| 70 | + | return h | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (h *hintHarness) drain() { | |
| 74 | + | for { | |
| 75 | + | select { | |
| 76 | + | case <-h.commands: | |
| 77 | + | default: | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // press and release send one key event. | |
| 84 | + | func (h *hintHarness) press(sym string, mods ports.Mods) { | |
| 85 | + | h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true}) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (h *hintHarness) release(sym string, mods ports.Mods) { | |
| 89 | + | h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods}) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // send gives core an input event stamped with the current security state. | |
| 93 | + | func (h *hintHarness) send(ev ports.InputEvent) { | |
| 94 | + | h.input <- ports.SecurityInput{State: h.state, Event: ev} | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // start presses the hint bind and returns the request. | |
| 98 | + | func (h *hintHarness) start() ports.HintScanRequest { | |
| 99 | + | h.t.Helper() | |
| 100 | + | h.press("g", ports.ModSuper) | |
| 101 | + | req := receive(h.t, h.scans) | |
| 102 | + | h.release("g", 0) // the user lets go of the whole chord | |
| 103 | + | return req | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // show starts hint mode and answers with targets, returning the next scene. | |
| 107 | + | func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene { | |
| 108 | + | h.t.Helper() | |
| 109 | + | req := h.start() | |
| 110 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets} | |
| 111 | + | return h.labelled(len(targets), 0) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // until returns the first scene, from the next one on, that satisfies ok: | |
| 115 | + | // the bind press and other input publish scenes before the one waited for. | |
| 116 | + | func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene { | |
| 117 | + | h.t.Helper() | |
| 118 | + | for { | |
| 119 | + | if s := scene(h.t, h.scenes); ok(s) { | |
| 120 | + | return s | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // labelled waits for a scene with n labels, all typed with typed letters. | |
| 126 | + | func (h *hintHarness) labelled(n, typed int) ports.Scene { | |
| 127 | + | h.t.Helper() | |
| 128 | + | return h.until(func(s ports.Scene) bool { | |
| 129 | + | return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // inside is a target of the given size inside window 1. | |
| 134 | + | func (h *hintHarness) inside(i int) ports.HintTarget { | |
| 135 | + | return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}} | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestHintRequestOnBind(t *testing.T) { | |
| 139 | + | h := newHint(t, core.Options{}) | |
| 140 | + | req := h.start() | |
| 141 | + | if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 { | |
| 142 | + | t.Fatalf("request %+v", req) | |
| 143 | + | } | |
| 144 | + | want := []ports.HintWindow{{ID: 1, PID: 4242, AppID: "org.test.app", Rect: h.win, Focused: true}} | |
| 145 | + | if !slices.Equal(req.Windows, want) { | |
| 146 | + | t.Fatalf("windows %+v, want %+v", req.Windows, want) | |
| 147 | + | } | |
| 148 | + | // A second bind while pending does not ask again; the pending phase | |
| 149 | + | // cancels on any key. | |
| 150 | + | h.press("g", ports.ModSuper) | |
| 151 | + | select { | |
| 152 | + | case r := <-h.scans: | |
| 153 | + | t.Fatalf("second request %+v", r) | |
| 154 | + | case <-time.After(50 * time.Millisecond): | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestHintRequestNilScansIsNoop(t *testing.T) { | |
| 159 | + | cfg := scrollDefaults() | |
| 160 | + | cfg.Binds["Cmd+g"] = "hint" | |
| 161 | + | input := make(chan ports.InputEvent, 4) | |
| 162 | + | client := make(chan ports.ClientEvent, 4) | |
| 163 | + | output := make(chan ports.OutputEvent, 4) | |
| 164 | + | commands := make(chan ports.ClientCommand, 32) | |
| 165 | + | scenes := make(chan []ports.Scene, 1) | |
| 166 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{}) | |
| 167 | + | if err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 171 | + | done := make(chan error, 1) | |
| 172 | + | go func() { done <- c.Run(ctx) }() | |
| 173 | + | defer func() { cancel(); receive(t, done) }() | |
| 174 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |
| 175 | + | scene(t, scenes) | |
| 176 | + | input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true} | |
| 177 | + | if s := scene(t, scenes); len(s.HintLabels) != 0 { | |
| 178 | + | t.Fatal(s.HintLabels) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHintRefusedWhenProtected(t *testing.T) { | |
| 183 | + | var state atomic.Pointer[ports.SecurityState] | |
| 184 | + | state.Store(&ports.SecurityState{}) | |
| 185 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 186 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe() | |
| 187 | + | h := newHint(t, core.Options{Security: gate}) | |
| 188 | + | // Hint mode shown, then the session locks: the labels go away. | |
| 189 | + | h.show(h.inside(0)) | |
| 190 | + | state.Store(&ports.SecurityState{Generation: 2, Protected: true}) | |
| 191 | + | h.state = *state.Load() | |
| 192 | + | h.client <- ports.WindowMapped{ID: 2} | |
| 193 | + | s := h.until(func(s ports.Scene) bool { return s.Security.Protected }) | |
| 194 | + | if len(s.HintLabels) != 0 { | |
| 195 | + | t.Fatalf("labels while protected %+v", s.HintLabels) | |
| 196 | + | } | |
| 197 | + | // And a new request is refused while it lasts. | |
| 198 | + | h.press("g", ports.ModSuper) | |
| 199 | + | select { | |
| 200 | + | case r := <-h.scans: | |
| 201 | + | t.Fatalf("request while protected %+v", r) | |
| 202 | + | case <-time.After(100 * time.Millisecond): | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func TestHintResultShowsLabels(t *testing.T) { | |
| 207 | + | h := newHint(t, core.Options{}) | |
| 208 | + | var targets []ports.HintTarget | |
| 209 | + | for i := 0; i < 26; i++ { | |
| 210 | + | targets = append(targets, h.inside(i)) | |
| 211 | + | } | |
| 212 | + | s := h.show(targets...) | |
| 213 | + | if len(s.HintLabels) != 26 { | |
| 214 | + | t.Fatalf("%d labels", len(s.HintLabels)) | |
| 215 | + | } | |
| 216 | + | seen := map[string]bool{} | |
| 217 | + | for _, l := range s.HintLabels { | |
| 218 | + | if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] { | |
| 219 | + | t.Fatalf("label %+v", l) | |
| 220 | + | } | |
| 221 | + | seen[l.Text] = true | |
| 222 | + | } | |
| 223 | + | // Sorted by position: the first target gets the first letter. | |
| 224 | + | if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y { | |
| 225 | + | t.Fatalf("first %+v", s.HintLabels[0]) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestHintTwoLetterLabels(t *testing.T) { | |
| 230 | + | h := newHint(t, core.Options{}) | |
| 231 | + | var targets []ports.HintTarget | |
| 232 | + | for i := 0; i < 27; i++ { | |
| 233 | + | targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}}) | |
| 234 | + | } | |
| 235 | + | s := h.show(targets...) | |
| 236 | + | if len(s.HintLabels) != 27 { | |
| 237 | + | t.Fatalf("%d labels", len(s.HintLabels)) | |
| 238 | + | } | |
| 239 | + | seen := map[string]bool{} | |
| 240 | + | for _, l := range s.HintLabels { | |
| 241 | + | if len(l.Text) != 2 || seen[l.Text] { | |
| 242 | + | t.Fatalf("label %+v", l) | |
| 243 | + | } | |
| 244 | + | seen[l.Text] = true | |
| 245 | + | } | |
| 246 | + | if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" { | |
| 247 | + | t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestHintDropsTargetsOutsideWindows(t *testing.T) { | |
| 252 | + | h := newHint(t, core.Options{}) | |
| 253 | + | in := h.inside(0) | |
| 254 | + | // In the gap around the window, over the background. | |
| 255 | + | out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}} | |
| 256 | + | if h.win.X < 8 || h.win.Y < 8 { | |
| 257 | + | t.Fatalf("window %+v leaves no gap", h.win) | |
| 258 | + | } | |
| 259 | + | req := h.start() | |
| 260 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}} | |
| 261 | + | s := h.labelled(1, 0) | |
| 262 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X { | |
| 263 | + | t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestHintTypingFilters(t *testing.T) { | |
| 268 | + | h := newHint(t, core.Options{}) | |
| 269 | + | var targets []ports.HintTarget | |
| 270 | + | for i := 0; i < 27; i++ { | |
| 271 | + | targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}}) | |
| 272 | + | } | |
| 273 | + | h.show(targets...) | |
| 274 | + | h.press("f", 0) | |
| 275 | + | s := h.labelled(26, 1) | |
| 276 | + | if len(s.HintLabels) != 26 { | |
| 277 | + | t.Fatalf("%d labels after f", len(s.HintLabels)) | |
| 278 | + | } | |
| 279 | + | for _, l := range s.HintLabels { | |
| 280 | + | if l.Typed != 1 || l.Text[0] != 'f' { | |
| 281 | + | t.Fatalf("label %+v", l) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | h.press("BackSpace", 0) | |
| 285 | + | h.labelled(27, 0) | |
| 286 | + | // Only the 27th target starts with j: its label is "jf". | |
| 287 | + | h.press("j", 0) | |
| 288 | + | s = h.labelled(1, 1) | |
| 289 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" { | |
| 290 | + | t.Fatalf("after j %+v", s.HintLabels) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestHintClick(t *testing.T) { | |
| 295 | + | h := newHint(t, core.Options{}) | |
| 296 | + | tg := h.inside(0) | |
| 297 | + | h.show(tg) | |
| 298 | + | h.drain() | |
| 299 | + | h.press("f", 0) | |
| 300 | + | s := h.labelled(0, 0) | |
| 301 | + | if len(s.HintLabels) != 0 { | |
| 302 | + | t.Fatalf("labels after click %+v", s.HintLabels) | |
| 303 | + | } | |
| 304 | + | cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X) | |
| 305 | + | cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y) | |
| 306 | + | var gotMotion, gotPress, gotRelease, gotFocus bool | |
| 307 | + | for len(h.commands) > 0 || !gotRelease { | |
| 308 | + | var cmd ports.ClientCommand | |
| 309 | + | select { | |
| 310 | + | case cmd = <-h.commands: | |
| 311 | + | case <-time.After(time.Second): | |
| 312 | + | t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease) | |
| 313 | + | } | |
| 314 | + | switch v := cmd.(type) { | |
| 315 | + | case ports.PointerFocus: | |
| 316 | + | if v.ID == 1 && v.X == cx && v.Y == cy { | |
| 317 | + | gotFocus = true | |
| 318 | + | } | |
| 319 | + | case ports.PointerMotionTo: | |
| 320 | + | if v.ID == 1 && v.X == cx && v.Y == cy { | |
| 321 | + | gotMotion = true | |
| 322 | + | } | |
| 323 | + | case ports.PointerButtonTo: | |
| 324 | + | if v.ID != 1 || v.Button != 0x110 { | |
| 325 | + | t.Fatalf("button %+v", v) | |
| 326 | + | } | |
| 327 | + | if v.Pressed { | |
| 328 | + | gotPress = true | |
| 329 | + | } else { | |
| 330 | + | gotRelease = gotPress | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | if !gotFocus || !gotMotion { | |
| 335 | + | t.Fatalf("focus %v motion %v", gotFocus, gotMotion) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestHintEscapeCancels(t *testing.T) { | |
| 340 | + | h := newHint(t, core.Options{}) | |
| 341 | + | h.show(h.inside(0)) | |
| 342 | + | h.drain() | |
| 343 | + | h.press("Escape", 0) | |
| 344 | + | s := h.labelled(0, 0) | |
| 345 | + | if len(s.HintLabels) != 0 { | |
| 346 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 347 | + | } | |
| 348 | + | h.release("Escape", 0) | |
| 349 | + | // A later key goes to the window again: the Escape release did not. | |
| 350 | + | h.press("x", 0) | |
| 351 | + | cmd := command(t, h.commands) | |
| 352 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |
| 353 | + | t.Fatalf("command %#v", cmd) | |
| 354 | + | } | |
| 355 | + | for len(h.commands) > 0 { | |
| 356 | + | if _, ok := (<-h.commands).(ports.PointerButtonTo); ok { | |
| 357 | + | t.Fatal("button sent") | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func TestHintKeysNotForwarded(t *testing.T) { | |
| 363 | + | h := newHint(t, core.Options{}) | |
| 364 | + | h.show(h.inside(0), h.inside(1)) | |
| 365 | + | h.drain() | |
| 366 | + | // A letter that matches nothing is swallowed and does not cancel. | |
| 367 | + | h.press("q", 0) | |
| 368 | + | h.release("q", 0) | |
| 369 | + | // Any other key cancels and is swallowed. | |
| 370 | + | h.press("Return", 0) | |
| 371 | + | s := h.labelled(0, 0) | |
| 372 | + | if len(s.HintLabels) != 0 { | |
| 373 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 374 | + | } | |
| 375 | + | h.release("Return", 0) | |
| 376 | + | h.press("x", 0) | |
| 377 | + | cmd := command(t, h.commands) | |
| 378 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |
| 379 | + | t.Fatalf("first forwarded command %#v", cmd) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestHintStaleSeqIgnored(t *testing.T) { | |
| 384 | + | h := newHint(t, core.Options{}) | |
| 385 | + | req := h.start() | |
| 386 | + | h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}} | |
| 387 | + | // A matching result afterwards shows: the stale one changed nothing. | |
| 388 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}} | |
| 389 | + | s := h.labelled(2, 0) | |
| 390 | + | if len(s.HintLabels) != 2 { | |
| 391 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestHintErrorCancels(t *testing.T) { | |
| 396 | + | h := newHint(t, core.Options{}) | |
| 397 | + | req := h.start() | |
| 398 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")} | |
| 399 | + | h.press("x", 0) | |
| 400 | + | cmd := command(t, h.commands) | |
| 401 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |
| 402 | + | t.Fatalf("command %#v", cmd) | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func TestHintWindowMovedAborts(t *testing.T) { | |
| 407 | + | h := newHint(t, core.Options{}) | |
| 408 | + | tg := h.inside(0) | |
| 409 | + | h.show(tg) | |
| 410 | + | // The window closes before the label is typed: the click must not go | |
| 411 | + | // anywhere. | |
| 412 | + | h.client <- ports.WindowUnmapped{ID: 1} | |
| 413 | + | h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 }) | |
| 414 | + | h.drain() | |
| 415 | + | h.press("f", 0) | |
| 416 | + | // With no window left a key goes nowhere; the next bind press, answered | |
| 417 | + | // by a scan request, shows core handled the label key before it. | |
| 418 | + | h.press("g", ports.ModSuper) | |
| 419 | + | receive(t, h.scans) | |
| 420 | + | for len(h.commands) > 0 { | |
| 421 | + | if _, ok := (<-h.commands).(ports.PointerButtonTo); ok { | |
| 422 | + | t.Fatal("button sent to a moved window") | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // barrier returns the commands core sent for everything given to it so far: | |
| 428 | + | // a marker key goes through the same input channel and owner, and is | |
| 429 | + | // forwarded to the focused window after the commands of the earlier events. | |
| 430 | + | func (h *hintHarness) barrier() []ports.ClientCommand { | |
| 431 | + | h.t.Helper() | |
| 432 | + | h.press("marker", 0) | |
| 433 | + | var out []ports.ClientCommand | |
| 434 | + | for { | |
| 435 | + | cmd := command(h.t, h.commands) | |
| 436 | + | if k, ok := cmd.(ports.ForwardKey); ok && k.Key.Keysym == "marker" { | |
| 437 | + | return out | |
| 438 | + | } | |
| 439 | + | out = append(out, cmd) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // buttonsSent drains the commands and counts pointer button commands. | |
| 444 | + | func (h *hintHarness) buttonsSent() (presses, releases int) { | |
| 445 | + | for _, cmd := range h.barrier() { | |
| 446 | + | if b, ok := cmd.(ports.PointerButtonTo); ok { | |
| 447 | + | if b.Pressed { | |
| 448 | + | presses++ | |
| 449 | + | } else { | |
| 450 | + | releases++ | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | return presses, releases | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // The bind's modifier is still held while the label is typed: the synthetic | |
| 458 | + | // click must reach the client, not start a Cmd+left-button window drag. | |
| 459 | + | func TestHintClickWithBindModifierHeld(t *testing.T) { | |
| 460 | + | h := newHint(t, core.Options{}) | |
| 461 | + | tg := h.inside(0) | |
| 462 | + | h.press("g", ports.ModSuper) | |
| 463 | + | req := receive(t, h.scans) | |
| 464 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |
| 465 | + | h.labelled(1, 0) | |
| 466 | + | h.drain() | |
| 467 | + | h.press("f", ports.ModSuper) // Super is still down | |
| 468 | + | h.labelled(0, 0) | |
| 469 | + | presses, releases := h.buttonsSent() | |
| 470 | + | if presses != 1 || releases != 1 { | |
| 471 | + | t.Fatalf("client got %d presses, %d releases", presses, releases) | |
| 472 | + | } | |
| 473 | + | // No drag is left behind: a later plain motion and a click work as usual. | |
| 474 | + | h.release("f", ports.ModSuper) | |
| 475 | + | h.release("Super_L", 0) | |
| 476 | + | h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)}) | |
| 477 | + | h.send(ports.PointerButton{Button: 0x110, Pressed: true}) | |
| 478 | + | h.send(ports.PointerButton{Button: 0x110}) | |
| 479 | + | if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 { | |
| 480 | + | t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases) | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // constrain gives window 1 a pointer constraint and waits until core applied it. | |
| 485 | + | func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) { | |
| 486 | + | h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}} | |
| 487 | + | h.client <- ports.WindowMapped{ID: 1} // a publish after the event | |
| 488 | + | h.until(func(ports.Scene) bool { return true }) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func TestHintRefusedWithPointerConstraint(t *testing.T) { | |
| 492 | + | for name, c := range map[string]ports.PointerConstraint{ | |
| 493 | + | "lock": {Mode: ports.ConstraintLock}, | |
| 494 | + | "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}}, | |
| 495 | + | } { | |
| 496 | + | t.Run(name, func(t *testing.T) { | |
| 497 | + | h := newHint(t, core.Options{}) | |
| 498 | + | h.constrain(c.Mode, c.Rect) | |
| 499 | + | h.press("g", ports.ModSuper) | |
| 500 | + | select { | |
| 501 | + | case r := <-h.scans: | |
| 502 | + | t.Fatalf("request under a %s: %+v", name, r) | |
| 503 | + | case <-time.After(100 * time.Millisecond): | |
| 504 | + | } | |
| 505 | + | }) | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // A constraint that appears while the labels are shown (the pointer would be | |
| 510 | + | // held or bent away from the target) cancels the click. | |
| 511 | + | func TestHintNoClickWhenConstraintAppears(t *testing.T) { | |
| 512 | + | for name, c := range map[string]ports.PointerConstraint{ | |
| 513 | + | "lock": {Mode: ports.ConstraintLock}, | |
| 514 | + | "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}}, | |
| 515 | + | } { | |
| 516 | + | t.Run(name, func(t *testing.T) { | |
| 517 | + | h := newHint(t, core.Options{}) | |
| 518 | + | h.show(h.inside(0)) | |
| 519 | + | h.constrain(c.Mode, c.Rect) | |
| 520 | + | h.drain() | |
| 521 | + | h.press("f", 0) | |
| 522 | + | if p, r := h.buttonsSent(); p+r != 0 { | |
| 523 | + | t.Fatalf("%d presses, %d releases under a %s", p, r, name) | |
| 524 | + | } | |
| 525 | + | }) | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | // pressReal sends a real left press over window 1 and waits for the client to | |
| 530 | + | // see it: the window then holds the pointer grab. | |
| 531 | + | func (h *hintHarness) pressReal() { | |
| 532 | + | h.t.Helper() | |
| 533 | + | h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)}) | |
| 534 | + | h.send(ports.PointerButton{Button: 0x110, Pressed: true}) | |
| 535 | + | for { | |
| 536 | + | cmd := command(h.t, h.commands) | |
| 537 | + | if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed { | |
| 538 | + | return | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func TestHintRefusedWhileButtonHeld(t *testing.T) { | |
| 544 | + | h := newHint(t, core.Options{}) | |
| 545 | + | h.pressReal() | |
| 546 | + | h.press("g", ports.ModSuper) | |
| 547 | + | select { | |
| 548 | + | case r := <-h.scans: | |
| 549 | + | t.Fatalf("request with a button held: %+v", r) | |
| 550 | + | case <-time.After(100 * time.Millisecond): | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // A button pressed (and so a grab taken) after the labels appeared: the label | |
| 555 | + | // is typed, no synthetic button goes out and the real press stays held. | |
| 556 | + | func TestHintNoClickWhileGrabbed(t *testing.T) { | |
| 557 | + | h := newHint(t, core.Options{}) | |
| 558 | + | h.show(h.inside(0)) | |
| 559 | + | h.pressReal() | |
| 560 | + | h.press("f", 0) | |
| 561 | + | if p, r := h.buttonsSent(); p+r != 0 { | |
| 562 | + | t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r) | |
| 563 | + | } | |
| 564 | + | // The real release still reaches the client: c.buttons was not touched. | |
| 565 | + | h.send(ports.PointerButton{Button: 0x110}) | |
| 566 | + | if _, r := h.buttonsSent(); r != 1 { | |
| 567 | + | t.Fatalf("real release delivered %d times", r) | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // The window stays mapped but moves away from the target's centre. | |
| 572 | + | func TestHintMappedWindowMovedAborts(t *testing.T) { | |
| 573 | + | h := newHint(t, core.Options{}) | |
| 574 | + | // A target at the right edge of window 1. | |
| 575 | + | tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}} | |
| 576 | + | h.show(tg) | |
| 577 | + | h.client <- ports.WindowMapped{ID: 2} | |
| 578 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 }) | |
| 579 | + | if s.Windows[0].Rect == h.win { | |
| 580 | + | t.Fatalf("window 1 did not move: %+v", s.Windows) | |
| 581 | + | } | |
| 582 | + | h.drain() | |
| 583 | + | h.press("f", 0) | |
| 584 | + | if p, r := h.buttonsSent(); p+r != 0 { | |
| 585 | + | t.Fatalf("click went to %d presses %d releases after the window moved", p, r) | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | // commandsSent drains the commands up to a barrier, keeping the ones that | |
| 590 | + | // matter for a click: modifiers, focus, motion and buttons. | |
| 591 | + | func (h *hintHarness) commandsSent() []ports.ClientCommand { | |
| 592 | + | var out []ports.ClientCommand | |
| 593 | + | for _, cmd := range h.barrier() { | |
| 594 | + | switch cmd.(type) { | |
| 595 | + | case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo: | |
| 596 | + | out = append(out, cmd) | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | return out | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func commandKinds(cmds []ports.ClientCommand) []string { | |
| 603 | + | var out []string | |
| 604 | + | for _, c := range cmds { | |
| 605 | + | switch v := c.(type) { | |
| 606 | + | case ports.SetModifiers: | |
| 607 | + | if v.Restore { | |
| 608 | + | out = append(out, "restore") | |
| 609 | + | } else { | |
| 610 | + | out = append(out, fmt.Sprintf("mods:%d", v.Mods)) | |
| 611 | + | } | |
| 612 | + | case ports.FocusWindow: | |
| 613 | + | out = append(out, fmt.Sprintf("focus:%d", v.ID)) | |
| 614 | + | case ports.PointerMotionTo: | |
| 615 | + | out = append(out, "motion") | |
| 616 | + | case ports.PointerButtonTo: | |
| 617 | + | if v.Pressed { | |
| 618 | + | out = append(out, "press") | |
| 619 | + | } else { | |
| 620 | + | out = append(out, "release") | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | return out | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | // An OCR target is clicked with the configured modifiers held: they go out | |
| 628 | + | // before the motion and are restored after the release. | |
| 629 | + | func TestHintTextClickHoldsModifiers(t *testing.T) { | |
| 630 | + | mods := ports.ModCtrl | ports.ModShift | |
| 631 | + | h := newHintMods(t, core.Options{}, mods) | |
| 632 | + | tg := h.inside(0) | |
| 633 | + | tg.Text = true | |
| 634 | + | h.show(tg) | |
| 635 | + | h.drain() | |
| 636 | + | h.press("f", 0) | |
| 637 | + | got := commandKinds(h.commandsSent()) | |
| 638 | + | want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"} | |
| 639 | + | if !slices.Equal(got, want) { | |
| 640 | + | t.Fatalf("commands %v, want %v", got, want) | |
| 641 | + | } | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | // Without text targets or without modifiers the click states no modifiers | |
| 645 | + | // (depressed 0) rather than leave the held bind modifier to the client. | |
| 646 | + | func TestHintClickWithoutModifiers(t *testing.T) { | |
| 647 | + | for name, c := range map[string]struct { | |
| 648 | + | mods ports.Mods | |
| 649 | + | text bool | |
| 650 | + | }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} { | |
| 651 | + | t.Run(name, func(t *testing.T) { | |
| 652 | + | h := newHintMods(t, core.Options{}, c.mods) | |
| 653 | + | tg := h.inside(0) | |
| 654 | + | tg.Text = c.text | |
| 655 | + | h.show(tg) | |
| 656 | + | h.drain() | |
| 657 | + | h.press("f", 0) | |
| 658 | + | got := commandKinds(h.commandsSent()) | |
| 659 | + | if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) { | |
| 660 | + | t.Fatalf("commands %v, want %v", got, want) | |
| 661 | + | } | |
| 662 | + | }) | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | // The bind's Super is still physically held: an accessibility click states | |
| 667 | + | // depressed 0, and the restore carries the real state of the latest key, | |
| 668 | + | // swallowed ones included (a latch a label key consumed). | |
| 669 | + | func TestHintClickHeldBindModifierAndLatchRestore(t *testing.T) { | |
| 670 | + | h := newHint(t, core.Options{}) | |
| 671 | + | tg := h.inside(0) | |
| 672 | + | h.press("g", ports.ModSuper) | |
| 673 | + | req := receive(t, h.scans) | |
| 674 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg, h.inside(1)}} | |
| 675 | + | h.labelled(2, 0) | |
| 676 | + | h.drain() | |
| 677 | + | real := ports.ModState{Depressed: 64, Latched: 0, Locked: 2} | |
| 678 | + | h.send(ports.KeyEvent{Keysym: "f", Base: "f", Mods: ports.ModSuper, Pressed: true, State: real}) | |
| 679 | + | cmds := h.commandsSent() | |
| 680 | + | var restore *ports.SetModifiers | |
| 681 | + | for i, c := range cmds { | |
| 682 | + | if v, ok := c.(ports.SetModifiers); ok { | |
| 683 | + | if i == 0 && (v.Restore || v.Mods != 0) { | |
| 684 | + | t.Fatalf("first modifiers %+v, want depressed 0", v) | |
| 685 | + | } | |
| 686 | + | if v.Restore { | |
| 687 | + | restore = &v | |
| 688 | + | } | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | if restore == nil || restore.State != real { | |
| 692 | + | t.Fatalf("restore %+v, want state %+v (commands %v)", restore, real, commandKinds(cmds)) | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | // A result is kept only for the window it was scanned on. | |
| 697 | + | func TestHintResultWindowChecks(t *testing.T) { | |
| 698 | + | h := newHint(t, core.Options{}) | |
| 699 | + | req := h.start() | |
| 700 | + | // Accessibility target naming another window: dropped; naming window 1: kept. | |
| 701 | + | ok := h.inside(1) | |
| 702 | + | ok.Window = 1 | |
| 703 | + | bad := h.inside(0) | |
| 704 | + | bad.Window = 7 | |
| 705 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{bad, ok}} | |
| 706 | + | s := h.labelled(1, 0) | |
| 707 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.Y != ok.Rect.Y { | |
| 708 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 709 | + | } | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | // A window that moved while the scan ran: an OCR target over it is dropped. | |
| 713 | + | func TestHintOCRResultDroppedWhenWindowMoved(t *testing.T) { | |
| 714 | + | h := newHint(t, core.Options{}) | |
| 715 | + | req := h.start() | |
| 716 | + | tg := h.inside(0) | |
| 717 | + | tg.Text = true | |
| 718 | + | h.client <- ports.WindowMapped{ID: 2} | |
| 719 | + | h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 720 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |
| 721 | + | // Whatever window now covers the target was not scanned: no labels. | |
| 722 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |
| 723 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 724 | + | } | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | // A new window covering the target (not in the snapshot) drops it as well. | |
| 728 | + | func TestHintOCRResultDroppedForUnseenWindow(t *testing.T) { | |
| 729 | + | h := newHint(t, core.Options{}) | |
| 730 | + | req := h.start() | |
| 731 | + | h.client <- ports.WindowMapped{ID: 2} | |
| 732 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 733 | + | tg := ports.HintTarget{Text: true} | |
| 734 | + | for _, w := range s.Windows { | |
| 735 | + | if w.ID == 2 { | |
| 736 | + | tg.Rect = ports.Rect{X: w.Rect.X + 5, Y: w.Rect.Y + 5, W: 20, H: 10} | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |
| 740 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |
| 741 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | // Targets outside the output are dropped. | |
| 746 | + | func TestHintDropsTargetsOutsideOutput(t *testing.T) { | |
| 747 | + | h := newHint(t, core.Options{}) | |
| 748 | + | req := h.start() | |
| 749 | + | out := ports.HintTarget{Rect: ports.Rect{X: 2000, Y: 10, W: 10, H: 10}} | |
| 750 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, h.inside(0)}} | |
| 751 | + | if s := h.labelled(1, 0); len(s.HintLabels) != 1 { | |
| 752 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // The output changing size while labels are shown ends hint mode. | |
| 757 | + | func TestHintCancelsWhenOutputChanges(t *testing.T) { | |
| 758 | + | h := newHint(t, core.Options{}) | |
| 759 | + | h.show(h.inside(0)) | |
| 760 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1200, Height: 800}} | |
| 761 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |
| 762 | + | t.Fatalf("labels %+v", s.HintLabels) | |
| 763 | + | } | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // A window that does not have the keyboard gets it before the modifiers. | |
| 767 | + | func TestHintTextClickFocusesWindowFirst(t *testing.T) { | |
| 768 | + | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |
| 769 | + | h.client <- ports.WindowMapped{ID: 2} | |
| 770 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 771 | + | var r ports.Rect | |
| 772 | + | for _, w := range s.Windows { | |
| 773 | + | if w.ID == 1 { | |
| 774 | + | r = w.Rect | |
| 775 | + | } | |
| 776 | + | } | |
| 777 | + | h.win = r | |
| 778 | + | tg := h.inside(0) | |
| 779 | + | tg.Text = true | |
| 780 | + | h.show(tg) | |
| 781 | + | h.drain() | |
| 782 | + | h.press("f", 0) | |
| 783 | + | got := commandKinds(h.commandsSent()) | |
| 784 | + | want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"} | |
| 785 | + | if !slices.Equal(got, want) { | |
| 786 | + | t.Fatalf("commands %v, want %v", got, want) | |
| 787 | + | } | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // A click that is no longer allowed sends no modifiers at all. | |
| 791 | + | func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) { | |
| 792 | + | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |
| 793 | + | tg := h.inside(0) | |
| 794 | + | tg.Text = true | |
| 795 | + | h.show(tg) | |
| 796 | + | h.constrain(ports.ConstraintLock, ports.Rect{}) | |
| 797 | + | h.drain() | |
| 798 | + | h.press("f", 0) | |
| 799 | + | if got := commandKinds(h.commandsSent()); len(got) != 0 { | |
| 800 | + | t.Fatalf("commands %v under a constraint", got) | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | func TestHintDisabledNotesOnce(t *testing.T) { | |
| 805 | + | cfg := scrollDefaults() | |
| 806 | + | cfg.Binds["Cmd+g"] = "hint" | |
| 807 | + | var noted atomic.Int32 | |
| 808 | + | h := &hintHarness{ | |
| 809 | + | input: make(chan ports.InputEvent, 32), | |
| 810 | + | client: make(chan ports.ClientEvent, 32), | |
| 811 | + | output: make(chan ports.OutputEvent, 8), | |
| 812 | + | commands: make(chan ports.ClientCommand, 256), | |
| 813 | + | scenes: make(chan []ports.Scene, 1), | |
| 814 | + | } | |
| 815 | + | ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes} | |
| 816 | + | c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }}) | |
| 817 | + | if err != nil { | |
| 818 | + | t.Fatal(err) | |
| 819 | + | } | |
| 820 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 821 | + | done := make(chan error, 1) | |
| 822 | + | go func() { done <- c.Run(ctx) }() | |
| 823 | + | t.Cleanup(func() { cancel(); receive(t, done) }) | |
| 824 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |
| 825 | + | scene(t, h.scenes) | |
| 826 | + | for range 3 { | |
| 827 | + | h.press("g", ports.ModSuper) | |
| 828 | + | h.release("g", 0) | |
| 829 | + | } | |
| 830 | + | c.Ping(ctx) | |
| 831 | + | if got := noted.Load(); got != 1 { | |
| 832 | + | t.Fatalf("disabled note ran %d times, want 1", got) | |
| 833 | + | } | |
| 834 | + | } | |
| @@ -0,0 +1,834 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "slices" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferwl/internal/core" | |||
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type hintHarness struct { | |||
| 17 | + | t *testing.T | |||
| 18 | + | input chan ports.InputEvent | |||
| 19 | + | client chan ports.ClientEvent | |||
| 20 | + | output chan ports.OutputEvent | |||
| 21 | + | commands chan ports.ClientCommand | |||
| 22 | + | scenes chan []ports.Scene | |||
| 23 | + | scans chan ports.HintScanRequest | |||
| 24 | + | hints chan ports.HintScanResult | |||
| 25 | + | win ports.Rect // window 1, output-local | |||
| 26 | + | // state is the security state input is stamped with. | |||
| 27 | + | state ports.SecurityState | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped. | |||
| 31 | + | func newHint(t *testing.T, opts core.Options) *hintHarness { | |||
| 32 | + | t.Helper() | |||
| 33 | + | return newHintMods(t, opts, 0) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // newHintMods is newHint with hint.text-click set to textClick. | |||
| 37 | + | func newHintMods(t *testing.T, opts core.Options, textClick ports.Mods) *hintHarness { | |||
| 38 | + | t.Helper() | |||
| 39 | + | cfg := scrollDefaults() | |||
| 40 | + | cfg.Hint.TextClick = textClick | |||
| 41 | + | cfg.Border.Width = 0 | |||
| 42 | + | cfg.Layout.Gaps = 20 | |||
| 43 | + | cfg.Binds["Cmd+g"] = "hint" | |||
| 44 | + | h := &hintHarness{ | |||
| 45 | + | t: t, | |||
| 46 | + | input: make(chan ports.InputEvent, 32), | |||
| 47 | + | client: make(chan ports.ClientEvent, 32), | |||
| 48 | + | output: make(chan ports.OutputEvent, 8), | |||
| 49 | + | commands: make(chan ports.ClientCommand, 256), | |||
| 50 | + | scenes: make(chan []ports.Scene, 1), | |||
| 51 | + | scans: make(chan ports.HintScanRequest, 1), | |||
| 52 | + | // Unbuffered: a send returns once core took the result. | |||
| 53 | + | hints: make(chan ports.HintScanResult), | |||
| 54 | + | } | |||
| 55 | + | ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints} | |||
| 56 | + | c, err := core.New(cfg, ch, opts) | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 61 | + | done := make(chan error, 1) | |||
| 62 | + | go func() { done <- c.Run(ctx) }() | |||
| 63 | + | t.Cleanup(func() { cancel(); receive(t, done) }) | |||
| 64 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |||
| 65 | + | scene(t, h.scenes) | |||
| 66 | + | h.client <- ports.WindowMapped{ID: 1, AppID: "org.test.app", PID: 4242} | |||
| 67 | + | s := scene(t, h.scenes) | |||
| 68 | + | h.win = s.Windows[0].Rect | |||
| 69 | + | h.drain() | |||
| 70 | + | return h | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (h *hintHarness) drain() { | |||
| 74 | + | for { | |||
| 75 | + | select { | |||
| 76 | + | case <-h.commands: | |||
| 77 | + | default: | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // press and release send one key event. | |||
| 84 | + | func (h *hintHarness) press(sym string, mods ports.Mods) { | |||
| 85 | + | h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true}) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (h *hintHarness) release(sym string, mods ports.Mods) { | |||
| 89 | + | h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods}) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // send gives core an input event stamped with the current security state. | |||
| 93 | + | func (h *hintHarness) send(ev ports.InputEvent) { | |||
| 94 | + | h.input <- ports.SecurityInput{State: h.state, Event: ev} | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // start presses the hint bind and returns the request. | |||
| 98 | + | func (h *hintHarness) start() ports.HintScanRequest { | |||
| 99 | + | h.t.Helper() | |||
| 100 | + | h.press("g", ports.ModSuper) | |||
| 101 | + | req := receive(h.t, h.scans) | |||
| 102 | + | h.release("g", 0) // the user lets go of the whole chord | |||
| 103 | + | return req | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // show starts hint mode and answers with targets, returning the next scene. | |||
| 107 | + | func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene { | |||
| 108 | + | h.t.Helper() | |||
| 109 | + | req := h.start() | |||
| 110 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets} | |||
| 111 | + | return h.labelled(len(targets), 0) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // until returns the first scene, from the next one on, that satisfies ok: | |||
| 115 | + | // the bind press and other input publish scenes before the one waited for. | |||
| 116 | + | func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene { | |||
| 117 | + | h.t.Helper() | |||
| 118 | + | for { | |||
| 119 | + | if s := scene(h.t, h.scenes); ok(s) { | |||
| 120 | + | return s | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // labelled waits for a scene with n labels, all typed with typed letters. | |||
| 126 | + | func (h *hintHarness) labelled(n, typed int) ports.Scene { | |||
| 127 | + | h.t.Helper() | |||
| 128 | + | return h.until(func(s ports.Scene) bool { | |||
| 129 | + | return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed) | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // inside is a target of the given size inside window 1. | |||
| 134 | + | func (h *hintHarness) inside(i int) ports.HintTarget { | |||
| 135 | + | return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}} | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestHintRequestOnBind(t *testing.T) { | |||
| 139 | + | h := newHint(t, core.Options{}) | |||
| 140 | + | req := h.start() | |||
| 141 | + | if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 { | |||
| 142 | + | t.Fatalf("request %+v", req) | |||
| 143 | + | } | |||
| 144 | + | want := []ports.HintWindow{{ID: 1, PID: 4242, AppID: "org.test.app", Rect: h.win, Focused: true}} | |||
| 145 | + | if !slices.Equal(req.Windows, want) { | |||
| 146 | + | t.Fatalf("windows %+v, want %+v", req.Windows, want) | |||
| 147 | + | } | |||
| 148 | + | // A second bind while pending does not ask again; the pending phase | |||
| 149 | + | // cancels on any key. | |||
| 150 | + | h.press("g", ports.ModSuper) | |||
| 151 | + | select { | |||
| 152 | + | case r := <-h.scans: | |||
| 153 | + | t.Fatalf("second request %+v", r) | |||
| 154 | + | case <-time.After(50 * time.Millisecond): | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestHintRequestNilScansIsNoop(t *testing.T) { | |||
| 159 | + | cfg := scrollDefaults() | |||
| 160 | + | cfg.Binds["Cmd+g"] = "hint" | |||
| 161 | + | input := make(chan ports.InputEvent, 4) | |||
| 162 | + | client := make(chan ports.ClientEvent, 4) | |||
| 163 | + | output := make(chan ports.OutputEvent, 4) | |||
| 164 | + | commands := make(chan ports.ClientCommand, 32) | |||
| 165 | + | scenes := make(chan []ports.Scene, 1) | |||
| 166 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{}) | |||
| 167 | + | if err != nil { | |||
| 168 | + | t.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 171 | + | done := make(chan error, 1) | |||
| 172 | + | go func() { done <- c.Run(ctx) }() | |||
| 173 | + | defer func() { cancel(); receive(t, done) }() | |||
| 174 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |||
| 175 | + | scene(t, scenes) | |||
| 176 | + | input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true} | |||
| 177 | + | if s := scene(t, scenes); len(s.HintLabels) != 0 { | |||
| 178 | + | t.Fatal(s.HintLabels) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHintRefusedWhenProtected(t *testing.T) { | |||
| 183 | + | var state atomic.Pointer[ports.SecurityState] | |||
| 184 | + | state.Store(&ports.SecurityState{}) | |||
| 185 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 186 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe() | |||
| 187 | + | h := newHint(t, core.Options{Security: gate}) | |||
| 188 | + | // Hint mode shown, then the session locks: the labels go away. | |||
| 189 | + | h.show(h.inside(0)) | |||
| 190 | + | state.Store(&ports.SecurityState{Generation: 2, Protected: true}) | |||
| 191 | + | h.state = *state.Load() | |||
| 192 | + | h.client <- ports.WindowMapped{ID: 2} | |||
| 193 | + | s := h.until(func(s ports.Scene) bool { return s.Security.Protected }) | |||
| 194 | + | if len(s.HintLabels) != 0 { | |||
| 195 | + | t.Fatalf("labels while protected %+v", s.HintLabels) | |||
| 196 | + | } | |||
| 197 | + | // And a new request is refused while it lasts. | |||
| 198 | + | h.press("g", ports.ModSuper) | |||
| 199 | + | select { | |||
| 200 | + | case r := <-h.scans: | |||
| 201 | + | t.Fatalf("request while protected %+v", r) | |||
| 202 | + | case <-time.After(100 * time.Millisecond): | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func TestHintResultShowsLabels(t *testing.T) { | |||
| 207 | + | h := newHint(t, core.Options{}) | |||
| 208 | + | var targets []ports.HintTarget | |||
| 209 | + | for i := 0; i < 26; i++ { | |||
| 210 | + | targets = append(targets, h.inside(i)) | |||
| 211 | + | } | |||
| 212 | + | s := h.show(targets...) | |||
| 213 | + | if len(s.HintLabels) != 26 { | |||
| 214 | + | t.Fatalf("%d labels", len(s.HintLabels)) | |||
| 215 | + | } | |||
| 216 | + | seen := map[string]bool{} | |||
| 217 | + | for _, l := range s.HintLabels { | |||
| 218 | + | if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] { | |||
| 219 | + | t.Fatalf("label %+v", l) | |||
| 220 | + | } | |||
| 221 | + | seen[l.Text] = true | |||
| 222 | + | } | |||
| 223 | + | // Sorted by position: the first target gets the first letter. | |||
| 224 | + | if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y { | |||
| 225 | + | t.Fatalf("first %+v", s.HintLabels[0]) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestHintTwoLetterLabels(t *testing.T) { | |||
| 230 | + | h := newHint(t, core.Options{}) | |||
| 231 | + | var targets []ports.HintTarget | |||
| 232 | + | for i := 0; i < 27; i++ { | |||
| 233 | + | targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}}) | |||
| 234 | + | } | |||
| 235 | + | s := h.show(targets...) | |||
| 236 | + | if len(s.HintLabels) != 27 { | |||
| 237 | + | t.Fatalf("%d labels", len(s.HintLabels)) | |||
| 238 | + | } | |||
| 239 | + | seen := map[string]bool{} | |||
| 240 | + | for _, l := range s.HintLabels { | |||
| 241 | + | if len(l.Text) != 2 || seen[l.Text] { | |||
| 242 | + | t.Fatalf("label %+v", l) | |||
| 243 | + | } | |||
| 244 | + | seen[l.Text] = true | |||
| 245 | + | } | |||
| 246 | + | if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" { | |||
| 247 | + | t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestHintDropsTargetsOutsideWindows(t *testing.T) { | |||
| 252 | + | h := newHint(t, core.Options{}) | |||
| 253 | + | in := h.inside(0) | |||
| 254 | + | // In the gap around the window, over the background. | |||
| 255 | + | out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}} | |||
| 256 | + | if h.win.X < 8 || h.win.Y < 8 { | |||
| 257 | + | t.Fatalf("window %+v leaves no gap", h.win) | |||
| 258 | + | } | |||
| 259 | + | req := h.start() | |||
| 260 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}} | |||
| 261 | + | s := h.labelled(1, 0) | |||
| 262 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X { | |||
| 263 | + | t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestHintTypingFilters(t *testing.T) { | |||
| 268 | + | h := newHint(t, core.Options{}) | |||
| 269 | + | var targets []ports.HintTarget | |||
| 270 | + | for i := 0; i < 27; i++ { | |||
| 271 | + | targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}}) | |||
| 272 | + | } | |||
| 273 | + | h.show(targets...) | |||
| 274 | + | h.press("f", 0) | |||
| 275 | + | s := h.labelled(26, 1) | |||
| 276 | + | if len(s.HintLabels) != 26 { | |||
| 277 | + | t.Fatalf("%d labels after f", len(s.HintLabels)) | |||
| 278 | + | } | |||
| 279 | + | for _, l := range s.HintLabels { | |||
| 280 | + | if l.Typed != 1 || l.Text[0] != 'f' { | |||
| 281 | + | t.Fatalf("label %+v", l) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | h.press("BackSpace", 0) | |||
| 285 | + | h.labelled(27, 0) | |||
| 286 | + | // Only the 27th target starts with j: its label is "jf". | |||
| 287 | + | h.press("j", 0) | |||
| 288 | + | s = h.labelled(1, 1) | |||
| 289 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" { | |||
| 290 | + | t.Fatalf("after j %+v", s.HintLabels) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestHintClick(t *testing.T) { | |||
| 295 | + | h := newHint(t, core.Options{}) | |||
| 296 | + | tg := h.inside(0) | |||
| 297 | + | h.show(tg) | |||
| 298 | + | h.drain() | |||
| 299 | + | h.press("f", 0) | |||
| 300 | + | s := h.labelled(0, 0) | |||
| 301 | + | if len(s.HintLabels) != 0 { | |||
| 302 | + | t.Fatalf("labels after click %+v", s.HintLabels) | |||
| 303 | + | } | |||
| 304 | + | cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X) | |||
| 305 | + | cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y) | |||
| 306 | + | var gotMotion, gotPress, gotRelease, gotFocus bool | |||
| 307 | + | for len(h.commands) > 0 || !gotRelease { | |||
| 308 | + | var cmd ports.ClientCommand | |||
| 309 | + | select { | |||
| 310 | + | case cmd = <-h.commands: | |||
| 311 | + | case <-time.After(time.Second): | |||
| 312 | + | t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease) | |||
| 313 | + | } | |||
| 314 | + | switch v := cmd.(type) { | |||
| 315 | + | case ports.PointerFocus: | |||
| 316 | + | if v.ID == 1 && v.X == cx && v.Y == cy { | |||
| 317 | + | gotFocus = true | |||
| 318 | + | } | |||
| 319 | + | case ports.PointerMotionTo: | |||
| 320 | + | if v.ID == 1 && v.X == cx && v.Y == cy { | |||
| 321 | + | gotMotion = true | |||
| 322 | + | } | |||
| 323 | + | case ports.PointerButtonTo: | |||
| 324 | + | if v.ID != 1 || v.Button != 0x110 { | |||
| 325 | + | t.Fatalf("button %+v", v) | |||
| 326 | + | } | |||
| 327 | + | if v.Pressed { | |||
| 328 | + | gotPress = true | |||
| 329 | + | } else { | |||
| 330 | + | gotRelease = gotPress | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | if !gotFocus || !gotMotion { | |||
| 335 | + | t.Fatalf("focus %v motion %v", gotFocus, gotMotion) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestHintEscapeCancels(t *testing.T) { | |||
| 340 | + | h := newHint(t, core.Options{}) | |||
| 341 | + | h.show(h.inside(0)) | |||
| 342 | + | h.drain() | |||
| 343 | + | h.press("Escape", 0) | |||
| 344 | + | s := h.labelled(0, 0) | |||
| 345 | + | if len(s.HintLabels) != 0 { | |||
| 346 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 347 | + | } | |||
| 348 | + | h.release("Escape", 0) | |||
| 349 | + | // A later key goes to the window again: the Escape release did not. | |||
| 350 | + | h.press("x", 0) | |||
| 351 | + | cmd := command(t, h.commands) | |||
| 352 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |||
| 353 | + | t.Fatalf("command %#v", cmd) | |||
| 354 | + | } | |||
| 355 | + | for len(h.commands) > 0 { | |||
| 356 | + | if _, ok := (<-h.commands).(ports.PointerButtonTo); ok { | |||
| 357 | + | t.Fatal("button sent") | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func TestHintKeysNotForwarded(t *testing.T) { | |||
| 363 | + | h := newHint(t, core.Options{}) | |||
| 364 | + | h.show(h.inside(0), h.inside(1)) | |||
| 365 | + | h.drain() | |||
| 366 | + | // A letter that matches nothing is swallowed and does not cancel. | |||
| 367 | + | h.press("q", 0) | |||
| 368 | + | h.release("q", 0) | |||
| 369 | + | // Any other key cancels and is swallowed. | |||
| 370 | + | h.press("Return", 0) | |||
| 371 | + | s := h.labelled(0, 0) | |||
| 372 | + | if len(s.HintLabels) != 0 { | |||
| 373 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 374 | + | } | |||
| 375 | + | h.release("Return", 0) | |||
| 376 | + | h.press("x", 0) | |||
| 377 | + | cmd := command(t, h.commands) | |||
| 378 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |||
| 379 | + | t.Fatalf("first forwarded command %#v", cmd) | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func TestHintStaleSeqIgnored(t *testing.T) { | |||
| 384 | + | h := newHint(t, core.Options{}) | |||
| 385 | + | req := h.start() | |||
| 386 | + | h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}} | |||
| 387 | + | // A matching result afterwards shows: the stale one changed nothing. | |||
| 388 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}} | |||
| 389 | + | s := h.labelled(2, 0) | |||
| 390 | + | if len(s.HintLabels) != 2 { | |||
| 391 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestHintErrorCancels(t *testing.T) { | |||
| 396 | + | h := newHint(t, core.Options{}) | |||
| 397 | + | req := h.start() | |||
| 398 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")} | |||
| 399 | + | h.press("x", 0) | |||
| 400 | + | cmd := command(t, h.commands) | |||
| 401 | + | if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" { | |||
| 402 | + | t.Fatalf("command %#v", cmd) | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestHintWindowMovedAborts(t *testing.T) { | |||
| 407 | + | h := newHint(t, core.Options{}) | |||
| 408 | + | tg := h.inside(0) | |||
| 409 | + | h.show(tg) | |||
| 410 | + | // The window closes before the label is typed: the click must not go | |||
| 411 | + | // anywhere. | |||
| 412 | + | h.client <- ports.WindowUnmapped{ID: 1} | |||
| 413 | + | h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 }) | |||
| 414 | + | h.drain() | |||
| 415 | + | h.press("f", 0) | |||
| 416 | + | // With no window left a key goes nowhere; the next bind press, answered | |||
| 417 | + | // by a scan request, shows core handled the label key before it. | |||
| 418 | + | h.press("g", ports.ModSuper) | |||
| 419 | + | receive(t, h.scans) | |||
| 420 | + | for len(h.commands) > 0 { | |||
| 421 | + | if _, ok := (<-h.commands).(ports.PointerButtonTo); ok { | |||
| 422 | + | t.Fatal("button sent to a moved window") | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | // barrier returns the commands core sent for everything given to it so far: | |||
| 428 | + | // a marker key goes through the same input channel and owner, and is | |||
| 429 | + | // forwarded to the focused window after the commands of the earlier events. | |||
| 430 | + | func (h *hintHarness) barrier() []ports.ClientCommand { | |||
| 431 | + | h.t.Helper() | |||
| 432 | + | h.press("marker", 0) | |||
| 433 | + | var out []ports.ClientCommand | |||
| 434 | + | for { | |||
| 435 | + | cmd := command(h.t, h.commands) | |||
| 436 | + | if k, ok := cmd.(ports.ForwardKey); ok && k.Key.Keysym == "marker" { | |||
| 437 | + | return out | |||
| 438 | + | } | |||
| 439 | + | out = append(out, cmd) | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // buttonsSent drains the commands and counts pointer button commands. | |||
| 444 | + | func (h *hintHarness) buttonsSent() (presses, releases int) { | |||
| 445 | + | for _, cmd := range h.barrier() { | |||
| 446 | + | if b, ok := cmd.(ports.PointerButtonTo); ok { | |||
| 447 | + | if b.Pressed { | |||
| 448 | + | presses++ | |||
| 449 | + | } else { | |||
| 450 | + | releases++ | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | return presses, releases | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // The bind's modifier is still held while the label is typed: the synthetic | |||
| 458 | + | // click must reach the client, not start a Cmd+left-button window drag. | |||
| 459 | + | func TestHintClickWithBindModifierHeld(t *testing.T) { | |||
| 460 | + | h := newHint(t, core.Options{}) | |||
| 461 | + | tg := h.inside(0) | |||
| 462 | + | h.press("g", ports.ModSuper) | |||
| 463 | + | req := receive(t, h.scans) | |||
| 464 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |||
| 465 | + | h.labelled(1, 0) | |||
| 466 | + | h.drain() | |||
| 467 | + | h.press("f", ports.ModSuper) // Super is still down | |||
| 468 | + | h.labelled(0, 0) | |||
| 469 | + | presses, releases := h.buttonsSent() | |||
| 470 | + | if presses != 1 || releases != 1 { | |||
| 471 | + | t.Fatalf("client got %d presses, %d releases", presses, releases) | |||
| 472 | + | } | |||
| 473 | + | // No drag is left behind: a later plain motion and a click work as usual. | |||
| 474 | + | h.release("f", ports.ModSuper) | |||
| 475 | + | h.release("Super_L", 0) | |||
| 476 | + | h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)}) | |||
| 477 | + | h.send(ports.PointerButton{Button: 0x110, Pressed: true}) | |||
| 478 | + | h.send(ports.PointerButton{Button: 0x110}) | |||
| 479 | + | if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 { | |||
| 480 | + | t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases) | |||
| 481 | + | } | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | // constrain gives window 1 a pointer constraint and waits until core applied it. | |||
| 485 | + | func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) { | |||
| 486 | + | h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}} | |||
| 487 | + | h.client <- ports.WindowMapped{ID: 1} // a publish after the event | |||
| 488 | + | h.until(func(ports.Scene) bool { return true }) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func TestHintRefusedWithPointerConstraint(t *testing.T) { | |||
| 492 | + | for name, c := range map[string]ports.PointerConstraint{ | |||
| 493 | + | "lock": {Mode: ports.ConstraintLock}, | |||
| 494 | + | "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}}, | |||
| 495 | + | } { | |||
| 496 | + | t.Run(name, func(t *testing.T) { | |||
| 497 | + | h := newHint(t, core.Options{}) | |||
| 498 | + | h.constrain(c.Mode, c.Rect) | |||
| 499 | + | h.press("g", ports.ModSuper) | |||
| 500 | + | select { | |||
| 501 | + | case r := <-h.scans: | |||
| 502 | + | t.Fatalf("request under a %s: %+v", name, r) | |||
| 503 | + | case <-time.After(100 * time.Millisecond): | |||
| 504 | + | } | |||
| 505 | + | }) | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // A constraint that appears while the labels are shown (the pointer would be | |||
| 510 | + | // held or bent away from the target) cancels the click. | |||
| 511 | + | func TestHintNoClickWhenConstraintAppears(t *testing.T) { | |||
| 512 | + | for name, c := range map[string]ports.PointerConstraint{ | |||
| 513 | + | "lock": {Mode: ports.ConstraintLock}, | |||
| 514 | + | "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}}, | |||
| 515 | + | } { | |||
| 516 | + | t.Run(name, func(t *testing.T) { | |||
| 517 | + | h := newHint(t, core.Options{}) | |||
| 518 | + | h.show(h.inside(0)) | |||
| 519 | + | h.constrain(c.Mode, c.Rect) | |||
| 520 | + | h.drain() | |||
| 521 | + | h.press("f", 0) | |||
| 522 | + | if p, r := h.buttonsSent(); p+r != 0 { | |||
| 523 | + | t.Fatalf("%d presses, %d releases under a %s", p, r, name) | |||
| 524 | + | } | |||
| 525 | + | }) | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | // pressReal sends a real left press over window 1 and waits for the client to | |||
| 530 | + | // see it: the window then holds the pointer grab. | |||
| 531 | + | func (h *hintHarness) pressReal() { | |||
| 532 | + | h.t.Helper() | |||
| 533 | + | h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)}) | |||
| 534 | + | h.send(ports.PointerButton{Button: 0x110, Pressed: true}) | |||
| 535 | + | for { | |||
| 536 | + | cmd := command(h.t, h.commands) | |||
| 537 | + | if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed { | |||
| 538 | + | return | |||
| 539 | + | } | |||
| 540 | + | } | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func TestHintRefusedWhileButtonHeld(t *testing.T) { | |||
| 544 | + | h := newHint(t, core.Options{}) | |||
| 545 | + | h.pressReal() | |||
| 546 | + | h.press("g", ports.ModSuper) | |||
| 547 | + | select { | |||
| 548 | + | case r := <-h.scans: | |||
| 549 | + | t.Fatalf("request with a button held: %+v", r) | |||
| 550 | + | case <-time.After(100 * time.Millisecond): | |||
| 551 | + | } | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | // A button pressed (and so a grab taken) after the labels appeared: the label | |||
| 555 | + | // is typed, no synthetic button goes out and the real press stays held. | |||
| 556 | + | func TestHintNoClickWhileGrabbed(t *testing.T) { | |||
| 557 | + | h := newHint(t, core.Options{}) | |||
| 558 | + | h.show(h.inside(0)) | |||
| 559 | + | h.pressReal() | |||
| 560 | + | h.press("f", 0) | |||
| 561 | + | if p, r := h.buttonsSent(); p+r != 0 { | |||
| 562 | + | t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r) | |||
| 563 | + | } | |||
| 564 | + | // The real release still reaches the client: c.buttons was not touched. | |||
| 565 | + | h.send(ports.PointerButton{Button: 0x110}) | |||
| 566 | + | if _, r := h.buttonsSent(); r != 1 { | |||
| 567 | + | t.Fatalf("real release delivered %d times", r) | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | // The window stays mapped but moves away from the target's centre. | |||
| 572 | + | func TestHintMappedWindowMovedAborts(t *testing.T) { | |||
| 573 | + | h := newHint(t, core.Options{}) | |||
| 574 | + | // A target at the right edge of window 1. | |||
| 575 | + | tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}} | |||
| 576 | + | h.show(tg) | |||
| 577 | + | h.client <- ports.WindowMapped{ID: 2} | |||
| 578 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 }) | |||
| 579 | + | if s.Windows[0].Rect == h.win { | |||
| 580 | + | t.Fatalf("window 1 did not move: %+v", s.Windows) | |||
| 581 | + | } | |||
| 582 | + | h.drain() | |||
| 583 | + | h.press("f", 0) | |||
| 584 | + | if p, r := h.buttonsSent(); p+r != 0 { | |||
| 585 | + | t.Fatalf("click went to %d presses %d releases after the window moved", p, r) | |||
| 586 | + | } | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | // commandsSent drains the commands up to a barrier, keeping the ones that | |||
| 590 | + | // matter for a click: modifiers, focus, motion and buttons. | |||
| 591 | + | func (h *hintHarness) commandsSent() []ports.ClientCommand { | |||
| 592 | + | var out []ports.ClientCommand | |||
| 593 | + | for _, cmd := range h.barrier() { | |||
| 594 | + | switch cmd.(type) { | |||
| 595 | + | case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo: | |||
| 596 | + | out = append(out, cmd) | |||
| 597 | + | } | |||
| 598 | + | } | |||
| 599 | + | return out | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | func commandKinds(cmds []ports.ClientCommand) []string { | |||
| 603 | + | var out []string | |||
| 604 | + | for _, c := range cmds { | |||
| 605 | + | switch v := c.(type) { | |||
| 606 | + | case ports.SetModifiers: | |||
| 607 | + | if v.Restore { | |||
| 608 | + | out = append(out, "restore") | |||
| 609 | + | } else { | |||
| 610 | + | out = append(out, fmt.Sprintf("mods:%d", v.Mods)) | |||
| 611 | + | } | |||
| 612 | + | case ports.FocusWindow: | |||
| 613 | + | out = append(out, fmt.Sprintf("focus:%d", v.ID)) | |||
| 614 | + | case ports.PointerMotionTo: | |||
| 615 | + | out = append(out, "motion") | |||
| 616 | + | case ports.PointerButtonTo: | |||
| 617 | + | if v.Pressed { | |||
| 618 | + | out = append(out, "press") | |||
| 619 | + | } else { | |||
| 620 | + | out = append(out, "release") | |||
| 621 | + | } | |||
| 622 | + | } | |||
| 623 | + | } | |||
| 624 | + | return out | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | // An OCR target is clicked with the configured modifiers held: they go out | |||
| 628 | + | // before the motion and are restored after the release. | |||
| 629 | + | func TestHintTextClickHoldsModifiers(t *testing.T) { | |||
| 630 | + | mods := ports.ModCtrl | ports.ModShift | |||
| 631 | + | h := newHintMods(t, core.Options{}, mods) | |||
| 632 | + | tg := h.inside(0) | |||
| 633 | + | tg.Text = true | |||
| 634 | + | h.show(tg) | |||
| 635 | + | h.drain() | |||
| 636 | + | h.press("f", 0) | |||
| 637 | + | got := commandKinds(h.commandsSent()) | |||
| 638 | + | want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"} | |||
| 639 | + | if !slices.Equal(got, want) { | |||
| 640 | + | t.Fatalf("commands %v, want %v", got, want) | |||
| 641 | + | } | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | // Without text targets or without modifiers the click states no modifiers | |||
| 645 | + | // (depressed 0) rather than leave the held bind modifier to the client. | |||
| 646 | + | func TestHintClickWithoutModifiers(t *testing.T) { | |||
| 647 | + | for name, c := range map[string]struct { | |||
| 648 | + | mods ports.Mods | |||
| 649 | + | text bool | |||
| 650 | + | }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} { | |||
| 651 | + | t.Run(name, func(t *testing.T) { | |||
| 652 | + | h := newHintMods(t, core.Options{}, c.mods) | |||
| 653 | + | tg := h.inside(0) | |||
| 654 | + | tg.Text = c.text | |||
| 655 | + | h.show(tg) | |||
| 656 | + | h.drain() | |||
| 657 | + | h.press("f", 0) | |||
| 658 | + | got := commandKinds(h.commandsSent()) | |||
| 659 | + | if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) { | |||
| 660 | + | t.Fatalf("commands %v, want %v", got, want) | |||
| 661 | + | } | |||
| 662 | + | }) | |||
| 663 | + | } | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | // The bind's Super is still physically held: an accessibility click states | |||
| 667 | + | // depressed 0, and the restore carries the real state of the latest key, | |||
| 668 | + | // swallowed ones included (a latch a label key consumed). | |||
| 669 | + | func TestHintClickHeldBindModifierAndLatchRestore(t *testing.T) { | |||
| 670 | + | h := newHint(t, core.Options{}) | |||
| 671 | + | tg := h.inside(0) | |||
| 672 | + | h.press("g", ports.ModSuper) | |||
| 673 | + | req := receive(t, h.scans) | |||
| 674 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg, h.inside(1)}} | |||
| 675 | + | h.labelled(2, 0) | |||
| 676 | + | h.drain() | |||
| 677 | + | real := ports.ModState{Depressed: 64, Latched: 0, Locked: 2} | |||
| 678 | + | h.send(ports.KeyEvent{Keysym: "f", Base: "f", Mods: ports.ModSuper, Pressed: true, State: real}) | |||
| 679 | + | cmds := h.commandsSent() | |||
| 680 | + | var restore *ports.SetModifiers | |||
| 681 | + | for i, c := range cmds { | |||
| 682 | + | if v, ok := c.(ports.SetModifiers); ok { | |||
| 683 | + | if i == 0 && (v.Restore || v.Mods != 0) { | |||
| 684 | + | t.Fatalf("first modifiers %+v, want depressed 0", v) | |||
| 685 | + | } | |||
| 686 | + | if v.Restore { | |||
| 687 | + | restore = &v | |||
| 688 | + | } | |||
| 689 | + | } | |||
| 690 | + | } | |||
| 691 | + | if restore == nil || restore.State != real { | |||
| 692 | + | t.Fatalf("restore %+v, want state %+v (commands %v)", restore, real, commandKinds(cmds)) | |||
| 693 | + | } | |||
| 694 | + | } | |||
| 695 | + | ||||
| 696 | + | // A result is kept only for the window it was scanned on. | |||
| 697 | + | func TestHintResultWindowChecks(t *testing.T) { | |||
| 698 | + | h := newHint(t, core.Options{}) | |||
| 699 | + | req := h.start() | |||
| 700 | + | // Accessibility target naming another window: dropped; naming window 1: kept. | |||
| 701 | + | ok := h.inside(1) | |||
| 702 | + | ok.Window = 1 | |||
| 703 | + | bad := h.inside(0) | |||
| 704 | + | bad.Window = 7 | |||
| 705 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{bad, ok}} | |||
| 706 | + | s := h.labelled(1, 0) | |||
| 707 | + | if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.Y != ok.Rect.Y { | |||
| 708 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 709 | + | } | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | // A window that moved while the scan ran: an OCR target over it is dropped. | |||
| 713 | + | func TestHintOCRResultDroppedWhenWindowMoved(t *testing.T) { | |||
| 714 | + | h := newHint(t, core.Options{}) | |||
| 715 | + | req := h.start() | |||
| 716 | + | tg := h.inside(0) | |||
| 717 | + | tg.Text = true | |||
| 718 | + | h.client <- ports.WindowMapped{ID: 2} | |||
| 719 | + | h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 720 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |||
| 721 | + | // Whatever window now covers the target was not scanned: no labels. | |||
| 722 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |||
| 723 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 724 | + | } | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | // A new window covering the target (not in the snapshot) drops it as well. | |||
| 728 | + | func TestHintOCRResultDroppedForUnseenWindow(t *testing.T) { | |||
| 729 | + | h := newHint(t, core.Options{}) | |||
| 730 | + | req := h.start() | |||
| 731 | + | h.client <- ports.WindowMapped{ID: 2} | |||
| 732 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 733 | + | tg := ports.HintTarget{Text: true} | |||
| 734 | + | for _, w := range s.Windows { | |||
| 735 | + | if w.ID == 2 { | |||
| 736 | + | tg.Rect = ports.Rect{X: w.Rect.X + 5, Y: w.Rect.Y + 5, W: 20, H: 10} | |||
| 737 | + | } | |||
| 738 | + | } | |||
| 739 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}} | |||
| 740 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |||
| 741 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | // Targets outside the output are dropped. | |||
| 746 | + | func TestHintDropsTargetsOutsideOutput(t *testing.T) { | |||
| 747 | + | h := newHint(t, core.Options{}) | |||
| 748 | + | req := h.start() | |||
| 749 | + | out := ports.HintTarget{Rect: ports.Rect{X: 2000, Y: 10, W: 10, H: 10}} | |||
| 750 | + | h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, h.inside(0)}} | |||
| 751 | + | if s := h.labelled(1, 0); len(s.HintLabels) != 1 { | |||
| 752 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 753 | + | } | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // The output changing size while labels are shown ends hint mode. | |||
| 757 | + | func TestHintCancelsWhenOutputChanges(t *testing.T) { | |||
| 758 | + | h := newHint(t, core.Options{}) | |||
| 759 | + | h.show(h.inside(0)) | |||
| 760 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1200, Height: 800}} | |||
| 761 | + | if s := h.labelled(0, 0); len(s.HintLabels) != 0 { | |||
| 762 | + | t.Fatalf("labels %+v", s.HintLabels) | |||
| 763 | + | } | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | // A window that does not have the keyboard gets it before the modifiers. | |||
| 767 | + | func TestHintTextClickFocusesWindowFirst(t *testing.T) { | |||
| 768 | + | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |||
| 769 | + | h.client <- ports.WindowMapped{ID: 2} | |||
| 770 | + | s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 771 | + | var r ports.Rect | |||
| 772 | + | for _, w := range s.Windows { | |||
| 773 | + | if w.ID == 1 { | |||
| 774 | + | r = w.Rect | |||
| 775 | + | } | |||
| 776 | + | } | |||
| 777 | + | h.win = r | |||
| 778 | + | tg := h.inside(0) | |||
| 779 | + | tg.Text = true | |||
| 780 | + | h.show(tg) | |||
| 781 | + | h.drain() | |||
| 782 | + | h.press("f", 0) | |||
| 783 | + | got := commandKinds(h.commandsSent()) | |||
| 784 | + | want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"} | |||
| 785 | + | if !slices.Equal(got, want) { | |||
| 786 | + | t.Fatalf("commands %v, want %v", got, want) | |||
| 787 | + | } | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | // A click that is no longer allowed sends no modifiers at all. | |||
| 791 | + | func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) { | |||
| 792 | + | h := newHintMods(t, core.Options{}, ports.ModCtrl) | |||
| 793 | + | tg := h.inside(0) | |||
| 794 | + | tg.Text = true | |||
| 795 | + | h.show(tg) | |||
| 796 | + | h.constrain(ports.ConstraintLock, ports.Rect{}) | |||
| 797 | + | h.drain() | |||
| 798 | + | h.press("f", 0) | |||
| 799 | + | if got := commandKinds(h.commandsSent()); len(got) != 0 { | |||
| 800 | + | t.Fatalf("commands %v under a constraint", got) | |||
| 801 | + | } | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | func TestHintDisabledNotesOnce(t *testing.T) { | |||
| 805 | + | cfg := scrollDefaults() | |||
| 806 | + | cfg.Binds["Cmd+g"] = "hint" | |||
| 807 | + | var noted atomic.Int32 | |||
| 808 | + | h := &hintHarness{ | |||
| 809 | + | input: make(chan ports.InputEvent, 32), | |||
| 810 | + | client: make(chan ports.ClientEvent, 32), | |||
| 811 | + | output: make(chan ports.OutputEvent, 8), | |||
| 812 | + | commands: make(chan ports.ClientCommand, 256), | |||
| 813 | + | scenes: make(chan []ports.Scene, 1), | |||
| 814 | + | } | |||
| 815 | + | ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes} | |||
| 816 | + | c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }}) | |||
| 817 | + | if err != nil { | |||
| 818 | + | t.Fatal(err) | |||
| 819 | + | } | |||
| 820 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 821 | + | done := make(chan error, 1) | |||
| 822 | + | go func() { done <- c.Run(ctx) }() | |||
| 823 | + | t.Cleanup(func() { cancel(); receive(t, done) }) | |||
| 824 | + | h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}} | |||
| 825 | + | scene(t, h.scenes) | |||
| 826 | + | for range 3 { | |||
| 827 | + | h.press("g", ports.ModSuper) | |||
| 828 | + | h.release("g", 0) | |||
| 829 | + | } | |||
| 830 | + | c.Ping(ctx) | |||
| 831 | + | if got := noted.Load(); got != 1 { | |||
| 832 | + | t.Fatalf("disabled note ran %d times, want 1", got) | |||
| 833 | + | } | |||
| 834 | + | } | |||
M internal/core/input.go +22 -8
| @@ -124,6 +124,21 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error { | |||
| 124 | 124 | return nil | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | // clickFocus focuses the window id and its output the way a click on it | |
| 128 | + | // does, unless it already has the keyboard. | |
| 129 | + | func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |
| 130 | + | s, w := c.screenOf(id) | |
| 131 | + | if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) { | |
| 132 | + | return nil | |
| 133 | + | } | |
| 134 | + | now := c.now() | |
| 135 | + | before := c.snapshot(now) | |
| 136 | + | w.Click(id) | |
| 137 | + | c.focusScreen = c.screenIndex(s.name()) | |
| 138 | + | c.transition(before, now) | |
| 139 | + | return c.publish(ctx) | |
| 140 | + | } | |
| 141 | + | ||
| 127 | 142 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |
| 128 | 143 | // Buttons a drag took stay in c.buttons until released, so the | |
| 129 | 144 | // security gate admits their release; no client sees it. | |
| @@ -199,14 +214,8 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||
| 199 | 214 | } | |
| 200 | 215 | } | |
| 201 | 216 | // A click focuses the window and its output. | |
| 202 | - | s, w := c.screenOf(id) | |
| 203 | - | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |
| 204 | - | now := c.now() | |
| 205 | - | before := c.snapshot(now) | |
| 206 | - | w.Click(id) | |
| 207 | - | c.focusScreen = c.screenIndex(s.name()) | |
| 208 | - | c.transition(before, now) | |
| 209 | - | if err := c.publish(ctx); err != nil { | |
| 217 | + | if v.Pressed { | |
| 218 | + | if err := c.clickFocus(ctx, id); err != nil { | |
| 210 | 219 | return err | |
| 211 | 220 | } | |
| 212 | 221 | } | |
| @@ -249,6 +258,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||
| 249 | 258 | ||
| 250 | 259 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |
| 251 | 260 | c.mods = key.Mods | |
| 261 | + | c.keyState = key.State | |
| 252 | 262 | c.repeatKey(key) | |
| 253 | 263 | // Releasing the command key commits the switcher; the release still | |
| 254 | 264 | // goes on to the bind lookup and the clients. | |
| @@ -277,6 +287,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||
| 277 | 287 | } | |
| 278 | 288 | return c.rehit(ctx) | |
| 279 | 289 | } | |
| 290 | + | // Hint mode takes every key press before binds and windows. | |
| 291 | + | if handled, err := c.hintKey(ctx, key); handled || err != nil { | |
| 292 | + | return err | |
| 293 | + | } | |
| 280 | 294 | // The overview takes its keys before any window; others still | |
| 281 | 295 | // run binds, and are not forwarded. | |
| 282 | 296 | // A launcher or a menu holding the keyboard gets them first. | |
| @@ -124,6 +124,21 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error { | |||||
| 124 | return nil | 124 | return nil | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | + | // clickFocus focuses the window id and its output the way a click on it | |||
| 128 | + | // does, unless it already has the keyboard. | |||
| 129 | + | func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |||
| 130 | + | s, w := c.screenOf(id) | |||
| 131 | + | if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) { | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | now := c.now() | |||
| 135 | + | before := c.snapshot(now) | |||
| 136 | + | w.Click(id) | |||
| 137 | + | c.focusScreen = c.screenIndex(s.name()) | |||
| 138 | + | c.transition(before, now) | |||
| 139 | + | return c.publish(ctx) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 127 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | 142 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | ||
| 128 | // Buttons a drag took stay in c.buttons until released, so the | 143 | // Buttons a drag took stay in c.buttons until released, so the | ||
| 129 | // security gate admits their release; no client sees it. | 144 | // security gate admits their release; no client sees it. | ||
| @@ -199,14 +214,8 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||||
| 199 | } | 214 | } | ||
| 200 | } | 215 | } | ||
| 201 | // A click focuses the window and its output. | 216 | // A click focuses the window and its output. | ||
| 202 | - | s, w := c.screenOf(id) | 217 | + | if v.Pressed { |
| 203 | - | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | 218 | + | if err := c.clickFocus(ctx, id); err != nil { |
| 204 | - | now := c.now() | |||
| 205 | - | before := c.snapshot(now) | |||
| 206 | - | w.Click(id) | |||
| 207 | - | c.focusScreen = c.screenIndex(s.name()) | |||
| 208 | - | c.transition(before, now) | |||
| 209 | - | if err := c.publish(ctx); err != nil { | |||
| 210 | return err | 219 | return err | ||
| 211 | } | 220 | } | ||
| 212 | } | 221 | } | ||
| @@ -249,6 +258,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||||
| 249 | 258 | ||||
| 250 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | 259 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | ||
| 251 | c.mods = key.Mods | 260 | c.mods = key.Mods | ||
| 261 | + | c.keyState = key.State | |||
| 252 | c.repeatKey(key) | 262 | c.repeatKey(key) | ||
| 253 | // Releasing the command key commits the switcher; the release still | 263 | // Releasing the command key commits the switcher; the release still | ||
| 254 | // goes on to the bind lookup and the clients. | 264 | // goes on to the bind lookup and the clients. | ||
| @@ -277,6 +287,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||||
| 277 | } | 287 | } | ||
| 278 | return c.rehit(ctx) | 288 | return c.rehit(ctx) | ||
| 279 | } | 289 | } | ||
| 290 | + | // Hint mode takes every key press before binds and windows. | |||
| 291 | + | if handled, err := c.hintKey(ctx, key); handled || err != nil { | |||
| 292 | + | return err | |||
| 293 | + | } | |||
| 280 | // The overview takes its keys before any window; others still | 294 | // The overview takes its keys before any window; others still | ||
| 281 | // run binds, and are not forwarded. | 295 | // run binds, and are not forwarded. | ||
| 282 | // A launcher or a menu holding the keyboard gets them first. | 296 | // A launcher or a menu holding the keyboard gets them first. | ||
M internal/core/scene.go +2 -0
| @@ -123,6 +123,8 @@ 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) | |
| 127 | + | scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH} | |
| 126 | 128 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 127 | 129 | // The springs of this frame are advanced: a scene shown while one still | |
| 128 | 130 | // runs wakes core when it flips; the one a spring settles on does not. | |
| @@ -123,6 +123,8 @@ 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) | |||
| 127 | + | scene.HintStyle = ports.HintStyle{Size: c.cfg.Hint.Size, Background: c.cfg.Hint.Background, Foreground: c.cfg.Hint.Foreground, Width: sc.info.Width, Height: sc.info.Height, PhysicalW: sc.info.PhysicalW, PhysicalH: sc.info.PhysicalH} | |||
| 126 | scene.Capture = c.captureSceneFor(sc, capture) | 128 | scene.Capture = c.captureSceneFor(sc, capture) | ||
| 127 | // The springs of this frame are advanced: a scene shown while one still | 129 | // 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. | 130 | // runs wakes core when it flips; the one a spring settles on does not. | ||