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