input.go RawHistoryFolder

1// Package headlessinput converts scripted text into keyboard events.
2package headlessinput
3
4import (
5	"context"
6	"math"
7	"strconv"
8	"strings"
9	"time"
10	"unicode"
11
12	"github.com/bnema/neferwl/internal/adapters/xkb"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/zerowrap"
15)
16
17// Run owns the keymap and converts script lines into input events. A keymap
18// received on keymaps replaces the current one, which is closed. Scripted
19// moves are global logical coordinates; moveCursor, when set, places the
20// software cursor of the output under them (physical position on it), using
21// the latest layout from layouts.
22func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger) error {
23	return RunSecure(ctx, km, keymaps, script, input, layouts, moveCursor, log, nil)
24}
25
26// RunSecure snapshots the protection epoch before each event enters the input
27// queue. Scripts are a headless test transport, never an unlock authority.
28func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger, security ports.SessionSecurity) error {
29	snapshot := func() ports.SecurityState {
30		if security != nil {
31			return security.Snapshot()
32		}
33		return ports.SecurityState{}
34	}
35	stamp := func(ev ports.InputEvent, state ports.SecurityState) ports.InputEvent {
36		if security == nil {
37			return ev
38		}
39		return ports.SecurityInput{State: state, Event: ev}
40	}
41	// Script payloads can contain credentials even when parsing fails. Keep a
42	// useful generic warning while protected, never input-derived fields.
43	warnInput := func(field, value, message string) {
44		entry := log.Warn()
45		if snapshot().Protected {
46			entry.Msg("invalid script input")
47			return
48		}
49		entry.Str(field, value).Msg(message)
50	}
51	var layout ports.Layout
52	defer func() { km.Close() }()
53	emit := func(code uint32, down bool) error {
54		state := snapshot()
55		var key ports.KeyEvent
56		if security != nil {
57			var deliver bool
58			var err error
59			key, deliver, err = km.KeySecure(code, down, monotonic(), state)
60			if err != nil {
61				return err
62			}
63			if !deliver {
64				return nil
65			}
66		} else {
67			key = km.Key(code, down, monotonic())
68		}
69		ev := stamp(key, state)
70		select {
71		case <-ctx.Done():
72			return ctx.Err()
73		case input <- ev:
74		}
75		timer := time.NewTimer(2 * time.Millisecond)
76		defer timer.Stop()
77		select {
78		case <-ctx.Done():
79			return ctx.Err()
80		case <-timer.C:
81			return nil
82		}
83	}
84	// stroke presses mods then name, holds name for hold, and releases
85	// them in reverse order.
86	stroke := func(name string, mods []uint32, hold time.Duration) error {
87		code, shift, ok := km.KeycodeFor(name)
88		if !ok {
89			warnInput("key", name, "unknown key")
90			return nil
91		}
92		if shift {
93			found := false
94			for _, m := range mods {
95				if m == 42 || m == 54 {
96					found = true
97				}
98			}
99			if !found {
100				mods = append(mods, 42)
101			}
102		}
103		for _, m := range mods {
104			if err := emit(m, true); err != nil {
105				return err
106			}
107		}
108		if err := emit(code, true); err != nil {
109			return err
110		}
111		if hold > 0 {
112			timer := time.NewTimer(hold)
113			select {
114			case <-ctx.Done():
115				timer.Stop()
116				return ctx.Err()
117			case <-timer.C:
118			}
119		}
120		if err := emit(code, false); err != nil {
121			return err
122		}
123		for i := len(mods) - 1; i >= 0; i-- {
124			if err := emit(mods[i], false); err != nil {
125				return err
126			}
127		}
128		return nil
129	}
130	punctuation := map[rune]string{' ': "space", '\n': "Return", '.': "period", ',': "comma", '-': "minus", '_': "underscore", '/': "slash", ':': "colon", ';': "semicolon", '!': "exclam", '?': "question", '\'': "apostrophe", '"': "quotedbl", '(': "parenleft", ')': "parenright", '=': "equal", '+': "plus", '\\': "backslash", '@': "at", '#': "numbersign", '$': "dollar", '%': "percent", '&': "ampersand", '*': "asterisk", '[': "bracketleft", ']': "bracketright", '{': "braceleft", '}': "braceright", '<': "less", '>': "greater", '|': "bar", '`': "grave", '~': "asciitilde", '^': "asciicircum"}
131	for {
132		select {
133		case <-ctx.Done():
134			return ctx.Err()
135		case next := <-keymaps:
136			if security != nil {
137				next.QuarantineFrom(km)
138			}
139			km.Close()
140			km = next
141			log.Info().Msg("keymap replaced")
142		case layout = <-layouts:
143		case line, ok := <-script:
144			if !ok {
145				return nil
146			}
147			fields := strings.Fields(line)
148			sendPointer := func(ev ports.InputEvent) error {
149				ev = stamp(ev, snapshot())
150				select {
151				case <-ctx.Done():
152					return ctx.Err()
153				case input <- ev:
154					return nil
155				}
156			}
157			buttonCode := func(name string) uint32 {
158				switch name {
159				case "left":
160					return 0x110
161				case "right":
162					return 0x111
163				case "middle":
164					return 0x112
165				}
166				return 0
167			}
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			}
193			if len(fields) > 0 && fields[0] == "move" {
194				if len(fields) == 3 {
195					x, ex := strconv.ParseFloat(fields[1], 64)
196					y, ey := strconv.ParseFloat(fields[2], 64)
197					if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
198						if o, ok := layout.At(x, y); ok && moveCursor != nil {
199							tx, ty := o.ToTarget(x, y)
200							moveCursor(o.Info.Name, tx, ty)
201						}
202						if err := sendPointer(ports.PointerMotion{X: x, Y: y, Time: now}); err != nil {
203							return err
204						}
205						continue
206					}
207				}
208				warnInput("line", line, "invalid move")
209				continue
210			}
211			if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") {
212				dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}}
213				d, ok := [2]float64{}, len(fields) == 2
214				if ok {
215					d, ok = dirs[fields[1]]
216				}
217				if !ok {
218					warnInput("line", line, "invalid swipe")
219					continue
220				}
221				fingers := 3
222				if fields[0] == "swipe4" {
223					fingers = 4
224				}
225				for _, ev := range swipeEvents(fingers, d[0], d[1], monotonic()) {
226					if err := sendPointer(ev); err != nil {
227						return err
228					}
229				}
230				continue
231			}
232			if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") {
233				name := "left"
234				if len(fields) == 2 {
235					name = fields[1]
236				}
237				code := buttonCode(name)
238				if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {
239					warnInput("line", line, "invalid button")
240					continue
241				}
242				if fields[0] != "up" {
243					if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, Time: now}); err != nil {
244						return err
245					}
246				}
247				if fields[0] != "down" {
248					if err := sendPointer(ports.PointerButton{Button: code, Time: now}); err != nil {
249						return err
250					}
251				}
252				continue
253			}
254			switch {
255			case strings.HasPrefix(line, "type "):
256				for _, r := range strings.TrimPrefix(line, "type ") {
257					name := punctuation[r]
258					if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
259						name = string(r)
260					}
261					if name == "" {
262						warnInput("rune", string(r), "unknown rune")
263						continue
264					}
265					if err := stroke(name, nil, 0); err != nil {
266						return err
267					}
268				}
269			case strings.HasPrefix(line, "key "), strings.HasPrefix(line, "hold "):
270				combo, hold := strings.TrimPrefix(line, "key "), time.Duration(0)
271				if rest, ok := strings.CutPrefix(line, "hold "); ok {
272					f := strings.Fields(rest)
273					var err error
274					if len(f) == 2 {
275						hold, err = time.ParseDuration(f[1])
276					}
277					if len(f) != 2 || err != nil || hold <= 0 {
278						warnInput("line", line, "invalid hold")
279						continue
280					}
281					combo = f[0]
282				}
283				parts := strings.Split(combo, "+")
284				var mods []uint32
285				valid := true
286				for _, part := range parts[:len(parts)-1] {
287					switch part {
288					case "Super":
289						mods = append(mods, 125)
290					case "Shift":
291						mods = append(mods, 42)
292					case "Ctrl":
293						mods = append(mods, 29)
294					case "Alt":
295						mods = append(mods, 56)
296					default:
297						valid = false
298					}
299				}
300				if !valid {
301					warnInput("combo", line, "unknown modifier")
302					continue
303				}
304				if err := stroke(parts[len(parts)-1], mods, hold); err != nil {
305					return err
306				}
307			case strings.HasPrefix(line, "sleep "):
308				d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
309				if err != nil || d < 0 {
310					warnInput("line", line, "invalid sleep")
311					continue
312				}
313				timer := time.NewTimer(d)
314				select {
315				case <-ctx.Done():
316					timer.Stop()
317					return ctx.Err()
318				case <-timer.C:
319				}
320			default:
321				warnInput("line", line, "unknown script line")
322			}
323		}
324	}
325}

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