v0.6.1 aa12db3

main.go RawHistoryFolder

1// Command demo shows a desktop document workspace (toolbar, document list,
2// text editor and properties pane, light and dark themes) in a Wayland window.
3//
4// NeferGUI draws and neferclient owns the Wayland window; the two libraries do
5// not import each other, so this file copies plain fields between them. The
6// glue follows neferclient's examples/layer program, with an xdg-toplevel
7// window instead of a layer surface.
8//
9// Everything runs on one owner goroutine: the loop in run waits for a wake-up,
10// calls Conn.Dispatch, then renders and presents.
11package main
12
13import (
14	"context"
15	_ "embed"
16	"errors"
17	"flag"
18	"fmt"
19	"os"
20	"os/signal"
21	"syscall"
22	"time"
23
24	"github.com/bnema/neferclient"
25	"github.com/bnema/nefergui"
26)
27
28//go:embed app.css
29var stylesheet []byte
30
31// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
32// bit order, so the plain bits can be copied.
33const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
34	neferclient.ModCapsLock | neferclient.ModNumLock
35
36type demo struct {
37	neferclient.NopHandler // methods this program does not need
38
39	conn *neferclient.Conn
40	surf *neferclient.Surface
41	seat *neferclient.Seat
42
43	styles string // path of the stylesheet file for RendererConfig.Styles
44	r      *nefergui.Renderer
45	rwake  <-chan struct{} // Renderer.Wake: nil until the renderer exists
46	out    nefergui.Output
47	model  Model
48	damage []neferclient.Rect
49
50	configured  bool
51	canPresent  bool // configured, and the last frame callback fired
52	haveAcquire bool
53	cursor      neferclient.CursorShape
54
55	frames, shown int // -frames limit and frames acknowledged so far
56	done          bool
57	err           error
58}
59
60func main() {
61	frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
62	flag.Parse()
63	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64	defer stop()
65	if err := run(ctx, *frames); err != nil {
66		fmt.Fprintln(os.Stderr, "demo:", err)
67		os.Exit(1)
68	}
69}
70
71func run(ctx context.Context, frames int) (err error) {
72	styles, err := writeStyles()
73	if err != nil {
74		return err
75	}
76	defer os.Remove(styles)
77
78	conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
79	if err != nil {
80		return fmt.Errorf("connect: %w", err)
81	}
82	d := &demo{conn: conn, styles: styles, frames: frames, cursor: neferclient.CursorDefault, model: Model{
83		Page: "home", Status: "Ready", Density: "comfortable", Volume: 40,
84		Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session.",
85	}}
86	// Close the connection first (it destroys every Wayland object), then
87	// free the renderer and its GPU resources.
88	defer func() {
89		err = errors.Join(err, conn.Close())
90		if d.r != nil {
91			err = errors.Join(err, d.r.Close())
92		}
93	}()
94	if d.surf, err = conn.NewToplevel("NeferGUI — Untitled.txt", 960, 640); err != nil {
95		return fmt.Errorf("window: %w", err)
96	}
97	d.seat = conn.Seat()
98
99	var tick <-chan time.Time
100	for !d.done {
101		select {
102		case <-ctx.Done():
103			return nil // interrupt or SIGTERM: a clean exit
104		case <-conn.Wake():
105			if err = conn.Dispatch(d); err != nil {
106				return fmt.Errorf("dispatch: %w", err)
107			}
108		case <-d.rwake: // another goroutine asked for a frame
109		case <-tick: // a built frame was waiting for the GPU
110		}
111		if d.err != nil {
112			return d.err
113		}
114		if err = d.draw(); err != nil {
115			return err
116		}
117		tick = nil
118		if d.r != nil && d.r.Pending() {
119			tick = time.After(2 * time.Millisecond)
120		}
121	}
122	return nil
123}
124
125// writeStyles writes the embedded stylesheet to a private temporary file:
126// RendererConfig.Styles takes a path.
127func writeStyles() (string, error) {
128	f, err := os.CreateTemp("", "nefergui-demo-*.css")
129	if err != nil {
130		return "", fmt.Errorf("stylesheet: %w", err)
131	}
132	_, err = f.Write(stylesheet)
133	if err = errors.Join(err, f.Close()); err != nil {
134		os.Remove(f.Name())
135		return "", fmt.Errorf("stylesheet: %w", err)
136	}
137	return f.Name(), nil
138}
139
140// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
141// complete feedback is used; this demo assumes a single GPU.
142func (d *demo) FeedbackDone(neferclient.SurfaceID) { d.setup() }
143
144func (d *demo) Configure(neferclient.SurfaceID, int32, int32) {
145	if !d.configured { // later configures must not lift the frame-callback gate
146		d.canPresent = true
147	}
148	d.configured = true
149	d.resize()
150	d.setup()
151}
152
153func (d *demo) Scale(neferclient.SurfaceID, float64) { d.resize() }
154
155func (d *demo) setup() {
156	fb := d.surf.Feedback()
157	if d.r != nil || !d.configured || fb == nil {
158		return
159	}
160	cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Styles: d.styles, Formats: make([]nefergui.Format, len(fb.Formats))}
161	for i, f := range fb.Formats {
162		cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
163	}
164	r, err := nefergui.NewRenderer(cfg)
165	if err != nil {
166		d.fail(fmt.Errorf("renderer: %w", err))
167		return
168	}
169	d.r, d.rwake = r, r.Wake()
170	d.resize()
171}
172
173// resize forwards the logical size and scale to the renderer.
174func (d *demo) resize() {
175	if d.r == nil {
176		return
177	}
178	w, h, scale := d.surf.Size()
179	d.r.Resize(int(w), int(h), scale)
180}
181
182// Frame: the compositor showed the last commit; the next Present is allowed.
183func (d *demo) Frame(neferclient.SurfaceID) {
184	d.canPresent = true
185	d.shown++
186	if d.frames > 0 {
187		if d.shown >= d.frames {
188			d.done = true
189			return
190		}
191		d.r.Invalidate()
192	}
193}
194
195func (d *demo) Closed(neferclient.SurfaceID) { d.done = true }
196
197func (d *demo) Error(err error) { d.fail(err) }
198
199func (d *demo) fail(err error) {
200	if d.err == nil {
201		d.err = err
202	}
203}
204
205// FDReady: a buffer's release eventfd is readable (id is the buffer).
206func (d *demo) FDReady(id uint64) {
207	if err := d.r.Released(id); err != nil {
208		d.fail(fmt.Errorf("release buffer %d: %w", id, err))
209	}
210}
211
212// Pointer and Key: neferclient events → nefergui.Input, field by field.
213func (d *demo) Pointer(ev *neferclient.PointerEvent) {
214	if d.r == nil {
215		return
216	}
217	in := nefergui.Input{X: ev.X, Y: ev.Y}
218	switch ev.Kind {
219	case neferclient.PointerEnter:
220		in.Kind = nefergui.InputPointerMotion
221		// The cursor shape is only valid after the enter: set it again.
222		if err := d.seat.SetCursor(d.cursor); err != nil {
223			d.fail(fmt.Errorf("cursor: %w", err))
224		}
225	case neferclient.PointerMotion:
226		in.Kind = nefergui.InputPointerMotion
227	case neferclient.PointerLeave:
228		in.Kind = nefergui.InputPointerLeave
229	case neferclient.PointerButton:
230		in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
231		if ev.Pressed {
232			in.Kind = nefergui.InputPointerPress
233		}
234	case neferclient.PointerAxis:
235		in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
236	default:
237		return
238	}
239	d.r.Input(&in)
240}
241
242func (d *demo) Key(ev *neferclient.KeyEvent) {
243	if d.r == nil {
244		return
245	}
246	d.r.Input(&nefergui.Input{
247		Kind:      nefergui.InputKey,
248		Keysym:    ev.Keysym,
249		Text:      ev.Text, // valid only during the call, as for Input
250		Pressed:   ev.Pressed,
251		Repeat:    ev.Repeat,
252		Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
253	})
254}
255
256func (d *demo) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
257	if d.r == nil {
258		return
259	}
260	kind := nefergui.InputFocusOut
261	if focused {
262		kind = nefergui.InputFocusIn
263	}
264	d.r.Input(&nefergui.Input{Kind: kind})
265}
266
267// draw renders a frame when something changed and presents it.
268func (d *demo) draw() error {
269	if d.r == nil || !d.canPresent {
270		return nil
271	}
272	ok, err := d.r.Render(&d.out, &d.model, app)
273	if err != nil {
274		return fmt.Errorf("render: %w", err)
275	}
276	if !ok {
277		return nil
278	}
279	if err = d.present(); err != nil {
280		return err
281	}
282	return d.setCursor()
283}
284
285// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
286func (d *demo) present() error {
287	out := &d.out
288	// A resize retired buffers: stop watching and destroy them first.
289	for _, rt := range out.Retired {
290		if err := d.conn.UnwatchFD(rt.ReleaseFD); err != nil {
291			return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
292		}
293		if err := d.surf.DestroyBuffer(rt.Buffer); err != nil {
294			return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
295		}
296		// The release timeline of a buffer is imported under the buffer's id.
297		if err := d.surf.DestroyTimeline(rt.Buffer); err != nil {
298			return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
299		}
300	}
301	if out.NewBuffer {
302		buf := neferclient.Buffer{
303			Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
304			PlaneCount: out.PlaneCount,
305		}
306		for i, p := range out.Planes {
307			buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
308		}
309		if err := d.surf.ImportBuffer(out.Buffer, &buf); err != nil {
310			return fmt.Errorf("import buffer: %w", err)
311		}
312		// The release eventfd is stable per buffer: watch it once.
313		if err := d.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
314			return fmt.Errorf("watch release fd: %w", err)
315		}
316	}
317	if out.NewTimelines {
318		if !d.haveAcquire { // the acquire timeline is shared by every buffer
319			if err := d.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
320				return fmt.Errorf("import acquire timeline: %w", err)
321			}
322			d.haveAcquire = true
323		}
324		if err := d.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
325			return fmt.Errorf("import release timeline: %w", err)
326		}
327	}
328	d.damage = d.damage[:0]
329	for _, r := range out.Damage {
330		d.damage = append(d.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
331	}
332	err := d.surf.Present(&neferclient.Present{
333		Buffer:          out.Buffer,
334		AcquireTimeline: out.Acquire.ID,
335		ReleaseTimeline: out.Release.ID,
336		AcquirePoint:    out.AcquirePoint,
337		ReleasePoint:    out.ReleasePoint,
338		Damage:          d.damage,
339		Opaque:          true, // XRGB8888: RendererConfig.Transparent is false
340	})
341	if err != nil {
342		return fmt.Errorf("present: %w", err)
343	}
344	d.canPresent = false // until Frame
345	return nil
346}
347
348// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
349func (d *demo) setCursor() error {
350	switch d.out.Cursor {
351	case nefergui.CursorPointer:
352		d.cursor = neferclient.CursorPointer
353	case nefergui.CursorText:
354		d.cursor = neferclient.CursorText
355	case nefergui.CursorNotAllowed:
356		d.cursor = neferclient.CursorNotAllowed
357	default:
358		d.cursor = neferclient.CursorDefault
359	}
360	if err := d.seat.SetCursor(d.cursor); err != nil {
361		return fmt.Errorf("cursor: %w", err)
362	}
363	return nil
364}

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 12

↑↓ moveenter openesc close