v0.1.0 d9218d6

output.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"fmt"
6	"golang.org/x/sys/unix"
7	"image"
8	"image/png"
9	"maps"
10	"os"
11	"path/filepath"
12	"time"
13
14	"github.com/bnema/neferwl/internal/adapters/syncfile"
15	"github.com/bnema/neferwl/internal/ports"
16	"github.com/bnema/zerowrap"
17)
18
19type Options struct {
20	// Cursor, when set, is drawn into screenshots with the image from
21	// LoadCursor at the scene scale.
22	Cursor        *Cursor
23	LoadCursor    func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
24	Width, Height int
25	ScreenshotDir string
26	Log           zerowrap.Logger
27	NewRenderer   func(w, h int) (ports.Renderer, error)
28	// Name and Presented report what the output has read after each
29	// frame, so wayland can release client buffers (nil: no reports).
30	Name      string
31	Presented chan<- ports.OutputPresented
32}
33
34func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
35	r, err := opts.NewRenderer(opts.Width, opts.Height)
36	if err != nil {
37		return fmt.Errorf("create renderer: %w", err)
38	}
39	defer r.Close()
40	surfaces := make(map[ports.WindowID]ports.SurfaceContent)
41	var scene ports.Scene
42	haveScene, dirty := false, false
43	frame := 0
44	var want ports.CursorChange
45	cursorScale := -1.0 // not loaded yet
46	seen := map[ports.WindowID]uint64{}
47	// pending is a report the channel could not take, retried soon.
48	var pending *ports.OutputPresented
49	update := func(c ports.SurfaceContent) {
50		seen[c.ID] = max(seen[c.ID], c.Seq)
51		dirty = dirty || scene.Shows(c.ID)
52		if c.Empty() {
53			delete(surfaces, c.ID)
54		} else {
55			surfaces[c.ID] = c
56		}
57	}
58	for {
59		if ctx.Err() != nil {
60			return nil
61		}
62		var retry <-chan time.Time
63		if pending != nil {
64			retry = time.After(time.Millisecond)
65		}
66		select {
67		case <-ctx.Done():
68			return nil
69		case <-retry:
70			pending = opts.send(pending)
71			continue
72		case s, ok := <-scenes:
73			if !ok {
74				scenes = nil
75				continue
76			}
77			scene, haveScene, dirty = s, true, true
78		case c, ok := <-contents:
79			if !ok {
80				contents = nil
81				continue
82			}
83			update(c)
84		case c := <-cursor:
85			// The cursor is only drawn into screenshots: no new frame.
86			want, cursorScale = c, -1
87		}
88		// Drain all queued updates before presenting one frame.
89	drain:
90		for {
91			select {
92			case s, ok := <-scenes:
93				if !ok {
94					scenes = nil
95				} else {
96					scene, haveScene, dirty = s, true, true
97				}
98			case c, ok := <-contents:
99				if !ok {
100					contents = nil
101				} else {
102					update(c)
103				}
104			default:
105				break drain
106			}
107		}
108		if !haveScene || !dirty {
109			// Contents not drawn are still read: report them.
110			pending = opts.report(pending, seen)
111			continue
112		}
113		dirty = false
114		if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
115			cursorScale = scene.Scale
116			if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {
117				opts.Cursor.set(img)
118			} else {
119				opts.Log.Warn().Err(err).Msg("cursor")
120			}
121		}
122		start := time.Now()
123		done, err := r.Render(scene, surfaces)
124		if err != nil {
125			return fmt.Errorf("render frame: %w", err)
126		}
127		if done != nil {
128			// No screen paces headless frames: wait for the GPU before
129			// reporting that its buffers were read.
130			err = syncfile.Wait(ctx, done)
131			done.Close()
132			if err != nil {
133				if ctx.Err() != nil {
134					return nil
135				}
136				return fmt.Errorf("frame fence: %w", err)
137			}
138		}
139		frame++
140		pending = opts.flipped(pending, seen, scene, surfaces)
141		if opts.ScreenshotDir != "" {
142			shot := r.Pixels()
143			if opts.Cursor != nil {
144				opts.Cursor.draw(shot)
145			}
146			if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot); err != nil {
147				return fmt.Errorf("screenshot: %w", err)
148			}
149			if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), shot); err != nil {
150				return fmt.Errorf("latest screenshot: %w", err)
151			}
152		}
153		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")
154	}
155}
156
157func writePNG(path string, img *image.RGBA) error {
158	f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
159	if err != nil {
160		return err
161	}
162	defer os.Remove(f.Name())
163	if err := png.Encode(f, img); err != nil {
164		_ = f.Close()
165		return err
166	}
167	if err := f.Close(); err != nil {
168		return err
169	}
170	return os.Rename(f.Name(), path)
171}
172
173// report sends what the output read, or returns it to retry when the
174// channel is full. Frames that were not drawn carry no flip.
175func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
176	if opts.Presented == nil {
177		return nil
178	}
179	return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
180}
181
182// flipped reports a drawn frame as a flip at the current CLOCK_MONOTONIC
183// time (software clock, refresh unknown), so presentation feedback and
184// frame callbacks follow headless frames. A report still waiting is
185// sent first.
186func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.WindowID]uint64, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.OutputPresented {
187	if opts.Presented == nil {
188		return nil
189	}
190	var merged int
191	if pending != nil && opts.send(pending) != nil {
192		// The reader is behind: this frame's report replaces the waiting
193		// one, and its flip counts as merged (its feedback is discarded).
194		if pending.Flip != nil {
195			merged = 1 + pending.Flip.Merged
196		}
197	}
198	var ts unix.Timespec
199	_ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
200	shows := map[ports.WindowID]uint64{}
201	for id, c := range surfaces {
202		if scene.Shows(id) {
203			shows[id] = c.Seq
204		}
205	}
206	return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows, Merged: merged}})
207}
208
209func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
210	select {
211	case opts.Presented <- *r:
212		return nil
213	default:
214		return r
215	}
216}

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