1package ui
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "github.com/bnema/nefergui/internal/css"
9 "github.com/bnema/nefergui/internal/keyboard"
10 "github.com/bnema/nefergui/internal/platform/wayland"
11 "github.com/bnema/nefergui/internal/text"
12)
13
14func key(name string) platformInput {
15 return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}
16}
17func shell(r *runtime, activated *int) func(*Frame) {
18 return func(f *Frame) {
19 root := f.Root()
20 root.Text("Hello")
21 if root.Button("Go", Key("go")).Activated() {
22 *activated++
23 }
24 root.Button("Disabled", Key("off"), Disabled(true))
25 root.Button("Next", Key("next"))
26 }
27}
28func TestShellStyleAndFocus(t *testing.T) {
29 r := newRuntime()
30 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
31 if err != nil {
32 t.Fatal(err)
33 }
34 r.setTextEngine(text.NewEngine(catalog))
35 r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px;color:red} button {height:20px;background:blue} button:hover {background:green} button:focus-visible {outline:2px solid red}`))
36 var activated int
37 view := shell(r, &activated)
38 if !r.Build(view) {
39 t.Fatal("first frame")
40 }
41 if len(r.output.Tree.Children) != 4 || len(r.output.Tree.Children[0].Lines) == 0 {
42 t.Fatal("text shell was not shaped and laid out", r.output.Tree)
43 }
44 button := r.output.Tree.Children[1]
45 r.route(platformInput{Kind: "motion", X: 2, Y: button.Border.Y + 2})
46 r.Build(view)
47 data, err := json.Marshal(r.output.Display)
48 if err != nil || !strings.Contains(string(data), `"G":0.5019607843137255`) {
49 t.Fatalf("hover snapshot: %s %v", data, err)
50 }
51 r.route(key("Tab"))
52 r.Build(view)
53 data, err = json.Marshal(r.output.Display)
54 if err != nil || !strings.Contains(string(data), `"Op":"outline"`) {
55 t.Fatalf("focus-visible snapshot: %s %v", data, err)
56 }
57 if !r.state.focus.same(r.Target(1)) || !r.state.visible {
58 t.Fatal("tab focus")
59 }
60 r.route(key("Return"))
61 r.Build(view)
62 if activated != 1 {
63 t.Fatal("keyboard activation", activated)
64 }
65 r.route(key("Tab"))
66 r.Build(view)
67 if !r.state.focus.same(r.Target(3)) {
68 t.Fatal("disabled included in tab order")
69 }
70 r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})
71 r.Build(view)
72 if !r.state.focus.same(r.Target(1)) {
73 t.Fatal("reverse tab")
74 }
75 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2})
76 r.Build(view)
77 if r.state.visible {
78 t.Fatal("pointer focus visible")
79 }
80 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2})
81 r.Build(view)
82 if activated != 2 {
83 t.Fatal("pointer activation", activated)
84 }
85}
86func TestOverlapCaptureRemovalAndScale(t *testing.T) {
87 r := newRuntime()
88 r.styles = css.Compile(css.UA(), css.Parse(`stack {height:30px} button {width:40px;height:20px}`))
89 front := true
90 var back, top int
91 view := func(f *Frame) {
92 s := f.Root().Stack()
93 if s.Button("back", Key("back")).Activated() {
94 back++
95 }
96 if front {
97 if s.Button("front", Key("front")).Activated() {
98 top++
99 }
100 }
101 }
102 r.Build(view)
103 for _, scale := range []float64{1.25, 1.5, 2} {
104 r.route(platformInput{Kind: "resize", Width: 80, Height: 60, Scale: scale})
105 r.Build(view)
106 if got := r.hit(20, 10); got == nil || got.identity.key != "front" {
107 t.Fatalf("scale %v hit %+v", scale, got)
108 }
109 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10})
110 r.Build(view)
111 if !r.state.capture.same(r.Target(0, 1)) {
112 t.Fatal("capture")
113 }
114 r.route(platformInput{Kind: "motion", X: 70, Y: 50})
115 r.Build(view)
116 if !r.state.capture.same(r.Target(0, 1)) {
117 t.Fatal("capture lost outside")
118 }
119 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 70, Y: 50})
120 r.Build(view)
121 if top != 0 || back != 0 {
122 t.Fatal("outside release activated")
123 }
124 }
125 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10})
126 r.Build(view)
127 front = false
128 r.Redraw()
129 r.Build(view)
130 if r.state.capture != nil || r.state.down {
131 t.Fatal("removed capture retained")
132 }
133 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 20, Y: 10})
134 r.Build(view)
135 if top != 0 || back != 0 {
136 t.Fatal("removed target activated")
137 }
138}
139func TestFocusRemovalAndScrollClamp(t *testing.T) {
140 r := newRuntime()
141 r.styles = css.Compile(css.UA(), css.Parse(`scroll {width:40px;height:20px} button {height:30px}`))
142 show := true
143 view := func(f *Frame) {
144 root := f.Root()
145 s := root.Scroll(Key("sc"))
146 if show {
147 s.Button("one", Key("one"))
148 }
149 s.Button("two", Key("two"))
150 }
151 r.Build(view)
152 r.route(key("Tab"))
153 r.Build(view)
154 if !r.state.focus.same(r.Target(0, 0)) {
155 t.Fatal("initial focus")
156 }
157 show = false
158 r.Redraw()
159 r.Build(view)
160 if r.state.focus != nil {
161 t.Fatal("focus should fall back to none")
162 }
163 r.route(key("Tab"))
164 r.Build(view)
165 if !r.state.focus.same(r.Target(0, 0)) {
166 t.Fatal("tab after removal")
167 }
168 r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000})
169 r.Build(view)
170 n := resultByID(r.output.Tree, idKey(r.Target(0)))
171 if n == nil || n.ScrollY != 10 {
172 t.Fatalf("scroll clamp: %+v", n)
173 }
174 if r.Build(view) {
175 t.Fatal("idle redraw")
176 }
177 r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000})
178 if r.Build(view) {
179 t.Fatal("clamped wheel redraw")
180 }
181 r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: -1000})
182 r.Build(view)
183 if r.output.Tree.Children[0].ScrollY != 0 {
184 t.Fatal("negative scroll clamp")
185 }
186}
187
188func TestPointerButtonOwnership(t *testing.T) {
189 r := newRuntime()
190 r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`))
191 a, b := 0, 0
192 view := func(f *Frame) {
193 root := f.Root()
194 if root.Button("a", Key("a")).Activated() {
195 a++
196 }
197 if root.Button("b", Key("b")).Activated() {
198 b++
199 }
200 }
201 r.Build(view)
202 pointer := func(kind string, button uint32, y float64) {
203 r.route(platformInput{Kind: kind, Button: button, X: 2, Y: y})
204 }
205 const secondary uint32 = 0x111
206 pointer("press", pointerPrimary, 2)
207 r.Build(view)
208 pointer("press", secondary, 22)
209 if !r.state.capture.same(r.Target(0)) || !r.state.focus.same(r.Target(0)) {
210 t.Fatal("second press stole capture/focus")
211 }
212 pointer("release", secondary, 22)
213 if !r.state.down {
214 t.Fatal("second release ended capture")
215 }
216 pointer("release", pointerPrimary, 2)
217 r.Build(view)
218 if a != 1 || b != 0 || r.state.down {
219 t.Fatalf("primary completion: %d %d", a, b)
220 }
221 pointer("press", secondary, 22)
222 r.Build(view)
223 if !r.state.capture.same(r.Target(1)) || r.state.focus == nil || !r.state.focus.same(r.Target(0)) {
224 t.Fatal("secondary capture changed focus")
225 }
226 pointer("release", pointerPrimary, 22)
227 if !r.state.down {
228 t.Fatal("wrong release ended secondary capture")
229 }
230 pointer("release", secondary, 22)
231 r.Build(view)
232 if b != 0 || r.state.down {
233 t.Fatal("secondary activated or remained captured")
234 }
235}
236
237// The cursor follows the hovered element's CSS cursor, stays on the captured
238// element while a primary button is held, and resets outside the surface.
239func TestCursorFollowsHoverAndCapture(t *testing.T) {
240 r := newRuntime()
241 r.styles = css.Compile(css.UA(), css.Parse(`button,input{height:20px;width:100px}`))
242 name := ""
243 view := func(f *Frame) {
244 root := f.Root()
245 root.Button("go", Key("go"))
246 root.Input("name", &name, Key("name"))
247 root.Button("off", Key("off"), Disabled(true))
248 root.Text("plain", Key("plain"))
249 }
250 r.Build(view)
251 at := func(kind string, y float64) css.Keyword {
252 r.route(platformInput{Kind: kind, Button: pointerPrimary, X: 2, Y: y})
253 return r.cursor()
254 }
255 if c := r.cursor(); c != css.KeywordDefault {
256 t.Fatalf("outside: %v", c)
257 }
258 for _, c := range []struct {
259 y float64
260 want uint32
261 }{{2, wayland.CursorPointer}, {22, wayland.CursorText}, {42, wayland.CursorNotAllowed}, {62, wayland.CursorDefault}} {
262 if got := cursorShape(at("motion", c.y)); got != c.want {
263 t.Errorf("y=%v shape %v, want %v", c.y, got, c.want)
264 }
265 }
266 if cursorShape(css.KeywordAuto) != cursorShape(css.KeywordDefault) {
267 t.Error("auto must map to the default shape")
268 }
269 // Press on the input, drag over the button: the input keeps its cursor.
270 at("press", 22)
271 r.Build(view)
272 if got := at("motion", 2); got != css.KeywordText {
273 t.Errorf("captured drag cursor %v", got)
274 }
275 at("release", 2)
276 r.Build(view)
277 if got := r.cursor(); got != css.KeywordPointer {
278 t.Errorf("after release cursor %v", got)
279 }
280 r.route(platformInput{Kind: "leave"})
281 if got := r.cursor(); got != css.KeywordDefault {
282 t.Errorf("after leave cursor %v", got)
283 }
284}
285
286// A control declared before the one whose event changes the model painted the
287// old value during the event frame. The settle frame must show the new value
288// without further input (the demo menu kept its old active item until the
289// pointer moved).
290func TestEventSettlesEarlierControls(t *testing.T) {
291 r := newRuntime()
292 r.styles = css.Compile(css.UA(), css.Parse(`button{height:20px} button.active{background:#ff0000}`))
293 page := "a"
294 view := func(f *Frame) {
295 root := f.Root()
296 for _, p := range []string{"a", "b"} {
297 opts := []ButtonOption{Key(p)}
298 if page == p {
299 opts = append(opts, Class("active"))
300 }
301 if root.Button(p, opts...).Activated() {
302 page = p
303 }
304 }
305 }
306 active := func() string {
307 for _, c := range r.output.Display {
308 if c.Op == "rect" && c.Color.R == 1 && c.Color.G == 0 {
309 return c.ID
310 }
311 }
312 return ""
313 }
314 r.Build(view)
315 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 22})
316 r.Build(view)
317 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 22})
318 r.Build(view)
319 if page != "b" {
320 t.Fatalf("page %q", page)
321 }
322 if !r.Build(view) {
323 t.Fatal("no settle frame after activation")
324 }
325 if got := active(); got != "root/k1:b" {
326 t.Fatalf("active item painted as %q after settle", got)
327 }
328 if r.Build(view) {
329 t.Fatal("settle frame scheduled another frame")
330 }
331}
332
333func TestKeyboardActivationLifecycle(t *testing.T) {
334 r := newRuntime()
335 r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px} button:active {background:red}`))
336 count := 0
337 view := func(f *Frame) {
338 root := f.Root()
339 if root.Button("go", Key("go")).Activated() {
340 count++
341 }
342 root.Button("other", Key("other"))
343 }
344 r.Build(view)
345 r.route(key("Tab"))
346 r.Build(view)
347 repeat := key("Return")
348 repeat.Key.Repeat = true
349 r.route(key("Return"))
350 r.route(repeat)
351 r.Build(view)
352 if count != 1 {
353 t.Fatal("Enter repeat activated", count)
354 }
355 space := key("space")
356 spaceRepeat := space
357 spaceRepeat.Key.Repeat = true
358 r.route(space)
359 r.route(spaceRepeat)
360 r.Build(view)
361 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362 t.Fatal("space press should only set active")
363 }
364 r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
365 r.Build(view)
366 if count != 2 || r.state.space != nil {
367 t.Fatal("space release should activate once", count)
368 }
369 r.route(space)
370 r.route(key("Escape"))
371 r.Build(view)
372 r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
373 if r.Build(view) {
374 t.Fatal("cancelled release redrew")
375 }
376 if count != 2 {
377 t.Fatal("Escape failed to cancel")
378 }
379 r.route(space)
380 r.route(platformInput{Kind: "focus-out"})
381 r.Build(view)
382 r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
383 if r.Build(view) || count != 2 {
384 t.Fatal("focus loss failed to cancel")
385 }
386 r.route(key("Tab"))
387 r.Build(view)
388 r.route(space)
389 r.route(key("Tab"))
390 r.Build(view)
391 r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
392 if r.Build(view) || count != 2 {
393 t.Fatal("focus change failed to cancel")
394 }
395}
396
397func TestScrollIdentityStopsBeingScrollable(t *testing.T) {
398 r := newRuntime()
399 r.styles = css.Compile(css.UA(), css.Parse(`scroll, box {width:40px;height:20px} text {height:40px}`))
400 scroll := true
401 view := func(f *Frame) {
402 root := f.Root()
403 var n Node
404 if scroll {
405 n = root.Scroll(Key("same"))
406 } else {
407 n = root.Box(Key("same"))
408 }
409 n.Text("content")
410 }
411 r.Build(view)
412 r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 15})
413 r.Build(view)
414 id := idKey(r.Target(0))
415 if r.state.scroll[id].H != 15 {
416 t.Fatal("scroll not stored")
417 }
418 scroll = false
419 r.Redraw()
420 r.Build(view)
421 if _, ok := r.state.scroll[id]; ok {
422 t.Fatal("non-scroll container retained offset")
423 }
424 scroll = true
425 r.Redraw()
426 r.Build(view)
427 if r.output.Tree.Children[0].ScrollY != 0 {
428 t.Fatal("stale offset restored")
429 }
430}
431
432func TestKeyedReorderKeepsInteractionState(t *testing.T) {
433 r := newRuntime()
434 r.styles = css.Compile(css.UA(), css.Parse(`scroll {height:20px} button {height:30px}`))
435 order := []string{"a", "b"}
436 view := func(f *Frame) {
437 root := f.Root()
438 for _, k := range order {
439 s := root.Scroll(Key(k))
440 s.Button(k, Key("control"))
441 }
442 }
443 r.Build(view)
444 r.route(platformInput{Kind: "motion", X: 2, Y: 2})
445 r.route(platformInput{Kind: "axis", X: 2, Y: 2, DY: 10})
446 r.route(key("Tab"))
447 r.Build(view)
448 hover, focus := r.state.hover, r.state.focus
449 scrollID := idKey(r.Target(0))
450 if r.state.scroll[scrollID].H != 10 {
451 t.Fatal("scroll state")
452 }
453 order = []string{"b", "a"}
454 r.Redraw()
455 r.Build(view)
456 if !r.state.focus.same(focus) || r.state.scroll[scrollID].H != 10 {
457 t.Fatal("keyed focus/scroll lost")
458 }
459 if r.state.hover.same(hover) || !r.state.hover.same(r.Target(0, 0)) {
460 t.Fatal("hover should track pointer over reordered layout")
461 }
462 r.route(platformInput{Kind: "motion", X: 2, Y: 32})
463 r.Build(view)
464 if !r.state.hover.same(hover) {
465 t.Fatal("hover failed to follow keyed element at new position")
466 }
467}
468
469func TestInputDuringBuildTargetsLastCommit(t *testing.T) {
470 r := newRuntime()
471 r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`))
472 r.Build(func(f *Frame) { f.Root().Button("old", Key("old")) })
473 r.Redraw()
474 entered, release := make(chan struct{}), make(chan struct{})
475 done := make(chan bool, 1)
476 go func() {
477 done <- r.Build(func(f *Frame) { f.Root().Button("new", Key("new")); close(entered); <-release })
478 }()
479 waitEntered(t, entered)
480 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2})
481 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2})
482 if len(r.pending) != 1 || !r.pending[0].Target.same(r.Target(0)) {
483 t.Fatal("in-flight input missed last committed target")
484 }
485 close(release)
486 if !<-done {
487 t.Fatal("build failed")
488 }
489 received := false
490 r.Build(func(f *Frame) { received = f.Root().Button("old", Key("old")).Activated() })
491 if !received {
492 t.Fatal("event lost during build")
493 }
494}
495
496func TestPhysicalToLogicalBoundary(t *testing.T) {
497 for _, scale := range []float64{1.25, 1.5, 2} {
498 x, y, ok := physicalToLogical(20*scale, 10*scale, scale)
499 if !ok || x != 20 || y != 10 {
500 t.Fatalf("scale %v: %v %v %v", scale, x, y, ok)
501 }
502 }
503 if _, _, ok := physicalToLogical(2, 2, 0); ok {
504 t.Fatal("zero scale")
505 }
506}
507
508func TestPointerLeaveClearsHoverKeepsCapture(t *testing.T) {
509 r := newRuntime()
510 r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px;background:blue} button:hover {background:red}`))
511 activations := 0
512 view := func(f *Frame) {
513 if f.Root().Button("go", Key("go")).Activated() {
514 activations++
515 }
516 }
517 r.Build(view)
518 r.route(platformInput{Kind: "motion", X: 2, Y: 2})
519 r.Build(view)
520 if !r.state.inside || !r.state.hover.same(r.Target(0)) || r.committed.children[0].computed.Style.BackgroundColor.R != 1 {
521 t.Fatal("hover not applied")
522 }
523 r.route(platformInput{Kind: "leave"})
524 r.Build(view)
525 if r.state.inside || r.state.hover != nil || r.committed.children[0].computed.Style.BackgroundColor.B != 1 {
526 t.Fatal("leave did not clear hover style")
527 }
528 r.route(platformInput{Kind: "leave"})
529 if r.Build(view) {
530 t.Fatal("duplicate leave invalidated")
531 }
532 r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2})
533 r.Build(view)
534 if !r.state.down || !r.state.capture.same(r.Target(0)) {
535 t.Fatal("no capture")
536 }
537 r.route(platformInput{Kind: "leave"})
538 r.Build(view)
539 if r.state.inside || r.state.hover != nil || !r.state.down || !r.state.capture.same(r.Target(0)) {
540 t.Fatal("leave cancelled capture or kept hover")
541 }
542 r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2})
543 r.Build(view)
544 if activations != 0 || r.state.down || r.state.capture != nil || r.state.inside || r.state.hover != nil {
545 t.Fatal("outside release activated or kept capture/hover")
546 }
547 if r.Build(view) {
548 t.Fatal("idle redraw after leave/release")
549 }
550}