Skip to content

Custom themes

A theme is any value that implements bellui.Theme. It has four methods, one for each kind of value a widget asks for:

MethodReturnsLooked up by
Color(name, variant)a color.Colorbellui.ThemeColorName, plus the light or dark variant
Size(name)a float32bellui.ThemeSizeName
Font(style)a font resourcebellui.TextStyle
Icon(name)an icon resourcebellui.ThemeIconName

The names are constants in the theme package, such as theme.ColorNamePrimary, theme.SizeNamePadding and theme.IconNameHome.

Wrap the default theme

You rarely want to define every value. Embed an existing theme and override only what you change. Every method you do not write falls through to the embedded theme:

type brandTheme struct {
	// The theme being customised. Every method brandTheme does not
	// define comes from it.
	bellui.Theme
}

var (
	_ bellui.Theme           = brandTheme{}
	_ theme.RenderStyleTheme = brandTheme{}
)

func newBrandTheme() brandTheme {
	return brandTheme{Theme: theme.DefaultTheme()}
}

func (t brandTheme) Color(n bellui.ThemeColorName, v bellui.ThemeVariant) color.Color {
	switch n {
	case theme.ColorNamePrimary:
		if v == theme.VariantDark {
			return color.NRGBA{R: 0xff, G: 0xb3, B: 0x80, A: 0xff}
		}
		return color.NRGBA{R: 0xb3, G: 0x4a, B: 0x00, A: 0xff}
	case theme.ColorNameForegroundOnPrimary:
		if v == theme.VariantDark {
			return color.NRGBA{R: 0x3a, G: 0x18, B: 0x00, A: 0xff}
		}
		return color.White
	}
	return t.Theme.Color(n, v)
}

func (t brandTheme) Size(n bellui.ThemeSizeName) float32 {
	switch n {
	case theme.SizeNameText:
		return 15
	case theme.SizeNameButtonRadius, theme.SizeNameInputRadius:
		return 0 // square corners
	}
	return t.Theme.Size(n)
}

func (t brandTheme) Icon(n bellui.ThemeIconName) bellui.Resource {
	if n == theme.IconNameConfirm {
		return t.Theme.Icon(theme.IconNameMailSend)
	}
	return t.Theme.Icon(n)
}

func (t brandTheme) Font(s bellui.TextStyle) bellui.Resource {
	// Return your own bundled font resources here; this keeps the defaults.
	return t.Theme.Font(s)
}

// RenderStyle keeps the default theme's modern widget look. Without it a
// wrapping theme falls back to the classic look.
func (t brandTheme) RenderStyle() theme.RenderStyle {
	if rs, ok := t.Theme.(theme.RenderStyleTheme); ok {
		return rs.RenderStyle()
	}
	return theme.RenderStyleModern
}

Install it once, before you show the first window:

a.Settings().SetTheme(newBrandTheme())

Some things to know:

  • Check the variant. Color receives the variant the app is using. Return a different colour for theme.VariantDark when the light one would not have enough contrast.
  • Keep the modern look. Widgets choose between the “classic” and “modern” drawing style by asking whether the theme implements theme.RenderStyleTheme. Embedding bellui.Theme does not pass that method through, so a wrapper without its own RenderStyle method gets the classic style. Add the method, as above.
  • Pair colours. When you change a colour, also change the colour drawn on top of it. ColorNamePrimary goes with ColorNameForegroundOnPrimary, for example.
  • Sizes are in device-independent units, not pixels. See Scaling.

Apply a theme to part of the window

container.NewThemeOverride draws its content with a different theme from the rest of the app:

// A compact tool row: only this part of the window uses the dense theme.
tools := container.NewThemeOverride(container.NewHBox(
	widget.NewButton("Cut", func() {}),
	widget.NewButton("Copy", func() {}),
	widget.NewButton("Paste", func() {}),
), theme.DenseTheme())

Everything inside the container uses the theme you pass, including widgets in nested containers. If you add objects to the content later, call Refresh on the ThemeOverride so they pick up its theme. Use overrides sparingly: a window with several looks is harder to read.

Generate colours from a seed colour

The default theme offers eight named primary colours. To start from any other colour, generate the MD3 scheme yourself and serve it from your theme:

type seedTheme struct {
	bellui.Theme                  // sizes, fonts and icons
	light, dark  *theme.MD3Scheme // colours, generated from the seed
}

func newSeedTheme(seed color.NRGBA) *seedTheme {
	palettes := theme.CorePalettesFromPrimary(seed)
	return &seedTheme{
		Theme: theme.DefaultTheme(),
		light: theme.LightSchemeFromPalettes(palettes),
		dark:  theme.DarkSchemeFromPalettes(palettes),
	}
}

func (t *seedTheme) Color(n bellui.ThemeColorName, v bellui.ThemeVariant) color.Color {
	scheme := t.light
	if v == theme.VariantDark {
		scheme = t.dark
	}
	if c := theme.ColorFromMD3Scheme(scheme, n, v); c != nil {
		return c
	}
	return t.Theme.Color(n, v) // names the scheme does not cover, such as warning
}

func (t *seedTheme) RenderStyle() theme.RenderStyle { return theme.RenderStyleModern }

The seed colour sets the hue. The primary palette always gets enough colour intensity to read as an accent, even from a greyish seed.

a.Settings().SetTheme(newSeedTheme(color.NRGBA{R: 0x00, G: 0x7a, B: 0x6e, A: 0xff}))

Load a theme from JSON

theme.FromJSON builds a theme from JSON. Anything the JSON does not set comes from the default theme:

const themeJSON = `{
  "Colors": {
    "primary": "#6a1b9a"
  },
  "Colors-dark": {
    "primary": "#ce93d8",
    "background": "#121016"
  },
  "Sizes": {
    "text": 15,
    "padding": 8
  }
}`

The keys are the string values of the theme names: "primary" for ColorNamePrimary, "text" for SizeNameText, and so on.

  • Colors applies to both variants. Colors-light and Colors-dark apply to one variant and win over Colors.
  • Colours are hex strings: #rgb, #rgba, #rrggbb or #rrggbbaa, with or without the #.
  • Sizes holds numbers.
  • Fonts and Icons map names to resource URIs, such as file:///…. Icon keys are icon names. Font keys are regular, bold, boldItalic and monospace. Other styles use one of these: italic, medium and symbol text use regular, and bold monospace text uses bold.

If the JSON cannot be parsed, FromJSON returns the error together with the default theme, so the app still has a usable theme:

th, err := theme.FromJSON(themeJSON)
if err != nil {
	bellui.LogError("Theme JSON is invalid, using the default theme", err)
}
a.Settings().SetTheme(th) // th is the default theme when err != nil

theme.FromJSONWithFallback takes the theme to fall back to instead. FromJSONReader and FromJSONReaderWithFallback read from an io.Reader.

A JSON theme does not select the modern widget style, so widgets are drawn in the classic style. To keep the modern style, wrap the JSON theme in a Go type with a RenderStyle method, as in Wrap the default theme.

Check a theme

theme.ValidateTheme asks a theme for every known colour, in both variants, and every known size. It returns one line for each colour that is nil or transparent and each size that is 0. Call it from a test of your theme. A size you set to 0 on purpose, such as a corner radius, is listed too.