detached 6000865

feat(headless): add output goroutine with PNG screenshots

commit
6000865e4608e4f922a12703563ac1ebf343e640
author
brice · · 2026-09-23 15:26 UTC
parent
f1e2d50
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +310 -12
M README.md +2 -2
@@ -25,5 +25,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
2525
2626 ## Try it (headless)
2727
28-Run `nefertty --backend=headless`, then connect with
29-`WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet.
28+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.
@@ -25,5 +25,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
25 25
26 ## Try it (headless)26 ## Try it (headless)
27 27
28-Run `nefertty --backend=headless`, then connect with28+Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
29-`WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet.29+`WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.
M cmd/nefertty/main.go +13 -1
@@ -78,6 +78,7 @@ func run() error {
7878 }
7979 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
8080 backend := flags.String("backend", "drm", "drm or headless")
81+ screenshot := flags.String("screenshot", "", "write PNG frames to directory")
8182 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
8283 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
8384 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
@@ -93,6 +94,11 @@ func run() error {
9394 fmt.Fprintln(os.Stderr, err)
9495 return err
9596 }
97+ if *screenshot != "" && *backend != "headless" {
98+ err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
99+ fmt.Fprintln(os.Stderr, err)
100+ return err
101+ }
96102 if *timeout < 0 {
97103 err := usageError{fmt.Errorf("negative timeout")}
98104 fmt.Fprintln(os.Stderr, err)
@@ -103,6 +109,12 @@ func run() error {
103109 fmt.Fprintln(os.Stderr, err)
104110 return err
105111 }
112+ if *screenshot != "" {
113+ if err := os.MkdirAll(*screenshot, 0o755); err != nil {
114+ fmt.Fprintln(os.Stderr, err)
115+ return err
116+ }
117+ }
106118 path := *configFlag
107119 var cfgErr error
108120 var cfg = config.Defaults()
@@ -132,7 +144,7 @@ func run() error {
132144 log := logging.For(ctx, "app")
133145 configLog := logging.For(ctx, "config")
134146 configLog.Info().Str("path", path).Msg("loaded config")
135- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil {
147+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil {
136148 // SIGINT and SIGTERM cancel the context and are clean exits.
137149 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
138150 return nil
@@ -78,6 +78,7 @@ func run() error {
78 }78 }
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 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")82 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
82 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")83 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
83 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")84 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
@@ -93,6 +94,11 @@ func run() error {
93 fmt.Fprintln(os.Stderr, err)94 fmt.Fprintln(os.Stderr, err)
94 return err95 return err
95 }96 }
97+ if *screenshot != "" && *backend != "headless" {
98+ err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
99+ fmt.Fprintln(os.Stderr, err)
100+ return err
101+ }
96 if *timeout < 0 {102 if *timeout < 0 {
97 err := usageError{fmt.Errorf("negative timeout")}103 err := usageError{fmt.Errorf("negative timeout")}
98 fmt.Fprintln(os.Stderr, err)104 fmt.Fprintln(os.Stderr, err)
@@ -103,6 +109,12 @@ func run() error {
103 fmt.Fprintln(os.Stderr, err)109 fmt.Fprintln(os.Stderr, err)
104 return err110 return err
105 }111 }
112+ if *screenshot != "" {
113+ if err := os.MkdirAll(*screenshot, 0o755); err != nil {
114+ fmt.Fprintln(os.Stderr, err)
115+ return err
116+ }
117+ }
106 path := *configFlag118 path := *configFlag
107 var cfgErr error119 var cfgErr error
108 var cfg = config.Defaults()120 var cfg = config.Defaults()
@@ -132,7 +144,7 @@ func run() error {
132 log := logging.For(ctx, "app")144 log := logging.For(ctx, "app")
133 configLog := logging.For(ctx, "config")145 configLog := logging.For(ctx, "config")
134 configLog.Info().Str("path", path).Msg("loaded config")146 configLog.Info().Str("path", path).Msg("loaded config")
135- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil {147+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil {
136 // SIGINT and SIGTERM cancel the context and are clean exits.148 // SIGINT and SIGTERM cancel the context and are clean exits.
137 if ctx.Err() != nil && errors.Is(err, context.Canceled) {149 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
138 return nil150 return nil
M cmd/nefertty/main_test.go +1 -0
@@ -21,6 +21,7 @@ func TestExitCodes(t *testing.T) {
2121 }{
2222 {[]string{"nefertty", "--help"}, 0},
2323 {[]string{"nefertty", "--backend=invalid"}, 2},
24+ {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
2425 {[]string{"nefertty", "--timeout=-1s"}, 2},
2526 {[]string{"nefertty", "extra"}, 2},
2627 {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},
@@ -21,6 +21,7 @@ func TestExitCodes(t *testing.T) {
21 }{21 }{
22 {[]string{"nefertty", "--help"}, 0},22 {[]string{"nefertty", "--help"}, 0},
23 {[]string{"nefertty", "--backend=invalid"}, 2},23 {[]string{"nefertty", "--backend=invalid"}, 2},
24+ {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
24 {[]string{"nefertty", "--timeout=-1s"}, 2},25 {[]string{"nefertty", "--timeout=-1s"}, 2},
25 {[]string{"nefertty", "extra"}, 2},26 {[]string{"nefertty", "extra"}, 2},
26 {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},27 {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},
A internal/adapters/headless/output.go +132 -0
@@ -0,0 +1,132 @@
1+package headless
2+
3+import (
4+ "context"
5+ "fmt"
6+ "image"
7+ "image/png"
8+ "os"
9+ "path/filepath"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/ports"
13+ "github.com/bnema/zerowrap"
14+)
15+
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ Pixels() *image.RGBA
19+ Close()
20+}
21+
22+type Options struct {
23+ Width, Height int
24+ ScreenshotDir string
25+ Log zerowrap.Logger
26+ NewRenderer func(w, h int) (Renderer, error)
27+}
28+
29+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
30+ r, err := opts.NewRenderer(opts.Width, opts.Height)
31+ if err != nil {
32+ return fmt.Errorf("create renderer: %w", err)
33+ }
34+ defer r.Close()
35+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
36+ var scene ports.Scene
37+ haveScene := false
38+ frame := 0
39+ update := func(c ports.SurfaceContent) {
40+ if c.Pixels == nil {
41+ delete(surfaces, c.ID)
42+ } else {
43+ surfaces[c.ID] = c
44+ }
45+ }
46+ for {
47+ if ctx.Err() != nil {
48+ return nil
49+ }
50+ select {
51+ case <-ctx.Done():
52+ return nil
53+ case s, ok := <-scenes:
54+ if !ok {
55+ scenes = nil
56+ continue
57+ }
58+ scene, haveScene = s, true
59+ case c, ok := <-contents:
60+ if !ok {
61+ contents = nil
62+ continue
63+ }
64+ update(c)
65+ }
66+ // Drain all queued updates before presenting one frame.
67+ drain:
68+ for {
69+ select {
70+ case s, ok := <-scenes:
71+ if !ok {
72+ scenes = nil
73+ } else {
74+ scene, haveScene = s, true
75+ }
76+ case c, ok := <-contents:
77+ if !ok {
78+ contents = nil
79+ } else {
80+ update(c)
81+ }
82+ default:
83+ break drain
84+ }
85+ }
86+ if !haveScene {
87+ continue
88+ }
89+ start := time.Now()
90+ if err := r.Render(scene, surfaces); err != nil {
91+ return fmt.Errorf("render frame: %w", err)
92+ }
93+ frame++
94+ if opts.ScreenshotDir != "" {
95+ if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {
96+ return fmt.Errorf("screenshot: %w", err)
97+ }
98+ if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil {
99+ return fmt.Errorf("latest screenshot: %w", err)
100+ }
101+ }
102+ opts.Log.Debug().Str("component", "render").Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
103+ }
104+}
105+
106+func writePNG(path string, img *image.RGBA) error {
107+ f, err := os.Create(path)
108+ if err != nil {
109+ return err
110+ }
111+ if err := png.Encode(f, img); err != nil {
112+ _ = f.Close()
113+ return err
114+ }
115+ return f.Close()
116+}
117+
118+func writeLatest(dir string, img *image.RGBA) error {
119+ f, err := os.CreateTemp(dir, ".latest-*.png")
120+ if err != nil {
121+ return err
122+ }
123+ defer os.Remove(f.Name())
124+ if err := png.Encode(f, img); err != nil {
125+ _ = f.Close()
126+ return err
127+ }
128+ if err := f.Close(); err != nil {
129+ return err
130+ }
131+ return os.Rename(f.Name(), filepath.Join(dir, "latest.png"))
132+}
@@ -0,0 +1,132 @@
1+package headless
2+
3+import (
4+ "context"
5+ "fmt"
6+ "image"
7+ "image/png"
8+ "os"
9+ "path/filepath"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/ports"
13+ "github.com/bnema/zerowrap"
14+)
15+
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ Pixels() *image.RGBA
19+ Close()
20+}
21+
22+type Options struct {
23+ Width, Height int
24+ ScreenshotDir string
25+ Log zerowrap.Logger
26+ NewRenderer func(w, h int) (Renderer, error)
27+}
28+
29+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
30+ r, err := opts.NewRenderer(opts.Width, opts.Height)
31+ if err != nil {
32+ return fmt.Errorf("create renderer: %w", err)
33+ }
34+ defer r.Close()
35+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
36+ var scene ports.Scene
37+ haveScene := false
38+ frame := 0
39+ update := func(c ports.SurfaceContent) {
40+ if c.Pixels == nil {
41+ delete(surfaces, c.ID)
42+ } else {
43+ surfaces[c.ID] = c
44+ }
45+ }
46+ for {
47+ if ctx.Err() != nil {
48+ return nil
49+ }
50+ select {
51+ case <-ctx.Done():
52+ return nil
53+ case s, ok := <-scenes:
54+ if !ok {
55+ scenes = nil
56+ continue
57+ }
58+ scene, haveScene = s, true
59+ case c, ok := <-contents:
60+ if !ok {
61+ contents = nil
62+ continue
63+ }
64+ update(c)
65+ }
66+ // Drain all queued updates before presenting one frame.
67+ drain:
68+ for {
69+ select {
70+ case s, ok := <-scenes:
71+ if !ok {
72+ scenes = nil
73+ } else {
74+ scene, haveScene = s, true
75+ }
76+ case c, ok := <-contents:
77+ if !ok {
78+ contents = nil
79+ } else {
80+ update(c)
81+ }
82+ default:
83+ break drain
84+ }
85+ }
86+ if !haveScene {
87+ continue
88+ }
89+ start := time.Now()
90+ if err := r.Render(scene, surfaces); err != nil {
91+ return fmt.Errorf("render frame: %w", err)
92+ }
93+ frame++
94+ if opts.ScreenshotDir != "" {
95+ if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {
96+ return fmt.Errorf("screenshot: %w", err)
97+ }
98+ if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil {
99+ return fmt.Errorf("latest screenshot: %w", err)
100+ }
101+ }
102+ opts.Log.Debug().Str("component", "render").Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
103+ }
104+}
105+
106+func writePNG(path string, img *image.RGBA) error {
107+ f, err := os.Create(path)
108+ if err != nil {
109+ return err
110+ }
111+ if err := png.Encode(f, img); err != nil {
112+ _ = f.Close()
113+ return err
114+ }
115+ return f.Close()
116+}
117+
118+func writeLatest(dir string, img *image.RGBA) error {
119+ f, err := os.CreateTemp(dir, ".latest-*.png")
120+ if err != nil {
121+ return err
122+ }
123+ defer os.Remove(f.Name())
124+ if err := png.Encode(f, img); err != nil {
125+ _ = f.Close()
126+ return err
127+ }
128+ if err := f.Close(); err != nil {
129+ return err
130+ }
131+ return os.Rename(f.Name(), filepath.Join(dir, "latest.png"))
132+}
A internal/adapters/headless/output_test.go +117 -0
@@ -0,0 +1,117 @@
1+package headless
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/png"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/nefertty/internal/ports"
15+)
16+
17+type fakeRenderer struct {
18+ mu sync.Mutex
19+ frames []ports.Scene
20+ contents []map[ports.WindowID]ports.SurfaceContent
21+ closed bool
22+ err error
23+}
24+
25+func (r *fakeRenderer) Render(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) error {
26+ r.mu.Lock()
27+ defer r.mu.Unlock()
28+ r.frames = append(r.frames, s)
29+ copyMap := make(map[ports.WindowID]ports.SurfaceContent)
30+ for id, v := range c {
31+ copyMap[id] = v
32+ }
33+ r.contents = append(r.contents, copyMap)
34+ return r.err
35+}
36+func (r *fakeRenderer) Pixels() *image.RGBA { return image.NewRGBA(image.Rect(0, 0, 2, 2)) }
37+func (r *fakeRenderer) Close() { r.mu.Lock(); defer r.mu.Unlock(); r.closed = true }
38+func TestRun(t *testing.T) {
39+ dir := t.TempDir()
40+ scenes := make(chan ports.Scene, 8)
41+ contents := make(chan ports.SurfaceContent, 8)
42+ r := new(fakeRenderer)
43+ contents <- ports.SurfaceContent{ID: 1, Pixels: []byte{1}}
44+ scenes <- ports.Scene{Seq: 1}
45+ scenes <- ports.Scene{Seq: 2}
46+ contents <- ports.SurfaceContent{ID: 2, Pixels: []byte{2}}
47+ ctx, cancel := context.WithCancel(context.Background())
48+ done := make(chan error, 1)
49+ go func() {
50+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents)
51+ }()
52+ wait := func(n int) {
53+ t.Helper()
54+ deadline := time.After(3 * time.Second)
55+ for {
56+ r.mu.Lock()
57+ count := len(r.frames)
58+ r.mu.Unlock()
59+ if count >= n {
60+ return
61+ }
62+ select {
63+ case <-deadline:
64+ t.Fatal("no frame")
65+ case <-time.After(time.Millisecond * 10):
66+ }
67+ }
68+ }
69+ wait(1)
70+ r.mu.Lock()
71+ if len(r.frames) != 1 || r.frames[0].Seq != 2 || len(r.contents[0]) != 2 {
72+ t.Errorf("coalescing: %+v %+v", r.frames, r.contents)
73+ }
74+ r.mu.Unlock()
75+ contents <- ports.SurfaceContent{ID: 1}
76+ wait(2)
77+ r.mu.Lock()
78+ if _, ok := r.contents[1][1]; ok {
79+ t.Error("content not deleted")
80+ }
81+ r.mu.Unlock()
82+ cancel()
83+ if err := <-done; err != nil {
84+ t.Fatal(err)
85+ }
86+ r.mu.Lock()
87+ if !r.closed {
88+ t.Error("renderer not closed")
89+ }
90+ r.mu.Unlock()
91+ for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} {
92+ f, err := os.Open(filepath.Join(dir, name))
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ _, err = png.Decode(f)
97+ _ = f.Close()
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ }
102+}
103+func TestErrors(t *testing.T) {
104+ expected := errors.New("failure")
105+ scenes := make(chan ports.Scene, 1)
106+ scenes <- ports.Scene{}
107+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) {
108+ t.Fatal(err)
109+ }
110+ r := &fakeRenderer{err: expected}
111+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) {
112+ t.Fatal(err)
113+ }
114+ if !r.closed {
115+ t.Fatal("not closed")
116+ }
117+}
@@ -0,0 +1,117 @@
1+package headless
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "image/png"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/nefertty/internal/ports"
15+)
16+
17+type fakeRenderer struct {
18+ mu sync.Mutex
19+ frames []ports.Scene
20+ contents []map[ports.WindowID]ports.SurfaceContent
21+ closed bool
22+ err error
23+}
24+
25+func (r *fakeRenderer) Render(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) error {
26+ r.mu.Lock()
27+ defer r.mu.Unlock()
28+ r.frames = append(r.frames, s)
29+ copyMap := make(map[ports.WindowID]ports.SurfaceContent)
30+ for id, v := range c {
31+ copyMap[id] = v
32+ }
33+ r.contents = append(r.contents, copyMap)
34+ return r.err
35+}
36+func (r *fakeRenderer) Pixels() *image.RGBA { return image.NewRGBA(image.Rect(0, 0, 2, 2)) }
37+func (r *fakeRenderer) Close() { r.mu.Lock(); defer r.mu.Unlock(); r.closed = true }
38+func TestRun(t *testing.T) {
39+ dir := t.TempDir()
40+ scenes := make(chan ports.Scene, 8)
41+ contents := make(chan ports.SurfaceContent, 8)
42+ r := new(fakeRenderer)
43+ contents <- ports.SurfaceContent{ID: 1, Pixels: []byte{1}}
44+ scenes <- ports.Scene{Seq: 1}
45+ scenes <- ports.Scene{Seq: 2}
46+ contents <- ports.SurfaceContent{ID: 2, Pixels: []byte{2}}
47+ ctx, cancel := context.WithCancel(context.Background())
48+ done := make(chan error, 1)
49+ go func() {
50+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents)
51+ }()
52+ wait := func(n int) {
53+ t.Helper()
54+ deadline := time.After(3 * time.Second)
55+ for {
56+ r.mu.Lock()
57+ count := len(r.frames)
58+ r.mu.Unlock()
59+ if count >= n {
60+ return
61+ }
62+ select {
63+ case <-deadline:
64+ t.Fatal("no frame")
65+ case <-time.After(time.Millisecond * 10):
66+ }
67+ }
68+ }
69+ wait(1)
70+ r.mu.Lock()
71+ if len(r.frames) != 1 || r.frames[0].Seq != 2 || len(r.contents[0]) != 2 {
72+ t.Errorf("coalescing: %+v %+v", r.frames, r.contents)
73+ }
74+ r.mu.Unlock()
75+ contents <- ports.SurfaceContent{ID: 1}
76+ wait(2)
77+ r.mu.Lock()
78+ if _, ok := r.contents[1][1]; ok {
79+ t.Error("content not deleted")
80+ }
81+ r.mu.Unlock()
82+ cancel()
83+ if err := <-done; err != nil {
84+ t.Fatal(err)
85+ }
86+ r.mu.Lock()
87+ if !r.closed {
88+ t.Error("renderer not closed")
89+ }
90+ r.mu.Unlock()
91+ for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} {
92+ f, err := os.Open(filepath.Join(dir, name))
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ _, err = png.Decode(f)
97+ _ = f.Close()
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ }
102+}
103+func TestErrors(t *testing.T) {
104+ expected := errors.New("failure")
105+ scenes := make(chan ports.Scene, 1)
106+ scenes <- ports.Scene{}
107+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) {
108+ t.Fatal(err)
109+ }
110+ r := &fakeRenderer{err: expected}
111+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) {
112+ t.Fatal(err)
113+ }
114+ if !r.closed {
115+ t.Fatal("not closed")
116+ }
117+}
M internal/app/app.go +45 -9
@@ -9,7 +9,9 @@ import (
99 "sync"
1010 "time"
1111
12+ "github.com/bnema/nefertty/internal/adapters/headless"
1213 "github.com/bnema/nefertty/internal/adapters/launcher"
14+ "github.com/bnema/nefertty/internal/adapters/vulkan"
1315 "github.com/bnema/nefertty/internal/adapters/wayland"
1416 "github.com/bnema/nefertty/internal/core"
1517 "github.com/bnema/nefertty/internal/logging"
@@ -17,11 +19,12 @@ import (
1719 )
1820
1921 type Options struct {
20- Backend string
21- Config ports.Config
22- Timeout time.Duration
23- NoTerminal bool
24- testScenes chan<- ports.Scene
22+ Backend string
23+ Config ports.Config
24+ Timeout time.Duration
25+ NoTerminal bool
26+ ScreenshotDir string
27+ testScenes chan<- ports.Scene
2528 }
2629
2730 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -47,6 +50,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
4750 spawn := make(chan ports.SpawnRequest, 32)
4851 scenes := make(chan ports.Scene, 1)
4952 configErrors := make(chan error, 8)
53+ renderScenes := make(chan ports.Scene, 1)
54+ contents := make(chan ports.SurfaceContent, 64)
55+ // TODO(merge): wire wayland Contents to contents.
5056 ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}
5157 c, err := core.New(opts.Config, ch)
5258 if err != nil {
@@ -67,12 +73,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
6773 inject(input)
6874 }
6975 var workers sync.WaitGroup
70- workers.Add(4)
71- done := make(chan error, 3)
76+ workers.Add(5)
77+ done := make(chan error, 4)
7278 go func() { defer workers.Done(); done <- server.Run(ctx) }()
7379 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
7480 go func() { defer workers.Done(); done <- c.Run(ctx) }()
75- go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }()
81+ go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
82+
83+ go func() {
84+ defer workers.Done()
85+ done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
86+ r, err := vulkan.New(w, h)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return vkRenderer{r}, nil
91+ }}, renderScenes, contents)
92+ }()
7693
7794 var result error
7895 select {
@@ -93,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
93110 return result
94111 }
95112
96-func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) {
113+func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
97114 log := logging.For(ctx, "core")
98115 for {
99116 select {
@@ -106,9 +123,28 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
106123 default:
107124 }
108125 }
126+ select {
127+ case renderScenes <- s:
128+ default:
129+ select {
130+ case <-renderScenes:
131+ default:
132+ }
133+ select {
134+ case renderScenes <- s:
135+ default:
136+ }
137+ }
109138 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
110139 case err := <-configErrors:
111140 log.Debug().Err(err).Msg("config rejected")
112141 }
113142 }
114143 }
144+
145+// TODO(merge): drop once vulkan.Render takes contents.
146+type vkRenderer struct{ *vulkan.Renderer }
147+
148+func (r vkRenderer) Render(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) error {
149+ return r.Renderer.Render(s)
150+}
@@ -9,7 +9,9 @@ import (
9 "sync"9 "sync"
10 "time"10 "time"
11 11
12+ "github.com/bnema/nefertty/internal/adapters/headless"
12 "github.com/bnema/nefertty/internal/adapters/launcher"13 "github.com/bnema/nefertty/internal/adapters/launcher"
14+ "github.com/bnema/nefertty/internal/adapters/vulkan"
13 "github.com/bnema/nefertty/internal/adapters/wayland"15 "github.com/bnema/nefertty/internal/adapters/wayland"
14 "github.com/bnema/nefertty/internal/core"16 "github.com/bnema/nefertty/internal/core"
15 "github.com/bnema/nefertty/internal/logging"17 "github.com/bnema/nefertty/internal/logging"
@@ -17,11 +19,12 @@ import (
17 )19 )
18 20
19 type Options struct {21 type Options struct {
20- Backend string22+ Backend string
21- Config ports.Config23+ Config ports.Config
22- Timeout time.Duration24+ Timeout time.Duration
23- NoTerminal bool25+ NoTerminal bool
24- testScenes chan<- ports.Scene26+ ScreenshotDir string
27+ testScenes chan<- ports.Scene
25 }28 }
26 29
27 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }30 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -47,6 +50,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
47 spawn := make(chan ports.SpawnRequest, 32)50 spawn := make(chan ports.SpawnRequest, 32)
48 scenes := make(chan ports.Scene, 1)51 scenes := make(chan ports.Scene, 1)
49 configErrors := make(chan error, 8)52 configErrors := make(chan error, 8)
53+ renderScenes := make(chan ports.Scene, 1)
54+ contents := make(chan ports.SurfaceContent, 64)
55+ // TODO(merge): wire wayland Contents to contents.
50 ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}56 ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}
51 c, err := core.New(opts.Config, ch)57 c, err := core.New(opts.Config, ch)
52 if err != nil {58 if err != nil {
@@ -67,12 +73,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
67 inject(input)73 inject(input)
68 }74 }
69 var workers sync.WaitGroup75 var workers sync.WaitGroup
70- workers.Add(4)76+ workers.Add(5)
71- done := make(chan error, 3)77+ done := make(chan error, 4)
72 go func() { defer workers.Done(); done <- server.Run(ctx) }()78 go func() { defer workers.Done(); done <- server.Run(ctx) }()
73 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()79 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
74 go func() { defer workers.Done(); done <- c.Run(ctx) }()80 go func() { defer workers.Done(); done <- c.Run(ctx) }()
75- go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }()81+ go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
82+
83+ go func() {
84+ defer workers.Done()
85+ done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
86+ r, err := vulkan.New(w, h)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return vkRenderer{r}, nil
91+ }}, renderScenes, contents)
92+ }()
76 93
77 var result error94 var result error
78 select {95 select {
@@ -93,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
93 return result110 return result
94 }111 }
95 112
96-func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) {113+func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {
97 log := logging.For(ctx, "core")114 log := logging.For(ctx, "core")
98 for {115 for {
99 select {116 select {
@@ -106,9 +123,28 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors
106 default:123 default:
107 }124 }
108 }125 }
126+ select {
127+ case renderScenes <- s:
128+ default:
129+ select {
130+ case <-renderScenes:
131+ default:
132+ }
133+ select {
134+ case renderScenes <- s:
135+ default:
136+ }
137+ }
109 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")138 log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
110 case err := <-configErrors:139 case err := <-configErrors:
111 log.Debug().Err(err).Msg("config rejected")140 log.Debug().Err(err).Msg("config rejected")
112 }141 }
113 }142 }
114 }143 }
144+
145+// TODO(merge): drop once vulkan.Render takes contents.
146+type vkRenderer struct{ *vulkan.Renderer }
147+
148+func (r vkRenderer) Render(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) error {
149+ return r.Renderer.Render(s)
150+}

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