detached 09a5c8c

fix(core): make hint clicks safe against modifiers, constraints and grabs

commit
09a5c8c5f2b4078eb6838855b493dea5bc695624
author
brice · · 2026-10-19 10:05 UTC
parent
90d23d4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +187 -8
M internal/core/core.go +3 -0
@@ -165,6 +165,9 @@ type Core struct {
165165 flash focusFlash
166166 // hint is keyboard hint mode (hint.go).
167167 hint hintState
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
168171 // switcher is the column switcher in progress: the screen it is on and
169172 // the timer that shows its cards (switcher.go).
170173 switcher struct {
@@ -165,6 +165,9 @@ type Core struct {
165 flash focusFlash165 flash focusFlash
166 // hint is keyboard hint mode (hint.go).166 // hint is keyboard hint mode (hint.go).
167 hint hintState167 hint hintState
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
168 // switcher is the column switcher in progress: the screen it is on and171 // switcher is the column switcher in progress: the screen it is on and
169 // the timer that shows its cards (switcher.go).172 // the timer that shows its cards (switcher.go).
170 switcher struct {173 switcher struct {
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 {
M internal/core/hint.go +31 -6
@@ -52,7 +52,7 @@ type hintState struct {
5252 // without a scanner, while the session is protected, while hint mode is
5353 // active, or on an output with a non-normal transform.
5454 func (c *Core) hintRequest() {
55- if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle {
55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
5656 return
5757 }
5858 sc := c.cur()
@@ -68,6 +68,15 @@ func (c *Core) hintRequest() {
6868 c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
6969 }
7070
71+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
7180 // hintResult handles a scanner result and reports whether to publish.
7281 func (c *Core) hintResult(res ports.HintScanResult) bool {
7382 h := &c.hint
@@ -186,27 +195,43 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
186195 }
187196
188197 // hintClick ends hint mode and left-clicks the target's centre, provided the
189-// same window is still there.
198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
190201 func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
191202 c.hintCancel()
192203 if err := c.publish(ctx); err != nil {
193204 return err
194205 }
195- if c.security.Protected {
196- return nil
197- }
198- if id, _, _ := c.hit(t.x, t.y); id != t.window {
206+ if !c.hintClickable(t) {
199207 return nil
200208 }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
201211 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
202212 return err
203213 }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
204219 if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
205220 return err
206221 }
207222 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
208223 }
209224
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
210235 // hintLabels are the labels to draw on sc: those still matching what was
211236 // typed. Nil unless sc is the hint output.
212237 func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
@@ -52,7 +52,7 @@ type hintState struct {
52 // without a scanner, while the session is protected, while hint mode is52 // without a scanner, while the session is protected, while hint mode is
53 // active, or on an output with a non-normal transform.53 // active, or on an output with a non-normal transform.
54 func (c *Core) hintRequest() {54 func (c *Core) hintRequest() {
55- if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle {55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
56 return56 return
57 }57 }
58 sc := c.cur()58 sc := c.cur()
@@ -68,6 +68,15 @@ func (c *Core) hintRequest() {
68 c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}68 c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
69 }69 }
70 70
71+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
71 // hintResult handles a scanner result and reports whether to publish.80 // hintResult handles a scanner result and reports whether to publish.
72 func (c *Core) hintResult(res ports.HintScanResult) bool {81 func (c *Core) hintResult(res ports.HintScanResult) bool {
73 h := &c.hint82 h := &c.hint
@@ -186,27 +195,43 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
186 }195 }
187 196
188 // hintClick ends hint mode and left-clicks the target's centre, provided the197 // hintClick ends hint mode and left-clicks the target's centre, provided the
189-// same window is still there.198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
190 func (c *Core) hintClick(ctx context.Context, t hintTarget) error {201 func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
191 c.hintCancel()202 c.hintCancel()
192 if err := c.publish(ctx); err != nil {203 if err := c.publish(ctx); err != nil {
193 return err204 return err
194 }205 }
195- if c.security.Protected {206+ if !c.hintClickable(t) {
196- return nil
197- }
198- if id, _, _ := c.hit(t.x, t.y); id != t.window {
199 return nil207 return nil
200 }208 }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
201 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {211 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
202 return err212 return err
203 }213 }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
204 if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {219 if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
205 return err220 return err
206 }221 }
207 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})222 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
208 }223 }
209 224
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
210 // hintLabels are the labels to draw on sc: those still matching what was235 // hintLabels are the labels to draw on sc: those still matching what was
211 // typed. Nil unless sc is the hint output.236 // typed. Nil unless sc is the hint output.
212 func (c *Core) hintLabels(sc *screen) []ports.HintLabel {237 func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
M internal/core/hint_test.go +152 -1
@@ -91,7 +91,7 @@ func (h *hintHarness) start() ports.HintScanRequest {
9191 h.t.Helper()
9292 h.press("g", ports.ModSuper)
9393 req := receive(h.t, h.scans)
94- h.release("g", ports.ModSuper)
94+ h.release("g", 0) // the user lets go of the whole chord
9595 return req
9696 }
9797
@@ -424,3 +424,154 @@ func TestHintWindowMovedAborts(t *testing.T) {
424424 }
425425 }
426426 }
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}
@@ -91,7 +91,7 @@ func (h *hintHarness) start() ports.HintScanRequest {
91 h.t.Helper()91 h.t.Helper()
92 h.press("g", ports.ModSuper)92 h.press("g", ports.ModSuper)
93 req := receive(h.t, h.scans)93 req := receive(h.t, h.scans)
94- h.release("g", ports.ModSuper)94+ h.release("g", 0) // the user lets go of the whole chord
95 return req95 return req
96 }96 }
97 97
@@ -424,3 +424,154 @@ func TestHintWindowMovedAborts(t *testing.T) {
424 }424 }
425 }425 }
426 }426 }
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}

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 26

Tags 10

↑↓ moveenter openesc close