detached 6ac92fb

feat(app): wire xkb keyboard and headless script input

commit
6ac92fb60ed1b27484f156dd04f43b581cce49c9
author
brice · · 2026-09-23 15:47 UTC
parent
18a2776
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +301 -4
M README.md +2 -0
@@ -27,3 +27,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
2727
2828 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
2929 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.
30+Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is
31+`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`.
@@ -27,3 +27,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
27 27
28 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with28 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
29 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.29 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.
30+Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is
31+`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`.
M cmd/nefertty/main.go +20 -1
@@ -79,6 +79,7 @@ func run() error {
7979 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
8080 backend := flags.String("backend", "drm", "drm or headless")
8181 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
82+ inputPath := flags.String("input", "", "headless input script path (- for stdin)")
8283 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
8384 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
8485 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
@@ -99,6 +100,11 @@ func run() error {
99100 fmt.Fprintln(os.Stderr, err)
100101 return err
101102 }
103+ if *inputPath != "" && *backend != "headless" {
104+ err := usageError{fmt.Errorf("--input requires --backend=headless")}
105+ fmt.Fprintln(os.Stderr, err)
106+ return err
107+ }
102108 if *timeout < 0 {
103109 err := usageError{fmt.Errorf("negative timeout")}
104110 fmt.Fprintln(os.Stderr, err)
@@ -128,6 +134,19 @@ func run() error {
128134 fmt.Fprintln(os.Stderr, cfgErr)
129135 return cfgErr
130136 }
137+ var script *os.File
138+ if *inputPath != "" {
139+ if *inputPath == "-" {
140+ script = os.Stdin
141+ } else {
142+ var err error
143+ script, err = os.Open(*inputPath)
144+ if err != nil {
145+ return err
146+ }
147+ defer script.Close()
148+ }
149+ }
131150 selected := mergeDebug(cfg.Log.Debug, *debugFlag)
132151 if _, err := logging.ParseDebug(selected); err != nil {
133152 fmt.Fprintln(os.Stderr, err)
@@ -144,7 +163,7 @@ func run() error {
144163 log := logging.For(ctx, "app")
145164 configLog := logging.For(ctx, "config")
146165 configLog.Info().Str("path", path).Msg("loaded config")
147- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil {
166+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
148167 // SIGINT and SIGTERM cancel the context and are clean exits.
149168 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
150169 return nil
@@ -79,6 +79,7 @@ func run() error {
79 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)79 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
80 backend := flags.String("backend", "drm", "drm or headless")80 backend := flags.String("backend", "drm", "drm or headless")
81 screenshot := flags.String("screenshot", "", "write PNG frames to directory")81 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
82+ inputPath := flags.String("input", "", "headless input script path (- for stdin)")
82 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")83 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
83 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")84 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
84 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")85 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
@@ -99,6 +100,11 @@ func run() error {
99 fmt.Fprintln(os.Stderr, err)100 fmt.Fprintln(os.Stderr, err)
100 return err101 return err
101 }102 }
103+ if *inputPath != "" && *backend != "headless" {
104+ err := usageError{fmt.Errorf("--input requires --backend=headless")}
105+ fmt.Fprintln(os.Stderr, err)
106+ return err
107+ }
102 if *timeout < 0 {108 if *timeout < 0 {
103 err := usageError{fmt.Errorf("negative timeout")}109 err := usageError{fmt.Errorf("negative timeout")}
104 fmt.Fprintln(os.Stderr, err)110 fmt.Fprintln(os.Stderr, err)
@@ -128,6 +134,19 @@ func run() error {
128 fmt.Fprintln(os.Stderr, cfgErr)134 fmt.Fprintln(os.Stderr, cfgErr)
129 return cfgErr135 return cfgErr
130 }136 }
137+ var script *os.File
138+ if *inputPath != "" {
139+ if *inputPath == "-" {
140+ script = os.Stdin
141+ } else {
142+ var err error
143+ script, err = os.Open(*inputPath)
144+ if err != nil {
145+ return err
146+ }
147+ defer script.Close()
148+ }
149+ }
131 selected := mergeDebug(cfg.Log.Debug, *debugFlag)150 selected := mergeDebug(cfg.Log.Debug, *debugFlag)
132 if _, err := logging.ParseDebug(selected); err != nil {151 if _, err := logging.ParseDebug(selected); err != nil {
133 fmt.Fprintln(os.Stderr, err)152 fmt.Fprintln(os.Stderr, err)
@@ -144,7 +163,7 @@ func run() error {
144 log := logging.For(ctx, "app")163 log := logging.For(ctx, "app")
145 configLog := logging.For(ctx, "config")164 configLog := logging.For(ctx, "config")
146 configLog.Info().Str("path", path).Msg("loaded config")165 configLog.Info().Str("path", path).Msg("loaded config")
147- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil {166+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil {
148 // SIGINT and SIGTERM cancel the context and are clean exits.167 // SIGINT and SIGTERM cancel the context and are clean exits.
149 if ctx.Err() != nil && errors.Is(err, context.Canceled) {168 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
150 return nil169 return nil
A internal/adapters/headlessinput/input.go +128 -0
@@ -0,0 +1,128 @@
1+// Package headlessinput converts scripted text into keyboard events.
2+package headlessinput
3+
4+import (
5+ "context"
6+ "strings"
7+ "time"
8+ "unicode"
9+
10+ "github.com/bnema/nefertty/internal/adapters/xkb"
11+ "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// Run owns the keymap and converts script lines into input events.
16+func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
17+ defer km.Close()
18+ emit := func(code uint32, down bool) error {
19+ ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
20+ select {
21+ case <-ctx.Done():
22+ return ctx.Err()
23+ case input <- ev:
24+ }
25+ timer := time.NewTimer(2 * time.Millisecond)
26+ defer timer.Stop()
27+ select {
28+ case <-ctx.Done():
29+ return ctx.Err()
30+ case <-timer.C:
31+ return nil
32+ }
33+ }
34+ stroke := func(name string, mods []uint32) error {
35+ code, shift, ok := km.KeycodeFor(name)
36+ if !ok {
37+ log.Warn().Str("key", name).Msg("unknown key")
38+ return nil
39+ }
40+ if shift {
41+ mods = append(mods, 42)
42+ }
43+ for _, m := range mods {
44+ if err := emit(m, true); err != nil {
45+ return err
46+ }
47+ }
48+ if err := emit(code, true); err != nil {
49+ return err
50+ }
51+ if err := emit(code, false); err != nil {
52+ return err
53+ }
54+ for i := len(mods) - 1; i >= 0; i-- {
55+ if err := emit(mods[i], false); err != nil {
56+ return err
57+ }
58+ }
59+ return nil
60+ }
61+ 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"}
62+ for {
63+ select {
64+ case <-ctx.Done():
65+ return ctx.Err()
66+ case line, ok := <-script:
67+ if !ok {
68+ return nil
69+ }
70+ switch {
71+ case strings.HasPrefix(line, "type "):
72+ for _, r := range strings.TrimPrefix(line, "type ") {
73+ name := punctuation[r]
74+ if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
75+ name = string(r)
76+ }
77+ if name == "" {
78+ log.Warn().Str("rune", string(r)).Msg("unknown rune")
79+ continue
80+ }
81+ if err := stroke(name, nil); err != nil {
82+ return err
83+ }
84+ }
85+ case strings.HasPrefix(line, "key "):
86+ parts := strings.Split(strings.TrimPrefix(line, "key "), "+")
87+ var mods []uint32
88+ valid := true
89+ for _, part := range parts[:len(parts)-1] {
90+ switch part {
91+ case "Super":
92+ mods = append(mods, 125)
93+ case "Shift":
94+ mods = append(mods, 42)
95+ case "Ctrl":
96+ mods = append(mods, 29)
97+ case "Alt":
98+ mods = append(mods, 56)
99+ default:
100+ valid = false
101+ }
102+ }
103+ if !valid {
104+ log.Warn().Str("combo", line).Msg("unknown modifier")
105+ continue
106+ }
107+ if err := stroke(parts[len(parts)-1], mods); err != nil {
108+ return err
109+ }
110+ case strings.HasPrefix(line, "sleep "):
111+ d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
112+ if err != nil || d < 0 {
113+ log.Warn().Str("line", line).Msg("invalid sleep")
114+ continue
115+ }
116+ timer := time.NewTimer(d)
117+ select {
118+ case <-ctx.Done():
119+ timer.Stop()
120+ return ctx.Err()
121+ case <-timer.C:
122+ }
123+ default:
124+ log.Warn().Str("line", line).Msg("unknown script line")
125+ }
126+ }
127+ }
128+}
@@ -0,0 +1,128 @@
1+// Package headlessinput converts scripted text into keyboard events.
2+package headlessinput
3+
4+import (
5+ "context"
6+ "strings"
7+ "time"
8+ "unicode"
9+
10+ "github.com/bnema/nefertty/internal/adapters/xkb"
11+ "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// Run owns the keymap and converts script lines into input events.
16+func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
17+ defer km.Close()
18+ emit := func(code uint32, down bool) error {
19+ ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
20+ select {
21+ case <-ctx.Done():
22+ return ctx.Err()
23+ case input <- ev:
24+ }
25+ timer := time.NewTimer(2 * time.Millisecond)
26+ defer timer.Stop()
27+ select {
28+ case <-ctx.Done():
29+ return ctx.Err()
30+ case <-timer.C:
31+ return nil
32+ }
33+ }
34+ stroke := func(name string, mods []uint32) error {
35+ code, shift, ok := km.KeycodeFor(name)
36+ if !ok {
37+ log.Warn().Str("key", name).Msg("unknown key")
38+ return nil
39+ }
40+ if shift {
41+ mods = append(mods, 42)
42+ }
43+ for _, m := range mods {
44+ if err := emit(m, true); err != nil {
45+ return err
46+ }
47+ }
48+ if err := emit(code, true); err != nil {
49+ return err
50+ }
51+ if err := emit(code, false); err != nil {
52+ return err
53+ }
54+ for i := len(mods) - 1; i >= 0; i-- {
55+ if err := emit(mods[i], false); err != nil {
56+ return err
57+ }
58+ }
59+ return nil
60+ }
61+ 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"}
62+ for {
63+ select {
64+ case <-ctx.Done():
65+ return ctx.Err()
66+ case line, ok := <-script:
67+ if !ok {
68+ return nil
69+ }
70+ switch {
71+ case strings.HasPrefix(line, "type "):
72+ for _, r := range strings.TrimPrefix(line, "type ") {
73+ name := punctuation[r]
74+ if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
75+ name = string(r)
76+ }
77+ if name == "" {
78+ log.Warn().Str("rune", string(r)).Msg("unknown rune")
79+ continue
80+ }
81+ if err := stroke(name, nil); err != nil {
82+ return err
83+ }
84+ }
85+ case strings.HasPrefix(line, "key "):
86+ parts := strings.Split(strings.TrimPrefix(line, "key "), "+")
87+ var mods []uint32
88+ valid := true
89+ for _, part := range parts[:len(parts)-1] {
90+ switch part {
91+ case "Super":
92+ mods = append(mods, 125)
93+ case "Shift":
94+ mods = append(mods, 42)
95+ case "Ctrl":
96+ mods = append(mods, 29)
97+ case "Alt":
98+ mods = append(mods, 56)
99+ default:
100+ valid = false
101+ }
102+ }
103+ if !valid {
104+ log.Warn().Str("combo", line).Msg("unknown modifier")
105+ continue
106+ }
107+ if err := stroke(parts[len(parts)-1], mods); err != nil {
108+ return err
109+ }
110+ case strings.HasPrefix(line, "sleep "):
111+ d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep "))
112+ if err != nil || d < 0 {
113+ log.Warn().Str("line", line).Msg("invalid sleep")
114+ continue
115+ }
116+ timer := time.NewTimer(d)
117+ select {
118+ case <-ctx.Done():
119+ timer.Stop()
120+ return ctx.Err()
121+ case <-timer.C:
122+ }
123+ default:
124+ log.Warn().Str("line", line).Msg("unknown script line")
125+ }
126+ }
127+ }
128+}
A internal/adapters/headlessinput/input_test.go +56 -0
@@ -0,0 +1,56 @@
1+package headlessinput
2+
3+import (
4+ "context"
5+ "github.com/bnema/nefertty/internal/adapters/xkb"
6+ "github.com/bnema/nefertty/internal/logging"
7+ "github.com/bnema/nefertty/internal/ports"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestScript(t *testing.T) {
13+ for _, tt := range []struct {
14+ line string
15+ names []string
16+ codes []uint32
17+ }{
18+ {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},
19+ {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
20+ } {
21+ t.Run(tt.line, func(t *testing.T) {
22+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
23+ if err != nil {
24+ if strings.Contains(err.Error(), "libxkbcommon.so.0") {
25+ t.Skip(err)
26+ }
27+ t.Fatal(err)
28+ }
29+ script := make(chan string, 1)
30+ script <- tt.line
31+ close(script)
32+ input := make(chan ports.InputEvent, 32)
33+ if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(input) != len(tt.names) {
37+ t.Fatalf("events=%d want %d", len(input), len(tt.names))
38+ }
39+ for i, name := range tt.names {
40+ ev := (<-input).(ports.KeyEvent)
41+ if ev.Keysym != name {
42+ t.Errorf("event %d: %s want %s", i, ev.Keysym, name)
43+ }
44+ if tt.codes != nil && ev.Keycode != tt.codes[i] {
45+ t.Errorf("code %d: %d", i, ev.Keycode)
46+ }
47+ if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 {
48+ t.Error("Return missing Super")
49+ }
50+ if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 {
51+ t.Error("Super not released")
52+ }
53+ }
54+ })
55+ }
56+}
@@ -0,0 +1,56 @@
1+package headlessinput
2+
3+import (
4+ "context"
5+ "github.com/bnema/nefertty/internal/adapters/xkb"
6+ "github.com/bnema/nefertty/internal/logging"
7+ "github.com/bnema/nefertty/internal/ports"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestScript(t *testing.T) {
13+ for _, tt := range []struct {
14+ line string
15+ names []string
16+ codes []uint32
17+ }{
18+ {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},
19+ {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
20+ } {
21+ t.Run(tt.line, func(t *testing.T) {
22+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
23+ if err != nil {
24+ if strings.Contains(err.Error(), "libxkbcommon.so.0") {
25+ t.Skip(err)
26+ }
27+ t.Fatal(err)
28+ }
29+ script := make(chan string, 1)
30+ script <- tt.line
31+ close(script)
32+ input := make(chan ports.InputEvent, 32)
33+ if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(input) != len(tt.names) {
37+ t.Fatalf("events=%d want %d", len(input), len(tt.names))
38+ }
39+ for i, name := range tt.names {
40+ ev := (<-input).(ports.KeyEvent)
41+ if ev.Keysym != name {
42+ t.Errorf("event %d: %s want %s", i, ev.Keysym, name)
43+ }
44+ if tt.codes != nil && ev.Keycode != tt.codes[i] {
45+ t.Errorf("code %d: %d", i, ev.Keycode)
46+ }
47+ if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 {
48+ t.Error("Return missing Super")
49+ }
50+ if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 {
51+ t.Error("Super not released")
52+ }
53+ }
54+ })
55+ }
56+}
M internal/app/app.go +39 -3
@@ -2,17 +2,21 @@
22 package app
33
44 import (
5+ "bufio"
56 "context"
67 "errors"
78 "fmt"
9+ "io"
810 "os"
911 "sync"
1012 "time"
1113
1214 "github.com/bnema/nefertty/internal/adapters/headless"
15+ "github.com/bnema/nefertty/internal/adapters/headlessinput"
1316 "github.com/bnema/nefertty/internal/adapters/launcher"
1417 "github.com/bnema/nefertty/internal/adapters/vulkan"
1518 "github.com/bnema/nefertty/internal/adapters/wayland"
19+ "github.com/bnema/nefertty/internal/adapters/xkb"
1620 "github.com/bnema/nefertty/internal/core"
1721 "github.com/bnema/nefertty/internal/logging"
1822 "github.com/bnema/nefertty/internal/ports"
@@ -24,6 +28,7 @@ type Options struct {
2428 Timeout time.Duration
2529 NoTerminal bool
2630 ScreenshotDir string
31+ Script io.Reader
2732 testScenes chan<- ports.Scene
2833 }
2934
@@ -58,9 +63,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
5863 return err
5964 }
6065 output <- ports.OutputMode{Width: 1920, Height: 1080}
66+ km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
67+ if err != nil {
68+ return err
69+ }
70+ keymap := km.String()
6171 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
62- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
72+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
6373 if err != nil {
74+ km.Close()
6475 return err
6576 }
6677 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
@@ -72,8 +83,33 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7283 inject(input)
7384 }
7485 var workers sync.WaitGroup
75- workers.Add(5)
76- done := make(chan error, 4)
86+ workers.Add(6)
87+ done := make(chan error, 6)
88+ script := make(chan string)
89+ go func() {
90+ defer workers.Done()
91+ _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
92+ }()
93+ if opts.Script != nil {
94+ workers.Add(1)
95+ go func() {
96+ defer workers.Done()
97+ defer close(script)
98+ scanner := bufio.NewScanner(opts.Script)
99+ for scanner.Scan() {
100+ select {
101+ case <-ctx.Done():
102+ return
103+ case script <- scanner.Text():
104+ }
105+ }
106+ if err := scanner.Err(); err != nil {
107+ log.Warn().Err(err).Msg("reading input script")
108+ }
109+ }()
110+ } else {
111+ close(script)
112+ }
77113 go func() { defer workers.Done(); done <- server.Run(ctx) }()
78114 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
79115 go func() { defer workers.Done(); done <- c.Run(ctx) }()
@@ -2,17 +2,21 @@
2 package app2 package app
3 3
4 import (4 import (
5+ "bufio"
5 "context"6 "context"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
9+ "io"
8 "os"10 "os"
9 "sync"11 "sync"
10 "time"12 "time"
11 13
12 "github.com/bnema/nefertty/internal/adapters/headless"14 "github.com/bnema/nefertty/internal/adapters/headless"
15+ "github.com/bnema/nefertty/internal/adapters/headlessinput"
13 "github.com/bnema/nefertty/internal/adapters/launcher"16 "github.com/bnema/nefertty/internal/adapters/launcher"
14 "github.com/bnema/nefertty/internal/adapters/vulkan"17 "github.com/bnema/nefertty/internal/adapters/vulkan"
15 "github.com/bnema/nefertty/internal/adapters/wayland"18 "github.com/bnema/nefertty/internal/adapters/wayland"
19+ "github.com/bnema/nefertty/internal/adapters/xkb"
16 "github.com/bnema/nefertty/internal/core"20 "github.com/bnema/nefertty/internal/core"
17 "github.com/bnema/nefertty/internal/logging"21 "github.com/bnema/nefertty/internal/logging"
18 "github.com/bnema/nefertty/internal/ports"22 "github.com/bnema/nefertty/internal/ports"
@@ -24,6 +28,7 @@ type Options struct {
24 Timeout time.Duration28 Timeout time.Duration
25 NoTerminal bool29 NoTerminal bool
26 ScreenshotDir string30 ScreenshotDir string
31+ Script io.Reader
27 testScenes chan<- ports.Scene32 testScenes chan<- ports.Scene
28 }33 }
29 34
@@ -58,9 +63,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
58 return err63 return err
59 }64 }
60 output <- ports.OutputMode{Width: 1920, Height: 1080}65 output <- ports.OutputMode{Width: 1920, Height: 1080}
66+ km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
67+ if err != nil {
68+ return err
69+ }
70+ keymap := km.String()
61 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")71 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
62- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))72+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
63 if err != nil {73 if err != nil {
74+ km.Close()
64 return err75 return err
65 }76 }
66 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")77 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
@@ -72,8 +83,33 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
72 inject(input)83 inject(input)
73 }84 }
74 var workers sync.WaitGroup85 var workers sync.WaitGroup
75- workers.Add(5)86+ workers.Add(6)
76- done := make(chan error, 4)87+ done := make(chan error, 6)
88+ script := make(chan string)
89+ go func() {
90+ defer workers.Done()
91+ _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
92+ }()
93+ if opts.Script != nil {
94+ workers.Add(1)
95+ go func() {
96+ defer workers.Done()
97+ defer close(script)
98+ scanner := bufio.NewScanner(opts.Script)
99+ for scanner.Scan() {
100+ select {
101+ case <-ctx.Done():
102+ return
103+ case script <- scanner.Text():
104+ }
105+ }
106+ if err := scanner.Err(); err != nil {
107+ log.Warn().Err(err).Msg("reading input script")
108+ }
109+ }()
110+ } else {
111+ close(script)
112+ }
77 go func() { defer workers.Done(); done <- server.Run(ctx) }()113 go func() { defer workers.Done(); done <- server.Run(ctx) }()
78 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()114 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
79 go func() { defer workers.Done(); done <- c.Run(ctx) }()115 go func() { defer workers.Done(); done <- c.Run(ctx) }()
M internal/app/app_test.go +56 -0
@@ -4,7 +4,11 @@ import (
44 "context"
55 "github.com/bnema/nefertty/internal/adapters/config"
66 "github.com/bnema/nefertty/internal/ports"
7+ "image/png"
8+ "os"
79 "os/exec"
10+ "path/filepath"
11+ "strings"
812 "testing"
913 "time"
1014 )
@@ -84,3 +88,55 @@ func TestHeadlessSpawnClose(t *testing.T) {
8488 input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}
8589 waitScene(0, 5*time.Second)
8690 }
91+
92+func TestHeadlessTyping(t *testing.T) {
93+ if _, err := exec.LookPath("foot"); err != nil {
94+ t.Skip("foot unavailable")
95+ }
96+ t.Setenv("HOME", t.TempDir())
97+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
98+ t.Setenv("XDG_STATE_HOME", t.TempDir())
99+ cfg := config.Defaults()
100+ cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
101+ dir := t.TempDir()
102+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second})
103+ if err != nil {
104+ if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
105+ t.Skipf("Vulkan unavailable: %v", err)
106+ }
107+ t.Fatal(err)
108+ }
109+ path := filepath.Join(dir, "latest.png")
110+ f, err := os.Open(path)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer f.Close()
115+ img, err := png.Decode(f)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ // The default column occupies the central portion of the 1920x1080 output.
120+ first := img.At(600, 300)
121+ varied := false
122+ for y := 200; y < 850 && !varied; y += 4 {
123+ for x := 500; x < 1400; x += 4 {
124+ if img.At(x, y) != first {
125+ varied = true
126+ break
127+ }
128+ }
129+ }
130+ if !varied {
131+ t.Fatal("window image is uniform")
132+ }
133+ if os.Getenv("NEFERTTY_KEEP_SHOTS") == "1" {
134+ data, err := os.ReadFile(path)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := os.WriteFile("/tmp/nefertty-typing.png", data, 0600); err != nil {
139+ t.Fatal(err)
140+ }
141+ }
142+}
@@ -4,7 +4,11 @@ import (
4 "context"4 "context"
5 "github.com/bnema/nefertty/internal/adapters/config"5 "github.com/bnema/nefertty/internal/adapters/config"
6 "github.com/bnema/nefertty/internal/ports"6 "github.com/bnema/nefertty/internal/ports"
7+ "image/png"
8+ "os"
7 "os/exec"9 "os/exec"
10+ "path/filepath"
11+ "strings"
8 "testing"12 "testing"
9 "time"13 "time"
10 )14 )
@@ -84,3 +88,55 @@ func TestHeadlessSpawnClose(t *testing.T) {
84 input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}88 input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}
85 waitScene(0, 5*time.Second)89 waitScene(0, 5*time.Second)
86 }90 }
91+
92+func TestHeadlessTyping(t *testing.T) {
93+ if _, err := exec.LookPath("foot"); err != nil {
94+ t.Skip("foot unavailable")
95+ }
96+ t.Setenv("HOME", t.TempDir())
97+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
98+ t.Setenv("XDG_STATE_HOME", t.TempDir())
99+ cfg := config.Defaults()
100+ cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
101+ dir := t.TempDir()
102+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second})
103+ if err != nil {
104+ if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
105+ t.Skipf("Vulkan unavailable: %v", err)
106+ }
107+ t.Fatal(err)
108+ }
109+ path := filepath.Join(dir, "latest.png")
110+ f, err := os.Open(path)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ defer f.Close()
115+ img, err := png.Decode(f)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ // The default column occupies the central portion of the 1920x1080 output.
120+ first := img.At(600, 300)
121+ varied := false
122+ for y := 200; y < 850 && !varied; y += 4 {
123+ for x := 500; x < 1400; x += 4 {
124+ if img.At(x, y) != first {
125+ varied = true
126+ break
127+ }
128+ }
129+ }
130+ if !varied {
131+ t.Fatal("window image is uniform")
132+ }
133+ if os.Getenv("NEFERTTY_KEEP_SHOTS") == "1" {
134+ data, err := os.ReadFile(path)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := os.WriteFile("/tmp/nefertty-typing.png", data, 0600); err != nil {
139+ t.Fatal(err)
140+ }
141+ }
142+}

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 25

Tags 10

↑↓ moveenter openesc close