detached 751c509

Merge branch 'nc/examples': end-to-end layer and lock examples with NeferGUI

commit
751c509154a4496bd433770dda4e20ef88992781
author
brice · · 2026-10-01 19:24 UTC
merge of
8d3baba + d2ca9d3 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1245 -1
M Makefile +3 -1
@@ -1,4 +1,4 @@
1-.PHONY: build clean-build test vet race mocks mocks-check fakes-check perf-check check
1+.PHONY: build clean-build test vet race mocks mocks-check fakes-check perf-check check examples-check
22
33 build:
44 CGO_ENABLED=0 go build ./...
@@ -20,5 +20,7 @@ fakes-check:
2020 [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && printf '%s\n' 'handwritten test double: generate with Mockery v3' >&2; exit 1; }
2121 perf-check:
2222 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...
23+examples-check:
24+ cd examples && CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 staticcheck ./...
2325 check: build clean-build vet test fakes-check perf-check
2426 CGO_ENABLED=0 staticcheck ./...
@@ -1,4 +1,4 @@
1-.PHONY: build clean-build test vet race mocks mocks-check fakes-check perf-check check1+.PHONY: build clean-build test vet race mocks mocks-check fakes-check perf-check check examples-check
2 2
3 build:3 build:
4 CGO_ENABLED=0 go build ./...4 CGO_ENABLED=0 go build ./...
@@ -20,5 +20,7 @@ fakes-check:
20 [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && printf '%s\n' 'handwritten test double: generate with Mockery v3' >&2; exit 1; }20 [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && printf '%s\n' 'handwritten test double: generate with Mockery v3' >&2; exit 1; }
21 perf-check:21 perf-check:
22 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...22 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...
23+examples-check:
24+ cd examples && CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 staticcheck ./...
23 check: build clean-build vet test fakes-check perf-check25 check: build clean-build vet test fakes-check perf-check
24 CGO_ENABLED=0 staticcheck ./...26 CGO_ENABLED=0 staticcheck ./...
M README.md +32 -0
@@ -11,6 +11,38 @@ A Wayland client toolkit library for Go: connection, outputs, surface roles, sea
1111 - A compositor advertising `zwp_linux_dmabuf_v1` (v4) and `wp_linux_drm_syncobj_manager_v1`.
1212 - `libxkbcommon` at runtime for keyboard input.
1313
14+## Usage
15+
16+The library does not render. The `examples` module pairs it with [NeferGUI](https://github.com/bnema/nefergui) (a pure graphics library with no Wayland code); the two never import each other, and the examples copy plain fields between them. The loop runs on one owner goroutine:
17+
18+```go
19+conn, err := neferclient.Connect(ctx, "")
20+if err != nil {
21+ return fmt.Errorf("connect: %w", err)
22+}
23+defer conn.Close()
24+surf, err := conn.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop, Width: 240, Height: 90})
25+if err != nil {
26+ return fmt.Errorf("layer surface: %w", err)
27+}
28+for {
29+ select {
30+ case <-conn.Wake():
31+ if err := conn.Dispatch(handler); err != nil { // Configure, FeedbackDone, Frame, Pointer, Key, FDReady...
32+ return fmt.Errorf("dispatch: %w", err)
33+ }
34+ case <-ctx.Done():
35+ return nil
36+ }
37+ // render with NeferGUI, then surf.ImportBuffer / surf.ImportTimeline / surf.Present
38+}
39+```
40+
41+- [`examples/layer`](examples/layer): a layer surface with a counter button.
42+- [`examples/lock`](examples/lock): a session lock with a masked password field. It is a demo and authenticates nothing; read its header before running it.
43+
44+Run them against a compositor with `cd examples && go run ./layer`. `make examples-check` builds, vets and lints them; the headless test needs a NeferWL binary: `cd examples && NEFERCLIENT_HEADLESS=/usr/sbin/neferwl go test -run Headless ./...`.
45+
1446 ## License
1547
1648 MIT
@@ -11,6 +11,38 @@ A Wayland client toolkit library for Go: connection, outputs, surface roles, sea
11 - A compositor advertising `zwp_linux_dmabuf_v1` (v4) and `wp_linux_drm_syncobj_manager_v1`.11 - A compositor advertising `zwp_linux_dmabuf_v1` (v4) and `wp_linux_drm_syncobj_manager_v1`.
12 - `libxkbcommon` at runtime for keyboard input.12 - `libxkbcommon` at runtime for keyboard input.
13 13
14+## Usage
15+
16+The library does not render. The `examples` module pairs it with [NeferGUI](https://github.com/bnema/nefergui) (a pure graphics library with no Wayland code); the two never import each other, and the examples copy plain fields between them. The loop runs on one owner goroutine:
17+
18+```go
19+conn, err := neferclient.Connect(ctx, "")
20+if err != nil {
21+ return fmt.Errorf("connect: %w", err)
22+}
23+defer conn.Close()
24+surf, err := conn.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop, Width: 240, Height: 90})
25+if err != nil {
26+ return fmt.Errorf("layer surface: %w", err)
27+}
28+for {
29+ select {
30+ case <-conn.Wake():
31+ if err := conn.Dispatch(handler); err != nil { // Configure, FeedbackDone, Frame, Pointer, Key, FDReady...
32+ return fmt.Errorf("dispatch: %w", err)
33+ }
34+ case <-ctx.Done():
35+ return nil
36+ }
37+ // render with NeferGUI, then surf.ImportBuffer / surf.ImportTimeline / surf.Present
38+}
39+```
40+
41+- [`examples/layer`](examples/layer): a layer surface with a counter button.
42+- [`examples/lock`](examples/lock): a session lock with a masked password field. It is a demo and authenticates nothing; read its header before running it.
43+
44+Run them against a compositor with `cd examples && go run ./layer`. `make examples-check` builds, vets and lints them; the headless test needs a NeferWL binary: `cd examples && NEFERCLIENT_HEADLESS=/usr/sbin/neferwl go test -run Headless ./...`.
45+
14 ## License46 ## License
15 47
16 MIT48 MIT
A examples/doc.go +4 -0
@@ -0,0 +1,4 @@
1+// Package examples holds the neferclient and NeferGUI example programs
2+// (layer, lock) and their headless tests. The two libraries never import each
3+// other: each program copies plain fields between them.
4+package examples
@@ -0,0 +1,4 @@
1+// Package examples holds the neferclient and NeferGUI example programs
2+// (layer, lock) and their headless tests. The two libraries never import each
3+// other: each program copies plain fields between them.
4+package examples
A examples/go.mod +8 -0
@@ -0,0 +1,8 @@
1+module github.com/bnema/neferclient/examples
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/neferclient v0.1.0
7+ github.com/bnema/nefergui v0.5.0
8+)
@@ -0,0 +1,8 @@
1+module github.com/bnema/neferclient/examples
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/neferclient v0.1.0
7+ github.com/bnema/nefergui v0.5.0
8+)
A examples/headless_test.go +262 -0
@@ -0,0 +1,262 @@
1+package examples_test
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "image"
8+ "image/png"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "slices"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+)
20+
21+// The tests run the example programs against a NeferWL compositor started
22+// headless in an isolated runtime directory, and are skipped unless
23+// NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
24+// that nested compositor: the child environment is stripped of the caller's
25+// session variables, and the socket is checked to be absolute (an absolute
26+// WAYLAND_DISPLAY is how the example finds it). Never point these tests at a
27+// real session.
28+
29+var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
30+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
31+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
32+
33+func cleanEnv() []string {
34+ var out []string
35+next:
36+ for _, e := range os.Environ() {
37+ for _, p := range envDrop {
38+ if strings.HasPrefix(e, p) {
39+ continue next
40+ }
41+ }
42+ out = append(out, e)
43+ }
44+ return out
45+}
46+
47+func must(t *testing.T, err error, what ...any) {
48+ t.Helper()
49+ if err != nil {
50+ t.Fatalf("%v: %v", fmt.Sprint(what...), err)
51+ }
52+}
53+
54+// syncBuffer is a bytes buffer safe for the copying goroutine of exec and the
55+// test goroutine.
56+type syncBuffer struct {
57+ mu sync.Mutex
58+ b strings.Builder
59+}
60+
61+func (s *syncBuffer) Write(p []byte) (int, error) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ return s.b.Write(p)
65+}
66+
67+func (s *syncBuffer) String() string {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ return s.b.String()
71+}
72+
73+type compositor struct {
74+ socket, shots string
75+ env []string
76+ input io.WriteCloser // NeferWL input script (stdin)
77+}
78+
79+// startHeadless runs NeferWL and returns once its socket exists.
80+func startHeadless(t *testing.T) *compositor {
81+ t.Helper()
82+ bin := os.Getenv("NEFERCLIENT_HEADLESS")
83+ if bin == "" {
84+ t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary")
85+ }
86+ bin, err := filepath.Abs(bin)
87+ must(t, err)
88+ root := t.TempDir()
89+ dirs := map[string]string{}
90+ for _, name := range []string{"run", "config", "data", "state", "shots"} {
91+ dirs[name] = filepath.Join(root, name)
92+ must(t, os.Mkdir(dirs[name], 0o700))
93+ }
94+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
95+ must(t, err)
96+ t.Cleanup(func() { _ = logf.Close() })
97+ env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
98+ "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
99+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480",
100+ "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-")
101+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
102+ cmd.Env = env
103+ cmd.Stdout, cmd.Stderr = logf, logf
104+ stdin, err := cmd.StdinPipe()
105+ must(t, err)
106+ must(t, cmd.Start())
107+ exited := make(chan struct{})
108+ go func() { _ = cmd.Wait(); close(exited) }()
109+ t.Cleanup(func() {
110+ _ = stdin.Close()
111+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
112+ <-exited
113+ })
114+ deadline := time.Now().Add(15 * time.Second)
115+ for {
116+ paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
117+ for _, p := range paths {
118+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
119+ if !filepath.IsAbs(p) {
120+ t.Fatalf("socket %q is not absolute", p)
121+ }
122+ return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
123+ }
124+ }
125+ select {
126+ case <-exited:
127+ b, _ := os.ReadFile(logf.Name())
128+ t.Fatalf("neferwl exited before serving:\n%s", b)
129+ case <-time.After(5 * time.Millisecond):
130+ }
131+ if time.Now().After(deadline) {
132+ b, _ := os.ReadFile(logf.Name())
133+ t.Fatalf("headless socket timeout:\n%s", b)
134+ }
135+ }
136+}
137+
138+// build compiles one example into a temporary directory.
139+func build(t *testing.T, pkg string) string {
140+ t.Helper()
141+ exe := filepath.Join(t.TempDir(), filepath.Base(pkg))
142+ out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput()
143+ must(t, err, string(out))
144+ return exe
145+}
146+
147+// command prepares an example to run against c, and only against c.
148+func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
149+ cmd := exec.CommandContext(ctx, exe, args...)
150+ // slices.Concat copies: c.env is never aliased or modified.
151+ cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket})
152+ return cmd
153+}
154+
155+func near(a, b uint32) bool {
156+ d := int(a>>8) - int(b>>8)
157+ return d >= -2 && d <= 2
158+}
159+
160+// hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per
161+// channel, at (x, y).
162+func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
163+ t.Helper()
164+ f, err := os.Open(path)
165+ must(t, err)
166+ defer f.Close()
167+ img, err := png.Decode(f)
168+ if err != nil { // a frame being written when we look
169+ return false
170+ }
171+ if !image.Pt(x, y).In(img.Bounds()) {
172+ return false
173+ }
174+ pr, pg, pb, _ := img.At(x, y).RGBA()
175+ return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
176+}
177+
178+func TestHeadlessLayer(t *testing.T) {
179+ exe := build(t, "./layer")
180+ c := startHeadless(t)
181+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
182+ defer cancel()
183+ cmd := c.command(ctx, exe, "-frames", "20")
184+ out, err := cmd.CombinedOutput()
185+ must(t, err, string(out))
186+
187+ // The button is 200x40 logical pixels at (10, 40) of a surface placed at
188+ // the output's top-left corner, filled with #2060c0. Sample near its
189+ // bottom-right corner, away from the label. The program may already have
190+ // exited, so look through every screenshot taken while it was mapped.
191+ deadline := time.Now().Add(10 * time.Second)
192+ for {
193+ shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
194+ for _, p := range shots {
195+ if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
196+ t.Logf("button colour found in %s", filepath.Base(p))
197+ return
198+ }
199+ }
200+ if time.Now().After(deadline) {
201+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))
202+ }
203+ time.Sleep(50 * time.Millisecond)
204+ }
205+}
206+
207+func TestHeadlessLock(t *testing.T) {
208+ exe := build(t, "./lock")
209+ c := startHeadless(t)
210+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
211+ defer cancel()
212+ cmd := c.command(ctx, exe)
213+ stdout, err := cmd.StdoutPipe()
214+ must(t, err)
215+ var stderr syncBuffer
216+ cmd.Stderr = &stderr
217+ must(t, cmd.Start())
218+ // Wait runs once: here, after the program is done, or from the cleanup if
219+ // the test fails first (the context kill ends the process).
220+ var waitOnce sync.Once
221+ var waitErr error
222+ wait := func() error {
223+ waitOnce.Do(func() { waitErr = cmd.Wait() })
224+ return waitErr
225+ }
226+ t.Cleanup(func() { cancel(); _ = wait() })
227+ var all syncBuffer // everything the program printed on stdout
228+ lines := make(chan string, 8)
229+ go func() {
230+ sc := bufio.NewScanner(stdout)
231+ for sc.Scan() {
232+ _, _ = all.Write(append(sc.Bytes(), '\n'))
233+ lines <- sc.Text()
234+ }
235+ close(lines)
236+ }()
237+ expect := func(want string) {
238+ t.Helper()
239+ select {
240+ case l := <-lines:
241+ if l != want {
242+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
243+ }
244+ case <-ctx.Done():
245+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
246+ }
247+ }
248+ expect("locked")
249+ expect("focused") // the keyboard reached a lock surface: typing is safe
250+ // Only the nested compositor receives this input; it types into the
251+ // password field (shown masked) and presses Enter.
252+ _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
253+ must(t, err)
254+ expect("unlocked")
255+ for range lines { // drain until the program closes stdout
256+ }
257+ must(t, wait(), stderr.String())
258+ // The typed text must not leak to the program's output.
259+ if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") {
260+ t.Fatal("the typed text appears in the program output")
261+ }
262+}
@@ -0,0 +1,262 @@
1+package examples_test
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "image"
8+ "image/png"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "slices"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+)
20+
21+// The tests run the example programs against a NeferWL compositor started
22+// headless in an isolated runtime directory, and are skipped unless
23+// NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
24+// that nested compositor: the child environment is stripped of the caller's
25+// session variables, and the socket is checked to be absolute (an absolute
26+// WAYLAND_DISPLAY is how the example finds it). Never point these tests at a
27+// real session.
28+
29+var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
30+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
31+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
32+
33+func cleanEnv() []string {
34+ var out []string
35+next:
36+ for _, e := range os.Environ() {
37+ for _, p := range envDrop {
38+ if strings.HasPrefix(e, p) {
39+ continue next
40+ }
41+ }
42+ out = append(out, e)
43+ }
44+ return out
45+}
46+
47+func must(t *testing.T, err error, what ...any) {
48+ t.Helper()
49+ if err != nil {
50+ t.Fatalf("%v: %v", fmt.Sprint(what...), err)
51+ }
52+}
53+
54+// syncBuffer is a bytes buffer safe for the copying goroutine of exec and the
55+// test goroutine.
56+type syncBuffer struct {
57+ mu sync.Mutex
58+ b strings.Builder
59+}
60+
61+func (s *syncBuffer) Write(p []byte) (int, error) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ return s.b.Write(p)
65+}
66+
67+func (s *syncBuffer) String() string {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ return s.b.String()
71+}
72+
73+type compositor struct {
74+ socket, shots string
75+ env []string
76+ input io.WriteCloser // NeferWL input script (stdin)
77+}
78+
79+// startHeadless runs NeferWL and returns once its socket exists.
80+func startHeadless(t *testing.T) *compositor {
81+ t.Helper()
82+ bin := os.Getenv("NEFERCLIENT_HEADLESS")
83+ if bin == "" {
84+ t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary")
85+ }
86+ bin, err := filepath.Abs(bin)
87+ must(t, err)
88+ root := t.TempDir()
89+ dirs := map[string]string{}
90+ for _, name := range []string{"run", "config", "data", "state", "shots"} {
91+ dirs[name] = filepath.Join(root, name)
92+ must(t, os.Mkdir(dirs[name], 0o700))
93+ }
94+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
95+ must(t, err)
96+ t.Cleanup(func() { _ = logf.Close() })
97+ env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
98+ "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
99+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480",
100+ "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-")
101+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
102+ cmd.Env = env
103+ cmd.Stdout, cmd.Stderr = logf, logf
104+ stdin, err := cmd.StdinPipe()
105+ must(t, err)
106+ must(t, cmd.Start())
107+ exited := make(chan struct{})
108+ go func() { _ = cmd.Wait(); close(exited) }()
109+ t.Cleanup(func() {
110+ _ = stdin.Close()
111+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
112+ <-exited
113+ })
114+ deadline := time.Now().Add(15 * time.Second)
115+ for {
116+ paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
117+ for _, p := range paths {
118+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
119+ if !filepath.IsAbs(p) {
120+ t.Fatalf("socket %q is not absolute", p)
121+ }
122+ return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
123+ }
124+ }
125+ select {
126+ case <-exited:
127+ b, _ := os.ReadFile(logf.Name())
128+ t.Fatalf("neferwl exited before serving:\n%s", b)
129+ case <-time.After(5 * time.Millisecond):
130+ }
131+ if time.Now().After(deadline) {
132+ b, _ := os.ReadFile(logf.Name())
133+ t.Fatalf("headless socket timeout:\n%s", b)
134+ }
135+ }
136+}
137+
138+// build compiles one example into a temporary directory.
139+func build(t *testing.T, pkg string) string {
140+ t.Helper()
141+ exe := filepath.Join(t.TempDir(), filepath.Base(pkg))
142+ out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput()
143+ must(t, err, string(out))
144+ return exe
145+}
146+
147+// command prepares an example to run against c, and only against c.
148+func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
149+ cmd := exec.CommandContext(ctx, exe, args...)
150+ // slices.Concat copies: c.env is never aliased or modified.
151+ cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket})
152+ return cmd
153+}
154+
155+func near(a, b uint32) bool {
156+ d := int(a>>8) - int(b>>8)
157+ return d >= -2 && d <= 2
158+}
159+
160+// hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per
161+// channel, at (x, y).
162+func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
163+ t.Helper()
164+ f, err := os.Open(path)
165+ must(t, err)
166+ defer f.Close()
167+ img, err := png.Decode(f)
168+ if err != nil { // a frame being written when we look
169+ return false
170+ }
171+ if !image.Pt(x, y).In(img.Bounds()) {
172+ return false
173+ }
174+ pr, pg, pb, _ := img.At(x, y).RGBA()
175+ return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
176+}
177+
178+func TestHeadlessLayer(t *testing.T) {
179+ exe := build(t, "./layer")
180+ c := startHeadless(t)
181+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
182+ defer cancel()
183+ cmd := c.command(ctx, exe, "-frames", "20")
184+ out, err := cmd.CombinedOutput()
185+ must(t, err, string(out))
186+
187+ // The button is 200x40 logical pixels at (10, 40) of a surface placed at
188+ // the output's top-left corner, filled with #2060c0. Sample near its
189+ // bottom-right corner, away from the label. The program may already have
190+ // exited, so look through every screenshot taken while it was mapped.
191+ deadline := time.Now().Add(10 * time.Second)
192+ for {
193+ shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
194+ for _, p := range shots {
195+ if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
196+ t.Logf("button colour found in %s", filepath.Base(p))
197+ return
198+ }
199+ }
200+ if time.Now().After(deadline) {
201+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))
202+ }
203+ time.Sleep(50 * time.Millisecond)
204+ }
205+}
206+
207+func TestHeadlessLock(t *testing.T) {
208+ exe := build(t, "./lock")
209+ c := startHeadless(t)
210+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
211+ defer cancel()
212+ cmd := c.command(ctx, exe)
213+ stdout, err := cmd.StdoutPipe()
214+ must(t, err)
215+ var stderr syncBuffer
216+ cmd.Stderr = &stderr
217+ must(t, cmd.Start())
218+ // Wait runs once: here, after the program is done, or from the cleanup if
219+ // the test fails first (the context kill ends the process).
220+ var waitOnce sync.Once
221+ var waitErr error
222+ wait := func() error {
223+ waitOnce.Do(func() { waitErr = cmd.Wait() })
224+ return waitErr
225+ }
226+ t.Cleanup(func() { cancel(); _ = wait() })
227+ var all syncBuffer // everything the program printed on stdout
228+ lines := make(chan string, 8)
229+ go func() {
230+ sc := bufio.NewScanner(stdout)
231+ for sc.Scan() {
232+ _, _ = all.Write(append(sc.Bytes(), '\n'))
233+ lines <- sc.Text()
234+ }
235+ close(lines)
236+ }()
237+ expect := func(want string) {
238+ t.Helper()
239+ select {
240+ case l := <-lines:
241+ if l != want {
242+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
243+ }
244+ case <-ctx.Done():
245+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
246+ }
247+ }
248+ expect("locked")
249+ expect("focused") // the keyboard reached a lock surface: typing is safe
250+ // Only the nested compositor receives this input; it types into the
251+ // password field (shown masked) and presses Enter.
252+ _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
253+ must(t, err)
254+ expect("unlocked")
255+ for range lines { // drain until the program closes stdout
256+ }
257+ must(t, wait(), stderr.String())
258+ // The typed text must not leak to the program's output.
259+ if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") {
260+ t.Fatal("the typed text appears in the program output")
261+ }
262+}
A examples/layer/main.go +373 -0
@@ -0,0 +1,373 @@
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+// With -frames N the program redraws continuously and exits after N frames
11+// (used by the headless test); without it, it runs until the surface is closed
12+// or it is interrupted.
13+package main
14+
15+import (
16+ "context"
17+ "errors"
18+ "flag"
19+ "fmt"
20+ "os"
21+ "os/signal"
22+ "syscall"
23+ "time"
24+
25+ "github.com/bnema/neferclient"
26+ "github.com/bnema/nefergui"
27+)
28+
29+// Layout of the surface, in logical pixels. The headless test samples a pixel
30+// inside the button at the same place.
31+const (
32+ surfaceW, surfaceH = 240, 90
33+ buttonColor = "#2060c0"
34+
35+ // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI
36+ // use the same bit order for them, so the plain bits can be copied.
37+ guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper
38+)
39+
40+type model struct{ count int }
41+
42+// view builds one frame from the model. Button.Activated is true during the
43+// frame built after a click.
44+func view(f *nefergui.Frame, m *model) {
45+ root := f.Root().Column(nefergui.Inline("padding:10px;gap:10px;color:#ffffff"))
46+ root.TextInt("Count: ", int64(m.count), nefergui.Inline("height:20px"))
47+ if root.Button("Add one", nefergui.Key("add"),
48+ nefergui.Inline("width:200px;height:40px;background:"+buttonColor)).Activated() {
49+ m.count++
50+ }
51+}
52+
53+type app struct {
54+ neferclient.NopHandler // methods this program does not need
55+
56+ conn *neferclient.Conn
57+ surf *neferclient.Surface
58+ seat *neferclient.Seat
59+
60+ r *nefergui.Renderer
61+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
62+ out nefergui.Output
63+ model model
64+ damage []neferclient.Rect
65+
66+ configured bool
67+ canPresent bool // configured, and the last frame callback fired
68+ haveAcquire bool
69+ cursor neferclient.CursorShape
70+
71+ frames, shown int // -frames limit and frames acknowledged so far
72+ done bool
73+ err error
74+}
75+
76+func main() {
77+ frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
78+ flag.Parse()
79+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
80+ defer stop()
81+ if err := run(ctx, *frames); err != nil {
82+ fmt.Fprintln(os.Stderr, "layer:", err)
83+ os.Exit(1)
84+ }
85+}
86+
87+func run(ctx context.Context, frames int) (err error) {
88+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
89+ if err != nil {
90+ return fmt.Errorf("connect: %w", err)
91+ }
92+ a := &app{conn: conn, frames: frames, cursor: neferclient.CursorDefault}
93+ // Shutdown order: close the connection first, which drops its descriptor
94+ // watches and destroys every Wayland object, then free the renderer and
95+ // its GPU resources (including the release eventfds).
96+ defer func() {
97+ err = errors.Join(err, conn.Close())
98+ if a.r != nil {
99+ err = errors.Join(err, a.r.Close())
100+ }
101+ }()
102+ a.surf, err = conn.NewLayerSurface(neferclient.LayerConfig{
103+ Level: neferclient.LayerTop,
104+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft,
105+ Width: surfaceW,
106+ Height: surfaceH,
107+ Keyboard: neferclient.KeyboardOnDemand,
108+ })
109+ if err != nil {
110+ return fmt.Errorf("layer surface: %w", err)
111+ }
112+ a.seat = conn.Seat()
113+
114+ var tick <-chan time.Time
115+ for !a.done {
116+ select {
117+ case <-ctx.Done():
118+ return nil // interrupt or SIGTERM: a clean exit
119+ case <-conn.Wake():
120+ if err = conn.Dispatch(a); err != nil {
121+ return fmt.Errorf("dispatch: %w", err)
122+ }
123+ case <-a.rwake: // another goroutine asked for a frame
124+ case <-tick: // a built frame was waiting for the GPU
125+ }
126+ if a.err != nil {
127+ return a.err
128+ }
129+ if err = a.draw(); err != nil {
130+ return err
131+ }
132+ tick = nil
133+ if a.r != nil && a.r.Pending() {
134+ tick = time.After(2 * time.Millisecond)
135+ }
136+ }
137+ return nil
138+}
139+
140+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. The renderer opens
141+// the compositor's main device and picks a format and modifier from the list.
142+//
143+// Only the first complete feedback is used. A later one (the compositor
144+// changing its preferred device or formats) is ignored: this demo assumes a
145+// single GPU, and rebuilding the renderer is application policy that would
146+// obscure the glue. A real application would create a new Renderer and
147+// re-import its buffers here.
148+func (a *app) FeedbackDone(neferclient.SurfaceID) { a.setup() }
149+
150+func (a *app) Configure(neferclient.SurfaceID, int32, int32) {
151+ if !a.configured { // later configures must not lift the frame-callback gate
152+ a.canPresent = true
153+ }
154+ a.configured = true
155+ a.resize()
156+ a.setup()
157+}
158+
159+func (a *app) Scale(neferclient.SurfaceID, float64) { a.resize() }
160+
161+func (a *app) setup() {
162+ fb := a.surf.Feedback()
163+ if a.r != nil || !a.configured || fb == nil {
164+ return
165+ }
166+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
167+ for i, f := range fb.Formats {
168+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
169+ }
170+ r, err := nefergui.NewRenderer(cfg)
171+ if err != nil {
172+ a.fail(fmt.Errorf("renderer: %w", err))
173+ return
174+ }
175+ a.r, a.rwake = r, r.Wake()
176+ a.resize()
177+}
178+
179+// resize forwards the logical size and scale to the renderer.
180+func (a *app) resize() {
181+ if a.r == nil {
182+ return
183+ }
184+ w, h, scale := a.surf.Size()
185+ a.r.Resize(int(w), int(h), scale)
186+}
187+
188+// Frame: the compositor showed the last commit; the next Present is allowed.
189+func (a *app) Frame(neferclient.SurfaceID) {
190+ a.canPresent = true
191+ a.shown++
192+ if a.frames > 0 {
193+ if a.shown >= a.frames {
194+ a.done = true
195+ return
196+ }
197+ a.r.Invalidate()
198+ }
199+}
200+
201+func (a *app) Closed(neferclient.SurfaceID) { a.done = true }
202+
203+func (a *app) Error(err error) { a.fail(err) }
204+
205+func (a *app) fail(err error) {
206+ if a.err == nil {
207+ a.err = err
208+ }
209+}
210+
211+// FDReady: the release eventfd of a buffer is readable (watched with
212+// Conn.WatchFD in present, id is the buffer): hand the buffer back.
213+func (a *app) FDReady(id uint64) {
214+ if err := a.r.Released(id); err != nil {
215+ a.fail(fmt.Errorf("release buffer %d: %w", id, err))
216+ }
217+}
218+
219+// Pointer and Key: neferclient events → nefergui.Input, field by field.
220+func (a *app) Pointer(ev *neferclient.PointerEvent) {
221+ if a.r == nil {
222+ return
223+ }
224+ in := nefergui.Input{X: ev.X, Y: ev.Y}
225+ switch ev.Kind {
226+ case neferclient.PointerEnter:
227+ in.Kind = nefergui.InputPointerMotion
228+ // The cursor shape is only valid after the enter: set the shape of
229+ // the last frame again.
230+ if err := a.seat.SetCursor(a.cursor); err != nil {
231+ a.fail(fmt.Errorf("cursor: %w", err))
232+ }
233+ case neferclient.PointerMotion:
234+ in.Kind = nefergui.InputPointerMotion
235+ case neferclient.PointerLeave:
236+ in.Kind = nefergui.InputPointerLeave
237+ case neferclient.PointerButton:
238+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
239+ if ev.Pressed {
240+ in.Kind = nefergui.InputPointerPress
241+ }
242+ case neferclient.PointerAxis:
243+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
244+ default:
245+ return
246+ }
247+ a.r.Input(&in)
248+}
249+
250+func (a *app) Key(ev *neferclient.KeyEvent) {
251+ if a.r == nil {
252+ return
253+ }
254+ a.r.Input(&nefergui.Input{
255+ Kind: nefergui.InputKey,
256+ Keysym: ev.Keysym,
257+ Text: ev.Text, // valid only during the call, as for Input
258+ Pressed: ev.Pressed,
259+ Repeat: ev.Repeat,
260+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
261+ })
262+}
263+
264+func (a *app) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
265+ if a.r == nil {
266+ return
267+ }
268+ kind := nefergui.InputFocusOut
269+ if focused {
270+ kind = nefergui.InputFocusIn
271+ }
272+ a.r.Input(&nefergui.Input{Kind: kind})
273+}
274+
275+// draw renders a frame when something changed and presents it.
276+func (a *app) draw() error {
277+ if a.r == nil || !a.canPresent {
278+ return nil
279+ }
280+ ok, err := a.r.Render(&a.out, &a.model, view)
281+ if err != nil {
282+ return fmt.Errorf("render: %w", err)
283+ }
284+ if !ok {
285+ return nil
286+ }
287+ if err = a.present(); err != nil {
288+ return err
289+ }
290+ return a.setCursor()
291+}
292+
293+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
294+func (a *app) present() error {
295+ out := &a.out
296+ // A resize retired buffers: stop watching and destroy them first.
297+ for _, rt := range out.Retired {
298+ if err := a.conn.UnwatchFD(rt.ReleaseFD); err != nil {
299+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
300+ }
301+ if err := a.surf.DestroyBuffer(rt.Buffer); err != nil {
302+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
303+ }
304+ // The release timeline of a buffer is imported under the buffer's id.
305+ if err := a.surf.DestroyTimeline(rt.Buffer); err != nil {
306+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
307+ }
308+ }
309+ if out.NewBuffer {
310+ buf := neferclient.Buffer{
311+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
312+ PlaneCount: out.PlaneCount,
313+ }
314+ for i, p := range out.Planes {
315+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
316+ }
317+ if err := a.surf.ImportBuffer(out.Buffer, &buf); err != nil {
318+ return fmt.Errorf("import buffer: %w", err)
319+ }
320+ // The release eventfd is stable per buffer: watch it once; FDReady
321+ // calls Renderer.Released.
322+ if err := a.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
323+ return fmt.Errorf("watch release fd: %w", err)
324+ }
325+ }
326+ if out.NewTimelines {
327+ if !a.haveAcquire { // the acquire timeline is shared by every buffer
328+ if err := a.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
329+ return fmt.Errorf("import acquire timeline: %w", err)
330+ }
331+ a.haveAcquire = true
332+ }
333+ if err := a.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
334+ return fmt.Errorf("import release timeline: %w", err)
335+ }
336+ }
337+ a.damage = a.damage[:0]
338+ for _, d := range out.Damage {
339+ a.damage = append(a.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
340+ }
341+ err := a.surf.Present(&neferclient.Present{
342+ Buffer: out.Buffer,
343+ AcquireTimeline: out.Acquire.ID,
344+ ReleaseTimeline: out.Release.ID,
345+ AcquirePoint: out.AcquirePoint,
346+ ReleasePoint: out.ReleasePoint,
347+ Damage: a.damage,
348+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
349+ })
350+ if err != nil {
351+ return fmt.Errorf("present: %w", err)
352+ }
353+ a.canPresent = false // until Frame
354+ return nil
355+}
356+
357+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
358+func (a *app) setCursor() error {
359+ switch a.out.Cursor {
360+ case nefergui.CursorPointer:
361+ a.cursor = neferclient.CursorPointer
362+ case nefergui.CursorText:
363+ a.cursor = neferclient.CursorText
364+ case nefergui.CursorNotAllowed:
365+ a.cursor = neferclient.CursorNotAllowed
366+ default:
367+ a.cursor = neferclient.CursorDefault
368+ }
369+ if err := a.seat.SetCursor(a.cursor); err != nil {
370+ return fmt.Errorf("cursor: %w", err)
371+ }
372+ return nil
373+}
@@ -0,0 +1,373 @@
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+// With -frames N the program redraws continuously and exits after N frames
11+// (used by the headless test); without it, it runs until the surface is closed
12+// or it is interrupted.
13+package main
14+
15+import (
16+ "context"
17+ "errors"
18+ "flag"
19+ "fmt"
20+ "os"
21+ "os/signal"
22+ "syscall"
23+ "time"
24+
25+ "github.com/bnema/neferclient"
26+ "github.com/bnema/nefergui"
27+)
28+
29+// Layout of the surface, in logical pixels. The headless test samples a pixel
30+// inside the button at the same place.
31+const (
32+ surfaceW, surfaceH = 240, 90
33+ buttonColor = "#2060c0"
34+
35+ // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI
36+ // use the same bit order for them, so the plain bits can be copied.
37+ guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper
38+)
39+
40+type model struct{ count int }
41+
42+// view builds one frame from the model. Button.Activated is true during the
43+// frame built after a click.
44+func view(f *nefergui.Frame, m *model) {
45+ root := f.Root().Column(nefergui.Inline("padding:10px;gap:10px;color:#ffffff"))
46+ root.TextInt("Count: ", int64(m.count), nefergui.Inline("height:20px"))
47+ if root.Button("Add one", nefergui.Key("add"),
48+ nefergui.Inline("width:200px;height:40px;background:"+buttonColor)).Activated() {
49+ m.count++
50+ }
51+}
52+
53+type app struct {
54+ neferclient.NopHandler // methods this program does not need
55+
56+ conn *neferclient.Conn
57+ surf *neferclient.Surface
58+ seat *neferclient.Seat
59+
60+ r *nefergui.Renderer
61+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
62+ out nefergui.Output
63+ model model
64+ damage []neferclient.Rect
65+
66+ configured bool
67+ canPresent bool // configured, and the last frame callback fired
68+ haveAcquire bool
69+ cursor neferclient.CursorShape
70+
71+ frames, shown int // -frames limit and frames acknowledged so far
72+ done bool
73+ err error
74+}
75+
76+func main() {
77+ frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
78+ flag.Parse()
79+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
80+ defer stop()
81+ if err := run(ctx, *frames); err != nil {
82+ fmt.Fprintln(os.Stderr, "layer:", err)
83+ os.Exit(1)
84+ }
85+}
86+
87+func run(ctx context.Context, frames int) (err error) {
88+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
89+ if err != nil {
90+ return fmt.Errorf("connect: %w", err)
91+ }
92+ a := &app{conn: conn, frames: frames, cursor: neferclient.CursorDefault}
93+ // Shutdown order: close the connection first, which drops its descriptor
94+ // watches and destroys every Wayland object, then free the renderer and
95+ // its GPU resources (including the release eventfds).
96+ defer func() {
97+ err = errors.Join(err, conn.Close())
98+ if a.r != nil {
99+ err = errors.Join(err, a.r.Close())
100+ }
101+ }()
102+ a.surf, err = conn.NewLayerSurface(neferclient.LayerConfig{
103+ Level: neferclient.LayerTop,
104+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft,
105+ Width: surfaceW,
106+ Height: surfaceH,
107+ Keyboard: neferclient.KeyboardOnDemand,
108+ })
109+ if err != nil {
110+ return fmt.Errorf("layer surface: %w", err)
111+ }
112+ a.seat = conn.Seat()
113+
114+ var tick <-chan time.Time
115+ for !a.done {
116+ select {
117+ case <-ctx.Done():
118+ return nil // interrupt or SIGTERM: a clean exit
119+ case <-conn.Wake():
120+ if err = conn.Dispatch(a); err != nil {
121+ return fmt.Errorf("dispatch: %w", err)
122+ }
123+ case <-a.rwake: // another goroutine asked for a frame
124+ case <-tick: // a built frame was waiting for the GPU
125+ }
126+ if a.err != nil {
127+ return a.err
128+ }
129+ if err = a.draw(); err != nil {
130+ return err
131+ }
132+ tick = nil
133+ if a.r != nil && a.r.Pending() {
134+ tick = time.After(2 * time.Millisecond)
135+ }
136+ }
137+ return nil
138+}
139+
140+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. The renderer opens
141+// the compositor's main device and picks a format and modifier from the list.
142+//
143+// Only the first complete feedback is used. A later one (the compositor
144+// changing its preferred device or formats) is ignored: this demo assumes a
145+// single GPU, and rebuilding the renderer is application policy that would
146+// obscure the glue. A real application would create a new Renderer and
147+// re-import its buffers here.
148+func (a *app) FeedbackDone(neferclient.SurfaceID) { a.setup() }
149+
150+func (a *app) Configure(neferclient.SurfaceID, int32, int32) {
151+ if !a.configured { // later configures must not lift the frame-callback gate
152+ a.canPresent = true
153+ }
154+ a.configured = true
155+ a.resize()
156+ a.setup()
157+}
158+
159+func (a *app) Scale(neferclient.SurfaceID, float64) { a.resize() }
160+
161+func (a *app) setup() {
162+ fb := a.surf.Feedback()
163+ if a.r != nil || !a.configured || fb == nil {
164+ return
165+ }
166+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
167+ for i, f := range fb.Formats {
168+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
169+ }
170+ r, err := nefergui.NewRenderer(cfg)
171+ if err != nil {
172+ a.fail(fmt.Errorf("renderer: %w", err))
173+ return
174+ }
175+ a.r, a.rwake = r, r.Wake()
176+ a.resize()
177+}
178+
179+// resize forwards the logical size and scale to the renderer.
180+func (a *app) resize() {
181+ if a.r == nil {
182+ return
183+ }
184+ w, h, scale := a.surf.Size()
185+ a.r.Resize(int(w), int(h), scale)
186+}
187+
188+// Frame: the compositor showed the last commit; the next Present is allowed.
189+func (a *app) Frame(neferclient.SurfaceID) {
190+ a.canPresent = true
191+ a.shown++
192+ if a.frames > 0 {
193+ if a.shown >= a.frames {
194+ a.done = true
195+ return
196+ }
197+ a.r.Invalidate()
198+ }
199+}
200+
201+func (a *app) Closed(neferclient.SurfaceID) { a.done = true }
202+
203+func (a *app) Error(err error) { a.fail(err) }
204+
205+func (a *app) fail(err error) {
206+ if a.err == nil {
207+ a.err = err
208+ }
209+}
210+
211+// FDReady: the release eventfd of a buffer is readable (watched with
212+// Conn.WatchFD in present, id is the buffer): hand the buffer back.
213+func (a *app) FDReady(id uint64) {
214+ if err := a.r.Released(id); err != nil {
215+ a.fail(fmt.Errorf("release buffer %d: %w", id, err))
216+ }
217+}
218+
219+// Pointer and Key: neferclient events → nefergui.Input, field by field.
220+func (a *app) Pointer(ev *neferclient.PointerEvent) {
221+ if a.r == nil {
222+ return
223+ }
224+ in := nefergui.Input{X: ev.X, Y: ev.Y}
225+ switch ev.Kind {
226+ case neferclient.PointerEnter:
227+ in.Kind = nefergui.InputPointerMotion
228+ // The cursor shape is only valid after the enter: set the shape of
229+ // the last frame again.
230+ if err := a.seat.SetCursor(a.cursor); err != nil {
231+ a.fail(fmt.Errorf("cursor: %w", err))
232+ }
233+ case neferclient.PointerMotion:
234+ in.Kind = nefergui.InputPointerMotion
235+ case neferclient.PointerLeave:
236+ in.Kind = nefergui.InputPointerLeave
237+ case neferclient.PointerButton:
238+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
239+ if ev.Pressed {
240+ in.Kind = nefergui.InputPointerPress
241+ }
242+ case neferclient.PointerAxis:
243+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
244+ default:
245+ return
246+ }
247+ a.r.Input(&in)
248+}
249+
250+func (a *app) Key(ev *neferclient.KeyEvent) {
251+ if a.r == nil {
252+ return
253+ }
254+ a.r.Input(&nefergui.Input{
255+ Kind: nefergui.InputKey,
256+ Keysym: ev.Keysym,
257+ Text: ev.Text, // valid only during the call, as for Input
258+ Pressed: ev.Pressed,
259+ Repeat: ev.Repeat,
260+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
261+ })
262+}
263+
264+func (a *app) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
265+ if a.r == nil {
266+ return
267+ }
268+ kind := nefergui.InputFocusOut
269+ if focused {
270+ kind = nefergui.InputFocusIn
271+ }
272+ a.r.Input(&nefergui.Input{Kind: kind})
273+}
274+
275+// draw renders a frame when something changed and presents it.
276+func (a *app) draw() error {
277+ if a.r == nil || !a.canPresent {
278+ return nil
279+ }
280+ ok, err := a.r.Render(&a.out, &a.model, view)
281+ if err != nil {
282+ return fmt.Errorf("render: %w", err)
283+ }
284+ if !ok {
285+ return nil
286+ }
287+ if err = a.present(); err != nil {
288+ return err
289+ }
290+ return a.setCursor()
291+}
292+
293+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
294+func (a *app) present() error {
295+ out := &a.out
296+ // A resize retired buffers: stop watching and destroy them first.
297+ for _, rt := range out.Retired {
298+ if err := a.conn.UnwatchFD(rt.ReleaseFD); err != nil {
299+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
300+ }
301+ if err := a.surf.DestroyBuffer(rt.Buffer); err != nil {
302+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
303+ }
304+ // The release timeline of a buffer is imported under the buffer's id.
305+ if err := a.surf.DestroyTimeline(rt.Buffer); err != nil {
306+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
307+ }
308+ }
309+ if out.NewBuffer {
310+ buf := neferclient.Buffer{
311+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
312+ PlaneCount: out.PlaneCount,
313+ }
314+ for i, p := range out.Planes {
315+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
316+ }
317+ if err := a.surf.ImportBuffer(out.Buffer, &buf); err != nil {
318+ return fmt.Errorf("import buffer: %w", err)
319+ }
320+ // The release eventfd is stable per buffer: watch it once; FDReady
321+ // calls Renderer.Released.
322+ if err := a.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
323+ return fmt.Errorf("watch release fd: %w", err)
324+ }
325+ }
326+ if out.NewTimelines {
327+ if !a.haveAcquire { // the acquire timeline is shared by every buffer
328+ if err := a.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
329+ return fmt.Errorf("import acquire timeline: %w", err)
330+ }
331+ a.haveAcquire = true
332+ }
333+ if err := a.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
334+ return fmt.Errorf("import release timeline: %w", err)
335+ }
336+ }
337+ a.damage = a.damage[:0]
338+ for _, d := range out.Damage {
339+ a.damage = append(a.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
340+ }
341+ err := a.surf.Present(&neferclient.Present{
342+ Buffer: out.Buffer,
343+ AcquireTimeline: out.Acquire.ID,
344+ ReleaseTimeline: out.Release.ID,
345+ AcquirePoint: out.AcquirePoint,
346+ ReleasePoint: out.ReleasePoint,
347+ Damage: a.damage,
348+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
349+ })
350+ if err != nil {
351+ return fmt.Errorf("present: %w", err)
352+ }
353+ a.canPresent = false // until Frame
354+ return nil
355+}
356+
357+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
358+func (a *app) setCursor() error {
359+ switch a.out.Cursor {
360+ case nefergui.CursorPointer:
361+ a.cursor = neferclient.CursorPointer
362+ case nefergui.CursorText:
363+ a.cursor = neferclient.CursorText
364+ case nefergui.CursorNotAllowed:
365+ a.cursor = neferclient.CursorNotAllowed
366+ default:
367+ a.cursor = neferclient.CursorDefault
368+ }
369+ if err := a.seat.SetCursor(a.cursor); err != nil {
370+ return fmt.Errorf("cursor: %w", err)
371+ }
372+ return nil
373+}
A examples/lock/main.go +519 -0
@@ -0,0 +1,519 @@
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", "focused" (the keyboard reached a lock surface)
16+// and "unlocked" on standard output as progress markers; the headless test
17+// waits for them. It never prints anything typed.
18+//
19+// All logic runs on the owner goroutine, inside or right after Conn.Dispatch.
20+// The lock needs one surface per output; each gets its own Renderer.
21+package main
22+
23+import (
24+ "context"
25+ "errors"
26+ "fmt"
27+ "os"
28+ "os/signal"
29+ "syscall"
30+ "time"
31+
32+ "github.com/bnema/neferclient"
33+ "github.com/bnema/nefergui"
34+)
35+
36+const keysymReturn, keysymKPEnter = 0xff0d, 0xff8d // xkb keysyms
37+
38+// guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI use
39+// the same bit order for them, so the plain bits can be copied.
40+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper
41+
42+// model is what the view reads. The secret stays in its buffer; the view only
43+// asks for its length.
44+type model struct{ secret *neferclient.SecretBuffer }
45+
46+func view(f *nefergui.Frame, m *model) {
47+ root := f.Root().Column(nefergui.Inline("padding:40px;gap:12px;color:#ffffff;background:#101820"))
48+ root.Heading("Session locked (demo: no authentication)")
49+ root.Text("Type anything, then press Enter to unlock.")
50+ field := root.Row(nefergui.Inline("width:320px;height:36px;padding:6px;background:#203040;border:1px solid #6080a0"))
51+ field.Masked(m.secret.Len()) // bullets only: the view never sees the text
52+}
53+
54+// screen is the lock surface of one output and its renderer.
55+type screen struct {
56+ output uint32 // wl_output registry name (Output.Global)
57+ conn *neferclient.Conn
58+ model *model
59+ surf *neferclient.Surface
60+ r *nefergui.Renderer
61+ out nefergui.Output
62+ damage []neferclient.Rect
63+
64+ configured, canPresent, haveAcquire bool
65+ live map[uint64]int // buffer → release eventfd still watched
66+}
67+
68+type app struct {
69+ neferclient.NopHandler
70+
71+ conn *neferclient.Conn
72+ lock *neferclient.Lock
73+ seat *neferclient.Seat
74+ secret *neferclient.SecretBuffer
75+ model model
76+ screens map[neferclient.SurfaceID]*screen
77+
78+ cursor neferclient.CursorShape
79+ hover neferclient.SurfaceID // surface under the pointer, 0 outside
80+ locked, unlocked bool
81+ err error
82+}
83+
84+func main() {
85+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
86+ defer stop()
87+ if err := run(ctx); err != nil {
88+ fmt.Fprintln(os.Stderr, "lock:", err)
89+ os.Exit(1)
90+ }
91+}
92+
93+func run(ctx context.Context) (err error) {
94+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
95+ if err != nil {
96+ return fmt.Errorf("connect: %w", err)
97+ }
98+ a := &app{conn: conn, screens: map[neferclient.SurfaceID]*screen{}, cursor: neferclient.CursorDefault}
99+ a.secret = neferclient.NewSecretBuffer(256)
100+ a.model.secret = a.secret
101+ // Shutdown order: wipe the secret, close the connection (this drops its
102+ // descriptor watches), then free the renderers and their GPU resources,
103+ // including the release eventfds. The same order as the layer example.
104+ defer func() {
105+ a.secret.Wipe()
106+ err = errors.Join(err, conn.Close())
107+ for _, s := range a.screens {
108+ err = errors.Join(err, s.close())
109+ }
110+ }()
111+
112+ // The compositor may announce its first output just after Connect.
113+ for len(conn.Outputs()) == 0 {
114+ select {
115+ case <-conn.Wake():
116+ if err = conn.Dispatch(a); err != nil {
117+ return fmt.Errorf("dispatch: %w", err)
118+ }
119+ case <-ctx.Done():
120+ return nil // interrupt or SIGTERM before the lock: nothing to undo
121+ }
122+ }
123+ if a.lock, err = conn.Lock(); err != nil {
124+ return fmt.Errorf("lock: %w", err)
125+ }
126+ for _, o := range conn.Outputs() {
127+ if err = a.addScreen(o.Global); err != nil {
128+ return err
129+ }
130+ }
131+ // Typed text goes into the secret buffer, not into Handler.Key.
132+ a.seat = conn.Seat()
133+ a.seat.SetSecret(a.secret)
134+ defer a.seat.SetSecret(nil)
135+
136+ var tick <-chan time.Time
137+ for !a.unlocked {
138+ select {
139+ case <-ctx.Done():
140+ // Interrupt or SIGTERM: a clean exit. The session stays locked,
141+ // because only Lock.Unlock ends a lock; the compositor decides
142+ // what happens to a lock whose client is gone.
143+ return nil
144+ case <-conn.Wake():
145+ if err = conn.Dispatch(a); err != nil {
146+ return fmt.Errorf("dispatch: %w", err)
147+ }
148+ case <-tick:
149+ }
150+ if a.err != nil {
151+ return a.err
152+ }
153+ if a.unlocked {
154+ break
155+ }
156+ pending := false
157+ for _, s := range a.screens {
158+ shown, err := s.draw()
159+ if err != nil {
160+ return err
161+ }
162+ // The cursor the hovered element asked for → Seat.SetCursor, only
163+ // from the screen under the pointer.
164+ if shown && s.surf.ID() == a.hover {
165+ a.cursor = cursorShape(s.out.Cursor)
166+ if err = a.seat.SetCursor(a.cursor); err != nil {
167+ return fmt.Errorf("cursor: %w", err)
168+ }
169+ }
170+ pending = pending || (s.r != nil && s.r.Pending())
171+ }
172+ tick = nil
173+ if pending {
174+ tick = time.After(2 * time.Millisecond)
175+ }
176+ }
177+ return nil
178+}
179+
180+func (a *app) addScreen(output uint32) error {
181+ surf, err := a.lock.NewSurface(output)
182+ if err != nil {
183+ return fmt.Errorf("lock surface: %w", err)
184+ }
185+ a.screens[surf.ID()] = &screen{output: output, conn: a.conn, model: &a.model, surf: surf, live: map[uint64]int{}}
186+ return nil
187+}
188+
189+func (a *app) fail(err error) {
190+ if a.err == nil {
191+ a.err = err
192+ }
193+}
194+
195+func (a *app) Error(err error) { a.fail(err) }
196+
197+// OutputAdded: a new output must be covered too while the session is locked.
198+func (a *app) OutputAdded(out *neferclient.Output) {
199+ if a.lock == nil || a.unlocked {
200+ return
201+ }
202+ if err := a.addScreen(out.Global); err != nil {
203+ a.fail(err)
204+ }
205+}
206+
207+// OutputRemoved: stop watching the screen's release fds, free its renderer and
208+// surface, and forget it.
209+func (a *app) OutputRemoved(global uint32) {
210+ for id, s := range a.screens {
211+ if s.output != global {
212+ continue
213+ }
214+ if a.hover == id {
215+ a.hover = 0
216+ }
217+ delete(a.screens, id)
218+ if err := s.release(); err != nil {
219+ a.fail(fmt.Errorf("output %d removed: %w", global, err))
220+ }
221+ }
222+}
223+
224+func (a *app) Locked() {
225+ a.locked = true
226+ fmt.Println("locked")
227+}
228+
229+func (a *app) LockFinished() {
230+ a.fail(errors.Join(errors.New("the compositor refused or ended the lock"), a.lock.Close()))
231+}
232+
233+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig.
234+//
235+// Only the first complete feedback is used. A later one (the compositor
236+// changing its preferred device or formats) is ignored: this demo assumes a
237+// single GPU, and rebuilding the renderers is application policy that would
238+// obscure the glue. A real application would create a new Renderer here and
239+// re-import its buffers.
240+func (a *app) FeedbackDone(id neferclient.SurfaceID) {
241+ if s := a.screens[id]; s != nil {
242+ if err := s.setup(); err != nil {
243+ a.fail(err)
244+ }
245+ }
246+}
247+
248+func (a *app) Configure(id neferclient.SurfaceID, _, _ int32) {
249+ if s := a.screens[id]; s != nil {
250+ if !s.configured { // later configures must not lift the frame-callback gate
251+ s.canPresent = true
252+ }
253+ s.configured = true
254+ s.resize()
255+ if err := s.setup(); err != nil {
256+ a.fail(err)
257+ }
258+ }
259+}
260+
261+func (a *app) Scale(id neferclient.SurfaceID, _ float64) {
262+ if s := a.screens[id]; s != nil {
263+ s.resize()
264+ }
265+}
266+
267+func (a *app) Frame(id neferclient.SurfaceID) {
268+ if s := a.screens[id]; s != nil {
269+ s.canPresent = true
270+ }
271+}
272+
273+// FDReady: the release eventfd of a buffer is readable (watched in present;
274+// the id packs the surface and the buffer): hand the buffer back.
275+func (a *app) FDReady(id uint64) {
276+ s := a.screens[neferclient.SurfaceID(id>>32)]
277+ if s == nil || s.r == nil {
278+ return
279+ }
280+ if err := s.r.Released(id & 0xffffffff); err != nil {
281+ a.fail(fmt.Errorf("release buffer: %w", err))
282+ }
283+}
284+
285+// Pointer: neferclient events → nefergui.Input, field by field.
286+func (a *app) Pointer(ev *neferclient.PointerEvent) {
287+ // Track the screen under the pointer first: the cursor shape is applied
288+ // only from it.
289+ switch ev.Kind {
290+ case neferclient.PointerEnter:
291+ a.hover = ev.Surface
292+ // The shape only applies after an enter: send the last one again.
293+ if err := a.seat.SetCursor(a.cursor); err != nil {
294+ a.fail(fmt.Errorf("cursor: %w", err))
295+ }
296+ case neferclient.PointerLeave:
297+ if a.hover == ev.Surface {
298+ a.hover = 0
299+ }
300+ }
301+ s := a.screens[ev.Surface]
302+ if s == nil || s.r == nil {
303+ return
304+ }
305+ in := nefergui.Input{X: ev.X, Y: ev.Y, Kind: nefergui.InputPointerMotion}
306+ switch ev.Kind {
307+ case neferclient.PointerLeave:
308+ in.Kind = nefergui.InputPointerLeave
309+ case neferclient.PointerButton:
310+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
311+ if ev.Pressed {
312+ in.Kind = nefergui.InputPointerPress
313+ }
314+ case neferclient.PointerAxis:
315+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
316+ }
317+ s.r.Input(&in)
318+}
319+
320+// Key: Enter unlocks (DEMO: no authentication). Keys that produced text were
321+// already stored in the SecretBuffer and carry no keysym or text: they are not
322+// forwarded, so the renderer never sees the secret.
323+func (a *app) Key(ev *neferclient.KeyEvent) {
324+ if ev.Secret {
325+ return
326+ }
327+ if ev.Pressed && !ev.Repeat && (ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter) {
328+ a.unlock()
329+ return
330+ }
331+ if s := a.screens[ev.Surface]; s != nil && s.r != nil {
332+ s.r.Input(&nefergui.Input{
333+ Kind: nefergui.InputKey, Keysym: ev.Keysym, Pressed: ev.Pressed, Repeat: ev.Repeat,
334+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
335+ })
336+ }
337+}
338+
339+func (a *app) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
340+ if focused {
341+ fmt.Println("focused")
342+ }
343+ if s := a.screens[id]; s != nil && s.r != nil {
344+ kind := nefergui.InputFocusOut
345+ if focused {
346+ kind = nefergui.InputFocusIn
347+ }
348+ s.r.Input(&nefergui.Input{Kind: kind})
349+ }
350+}
351+
352+// SecretChanged: the buffer changed; only its length is shown, on every screen.
353+func (a *app) SecretChanged(int) {
354+ for _, s := range a.screens {
355+ if s.r != nil {
356+ s.r.Invalidate()
357+ }
358+ }
359+}
360+
361+// unlock ends the session lock. DEMO ONLY: nothing is verified here.
362+func (a *app) unlock() {
363+ if !a.locked || a.unlocked {
364+ return
365+ }
366+ a.secret.Wipe()
367+ if err := a.lock.Unlock(); err != nil {
368+ a.fail(fmt.Errorf("unlock: %w", err))
369+ return
370+ }
371+ a.unlocked = true
372+ fmt.Println("unlocked")
373+}
374+
375+// setup creates the renderer once the surface is configured and its dmabuf
376+// feedback is complete.
377+func (s *screen) setup() error {
378+ fb := s.surf.Feedback()
379+ if s.r != nil || !s.configured || fb == nil {
380+ return nil
381+ }
382+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
383+ for i, f := range fb.Formats {
384+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
385+ }
386+ r, err := nefergui.NewRenderer(cfg)
387+ if err != nil {
388+ return fmt.Errorf("renderer: %w", err)
389+ }
390+ s.r = r
391+ s.resize()
392+ return nil
393+}
394+
395+func (s *screen) resize() {
396+ if s.r != nil {
397+ w, h, scale := s.surf.Size()
398+ s.r.Resize(int(w), int(h), scale)
399+ }
400+}
401+
402+// draw renders when something changed and presents the frame.
403+func (s *screen) draw() (bool, error) {
404+ if s.r == nil || !s.canPresent {
405+ return false, nil
406+ }
407+ ok, err := s.r.Render(&s.out, s.model, view)
408+ if err != nil {
409+ return false, fmt.Errorf("render: %w", err)
410+ }
411+ if !ok {
412+ return false, nil
413+ }
414+ return true, s.present()
415+}
416+
417+func cursorShape(c nefergui.Cursor) neferclient.CursorShape {
418+ switch c {
419+ case nefergui.CursorPointer:
420+ return neferclient.CursorPointer
421+ case nefergui.CursorText:
422+ return neferclient.CursorText
423+ case nefergui.CursorNotAllowed:
424+ return neferclient.CursorNotAllowed
425+ }
426+ return neferclient.CursorDefault
427+}
428+
429+// watchID packs a surface and a buffer into the Conn.WatchFD id.
430+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
431+
432+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
433+func (s *screen) present() error {
434+ out := &s.out
435+ for _, rt := range out.Retired { // buffers of an older size
436+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
437+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
438+ }
439+ delete(s.live, rt.Buffer)
440+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
441+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
442+ }
443+ // The release timeline of a buffer is imported under the buffer's id.
444+ if err := s.surf.DestroyTimeline(rt.Buffer); err != nil {
445+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
446+ }
447+ }
448+ if out.NewBuffer {
449+ buf := neferclient.Buffer{
450+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
451+ PlaneCount: out.PlaneCount,
452+ }
453+ for i, p := range out.Planes {
454+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
455+ }
456+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
457+ return fmt.Errorf("import buffer: %w", err)
458+ }
459+ // The release eventfd is stable per buffer: watch it once; FDReady
460+ // calls Renderer.Released.
461+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
462+ return fmt.Errorf("watch release fd: %w", err)
463+ }
464+ s.live[out.Buffer] = out.ReleaseFD
465+ }
466+ if out.NewTimelines {
467+ if !s.haveAcquire { // the acquire timeline is shared by every buffer
468+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
469+ return fmt.Errorf("import acquire timeline: %w", err)
470+ }
471+ s.haveAcquire = true
472+ }
473+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
474+ return fmt.Errorf("import release timeline: %w", err)
475+ }
476+ }
477+ s.damage = s.damage[:0]
478+ for _, d := range out.Damage {
479+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
480+ }
481+ err := s.surf.Present(&neferclient.Present{
482+ Buffer: out.Buffer,
483+ AcquireTimeline: out.Acquire.ID,
484+ ReleaseTimeline: out.Release.ID,
485+ AcquirePoint: out.AcquirePoint,
486+ ReleasePoint: out.ReleasePoint,
487+ Damage: s.damage,
488+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
489+ })
490+ if err != nil {
491+ return fmt.Errorf("present: %w", err)
492+ }
493+ s.canPresent = false // until Handler.Frame
494+ return nil
495+}
496+
497+// release stops watching the live release eventfds, then frees the renderer and
498+// the surface (an output went away). The release eventfds belong to the
499+// renderer, so they are unwatched before it closes.
500+func (s *screen) release() error {
501+ var errs []error
502+ for buffer, fd := range s.live {
503+ if err := s.conn.UnwatchFD(fd); err != nil {
504+ errs = append(errs, fmt.Errorf("unwatch buffer %d: %w", buffer, err))
505+ }
506+ }
507+ clear(s.live)
508+ return errors.Join(append(errs, s.close())...)
509+}
510+
511+// close frees the renderer and the surface. The session lock itself is only
512+// ended by Lock.Unlock.
513+func (s *screen) close() error {
514+ var err error
515+ if s.r != nil {
516+ err = s.r.Close()
517+ }
518+ return errors.Join(err, s.surf.Close())
519+}
@@ -0,0 +1,519 @@
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", "focused" (the keyboard reached a lock surface)
16+// and "unlocked" on standard output as progress markers; the headless test
17+// waits for them. It never prints anything typed.
18+//
19+// All logic runs on the owner goroutine, inside or right after Conn.Dispatch.
20+// The lock needs one surface per output; each gets its own Renderer.
21+package main
22+
23+import (
24+ "context"
25+ "errors"
26+ "fmt"
27+ "os"
28+ "os/signal"
29+ "syscall"
30+ "time"
31+
32+ "github.com/bnema/neferclient"
33+ "github.com/bnema/nefergui"
34+)
35+
36+const keysymReturn, keysymKPEnter = 0xff0d, 0xff8d // xkb keysyms
37+
38+// guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI use
39+// the same bit order for them, so the plain bits can be copied.
40+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper
41+
42+// model is what the view reads. The secret stays in its buffer; the view only
43+// asks for its length.
44+type model struct{ secret *neferclient.SecretBuffer }
45+
46+func view(f *nefergui.Frame, m *model) {
47+ root := f.Root().Column(nefergui.Inline("padding:40px;gap:12px;color:#ffffff;background:#101820"))
48+ root.Heading("Session locked (demo: no authentication)")
49+ root.Text("Type anything, then press Enter to unlock.")
50+ field := root.Row(nefergui.Inline("width:320px;height:36px;padding:6px;background:#203040;border:1px solid #6080a0"))
51+ field.Masked(m.secret.Len()) // bullets only: the view never sees the text
52+}
53+
54+// screen is the lock surface of one output and its renderer.
55+type screen struct {
56+ output uint32 // wl_output registry name (Output.Global)
57+ conn *neferclient.Conn
58+ model *model
59+ surf *neferclient.Surface
60+ r *nefergui.Renderer
61+ out nefergui.Output
62+ damage []neferclient.Rect
63+
64+ configured, canPresent, haveAcquire bool
65+ live map[uint64]int // buffer → release eventfd still watched
66+}
67+
68+type app struct {
69+ neferclient.NopHandler
70+
71+ conn *neferclient.Conn
72+ lock *neferclient.Lock
73+ seat *neferclient.Seat
74+ secret *neferclient.SecretBuffer
75+ model model
76+ screens map[neferclient.SurfaceID]*screen
77+
78+ cursor neferclient.CursorShape
79+ hover neferclient.SurfaceID // surface under the pointer, 0 outside
80+ locked, unlocked bool
81+ err error
82+}
83+
84+func main() {
85+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
86+ defer stop()
87+ if err := run(ctx); err != nil {
88+ fmt.Fprintln(os.Stderr, "lock:", err)
89+ os.Exit(1)
90+ }
91+}
92+
93+func run(ctx context.Context) (err error) {
94+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
95+ if err != nil {
96+ return fmt.Errorf("connect: %w", err)
97+ }
98+ a := &app{conn: conn, screens: map[neferclient.SurfaceID]*screen{}, cursor: neferclient.CursorDefault}
99+ a.secret = neferclient.NewSecretBuffer(256)
100+ a.model.secret = a.secret
101+ // Shutdown order: wipe the secret, close the connection (this drops its
102+ // descriptor watches), then free the renderers and their GPU resources,
103+ // including the release eventfds. The same order as the layer example.
104+ defer func() {
105+ a.secret.Wipe()
106+ err = errors.Join(err, conn.Close())
107+ for _, s := range a.screens {
108+ err = errors.Join(err, s.close())
109+ }
110+ }()
111+
112+ // The compositor may announce its first output just after Connect.
113+ for len(conn.Outputs()) == 0 {
114+ select {
115+ case <-conn.Wake():
116+ if err = conn.Dispatch(a); err != nil {
117+ return fmt.Errorf("dispatch: %w", err)
118+ }
119+ case <-ctx.Done():
120+ return nil // interrupt or SIGTERM before the lock: nothing to undo
121+ }
122+ }
123+ if a.lock, err = conn.Lock(); err != nil {
124+ return fmt.Errorf("lock: %w", err)
125+ }
126+ for _, o := range conn.Outputs() {
127+ if err = a.addScreen(o.Global); err != nil {
128+ return err
129+ }
130+ }
131+ // Typed text goes into the secret buffer, not into Handler.Key.
132+ a.seat = conn.Seat()
133+ a.seat.SetSecret(a.secret)
134+ defer a.seat.SetSecret(nil)
135+
136+ var tick <-chan time.Time
137+ for !a.unlocked {
138+ select {
139+ case <-ctx.Done():
140+ // Interrupt or SIGTERM: a clean exit. The session stays locked,
141+ // because only Lock.Unlock ends a lock; the compositor decides
142+ // what happens to a lock whose client is gone.
143+ return nil
144+ case <-conn.Wake():
145+ if err = conn.Dispatch(a); err != nil {
146+ return fmt.Errorf("dispatch: %w", err)
147+ }
148+ case <-tick:
149+ }
150+ if a.err != nil {
151+ return a.err
152+ }
153+ if a.unlocked {
154+ break
155+ }
156+ pending := false
157+ for _, s := range a.screens {
158+ shown, err := s.draw()
159+ if err != nil {
160+ return err
161+ }
162+ // The cursor the hovered element asked for → Seat.SetCursor, only
163+ // from the screen under the pointer.
164+ if shown && s.surf.ID() == a.hover {
165+ a.cursor = cursorShape(s.out.Cursor)
166+ if err = a.seat.SetCursor(a.cursor); err != nil {
167+ return fmt.Errorf("cursor: %w", err)
168+ }
169+ }
170+ pending = pending || (s.r != nil && s.r.Pending())
171+ }
172+ tick = nil
173+ if pending {
174+ tick = time.After(2 * time.Millisecond)
175+ }
176+ }
177+ return nil
178+}
179+
180+func (a *app) addScreen(output uint32) error {
181+ surf, err := a.lock.NewSurface(output)
182+ if err != nil {
183+ return fmt.Errorf("lock surface: %w", err)
184+ }
185+ a.screens[surf.ID()] = &screen{output: output, conn: a.conn, model: &a.model, surf: surf, live: map[uint64]int{}}
186+ return nil
187+}
188+
189+func (a *app) fail(err error) {
190+ if a.err == nil {
191+ a.err = err
192+ }
193+}
194+
195+func (a *app) Error(err error) { a.fail(err) }
196+
197+// OutputAdded: a new output must be covered too while the session is locked.
198+func (a *app) OutputAdded(out *neferclient.Output) {
199+ if a.lock == nil || a.unlocked {
200+ return
201+ }
202+ if err := a.addScreen(out.Global); err != nil {
203+ a.fail(err)
204+ }
205+}
206+
207+// OutputRemoved: stop watching the screen's release fds, free its renderer and
208+// surface, and forget it.
209+func (a *app) OutputRemoved(global uint32) {
210+ for id, s := range a.screens {
211+ if s.output != global {
212+ continue
213+ }
214+ if a.hover == id {
215+ a.hover = 0
216+ }
217+ delete(a.screens, id)
218+ if err := s.release(); err != nil {
219+ a.fail(fmt.Errorf("output %d removed: %w", global, err))
220+ }
221+ }
222+}
223+
224+func (a *app) Locked() {
225+ a.locked = true
226+ fmt.Println("locked")
227+}
228+
229+func (a *app) LockFinished() {
230+ a.fail(errors.Join(errors.New("the compositor refused or ended the lock"), a.lock.Close()))
231+}
232+
233+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig.
234+//
235+// Only the first complete feedback is used. A later one (the compositor
236+// changing its preferred device or formats) is ignored: this demo assumes a
237+// single GPU, and rebuilding the renderers is application policy that would
238+// obscure the glue. A real application would create a new Renderer here and
239+// re-import its buffers.
240+func (a *app) FeedbackDone(id neferclient.SurfaceID) {
241+ if s := a.screens[id]; s != nil {
242+ if err := s.setup(); err != nil {
243+ a.fail(err)
244+ }
245+ }
246+}
247+
248+func (a *app) Configure(id neferclient.SurfaceID, _, _ int32) {
249+ if s := a.screens[id]; s != nil {
250+ if !s.configured { // later configures must not lift the frame-callback gate
251+ s.canPresent = true
252+ }
253+ s.configured = true
254+ s.resize()
255+ if err := s.setup(); err != nil {
256+ a.fail(err)
257+ }
258+ }
259+}
260+
261+func (a *app) Scale(id neferclient.SurfaceID, _ float64) {
262+ if s := a.screens[id]; s != nil {
263+ s.resize()
264+ }
265+}
266+
267+func (a *app) Frame(id neferclient.SurfaceID) {
268+ if s := a.screens[id]; s != nil {
269+ s.canPresent = true
270+ }
271+}
272+
273+// FDReady: the release eventfd of a buffer is readable (watched in present;
274+// the id packs the surface and the buffer): hand the buffer back.
275+func (a *app) FDReady(id uint64) {
276+ s := a.screens[neferclient.SurfaceID(id>>32)]
277+ if s == nil || s.r == nil {
278+ return
279+ }
280+ if err := s.r.Released(id & 0xffffffff); err != nil {
281+ a.fail(fmt.Errorf("release buffer: %w", err))
282+ }
283+}
284+
285+// Pointer: neferclient events → nefergui.Input, field by field.
286+func (a *app) Pointer(ev *neferclient.PointerEvent) {
287+ // Track the screen under the pointer first: the cursor shape is applied
288+ // only from it.
289+ switch ev.Kind {
290+ case neferclient.PointerEnter:
291+ a.hover = ev.Surface
292+ // The shape only applies after an enter: send the last one again.
293+ if err := a.seat.SetCursor(a.cursor); err != nil {
294+ a.fail(fmt.Errorf("cursor: %w", err))
295+ }
296+ case neferclient.PointerLeave:
297+ if a.hover == ev.Surface {
298+ a.hover = 0
299+ }
300+ }
301+ s := a.screens[ev.Surface]
302+ if s == nil || s.r == nil {
303+ return
304+ }
305+ in := nefergui.Input{X: ev.X, Y: ev.Y, Kind: nefergui.InputPointerMotion}
306+ switch ev.Kind {
307+ case neferclient.PointerLeave:
308+ in.Kind = nefergui.InputPointerLeave
309+ case neferclient.PointerButton:
310+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
311+ if ev.Pressed {
312+ in.Kind = nefergui.InputPointerPress
313+ }
314+ case neferclient.PointerAxis:
315+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
316+ }
317+ s.r.Input(&in)
318+}
319+
320+// Key: Enter unlocks (DEMO: no authentication). Keys that produced text were
321+// already stored in the SecretBuffer and carry no keysym or text: they are not
322+// forwarded, so the renderer never sees the secret.
323+func (a *app) Key(ev *neferclient.KeyEvent) {
324+ if ev.Secret {
325+ return
326+ }
327+ if ev.Pressed && !ev.Repeat && (ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter) {
328+ a.unlock()
329+ return
330+ }
331+ if s := a.screens[ev.Surface]; s != nil && s.r != nil {
332+ s.r.Input(&nefergui.Input{
333+ Kind: nefergui.InputKey, Keysym: ev.Keysym, Pressed: ev.Pressed, Repeat: ev.Repeat,
334+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
335+ })
336+ }
337+}
338+
339+func (a *app) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
340+ if focused {
341+ fmt.Println("focused")
342+ }
343+ if s := a.screens[id]; s != nil && s.r != nil {
344+ kind := nefergui.InputFocusOut
345+ if focused {
346+ kind = nefergui.InputFocusIn
347+ }
348+ s.r.Input(&nefergui.Input{Kind: kind})
349+ }
350+}
351+
352+// SecretChanged: the buffer changed; only its length is shown, on every screen.
353+func (a *app) SecretChanged(int) {
354+ for _, s := range a.screens {
355+ if s.r != nil {
356+ s.r.Invalidate()
357+ }
358+ }
359+}
360+
361+// unlock ends the session lock. DEMO ONLY: nothing is verified here.
362+func (a *app) unlock() {
363+ if !a.locked || a.unlocked {
364+ return
365+ }
366+ a.secret.Wipe()
367+ if err := a.lock.Unlock(); err != nil {
368+ a.fail(fmt.Errorf("unlock: %w", err))
369+ return
370+ }
371+ a.unlocked = true
372+ fmt.Println("unlocked")
373+}
374+
375+// setup creates the renderer once the surface is configured and its dmabuf
376+// feedback is complete.
377+func (s *screen) setup() error {
378+ fb := s.surf.Feedback()
379+ if s.r != nil || !s.configured || fb == nil {
380+ return nil
381+ }
382+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats))}
383+ for i, f := range fb.Formats {
384+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
385+ }
386+ r, err := nefergui.NewRenderer(cfg)
387+ if err != nil {
388+ return fmt.Errorf("renderer: %w", err)
389+ }
390+ s.r = r
391+ s.resize()
392+ return nil
393+}
394+
395+func (s *screen) resize() {
396+ if s.r != nil {
397+ w, h, scale := s.surf.Size()
398+ s.r.Resize(int(w), int(h), scale)
399+ }
400+}
401+
402+// draw renders when something changed and presents the frame.
403+func (s *screen) draw() (bool, error) {
404+ if s.r == nil || !s.canPresent {
405+ return false, nil
406+ }
407+ ok, err := s.r.Render(&s.out, s.model, view)
408+ if err != nil {
409+ return false, fmt.Errorf("render: %w", err)
410+ }
411+ if !ok {
412+ return false, nil
413+ }
414+ return true, s.present()
415+}
416+
417+func cursorShape(c nefergui.Cursor) neferclient.CursorShape {
418+ switch c {
419+ case nefergui.CursorPointer:
420+ return neferclient.CursorPointer
421+ case nefergui.CursorText:
422+ return neferclient.CursorText
423+ case nefergui.CursorNotAllowed:
424+ return neferclient.CursorNotAllowed
425+ }
426+ return neferclient.CursorDefault
427+}
428+
429+// watchID packs a surface and a buffer into the Conn.WatchFD id.
430+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
431+
432+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
433+func (s *screen) present() error {
434+ out := &s.out
435+ for _, rt := range out.Retired { // buffers of an older size
436+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
437+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
438+ }
439+ delete(s.live, rt.Buffer)
440+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
441+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
442+ }
443+ // The release timeline of a buffer is imported under the buffer's id.
444+ if err := s.surf.DestroyTimeline(rt.Buffer); err != nil {
445+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
446+ }
447+ }
448+ if out.NewBuffer {
449+ buf := neferclient.Buffer{
450+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
451+ PlaneCount: out.PlaneCount,
452+ }
453+ for i, p := range out.Planes {
454+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
455+ }
456+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
457+ return fmt.Errorf("import buffer: %w", err)
458+ }
459+ // The release eventfd is stable per buffer: watch it once; FDReady
460+ // calls Renderer.Released.
461+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
462+ return fmt.Errorf("watch release fd: %w", err)
463+ }
464+ s.live[out.Buffer] = out.ReleaseFD
465+ }
466+ if out.NewTimelines {
467+ if !s.haveAcquire { // the acquire timeline is shared by every buffer
468+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
469+ return fmt.Errorf("import acquire timeline: %w", err)
470+ }
471+ s.haveAcquire = true
472+ }
473+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
474+ return fmt.Errorf("import release timeline: %w", err)
475+ }
476+ }
477+ s.damage = s.damage[:0]
478+ for _, d := range out.Damage {
479+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
480+ }
481+ err := s.surf.Present(&neferclient.Present{
482+ Buffer: out.Buffer,
483+ AcquireTimeline: out.Acquire.ID,
484+ ReleaseTimeline: out.Release.ID,
485+ AcquirePoint: out.AcquirePoint,
486+ ReleasePoint: out.ReleasePoint,
487+ Damage: s.damage,
488+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
489+ })
490+ if err != nil {
491+ return fmt.Errorf("present: %w", err)
492+ }
493+ s.canPresent = false // until Handler.Frame
494+ return nil
495+}
496+
497+// release stops watching the live release eventfds, then frees the renderer and
498+// the surface (an output went away). The release eventfds belong to the
499+// renderer, so they are unwatched before it closes.
500+func (s *screen) release() error {
501+ var errs []error
502+ for buffer, fd := range s.live {
503+ if err := s.conn.UnwatchFD(fd); err != nil {
504+ errs = append(errs, fmt.Errorf("unwatch buffer %d: %w", buffer, err))
505+ }
506+ }
507+ clear(s.live)
508+ return errors.Join(append(errs, s.close())...)
509+}
510+
511+// close frees the renderer and the surface. The session lock itself is only
512+// ended by Lock.Unlock.
513+func (s *screen) close() error {
514+ var err error
515+ if s.r != nil {
516+ err = s.r.Close()
517+ }
518+ return errors.Join(err, s.surf.Close())
519+}
M present.go +16 -0
@@ -124,6 +124,22 @@ func (s *Surface) ImportTimeline(id uint64, fd int) error {
124124 return nil
125125 }
126126
127+// DestroyTimeline destroys an imported timeline object. The id is the one given
128+// to [Surface.ImportTimeline]; it may be imported again afterwards. Do not
129+// destroy a timeline that a pending commit still references. Surface.Close
130+// destroys every remaining timeline.
131+func (s *Surface) DestroyTimeline(id uint64) error {
132+ t, ok := s.timelines[id]
133+ if !ok {
134+ return fmt.Errorf("neferclient: unknown timeline %d", id)
135+ }
136+ delete(s.timelines, id)
137+ if err := t.Destroy(); err != nil {
138+ return fmt.Errorf("neferclient: destroy timeline %d: %w", id, err)
139+ }
140+ return nil
141+}
142+
127143 // Present commits an imported buffer. It refuses before the first configure,
128144 // before the first complete dmabuf feedback and while the previous frame
129145 // callback is pending.
@@ -124,6 +124,22 @@ func (s *Surface) ImportTimeline(id uint64, fd int) error {
124 return nil124 return nil
125 }125 }
126 126
127+// DestroyTimeline destroys an imported timeline object. The id is the one given
128+// to [Surface.ImportTimeline]; it may be imported again afterwards. Do not
129+// destroy a timeline that a pending commit still references. Surface.Close
130+// destroys every remaining timeline.
131+func (s *Surface) DestroyTimeline(id uint64) error {
132+ t, ok := s.timelines[id]
133+ if !ok {
134+ return fmt.Errorf("neferclient: unknown timeline %d", id)
135+ }
136+ delete(s.timelines, id)
137+ if err := t.Destroy(); err != nil {
138+ return fmt.Errorf("neferclient: destroy timeline %d: %w", id, err)
139+ }
140+ return nil
141+}
142+
127 // Present commits an imported buffer. It refuses before the first configure,143 // Present commits an imported buffer. It refuses before the first configure,
128 // before the first complete dmabuf feedback and while the previous frame144 // before the first complete dmabuf feedback and while the previous frame
129 // callback is pending.145 // callback is pending.
M surface_wire_test.go +28 -0
@@ -58,6 +58,34 @@ func TestImportErrorPathsCloseDupsOnce(t *testing.T) {
5858 require.Equal(t, before, openFDs(t))
5959 }
6060
61+// DestroyTimeline sends wp_linux_drm_syncobj_timeline_v1.destroy (opcode 0) for
62+// the imported timeline, refuses an unknown id and allows importing it again.
63+func TestDestroyTimeline(t *testing.T) {
64+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
65+ s, err := c.NewToplevel("tl", 64, 48)
66+ require.NoError(t, err)
67+ memfd, err := unix.MemfdCreate("tl", unix.MFD_CLOEXEC)
68+ require.NoError(t, err)
69+ defer unix.Close(memfd)
70+ require.NoError(t, s.ImportTimeline(7, memfd))
71+ require.NoError(t, c.Roundtrip())
72+
73+ var destroys atomic.Int32
74+ srv.mu.Lock()
75+ srv.hook = func(_ uint32, op uint16, _ []byte) {
76+ if op == 0 {
77+ destroys.Add(1)
78+ }
79+ }
80+ srv.mu.Unlock()
81+ require.NoError(t, s.DestroyTimeline(7))
82+ require.NoError(t, c.Roundtrip())
83+ require.Equal(t, int32(1), destroys.Load(), "one destroy request")
84+ require.ErrorContains(t, s.DestroyTimeline(7), "unknown timeline 7")
85+ require.ErrorContains(t, s.DestroyTimeline(99), "unknown timeline 99")
86+ require.NoError(t, s.ImportTimeline(7, memfd), "the id is free again")
87+}
88+
6189 // requestLog counts the commits the wire peer sees.
6290 type requestLog struct{ commits atomic.Int32 }
6391
@@ -58,6 +58,34 @@ func TestImportErrorPathsCloseDupsOnce(t *testing.T) {
58 require.Equal(t, before, openFDs(t))58 require.Equal(t, before, openFDs(t))
59 }59 }
60 60
61+// DestroyTimeline sends wp_linux_drm_syncobj_timeline_v1.destroy (opcode 0) for
62+// the imported timeline, refuses an unknown id and allows importing it again.
63+func TestDestroyTimeline(t *testing.T) {
64+ c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
65+ s, err := c.NewToplevel("tl", 64, 48)
66+ require.NoError(t, err)
67+ memfd, err := unix.MemfdCreate("tl", unix.MFD_CLOEXEC)
68+ require.NoError(t, err)
69+ defer unix.Close(memfd)
70+ require.NoError(t, s.ImportTimeline(7, memfd))
71+ require.NoError(t, c.Roundtrip())
72+
73+ var destroys atomic.Int32
74+ srv.mu.Lock()
75+ srv.hook = func(_ uint32, op uint16, _ []byte) {
76+ if op == 0 {
77+ destroys.Add(1)
78+ }
79+ }
80+ srv.mu.Unlock()
81+ require.NoError(t, s.DestroyTimeline(7))
82+ require.NoError(t, c.Roundtrip())
83+ require.Equal(t, int32(1), destroys.Load(), "one destroy request")
84+ require.ErrorContains(t, s.DestroyTimeline(7), "unknown timeline 7")
85+ require.ErrorContains(t, s.DestroyTimeline(99), "unknown timeline 99")
86+ require.NoError(t, s.ImportTimeline(7, memfd), "the id is free again")
87+}
88+
61 // requestLog counts the commits the wire peer sees.89 // requestLog counts the commits the wire peer sees.
62 type requestLog struct{ commits atomic.Int32 }90 type requestLog struct{ commits atomic.Int32 }
63 91

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