v0.6.1 3d41bc5

layerui.go RawHistoryFolder

1// Package layerui shows one NeferGUI view on a layer surface: neferclient owns
2// the Wayland connection and surface, nefergui.Renderer draws into DMA-BUF
3// buffers, and this package copies plain fields between the two. Each Run has
4// its own connection and runs everything on the calling goroutine.
5package layerui
6
7import (
8	"context"
9	"errors"
10	"fmt"
11	"time"
12
13	"github.com/bnema/neferclient"
14	"github.com/bnema/nefergui"
15	"github.com/bnema/wlturbo"
16)
17
18// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
19// bit order, so the plain bits can be copied.
20const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
21	neferclient.ModCapsLock | neferclient.ModNumLock
22
23// pendingRetry is how soon a frame that waits for the GPU is tried again.
24const pendingRetry = 2 * time.Millisecond
25
26// Config describes one layer surface and the hooks of its owner loop. Every
27// hook but OnSurface runs on the goroutine that called Run.
28type Config struct {
29	Layer  neferclient.LayerConfig
30	Styles string // CSS file path for nefergui.RendererConfig.Styles
31
32	// Wake redraws the view for each value received.
33	Wake <-chan struct{}
34	// OnResize reports the logical size and scale at the first configure and
35	// on every change.
36	OnResize func(width, height int, scale float64)
37	// OnInput sees every input event before NeferGUI routes it to controls;
38	// returning true redraws the view.
39	OnInput func(nefergui.Input) bool
40	// OnSurface runs once, after the layer is configured and before its first
41	// buffer is shown, with the connection's display and the layer's
42	// wl_surface. An error ends Run and nothing is shown. It runs on its own
43	// goroutine while the owner loop keeps applying events, so it may wait for
44	// the compositor's answer to a request of its own; it must only send
45	// requests, and return when ctx ends: Run waits for it.
46	//
47	// The input region starts as Layer.InputRects; a view's
48	// Frame.SetInputRects replaces it.
49	OnSurface func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
50}
51
52// Run shows view on a transparent layer surface until ctx ends (it returns
53// ctx.Err()), the compositor closes the surface (nil) or something fails.
54func Run[T any](ctx context.Context, cfg Config, model *T, view func(*nefergui.Frame, *T)) (err error) {
55	conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
56	if err != nil {
57		return fmt.Errorf("connect: %w", err)
58	}
59	l := &loop[T]{ctx: ctx, cfg: cfg, conn: conn, model: model, view: view, cursor: neferclient.CursorDefault}
60	// Close the connection first (it destroys every Wayland object), then
61	// free the renderer and its GPU resources.
62	defer func() {
63		err = errors.Join(err, conn.Close())
64		if l.r != nil {
65			err = errors.Join(err, l.r.Close())
66		}
67	}()
68	if l.surf, err = conn.NewLayerSurface(cfg.Layer); err != nil {
69		return fmt.Errorf("layer surface: %w", err)
70	}
71	l.seat = conn.Seat()
72	defer l.waitHook()
73	return l.run()
74}
75
76type loop[T any] struct {
77	neferclient.NopHandler
78
79	ctx   context.Context
80	cfg   Config
81	conn  *neferclient.Conn
82	surf  *neferclient.Surface
83	seat  *neferclient.Seat
84	model *T
85	view  func(*nefergui.Frame, *T)
86
87	r      *nefergui.Renderer
88	rwake  <-chan struct{} // Renderer.Wake: nil until the renderer exists
89	out    nefergui.Output
90	damage []neferclient.Rect
91	region []neferclient.Rect // reused by setInputRegion
92
93	w, h        int32
94	scale       float64
95	configured  bool
96	canPresent  bool // configured, and the last frame callback fired
97	authorized  bool // OnSurface succeeded, or there is none
98	hookStarted bool
99	hookDone    chan error // OnSurface's result while it is pending
100	hookCancel  context.CancelFunc
101	haveAcquire bool
102	cursor      neferclient.CursorShape
103
104	closed bool
105	err    error
106}
107
108func (l *loop[T]) run() error {
109	l.authorized = l.cfg.OnSurface == nil
110	retry := time.NewTimer(pendingRetry) // reused: Pending can last many frames
111	retry.Stop()
112	defer retry.Stop()
113	var tick <-chan time.Time
114	for !l.closed {
115		select {
116		case <-l.ctx.Done():
117			return l.ctx.Err()
118		case <-l.conn.Wake():
119			if err := l.conn.Dispatch(l); err != nil {
120				return fmt.Errorf("dispatch: %w", err)
121			}
122		case _, ok := <-l.cfg.Wake:
123			if !ok {
124				l.cfg.Wake = nil // a closed channel would spin
125			} else if l.r != nil {
126				l.r.Invalidate()
127			}
128		case err := <-l.hookDone:
129			l.hookDone = nil
130			if err != nil {
131				return err
132			}
133			l.authorized = true
134		case <-l.rwake: // another goroutine asked for a frame
135		case <-tick: // a built frame was waiting for the GPU
136		}
137		if l.err != nil {
138			return l.err
139		}
140		if l.closed {
141			return nil
142		}
143		if err := l.draw(); err != nil {
144			return err
145		}
146		tick = nil
147		if l.r != nil && l.r.Pending() {
148			retry.Reset(pendingRetry)
149			tick = retry.C
150		}
151	}
152	return nil
153}
154
155// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
156// complete feedback is used: one GPU is assumed.
157func (l *loop[T]) FeedbackDone(neferclient.SurfaceID) { l.setup() }
158
159func (l *loop[T]) Configure(neferclient.SurfaceID, int32, int32) {
160	if !l.configured { // later configures must not lift the frame-callback gate
161		l.canPresent = true
162	}
163	l.configured = true
164	l.resize()
165	l.setup()
166}
167
168func (l *loop[T]) Scale(neferclient.SurfaceID, float64) { l.resize() }
169
170func (l *loop[T]) setup() {
171	fb := l.surf.Feedback()
172	if l.r != nil || !l.configured || fb == nil {
173		return
174	}
175	cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Transparent: true, Styles: l.cfg.Styles,
176		Formats: make([]nefergui.Format, len(fb.Formats))}
177	for i, f := range fb.Formats {
178		cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
179	}
180	r, err := nefergui.NewRenderer(cfg)
181	if err != nil {
182		l.fail(fmt.Errorf("renderer: %w", err))
183		return
184	}
185	l.r, l.rwake = r, r.Wake()
186	w, h, scale := l.surf.Size()
187	r.Resize(int(w), int(h), scale)
188}
189
190// resize forwards a changed logical size or scale to OnResize and the
191// renderer. Before the first configure the size is not known yet.
192func (l *loop[T]) resize() {
193	if !l.configured {
194		return
195	}
196	w, h, scale := l.surf.Size()
197	if w == l.w && h == l.h && scale == l.scale {
198		return
199	}
200	l.w, l.h, l.scale = w, h, scale
201	if l.cfg.OnResize != nil {
202		l.cfg.OnResize(int(w), int(h), scale)
203	}
204	if l.r != nil {
205		l.r.Resize(int(w), int(h), scale)
206	}
207}
208
209// Frame: the compositor showed the last commit; the next Present is allowed.
210func (l *loop[T]) Frame(neferclient.SurfaceID) { l.canPresent = true }
211
212func (l *loop[T]) Closed(neferclient.SurfaceID) { l.closed = true }
213
214func (l *loop[T]) Error(err error) { l.fail(err) }
215
216func (l *loop[T]) fail(err error) {
217	if l.err == nil {
218		l.err = err
219	}
220}
221
222// FDReady: a buffer's release eventfd is readable (id is the buffer).
223func (l *loop[T]) FDReady(id uint64) {
224	if err := l.r.Released(id); err != nil {
225		l.fail(fmt.Errorf("release buffer %d: %w", id, err))
226	}
227}
228
229func (l *loop[T]) Pointer(ev *neferclient.PointerEvent) {
230	in, ok := pointerInput(ev)
231	if !ok {
232		return
233	}
234	if ev.Kind == neferclient.PointerEnter {
235		// The cursor shape is only valid after the enter: set it again.
236		if err := l.seat.SetCursor(l.cursor); err != nil {
237			l.fail(fmt.Errorf("cursor: %w", err))
238		}
239	}
240	l.input(&in)
241}
242
243func (l *loop[T]) Key(ev *neferclient.KeyEvent) {
244	in := keyInput(ev)
245	l.input(&in)
246}
247
248func (l *loop[T]) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
249	in := nefergui.Input{Kind: nefergui.InputFocusOut}
250	if focused {
251		in.Kind = nefergui.InputFocusIn
252	}
253	l.input(&in)
254}
255
256// input gives ev to OnInput, then to NeferGUI. Before the renderer exists
257// nothing is shown, so nothing is delivered.
258func (l *loop[T]) input(ev *nefergui.Input) {
259	if l.r == nil {
260		return
261	}
262	if l.cfg.OnInput != nil && l.cfg.OnInput(*ev) {
263		l.r.Invalidate()
264	}
265	l.r.Input(ev)
266}
267
268// pointerInput converts a neferclient pointer event; ok is false for events
269// NeferGUI does not take.
270func pointerInput(ev *neferclient.PointerEvent) (in nefergui.Input, ok bool) {
271	in = nefergui.Input{X: ev.X, Y: ev.Y}
272	switch ev.Kind {
273	case neferclient.PointerEnter, neferclient.PointerMotion:
274		in.Kind = nefergui.InputPointerMotion
275	case neferclient.PointerLeave:
276		in.Kind = nefergui.InputPointerLeave
277	case neferclient.PointerButton:
278		in.Kind, in.Button, in.Pressed = nefergui.InputPointerRelease, ev.Button, ev.Pressed
279		if ev.Pressed {
280			in.Kind = nefergui.InputPointerPress
281		}
282	case neferclient.PointerAxis:
283		in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
284	default:
285		return nefergui.Input{}, false
286	}
287	return in, true
288}
289
290// keyInput converts a neferclient key event. Text aliases neferclient storage
291// that is valid only during the Key call, as NeferGUI requires.
292func keyInput(ev *neferclient.KeyEvent) nefergui.Input {
293	return nefergui.Input{
294		Kind:      nefergui.InputKey,
295		Keysym:    ev.Keysym,
296		Text:      ev.Text,
297		Pressed:   ev.Pressed,
298		Repeat:    ev.Repeat,
299		Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
300	}
301}
302
303// draw renders a frame when something changed and presents it.
304func (l *loop[T]) draw() error {
305	if l.r == nil || !l.canPresent {
306		return nil
307	}
308	if !l.authorized {
309		l.startHook()
310		return nil
311	}
312	ok, err := l.r.Render(&l.out, l.model, l.view)
313	if err != nil {
314		return fmt.Errorf("render: %w", err)
315	}
316	if !ok {
317		return nil
318	}
319	if err = l.setInputRegion(); err != nil {
320		return err
321	}
322	if err = l.present(); err != nil {
323		return err
324	}
325	return l.setCursor()
326}
327
328// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
329// view changed it. Both are logical surface pixels; nil means the whole
330// surface and an empty slice click-through in both libraries.
331func (l *loop[T]) setInputRegion() error {
332	region, changed := inputRegion(l.region[:0], &l.out)
333	if !changed {
334		return nil
335	}
336	if region != nil {
337		l.region = region
338	}
339	if err := l.surf.SetInputRegion(region); err != nil {
340		return fmt.Errorf("input region: %w", err)
341	}
342	return nil
343}
344
345// inputRegion converts out's input region into dst; changed is false when
346// the view did not change it.
347func inputRegion(dst []neferclient.Rect, out *nefergui.Output) (region []neferclient.Rect, changed bool) {
348	if !out.InputRectsChanged {
349		return nil, false
350	}
351	if out.InputRects == nil {
352		return nil, true
353	}
354	if dst == nil {
355		dst = []neferclient.Rect{}
356	}
357	for _, r := range out.InputRects {
358		dst = append(dst, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
359	}
360	return dst, true
361}
362
363// startHook starts OnSurface once. The owner loop keeps dispatching while it
364// waits: neferclient's reader stops reading when the event queue is full.
365func (l *loop[T]) startHook() {
366	if l.hookStarted {
367		return
368	}
369	l.hookStarted = true
370	ctx, cancel := context.WithCancel(l.ctx)
371	done := make(chan error, 1)
372	l.hookDone, l.hookCancel = done, cancel
373	display, surface := l.conn.Display(), l.surf.WLSurface()
374	go func() { done <- l.cfg.OnSurface(ctx, display, surface) }()
375}
376
377// waitHook ends a pending OnSurface and waits for it before the connection
378// closes.
379func (l *loop[T]) waitHook() {
380	if l.hookCancel == nil {
381		return
382	}
383	l.hookCancel()
384	if l.hookDone != nil {
385		<-l.hookDone
386	}
387}
388
389// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
390func (l *loop[T]) present() error {
391	out := &l.out
392	// A resize retired buffers: stop watching and destroy them first.
393	for _, rt := range out.Retired {
394		if err := l.conn.UnwatchFD(rt.ReleaseFD); err != nil {
395			return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
396		}
397		if err := l.surf.DestroyBuffer(rt.Buffer); err != nil {
398			return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
399		}
400		// The release timeline of a buffer is imported under the buffer's id.
401		if err := l.surf.DestroyTimeline(rt.Buffer); err != nil {
402			return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
403		}
404	}
405	if out.NewBuffer {
406		buf := neferclient.Buffer{
407			Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
408			PlaneCount: out.PlaneCount,
409		}
410		for i, p := range out.Planes {
411			buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
412		}
413		if err := l.surf.ImportBuffer(out.Buffer, &buf); err != nil {
414			return fmt.Errorf("import buffer: %w", err)
415		}
416		// The release eventfd is stable per buffer: watch it once.
417		if err := l.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
418			return fmt.Errorf("watch release fd: %w", err)
419		}
420	}
421	if out.NewTimelines {
422		if !l.haveAcquire { // the acquire timeline is shared by every buffer
423			if err := l.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
424				return fmt.Errorf("import acquire timeline: %w", err)
425			}
426			l.haveAcquire = true
427		}
428		if err := l.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
429			return fmt.Errorf("import release timeline: %w", err)
430		}
431	}
432	l.damage = l.damage[:0]
433	for _, d := range out.Damage {
434		l.damage = append(l.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
435	}
436	err := l.surf.Present(&neferclient.Present{
437		Buffer:          out.Buffer,
438		AcquireTimeline: out.Acquire.ID,
439		ReleaseTimeline: out.Release.ID,
440		AcquirePoint:    out.AcquirePoint,
441		ReleasePoint:    out.ReleasePoint,
442		Damage:          l.damage,
443	})
444	if err != nil {
445		return fmt.Errorf("present: %w", err)
446	}
447	l.canPresent = false // until Frame
448	return nil
449}
450
451// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
452func (l *loop[T]) setCursor() error {
453	switch l.out.Cursor {
454	case nefergui.CursorPointer:
455		l.cursor = neferclient.CursorPointer
456	case nefergui.CursorText:
457		l.cursor = neferclient.CursorText
458	case nefergui.CursorNotAllowed:
459		l.cursor = neferclient.CursorNotAllowed
460	default:
461		l.cursor = neferclient.CursorDefault
462	}
463	if err := l.seat.SetCursor(l.cursor); err != nil {
464		return fmt.Errorf("cursor: %w", err)
465	}
466	return nil
467}

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 2

Tags 9

↑↓ moveenter openesc close