v0.2.0 e178e0b

main.go RawHistoryFolder

1// Command layer shows a wlr-layer-shell surface with a counter button: the
2// smallest end-to-end use of neferclient (Wayland client) and NeferGUI
3// (renderer). The two libraries do not import each other; this file is the
4// glue, and every step is marked.
5//
6// Everything runs on one owner goroutine: the loop in run waits for a wake-up,
7// calls Conn.Dispatch, and all Handler methods, renderer calls and Present
8// calls happen inside that call or right after it.
9//
10// Only the button takes pointer input: the view stages its rectangle with
11// Frame.SetInputRects and the program forwards it to Surface.SetInputRegion,
12// so the rest of the surface is click-through.
13//
14// With -frames N the program redraws continuously and exits after N frames
15// (used by the headless test); without it, it runs until the surface is closed
16// or it is interrupted. -log-pointer prints pointer enter and leave events.
17package main
18
19import (
20	"context"
21	"errors"
22	"flag"
23	"fmt"
24	"os"
25	"os/signal"
26	"syscall"
27	"time"
28
29	"github.com/bnema/neferclient"
30	"github.com/bnema/nefergui"
31)
32
33// Layout of the surface, in logical pixels: padding, a label row, a gap, then
34// the button. The headless test samples a pixel inside the button and moves the
35// pointer over the label and the button at the same places.
36const (
37	surfaceW, surfaceH = 240, 90
38	pad, labelH, gap   = 10, 20, 10
39	buttonW, buttonH   = 200, 40
40	buttonY            = pad + labelH + gap
41	buttonColor        = "#2060c0"
42
43	// guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI
44	// use the same bit order for them, so the plain bits can be copied.
45	guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper
46)
47
48type model struct{ count int }
49
50// The styles and the clickable rectangle are built once from the layout
51// constants, so they cannot disagree. NeferGUI does not report where a control
52// was laid out; inputRects is the button. SetInputRects copies the slice.
53var (
54	columnStyle = nefergui.Inline(fmt.Sprintf("padding:%dpx;gap:%dpx;color:#ffffff", pad, gap))
55	labelStyle  = nefergui.Inline(fmt.Sprintf("height:%dpx", labelH))
56	buttonStyle = nefergui.Inline(fmt.Sprintf("width:%dpx;height:%dpx;background:%s", buttonW, buttonH, buttonColor))
57	inputRects  = []nefergui.Rect{{X: pad, Y: buttonY, Width: buttonW, Height: buttonH}}
58)
59
60// view builds one frame from the model. Button.Activated is true during the
61// frame built after a click.
62func view(f *nefergui.Frame, m *model) {
63	root := f.Root().Column(columnStyle)
64	root.TextInt("Count: ", int64(m.count), labelStyle)
65	if root.Button("Add one", nefergui.Key("add"), buttonStyle).Activated() {
66		m.count++
67	}
68	if err := f.SetInputRects(inputRects); err != nil {
69		panic(err) // a fixed, valid rectangle: an error is a programming bug
70	}
71}
72
73type app struct {
74	neferclient.NopHandler // methods this program does not need
75
76	conn *neferclient.Conn
77	surf *neferclient.Surface
78	seat *neferclient.Seat
79
80	r      *nefergui.Renderer
81	rwake  <-chan struct{} // Renderer.Wake: nil until the renderer exists
82	out    nefergui.Output
83	model  model
84	damage []neferclient.Rect
85	region []neferclient.Rect // input region sent to the compositor, reused
86
87	logPointer bool
88
89	configured  bool
90	canPresent  bool // configured, and the last frame callback fired
91	haveAcquire bool
92	cursor      neferclient.CursorShape
93
94	frames, shown int // -frames limit and frames acknowledged so far
95	done          bool
96	err           error
97}
98
99func main() {
100	frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
101	logPointer := flag.Bool("log-pointer", false, "print pointer enter and leave events on stdout")
102	flag.Parse()
103	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
104	defer stop()
105	if err := run(ctx, *frames, *logPointer); err != nil {
106		fmt.Fprintln(os.Stderr, "layer:", err)
107		os.Exit(1)
108	}
109}
110
111func run(ctx context.Context, frames int, logPointer bool) (err error) {
112	conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
113	if err != nil {
114		return fmt.Errorf("connect: %w", err)
115	}
116	// region starts non-nil so that an empty NeferGUI region stays an empty
117	// slice (click-through) and does not become nil (whole surface).
118	a := &app{conn: conn, frames: frames, logPointer: logPointer, cursor: neferclient.CursorDefault,
119		region: []neferclient.Rect{}}
120	// Shutdown order: close the connection first, which drops its descriptor
121	// watches and destroys every Wayland object, then free the renderer and
122	// its GPU resources (including the release eventfds).
123	defer func() {
124		err = errors.Join(err, conn.Close())
125		if a.r != nil {
126			err = errors.Join(err, a.r.Close())
127		}
128	}()
129	a.surf, err = conn.NewLayerSurface(neferclient.LayerConfig{
130		Level:    neferclient.LayerTop,
131		Anchors:  neferclient.AnchorTop | neferclient.AnchorLeft,
132		Width:    surfaceW,
133		Height:   surfaceH,
134		Keyboard: neferclient.KeyboardOnDemand,
135	})
136	if err != nil {
137		return fmt.Errorf("layer surface: %w", err)
138	}
139	a.seat = conn.Seat()
140
141	var tick <-chan time.Time
142	for !a.done {
143		select {
144		case <-ctx.Done():
145			return nil // interrupt or SIGTERM: a clean exit
146		case <-conn.Wake():
147			if err = conn.Dispatch(a); err != nil {
148				return fmt.Errorf("dispatch: %w", err)
149			}
150		case <-a.rwake: // another goroutine asked for a frame
151		case <-tick: // a built frame was waiting for the GPU
152		}
153		if a.err != nil {
154			return a.err
155		}
156		if err = a.draw(); err != nil {
157			return err
158		}
159		tick = nil
160		if a.r != nil && a.r.Pending() {
161			tick = time.After(2 * time.Millisecond)
162		}
163	}
164	return nil
165}
166
167// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. The renderer opens
168// the compositor's main device and picks a format and modifier from the list.
169//
170// Only the first complete feedback is used. A later one (the compositor
171// changing its preferred device or formats) is ignored: this demo assumes a
172// single GPU, and rebuilding the renderer is application policy that would
173// obscure the glue. A real application would create a new Renderer and
174// re-import its buffers here.
175func (a *app) FeedbackDone(neferclient.SurfaceID) { a.setup() }
176
177func (a *app) Configure(neferclient.SurfaceID, int32, int32) {
178	if !a.configured { // later configures must not lift the frame-callback gate
179		a.canPresent = true
180	}
181	a.configured = true
182	a.resize()
183	a.setup()
184}
185
186func (a *app) Scale(neferclient.SurfaceID, float64) { a.resize() }
187
188func (a *app) setup() {
189	fb := a.surf.Feedback()
190	if a.r != nil || !a.configured || fb == nil {
191		return
192	}
193	cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
194	for i, f := range fb.Formats {
195		cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
196	}
197	r, err := nefergui.NewRenderer(cfg)
198	if err != nil {
199		a.fail(fmt.Errorf("renderer: %w", err))
200		return
201	}
202	a.r, a.rwake = r, r.Wake()
203	a.resize()
204}
205
206// resize forwards the logical size and scale to the renderer.
207func (a *app) resize() {
208	if a.r == nil {
209		return
210	}
211	w, h, scale := a.surf.Size()
212	a.r.Resize(int(w), int(h), scale)
213}
214
215// Frame: the compositor showed the last commit; the next Present is allowed.
216func (a *app) Frame(neferclient.SurfaceID) {
217	a.canPresent = true
218	a.shown++
219	if a.frames > 0 {
220		if a.shown >= a.frames {
221			a.done = true
222			return
223		}
224		a.r.Invalidate()
225	}
226}
227
228func (a *app) Closed(neferclient.SurfaceID) { a.done = true }
229
230func (a *app) Error(err error) { a.fail(err) }
231
232func (a *app) fail(err error) {
233	if a.err == nil {
234		a.err = err
235	}
236}
237
238// FDReady: the release eventfd of a buffer is readable (watched with
239// Conn.WatchFD in present, id is the buffer): hand the buffer back.
240func (a *app) FDReady(id uint64) {
241	if err := a.r.Released(id); err != nil {
242		a.fail(fmt.Errorf("release buffer %d: %w", id, err))
243	}
244}
245
246// Pointer and Key: neferclient events → nefergui.Input, field by field.
247func (a *app) Pointer(ev *neferclient.PointerEvent) {
248	if a.r == nil {
249		return
250	}
251	in := nefergui.Input{X: ev.X, Y: ev.Y}
252	switch ev.Kind {
253	case neferclient.PointerEnter:
254		in.Kind = nefergui.InputPointerMotion
255		if a.logPointer {
256			fmt.Printf("pointer enter %.0f %.0f\n", ev.X, ev.Y)
257		}
258		// The cursor shape is only valid after the enter: set the shape of
259		// the last frame again.
260		if err := a.seat.SetCursor(a.cursor); err != nil {
261			a.fail(fmt.Errorf("cursor: %w", err))
262		}
263	case neferclient.PointerMotion:
264		in.Kind = nefergui.InputPointerMotion
265	case neferclient.PointerLeave:
266		in.Kind = nefergui.InputPointerLeave
267		if a.logPointer {
268			fmt.Println("pointer leave")
269		}
270	case neferclient.PointerButton:
271		in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
272		if ev.Pressed {
273			in.Kind = nefergui.InputPointerPress
274		}
275	case neferclient.PointerAxis:
276		in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
277	default:
278		return
279	}
280	a.r.Input(&in)
281}
282
283func (a *app) Key(ev *neferclient.KeyEvent) {
284	if a.r == nil {
285		return
286	}
287	a.r.Input(&nefergui.Input{
288		Kind:      nefergui.InputKey,
289		Keysym:    ev.Keysym,
290		Text:      ev.Text, // valid only during the call, as for Input
291		Pressed:   ev.Pressed,
292		Repeat:    ev.Repeat,
293		Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
294	})
295}
296
297func (a *app) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
298	if a.r == nil {
299		return
300	}
301	kind := nefergui.InputFocusOut
302	if focused {
303		kind = nefergui.InputFocusIn
304	}
305	a.r.Input(&nefergui.Input{Kind: kind})
306}
307
308// draw renders a frame when something changed and presents it.
309func (a *app) draw() error {
310	if a.r == nil || !a.canPresent {
311		return nil
312	}
313	ok, err := a.r.Render(&a.out, &a.model, view)
314	if err != nil {
315		return fmt.Errorf("render: %w", err)
316	}
317	if !ok {
318		return nil
319	}
320	if err = a.setInputRegion(); err != nil {
321		return err
322	}
323	if err = a.present(); err != nil {
324		return err
325	}
326	return a.setCursor()
327}
328
329// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
330// view changed it. Both use logical pixels in surface coordinates, so the
331// rectangles are copied field by field without scaling (Output.Width and
332// Height, the buffer size, are the physical ones). Nil means the whole surface
333// and an empty slice means click-through, in both libraries. Before the first
334// Present the region is applied with it; afterwards SetInputRegion commits it
335// immediately.
336func (a *app) setInputRegion() error {
337	out := &a.out
338	if !out.InputRectsChanged {
339		return nil
340	}
341	var region []neferclient.Rect // nil: the whole surface
342	if out.InputRects != nil {
343		a.region = a.region[:0] // reused: no allocation once it has grown
344		for _, r := range out.InputRects {
345			a.region = append(a.region, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
346		}
347		region = a.region
348	}
349	if err := a.surf.SetInputRegion(region); err != nil {
350		return fmt.Errorf("input region: %w", err)
351	}
352	return nil
353}
354
355// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
356func (a *app) present() error {
357	out := &a.out
358	// A resize retired buffers: stop watching and destroy them first.
359	for _, rt := range out.Retired {
360		if err := a.conn.UnwatchFD(rt.ReleaseFD); err != nil {
361			return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
362		}
363		if err := a.surf.DestroyBuffer(rt.Buffer); err != nil {
364			return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
365		}
366		// The release timeline of a buffer is imported under the buffer's id.
367		if err := a.surf.DestroyTimeline(rt.Buffer); err != nil {
368			return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
369		}
370	}
371	if out.NewBuffer {
372		buf := neferclient.Buffer{
373			Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
374			PlaneCount: out.PlaneCount,
375		}
376		for i, p := range out.Planes {
377			buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
378		}
379		if err := a.surf.ImportBuffer(out.Buffer, &buf); err != nil {
380			return fmt.Errorf("import buffer: %w", err)
381		}
382		// The release eventfd is stable per buffer: watch it once; FDReady
383		// calls Renderer.Released.
384		if err := a.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
385			return fmt.Errorf("watch release fd: %w", err)
386		}
387	}
388	if out.NewTimelines {
389		if !a.haveAcquire { // the acquire timeline is shared by every buffer
390			if err := a.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
391				return fmt.Errorf("import acquire timeline: %w", err)
392			}
393			a.haveAcquire = true
394		}
395		if err := a.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
396			return fmt.Errorf("import release timeline: %w", err)
397		}
398	}
399	a.damage = a.damage[:0]
400	for _, d := range out.Damage {
401		a.damage = append(a.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
402	}
403	err := a.surf.Present(&neferclient.Present{
404		Buffer:          out.Buffer,
405		AcquireTimeline: out.Acquire.ID,
406		ReleaseTimeline: out.Release.ID,
407		AcquirePoint:    out.AcquirePoint,
408		ReleasePoint:    out.ReleasePoint,
409		Damage:          a.damage,
410		Opaque:          true, // XRGB8888: RendererConfig.Transparent is false
411	})
412	if err != nil {
413		return fmt.Errorf("present: %w", err)
414	}
415	a.canPresent = false // until Frame
416	return nil
417}
418
419// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
420func (a *app) setCursor() error {
421	switch a.out.Cursor {
422	case nefergui.CursorPointer:
423		a.cursor = neferclient.CursorPointer
424	case nefergui.CursorText:
425		a.cursor = neferclient.CursorText
426	case nefergui.CursorNotAllowed:
427		a.cursor = neferclient.CursorNotAllowed
428	default:
429		a.cursor = neferclient.CursorDefault
430	}
431	if err := a.seat.SetCursor(a.cursor); err != nil {
432		return fmt.Errorf("cursor: %w", err)
433	}
434	return nil
435}

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 1

Tags 4

↑↓ moveenter openesc close