1package ui
2
3import (
4 "context"
5 "errors"
6 "math"
7 "net"
8 "path/filepath"
9 "strconv"
10 "testing"
11 "time"
12
13 "github.com/bnema/wlturbo"
14 "github.com/bnema/wlturbo/wl"
15
16 "github.com/bnema/nefergui/internal/css"
17 "github.com/bnema/nefergui/internal/keyboard"
18 "github.com/bnema/nefergui/internal/layout"
19 "github.com/bnema/nefergui/internal/platform/wayland"
20 "github.com/bnema/nefergui/internal/text"
21)
22
23func TestLayerConfigSurfaceOptions(t *testing.T) {
24 c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25 Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26 got, err := c.surfaceOptions()
27 if err != nil {
28 t.Fatal(err)
29 }
30 l := got.Layer
31 if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32 l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33 t.Fatalf("layer options: %+v", l)
34 }
35 if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36 t.Fatalf("input rects: %+v", got.InputRects)
37 }
38 for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39 o, err := LayerConfig{Level: level}.surfaceOptions()
40 if err != nil || o.Layer.Layer != want {
41 t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42 }
43 }
44 // nil InputRects keeps the full surface; empty non-nil means click-through.
45 if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46 t.Fatal("nil rects became non-nil")
47 }
48 if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49 t.Fatal("empty rects became nil")
50 }
51}
52
53func TestLayerConfigRejectsInvalid(t *testing.T) {
54 for name, c := range map[string]LayerConfig{
55 "zero level": {},
56 "bad level": {Level: 9},
57 "bad anchor": {Level: LayerTop, Anchors: 1 << 5},
58 "bad keyboard": {Level: LayerTop, Keyboard: 7},
59 "empty rect": {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60 "nul output": {Level: LayerTop, Output: "a\x00"},
61 "all+output": {Level: LayerTop, AllOutputs: true, Output: "DP-1"},
62 "all+keyboard": {Level: LayerTop, AllOutputs: true, Keyboard: KeyboardOnDemand},
63 } {
64 if _, err := c.surfaceOptions(); err == nil {
65 t.Errorf("%s: expected error", name)
66 }
67 }
68}
69
70func TestLayerCopiesInputRects(t *testing.T) {
71 rects := []Rect{{0, 0, 1, 1}}
72 var cfg windowConfig
73 Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
74 rects[0].Width = 99
75 if cfg.layer.InputRects[0].Width != 1 {
76 t.Fatal("option aliases caller slice")
77 }
78}
79
80func TestInputEventMapping(t *testing.T) {
81 ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
82 if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
83 t.Fatalf("press: %+v", ev)
84 }
85 ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
86 if ev.Kind != InputPointerRelease || ev.Pressed {
87 t.Fatalf("release: %+v", ev)
88 }
89 ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
90 if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
91 t.Fatalf("key: %+v", ev)
92 }
93 ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
94 if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
95 t.Fatalf("axis: %+v", ev)
96 }
97 for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
98 if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
99 t.Fatalf("%s: %+v", kind, ev)
100 }
101 }
102 if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
103 t.Fatal("unknown kind delivered")
104 }
105}
106
107func TestInputHookDelivery(t *testing.T) {
108 var got []InputEvent
109 h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
110 if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
111 t.Fatal("motion reported a change")
112 }
113 if !h.input(wayland.Input{Kind: "press", Button: 1}) {
114 t.Fatal("press change not reported")
115 }
116 if len(got) != 2 || got[0].Kind != InputPointerMotion {
117 t.Fatalf("got %+v", got)
118 }
119 // Absent callbacks and steady-state delivery do not allocate.
120 idle := &inputHooks{}
121 in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
122 if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
123 t.Fatalf("idle input allocs %v", n)
124 }
125 count := 0
126 live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
127 if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
128 t.Fatalf("hooked input allocs %v", n)
129 }
130 if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
131 t.Fatalf("idle resize allocs %v", n)
132 }
133}
134
135func TestResizeReportedOnlyOnChange(t *testing.T) {
136 type size struct {
137 w, h int
138 s float64
139 }
140 var got []size
141 h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
142 var changed []bool
143 for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
144 changed = append(changed, h.resize(s.w, s.h, s.s))
145 }
146 want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
147 if len(got) != len(want) {
148 t.Fatalf("resize reports %v", got)
149 }
150 for i := range want {
151 if got[i] != want[i] {
152 t.Fatalf("report %d: %v", i, got[i])
153 }
154 }
155 if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
156 t.Fatalf("changed flags %v", changed)
157 }
158}
159
160func TestExternalWakeForcesBuild(t *testing.T) {
161 r := newRuntime()
162 builds := 0
163 view := func(f *Frame) { builds++; f.Root() }
164 r.Build(view)
165 if r.Build(view) {
166 t.Fatal("idle runtime rebuilt")
167 }
168 ch := make(chan struct{})
169 ctx, cancel := context.WithCancel(context.Background())
170 defer cancel()
171 done := make(chan struct{})
172 go func() { r.forwardWake(ctx, ch); close(done) }()
173 ch <- struct{}{}
174 waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
175 defer waitCancel()
176 if err := r.Wait(waitCtx); err != nil {
177 t.Fatal("wake not forwarded:", err)
178 }
179 if !r.Build(view) || builds != 2 {
180 t.Fatalf("wake did not rebuild: builds=%d", builds)
181 }
182 close(ch)
183 select {
184 case <-done:
185 case <-time.After(time.Second):
186 t.Fatal("forwarder did not stop on close")
187 }
188}
189
190// AllOutputs redraws the other surfaces only after a build that handled events.
191func TestBuildReportsConsumedInput(t *testing.T) {
192 r := newRuntime()
193 view := func(f *Frame) { f.Root().Button("Go", Key("go")) }
194 r.Build(view)
195 if r.consumedInput() {
196 t.Fatal("first build consumed nothing")
197 }
198 r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"})
199 if !r.Build(view) || !r.consumedInput() {
200 t.Fatal("event build not reported")
201 }
202 if !r.Build(view) || r.consumedInput() {
203 t.Fatal("follow-up build must not report events")
204 }
205}
206
207func TestNodeRectAndFrameSize(t *testing.T) {
208 r := newRuntime()
209 r.width, r.height = 400, 300
210 var w, h float64
211 r.Build(func(f *Frame) {
212 w, h = f.Size()
213 stack := f.Root().Stack()
214 stack.Box(Key("a")).Rect(10, 20, 30, 40)
215 stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
216 })
217 if w != 400 || h != 300 {
218 t.Fatalf("size %v x %v", w, h)
219 }
220 kids := r.output.Tree.Children[0].Children
221 if len(kids) != 2 {
222 t.Fatalf("children %d", len(kids))
223 }
224 if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
225 t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
226 }
227 // Hit testing follows the rect geometry.
228 if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
229 t.Fatalf("hit %q want %q", id, kids[0].ID)
230 }
231}
232
233// Raw input observation must not replace normal control routing.
234func TestRawHookDoesNotBlockControls(t *testing.T) {
235 r := newRuntime()
236 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
237 if err != nil {
238 t.Fatal(err)
239 }
240 r.setTextEngine(text.NewEngine(catalog))
241 r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
242 activated := 0
243 view := shell(r, &activated)
244 r.Build(view)
245 button := r.output.Tree.Children[1]
246 seen := 0
247 h := &inputHooks{onInput: func(ev InputEvent) bool {
248 if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
249 seen++
250 }
251 return false
252 }}
253 for _, in := range []wayland.Input{
254 {Kind: "motion", X: 1, Y: button.Border.Y + 1},
255 {Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
256 {Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
257 } {
258 h.input(in)
259 r.routeInput(in)
260 }
261 r.Build(view)
262 if seen != 2 || activated != 1 {
263 t.Fatalf("seen %d activated %d", seen, activated)
264 }
265}
266
267func TestSurfaceHookOptionIsStored(t *testing.T) {
268 var cfg windowConfig
269 OnSurface(nil).window(&cfg)
270 if cfg.onSurface != nil {
271 t.Fatal("nil hook stored")
272 }
273 called := false
274 OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
275 if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
276 t.Fatal("hook not stored")
277 }
278}
279
280// testDisplay is a real wlturbo display over a unix socket with no compositor.
281func testDisplay(t *testing.T) *wl.Display {
282 t.Helper()
283 l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
284 if err != nil {
285 t.Fatal(err)
286 }
287 defer l.Close()
288 accepted := make(chan net.Conn, 1)
289 go func() {
290 c, _ := l.Accept()
291 accepted <- c
292 }()
293 c, err := net.Dial("unix", l.Addr().String())
294 if err != nil {
295 t.Fatal(err)
296 }
297 server := <-accepted
298 t.Cleanup(func() { server.Close() })
299 d, err := wlturbo.ConnectFromConn(c)
300 if err != nil {
301 t.Fatal(err)
302 }
303 t.Cleanup(func() { d.Close() })
304 return d
305}
306
307func TestStartupHookErrorsAndCancellation(t *testing.T) {
308 boom := errors.New("boom")
309 // A plain error passes through and does not need a display.
310 if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
311 t.Fatalf("error: %v", err)
312 }
313 if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
314 t.Fatalf("nil: %v", err)
315 }
316 // A hook that observes cancellation reports ctx.Err joined with its own error.
317 display := testDisplay(t)
318 ctx, cancel := context.WithCancel(context.Background())
319 err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
320 cancel()
321 <-ctx.Done()
322 return boom
323 }, WaylandSurface{Display: display})
324 if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
325 t.Fatalf("cancel: %v", err)
326 }
327 // Cancelling without a hook error still reports the cancellation.
328 ctx, cancel = context.WithCancel(context.Background())
329 err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
330 if !errors.Is(err, context.Canceled) {
331 t.Fatalf("cancel without error: %v", err)
332 }
333}
334
335func TestFrameSetInputRects(t *testing.T) {
336 r := newRuntime()
337 var setErr error
338 rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
339 r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
340 if setErr != nil {
341 t.Fatal(setErr)
342 }
343 rects[0].Width = 99 // caller reuse must not affect the staged copy
344 var got [][]wayland.Rect
345 set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
346 if err := r.applyInputRects(set); err != nil {
347 t.Fatal(err)
348 }
349 if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
350 t.Fatalf("applied %+v", got)
351 }
352 // Nothing staged: the platform is not called again.
353 if err := r.applyInputRects(set); err != nil || len(got) != 1 {
354 t.Fatalf("restaged: %v %+v", err, got)
355 }
356 // nil restores the full surface; empty non-nil is click-through.
357 var nilSeen, emptySeen bool
358 r.Redraw()
359 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
360 _ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
361 r.Redraw()
362 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
363 _ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
364 if !nilSeen || !emptySeen {
365 t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
366 }
367 // Invalid rects are rejected in the frame call and stage nothing.
368 r.Redraw()
369 for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
370 r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
371 if setErr == nil || r.inputStaged {
372 t.Fatalf("accepted %+v", bad)
373 }
374 r.Redraw()
375 }
376 // Steady-state staging and apply do not allocate once buffers exist.
377 r.Build(func(f *Frame) {
378 f.Root()
379 if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
380 t.Fatalf("SetInputRects allocs %v", n)
381 }
382 })
383 noop := func([]wayland.Rect) error { return nil }
384 r.inputStaged = true
385 if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
386 t.Fatalf("applyInputRects allocs %v", n)
387 }
388}
389
390// A failed stage keeps the region pending so the next frame retries it.
391func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
392 r := newRuntime()
393 r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
394 boom := errors.New("stage failed")
395 if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
396 t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
397 }
398 var got []wayland.Rect
399 if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
400 t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
401 }
402 if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
403 t.Fatalf("retried region %+v", got)
404 }
405 // A later frame that does not call SetInputRects stages nothing new.
406 r.Redraw()
407 r.Build(func(f *Frame) { f.Root() })
408 called := false
409 _ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
410 if called {
411 t.Fatal("unchanged frame restaged the region")
412 }
413}
414
415func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
416 r := newRuntime()
417 r.width, r.height = 200, 100
418 r.Build(func(f *Frame) {
419 col := f.Root().Column()
420 col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
421 col.Box(Key("b"), Inline("height:10px"))
422 })
423 kids := r.output.Tree.Children[0].Children
424 if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
425 t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
426 }
427}
428
429// The absolute geometry path adds no allocations over the same tree without it.
430func TestNodeRectAddsNoAllocations(t *testing.T) {
431 build := func(withRect bool) func() {
432 r := newRuntime()
433 r.width, r.height = 640, 480
434 view := func(f *Frame) {
435 st := f.Root().Stack()
436 for i := 0; i < 32; i++ {
437 b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
438 if withRect {
439 b.Rect(float64(i), float64(i), 20, 20)
440 }
441 }
442 }
443 r.Build(view)
444 return func() { r.Redraw(); r.Build(view) }
445 }
446 plain := testing.AllocsPerRun(20, build(false))
447 rect := testing.AllocsPerRun(20, build(true))
448 if rect > plain {
449 t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
450 }
451}