detached 6735ebf

fix(core): keep hint targets on their window, send explicit click modifiers, cancel on output change

commit
6735ebf2aafbab9942e7cf4f5802b3f99dfbbc7f
author
brice · · 2026-10-19 15:32 UTC
parent
a1a57ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +217 -61
M internal/core/core.go +3 -0
@@ -168,6 +168,9 @@ type Core struct {
168168 flash focusFlash
169169 // hint is keyboard hint mode (hint.go).
170170 hint hintState
171+ // keyState is the real modifier state of the latest key event, swallowed
172+ // keys included.
173+ keyState ports.ModState
171174 // synthetic is set while a hint click runs through the pointer handlers:
172175 // compositor modifier+button actions (window drag) do not apply to it.
173176 synthetic bool
@@ -168,6 +168,9 @@ type Core struct {
168 flash focusFlash168 flash focusFlash
169 // hint is keyboard hint mode (hint.go).169 // hint is keyboard hint mode (hint.go).
170 hint hintState170 hint hintState
171+ // keyState is the real modifier state of the latest key event, swallowed
172+ // keys included.
173+ keyState ports.ModState
171 // synthetic is set while a hint click runs through the pointer handlers:174 // synthetic is set while a hint click runs through the pointer handlers:
172 // compositor modifier+button actions (window drag) do not apply to it.175 // compositor modifier+button actions (window drag) do not apply to it.
173 synthetic bool176 synthetic bool
M internal/core/hint.go +75 -19
@@ -45,10 +45,27 @@ type hintState struct {
4545 // ws is the workspace on the hint output when hint mode began: another
4646 // one on screen ends it.
4747 ws *Workspace
48+ geom hintGeom
49+ windows []ports.HintWindow // visible windows at request time
4850 targets []hintTarget
4951 typed string
5052 }
5153
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+
5269 // hintRequest starts hint mode on the focused screen. It does nothing
5370 // (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
5471 // active, or on an output with a non-normal transform.
@@ -67,13 +84,14 @@ func (c *Core) hintRequest() {
6784 if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
6885 return
6986 }
70- 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: c.hintWindows(sc)}
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}
7189 select {
7290 case c.ch.HintScans <- req:
7391 default:
7492 return
7593 }
76- c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
94+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)}
7795 }
7896
7997 // hintWindows lists the windows drawn on sc, bottom to top, with their client
@@ -122,16 +140,28 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
122140 return true
123141 }
124142 sc := c.screens[i]
143+ if sc.hintGeom() != h.geom {
144+ c.hintCancel()
145+ return true
146+ }
147+ out := sc.rect()
125148 var kept []hintTarget
126149 for _, t := range res.Targets {
127150 x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
128151 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+ }
129155 id, _, _ := c.hit(x, y)
130156 if id == 0 {
131157 continue
132158 }
133- if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
134- // A layer surface, not a managed window.
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) {
135165 continue
136166 }
137167 kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
@@ -158,11 +188,32 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
158188 return true
159189 }
160190
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+
161212 // hintCancel ends hint mode.
162213 func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
163214
164215 // hintValidate ends hint mode when what it was started on is gone: the
165-// session became protected, the output went, another workspace is on screen
216+// session became protected, the output went or changed geometry, another workspace is on screen
166217 // there, or the overview opened. It runs at the start of every publish.
167218 func (c *Core) hintValidate() {
168219 h := &c.hint
@@ -172,6 +223,10 @@ func (c *Core) hintValidate() {
172223 i := c.screenIndex(h.output)
173224 if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
174225 c.hintCancel()
226+ return
227+ }
228+ if c.screens[i].hintGeom() != h.geom {
229+ c.hintCancel()
175230 }
176231 }
177232
@@ -229,8 +284,9 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
229284 // still be down) from turning it into a window drag, without touching c.mods.
230285 //
231286 // A target found by OCR is clicked with hint.text-click held (terminals open
232-// a link on a modified click): the window gets the keyboard first, so it sees
233-// the modifiers, and the real modifier state is always sent back afterwards.
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.
234290 func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
235291 c.hintCancel()
236292 if err := c.publish(ctx); err != nil {
@@ -239,19 +295,19 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
239295 if !c.hintClickable(t) {
240296 return nil
241297 }
242- if mods := c.hintMods(t); mods != 0 {
243- if err := c.clickFocus(ctx, t.window); err != nil {
244- return err
245- }
246- if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: mods}); err != nil {
247- return err
248- }
249- defer func() {
250- if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true}); err == nil {
251- err = e
252- }
253- }()
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
254302 }
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+ }()
255311 c.synthetic = true
256312 defer func() { c.synthetic = false }()
257313 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
@@ -45,10 +45,27 @@ type hintState struct {
45 // ws is the workspace on the hint output when hint mode began: another45 // ws is the workspace on the hint output when hint mode began: another
46 // one on screen ends it.46 // one on screen ends it.
47 ws *Workspace47 ws *Workspace
48+ geom hintGeom
49+ windows []ports.HintWindow // visible windows at request time
48 targets []hintTarget50 targets []hintTarget
49 typed string51 typed string
50 }52 }
51 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+
52 // hintRequest starts hint mode on the focused screen. It does nothing69 // hintRequest starts hint mode on the focused screen. It does nothing
53 // (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is70 // (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
54 // active, or on an output with a non-normal transform.71 // active, or on an output with a non-normal transform.
@@ -67,13 +84,14 @@ func (c *Core) hintRequest() {
67 if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {84 if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
68 return85 return
69 }86 }
70- 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: c.hintWindows(sc)}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}
71 select {89 select {
72 case c.ch.HintScans <- req:90 case c.ch.HintScans <- req:
73 default:91 default:
74 return92 return
75 }93 }
76- c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}94+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)}
77 }95 }
78 96
79 // hintWindows lists the windows drawn on sc, bottom to top, with their client97 // hintWindows lists the windows drawn on sc, bottom to top, with their client
@@ -122,16 +140,28 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
122 return true140 return true
123 }141 }
124 sc := c.screens[i]142 sc := c.screens[i]
143+ if sc.hintGeom() != h.geom {
144+ c.hintCancel()
145+ return true
146+ }
147+ out := sc.rect()
125 var kept []hintTarget148 var kept []hintTarget
126 for _, t := range res.Targets {149 for _, t := range res.Targets {
127 x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2150 x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
128 y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2151 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+ }
129 id, _, _ := c.hit(x, y)155 id, _, _ := c.hit(x, y)
130 if id == 0 {156 if id == 0 {
131 continue157 continue
132 }158 }
133- if s, _ := c.screenOf(c.popupRoot(id)); s == nil {159+ root := c.popupRoot(id)
134- // A layer surface, not a managed window.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) {
135 continue165 continue
136 }166 }
137 kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})167 kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
@@ -158,11 +188,32 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
158 return true188 return true
159 }189 }
160 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+
161 // hintCancel ends hint mode.212 // hintCancel ends hint mode.
162 func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }213 func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
163 214
164 // hintValidate ends hint mode when what it was started on is gone: the215 // hintValidate ends hint mode when what it was started on is gone: the
165-// session became protected, the output went, another workspace is on screen216+// session became protected, the output went or changed geometry, another workspace is on screen
166 // there, or the overview opened. It runs at the start of every publish.217 // there, or the overview opened. It runs at the start of every publish.
167 func (c *Core) hintValidate() {218 func (c *Core) hintValidate() {
168 h := &c.hint219 h := &c.hint
@@ -172,6 +223,10 @@ func (c *Core) hintValidate() {
172 i := c.screenIndex(h.output)223 i := c.screenIndex(h.output)
173 if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {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 {
174 c.hintCancel()225 c.hintCancel()
226+ return
227+ }
228+ if c.screens[i].hintGeom() != h.geom {
229+ c.hintCancel()
175 }230 }
176 }231 }
177 232
@@ -229,8 +284,9 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
229 // still be down) from turning it into a window drag, without touching c.mods.284 // still be down) from turning it into a window drag, without touching c.mods.
230 //285 //
231 // A target found by OCR is clicked with hint.text-click held (terminals open286 // A target found by OCR is clicked with hint.text-click held (terminals open
232-// a link on a modified click): the window gets the keyboard first, so it sees287+// a link on a modified click). Every click gives the window the keyboard
233-// the modifiers, and the real modifier state is always sent back afterwards.288+// first, then sends exactly the modifiers to hold (none for other targets),
289+// and the real modifier state is always sent back afterwards.
234 func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {290 func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
235 c.hintCancel()291 c.hintCancel()
236 if err := c.publish(ctx); err != nil {292 if err := c.publish(ctx); err != nil {
@@ -239,19 +295,19 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
239 if !c.hintClickable(t) {295 if !c.hintClickable(t) {
240 return nil296 return nil
241 }297 }
242- if mods := c.hintMods(t); mods != 0 {298+ // Every click states its modifiers (possibly none): the bind's Cmd may
243- if err := c.clickFocus(ctx, t.window); err != nil {299+ // still be down and the client must not see it on a plain click.
244- return err300+ if err := c.clickFocus(ctx, t.window); err != nil {
245- }301+ return err
246- if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: mods}); err != nil {
247- return err
248- }
249- defer func() {
250- if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true}); err == nil {
251- err = e
252- }
253- }()
254 }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+ }()
255 c.synthetic = true311 c.synthetic = true
256 defer func() { c.synthetic = false }()312 defer func() { c.synthetic = false }()
257 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {313 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
M internal/core/hint_test.go +136 -42
@@ -291,22 +291,6 @@ func TestHintTypingFilters(t *testing.T) {
291291 }
292292 }
293293
294-func TestHintBackspace(t *testing.T) {
295- h := newHint(t, core.Options{})
296- var targets []ports.HintTarget
297- for i := 0; i < 27; i++ {
298- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
299- }
300- h.show(targets...)
301- h.press("f", 0)
302- h.labelled(26, 1)
303- h.press("BackSpace", 0)
304- s := h.labelled(27, 0)
305- if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
306- t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
307- }
308-}
309-
310294 func TestHintClick(t *testing.T) {
311295 h := newHint(t, core.Options{})
312296 tg := h.inside(0)
@@ -429,7 +413,10 @@ func TestHintWindowMovedAborts(t *testing.T) {
429413 h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
430414 h.drain()
431415 h.press("f", 0)
432- time.Sleep(50 * time.Millisecond)
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)
433420 for len(h.commands) > 0 {
434421 if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
435422 t.Fatal("button sent to a moved window")
@@ -437,23 +424,34 @@ func TestHintWindowMovedAborts(t *testing.T) {
437424 }
438425 }
439426
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+
440443 // buttonsSent drains the commands and counts pointer button commands.
441444 func (h *hintHarness) buttonsSent() (presses, releases int) {
442- time.Sleep(50 * time.Millisecond)
443- for {
444- select {
445- case cmd := <-h.commands:
446- if b, ok := cmd.(ports.PointerButtonTo); ok {
447- if b.Pressed {
448- presses++
449- } else {
450- releases++
451- }
445+ for _, cmd := range h.barrier() {
446+ if b, ok := cmd.(ports.PointerButtonTo); ok {
447+ if b.Pressed {
448+ presses++
449+ } else {
450+ releases++
452451 }
453- default:
454- return
455452 }
456453 }
454+ return presses, releases
457455 }
458456
459457 // The bind's modifier is still held while the label is typed: the synthetic
@@ -588,22 +586,17 @@ func TestHintMappedWindowMovedAborts(t *testing.T) {
588586 }
589587 }
590588
591-// commandsSent drains the commands after a short wait, keeping the ones that
589+// commandsSent drains the commands up to a barrier, keeping the ones that
592590 // matter for a click: modifiers, focus, motion and buttons.
593591 func (h *hintHarness) commandsSent() []ports.ClientCommand {
594- time.Sleep(50 * time.Millisecond)
595592 var out []ports.ClientCommand
596- for {
597- select {
598- case cmd := <-h.commands:
599- switch cmd.(type) {
600- case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo:
601- out = append(out, cmd)
602- }
603- default:
604- return out
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)
605597 }
606598 }
599+ return out
607600 }
608601
609602 func commandKinds(cmds []ports.ClientCommand) []string {
@@ -648,7 +641,8 @@ func TestHintTextClickHoldsModifiers(t *testing.T) {
648641 }
649642 }
650643
651-// Without text targets or without modifiers the click is exactly as before.
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.
652646 func TestHintClickWithoutModifiers(t *testing.T) {
653647 for name, c := range map[string]struct {
654648 mods ports.Mods
@@ -662,13 +656,113 @@ func TestHintClickWithoutModifiers(t *testing.T) {
662656 h.drain()
663657 h.press("f", 0)
664658 got := commandKinds(h.commandsSent())
665- if want := []string{"motion", "press", "release"}; !slices.Equal(got, want) {
659+ if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) {
666660 t.Fatalf("commands %v, want %v", got, want)
667661 }
668662 })
669663 }
670664 }
671665
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+
672766 // A window that does not have the keyboard gets it before the modifiers.
673767 func TestHintTextClickFocusesWindowFirst(t *testing.T) {
674768 h := newHintMods(t, core.Options{}, ports.ModCtrl)
@@ -291,22 +291,6 @@ func TestHintTypingFilters(t *testing.T) {
291 }291 }
292 }292 }
293 293
294-func TestHintBackspace(t *testing.T) {
295- h := newHint(t, core.Options{})
296- var targets []ports.HintTarget
297- for i := 0; i < 27; i++ {
298- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
299- }
300- h.show(targets...)
301- h.press("f", 0)
302- h.labelled(26, 1)
303- h.press("BackSpace", 0)
304- s := h.labelled(27, 0)
305- if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
306- t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
307- }
308-}
309-
310 func TestHintClick(t *testing.T) {294 func TestHintClick(t *testing.T) {
311 h := newHint(t, core.Options{})295 h := newHint(t, core.Options{})
312 tg := h.inside(0)296 tg := h.inside(0)
@@ -429,7 +413,10 @@ func TestHintWindowMovedAborts(t *testing.T) {
429 h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })413 h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
430 h.drain()414 h.drain()
431 h.press("f", 0)415 h.press("f", 0)
432- time.Sleep(50 * time.Millisecond)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)
433 for len(h.commands) > 0 {420 for len(h.commands) > 0 {
434 if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {421 if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
435 t.Fatal("button sent to a moved window")422 t.Fatal("button sent to a moved window")
@@ -437,23 +424,34 @@ func TestHintWindowMovedAborts(t *testing.T) {
437 }424 }
438 }425 }
439 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+
440 // buttonsSent drains the commands and counts pointer button commands.443 // buttonsSent drains the commands and counts pointer button commands.
441 func (h *hintHarness) buttonsSent() (presses, releases int) {444 func (h *hintHarness) buttonsSent() (presses, releases int) {
442- time.Sleep(50 * time.Millisecond)445+ for _, cmd := range h.barrier() {
443- for {446+ if b, ok := cmd.(ports.PointerButtonTo); ok {
444- select {447+ if b.Pressed {
445- case cmd := <-h.commands:448+ presses++
446- if b, ok := cmd.(ports.PointerButtonTo); ok {449+ } else {
447- if b.Pressed {450+ releases++
448- presses++
449- } else {
450- releases++
451- }
452 }451 }
453- default:
454- return
455 }452 }
456 }453 }
454+ return presses, releases
457 }455 }
458 456
459 // The bind's modifier is still held while the label is typed: the synthetic457 // The bind's modifier is still held while the label is typed: the synthetic
@@ -588,22 +586,17 @@ func TestHintMappedWindowMovedAborts(t *testing.T) {
588 }586 }
589 }587 }
590 588
591-// commandsSent drains the commands after a short wait, keeping the ones that589+// commandsSent drains the commands up to a barrier, keeping the ones that
592 // matter for a click: modifiers, focus, motion and buttons.590 // matter for a click: modifiers, focus, motion and buttons.
593 func (h *hintHarness) commandsSent() []ports.ClientCommand {591 func (h *hintHarness) commandsSent() []ports.ClientCommand {
594- time.Sleep(50 * time.Millisecond)
595 var out []ports.ClientCommand592 var out []ports.ClientCommand
596- for {593+ for _, cmd := range h.barrier() {
597- select {594+ switch cmd.(type) {
598- case cmd := <-h.commands:595+ case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo:
599- switch cmd.(type) {596+ out = append(out, cmd)
600- case ports.SetModifiers, ports.FocusWindow, ports.PointerMotionTo, ports.PointerButtonTo:
601- out = append(out, cmd)
602- }
603- default:
604- return out
605 }597 }
606 }598 }
599+ return out
607 }600 }
608 601
609 func commandKinds(cmds []ports.ClientCommand) []string {602 func commandKinds(cmds []ports.ClientCommand) []string {
@@ -648,7 +641,8 @@ func TestHintTextClickHoldsModifiers(t *testing.T) {
648 }641 }
649 }642 }
650 643
651-// Without text targets or without modifiers the click is exactly as before.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.
652 func TestHintClickWithoutModifiers(t *testing.T) {646 func TestHintClickWithoutModifiers(t *testing.T) {
653 for name, c := range map[string]struct {647 for name, c := range map[string]struct {
654 mods ports.Mods648 mods ports.Mods
@@ -662,13 +656,113 @@ func TestHintClickWithoutModifiers(t *testing.T) {
662 h.drain()656 h.drain()
663 h.press("f", 0)657 h.press("f", 0)
664 got := commandKinds(h.commandsSent())658 got := commandKinds(h.commandsSent())
665- if want := []string{"motion", "press", "release"}; !slices.Equal(got, want) {659+ if want := []string{"mods:0", "motion", "press", "release", "restore"}; !slices.Equal(got, want) {
666 t.Fatalf("commands %v, want %v", got, want)660 t.Fatalf("commands %v, want %v", got, want)
667 }661 }
668 })662 })
669 }663 }
670 }664 }
671 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+
672 // A window that does not have the keyboard gets it before the modifiers.766 // A window that does not have the keyboard gets it before the modifiers.
673 func TestHintTextClickFocusesWindowFirst(t *testing.T) {767 func TestHintTextClickFocusesWindowFirst(t *testing.T) {
674 h := newHintMods(t, core.Options{}, ports.ModCtrl)768 h := newHintMods(t, core.Options{}, ports.ModCtrl)
M internal/core/input.go +1 -0
@@ -258,6 +258,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
258258
259259 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
260260 c.mods = key.Mods
261+ c.keyState = key.State
261262 c.repeatKey(key)
262263 // Releasing the command key commits the switcher; the release still
263264 // goes on to the bind lookup and the clients.
@@ -258,6 +258,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
258 258
259 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {259 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
260 c.mods = key.Mods260 c.mods = key.Mods
261+ c.keyState = key.State
261 c.repeatKey(key)262 c.repeatKey(key)
262 // Releasing the command key commits the switcher; the release still263 // Releasing the command key commits the switcher; the release still
263 // goes on to the bind lookup and the clients.264 // goes on to the bind lookup and the clients.
M internal/ports/hint_messages.go +2 -0
@@ -42,6 +42,8 @@ type HintTarget struct {
4242 // Text is set for targets found by OCR (read from pixels, not from an
4343 // accessibility tree).
4444 Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
4547 }
4648
4749 // HintScanResult answers a HintScanRequest. Core owns Targets.
@@ -42,6 +42,8 @@ type HintTarget struct {
42 // Text is set for targets found by OCR (read from pixels, not from an42 // Text is set for targets found by OCR (read from pixels, not from an
43 // accessibility tree).43 // accessibility tree).
44 Text bool44 Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
45 }47 }
46 48
47 // HintScanResult answers a HintScanRequest. Core owns Targets.49 // HintScanResult answers a HintScanRequest. Core owns Targets.

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 23

Tags 10

↑↓ moveenter openesc close