detached 40c7803

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1256 -10
M internal/core/action.go +6 -0
@@ -63,6 +63,9 @@ const (
6363 // command key is held (switcher.go).
6464 ActionSwitchColumnNext Action = "switch-column-next"
6565 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
6669 )
6770
6871 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -303,6 +306,9 @@ func (c *Core) dispatchAction(a Action) Effect {
303306 return Effect{}
304307 }
305308 switch a {
309+ case ActionHint:
310+ c.hintRequest()
311+ return Effect{}
306312 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
307313 if i := c.neighbor(monitorDirection(a)); i >= 0 {
308314 c.focusScreen = i
@@ -63,6 +63,9 @@ const (
63 // command key is held (switcher.go).63 // command key is held (switcher.go).
64 ActionSwitchColumnNext Action = "switch-column-next"64 ActionSwitchColumnNext Action = "switch-column-next"
65 ActionSwitchColumnPrev Action = "switch-column-prev"65 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
66 )69 )
67 70
68 // monitorDirection returns the direction of the neighbor screen targeted by71 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -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 = i314 c.focusScreen = i
M internal/core/core.go +30 -1
@@ -47,6 +47,12 @@ type Channels struct {
4747 // animating scene (Scene.Animating): a running slide moves one step per
4848 // report. Without them it moves on a timer.
4949 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
5056 }
5157
5258 // Options are the services and policy core is constructed with.
@@ -57,6 +63,9 @@ type Options struct {
5763 Clock ports.Clock
5864 // Terminal enables automatic terminal opening according to terminal.auto-open.
5965 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()
6069 }
6170
6271 // fullscreenGrace is how long after mapping a window's fullscreen request
@@ -157,6 +166,14 @@ type Core struct {
157166 // capt is the capture state (capture.go, capindicator.go).
158167 capt captureState
159168 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
160177 // switcher is the column switcher in progress: the screen it is on and
161178 // the timer that shows its cards (switcher.go).
162179 switcher struct {
@@ -275,7 +292,7 @@ func (c *Core) apply(cfg ports.Config) error {
275292 }
276293 switch Action(a) {
277294 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:
279296 default:
280297 return fmt.Errorf("invalid action %q", a)
281298 }
@@ -634,6 +651,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634651 return errSecurityChanged
635652 }
636653 c.syncSecurity()
654+ c.hintValidate()
637655 if c.security.Protected {
638656 return c.publishProtected(ctx)
639657 }
@@ -1206,6 +1224,17 @@ func (c *Core) Run(ctx context.Context) error {
12061224 return nil
12071225 }
12081226 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+ }
12091238 case ev, ok := <-c.ch.Output:
12101239 if !ok {
12111240 c.ch.Output = nil
@@ -47,6 +47,12 @@ type Channels struct {
47 // animating scene (Scene.Animating): a running slide moves one step per47 // animating scene (Scene.Animating): a running slide moves one step per
48 // report. Without them it moves on a timer.48 // report. Without them it moves on a timer.
49 Frames <-chan ports.OutputFrame49 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
50 }56 }
51 57
52 // Options are the services and policy core is constructed with.58 // Options are the services and policy core is constructed with.
@@ -57,6 +63,9 @@ type Options struct {
57 Clock ports.Clock63 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 bool65 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 request71 // 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 captureState167 capt captureState
159 flash focusFlash168 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 and177 // 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 errSecurityChanged651 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 nil1224 return nil
1207 }1225 }
1208 continue1226 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 = nil1240 c.ch.Output = nil
M internal/core/drag.go +1 -1
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
9090 // startDrag starts a cmd + button drag on the window under the pointer.
9191 // It reports whether it did: the press is then not forwarded.
9292 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
9494 return false, nil
9595 }
9696 if v.Button != btnLeft && v.Button != btnRight {
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
90 // startDrag starts a cmd + button drag on the window under the pointer.90 // startDrag starts a cmd + button drag on the window under the pointer.
91 // It reports whether it did: the press is then not forwarded.91 // It reports whether it did: the press is then not forwarded.
92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
94 return false, nil94 return false, nil
95 }95 }
96 if v.Button != btnLeft && v.Button != btnRight {96 if v.Button != btnLeft && v.Button != btnRight {
A internal/core/hint.go +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 {
124124 return nil
125125 }
126126
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+
127142 func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
128143 // Buttons a drag took stay in c.buttons until released, so the
129144 // security gate admits their release; no client sees it.
@@ -199,14 +214,8 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
199214 }
200215 }
201216 // 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 {
210219 return err
211220 }
212221 }
@@ -249,6 +258,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
249258
250259 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
251260 c.mods = key.Mods
261+ c.keyState = key.State
252262 c.repeatKey(key)
253263 // Releasing the command key commits the switcher; the release still
254264 // goes on to the bind lookup and the clients.
@@ -277,6 +287,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
277287 }
278288 return c.rehit(ctx)
279289 }
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+ }
280294 // The overview takes its keys before any window; others still
281295 // run binds, and are not forwarded.
282296 // 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 nil124 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 the143 // 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 err219 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.Mods260 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 still263 // 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 still294 // 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
123123 }
124124 scene.Flash = c.flashFor(sc)
125125 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}
126128 scene.Capture = c.captureSceneFor(sc, capture)
127129 // The springs of this frame are advanced: a scene shown while one still
128130 // 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 still129 // 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.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close