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 Unverified
B5690EEEBB952194The key is unknown
3 files · +158 -46
M examples/doc.go +3 -1
| @@ -1,4 +1,6 @@ | |||
| 1 | 1 | // Package examples holds the neferclient and NeferGUI example programs | |
| 2 | 2 | // (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 | 6 | package examples | |
| @@ -1,4 +1,6 @@ | |||||
| 1 | // Package examples holds the neferclient and NeferGUI example programs | 1 | // Package examples holds the neferclient and NeferGUI example programs | ||
| 2 | // (layer, lock) and their headless tests. The two libraries never import each | 2 | // (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 examples | 6 | 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 { | |||
| 175 | 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) { | |
| 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() | |
| 191 | 185 | deadline := time.Now().Add(10 * time.Second) | |
| 192 | 186 | for { | |
| 193 | 187 | shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png")) | |
| 194 | 188 | for _, p := range shots { | |
| 195 | 189 | if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) { | |
| 196 | - | t.Logf("button colour found in %s", filepath.Base(p)) | |
| 197 | 190 | return | |
| 198 | 191 | } | |
| 199 | 192 | } | |
| 200 | 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 | 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 | 274 | func TestHeadlessLock(t *testing.T) { | |
| 208 | 275 | exe := build(t, "./lock") | |
| 209 | 276 | c := startHeadless(t) | |
| @@ -225,26 +292,7 @@ func TestHeadlessLock(t *testing.T) { | |||
| 225 | 292 | } | |
| 226 | 293 | t.Cleanup(func() { cancel(); _ = wait() }) | |
| 227 | 294 | 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) | |
| 248 | 296 | expect("locked") | |
| 249 | 297 | expect("focused") // the keyboard reached a lock surface: typing is safe | |
| 250 | 298 | // 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 | return | 190 | 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 stdout | 294 | 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 safe | 297 | expect("focused") // the keyboard reached a lock surface: typing is safe | ||
| 250 | // Only the nested compositor receives this input; it types into the | 298 | // Only the nested compositor receives this input; it types into the | ||
M examples/layer/main.go +72 -10
| @@ -7,9 +7,13 @@ | |||
| 7 | 7 | // calls Conn.Dispatch, and all Handler methods, renderer calls and Present | |
| 8 | 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 | 14 | // With -frames N the program redraws continuously and exits after N frames | |
| 11 | 15 | // (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 | 17 | package main | |
| 14 | 18 | ||
| 15 | 19 | import ( | |
| @@ -26,10 +30,14 @@ import ( | |||
| 26 | 30 | "github.com/bnema/nefergui" | |
| 27 | 31 | ) | |
| 28 | 32 | ||
| 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. | |
| 31 | 36 | const ( | |
| 32 | 37 | surfaceW, surfaceH = 240, 90 | |
| 38 | + | pad, labelH, gap = 10, 20, 10 | |
| 39 | + | buttonW, buttonH = 200, 40 | |
| 40 | + | buttonY = pad + labelH + gap | |
| 33 | 41 | buttonColor = "#2060c0" | |
| 34 | 42 | ||
| 35 | 43 | // guiMods is the set of modifiers NeferGUI knows. neferclient and NeferGUI | |
| @@ -39,15 +47,27 @@ const ( | |||
| 39 | 47 | ||
| 40 | 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 | 60 | // view builds one frame from the model. Button.Activated is true during the | |
| 43 | 61 | // frame built after a click. | |
| 44 | 62 | 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() { | |
| 49 | 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 | 73 | type app struct { | |
| @@ -62,6 +82,9 @@ type app struct { | |||
| 62 | 82 | out nefergui.Output | |
| 63 | 83 | model model | |
| 64 | 84 | damage []neferclient.Rect | |
| 85 | + | region []neferclient.Rect // input region sent to the compositor, reused | |
| 86 | + | ||
| 87 | + | logPointer bool | |
| 65 | 88 | ||
| 66 | 89 | configured bool | |
| 67 | 90 | canPresent bool // configured, and the last frame callback fired | |
| @@ -75,21 +98,25 @@ type app struct { | |||
| 75 | 98 | ||
| 76 | 99 | func main() { | |
| 77 | 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 | 102 | flag.Parse() | |
| 79 | 103 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 80 | 104 | defer stop() | |
| 81 | - | if err := run(ctx, *frames); err != nil { | |
| 105 | + | if err := run(ctx, *frames, *logPointer); err != nil { | |
| 82 | 106 | fmt.Fprintln(os.Stderr, "layer:", err) | |
| 83 | 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 | 112 | conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY | |
| 89 | 113 | if err != nil { | |
| 90 | 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 | 120 | // Shutdown order: close the connection first, which drops its descriptor | |
| 94 | 121 | // watches and destroys every Wayland object, then free the renderer and | |
| 95 | 122 | // its GPU resources (including the release eventfds). | |
| @@ -225,6 +252,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) { | |||
| 225 | 252 | switch ev.Kind { | |
| 226 | 253 | case neferclient.PointerEnter: | |
| 227 | 254 | in.Kind = nefergui.InputPointerMotion | |
| 255 | + | if a.logPointer { | |
| 256 | + | fmt.Printf("pointer enter %.0f %.0f\n", ev.X, ev.Y) | |
| 257 | + | } | |
| 228 | 258 | // The cursor shape is only valid after the enter: set the shape of | |
| 229 | 259 | // the last frame again. | |
| 230 | 260 | if err := a.seat.SetCursor(a.cursor); err != nil { | |
| @@ -234,6 +264,9 @@ func (a *app) Pointer(ev *neferclient.PointerEvent) { | |||
| 234 | 264 | in.Kind = nefergui.InputPointerMotion | |
| 235 | 265 | case neferclient.PointerLeave: | |
| 236 | 266 | in.Kind = nefergui.InputPointerLeave | |
| 267 | + | if a.logPointer { | |
| 268 | + | fmt.Println("pointer leave") | |
| 269 | + | } | |
| 237 | 270 | case neferclient.PointerButton: | |
| 238 | 271 | in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button | |
| 239 | 272 | if ev.Pressed { | |
| @@ -284,12 +317,41 @@ func (a *app) draw() error { | |||
| 284 | 317 | if !ok { | |
| 285 | 318 | return nil | |
| 286 | 319 | } | |
| 320 | + | if err = a.setInputRegion(); err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 287 | 323 | if err = a.present(); err != nil { | |
| 288 | 324 | return err | |
| 289 | 325 | } | |
| 290 | 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 | 355 | // present: Renderer.Render output → ImportBuffer, ImportTimeline, Present. | |
| 294 | 356 | func (a *app) present() error { | |
| 295 | 357 | out := &a.out | |
| @@ -7,9 +7,13 @@ | |||||
| 7 | // calls Conn.Dispatch, and all Handler methods, renderer calls and Present | 7 | // 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 frames | 14 | // 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 | 15 | // (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 main | 17 | 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 pixel | 33 | + | // 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, 90 | 37 | 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 NeferGUI | 43 | // 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 the | 60 | // 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.Output | 82 | out nefergui.Output | ||
| 63 | model model | 83 | model model | ||
| 64 | damage []neferclient.Rect | 84 | damage []neferclient.Rect | ||
| 85 | + | region []neferclient.Rect // input region sent to the compositor, reused | |||
| 86 | + | ||||
| 87 | + | logPointer bool | |||
| 65 | 88 | ||||
| 66 | configured bool | 89 | configured bool | ||
| 67 | canPresent bool // configured, and the last frame callback fired | 90 | 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_DISPLAY | 112 | 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 descriptor | 120 | // Shutdown order: close the connection first, which drops its descriptor | ||
| 94 | // watches and destroys every Wayland object, then free the renderer and | 121 | // 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.InputPointerMotion | 254 | 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 of | 258 | // 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.InputPointerMotion | 264 | in.Kind = nefergui.InputPointerMotion | ||
| 235 | case neferclient.PointerLeave: | 265 | case neferclient.PointerLeave: | ||
| 236 | in.Kind = nefergui.InputPointerLeave | 266 | 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.Button | 271 | 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 nil | 318 | 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 err | 324 | 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.out | 357 | out := &a.out | ||