v0.1.0 d9218d6

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	var layout ports.Layout
24	defer func() { km.Close() }()
25	emit := func(code uint32, down bool) error {
26		ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
27		select {
28		case <-ctx.Done():
29			return ctx.Err()
30		case input <- ev:
31		}
32		timer := time.NewTimer(2 * time.Millisecond)
33		defer timer.Stop()
34		select {
35		case <-ctx.Done():
36			return ctx.Err()
37		case <-timer.C:
38			return nil
39		}
40	}
41	stroke := func(name string, mods []uint32) error {
42		code, shift, ok := km.KeycodeFor(name)
43		if !ok {
44			log.Warn().Str("key", name).Msg("unknown key")
45			return nil
46		}
47		if shift {
48			found := false
49			for _, m := range mods {
50				if m == 42 || m == 54 {
51					found = true
52				}
53			}
54			if !found {
55				mods = append(mods, 42)
56			}
57		}
58		for _, m := range mods {
59			if err := emit(m, true); err != nil {
60				return err
61			}
62		}
63		if err := emit(code, true); err != nil {
64			return err
65		}
66		if err := emit(code, false); err != nil {
67			return err
68		}
69		for i := len(mods) - 1; i >= 0; i-- {
70			if err := emit(mods[i], false); err != nil {
71				return err
72			}
73		}
74		return nil
75	}
76	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"}
77	for {
78		select {
79		case <-ctx.Done():
80			return ctx.Err()
81		case next := <-keymaps:
82			km.Close()
83			km = next
84			log.Info().Msg("keymap replaced")
85		case layout = <-layouts:
86		case line, ok := <-script:
87			if !ok {
88				return nil
89			}
90			fields := strings.Fields(line)
91			sendPointer := func(ev ports.InputEvent) error {
92				select {
93				case <-ctx.Done():
94					return ctx.Err()
95				case input <- ev:
96					return nil
97				}
98			}
99			buttonCode := func(name string) uint32 {
100				switch name {
101				case "left":
102					return 0x110
103				case "right":
104					return 0x111
105				case "middle":
106					return 0x112
107				}
108				return 0
109			}
110			now := uint32(time.Now().UnixMilli())
111			if len(fields) > 0 && fields[0] == "move" {
112				if len(fields) == 3 {
113					x, ex := strconv.ParseFloat(fields[1], 64)
114					y, ey := strconv.ParseFloat(fields[2], 64)
115					if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
116						if o, ok := layout.At(x, y); ok && moveCursor != nil {
117							moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)
118						}
119						if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
120							return err
121						}
122						continue
123					}
124				}
125				log.Warn().Str("line", line).Msg("invalid move")
126				continue
127			}
128			if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") {
129				name := "left"
130				if len(fields) == 2 {
131					name = fields[1]
132				}
133				code := buttonCode(name)
134				if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {
135					log.Warn().Str("line", line).Msg("invalid button")
136					continue
137				}
138				if fields[0] != "up" {
139					if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, TimeMsec: now}); err != nil {
140						return err
141					}
142				}
143				if fields[0] != "down" {
144					if err := sendPointer(ports.PointerButton{Button: code, TimeMsec: now}); err != nil {
145						return err
146					}
147				}
148				continue
149			}
150			switch {
151			case strings.HasPrefix(line, "type "):
152				for _, r := range strings.TrimPrefix(line, "type ") {
153					name := punctuation[r]
154					if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
155						name = string(r)
156					}
157					if name == "" {
158						log.Warn().Str("rune", string(r)).Msg("unknown rune")
159						continue
160					}
161					if err := stroke(name, nil); err != nil {
162						return err
163					}
164				}
165			case strings.HasPrefix(line, "key "):
166				parts := strings.Split(strings.TrimPrefix(line, "key "), "+")
167				var mods []uint32
168				valid := true
169				for _, part := range parts[:len(parts)-1] {
170					switch part {
171					case "Super":
172						mods = append(mods, 125)
173					case "Shift":
174						mods = append(mods, 42)
175					case "Ctrl":
176						mods = append(mods, 29)
177					case "Alt":
178						mods = append(mods, 56)
179					default:
180						valid = false
181					}
182				}
183				if !valid {
184					log.Warn().Str("combo", line).Msg("unknown modifier")
185					continue
186				}
187				if err := stroke(parts[len(parts)-1], mods); err != nil {
188					return err
189				}
190			case strings.HasPrefix(line, "sleep "):
191				d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
192				if err != nil || d < 0 {
193					log.Warn().Str("line", line).Msg("invalid sleep")
194					continue
195				}
196				timer := time.NewTimer(d)
197				select {
198				case <-ctx.Done():
199					timer.Stop()
200					return ctx.Err()
201				case <-timer.C:
202				}
203			default:
204				log.Warn().Str("line", line).Msg("unknown script line")
205			}
206		}
207	}
208}

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