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