feat(core): click text links with configurable modifiers
- commit
- 6dbcb7b249db3a629b7e3fcde3e948f11b6ce584
- author
- brice · · 2026-10-19 13:47 UTC
- parent
- 6c0dbb6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 164 | 164 | c.Terminal.Command = []string{"foot"} | |
| 165 | 165 | } | |
| 166 | 166 | c.Terminal.AutoOpen = "first" | |
| 167 | + | c.Hint.TextClick = ports.ModCtrl | ports.ModShift | |
| 167 | 168 | c.Xwayland = "xwayland-satellite" | |
| 168 | 169 | c.Background.Color = "#111111" | |
| 169 | 170 | c.Floating.Dim = 0.3 | |
| @@ -876,6 +877,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 876 | 877 | default: | |
| 877 | 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 | 886 | case "focus.flash-width": | |
| 880 | 887 | n, err := strconv.Atoi(v) | |
| 881 | 888 | if err != nil || n < 1 || n > 8 { | |
| @@ -1299,6 +1306,36 @@ func checkAction(v string) error { | |||
| 1299 | 1306 | 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 | 1339 | // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`: | |
| 1303 | 1340 | // modifiers sorted, the key as an xkb keysym name. | |
| 1304 | 1341 | 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.3 | 170 | 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 nil | 1306 | 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) { | |||
| 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 | + | } | |
| @@ -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 { | |||
| 35 | 35 | window WindowID | |
| 36 | 36 | label string | |
| 37 | 37 | rect ports.Rect | |
| 38 | + | text bool // found by OCR | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | type hintState struct { | |
| @@ -126,7 +127,7 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 126 | 127 | // A layer surface, not a managed window. | |
| 127 | 128 | 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 | 132 | if len(kept) == 0 { | |
| 132 | 133 | c.hintCancel() | |
| @@ -219,7 +220,11 @@ func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) { | |||
| 219 | 220 | // pointer is free and the same window is still under it. The click is | |
| 220 | 221 | // synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may | |
| 221 | 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 | 228 | c.hintCancel() | |
| 224 | 229 | if err := c.publish(ctx); err != nil { | |
| 225 | 230 | return err | |
| @@ -227,6 +232,19 @@ func (c *Core) hintClick(ctx context.Context, t hintTarget) error { | |||
| 227 | 232 | if !c.hintClickable(t) { | |
| 228 | 233 | 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 | 248 | c.synthetic = true | |
| 231 | 249 | defer func() { c.synthetic = false }() | |
| 232 | 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 | 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 | 273 | // hintClickable reports whether a click on t may go out now: unprotected, | |
| 247 | 274 | // pointer free, and the selected window is still under the target's centre. | |
| 248 | 275 | func (c *Core) hintClickable(t hintTarget) bool { | |
| @@ -35,6 +35,7 @@ type hintTarget struct { | |||||
| 35 | window WindowID | 35 | window WindowID | ||
| 36 | label string | 36 | label string | ||
| 37 | rect ports.Rect | 37 | 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 | continue | 128 | 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 is | 220 | // 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 may | 221 | // 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 err | 230 | 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 nil | 233 | 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 = true | 248 | 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 { | |||
| 29 | 29 | ||
| 30 | 30 | // newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped. | |
| 31 | 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 | 38 | t.Helper() | |
| 33 | 39 | cfg := scrollDefaults() | |
| 40 | + | cfg.Hint.TextClick = textClick | |
| 34 | 41 | cfg.Border.Width = 0 | |
| 35 | 42 | cfg.Layout.Gaps = 20 | |
| 36 | 43 | cfg.Binds["Cmd+g"] = "hint" | |
| @@ -580,3 +587,122 @@ func TestHintMappedWindowMovedAborts(t *testing.T) { | |||
| 580 | 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 | + | } | |
| @@ -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 = 0 | 41 | cfg.Border.Width = 0 | ||
| 35 | cfg.Layout.Gaps = 20 | 42 | 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 { | |||
| 124 | 124 | return nil | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | // clickFocus focuses the window id and its output the way a click on it | |
| 128 | + | // does, unless it already has the keyboard. | |
| 129 | + | func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |
| 130 | + | s, w := c.screenOf(id) | |
| 131 | + | if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) { | |
| 132 | + | return nil | |
| 133 | + | } | |
| 134 | + | now := c.now() | |
| 135 | + | before := c.snapshot(now) | |
| 136 | + | w.Click(id) | |
| 137 | + | c.focusScreen = c.screenIndex(s.name()) | |
| 138 | + | c.transition(before, now) | |
| 139 | + | return c.publish(ctx) | |
| 140 | + | } | |
| 141 | + | ||
| 127 | 142 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |
| 128 | 143 | // Buttons a drag took stay in c.buttons until released, so the | |
| 129 | 144 | // security gate admits their release; no client sees it. | |
| @@ -199,14 +214,8 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||
| 199 | 214 | } | |
| 200 | 215 | } | |
| 201 | 216 | // A click focuses the window and its output. | |
| 202 | - | s, w := c.screenOf(id) | |
| 203 | - | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |
| 204 | - | now := c.now() | |
| 205 | - | before := c.snapshot(now) | |
| 206 | - | w.Click(id) | |
| 207 | - | c.focusScreen = c.screenIndex(s.name()) | |
| 208 | - | c.transition(before, now) | |
| 209 | - | if err := c.publish(ctx); err != nil { | |
| 217 | + | if v.Pressed { | |
| 218 | + | if err := c.clickFocus(ctx, id); err != nil { | |
| 210 | 219 | return err | |
| 211 | 220 | } | |
| 212 | 221 | } | |
| @@ -124,6 +124,21 @@ func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error { | |||||
| 124 | return nil | 124 | return nil | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | + | // clickFocus focuses the window id and its output the way a click on it | |||
| 128 | + | // does, unless it already has the keyboard. | |||
| 129 | + | func (c *Core) clickFocus(ctx context.Context, id WindowID) error { | |||
| 130 | + | s, w := c.screenOf(id) | |||
| 131 | + | if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) { | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | now := c.now() | |||
| 135 | + | before := c.snapshot(now) | |||
| 136 | + | w.Click(id) | |||
| 137 | + | c.focusScreen = c.screenIndex(s.name()) | |||
| 138 | + | c.transition(before, now) | |||
| 139 | + | return c.publish(ctx) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 127 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | 142 | func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | ||
| 128 | // Buttons a drag took stay in c.buttons until released, so the | 143 | // Buttons a drag took stay in c.buttons until released, so the | ||
| 129 | // security gate admits their release; no client sees it. | 144 | // security gate admits their release; no client sees it. | ||
| @@ -199,14 +214,8 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||||
| 199 | } | 214 | } | ||
| 200 | } | 215 | } | ||
| 201 | // A click focuses the window and its output. | 216 | // A click focuses the window and its output. | ||
| 202 | - | s, w := c.screenOf(id) | 217 | + | if v.Pressed { |
| 203 | - | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | 218 | + | if err := c.clickFocus(ctx, id); err != nil { |
| 204 | - | now := c.now() | |||
| 205 | - | before := c.snapshot(now) | |||
| 206 | - | w.Click(id) | |||
| 207 | - | c.focusScreen = c.screenIndex(s.name()) | |||
| 208 | - | c.transition(before, now) | |||
| 209 | - | if err := c.publish(ctx); err != nil { | |||
| 210 | return err | 219 | return err | ||
| 211 | } | 220 | } | ||
| 212 | } | 221 | } | ||