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