1package ui
2
3import (
4 "math"
5 "testing"
6
7 "github.com/bnema/nefergui/internal/css"
8 "github.com/bnema/nefergui/internal/layout"
9)
10
11func ownCommands(r *runtime, id string) []layout.Command {
12 var out []layout.Command
13 for _, cmd := range r.output.Display {
14 if cmd.ID == id {
15 out = append(out, cmd)
16 }
17 }
18 return out
19}
20
21func accentRect(cmds []layout.Command, accent css.Color) (layout.Rect, bool) {
22 for _, cmd := range cmds {
23 if cmd.Op == "rect" && cmd.Color == accent {
24 return cmd.Rect, true
25 }
26 }
27 return layout.Rect{}, false
28}
29
30// Checkbox and radio paint an outlined indicator when unchecked and an
31// accent-filled one when checked, inside the reserved left padding; author
32// accent-color and :checked drive them.
33func TestCheckIndicatorsFollowStateAndCSS(t *testing.T) {
34 r := newRuntime()
35 r.styles = css.Compile(css.UA(), css.Parse(`checkbox, radio {width:200px;height:24px;font-size:16px} checkbox:checked {accent-color:#00ff00} radio {accent-color:#ff0000}`))
36 checked, choice := false, ""
37 view := func(f *Frame) {
38 root := f.Root()
39 root.Checkbox("Box", &checked, Key("box"))
40 root.Radio("A", "a", &choice, Key("a"))
41 }
42 green, red := css.Color{G: 1, A: 1}, css.Color{R: 1, A: 1}
43 for _, on := range []bool{false, true} {
44 checked = on
45 choice = map[bool]string{true: "a"}[on]
46 r.Redraw() // the model changed outside NeferGUI's events
47 r.Build(view)
48 for id, accent := range map[string]css.Color{"root/k3:box": green, "root/k1:a": red} {
49 cmds := ownCommands(r, id)
50 n := resultByID(r.output.Tree, id)
51 rect, filled := accentRect(cmds, accent)
52 if filled != on {
53 t.Fatalf("%s checked=%v: accent fill %v in %+v", id, on, filled, cmds)
54 }
55 if !on {
56 found := false
57 for _, cmd := range cmds {
58 if cmd.Op == "border" && cmd.Rect.W == 16 {
59 found, rect = true, cmd.Rect
60 }
61 }
62 if !found {
63 t.Fatalf("%s unchecked: no outlined indicator in %+v", id, cmds)
64 }
65 }
66 if rect.W != 16 || rect.H != 16 || rect.X+rect.W > n.Content.X || rect.X < n.Border.X {
67 t.Fatalf("%s indicator %+v outside the reserved padding (content %+v)", id, rect, n.Content)
68 }
69 }
70 }
71}
72
73// The slider paints a rail across the pointer track, then fill and thumb at
74// the value fraction; the thumb follows the value after a key change.
75func TestSliderTrackFollowsValue(t *testing.T) {
76 r := newRuntime()
77 r.styles = css.Compile(css.UA(), css.Parse(`slider {width:200px;font-size:16px;accent-color:#0000ff}`))
78 v := 2.5
79 view := func(f *Frame) { f.Root().Slider("Volume", &v, 0, 10, 0.5, Key("s")) }
80 r.Build(view)
81 id := "root/k1:s"
82 n := resultByID(r.output.Tree, id)
83 blue := css.Color{B: 1, A: 1}
84 thumbX := func() float64 {
85 t.Helper()
86 var rects []layout.Rect
87 for _, cmd := range ownCommands(r, id) {
88 if cmd.Op == "rect" && cmd.Color == blue {
89 rects = append(rects, cmd.Rect)
90 }
91 }
92 if len(rects) != 2 {
93 t.Fatalf("want fill and thumb, got %+v", rects)
94 }
95 thumb := rects[1]
96 if thumb.Y < n.Content.Y+n.Content.H || thumb.Y+thumb.H > n.Border.Y+n.Border.H {
97 t.Fatalf("thumb %+v outside the reserved bottom band of %+v", thumb, n.Border)
98 }
99 return thumb.X + thumb.W/2
100 }
101 if got, want := thumbX(), n.Content.X+0.25*n.Content.W; got != want {
102 t.Fatalf("thumb center %v, want %v", got, want)
103 }
104 r.route(platformInput{Kind: "press", X: n.Content.X + 0.75*n.Content.W, Y: n.Content.Y + 1, Button: pointerPrimary})
105 r.Build(view)
106 r.Build(view)
107 if v != 7.5 {
108 t.Fatalf("pointer value %v", v)
109 }
110 if got, want := thumbX(), n.Content.X+0.75*n.Content.W; got != want {
111 t.Fatalf("thumb center after press %v, want %v", got, want)
112 }
113}
114
115// Edge cases: a clipping control keeps its padding paint outside the content
116// clip, a slider without bottom padding paints no track, the unchecked
117// outline carries its muted alpha once, disabled checkboxes are dimmed, and
118// extreme finite ranges still give a finite thumb position.
119func TestIndicatorEdgeCases(t *testing.T) {
120 r := newRuntime()
121 r.styles = css.Compile(css.UA(), css.Parse(`checkbox {width:200px;height:24px;font-size:16px;overflow:hidden} slider {width:200px;padding-bottom:0}`))
122 checked, v, off := false, 1.0, false
123 r.Build(func(f *Frame) {
124 root := f.Root()
125 root.Checkbox("Box", &checked, Key("box"))
126 root.Checkbox("Off", &off, Key("off"), Disabled(true))
127 root.Slider("S", &v, 0, 10, 1, Key("s"))
128 })
129 clipped, outlined := false, false
130 for _, cmd := range r.output.Display {
131 if cmd.ID != "root/k3:box" {
132 continue
133 }
134 if cmd.Op == "clip-push" {
135 clipped = true
136 }
137 if cmd.Op == "border" && cmd.Rect.W == 16 {
138 outlined = true
139 if clipped {
140 t.Error("indicator painted inside the content clip")
141 }
142 if cmd.Color.A != 1 || cmd.Colors.Top.A != 0.45 {
143 t.Errorf("outline alpha color=%v side=%v, want opaque base and 0.45 sides", cmd.Color.A, cmd.Colors.Top.A)
144 }
145 }
146 }
147 if !clipped || !outlined {
148 t.Fatalf("clipped checkbox: clip=%v indicator=%v", clipped, outlined)
149 }
150 dimmed := false
151 for _, cmd := range ownCommands(r, "root/k3:off") {
152 if cmd.Op == "border" && cmd.Rect.W == 16 {
153 dimmed = true
154 if cmd.Opacity != 0.5 {
155 t.Errorf("disabled checkbox indicator opacity %v", cmd.Opacity)
156 }
157 }
158 }
159 if !dimmed {
160 t.Error("disabled checkbox indicator missing")
161 }
162 for _, cmd := range ownCommands(r, "root/k1:s") {
163 if cmd.Op == "rect" {
164 t.Errorf("slider without bottom padding painted %+v", cmd)
165 break
166 }
167 }
168 for _, c := range []struct{ v, min, max, want float64 }{
169 {0, -math.MaxFloat64, math.MaxFloat64, 0.5},
170 {math.MaxFloat64, -math.MaxFloat64, math.MaxFloat64, 1},
171 {5, 5, 5, 0},
172 {2.5, 0, 10, 0.25},
173 {math.SmallestNonzeroFloat64, 0, 2 * math.SmallestNonzeroFloat64, 0.5},
174 {math.NaN(), 0, 1, 0},
175 } {
176 if got := sliderFraction(c.v, c.min, c.max); got != c.want {
177 t.Errorf("sliderFraction(%v,%v,%v)=%v want %v", c.v, c.min, c.max, got, c.want)
178 }
179 }
180}