detached b46ec2f

feat(examples): session lock demo with a masked password field

commit
b46ec2f7272d0462835848e650f569122c144225
author
brice · · 2026-10-01 19:13 UTC
parent
5e501a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +439 -0
A examples/lock/main.go +439 -0
@@ -0,0 +1,439 @@
1+// Command lock shows an ext-session-lock-v1 session lock with a password
2+// field, drawn by NeferGUI.
3+//
4+// THIS IS A DEMO, NOT A SCREEN LOCKER. It performs no authentication: pressing
5+// Enter unlocks the session whatever was typed, so anyone at the keyboard can
6+// open it. Do not use it, or code copied from it, to protect a real session. A
7+// real locker must verify the secret (PAM, for example) before it calls
8+// Lock.Unlock. Run it on a real session only if you accept that.
9+//
10+// The typed text goes into a neferclient.SecretBuffer, never into the view or
11+// the renderer: the field shows Node.Masked with the count from
12+// SecretBuffer.Len(). The program never converts the secret to a string and
13+// never prints it; the buffer is wiped on unlock and on exit.
14+//
15+// The program prints "locked" and "unlocked" on standard output as progress
16+// markers (the headless test waits for them).
17+//
18+// All logic runs on the owner goroutine, inside or right after Conn.Dispatch.
19+// The lock needs one surface per output; each gets its own Renderer.
20+package main
21+
22+import (
23+ "context"
24+ "errors"
25+ "fmt"
26+ "os"
27+ "os/signal"
28+ "time"
29+
30+ "github.com/bnema/neferclient"
31+ "github.com/bnema/nefergui"
32+)
33+
34+const keysymReturn, keysymKPEnter = 0xff0d, 0xff8d // xkb keysyms
35+
36+// model is what the view reads. The secret stays in its buffer; the view only
37+// asks for its length.
38+type model struct{ secret *neferclient.SecretBuffer }
39+
40+func view(f *nefergui.Frame, m *model) {
41+ root := f.Root().Column(nefergui.Inline("padding:40px;gap:12px;color:#ffffff;background:#101820"))
42+ root.Heading("Session locked (demo: no authentication)")
43+ root.Text("Type anything, then press Enter to unlock.")
44+ field := root.Row(nefergui.Inline("width:320px;height:36px;padding:6px;background:#203040;border:1px solid #6080a0"))
45+ field.Masked(m.secret.Len()) // bullets only: the view never sees the text
46+}
47+
48+// screen is the lock surface of one output and its renderer.
49+type screen struct {
50+ conn *neferclient.Conn
51+ model *model
52+ surf *neferclient.Surface
53+ r *nefergui.Renderer
54+ out nefergui.Output
55+ damage []neferclient.Rect
56+
57+ configured, canPresent, haveAcquire bool
58+}
59+
60+type app struct {
61+ neferclient.NopHandler
62+
63+ conn *neferclient.Conn
64+ lock *neferclient.Lock
65+ seat *neferclient.Seat
66+ secret *neferclient.SecretBuffer
67+ model model
68+ screens map[neferclient.SurfaceID]*screen
69+
70+ cursor neferclient.CursorShape
71+ locked, unlocked bool
72+ err error
73+}
74+
75+func main() {
76+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
77+ defer stop()
78+ if err := run(ctx); err != nil {
79+ fmt.Fprintln(os.Stderr, "lock:", err)
80+ os.Exit(1)
81+ }
82+}
83+
84+func run(ctx context.Context) (err error) {
85+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
86+ if err != nil {
87+ return fmt.Errorf("connect: %w", err)
88+ }
89+ a := &app{conn: conn, screens: map[neferclient.SurfaceID]*screen{}, cursor: neferclient.CursorDefault}
90+ a.secret = neferclient.NewSecretBuffer(256)
91+ a.model.secret = a.secret
92+ defer func() {
93+ a.secret.Wipe()
94+ for _, s := range a.screens {
95+ err = errors.Join(err, s.close())
96+ }
97+ err = errors.Join(err, conn.Close())
98+ }()
99+
100+ // The compositor may announce its first output just after Connect.
101+ for len(conn.Outputs()) == 0 {
102+ select {
103+ case <-conn.Wake():
104+ if err = conn.Dispatch(a); err != nil {
105+ return fmt.Errorf("dispatch: %w", err)
106+ }
107+ case <-ctx.Done():
108+ return ctx.Err()
109+ }
110+ }
111+ if a.lock, err = conn.Lock(); err != nil {
112+ return fmt.Errorf("lock: %w", err)
113+ }
114+ for _, o := range conn.Outputs() {
115+ if err = a.addScreen(o.Global); err != nil {
116+ return err
117+ }
118+ }
119+ // Typed text goes into the secret buffer, not into Handler.Key.
120+ a.seat = conn.Seat()
121+ a.seat.SetSecret(a.secret)
122+ defer a.seat.SetSecret(nil)
123+
124+ var tick <-chan time.Time
125+ for !a.unlocked {
126+ select {
127+ case <-ctx.Done():
128+ return ctx.Err() // the session stays locked: only Unlock ends it
129+ case <-conn.Wake():
130+ if err = conn.Dispatch(a); err != nil {
131+ return fmt.Errorf("dispatch: %w", err)
132+ }
133+ case <-tick:
134+ }
135+ if a.err != nil {
136+ return a.err
137+ }
138+ if a.unlocked {
139+ break
140+ }
141+ pending := false
142+ for _, s := range a.screens {
143+ shown, err := s.draw()
144+ if err != nil {
145+ return err
146+ }
147+ if shown {
148+ // The cursor the hovered element asked for → Seat.SetCursor.
149+ a.cursor = cursorShape(s.out.Cursor)
150+ if err = a.seat.SetCursor(a.cursor); err != nil {
151+ return fmt.Errorf("cursor: %w", err)
152+ }
153+ }
154+ pending = pending || (s.r != nil && s.r.Pending())
155+ }
156+ tick = nil
157+ if pending {
158+ tick = time.After(2 * time.Millisecond)
159+ }
160+ }
161+ return nil
162+}
163+
164+func (a *app) addScreen(output uint32) error {
165+ surf, err := a.lock.NewSurface(output)
166+ if err != nil {
167+ return fmt.Errorf("lock surface: %w", err)
168+ }
169+ a.screens[surf.ID()] = &screen{conn: a.conn, model: &a.model, surf: surf}
170+ return nil
171+}
172+
173+func (a *app) fail(err error) {
174+ if a.err == nil {
175+ a.err = err
176+ }
177+}
178+
179+func (a *app) Error(err error) { a.fail(err) }
180+
181+// OutputAdded: a new output must be covered too while the session is locked.
182+func (a *app) OutputAdded(out *neferclient.Output) {
183+ if a.lock == nil || a.unlocked {
184+ return
185+ }
186+ if err := a.addScreen(out.Global); err != nil {
187+ a.fail(err)
188+ }
189+}
190+
191+func (a *app) Locked() {
192+ a.locked = true
193+ fmt.Println("locked")
194+}
195+
196+func (a *app) LockFinished() {
197+ a.fail(errors.Join(errors.New("the compositor refused or ended the lock"), a.lock.Close()))
198+}
199+
200+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig.
201+func (a *app) FeedbackDone(id neferclient.SurfaceID) {
202+ if s := a.screens[id]; s != nil {
203+ a.fail(s.setup())
204+ }
205+}
206+
207+func (a *app) Configure(id neferclient.SurfaceID, _, _ int32) {
208+ if s := a.screens[id]; s != nil {
209+ s.configured, s.canPresent = true, true
210+ s.resize()
211+ a.fail(s.setup())
212+ }
213+}
214+
215+func (a *app) Scale(id neferclient.SurfaceID, _ float64) {
216+ if s := a.screens[id]; s != nil {
217+ s.resize()
218+ }
219+}
220+
221+func (a *app) Frame(id neferclient.SurfaceID) {
222+ if s := a.screens[id]; s != nil {
223+ s.canPresent = true
224+ }
225+}
226+
227+// FDReady: the release eventfd of a buffer is readable (watched in present;
228+// the id packs the surface and the buffer): hand the buffer back.
229+func (a *app) FDReady(id uint64) {
230+ s := a.screens[neferclient.SurfaceID(id>>32)]
231+ if s == nil || s.r == nil {
232+ return
233+ }
234+ if err := s.r.Released(id & 0xffffffff); err != nil {
235+ a.fail(fmt.Errorf("release buffer: %w", err))
236+ }
237+}
238+
239+// Pointer: neferclient events → nefergui.Input, field by field.
240+func (a *app) Pointer(ev *neferclient.PointerEvent) {
241+ s := a.screens[ev.Surface]
242+ if s == nil || s.r == nil {
243+ return
244+ }
245+ in := nefergui.Input{X: ev.X, Y: ev.Y, Kind: nefergui.InputPointerMotion}
246+ switch ev.Kind {
247+ case neferclient.PointerEnter:
248+ // The shape only applies after an enter: send the last one again.
249+ a.fail(a.seat.SetCursor(a.cursor))
250+ case neferclient.PointerLeave:
251+ in.Kind = nefergui.InputPointerLeave
252+ case neferclient.PointerButton:
253+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
254+ if ev.Pressed {
255+ in.Kind = nefergui.InputPointerPress
256+ }
257+ case neferclient.PointerAxis:
258+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
259+ }
260+ s.r.Input(&in)
261+}
262+
263+// Key: Enter unlocks (DEMO: no authentication). Keys that produced text were
264+// already stored in the SecretBuffer and carry no keysym or text: they are not
265+// forwarded, so the renderer never sees the secret.
266+func (a *app) Key(ev *neferclient.KeyEvent) {
267+ if ev.Secret {
268+ return
269+ }
270+ if ev.Pressed && !ev.Repeat && (ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter) {
271+ a.unlock()
272+ return
273+ }
274+ if s := a.screens[ev.Surface]; s != nil && s.r != nil {
275+ s.r.Input(&nefergui.Input{
276+ Kind: nefergui.InputKey, Keysym: ev.Keysym, Pressed: ev.Pressed, Repeat: ev.Repeat,
277+ Modifiers: nefergui.Modifiers(ev.Modifiers & 0x0f), // shift, ctrl, alt, super
278+ })
279+ }
280+}
281+
282+func (a *app) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
283+ if s := a.screens[id]; s != nil && s.r != nil {
284+ kind := nefergui.InputFocusOut
285+ if focused {
286+ kind = nefergui.InputFocusIn
287+ }
288+ s.r.Input(&nefergui.Input{Kind: kind})
289+ }
290+}
291+
292+// SecretChanged: the buffer changed; only its length is shown, on every screen.
293+func (a *app) SecretChanged(int) {
294+ for _, s := range a.screens {
295+ if s.r != nil {
296+ s.r.Invalidate()
297+ }
298+ }
299+}
300+
301+// unlock ends the session lock. DEMO ONLY: nothing is verified here.
302+func (a *app) unlock() {
303+ if !a.locked || a.unlocked {
304+ return
305+ }
306+ a.secret.Wipe()
307+ if err := a.lock.Unlock(); err != nil {
308+ a.fail(fmt.Errorf("unlock: %w", err))
309+ return
310+ }
311+ a.unlocked = true
312+ fmt.Println("unlocked")
313+}
314+
315+// setup creates the renderer once the surface is configured and its dmabuf
316+// feedback is complete.
317+func (s *screen) setup() error {
318+ fb := s.surf.Feedback()
319+ if s.r != nil || !s.configured || fb == nil {
320+ return nil
321+ }
322+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
323+ for i, f := range fb.Formats {
324+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
325+ }
326+ r, err := nefergui.NewRenderer(cfg)
327+ if err != nil {
328+ return fmt.Errorf("renderer: %w", err)
329+ }
330+ s.r = r
331+ s.resize()
332+ return nil
333+}
334+
335+func (s *screen) resize() {
336+ if s.r != nil {
337+ w, h, scale := s.surf.Size()
338+ s.r.Resize(int(w), int(h), scale)
339+ }
340+}
341+
342+// draw renders when something changed and presents the frame.
343+func (s *screen) draw() (bool, error) {
344+ if s.r == nil || !s.canPresent {
345+ return false, nil
346+ }
347+ ok, err := s.r.Render(&s.out, s.model, view)
348+ if err != nil {
349+ return false, fmt.Errorf("render: %w", err)
350+ }
351+ if !ok {
352+ return false, nil
353+ }
354+ return true, s.present()
355+}
356+
357+func cursorShape(c nefergui.Cursor) neferclient.CursorShape {
358+ switch c {
359+ case nefergui.CursorPointer:
360+ return neferclient.CursorPointer
361+ case nefergui.CursorText:
362+ return neferclient.CursorText
363+ case nefergui.CursorNotAllowed:
364+ return neferclient.CursorNotAllowed
365+ }
366+ return neferclient.CursorDefault
367+}
368+
369+// watchID packs a surface and a buffer into the Conn.WatchFD id.
370+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
371+
372+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
373+func (s *screen) present() error {
374+ out := &s.out
375+ for _, rt := range out.Retired { // buffers of an older size
376+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
377+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
378+ }
379+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
380+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
381+ }
382+ }
383+ if out.NewBuffer {
384+ buf := neferclient.Buffer{
385+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
386+ PlaneCount: out.PlaneCount,
387+ }
388+ for i, p := range out.Planes {
389+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
390+ }
391+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
392+ return fmt.Errorf("import buffer: %w", err)
393+ }
394+ // The release eventfd is stable per buffer: watch it once; FDReady
395+ // calls Renderer.Released.
396+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
397+ return fmt.Errorf("watch release fd: %w", err)
398+ }
399+ }
400+ if out.NewTimelines {
401+ if !s.haveAcquire { // the acquire timeline is shared by every buffer
402+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
403+ return fmt.Errorf("import acquire timeline: %w", err)
404+ }
405+ s.haveAcquire = true
406+ }
407+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
408+ return fmt.Errorf("import release timeline: %w", err)
409+ }
410+ }
411+ s.damage = s.damage[:0]
412+ for _, d := range out.Damage {
413+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
414+ }
415+ err := s.surf.Present(&neferclient.Present{
416+ Buffer: out.Buffer,
417+ AcquireTimeline: out.Acquire.ID,
418+ ReleaseTimeline: out.Release.ID,
419+ AcquirePoint: out.AcquirePoint,
420+ ReleasePoint: out.ReleasePoint,
421+ Damage: s.damage,
422+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
423+ })
424+ if err != nil {
425+ return fmt.Errorf("present: %w", err)
426+ }
427+ s.canPresent = false // until Handler.Frame
428+ return nil
429+}
430+
431+// close frees the renderer and the surface. The session lock itself is only
432+// ended by Lock.Unlock.
433+func (s *screen) close() error {
434+ var err error
435+ if s.r != nil {
436+ err = s.r.Close()
437+ }
438+ return errors.Join(err, s.surf.Close())
439+}
@@ -0,0 +1,439 @@
1+// Command lock shows an ext-session-lock-v1 session lock with a password
2+// field, drawn by NeferGUI.
3+//
4+// THIS IS A DEMO, NOT A SCREEN LOCKER. It performs no authentication: pressing
5+// Enter unlocks the session whatever was typed, so anyone at the keyboard can
6+// open it. Do not use it, or code copied from it, to protect a real session. A
7+// real locker must verify the secret (PAM, for example) before it calls
8+// Lock.Unlock. Run it on a real session only if you accept that.
9+//
10+// The typed text goes into a neferclient.SecretBuffer, never into the view or
11+// the renderer: the field shows Node.Masked with the count from
12+// SecretBuffer.Len(). The program never converts the secret to a string and
13+// never prints it; the buffer is wiped on unlock and on exit.
14+//
15+// The program prints "locked" and "unlocked" on standard output as progress
16+// markers (the headless test waits for them).
17+//
18+// All logic runs on the owner goroutine, inside or right after Conn.Dispatch.
19+// The lock needs one surface per output; each gets its own Renderer.
20+package main
21+
22+import (
23+ "context"
24+ "errors"
25+ "fmt"
26+ "os"
27+ "os/signal"
28+ "time"
29+
30+ "github.com/bnema/neferclient"
31+ "github.com/bnema/nefergui"
32+)
33+
34+const keysymReturn, keysymKPEnter = 0xff0d, 0xff8d // xkb keysyms
35+
36+// model is what the view reads. The secret stays in its buffer; the view only
37+// asks for its length.
38+type model struct{ secret *neferclient.SecretBuffer }
39+
40+func view(f *nefergui.Frame, m *model) {
41+ root := f.Root().Column(nefergui.Inline("padding:40px;gap:12px;color:#ffffff;background:#101820"))
42+ root.Heading("Session locked (demo: no authentication)")
43+ root.Text("Type anything, then press Enter to unlock.")
44+ field := root.Row(nefergui.Inline("width:320px;height:36px;padding:6px;background:#203040;border:1px solid #6080a0"))
45+ field.Masked(m.secret.Len()) // bullets only: the view never sees the text
46+}
47+
48+// screen is the lock surface of one output and its renderer.
49+type screen struct {
50+ conn *neferclient.Conn
51+ model *model
52+ surf *neferclient.Surface
53+ r *nefergui.Renderer
54+ out nefergui.Output
55+ damage []neferclient.Rect
56+
57+ configured, canPresent, haveAcquire bool
58+}
59+
60+type app struct {
61+ neferclient.NopHandler
62+
63+ conn *neferclient.Conn
64+ lock *neferclient.Lock
65+ seat *neferclient.Seat
66+ secret *neferclient.SecretBuffer
67+ model model
68+ screens map[neferclient.SurfaceID]*screen
69+
70+ cursor neferclient.CursorShape
71+ locked, unlocked bool
72+ err error
73+}
74+
75+func main() {
76+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
77+ defer stop()
78+ if err := run(ctx); err != nil {
79+ fmt.Fprintln(os.Stderr, "lock:", err)
80+ os.Exit(1)
81+ }
82+}
83+
84+func run(ctx context.Context) (err error) {
85+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
86+ if err != nil {
87+ return fmt.Errorf("connect: %w", err)
88+ }
89+ a := &app{conn: conn, screens: map[neferclient.SurfaceID]*screen{}, cursor: neferclient.CursorDefault}
90+ a.secret = neferclient.NewSecretBuffer(256)
91+ a.model.secret = a.secret
92+ defer func() {
93+ a.secret.Wipe()
94+ for _, s := range a.screens {
95+ err = errors.Join(err, s.close())
96+ }
97+ err = errors.Join(err, conn.Close())
98+ }()
99+
100+ // The compositor may announce its first output just after Connect.
101+ for len(conn.Outputs()) == 0 {
102+ select {
103+ case <-conn.Wake():
104+ if err = conn.Dispatch(a); err != nil {
105+ return fmt.Errorf("dispatch: %w", err)
106+ }
107+ case <-ctx.Done():
108+ return ctx.Err()
109+ }
110+ }
111+ if a.lock, err = conn.Lock(); err != nil {
112+ return fmt.Errorf("lock: %w", err)
113+ }
114+ for _, o := range conn.Outputs() {
115+ if err = a.addScreen(o.Global); err != nil {
116+ return err
117+ }
118+ }
119+ // Typed text goes into the secret buffer, not into Handler.Key.
120+ a.seat = conn.Seat()
121+ a.seat.SetSecret(a.secret)
122+ defer a.seat.SetSecret(nil)
123+
124+ var tick <-chan time.Time
125+ for !a.unlocked {
126+ select {
127+ case <-ctx.Done():
128+ return ctx.Err() // the session stays locked: only Unlock ends it
129+ case <-conn.Wake():
130+ if err = conn.Dispatch(a); err != nil {
131+ return fmt.Errorf("dispatch: %w", err)
132+ }
133+ case <-tick:
134+ }
135+ if a.err != nil {
136+ return a.err
137+ }
138+ if a.unlocked {
139+ break
140+ }
141+ pending := false
142+ for _, s := range a.screens {
143+ shown, err := s.draw()
144+ if err != nil {
145+ return err
146+ }
147+ if shown {
148+ // The cursor the hovered element asked for → Seat.SetCursor.
149+ a.cursor = cursorShape(s.out.Cursor)
150+ if err = a.seat.SetCursor(a.cursor); err != nil {
151+ return fmt.Errorf("cursor: %w", err)
152+ }
153+ }
154+ pending = pending || (s.r != nil && s.r.Pending())
155+ }
156+ tick = nil
157+ if pending {
158+ tick = time.After(2 * time.Millisecond)
159+ }
160+ }
161+ return nil
162+}
163+
164+func (a *app) addScreen(output uint32) error {
165+ surf, err := a.lock.NewSurface(output)
166+ if err != nil {
167+ return fmt.Errorf("lock surface: %w", err)
168+ }
169+ a.screens[surf.ID()] = &screen{conn: a.conn, model: &a.model, surf: surf}
170+ return nil
171+}
172+
173+func (a *app) fail(err error) {
174+ if a.err == nil {
175+ a.err = err
176+ }
177+}
178+
179+func (a *app) Error(err error) { a.fail(err) }
180+
181+// OutputAdded: a new output must be covered too while the session is locked.
182+func (a *app) OutputAdded(out *neferclient.Output) {
183+ if a.lock == nil || a.unlocked {
184+ return
185+ }
186+ if err := a.addScreen(out.Global); err != nil {
187+ a.fail(err)
188+ }
189+}
190+
191+func (a *app) Locked() {
192+ a.locked = true
193+ fmt.Println("locked")
194+}
195+
196+func (a *app) LockFinished() {
197+ a.fail(errors.Join(errors.New("the compositor refused or ended the lock"), a.lock.Close()))
198+}
199+
200+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig.
201+func (a *app) FeedbackDone(id neferclient.SurfaceID) {
202+ if s := a.screens[id]; s != nil {
203+ a.fail(s.setup())
204+ }
205+}
206+
207+func (a *app) Configure(id neferclient.SurfaceID, _, _ int32) {
208+ if s := a.screens[id]; s != nil {
209+ s.configured, s.canPresent = true, true
210+ s.resize()
211+ a.fail(s.setup())
212+ }
213+}
214+
215+func (a *app) Scale(id neferclient.SurfaceID, _ float64) {
216+ if s := a.screens[id]; s != nil {
217+ s.resize()
218+ }
219+}
220+
221+func (a *app) Frame(id neferclient.SurfaceID) {
222+ if s := a.screens[id]; s != nil {
223+ s.canPresent = true
224+ }
225+}
226+
227+// FDReady: the release eventfd of a buffer is readable (watched in present;
228+// the id packs the surface and the buffer): hand the buffer back.
229+func (a *app) FDReady(id uint64) {
230+ s := a.screens[neferclient.SurfaceID(id>>32)]
231+ if s == nil || s.r == nil {
232+ return
233+ }
234+ if err := s.r.Released(id & 0xffffffff); err != nil {
235+ a.fail(fmt.Errorf("release buffer: %w", err))
236+ }
237+}
238+
239+// Pointer: neferclient events → nefergui.Input, field by field.
240+func (a *app) Pointer(ev *neferclient.PointerEvent) {
241+ s := a.screens[ev.Surface]
242+ if s == nil || s.r == nil {
243+ return
244+ }
245+ in := nefergui.Input{X: ev.X, Y: ev.Y, Kind: nefergui.InputPointerMotion}
246+ switch ev.Kind {
247+ case neferclient.PointerEnter:
248+ // The shape only applies after an enter: send the last one again.
249+ a.fail(a.seat.SetCursor(a.cursor))
250+ case neferclient.PointerLeave:
251+ in.Kind = nefergui.InputPointerLeave
252+ case neferclient.PointerButton:
253+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
254+ if ev.Pressed {
255+ in.Kind = nefergui.InputPointerPress
256+ }
257+ case neferclient.PointerAxis:
258+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
259+ }
260+ s.r.Input(&in)
261+}
262+
263+// Key: Enter unlocks (DEMO: no authentication). Keys that produced text were
264+// already stored in the SecretBuffer and carry no keysym or text: they are not
265+// forwarded, so the renderer never sees the secret.
266+func (a *app) Key(ev *neferclient.KeyEvent) {
267+ if ev.Secret {
268+ return
269+ }
270+ if ev.Pressed && !ev.Repeat && (ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter) {
271+ a.unlock()
272+ return
273+ }
274+ if s := a.screens[ev.Surface]; s != nil && s.r != nil {
275+ s.r.Input(&nefergui.Input{
276+ Kind: nefergui.InputKey, Keysym: ev.Keysym, Pressed: ev.Pressed, Repeat: ev.Repeat,
277+ Modifiers: nefergui.Modifiers(ev.Modifiers & 0x0f), // shift, ctrl, alt, super
278+ })
279+ }
280+}
281+
282+func (a *app) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
283+ if s := a.screens[id]; s != nil && s.r != nil {
284+ kind := nefergui.InputFocusOut
285+ if focused {
286+ kind = nefergui.InputFocusIn
287+ }
288+ s.r.Input(&nefergui.Input{Kind: kind})
289+ }
290+}
291+
292+// SecretChanged: the buffer changed; only its length is shown, on every screen.
293+func (a *app) SecretChanged(int) {
294+ for _, s := range a.screens {
295+ if s.r != nil {
296+ s.r.Invalidate()
297+ }
298+ }
299+}
300+
301+// unlock ends the session lock. DEMO ONLY: nothing is verified here.
302+func (a *app) unlock() {
303+ if !a.locked || a.unlocked {
304+ return
305+ }
306+ a.secret.Wipe()
307+ if err := a.lock.Unlock(); err != nil {
308+ a.fail(fmt.Errorf("unlock: %w", err))
309+ return
310+ }
311+ a.unlocked = true
312+ fmt.Println("unlocked")
313+}
314+
315+// setup creates the renderer once the surface is configured and its dmabuf
316+// feedback is complete.
317+func (s *screen) setup() error {
318+ fb := s.surf.Feedback()
319+ if s.r != nil || !s.configured || fb == nil {
320+ return nil
321+ }
322+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
323+ for i, f := range fb.Formats {
324+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
325+ }
326+ r, err := nefergui.NewRenderer(cfg)
327+ if err != nil {
328+ return fmt.Errorf("renderer: %w", err)
329+ }
330+ s.r = r
331+ s.resize()
332+ return nil
333+}
334+
335+func (s *screen) resize() {
336+ if s.r != nil {
337+ w, h, scale := s.surf.Size()
338+ s.r.Resize(int(w), int(h), scale)
339+ }
340+}
341+
342+// draw renders when something changed and presents the frame.
343+func (s *screen) draw() (bool, error) {
344+ if s.r == nil || !s.canPresent {
345+ return false, nil
346+ }
347+ ok, err := s.r.Render(&s.out, s.model, view)
348+ if err != nil {
349+ return false, fmt.Errorf("render: %w", err)
350+ }
351+ if !ok {
352+ return false, nil
353+ }
354+ return true, s.present()
355+}
356+
357+func cursorShape(c nefergui.Cursor) neferclient.CursorShape {
358+ switch c {
359+ case nefergui.CursorPointer:
360+ return neferclient.CursorPointer
361+ case nefergui.CursorText:
362+ return neferclient.CursorText
363+ case nefergui.CursorNotAllowed:
364+ return neferclient.CursorNotAllowed
365+ }
366+ return neferclient.CursorDefault
367+}
368+
369+// watchID packs a surface and a buffer into the Conn.WatchFD id.
370+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
371+
372+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
373+func (s *screen) present() error {
374+ out := &s.out
375+ for _, rt := range out.Retired { // buffers of an older size
376+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
377+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
378+ }
379+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
380+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
381+ }
382+ }
383+ if out.NewBuffer {
384+ buf := neferclient.Buffer{
385+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
386+ PlaneCount: out.PlaneCount,
387+ }
388+ for i, p := range out.Planes {
389+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
390+ }
391+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
392+ return fmt.Errorf("import buffer: %w", err)
393+ }
394+ // The release eventfd is stable per buffer: watch it once; FDReady
395+ // calls Renderer.Released.
396+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
397+ return fmt.Errorf("watch release fd: %w", err)
398+ }
399+ }
400+ if out.NewTimelines {
401+ if !s.haveAcquire { // the acquire timeline is shared by every buffer
402+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
403+ return fmt.Errorf("import acquire timeline: %w", err)
404+ }
405+ s.haveAcquire = true
406+ }
407+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
408+ return fmt.Errorf("import release timeline: %w", err)
409+ }
410+ }
411+ s.damage = s.damage[:0]
412+ for _, d := range out.Damage {
413+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
414+ }
415+ err := s.surf.Present(&neferclient.Present{
416+ Buffer: out.Buffer,
417+ AcquireTimeline: out.Acquire.ID,
418+ ReleaseTimeline: out.Release.ID,
419+ AcquirePoint: out.AcquirePoint,
420+ ReleasePoint: out.ReleasePoint,
421+ Damage: s.damage,
422+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
423+ })
424+ if err != nil {
425+ return fmt.Errorf("present: %w", err)
426+ }
427+ s.canPresent = false // until Handler.Frame
428+ return nil
429+}
430+
431+// close frees the renderer and the surface. The session lock itself is only
432+// ended by Lock.Unlock.
433+func (s *screen) close() error {
434+ var err error
435+ if s.r != nil {
436+ err = s.r.Close()
437+ }
438+ return errors.Join(err, s.surf.Close())
439+}

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