detached 8c028cb

feat(examples): forward NeferGUI input rects to the layer input region (#2)

* feat(examples): forward NeferGUI input rects to the layer input region

* refactor(examples): derive layer styles from layout constants and share headless test helpers
commit
8c028cb57d567aedc76dea28801fc4f24857db44
author
bnema · · 2026-10-01 20:45 UTC
committer
GitHub · · 2026-10-01 20:45 UTC
parent
35f7ff1
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +158 -46
M examples/doc.go +3 -1
@@ -1,4 +1,6 @@
11 // Package examples holds the neferclient and NeferGUI example programs
22 // (layer, lock) and their headless tests. The two libraries never import each
3-// other: each program copies plain fields between them.
3+// other: each program copies plain fields between them. The layer program also
4+// shows the input region: NeferGUI's input rectangles become the Wayland input
5+// region, so pointer input outside the button passes through the surface.
46 package examples
@@ -1,4 +1,6 @@
1 // Package examples holds the neferclient and NeferGUI example programs1 // Package examples holds the neferclient and NeferGUI example programs
2 // (layer, lock) and their headless tests. The two libraries never import each2 // (layer, lock) and their headless tests. The two libraries never import each
3-// other: each program copies plain fields between them.3+// other: each program copies plain fields between them. The layer program also
4+// shows the input region: NeferGUI's input rectangles become the Wayland input
5+// region, so pointer input outside the button passes through the surface.
4 package examples6 package examples
M examples/headless_test.go +83 -35
@@ -175,35 +175,102 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
175175 return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
176176 }
177177
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.
178+// waitButton waits until a screenshot shows the layer example's button: it is
179+// 200x40 logical pixels at (10, 40) of a surface placed at the output's
180+// top-left corner, filled with #2060c0. It samples near the bottom-right
181+// corner, away from the label, and looks through every screenshot because the
182+// program may already have exited.
183+func waitButton(t *testing.T, c *compositor, detail func() string) {
184+ t.Helper()
191185 deadline := time.Now().Add(10 * time.Second)
192186 for {
193187 shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
194188 for _, p := range shots {
195189 if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
196- t.Logf("button colour found in %s", filepath.Base(p))
197190 return
198191 }
199192 }
200193 if time.Now().After(deadline) {
201- t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))
194+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75): %s", len(shots), detail())
202195 }
203196 time.Sleep(50 * time.Millisecond)
204197 }
205198 }
206199
200+// lineReader reads the program's stdout line by line, copying it into all when
201+// non-nil. expect fails the test unless the next line is want before ctx ends.
202+func lineReader(ctx context.Context, t *testing.T, stdout io.Reader, all *syncBuffer, stderr *syncBuffer) (expect func(want string), lines <-chan string) {
203+ ch := make(chan string, 8)
204+ go func() {
205+ defer close(ch)
206+ sc := bufio.NewScanner(stdout)
207+ for sc.Scan() {
208+ if all != nil {
209+ _, _ = all.Write(append(sc.Bytes(), '\n'))
210+ }
211+ select {
212+ case ch <- sc.Text():
213+ case <-ctx.Done(): // the test is over: do not block
214+ return
215+ }
216+ }
217+ }()
218+ expect = func(want string) {
219+ t.Helper()
220+ select {
221+ case l := <-ch:
222+ if l != want {
223+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
224+ }
225+ case <-ctx.Done():
226+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
227+ }
228+ }
229+ return expect, ch
230+}
231+
232+func TestHeadlessLayer(t *testing.T) {
233+ exe := build(t, "./layer")
234+ c := startHeadless(t)
235+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
236+ defer cancel()
237+ cmd := c.command(ctx, exe, "-frames", "20")
238+ out, err := cmd.CombinedOutput()
239+ must(t, err, string(out))
240+ waitButton(t, c, func() string { return string(out) })
241+}
242+
243+// TestHeadlessLayerInputRegion checks that the layer example forwards the
244+// view's input rectangle to the compositor: NeferWL only delivers pointer
245+// events to the surface inside the button, not over the label above it.
246+func TestHeadlessLayerInputRegion(t *testing.T) {
247+ exe := build(t, "./layer")
248+ c := startHeadless(t)
249+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
250+ defer cancel()
251+ cmd := c.command(ctx, exe, "-log-pointer") // runs until killed
252+ stdout, err := cmd.StdoutPipe()
253+ must(t, err)
254+ var stderr syncBuffer
255+ cmd.Stderr = &stderr
256+ must(t, cmd.Start())
257+ t.Cleanup(func() { cancel(); _ = cmd.Wait() })
258+ expect, _ := lineReader(ctx, t, stdout, nil, &stderr)
259+
260+ // The region is committed with the first buffer, so once the button is
261+ // visible in a screenshot the compositor knows it.
262+ waitButton(t, c, stderr.String)
263+
264+ // (50, 20) is over the label, outside the input region: the surface sees
265+ // nothing. (100, 60) is over the button: the first event the program
266+ // prints is its enter, so the label position did not enter earlier.
267+ // Back on the label, the pointer leaves the surface again.
268+ _, err = io.WriteString(c.input, "move 50 20\nsleep 300ms\nmove 100 60\nsleep 300ms\nmove 50 20\n")
269+ must(t, err)
270+ expect("pointer enter 100 60")
271+ expect("pointer leave")
272+}
273+
207274 func TestHeadlessLock(t *testing.T) {
208275 exe := build(t, "./lock")
209276 c := startHeadless(t)
@@ -225,26 +292,7 @@ func TestHeadlessLock(t *testing.T) {
225292 }
226293 t.Cleanup(func() { cancel(); _ = wait() })
227294 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- }
295+ expect, lines := lineReader(ctx, t, stdout, &all, &stderr)
248296 expect("locked")
249297 expect("focused") // the keyboard reached a lock surface: typing is safe
250298 // Only the nested compositor receives this input; it types into the
@@ -175,35 +175,102 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
175 return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)175 return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
176 }176 }
177 177
178-func TestHeadlessLayer(t *testing.T) {178+// waitButton waits until a screenshot shows the layer example's button: it is
179- exe := build(t, "./layer")179+// 200x40 logical pixels at (10, 40) of a surface placed at the output's
180- c := startHeadless(t)180+// top-left corner, filled with #2060c0. It samples near the bottom-right
181- ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)181+// corner, away from the label, and looks through every screenshot because the
182- defer cancel()182+// program may already have exited.
183- cmd := c.command(ctx, exe, "-frames", "20")183+func waitButton(t *testing.T, c *compositor, detail func() string) {
184- out, err := cmd.CombinedOutput()184+ t.Helper()
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)185 deadline := time.Now().Add(10 * time.Second)
192 for {186 for {
193 shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))187 shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
194 for _, p := range shots {188 for _, p := range shots {
195 if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {189 if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
196- t.Logf("button colour found in %s", filepath.Base(p))
197 return190 return
198 }191 }
199 }192 }
200 if time.Now().After(deadline) {193 if time.Now().After(deadline) {
201- t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))194+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75): %s", len(shots), detail())
202 }195 }
203 time.Sleep(50 * time.Millisecond)196 time.Sleep(50 * time.Millisecond)
204 }197 }
205 }198 }
206 199
200+// lineReader reads the program's stdout line by line, copying it into all when
201+// non-nil. expect fails the test unless the next line is want before ctx ends.
202+func lineReader(ctx context.Context, t *testing.T, stdout io.Reader, all *syncBuffer, stderr *syncBuffer) (expect func(want string), lines <-chan string) {
203+ ch := make(chan string, 8)
204+ go func() {
205+ defer close(ch)
206+ sc := bufio.NewScanner(stdout)
207+ for sc.Scan() {
208+ if all != nil {
209+ _, _ = all.Write(append(sc.Bytes(), '\n'))
210+ }
211+ select {
212+ case ch <- sc.Text():
213+ case <-ctx.Done(): // the test is over: do not block
214+ return
215+ }
216+ }
217+ }()
218+ expect = func(want string) {
219+ t.Helper()
220+ select {
221+ case l := <-ch:
222+ if l != want {
223+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
224+ }
225+ case <-ctx.Done():
226+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
227+ }
228+ }
229+ return expect, ch
230+}
231+
232+func TestHeadlessLayer(t *testing.T) {
233+ exe := build(t, "./layer")
234+ c := startHeadless(t)
235+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
236+ defer cancel()
237+ cmd := c.command(ctx, exe, "-frames", "20")
238+ out, err := cmd.CombinedOutput()
239+ must(t, err, string(out))
240+ waitButton(t, c, func() string { return string(out) })
241+}
242+
243+// TestHeadlessLayerInputRegion checks that the layer example forwards the
244+// view's input rectangle to the compositor: NeferWL only delivers pointer
245+// events to the surface inside the button, not over the label above it.
246+func TestHeadlessLayerInputRegion(t *testing.T) {
247+ exe := build(t, "./layer")
248+ c := startHeadless(t)
249+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
250+ defer cancel()
251+ cmd := c.command(ctx, exe, "-log-pointer") // runs until killed
252+ stdout, err := cmd.StdoutPipe()
253+ must(t, err)
254+ var stderr syncBuffer
255+ cmd.Stderr = &stderr
256+ must(t, cmd.Start())
257+ t.Cleanup(func() { cancel(); _ = cmd.Wait() })
258+ expect, _ := lineReader(ctx, t, stdout, nil, &stderr)
259+
260+ // The region is committed with the first buffer, so once the button is
261+ // visible in a screenshot the compositor knows it.
262+ waitButton(t, c, stderr.String)
263+
264+ // (50, 20) is over the label, outside the input region: the surface sees
265+ // nothing. (100, 60) is over the button: the first event the program
266+ // prints is its enter, so the label position did not enter earlier.
267+ // Back on the label, the pointer leaves the surface again.
268+ _, err = io.WriteString(c.input, "move 50 20\nsleep 300ms\nmove 100 60\nsleep 300ms\nmove 50 20\n")
269+ must(t, err)
270+ expect("pointer enter 100 60")
271+ expect("pointer leave")
272+}
273+
207 func TestHeadlessLock(t *testing.T) {274 func TestHeadlessLock(t *testing.T) {
208 exe := build(t, "./lock")275 exe := build(t, "./lock")
209 c := startHeadless(t)276 c := startHeadless(t)
@@ -225,26 +292,7 @@ func TestHeadlessLock(t *testing.T) {
225 }292 }
226 t.Cleanup(func() { cancel(); _ = wait() })293 t.Cleanup(func() { cancel(); _ = wait() })
227 var all syncBuffer // everything the program printed on stdout294 var all syncBuffer // everything the program printed on stdout
228- lines := make(chan string, 8)295+ expect, lines := lineReader(ctx, t, stdout, &all, &stderr)
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")296 expect("locked")
249 expect("focused") // the keyboard reached a lock surface: typing is safe297 expect("focused") // the keyboard reached a lock surface: typing is safe
250 // Only the nested compositor receives this input; it types into the298 // Only the nested compositor receives this input; it types into the
M examples/layer/main.go +72 -10
@@ -7,9 +7,13 @@
77 // calls Conn.Dispatch, and all Handler methods, renderer calls and Present
88 // calls happen inside that call or right after it.
99 //
10+// Only the button takes pointer input: the view stages its rectangle with
11+// Frame.SetInputRects and the program forwards it to Surface.SetInputRegion,
12+// so the rest of the surface is click-through.
13+//
1014 // With -frames N the program redraws continuously and exits after N frames
1115 // (used by the headless test); without it, it runs until the surface is closed
12-// or it is interrupted.
16+// or it is interrupted. -log-pointer prints pointer enter and leave events.
1317 package main
1418
1519 import (
@@ -26,10 +30,14 @@ import (
2630 "github.com/bnema/nefergui"
2731 )
2832
29-// Layout of the surface, in logical pixels. The headless test samples a pixel
30-// inside the button at the same place.
33+// Layout of the surface, in logical pixels: padding, a label row, a gap, then
34+// the button. The headless test samples a pixel inside the button and moves the
35+// pointer over the label and the button at the same places.
3136 const (
3237 surfaceW, surfaceH = 240, 90
38+ pad, labelH, gap = 10, 20, 10
39+ buttonW, buttonH = 200, 40
40+ buttonY = pad + labelH + gap
3341 buttonColor = "#2060c0"
3442
3543 // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI
@@ -39,15 +47,27 @@ const (
3947
4048 type model struct{ count int }
4149
50+// The styles and the clickable rectangle are built once from the layout
51+// constants, so they cannot disagree. NeferGUI does not report where a control
52+// was laid out; inputRects is the button. SetInputRects copies the slice.
53+var (
54+ columnStyle = nefergui.Inline(fmt.Sprintf("padding:%dpx;gap:%dpx;color:#ffffff", pad, gap))
55+ labelStyle = nefergui.Inline(fmt.Sprintf("height:%dpx", labelH))
56+ buttonStyle = nefergui.Inline(fmt.Sprintf("width:%dpx;height:%dpx;background:%s", buttonW, buttonH, buttonColor))
57+ inputRects = []nefergui.Rect{{X: pad, Y: buttonY, Width: buttonW, Height: buttonH}}
58+)
59+
4260 // view builds one frame from the model. Button.Activated is true during the
4361 // frame built after a click.
4462 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() {
63+ root := f.Root().Column(columnStyle)
64+ root.TextInt("Count: ", int64(m.count), labelStyle)
65+ if root.Button("Add one", nefergui.Key("add"), buttonStyle).Activated() {
4966 m.count++
5067 }
68+ if err := f.SetInputRects(inputRects); err != nil {
69+ panic(err) // a fixed, valid rectangle: an error is a programming bug
70+ }
5171 }
5272
5373 type app struct {
@@ -62,6 +82,9 @@ type app struct {
6282 out nefergui.Output
6383 model model
6484 damage []neferclient.Rect
85+ region []neferclient.Rect // input region sent to the compositor, reused
86+
87+ logPointer bool
6588
6689 configured bool
6790 canPresent bool // configured, and the last frame callback fired
@@ -75,21 +98,25 @@ type app struct {
7598
7699 func main() {
77100 frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
101+ logPointer := flag.Bool("log-pointer", false, "print pointer enter and leave events on stdout")
78102 flag.Parse()
79103 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
80104 defer stop()
81- if err := run(ctx, *frames); err != nil {
105+ if err := run(ctx, *frames, *logPointer); err != nil {
82106 fmt.Fprintln(os.Stderr, "layer:", err)
83107 os.Exit(1)
84108 }
85109 }
86110
87-func run(ctx context.Context, frames int) (err error) {
111+func run(ctx context.Context, frames int, logPointer bool) (err error) {
88112 conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
89113 if err != nil {
90114 return fmt.Errorf("connect: %w", err)
91115 }
92- a := &app{conn: conn, frames: frames, cursor: neferclient.CursorDefault}
116+ // region starts non-nil so that an empty NeferGUI region stays an empty
117+ // slice (click-through) and does not become nil (whole surface).
118+ a := &app{conn: conn, frames: frames, logPointer: logPointer, cursor: neferclient.CursorDefault,
119+ region: []neferclient.Rect{}}
93120 // Shutdown order: close the connection first, which drops its descriptor
94121 // watches and destroys every Wayland object, then free the renderer and
95122 // its GPU resources (including the release eventfds).
@@ -225,6 +252,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) {
225252 switch ev.Kind {
226253 case neferclient.PointerEnter:
227254 in.Kind = nefergui.InputPointerMotion
255+ if a.logPointer {
256+ fmt.Printf("pointer enter %.0f %.0f\n", ev.X, ev.Y)
257+ }
228258 // The cursor shape is only valid after the enter: set the shape of
229259 // the last frame again.
230260 if err := a.seat.SetCursor(a.cursor); err != nil {
@@ -234,6 +264,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) {
234264 in.Kind = nefergui.InputPointerMotion
235265 case neferclient.PointerLeave:
236266 in.Kind = nefergui.InputPointerLeave
267+ if a.logPointer {
268+ fmt.Println("pointer leave")
269+ }
237270 case neferclient.PointerButton:
238271 in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
239272 if ev.Pressed {
@@ -284,12 +317,41 @@ func (a *app) draw() error {
284317 if !ok {
285318 return nil
286319 }
320+ if err = a.setInputRegion(); err != nil {
321+ return err
322+ }
287323 if err = a.present(); err != nil {
288324 return err
289325 }
290326 return a.setCursor()
291327 }
292328
329+// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
330+// view changed it. Both use logical pixels in surface coordinates, so the
331+// rectangles are copied field by field without scaling (Output.Width and
332+// Height, the buffer size, are the physical ones). Nil means the whole surface
333+// and an empty slice means click-through, in both libraries. Before the first
334+// Present the region is applied with it; afterwards SetInputRegion commits it
335+// immediately.
336+func (a *app) setInputRegion() error {
337+ out := &a.out
338+ if !out.InputRectsChanged {
339+ return nil
340+ }
341+ var region []neferclient.Rect // nil: the whole surface
342+ if out.InputRects != nil {
343+ a.region = a.region[:0] // reused: no allocation once it has grown
344+ for _, r := range out.InputRects {
345+ a.region = append(a.region, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
346+ }
347+ region = a.region
348+ }
349+ if err := a.surf.SetInputRegion(region); err != nil {
350+ return fmt.Errorf("input region: %w", err)
351+ }
352+ return nil
353+}
354+
293355 // present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
294356 func (a *app) present() error {
295357 out := &a.out
@@ -7,9 +7,13 @@
7 // calls Conn.Dispatch, and all Handler methods, renderer calls and Present7 // calls Conn.Dispatch, and all Handler methods, renderer calls and Present
8 // calls happen inside that call or right after it.8 // calls happen inside that call or right after it.
9 //9 //
10+// Only the button takes pointer input: the view stages its rectangle with
11+// Frame.SetInputRects and the program forwards it to Surface.SetInputRegion,
12+// so the rest of the surface is click-through.
13+//
10 // With -frames N the program redraws continuously and exits after N frames14 // 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 closed15 // (used by the headless test); without it, it runs until the surface is closed
12-// or it is interrupted.16+// or it is interrupted. -log-pointer prints pointer enter and leave events.
13 package main17 package main
14 18
15 import (19 import (
@@ -26,10 +30,14 @@ import (
26 "github.com/bnema/nefergui"30 "github.com/bnema/nefergui"
27 )31 )
28 32
29-// Layout of the surface, in logical pixels. The headless test samples a pixel33+// Layout of the surface, in logical pixels: padding, a label row, a gap, then
30-// inside the button at the same place.34+// the button. The headless test samples a pixel inside the button and moves the
35+// pointer over the label and the button at the same places.
31 const (36 const (
32 surfaceW, surfaceH = 240, 9037 surfaceW, surfaceH = 240, 90
38+ pad, labelH, gap = 10, 20, 10
39+ buttonW, buttonH = 200, 40
40+ buttonY = pad + labelH + gap
33 buttonColor = "#2060c0"41 buttonColor = "#2060c0"
34 42
35 // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI43 // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI
@@ -39,15 +47,27 @@ const (
39 47
40 type model struct{ count int }48 type model struct{ count int }
41 49
50+// The styles and the clickable rectangle are built once from the layout
51+// constants, so they cannot disagree. NeferGUI does not report where a control
52+// was laid out; inputRects is the button. SetInputRects copies the slice.
53+var (
54+ columnStyle = nefergui.Inline(fmt.Sprintf("padding:%dpx;gap:%dpx;color:#ffffff", pad, gap))
55+ labelStyle = nefergui.Inline(fmt.Sprintf("height:%dpx", labelH))
56+ buttonStyle = nefergui.Inline(fmt.Sprintf("width:%dpx;height:%dpx;background:%s", buttonW, buttonH, buttonColor))
57+ inputRects = []nefergui.Rect{{X: pad, Y: buttonY, Width: buttonW, Height: buttonH}}
58+)
59+
42 // view builds one frame from the model. Button.Activated is true during the60 // view builds one frame from the model. Button.Activated is true during the
43 // frame built after a click.61 // frame built after a click.
44 func view(f *nefergui.Frame, m *model) {62 func view(f *nefergui.Frame, m *model) {
45- root := f.Root().Column(nefergui.Inline("padding:10px;gap:10px;color:#ffffff"))63+ root := f.Root().Column(columnStyle)
46- root.TextInt("Count: ", int64(m.count), nefergui.Inline("height:20px"))64+ root.TextInt("Count: ", int64(m.count), labelStyle)
47- if root.Button("Add one", nefergui.Key("add"),65+ if root.Button("Add one", nefergui.Key("add"), buttonStyle).Activated() {
48- nefergui.Inline("width:200px;height:40px;background:"+buttonColor)).Activated() {
49 m.count++66 m.count++
50 }67 }
68+ if err := f.SetInputRects(inputRects); err != nil {
69+ panic(err) // a fixed, valid rectangle: an error is a programming bug
70+ }
51 }71 }
52 72
53 type app struct {73 type app struct {
@@ -62,6 +82,9 @@ type app struct {
62 out nefergui.Output82 out nefergui.Output
63 model model83 model model
64 damage []neferclient.Rect84 damage []neferclient.Rect
85+ region []neferclient.Rect // input region sent to the compositor, reused
86+
87+ logPointer bool
65 88
66 configured bool89 configured bool
67 canPresent bool // configured, and the last frame callback fired90 canPresent bool // configured, and the last frame callback fired
@@ -75,21 +98,25 @@ type app struct {
75 98
76 func main() {99 func main() {
77 frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")100 frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
101+ logPointer := flag.Bool("log-pointer", false, "print pointer enter and leave events on stdout")
78 flag.Parse()102 flag.Parse()
79 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)103 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
80 defer stop()104 defer stop()
81- if err := run(ctx, *frames); err != nil {105+ if err := run(ctx, *frames, *logPointer); err != nil {
82 fmt.Fprintln(os.Stderr, "layer:", err)106 fmt.Fprintln(os.Stderr, "layer:", err)
83 os.Exit(1)107 os.Exit(1)
84 }108 }
85 }109 }
86 110
87-func run(ctx context.Context, frames int) (err error) {111+func run(ctx context.Context, frames int, logPointer bool) (err error) {
88 conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY112 conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
89 if err != nil {113 if err != nil {
90 return fmt.Errorf("connect: %w", err)114 return fmt.Errorf("connect: %w", err)
91 }115 }
92- a := &app{conn: conn, frames: frames, cursor: neferclient.CursorDefault}116+ // region starts non-nil so that an empty NeferGUI region stays an empty
117+ // slice (click-through) and does not become nil (whole surface).
118+ a := &app{conn: conn, frames: frames, logPointer: logPointer, cursor: neferclient.CursorDefault,
119+ region: []neferclient.Rect{}}
93 // Shutdown order: close the connection first, which drops its descriptor120 // Shutdown order: close the connection first, which drops its descriptor
94 // watches and destroys every Wayland object, then free the renderer and121 // watches and destroys every Wayland object, then free the renderer and
95 // its GPU resources (including the release eventfds).122 // its GPU resources (including the release eventfds).
@@ -225,6 +252,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) {
225 switch ev.Kind {252 switch ev.Kind {
226 case neferclient.PointerEnter:253 case neferclient.PointerEnter:
227 in.Kind = nefergui.InputPointerMotion254 in.Kind = nefergui.InputPointerMotion
255+ if a.logPointer {
256+ fmt.Printf("pointer enter %.0f %.0f\n", ev.X, ev.Y)
257+ }
228 // The cursor shape is only valid after the enter: set the shape of258 // The cursor shape is only valid after the enter: set the shape of
229 // the last frame again.259 // the last frame again.
230 if err := a.seat.SetCursor(a.cursor); err != nil {260 if err := a.seat.SetCursor(a.cursor); err != nil {
@@ -234,6 +264,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) {
234 in.Kind = nefergui.InputPointerMotion264 in.Kind = nefergui.InputPointerMotion
235 case neferclient.PointerLeave:265 case neferclient.PointerLeave:
236 in.Kind = nefergui.InputPointerLeave266 in.Kind = nefergui.InputPointerLeave
267+ if a.logPointer {
268+ fmt.Println("pointer leave")
269+ }
237 case neferclient.PointerButton:270 case neferclient.PointerButton:
238 in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button271 in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
239 if ev.Pressed {272 if ev.Pressed {
@@ -284,12 +317,41 @@ func (a *app) draw() error {
284 if !ok {317 if !ok {
285 return nil318 return nil
286 }319 }
320+ if err = a.setInputRegion(); err != nil {
321+ return err
322+ }
287 if err = a.present(); err != nil {323 if err = a.present(); err != nil {
288 return err324 return err
289 }325 }
290 return a.setCursor()326 return a.setCursor()
291 }327 }
292 328
329+// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
330+// view changed it. Both use logical pixels in surface coordinates, so the
331+// rectangles are copied field by field without scaling (Output.Width and
332+// Height, the buffer size, are the physical ones). Nil means the whole surface
333+// and an empty slice means click-through, in both libraries. Before the first
334+// Present the region is applied with it; afterwards SetInputRegion commits it
335+// immediately.
336+func (a *app) setInputRegion() error {
337+ out := &a.out
338+ if !out.InputRectsChanged {
339+ return nil
340+ }
341+ var region []neferclient.Rect // nil: the whole surface
342+ if out.InputRects != nil {
343+ a.region = a.region[:0] // reused: no allocation once it has grown
344+ for _, r := range out.InputRects {
345+ a.region = append(a.region, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
346+ }
347+ region = a.region
348+ }
349+ if err := a.surf.SetInputRegion(region); err != nil {
350+ return fmt.Errorf("input region: %w", err)
351+ }
352+ return nil
353+}
354+
293 // present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.355 // present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
294 func (a *app) present() error {356 func (a *app) present() error {
295 out := &a.out357 out := &a.out

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