detached c5fa0d0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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`
4141 | `type <text>` | Type text |
4242 | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super |
4343 | `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`) |
4445 | `sleep 1s` | Wait |
4546 | `move <x> <y>` | Move the pointer, in output coordinates |
4647 | `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,
166166 return 0
167167 }
168168 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+ }
169193 if len(fields) > 0 && fields[0] == "move" {
170194 if len(fields) == 3 {
171195 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 0166 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) {
2323 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
2424 {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}},
2525 {"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},
2634 } {
2735 t.Run(tt.line, func(t *testing.T) {
2836 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -61,6 +69,47 @@ func TestScript(t *testing.T) {
6169 }
6270 }
6371
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+
64113 // hold keeps the key and its modifiers down for the given duration, then
65114 // releases the key before the modifier.
66115 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, then113 // 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) {

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