1package ui
2
3import (
4 "math"
5 "slices"
6
7 "github.com/bnema/nefergui/internal/css"
8 "github.com/bnema/nefergui/internal/layout"
9)
10
11// paintIndicators adds checkbox and radio indicators and slider tracks after
12// layout. They sit in the room the UA sheet reserves (checkbox and radio
13// padding-left, slider padding-bottom), are sized from font-size and colored
14// from accent-color and color, so author CSS drives them like any other paint.
15func (r *runtime) paintIndicators(root *element, out *layout.Output) {
16 var visit func(*element)
17 visit = func(e *element) {
18 if e == nil {
19 return
20 }
21 if (e.typ == "checkbox" || e.typ == "radio" || e.typ == "slider") && e.computed != nil {
22 id := idKey(e.identity)
23 if n := resultByID(out.Tree, id); n != nil {
24 insertOwnCommands(out, id, indicatorCommands(e, n, e.computed.Style))
25 }
26 }
27 for _, child := range e.children {
28 visit(child)
29 }
30 }
31 visit(root)
32}
33
34// indicatorCommands returns the paint for one control. The indicator is one
35// em square (16px when font-size is unset).
36func indicatorCommands(e *element, n *layout.Result, s *css.Style) []layout.Command {
37 size := s.FontSize.Value
38 if size <= 0 {
39 size = 16
40 }
41 base := layout.Command{ID: n.ID, Opacity: math.Min(1, math.Max(0, s.Opacity))}
42 muted := s.Color
43 muted.A *= 0.45
44 if e.typ == "slider" {
45 return sliderCommands(e, n, base, size, s.AccentColor, muted)
46 }
47 // Center the box in the reserved left padding, and vertically on the
48 // content box so it lines up with the label.
49 left := n.Border.X + n.BorderWidths.Left
50 x := left + math.Max(0, (n.Padding.Left-size)/2)
51 y := n.Content.Y + (n.Content.H-size)/2
52 box := layout.Rect{X: x, Y: y, W: size, H: size}
53 radius := size * 0.2
54 if e.typ == "radio" {
55 radius = size / 2
56 }
57 radii := [4]float64{radius, radius, radius, radius}
58 if !e.checked {
59 w := math.Max(1, math.Round(size/10))
60 cmd := base
61 // The renderer multiplies side alpha by Color alpha: keep Color opaque
62 // so the sides carry the muted alpha once.
63 opaque := muted
64 opaque.A = 1
65 cmd.Op, cmd.Rect, cmd.Radii, cmd.Color = "border", box, radii, opaque
66 cmd.Widths = layout.Edges{Top: w, Right: w, Bottom: w, Left: w}
67 cmd.Colors = css.ColorSides{Top: muted, Right: muted, Bottom: muted, Left: muted}
68 return []layout.Command{cmd}
69 }
70 fill := base
71 fill.Op, fill.Rect, fill.Radii, fill.Color = "rect", box, radii, s.AccentColor
72 // A contrasting inner mark: a dot for radios, a smaller square for
73 // checkboxes (the renderer paints rounded boxes, not glyph paths).
74 inner := size * 0.4
75 if e.typ == "checkbox" {
76 inner = size * 0.5
77 }
78 mark := base
79 markRadius := inner / 2
80 if e.typ == "checkbox" {
81 markRadius = inner * 0.2
82 }
83 mark.Op, mark.Color = "rect", css.Color{R: 1, G: 1, B: 1, A: 1}
84 mark.Rect = layout.Rect{X: x + (size-inner)/2, Y: y + (size-inner)/2, W: inner, H: inner}
85 mark.Radii = [4]float64{markRadius, markRadius, markRadius, markRadius}
86 return []layout.Command{fill, mark}
87}
88
89// sliderCommands paints the track under the label, in the reserved bottom
90// padding, across the content width the pointer maps to. The filled part and
91// the thumb follow the value fraction.
92func sliderCommands(e *element, n *layout.Result, base layout.Command, size float64, accent, muted css.Color) []layout.Command {
93 // Without reserved bottom padding there is no room: paint nothing rather
94 // than outside the control's box.
95 band := n.Padding.Bottom
96 if band <= 0 {
97 return nil
98 }
99 centerY := n.Content.Y + n.Content.H + band/2
100 trackH := math.Max(2, math.Round(size/4))
101 track := layout.Rect{X: n.Content.X, Y: centerY - trackH/2, W: n.Content.W, H: trackH}
102 radii := [4]float64{trackH / 2, trackH / 2, trackH / 2, trackH / 2}
103 rail := base
104 rail.Op, rail.Rect, rail.Radii, rail.Color = "rect", track, radii, muted
105 commands := []layout.Command{rail}
106 if e.fraction < 0 {
107 return commands
108 }
109 x := track.X + e.fraction*track.W
110 if filled := x - track.X; filled > 0 {
111 fill := base
112 fill.Op, fill.Radii, fill.Color = "rect", radii, accent
113 fill.Rect = layout.Rect{X: track.X, Y: track.Y, W: filled, H: track.H}
114 commands = append(commands, fill)
115 }
116 d := math.Min(size, band)
117 thumb := base
118 thumb.Op, thumb.Color = "rect", accent
119 thumb.Rect = layout.Rect{X: x - d/2, Y: centerY - d/2, W: d, H: d}
120 thumb.Radii = [4]float64{d / 2, d / 2, d / 2, d / 2}
121 return append(commands, thumb)
122}
123
124// insertOwnCommands places padding paint after the element's box paint and
125// before its content: at its clip-push when it clips (the clip is the content
126// box, which would hide the padding), otherwise after its last command.
127func insertOwnCommands(out *layout.Output, id string, commands []layout.Command) {
128 at := -1
129 for i := range out.Display {
130 if out.Display[i].ID != id {
131 continue
132 }
133 if out.Display[i].Op == "clip-push" {
134 at = i
135 break
136 }
137 at = i + 1
138 }
139 insertAt(out, at, commands)
140}
141
142// insertClippedCommands places content decorations inside the element's clip,
143// after its text and before its clip-pop. Editors always clip.
144func insertClippedCommands(out *layout.Output, id string, commands []layout.Command) {
145 for i := range out.Display {
146 if out.Display[i].ID == id && out.Display[i].Op == "clip-pop" {
147 insertAt(out, i, commands)
148 return
149 }
150 }
151}
152func insertAt(out *layout.Output, at int, commands []layout.Command) {
153 if at < 0 || len(commands) == 0 {
154 return
155 }
156 out.Display = slices.Insert(out.Display, at, commands...)
157}