// SPDX-License-Identifier: Unlicense OR MIT // Material Design widget styling for Gio. // Simplified MVP: Theme, ButtonStyle, Label, background fill. package gio import ( "image" "image/color" ) // MaterialPalette holds the standard Material color palette. type MaterialPalette struct { // Bg is the window background color. Bg color.NRGBA // Fg is the primary text/content color. Fg color.NRGBA // ContrastBg is used for interactive elements (buttons, etc.). ContrastBg color.NRGBA // ContrastFg is text/icons on top of ContrastBg. ContrastFg color.NRGBA // Surface is card/surface background. Surface color.NRGBA // Error is the error color. Error color.NRGBA } // Theme holds the visual style for an application. type Theme struct { Palette MaterialPalette TextSize float32 // base text size in CSS px Face string // CSS font family Shaper Shaper } // NewMaterialTheme creates a default Material theme. func NewMaterialTheme(shaper Shaper) *Theme { return &Theme{ Palette: MaterialPalette{ Bg: nrgba(0xFFFFFF), Fg: nrgba(0x000000), ContrastBg: nrgba(0x3F51B5), ContrastFg: nrgba(0xFFFFFF), Surface: nrgba(0xFFFFFF), Error: nrgba(0xB00020), }, TextSize: 16, Face: "sans-serif", Shaper: shaper, } } // nrgba converts a 0xRRGGBB or 0xAARRGGBB hex value to color.NRGBA. func nrgba(c uint32) color.NRGBA { if c <= 0xFFFFFF { return color.NRGBA{ A: 0xFF, R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c), } } return color.NRGBA{ A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c), } } // withAlpha returns a color with the alpha component replaced. func withAlpha(c color.NRGBA, alpha uint8) color.NRGBA { c.A = alpha return c } // blendColors blends foreground over background at the given ratio (0–255). func blendColors(bg, fg color.NRGBA, ratio uint8) color.NRGBA { inv := 255 - int(ratio) r := int(ratio) return color.NRGBA{ R: uint8((int(fg.R)*r + int(bg.R)*inv) / 255), G: uint8((int(fg.G)*r + int(bg.G)*inv) / 255), B: uint8((int(fg.B)*r + int(bg.B)*inv) / 255), A: uint8((int(fg.A)*r + int(bg.A)*inv) / 255), } } // hovered returns a lightened/dimmed version of the color for hover state. func hoveredColor(c color.NRGBA) color.NRGBA { return blendColors(c, nrgba(0xFFFFFF), 30) } // pressed returns a darkened version for press state. func pressedColor(c color.NRGBA) color.NRGBA { return blendColors(c, nrgba(0x000000), 30) } // --- ButtonStyle --- // ButtonStyle renders a Material-style button. type ButtonStyle struct { Text string TextColor color.NRGBA Font Font TextSize float32 BgColor color.NRGBA CornerRadius int // pixels PadX int // horizontal padding pixels PadY int // vertical padding pixels Button *Clickable } // MaterialButton creates a ButtonStyle with the theme's colors. func MaterialButton(th *Theme, button *Clickable, txt string) ButtonStyle { return ButtonStyle{ Text: txt, TextColor: th.Palette.ContrastFg, Font: Font{Family: th.Face}, TextSize: th.TextSize * 14.0 / 16.0, BgColor: th.Palette.ContrastBg, CornerRadius: 12, PadX: 12, PadY: 10, Button: button, } } // Layout renders the button, registers the click area, and returns dimensions. // Check b.Button.Clicked() after Layout to detect clicks. func (b *ButtonStyle) Layout(gtx GioContext) Dimensions { if gtx.Shaper == nil { return Dimensions{} } physSize := b.TextSize tw, th := gtx.Shaper.MeasureLine(b.Font, physSize, b.Text) w := int(tw) + 2*b.PadX h := int(th) + 2*b.PadY if max := gtx.Constraints.Max.X; w > max { w = max } if max := gtx.Constraints.Max.Y; h > max { h = max } // Wrap drawing in Button.Layout so the Clickable registers its hit area. return b.Button.Layout(gtx, func(gtx GioContext) Dimensions { rect := image.Rect(0, 0, w, h) bg := b.BgColor if b.Button.Pressed() { bg = pressedColor(bg) } else if b.Button.Hovered() { bg = hoveredColor(bg) } clipStack := UniformClipRRect(rect, b.CornerRadius).Push(gtx.Ops) Fill(gtx.Ops, bg) textX := (w - int(tw)) / 2 textY := (h - int(th)) / 2 DrawTextAt(gtx, b.Font, physSize, b.Text, textX, textY, w-2*b.PadX) clipStack.Pop() return Dimensions{Size: image.Pt(w, h)} }) } // --- LabelStyle --- // LabelStyle renders text with a given color and size. type LabelStyle struct { Text string Color color.NRGBA Font Font TextSize float32 MaxLines int // 0 = unlimited } // MaterialLabel creates a LabelStyle with the theme's text settings. func MaterialLabel(th *Theme, txt string) LabelStyle { return LabelStyle{ Text: txt, Color: th.Palette.Fg, Font: Font{Family: th.Face}, TextSize: th.TextSize, } } // Layout renders the label and returns dimensions. func (l LabelStyle) Layout(gtx GioContext) Dimensions { if gtx.Shaper == nil || l.Text == "" { return Dimensions{} } physSize := l.TextSize // logical font size; DPR handled by canvas scaling maxW := gtx.Constraints.Max.X img := gtx.Shaper.RenderToImage(l.Font, physSize, l.Text, int32(maxW)) if img == nil { return Dimensions{} } tintImage(img.Pix, l.Color) sz := img.Bounds().Size() clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops) NewPaintImageOp(img).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) clip.Pop() return Dimensions{Size: sz} } // tintImage recolors non-transparent text pixels to the given color. // The canvas renders black text on transparent; we replace the RGB with tint color. func tintImage(pix []uint8, tint color.NRGBA) { for i := 0; i+3 < len(pix); i += 4 { a := pix[i+3] if a == 0 { continue // transparent background, skip } // Replace white text with tint color, preserve alpha. pix[i+0] = tint.R pix[i+1] = tint.G pix[i+2] = tint.B // alpha unchanged } } // --- Background fill helper --- // FillBackground fills the constraint region with a solid color. func FillBackground(gtx GioContext, c color.NRGBA) { FillRect(gtx.Ops, image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y), c) } // --- CheckboxStyle --- // CheckboxStyle renders a Bool as a Material checkbox. type CheckboxStyle struct { Label string Color color.NRGBA Font Font TextSize float32 Bool *Bool } // MaterialCheckbox creates a CheckboxStyle from the theme. func MaterialCheckbox(th *Theme, b *Bool, label string) CheckboxStyle { return CheckboxStyle{ Label: label, Color: th.Palette.ContrastBg, Font: Font{Family: th.Face}, TextSize: th.TextSize, Bool: b, } } // Layout renders the checkbox + label and processes clicks. func (c *CheckboxStyle) Layout(gtx GioContext) Dimensions { const boxSize = 18 const gap = 6 rect := image.Rect(0, 0, boxSize, boxSize) c.Bool.Layout(gtx, func(gtx GioContext) Dimensions { return Dimensions{Size: image.Pt(boxSize, boxSize)} }) // Box outline. outline := image.Rect(0, 0, boxSize, boxSize) FillRect(gtx.Ops, outline, color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF}) inner := image.Rect(1, 1, boxSize-1, boxSize-1) FillRect(gtx.Ops, inner, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) if c.Bool.Value { // Filled box with check mark colors. FillRect(gtx.Ops, image.Rect(2, 2, boxSize-2, boxSize-2), c.Color) } _ = rect // Label. totalW := boxSize + gap if gtx.Shaper != nil && c.Label != "" { textGtx := gtx textGtx.Constraints = Constraints{Max: image.Pt( gtx.Constraints.Max.X-totalW, gtx.Constraints.Max.Y, )} stack := OpOffset(image.Pt(totalW, (boxSize-int(c.TextSize))/2)).Push(gtx.Ops) tw, _ := gtx.Shaper.MeasureLine(c.Font, c.TextSize, c.Label) img := gtx.Shaper.RenderToImage(c.Font, c.TextSize, c.Label, int32(textGtx.Constraints.Max.X)) if img != nil { tintImage(img.Pix, color.NRGBA{A: 0xFF}) isz := img.Bounds().Size() iclip := ClipRect(image.Rect(0, 0, isz.X, isz.Y)).Push(gtx.Ops) NewPaintImageOp(img).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) iclip.Pop() } stack.Pop() totalW += int(tw) + gap } h := boxSize if int(c.TextSize) > h { h = int(c.TextSize) } return Dimensions{Size: image.Pt(totalW, h)} } // --- SwitchStyle --- // SwitchStyle renders a Bool as a Material switch toggle. type SwitchStyle struct { Color color.NRGBA Bool *Bool } // MaterialSwitch creates a SwitchStyle from the theme. func MaterialSwitch(th *Theme, b *Bool) SwitchStyle { return SwitchStyle{Color: th.Palette.ContrastBg, Bool: b} } // Layout renders the switch and processes clicks. func (s *SwitchStyle) Layout(gtx GioContext) Dimensions { const trackW, trackH = 36, 14 const thumbD = 20 const thumbOff = (thumbD - trackH) / 2 rect := image.Rect(0, 0, trackW, thumbD) s.Bool.Layout(gtx, func(gtx GioContext) Dimensions { return Dimensions{Size: image.Pt(trackW, thumbD)} }) // Track. trackColor := color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF} if s.Bool.Value { trackColor = withAlpha(s.Color, 0x80) } trackRect := image.Rect(0, thumbOff, trackW, thumbOff+trackH) trackClip := UniformClipRRect(trackRect, trackH/2).Push(gtx.Ops) Fill(gtx.Ops, trackColor) trackClip.Pop() _ = rect // Thumb. thumbX := 0 if s.Bool.Value { thumbX = trackW - thumbD } thumbColor := color.NRGBA{R: 0xFA, G: 0xFA, B: 0xFA, A: 0xFF} if s.Bool.Value { thumbColor = s.Color } thumbRect := image.Rect(thumbX, 0, thumbX+thumbD, thumbD) clipStack := ClipEllipse(thumbRect).Push(gtx.Ops) FillRect(gtx.Ops, thumbRect, thumbColor) clipStack.Pop() return Dimensions{Size: image.Pt(trackW, thumbD)} } // --- SliderStyle --- // SliderStyle renders a Float as a Material slider. type SliderStyle struct { Color color.NRGBA Float *Float } // MaterialSlider creates a SliderStyle from the theme. func MaterialSlider(th *Theme, f *Float) SliderStyle { return SliderStyle{Color: th.Palette.ContrastBg, Float: f} } // Layout renders the slider track + thumb and processes drag events. func (s *SliderStyle) Layout(gtx GioContext) Dimensions { const trackH = 4 const thumbD = 16 sz := gtx.Constraints.Max if sz.X < thumbD { sz.X = thumbD } h := thumbD // Float.Layout processes drag. s.Float.Layout(gtx, func(gtx GioContext) Dimensions { return Dimensions{Size: image.Pt(sz.X, h)} }) // Track background. trackY := (h - trackH) / 2 FillRect(gtx.Ops, image.Rect(0, trackY, sz.X, trackY+trackH), color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF}) // Track fill (from left to thumb). span := s.Float.Max - s.Float.Min t := float32(0) if span > 0 { t = (s.Float.Value - s.Float.Min) / span } fillW := int(t * float32(sz.X)) if fillW > 0 { FillRect(gtx.Ops, image.Rect(0, trackY, fillW, trackY+trackH), s.Color) } // Thumb. thumbX := fillW - thumbD/2 if thumbX < 0 { thumbX = 0 } if thumbX+thumbD > sz.X { thumbX = sz.X - thumbD } thumbRect := image.Rect(thumbX, 0, thumbX+thumbD, thumbD) clipStack := ClipEllipse(thumbRect).Push(gtx.Ops) FillRect(gtx.Ops, thumbRect, s.Color) clipStack.Pop() return Dimensions{Size: image.Pt(sz.X, h)} } // --- ProgressBarStyle --- // ProgressBarStyle renders a linear progress indicator. type ProgressBarStyle struct { Progress float32 // 0.0 to 1.0 Color color.NRGBA Height int } // MaterialProgressBar creates a ProgressBarStyle from the theme. func MaterialProgressBar(th *Theme, progress float32) ProgressBarStyle { return ProgressBarStyle{ Progress: progress, Color: th.Palette.ContrastBg, Height: 4, } } // Layout renders the progress bar. func (p ProgressBarStyle) Layout(gtx GioContext) Dimensions { w := gtx.Constraints.Max.X h := p.Height if h <= 0 { h = 4 } FillRect(gtx.Ops, image.Rect(0, 0, w, h), color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF}) t := p.Progress if t < 0 { t = 0 } if t > 1 { t = 1 } fillW := int(t * float32(w)) if fillW > 0 { FillRect(gtx.Ops, image.Rect(0, 0, fillW, h), p.Color) } return Dimensions{Size: image.Pt(w, h)} } // --- Column/Row layout helpers --- // Column lays out widgets vertically, top-to-bottom. // Returns total dimensions. func Column(gtx GioContext, spacing int, widgets ...GioWidget) Dimensions { y := 0 maxW := 0 for _, w := range widgets { inner := gtx.WithConstraints(Constraints{ Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y-y), }) stack := OpOffset(image.Pt(0, y)).Push(gtx.Ops) dims := w(inner) stack.Pop() y += dims.Size.Y + spacing if dims.Size.X > maxW { maxW = dims.Size.X } } if y > spacing { y -= spacing } return Dimensions{Size: image.Pt(maxW, y)} } // Row lays out widgets horizontally, left-to-right. // Returns total dimensions. func Row(gtx GioContext, spacing int, widgets ...GioWidget) Dimensions { x := 0 maxH := 0 for _, w := range widgets { inner := gtx.WithConstraints(Constraints{ Max: image.Pt(gtx.Constraints.Max.X-x, gtx.Constraints.Max.Y), }) stack := OpOffset(image.Pt(x, 0)).Push(gtx.Ops) dims := w(inner) stack.Pop() x += dims.Size.X + spacing if dims.Size.Y > maxH { maxH = dims.Size.Y } } if x > spacing { x -= spacing } return Dimensions{Size: image.Pt(x, maxH)} }