feat(headless): hold modifiers across scripted pointer actions
- commit
- c5fa0d04e42273db3068f7bb35c618fae8c3c925
- author
- brice · · 2026-10-21 05:37 UTC
- committer
- brice · · 2026-10-21 07:11 UTC
- parent
- 8cf90bb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +74 -0
M docs/dev/headless.md +1 -0
| @@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png` | |||
| 41 | 41 | | `type <text>` | Type text | | |
| 42 | 42 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | |
| 43 | 43 | | `hold Super+h 1s` | Press a key like `key`, hold it, then release it | | |
| 44 | + | | `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) | | |
| 44 | 45 | | `sleep 1s` | Wait | | |
| 45 | 46 | | `move <x> <y>` | Move the pointer, in output coordinates | | |
| 46 | 47 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | |
| @@ -41,6 +41,7 @@ The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png` | |||||
| 41 | | `type <text>` | Type text | | 41 | | `type <text>` | Type text | | ||
| 42 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | 42 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | ||
| 43 | | `hold Super+h 1s` | Press a key like `key`, hold it, then release it | | 43 | | `hold Super+h 1s` | Press a key like `key`, hold it, then release it | | ||
| 44 | + | | `press Super`, `release Super` | Hold or release a modifier across mouse actions (`Super`, `Shift`, `Ctrl`, `Alt`) | | |||
| 44 | | `sleep 1s` | Wait | | 45 | | `sleep 1s` | Wait | | ||
| 45 | | `move <x> <y>` | Move the pointer, in output coordinates | | 46 | | `move <x> <y>` | Move the pointer, in output coordinates | | ||
| 46 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | 47 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | ||
M internal/adapters/headlessinput/input.go +24 -0
| @@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 166 | 166 | return 0 | |
| 167 | 167 | } | |
| 168 | 168 | now := monotonic() | |
| 169 | + | // Separate modifier edges allow mouse actions while a modifier is held. | |
| 170 | + | if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") { | |
| 171 | + | var code uint32 | |
| 172 | + | if len(fields) == 2 { | |
| 173 | + | switch fields[1] { | |
| 174 | + | case "Super": | |
| 175 | + | code = 125 | |
| 176 | + | case "Shift": | |
| 177 | + | code = 42 | |
| 178 | + | case "Ctrl": | |
| 179 | + | code = 29 | |
| 180 | + | case "Alt": | |
| 181 | + | code = 56 | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if code == 0 { | |
| 185 | + | warnInput("line", line, "invalid modifier") | |
| 186 | + | continue | |
| 187 | + | } | |
| 188 | + | if err := emit(code, fields[0] == "press"); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | continue | |
| 192 | + | } | |
| 169 | 193 | if len(fields) > 0 && fields[0] == "move" { | |
| 170 | 194 | if len(fields) == 3 { | |
| 171 | 195 | x, ex := strconv.ParseFloat(fields[1], 64) | |
| @@ -166,6 +166,30 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||||
| 166 | return 0 | 166 | return 0 | ||
| 167 | } | 167 | } | ||
| 168 | now := monotonic() | 168 | now := monotonic() | ||
| 169 | + | // Separate modifier edges allow mouse actions while a modifier is held. | |||
| 170 | + | if len(fields) > 0 && (fields[0] == "press" || fields[0] == "release") { | |||
| 171 | + | var code uint32 | |||
| 172 | + | if len(fields) == 2 { | |||
| 173 | + | switch fields[1] { | |||
| 174 | + | case "Super": | |||
| 175 | + | code = 125 | |||
| 176 | + | case "Shift": | |||
| 177 | + | code = 42 | |||
| 178 | + | case "Ctrl": | |||
| 179 | + | code = 29 | |||
| 180 | + | case "Alt": | |||
| 181 | + | code = 56 | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | if code == 0 { | |||
| 185 | + | warnInput("line", line, "invalid modifier") | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | if err := emit(code, fields[0] == "press"); err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | continue | |||
| 192 | + | } | |||
| 169 | if len(fields) > 0 && fields[0] == "move" { | 193 | if len(fields) > 0 && fields[0] == "move" { | ||
| 170 | if len(fields) == 3 { | 194 | if len(fields) == 3 { | ||
| 171 | x, ex := strconv.ParseFloat(fields[1], 64) | 195 | x, ex := strconv.ParseFloat(fields[1], 64) | ||
M internal/adapters/headlessinput/input_test.go +49 -0
| @@ -23,6 +23,14 @@ func TestScript(t *testing.T) { | |||
| 23 | 23 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | |
| 24 | 24 | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | |
| 25 | 25 | {"hold h", nil, nil}, | |
| 26 | + | {"press Super", []string{"Super_L"}, []uint32{125}}, | |
| 27 | + | {"release Super", []string{"Super_L"}, []uint32{125}}, | |
| 28 | + | {"press Shift", []string{"Shift_L"}, []uint32{42}}, | |
| 29 | + | {"press Ctrl", []string{"Control_L"}, []uint32{29}}, | |
| 30 | + | {"press Alt", []string{"Alt_L"}, []uint32{56}}, | |
| 31 | + | {"press", nil, nil}, | |
| 32 | + | {"release Super extra", nil, nil}, | |
| 33 | + | {"press unknown", nil, nil}, | |
| 26 | 34 | } { | |
| 27 | 35 | t.Run(tt.line, func(t *testing.T) { | |
| 28 | 36 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| @@ -61,6 +69,47 @@ func TestScript(t *testing.T) { | |||
| 61 | 69 | } | |
| 62 | 70 | } | |
| 63 | 71 | ||
| 72 | + | func TestModifierAcrossMouseActions(t *testing.T) { | |
| 73 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | script := make(chan string, 8) | |
| 78 | + | for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} { | |
| 79 | + | script <- line | |
| 80 | + | } | |
| 81 | + | close(script) | |
| 82 | + | input := make(chan ports.InputEvent, 16) | |
| 83 | + | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | if len(input) != 6 { | |
| 87 | + | t.Fatal(len(input)) | |
| 88 | + | } | |
| 89 | + | press := (<-input).(ports.KeyEvent) | |
| 90 | + | if !press.Pressed || press.Mods&ports.ModSuper == 0 { | |
| 91 | + | t.Fatal(press) | |
| 92 | + | } | |
| 93 | + | if _, ok := (<-input).(ports.PointerMotion); !ok { | |
| 94 | + | t.Fatal("missing motion") | |
| 95 | + | } | |
| 96 | + | down := (<-input).(ports.PointerButton) | |
| 97 | + | if down.Button != 0x111 || !down.Pressed { | |
| 98 | + | t.Fatal(down) | |
| 99 | + | } | |
| 100 | + | if _, ok := (<-input).(ports.PointerMotion); !ok { | |
| 101 | + | t.Fatal("missing drag motion") | |
| 102 | + | } | |
| 103 | + | up := (<-input).(ports.PointerButton) | |
| 104 | + | if up.Button != 0x111 || up.Pressed { | |
| 105 | + | t.Fatal(up) | |
| 106 | + | } | |
| 107 | + | release := (<-input).(ports.KeyEvent) | |
| 108 | + | if release.Pressed || release.Mods&ports.ModSuper != 0 { | |
| 109 | + | t.Fatal(release) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 64 | 113 | // hold keeps the key and its modifiers down for the given duration, then | |
| 65 | 114 | // releases the key before the modifier. | |
| 66 | 115 | func TestHoldKeepsKeyDown(t *testing.T) { | |
| @@ -23,6 +23,14 @@ func TestScript(t *testing.T) { | |||||
| 23 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | 23 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | ||
| 24 | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | 24 | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | ||
| 25 | {"hold h", nil, nil}, | 25 | {"hold h", nil, nil}, | ||
| 26 | + | {"press Super", []string{"Super_L"}, []uint32{125}}, | |||
| 27 | + | {"release Super", []string{"Super_L"}, []uint32{125}}, | |||
| 28 | + | {"press Shift", []string{"Shift_L"}, []uint32{42}}, | |||
| 29 | + | {"press Ctrl", []string{"Control_L"}, []uint32{29}}, | |||
| 30 | + | {"press Alt", []string{"Alt_L"}, []uint32{56}}, | |||
| 31 | + | {"press", nil, nil}, | |||
| 32 | + | {"release Super extra", nil, nil}, | |||
| 33 | + | {"press unknown", nil, nil}, | |||
| 26 | } { | 34 | } { | ||
| 27 | t.Run(tt.line, func(t *testing.T) { | 35 | t.Run(tt.line, func(t *testing.T) { | ||
| 28 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | 36 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | ||
| @@ -61,6 +69,47 @@ func TestScript(t *testing.T) { | |||||
| 61 | } | 69 | } | ||
| 62 | } | 70 | } | ||
| 63 | 71 | ||||
| 72 | + | func TestModifierAcrossMouseActions(t *testing.T) { | |||
| 73 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | script := make(chan string, 8) | |||
| 78 | + | for _, line := range []string{"press Super", "move 500 100", "down right", "move 700 100", "up right", "release Super"} { | |||
| 79 | + | script <- line | |||
| 80 | + | } | |||
| 81 | + | close(script) | |||
| 82 | + | input := make(chan ports.InputEvent, 16) | |||
| 83 | + | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | if len(input) != 6 { | |||
| 87 | + | t.Fatal(len(input)) | |||
| 88 | + | } | |||
| 89 | + | press := (<-input).(ports.KeyEvent) | |||
| 90 | + | if !press.Pressed || press.Mods&ports.ModSuper == 0 { | |||
| 91 | + | t.Fatal(press) | |||
| 92 | + | } | |||
| 93 | + | if _, ok := (<-input).(ports.PointerMotion); !ok { | |||
| 94 | + | t.Fatal("missing motion") | |||
| 95 | + | } | |||
| 96 | + | down := (<-input).(ports.PointerButton) | |||
| 97 | + | if down.Button != 0x111 || !down.Pressed { | |||
| 98 | + | t.Fatal(down) | |||
| 99 | + | } | |||
| 100 | + | if _, ok := (<-input).(ports.PointerMotion); !ok { | |||
| 101 | + | t.Fatal("missing drag motion") | |||
| 102 | + | } | |||
| 103 | + | up := (<-input).(ports.PointerButton) | |||
| 104 | + | if up.Button != 0x111 || up.Pressed { | |||
| 105 | + | t.Fatal(up) | |||
| 106 | + | } | |||
| 107 | + | release := (<-input).(ports.KeyEvent) | |||
| 108 | + | if release.Pressed || release.Mods&ports.ModSuper != 0 { | |||
| 109 | + | t.Fatal(release) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 64 | // hold keeps the key and its modifiers down for the given duration, then | 113 | // hold keeps the key and its modifiers down for the given duration, then | ||
| 65 | // releases the key before the modifier. | 114 | // releases the key before the modifier. | ||
| 66 | func TestHoldKeepsKeyDown(t *testing.T) { | 115 | func TestHoldKeepsKeyDown(t *testing.T) { | ||