material.mx raw
1 // SPDX-License-Identifier: Unlicense OR MIT
2
3 // Material Design widget styling for Gio.
4 // Simplified MVP: Theme, ButtonStyle, Label, background fill.
5
6 package gio
7
8 import (
9 "image"
10 "image/color"
11 )
12
13 // MaterialPalette holds the standard Material color palette.
14 type MaterialPalette struct {
15 // Bg is the window background color.
16 Bg color.NRGBA
17 // Fg is the primary text/content color.
18 Fg color.NRGBA
19 // ContrastBg is used for interactive elements (buttons, etc.).
20 ContrastBg color.NRGBA
21 // ContrastFg is text/icons on top of ContrastBg.
22 ContrastFg color.NRGBA
23 // Surface is card/surface background.
24 Surface color.NRGBA
25 // Error is the error color.
26 Error color.NRGBA
27 }
28
29 // Theme holds the visual style for an application.
30 type Theme struct {
31 Palette MaterialPalette
32 TextSize float32 // base text size in CSS px
33 Face string // CSS font family
34 Shaper Shaper
35 }
36
37 // NewMaterialTheme creates a default Material theme.
38 func NewMaterialTheme(shaper Shaper) *Theme {
39 return &Theme{
40 Palette: MaterialPalette{
41 Bg: nrgba(0xFFFFFF),
42 Fg: nrgba(0x000000),
43 ContrastBg: nrgba(0x3F51B5),
44 ContrastFg: nrgba(0xFFFFFF),
45 Surface: nrgba(0xFFFFFF),
46 Error: nrgba(0xB00020),
47 },
48 TextSize: 16,
49 Face: "sans-serif",
50 Shaper: shaper,
51 }
52 }
53
54 // nrgba converts a 0xRRGGBB or 0xAARRGGBB hex value to color.NRGBA.
55 func nrgba(c uint32) color.NRGBA {
56 if c <= 0xFFFFFF {
57 return color.NRGBA{
58 A: 0xFF,
59 R: uint8(c >> 16),
60 G: uint8(c >> 8),
61 B: uint8(c),
62 }
63 }
64 return color.NRGBA{
65 A: uint8(c >> 24),
66 R: uint8(c >> 16),
67 G: uint8(c >> 8),
68 B: uint8(c),
69 }
70 }
71
72 // withAlpha returns a color with the alpha component replaced.
73 func withAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
74 c.A = alpha
75 return c
76 }
77
78 // blendColors blends foreground over background at the given ratio (0–255).
79 func blendColors(bg, fg color.NRGBA, ratio uint8) color.NRGBA {
80 inv := 255 - int(ratio)
81 r := int(ratio)
82 return color.NRGBA{
83 R: uint8((int(fg.R)*r + int(bg.R)*inv) / 255),
84 G: uint8((int(fg.G)*r + int(bg.G)*inv) / 255),
85 B: uint8((int(fg.B)*r + int(bg.B)*inv) / 255),
86 A: uint8((int(fg.A)*r + int(bg.A)*inv) / 255),
87 }
88 }
89
90 // hovered returns a lightened/dimmed version of the color for hover state.
91 func hoveredColor(c color.NRGBA) color.NRGBA {
92 return blendColors(c, nrgba(0xFFFFFF), 30)
93 }
94
95 // pressed returns a darkened version for press state.
96 func pressedColor(c color.NRGBA) color.NRGBA {
97 return blendColors(c, nrgba(0x000000), 30)
98 }
99
100 // --- ButtonStyle ---
101
102 // ButtonStyle renders a Material-style button.
103 type ButtonStyle struct {
104 Text string
105 TextColor color.NRGBA
106 Font Font
107 TextSize float32
108 BgColor color.NRGBA
109 CornerRadius int // pixels
110 PadX int // horizontal padding pixels
111 PadY int // vertical padding pixels
112 Button *Clickable
113 }
114
115 // MaterialButton creates a ButtonStyle with the theme's colors.
116 func MaterialButton(th *Theme, button *Clickable, txt string) ButtonStyle {
117 return ButtonStyle{
118 Text: txt,
119 TextColor: th.Palette.ContrastFg,
120 Font: Font{Family: th.Face},
121 TextSize: th.TextSize * 14.0 / 16.0,
122 BgColor: th.Palette.ContrastBg,
123 CornerRadius: 12,
124 PadX: 12,
125 PadY: 10,
126 Button: button,
127 }
128 }
129
130 // Layout renders the button, registers the click area, and returns dimensions.
131 // Check b.Button.Clicked() after Layout to detect clicks.
132 func (b *ButtonStyle) Layout(gtx GioContext) Dimensions {
133 if gtx.Shaper == nil {
134 return Dimensions{}
135 }
136 physSize := b.TextSize
137 tw, th := gtx.Shaper.MeasureLine(b.Font, physSize, b.Text)
138
139 w := int(tw) + 2*b.PadX
140 h := int(th) + 2*b.PadY
141 if max := gtx.Constraints.Max.X; w > max {
142 w = max
143 }
144 if max := gtx.Constraints.Max.Y; h > max {
145 h = max
146 }
147
148 // Wrap drawing in Button.Layout so the Clickable registers its hit area.
149 return b.Button.Layout(gtx, func(gtx GioContext) Dimensions {
150 rect := image.Rect(0, 0, w, h)
151 bg := b.BgColor
152 if b.Button.Pressed() {
153 bg = pressedColor(bg)
154 } else if b.Button.Hovered() {
155 bg = hoveredColor(bg)
156 }
157 clipStack := UniformClipRRect(rect, b.CornerRadius).Push(gtx.Ops)
158 Fill(gtx.Ops, bg)
159 textX := (w - int(tw)) / 2
160 textY := (h - int(th)) / 2
161 DrawTextAt(gtx, b.Font, physSize, b.Text, textX, textY, w-2*b.PadX)
162 clipStack.Pop()
163 return Dimensions{Size: image.Pt(w, h)}
164 })
165 }
166
167 // --- LabelStyle ---
168
169 // LabelStyle renders text with a given color and size.
170 type LabelStyle struct {
171 Text string
172 Color color.NRGBA
173 Font Font
174 TextSize float32
175 MaxLines int // 0 = unlimited
176 }
177
178 // MaterialLabel creates a LabelStyle with the theme's text settings.
179 func MaterialLabel(th *Theme, txt string) LabelStyle {
180 return LabelStyle{
181 Text: txt,
182 Color: th.Palette.Fg,
183 Font: Font{Family: th.Face},
184 TextSize: th.TextSize,
185 }
186 }
187
188 // Layout renders the label and returns dimensions.
189 func (l LabelStyle) Layout(gtx GioContext) Dimensions {
190 if gtx.Shaper == nil || l.Text == "" {
191 return Dimensions{}
192 }
193 physSize := l.TextSize // logical font size; DPR handled by canvas scaling
194 maxW := gtx.Constraints.Max.X
195 img := gtx.Shaper.RenderToImage(l.Font, physSize, l.Text, int32(maxW))
196 if img == nil {
197 return Dimensions{}
198 }
199 tintImage(img.Pix, l.Color)
200 sz := img.Bounds().Size()
201 clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops)
202 NewPaintImageOp(img).Add(gtx.Ops)
203 PaintPaintOp{}.Add(gtx.Ops)
204 clip.Pop()
205 return Dimensions{Size: sz}
206 }
207
208 // tintImage recolors non-transparent text pixels to the given color.
209 // The canvas renders black text on transparent; we replace the RGB with tint color.
210 func tintImage(pix []uint8, tint color.NRGBA) {
211 for i := 0; i+3 < len(pix); i += 4 {
212 a := pix[i+3]
213 if a == 0 {
214 continue // transparent background, skip
215 }
216 // Replace white text with tint color, preserve alpha.
217 pix[i+0] = tint.R
218 pix[i+1] = tint.G
219 pix[i+2] = tint.B
220 // alpha unchanged
221 }
222 }
223
224 // --- Background fill helper ---
225
226 // FillBackground fills the constraint region with a solid color.
227 func FillBackground(gtx GioContext, c color.NRGBA) {
228 FillRect(gtx.Ops, image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y), c)
229 }
230
231 // --- CheckboxStyle ---
232
233 // CheckboxStyle renders a Bool as a Material checkbox.
234 type CheckboxStyle struct {
235 Label string
236 Color color.NRGBA
237 Font Font
238 TextSize float32
239 Bool *Bool
240 }
241
242 // MaterialCheckbox creates a CheckboxStyle from the theme.
243 func MaterialCheckbox(th *Theme, b *Bool, label string) CheckboxStyle {
244 return CheckboxStyle{
245 Label: label,
246 Color: th.Palette.ContrastBg,
247 Font: Font{Family: th.Face},
248 TextSize: th.TextSize,
249 Bool: b,
250 }
251 }
252
253 // Layout renders the checkbox + label and processes clicks.
254 func (c *CheckboxStyle) Layout(gtx GioContext) Dimensions {
255 const boxSize = 18
256 const gap = 6
257
258 rect := image.Rect(0, 0, boxSize, boxSize)
259 c.Bool.Layout(gtx, func(gtx GioContext) Dimensions {
260 return Dimensions{Size: image.Pt(boxSize, boxSize)}
261 })
262
263 // Box outline.
264 outline := image.Rect(0, 0, boxSize, boxSize)
265 FillRect(gtx.Ops, outline, color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF})
266 inner := image.Rect(1, 1, boxSize-1, boxSize-1)
267 FillRect(gtx.Ops, inner, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
268
269 if c.Bool.Value {
270 // Filled box with check mark colors.
271 FillRect(gtx.Ops, image.Rect(2, 2, boxSize-2, boxSize-2), c.Color)
272 }
273 _ = rect
274
275 // Label.
276 totalW := boxSize + gap
277 if gtx.Shaper != nil && c.Label != "" {
278 textGtx := gtx
279 textGtx.Constraints = Constraints{Max: image.Pt(
280 gtx.Constraints.Max.X-totalW, gtx.Constraints.Max.Y,
281 )}
282 stack := OpOffset(image.Pt(totalW, (boxSize-int(c.TextSize))/2)).Push(gtx.Ops)
283 tw, _ := gtx.Shaper.MeasureLine(c.Font, c.TextSize, c.Label)
284 img := gtx.Shaper.RenderToImage(c.Font, c.TextSize, c.Label, int32(textGtx.Constraints.Max.X))
285 if img != nil {
286 tintImage(img.Pix, color.NRGBA{A: 0xFF})
287 isz := img.Bounds().Size()
288 iclip := ClipRect(image.Rect(0, 0, isz.X, isz.Y)).Push(gtx.Ops)
289 NewPaintImageOp(img).Add(gtx.Ops)
290 PaintPaintOp{}.Add(gtx.Ops)
291 iclip.Pop()
292 }
293 stack.Pop()
294 totalW += int(tw) + gap
295 }
296
297 h := boxSize
298 if int(c.TextSize) > h {
299 h = int(c.TextSize)
300 }
301 return Dimensions{Size: image.Pt(totalW, h)}
302 }
303
304 // --- SwitchStyle ---
305
306 // SwitchStyle renders a Bool as a Material switch toggle.
307 type SwitchStyle struct {
308 Color color.NRGBA
309 Bool *Bool
310 }
311
312 // MaterialSwitch creates a SwitchStyle from the theme.
313 func MaterialSwitch(th *Theme, b *Bool) SwitchStyle {
314 return SwitchStyle{Color: th.Palette.ContrastBg, Bool: b}
315 }
316
317 // Layout renders the switch and processes clicks.
318 func (s *SwitchStyle) Layout(gtx GioContext) Dimensions {
319 const trackW, trackH = 36, 14
320 const thumbD = 20
321 const thumbOff = (thumbD - trackH) / 2
322
323 rect := image.Rect(0, 0, trackW, thumbD)
324 s.Bool.Layout(gtx, func(gtx GioContext) Dimensions {
325 return Dimensions{Size: image.Pt(trackW, thumbD)}
326 })
327
328 // Track.
329 trackColor := color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF}
330 if s.Bool.Value {
331 trackColor = withAlpha(s.Color, 0x80)
332 }
333 trackRect := image.Rect(0, thumbOff, trackW, thumbOff+trackH)
334 trackClip := UniformClipRRect(trackRect, trackH/2).Push(gtx.Ops)
335 Fill(gtx.Ops, trackColor)
336 trackClip.Pop()
337 _ = rect
338
339 // Thumb.
340 thumbX := 0
341 if s.Bool.Value {
342 thumbX = trackW - thumbD
343 }
344 thumbColor := color.NRGBA{R: 0xFA, G: 0xFA, B: 0xFA, A: 0xFF}
345 if s.Bool.Value {
346 thumbColor = s.Color
347 }
348 thumbRect := image.Rect(thumbX, 0, thumbX+thumbD, thumbD)
349 clipStack := ClipEllipse(thumbRect).Push(gtx.Ops)
350 FillRect(gtx.Ops, thumbRect, thumbColor)
351 clipStack.Pop()
352
353 return Dimensions{Size: image.Pt(trackW, thumbD)}
354 }
355
356 // --- SliderStyle ---
357
358 // SliderStyle renders a Float as a Material slider.
359 type SliderStyle struct {
360 Color color.NRGBA
361 Float *Float
362 }
363
364 // MaterialSlider creates a SliderStyle from the theme.
365 func MaterialSlider(th *Theme, f *Float) SliderStyle {
366 return SliderStyle{Color: th.Palette.ContrastBg, Float: f}
367 }
368
369 // Layout renders the slider track + thumb and processes drag events.
370 func (s *SliderStyle) Layout(gtx GioContext) Dimensions {
371 const trackH = 4
372 const thumbD = 16
373
374 sz := gtx.Constraints.Max
375 if sz.X < thumbD {
376 sz.X = thumbD
377 }
378 h := thumbD
379
380 // Float.Layout processes drag.
381 s.Float.Layout(gtx, func(gtx GioContext) Dimensions {
382 return Dimensions{Size: image.Pt(sz.X, h)}
383 })
384
385 // Track background.
386 trackY := (h - trackH) / 2
387 FillRect(gtx.Ops, image.Rect(0, trackY, sz.X, trackY+trackH),
388 color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF})
389
390 // Track fill (from left to thumb).
391 span := s.Float.Max - s.Float.Min
392 t := float32(0)
393 if span > 0 {
394 t = (s.Float.Value - s.Float.Min) / span
395 }
396 fillW := int(t * float32(sz.X))
397 if fillW > 0 {
398 FillRect(gtx.Ops, image.Rect(0, trackY, fillW, trackY+trackH), s.Color)
399 }
400
401 // Thumb.
402 thumbX := fillW - thumbD/2
403 if thumbX < 0 {
404 thumbX = 0
405 }
406 if thumbX+thumbD > sz.X {
407 thumbX = sz.X - thumbD
408 }
409 thumbRect := image.Rect(thumbX, 0, thumbX+thumbD, thumbD)
410 clipStack := ClipEllipse(thumbRect).Push(gtx.Ops)
411 FillRect(gtx.Ops, thumbRect, s.Color)
412 clipStack.Pop()
413
414 return Dimensions{Size: image.Pt(sz.X, h)}
415 }
416
417 // --- ProgressBarStyle ---
418
419 // ProgressBarStyle renders a linear progress indicator.
420 type ProgressBarStyle struct {
421 Progress float32 // 0.0 to 1.0
422 Color color.NRGBA
423 Height int
424 }
425
426 // MaterialProgressBar creates a ProgressBarStyle from the theme.
427 func MaterialProgressBar(th *Theme, progress float32) ProgressBarStyle {
428 return ProgressBarStyle{
429 Progress: progress,
430 Color: th.Palette.ContrastBg,
431 Height: 4,
432 }
433 }
434
435 // Layout renders the progress bar.
436 func (p ProgressBarStyle) Layout(gtx GioContext) Dimensions {
437 w := gtx.Constraints.Max.X
438 h := p.Height
439 if h <= 0 {
440 h = 4
441 }
442
443 FillRect(gtx.Ops, image.Rect(0, 0, w, h),
444 color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF})
445
446 t := p.Progress
447 if t < 0 {
448 t = 0
449 }
450 if t > 1 {
451 t = 1
452 }
453 fillW := int(t * float32(w))
454 if fillW > 0 {
455 FillRect(gtx.Ops, image.Rect(0, 0, fillW, h), p.Color)
456 }
457
458 return Dimensions{Size: image.Pt(w, h)}
459 }
460
461 // --- Column/Row layout helpers ---
462
463 // Column lays out widgets vertically, top-to-bottom.
464 // Returns total dimensions.
465 func Column(gtx GioContext, spacing int, widgets ...GioWidget) Dimensions {
466 y := 0
467 maxW := 0
468 for _, w := range widgets {
469 inner := gtx.WithConstraints(Constraints{
470 Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y-y),
471 })
472 stack := OpOffset(image.Pt(0, y)).Push(gtx.Ops)
473 dims := w(inner)
474 stack.Pop()
475 y += dims.Size.Y + spacing
476 if dims.Size.X > maxW {
477 maxW = dims.Size.X
478 }
479 }
480 if y > spacing {
481 y -= spacing
482 }
483 return Dimensions{Size: image.Pt(maxW, y)}
484 }
485
486 // Row lays out widgets horizontally, left-to-right.
487 // Returns total dimensions.
488 func Row(gtx GioContext, spacing int, widgets ...GioWidget) Dimensions {
489 x := 0
490 maxH := 0
491 for _, w := range widgets {
492 inner := gtx.WithConstraints(Constraints{
493 Max: image.Pt(gtx.Constraints.Max.X-x, gtx.Constraints.Max.Y),
494 })
495 stack := OpOffset(image.Pt(x, 0)).Push(gtx.Ops)
496 dims := w(inner)
497 stack.Pop()
498 x += dims.Size.X + spacing
499 if dims.Size.Y > maxH {
500 maxH = dims.Size.Y
501 }
502 }
503 if x > spacing {
504 x -= spacing
505 }
506 return Dimensions{Size: image.Pt(x, maxH)}
507 }
508