detached 6dbcb7b

feat(core): click text links with configurable modifiers

commit
6dbcb7b249db3a629b7e3fcde3e948f11b6ce584
author
brice · · 2026-10-19 13:47 UTC
parent
6c0dbb6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +234 -10
M internal/adapters/config/config.go +37 -0
@@ -164,6 +164,7 @@ func Defaults() ports.Config {
164164 c.Terminal.Command = []string{"foot"}
165165 }
166166 c.Terminal.AutoOpen = "first"
167+ c.Hint.TextClick = ports.ModCtrl | ports.ModShift
167168 c.Xwayland = "xwayland-satellite"
168169 c.Background.Color = "#111111"
169170 c.Floating.Dim = 0.3
@@ -876,6 +877,12 @@ func set(c *ports.Config, key, v string) error {
876877 default:
877878 return fmt.Errorf("must be clickable or links")
878879 }
880+ case "hint.text-click":
881+ m, err := parseTextClick(v)
882+ if err != nil {
883+ return err
884+ }
885+ c.Hint.TextClick = m
879886 case "focus.flash-width":
880887 n, err := strconv.Atoi(v)
881888 if err != nil || n < 1 || n > 8 {
@@ -1299,6 +1306,36 @@ func checkAction(v string) error {
12991306 return nil
13001307 }
13011308
1309+// parseTextClick parses hint.text-click: `+`-separated modifier names as in
1310+// binds, or none. cmd is an alias of super here (it is not resolved through
1311+// keyboard.cmd, so the result does not depend on key order).
1312+func parseTextClick(v string) (ports.Mods, error) {
1313+ if strings.ToLower(v) == "none" {
1314+ return 0, nil
1315+ }
1316+ var mods ports.Mods
1317+ for _, p := range strings.Split(v, "+") {
1318+ var m ports.Mods
1319+ switch strings.ToLower(strings.TrimSpace(p)) {
1320+ case "shift":
1321+ m = ports.ModShift
1322+ case "ctrl":
1323+ m = ports.ModCtrl
1324+ case "alt":
1325+ m = ports.ModAlt
1326+ case "super", "cmd":
1327+ m = ports.ModSuper
1328+ default:
1329+ return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p)
1330+ }
1331+ if mods&m != 0 {
1332+ return 0, fmt.Errorf("duplicate modifier %q", p)
1333+ }
1334+ mods |= m
1335+ }
1336+ return mods, nil
1337+}
1338+
13021339 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
13031340 // modifiers sorted, the key as an xkb keysym name.
13041341 func parseCombo(s string) (string, error) {
@@ -164,6 +164,7 @@ func Defaults() ports.Config {
164 c.Terminal.Command = []string{"foot"}164 c.Terminal.Command = []string{"foot"}
165 }165 }
166 c.Terminal.AutoOpen = "first"166 c.Terminal.AutoOpen = "first"
167+ c.Hint.TextClick = ports.ModCtrl | ports.ModShift
167 c.Xwayland = "xwayland-satellite"168 c.Xwayland = "xwayland-satellite"
168 c.Background.Color = "#111111"169 c.Background.Color = "#111111"
169 c.Floating.Dim = 0.3170 c.Floating.Dim = 0.3
@@ -876,6 +877,12 @@ func set(c *ports.Config, key, v string) error {
876 default:877 default:
877 return fmt.Errorf("must be clickable or links")878 return fmt.Errorf("must be clickable or links")
878 }879 }
880+ case "hint.text-click":
881+ m, err := parseTextClick(v)
882+ if err != nil {
883+ return err
884+ }
885+ c.Hint.TextClick = m
879 case "focus.flash-width":886 case "focus.flash-width":
880 n, err := strconv.Atoi(v)887 n, err := strconv.Atoi(v)
881 if err != nil || n < 1 || n > 8 {888 if err != nil || n < 1 || n > 8 {
@@ -1299,6 +1306,36 @@ func checkAction(v string) error {
1299 return nil1306 return nil
1300 }1307 }
1301 1308
1309+// parseTextClick parses hint.text-click: `+`-separated modifier names as in
1310+// binds, or none. cmd is an alias of super here (it is not resolved through
1311+// keyboard.cmd, so the result does not depend on key order).
1312+func parseTextClick(v string) (ports.Mods, error) {
1313+ if strings.ToLower(v) == "none" {
1314+ return 0, nil
1315+ }
1316+ var mods ports.Mods
1317+ for _, p := range strings.Split(v, "+") {
1318+ var m ports.Mods
1319+ switch strings.ToLower(strings.TrimSpace(p)) {
1320+ case "shift":
1321+ m = ports.ModShift
1322+ case "ctrl":
1323+ m = ports.ModCtrl
1324+ case "alt":
1325+ m = ports.ModAlt
1326+ case "super", "cmd":
1327+ m = ports.ModSuper
1328+ default:
1329+ return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p)
1330+ }
1331+ if mods&m != 0 {
1332+ return 0, fmt.Errorf("duplicate modifier %q", p)
1333+ }
1334+ mods |= m
1335+ }
1336+ return mods, nil
1337+}
1338+
1302 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:1339 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
1303 // modifiers sorted, the key as an xkb keysym name.1340 // modifiers sorted, the key as an xkb keysym name.
1304 func parseCombo(s string) (string, error) {1341 func parseCombo(s string) (string, error) {
M internal/adapters/config/config_test.go +25 -0
@@ -993,3 +993,28 @@ func TestHintStyleConfig(t *testing.T) {
993993 }
994994 }
995995 }
996+
997+func TestHintTextClick(t *testing.T) {
998+ if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
999+ t.Fatalf("default %d", got)
1000+ }
1001+ for in, want := range map[string]ports.Mods{
1002+ "ctrl+shift": ports.ModCtrl | ports.ModShift,
1003+ "Alt": ports.ModAlt,
1004+ "super": ports.ModSuper,
1005+ "cmd+shift": ports.ModSuper | ports.ModShift,
1006+ "none": 0,
1007+ "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1008+ } {
1009+ c, w := parseString(t, "hint.text-click = "+in+"\n")
1010+ if c.Hint.TextClick != want || len(w) != 0 {
1011+ t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1012+ }
1013+ }
1014+ for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1015+ c, w := parseString(t, "hint.text-click = "+bad+"\n")
1016+ if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1017+ t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1018+ }
1019+ }
1020+}
@@ -993,3 +993,28 @@ func TestHintStyleConfig(t *testing.T) {
993 }993 }
994 }994 }
995 }995 }
996+
997+func TestHintTextClick(t *testing.T) {
998+ if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
999+ t.Fatalf("default %d", got)
1000+ }
1001+ for in, want := range map[string]ports.Mods{
1002+ "ctrl+shift": ports.ModCtrl | ports.ModShift,
1003+ "Alt": ports.ModAlt,
1004+ "super": ports.ModSuper,
1005+ "cmd+shift": ports.ModSuper | ports.ModShift,
1006+ "none": 0,
1007+ "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1008+ } {
1009+ c, w := parseString(t, "hint.text-click = "+in+"\n")
1010+ if c.Hint.TextClick != want || len(w) != 0 {
1011+ t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1012+ }
1013+ }
1014+ for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1015+ c, w := parseString(t, "hint.text-click = "+bad+"\n")
1016+ if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1017+ t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1018+ }
1019+ }
1020+}
M internal/core/hint.go +29 -2
@@ -35,6 +35,7 @@ type hintTarget struct {
3535 window WindowID
3636 label string
3737 rect ports.Rect
38+ text bool // found by OCR
3839 }
3940
4041 type hintState struct {
@@ -126,7 +127,7 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
126127 // A layer surface, not a managed window.
127128 continue
128129 }
129- kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
130+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
130131 }
131132 if len(kept) == 0 {
132133 c.hintCancel()
@@ -219,7 +220,11 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
219220 // pointer is free and the same window is still under it. The click is
220221 // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
221222 // still be down) from turning it into a window drag, without touching c.mods.
222-func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
223+//
224+// A target found by OCR is clicked with hint.text-click held (terminals open
225+// a link on a modified click): the window gets the keyboard first, so it sees
226+// the modifiers, and the real modifier state is always sent back afterwards.
227+func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
223228 c.hintCancel()
224229 if err := c.publish(ctx); err != nil {
225230 return err
@@ -227,6 +232,19 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
227232 if !c.hintClickable(t) {
228233 return nil
229234 }
235+ if mods := c.hintMods(t); mods != 0 {
236+ if err := c.clickFocus(ctx, t.window); err != nil {
237+ return err
238+ }
239+ if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: mods}); err != nil {
240+ return err
241+ }
242+ defer func() {
243+ if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true}); err == nil {
244+ err = e
245+ }
246+ }()
247+ }
230248 c.synthetic = true
231249 defer func() { c.synthetic = false }()
232250 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
@@ -243,6 +261,15 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
243261 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
244262 }
245263
264+// hintMods are the modifiers to hold for a click on t, read at click time so
265+// a reloaded configuration applies.
266+func (c *Core) hintMods(t hintTarget) ports.Mods {
267+ if !t.text {
268+ return 0
269+ }
270+ return c.cfg.Hint.TextClick
271+}
272+
246273 // hintClickable reports whether a click on t may go out now: unprotected,
247274 // pointer free, and the selected window is still under the target's centre.
248275 func (c *Core) hintClickable(t hintTarget) bool {
@@ -35,6 +35,7 @@ type hintTarget struct {
35 window WindowID35 window WindowID
36 label string36 label string
37 rect ports.Rect37 rect ports.Rect
38+ text bool // found by OCR
38 }39 }
39 40
40 type hintState struct {41 type hintState struct {
@@ -126,7 +127,7 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
126 // A layer surface, not a managed window.127 // A layer surface, not a managed window.
127 continue128 continue
128 }129 }
129- kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})130+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
130 }131 }
131 if len(kept) == 0 {132 if len(kept) == 0 {
132 c.hintCancel()133 c.hintCancel()
@@ -219,7 +220,11 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
219 // pointer is free and the same window is still under it. The click is220 // pointer is free and the same window is still under it. The click is
220 // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may221 // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
221 // still be down) from turning it into a window drag, without touching c.mods.222 // still be down) from turning it into a window drag, without touching c.mods.
222-func (c *Core) hintClick(ctx context.Context, t hintTarget) error {223+//
224+// A target found by OCR is clicked with hint.text-click held (terminals open
225+// a link on a modified click): the window gets the keyboard first, so it sees
226+// the modifiers, and the real modifier state is always sent back afterwards.
227+func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
223 c.hintCancel()228 c.hintCancel()
224 if err := c.publish(ctx); err != nil {229 if err := c.publish(ctx); err != nil {
225 return err230 return err
@@ -227,6 +232,19 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
227 if !c.hintClickable(t) {232 if !c.hintClickable(t) {
228 return nil233 return nil
229 }234 }
235+ if mods := c.hintMods(t); mods != 0 {
236+ if err := c.clickFocus(ctx, t.window); err != nil {
237+ return err
238+ }
239+ if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: mods}); err != nil {
240+ return err
241+ }
242+ defer func() {
243+ if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true}); err == nil {
244+ err = e
245+ }
246+ }()
247+ }
230 c.synthetic = true248 c.synthetic = true
231 defer func() { c.synthetic = false }()249 defer func() { c.synthetic = false }()
232 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {250 if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
@@ -243,6 +261,15 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
243 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})261 return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
244 }262 }
245 263
264+// hintMods are the modifiers to hold for a click on t, read at click time so
265+// a reloaded configuration applies.
266+func (c *Core) hintMods(t hintTarget) ports.Mods {
267+ if !t.text {
268+ return 0
269+ }
270+ return c.cfg.Hint.TextClick
271+}
272+
246 // hintClickable reports whether a click on t may go out now: unprotected,273 // hintClickable reports whether a click on t may go out now: unprotected,
247 // pointer free, and the selected window is still under the target's centre.274 // pointer free, and the selected window is still under the target's centre.
248 func (c *Core) hintClickable(t hintTarget) bool {275 func (c *Core) hintClickable(t hintTarget) bool {
M internal/core/hint_test.go +126 -0
@@ -29,8 +29,15 @@ type hintHarness struct {
2929
3030 // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
3131 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 {
3238 t.Helper()
3339 cfg := scrollDefaults()
40+ cfg.Hint.TextClick = textClick
3441 cfg.Border.Width = 0
3542 cfg.Layout.Gaps = 20
3643 cfg.Binds["Cmd+g"] = "hint"
@@ -580,3 +587,122 @@ func TestHintMappedWindowMovedAborts(t *testing.T) {
580587 t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
581588 }
582589 }
590+
591+// commandsSent drains the commands after a short wait, keeping the ones that
592+// matter for a click: modifiers, focus, motion and buttons.
593+func (h *hintHarness) commandsSent() []ports.ClientCommand {
594+ time.Sleep(50 * time.Millisecond)
595+ 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
605+ }
606+ }
607+}
608+
609+func commandKinds(cmds []ports.ClientCommand) []string {
610+ var out []string
611+ for _, c := range cmds {
612+ switch v := c.(type) {
613+ case ports.SetModifiers:
614+ if v.Restore {
615+ out = append(out, "restore")
616+ } else {
617+ out = append(out, fmt.Sprintf("mods:%d", v.Mods))
618+ }
619+ case ports.FocusWindow:
620+ out = append(out, fmt.Sprintf("focus:%d", v.ID))
621+ case ports.PointerMotionTo:
622+ out = append(out, "motion")
623+ case ports.PointerButtonTo:
624+ if v.Pressed {
625+ out = append(out, "press")
626+ } else {
627+ out = append(out, "release")
628+ }
629+ }
630+ }
631+ return out
632+}
633+
634+// An OCR target is clicked with the configured modifiers held: they go out
635+// before the motion and are restored after the release.
636+func TestHintTextClickHoldsModifiers(t *testing.T) {
637+ mods := ports.ModCtrl | ports.ModShift
638+ h := newHintMods(t, core.Options{}, mods)
639+ tg := h.inside(0)
640+ tg.Text = true
641+ h.show(tg)
642+ h.drain()
643+ h.press("f", 0)
644+ got := commandKinds(h.commandsSent())
645+ want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"}
646+ if !slices.Equal(got, want) {
647+ t.Fatalf("commands %v, want %v", got, want)
648+ }
649+}
650+
651+// Without text targets or without modifiers the click is exactly as before.
652+func TestHintClickWithoutModifiers(t *testing.T) {
653+ for name, c := range map[string]struct {
654+ mods ports.Mods
655+ text bool
656+ }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} {
657+ t.Run(name, func(t *testing.T) {
658+ h := newHintMods(t, core.Options{}, c.mods)
659+ tg := h.inside(0)
660+ tg.Text = c.text
661+ h.show(tg)
662+ h.drain()
663+ h.press("f", 0)
664+ got := commandKinds(h.commandsSent())
665+ if want := []string{"motion", "press", "release"}; !slices.Equal(got, want) {
666+ t.Fatalf("commands %v, want %v", got, want)
667+ }
668+ })
669+ }
670+}
671+
672+// A window that does not have the keyboard gets it before the modifiers.
673+func TestHintTextClickFocusesWindowFirst(t *testing.T) {
674+ h := newHintMods(t, core.Options{}, ports.ModCtrl)
675+ h.client <- ports.WindowMapped{ID: 2}
676+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
677+ var r ports.Rect
678+ for _, w := range s.Windows {
679+ if w.ID == 1 {
680+ r = w.Rect
681+ }
682+ }
683+ h.win = r
684+ tg := h.inside(0)
685+ tg.Text = true
686+ h.show(tg)
687+ h.drain()
688+ h.press("f", 0)
689+ got := commandKinds(h.commandsSent())
690+ want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"}
691+ if !slices.Equal(got, want) {
692+ t.Fatalf("commands %v, want %v", got, want)
693+ }
694+}
695+
696+// A click that is no longer allowed sends no modifiers at all.
697+func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) {
698+ h := newHintMods(t, core.Options{}, ports.ModCtrl)
699+ tg := h.inside(0)
700+ tg.Text = true
701+ h.show(tg)
702+ h.constrain(ports.ConstraintLock, ports.Rect{})
703+ h.drain()
704+ h.press("f", 0)
705+ if got := commandKinds(h.commandsSent()); len(got) != 0 {
706+ t.Fatalf("commands %v under a constraint", got)
707+ }
708+}
@@ -29,8 +29,15 @@ type hintHarness struct {
29 29
30 // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.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 {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 {
32 t.Helper()38 t.Helper()
33 cfg := scrollDefaults()39 cfg := scrollDefaults()
40+ cfg.Hint.TextClick = textClick
34 cfg.Border.Width = 041 cfg.Border.Width = 0
35 cfg.Layout.Gaps = 2042 cfg.Layout.Gaps = 20
36 cfg.Binds["Cmd+g"] = "hint"43 cfg.Binds["Cmd+g"] = "hint"
@@ -580,3 +587,122 @@ func TestHintMappedWindowMovedAborts(t *testing.T) {
580 t.Fatalf("click went to %d presses %d releases after the window moved", p, r)587 t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
581 }588 }
582 }589 }
590+
591+// commandsSent drains the commands after a short wait, keeping the ones that
592+// matter for a click: modifiers, focus, motion and buttons.
593+func (h *hintHarness) commandsSent() []ports.ClientCommand {
594+ time.Sleep(50 * time.Millisecond)
595+ 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
605+ }
606+ }
607+}
608+
609+func commandKinds(cmds []ports.ClientCommand) []string {
610+ var out []string
611+ for _, c := range cmds {
612+ switch v := c.(type) {
613+ case ports.SetModifiers:
614+ if v.Restore {
615+ out = append(out, "restore")
616+ } else {
617+ out = append(out, fmt.Sprintf("mods:%d", v.Mods))
618+ }
619+ case ports.FocusWindow:
620+ out = append(out, fmt.Sprintf("focus:%d", v.ID))
621+ case ports.PointerMotionTo:
622+ out = append(out, "motion")
623+ case ports.PointerButtonTo:
624+ if v.Pressed {
625+ out = append(out, "press")
626+ } else {
627+ out = append(out, "release")
628+ }
629+ }
630+ }
631+ return out
632+}
633+
634+// An OCR target is clicked with the configured modifiers held: they go out
635+// before the motion and are restored after the release.
636+func TestHintTextClickHoldsModifiers(t *testing.T) {
637+ mods := ports.ModCtrl | ports.ModShift
638+ h := newHintMods(t, core.Options{}, mods)
639+ tg := h.inside(0)
640+ tg.Text = true
641+ h.show(tg)
642+ h.drain()
643+ h.press("f", 0)
644+ got := commandKinds(h.commandsSent())
645+ want := []string{fmt.Sprintf("mods:%d", mods), "motion", "press", "release", "restore"}
646+ if !slices.Equal(got, want) {
647+ t.Fatalf("commands %v, want %v", got, want)
648+ }
649+}
650+
651+// Without text targets or without modifiers the click is exactly as before.
652+func TestHintClickWithoutModifiers(t *testing.T) {
653+ for name, c := range map[string]struct {
654+ mods ports.Mods
655+ text bool
656+ }{"accessibility target": {ports.ModCtrl, false}, "none configured": {0, true}} {
657+ t.Run(name, func(t *testing.T) {
658+ h := newHintMods(t, core.Options{}, c.mods)
659+ tg := h.inside(0)
660+ tg.Text = c.text
661+ h.show(tg)
662+ h.drain()
663+ h.press("f", 0)
664+ got := commandKinds(h.commandsSent())
665+ if want := []string{"motion", "press", "release"}; !slices.Equal(got, want) {
666+ t.Fatalf("commands %v, want %v", got, want)
667+ }
668+ })
669+ }
670+}
671+
672+// A window that does not have the keyboard gets it before the modifiers.
673+func TestHintTextClickFocusesWindowFirst(t *testing.T) {
674+ h := newHintMods(t, core.Options{}, ports.ModCtrl)
675+ h.client <- ports.WindowMapped{ID: 2}
676+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 })
677+ var r ports.Rect
678+ for _, w := range s.Windows {
679+ if w.ID == 1 {
680+ r = w.Rect
681+ }
682+ }
683+ h.win = r
684+ tg := h.inside(0)
685+ tg.Text = true
686+ h.show(tg)
687+ h.drain()
688+ h.press("f", 0)
689+ got := commandKinds(h.commandsSent())
690+ want := []string{"focus:1", fmt.Sprintf("mods:%d", ports.ModCtrl), "motion", "press", "release", "restore"}
691+ if !slices.Equal(got, want) {
692+ t.Fatalf("commands %v, want %v", got, want)
693+ }
694+}
695+
696+// A click that is no longer allowed sends no modifiers at all.
697+func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) {
698+ h := newHintMods(t, core.Options{}, ports.ModCtrl)
699+ tg := h.inside(0)
700+ tg.Text = true
701+ h.show(tg)
702+ h.constrain(ports.ConstraintLock, ports.Rect{})
703+ h.drain()
704+ h.press("f", 0)
705+ if got := commandKinds(h.commandsSent()); len(got) != 0 {
706+ t.Fatalf("commands %v under a constraint", got)
707+ }
708+}
M internal/core/input.go +17 -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 }
@@ -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 }

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