Theming
The theme is the single source of truth for color, spacing, radius, and typography. It is installed once as a gpui global and read by every component.
use guise::prelude::*;
Theme::light().init(cx); // or Theme::dark()
Read it anywhere you have an &App (or a &Context<_>, which derefs to it):
let t = guise::theme::theme(cx); // &Theme
let accent = t.primary().hsla();
The Theme struct
| Field | Type | Meaning |
|---|---|---|
scheme |
ColorScheme |
Light or Dark |
palette |
Palette |
the 14×10 named-color ramps |
primary_color |
ColorName |
default fill color (Blue) |
primary_shade_light / primary_shade_dark |
usize |
filled shade per scheme (6 / 8) |
white / black |
Color |
absolute white/black |
spacing / radius / font_size |
Scale |
xs..xl token scales (px) |
default_radius |
Size |
corner radius when a component doesn't specify one |
line_height |
f32 |
base line height |
font_family |
SharedString |
default UI font |
Construct with Theme::light() / Theme::dark(), tweak fields, then .init(cx):
let mut theme = Theme::dark();
theme.primary_color = ColorName::Grape;
theme.default_radius = Size::Md;
theme.init(cx);
Colors
Color is a 24-bit RGB triple that converts into gpui's Hsla:
let c = Color::hex("#228be6"); // parse (panics on bad literal — use for constants)
let c = Color::new(34, 139, 230); // from components
c.hsla(); // opaque gpui::Hsla
c.alpha(0.2); // gpui::Hsla with alpha
c.contrasting(); // black or white, whichever reads on top of `c`
CSS-style colors
Write colors the way you would in CSS — hex, rgb/rgba, hsl/hsla, or a
named color — with the color! macro (compile-time literals) or css(..)
(runtime strings). Both produce a gpui Hsla, which carries alpha and drops
straight into .bg(..) / .text_color(..) and any guise .color(..).
use guise::prelude::*;
color!(rgb(34, 139, 230))
color!(rgba(34, 139, 230, 0.5))
color!(hsl(210, 80, 52)) // s / l are percentages (no `%` token)
color!(hsla(210, 80, 52, 0.5))
color!(teal) // a CSS named color
color!("#228be6") // any CSS string: hex (#rgb, #rgba, #rrggbb, #rrggbbaa),
color!("hsl(210, 80%, 52%)") // functional with `%`, or a named color
css("rgba(34,139,230,.5)")? // runtime parse (config, theme files) -> Result<Hsla>
Bare hex like
#228be6can't be a Rust macro token, so pass hex as a string:color!("#228be6"). Everything else works as bare tokens.
On components. Any colored component's .color(..) accepts a palette
ColorName or an explicit CSS color — guise derives the variant shades
(filled/light/outline/hover) from a single custom color:
Button::new("go", "Go").color(color!(rgba(112, 72, 232, 1)))
Badge::new("New").variant(Variant::Light).color(color!("#e64980"))
This is the ColorValue type (ColorName and Hsla both Into<ColorValue>).
It's wired into Button, Badge, ActionIcon, Alert, and Chip; other
components still take a ColorName.
The palette
14 named colors, each a 10-step ramp from lightest (0) to darkest (9) —
the open-color values.
t.color(ColorName::Teal, 6); // a specific shade
t.palette.shades(ColorName::Red).get(3);
ColorName variants: Dark, Gray, Red, Pink, Grape, Violet,
Indigo, Blue, Cyan, Teal, Green, Lime, Yellow, Orange.
ColorName::ALL iterates them; .label() gives the lowercase name.
Re-pin a whole ramp before init with Palette::set_shades — for example, to
swap guise's neutral Dark scale for an app's own:
use guise::theme::{Color, Shades};
let mut theme = Theme::dark();
theme.palette.set_shades(
ColorName::Dark,
Shades(["#C1C2C5", "#A6A7AB", "#909296", "#5C5F66", "#373A40",
"#2C2E33", "#25262B", "#1A1B1E", "#141517", "#101113"].map(Color::hex)),
);
theme.init(cx);
Every semantic color (body, surface, text, border, …) then resolves
against the replaced ramp.
Semantic colors (scheme-aware)
These resolve differently in light vs. dark mode — use them instead of hard-coding:
| Method | Use for |
|---|---|
t.body() |
window / page background |
t.surface() |
raised surfaces (Paper, Card, Menu) |
t.surface_hover() |
hover/recessed fill |
t.text() |
primary text |
t.dimmed() |
secondary text |
t.border() |
borders and dividers |
t.primary() |
the primary color at its scheme shade |
t.success() |
positive accents (confirmations, valid states) |
t.warning() |
caution accents |
t.danger() |
errors, destructive actions |
t.info() |
notices and hints |
t.selection() |
the wash behind selected text (returns an Hsla, already tinted) |
div().bg(t.surface().hsla()).text_color(t.text().hsla())
Override any of these (and the primary accent) app-wide with a CSS color via the
with_* builders. Overrides are opaque (alpha is dropped — semantic colors are
solid) and apply everywhere the getter is read, so the whole UI restyles:
Theme::dark()
.with_primary(color!("#7048e8")) // hex as a string
.with_body(color!(rgb(11, 11, 15)))
.with_surface(color!(rgb(20, 20, 28)))
.with_text(color!("hsl(220, 15%, 92%)"))
.init(cx);
The setters are named with_* (not body/primary) to avoid clashing with the
same-named getters; each accepts anything Into<Hsla> — a color!, css(..),
or a palette Color. The full override set: with_primary, with_body,
with_surface, with_surface_hover, with_text, with_dimmed,
with_border, with_success, with_warning, with_danger, with_info.
Prebuilt themes
Six well-known palettes ship as ready themes — every override slot set:
Theme::catppuccin().init(cx); // Catppuccin Mocha (dark)
Theme::nord().init(cx);
Theme::tokyonight().init(cx);
Theme::gruvbox().init(cx);
Theme::dracula().init(cx);
Theme::solarized_light().init(cx);
// Or by name (see PRESET_NAMES):
if let Some(theme) = Theme::preset("dracula") { theme.init(cx); }
JSON theme files
Theme::from_json(source) loads a theme from a flat JSON object of string
values — deliberately flat so the parser stays dependency-free and files
diff cleanly. Colors take any form css() accepts (hex, rgb(), hsl()):
{
"name": "midnight",
"scheme": "dark",
"primary": "#7aa2f7",
"body": "#1a1b26",
"surface": "#16161e",
"surfacehover": "#292e42",
"text": "#c0caf5",
"dimmed": "#565f89",
"border": "#3b4261",
"success": "rgb(158, 206, 106)",
"warning": "#e0af68",
"danger": "#f7768e",
"info": "#7dcfff",
"fontfamily": "Inter",
"radius": "md"
}
let theme = Theme::from_json(&std::fs::read_to_string("theme.json")?)?;
theme.init(cx);
Every key is optional (scheme defaults to dark; unset colors keep the
scheme defaults), but unknown keys are rejected with
ThemeJsonError::UnknownKey — they're almost always typos. Bad colors and
tokens name the offending key in ThemeJsonError::BadValue. name and
$schema are accepted and ignored, so files can self-describe.
Sizing tokens
Size (Xs, Sm, Md, Lg, Xl; default Md) indexes three scales:
t.spacing(Size::Md); // 16.0
t.radius(Size::Sm); // 4.0
t.font_size(Size::Lg); // 18.0
Defaults (px):
| Scale | xs | sm | md | lg | xl |
|---|---|---|---|---|---|
| spacing | 10 | 12 | 16 | 20 | 32 |
| radius | 2 | 4 | 8 | 16 | 32 |
| font_size | 12 | 14 | 16 | 18 | 20 |
Override a whole scale on the theme before init:
let mut theme = Theme::light();
theme.spacing = Scale::new(8.0, 12.0, 16.0, 24.0, 40.0);
theme.init(cx);
Switching light / dark at runtime
The theme is a mutable global; flip it and request a redraw:
let dark = cx.global::<Theme>().scheme.is_dark();
cx.global_mut::<Theme>().scheme = if dark { ColorScheme::Light } else { ColorScheme::Dark };
cx.refresh_windows();
Because every component reads theme(cx) at render time, the whole UI restyles
on the next frame — there is nothing to thread through your views.
The theme manager
Theme is one global, so switching is the snippet above. What that snippet
doesn't answer is which themes exist, which one the user picked last time, and
what "follow the system" means in an app that ships four dark themes.
ThemeManager owns exactly that — a registry, a choice, and the OS-appearance
watch — and nothing about how a theme looks.
use guise::prelude::*;
ThemeManager::new() // seeded with `light` and `dark`
.with_presets() // + the six prebuilt presets
.with_dir(config_dir.join("themes")) // + every *.json in a folder
.with(ThemeEntry::new("brand", brand_theme()).name("Acme"))
.choice(saved.parse().unwrap()) // "system" | "theme:dracula"
.install(cx); // sets the Theme global + redraws
install puts the manager in the global map beside Theme and applies the
resolved theme. From then on every mutator is an associated function taking
&mut App, so any listener can reach it:
ThemeManager::select(cx, "dracula"); // wear one theme; false if unregistered
ThemeManager::follow_system(cx); // back to ThemeChoice::System
ThemeManager::toggle(cx); // swap to the light/dark counterpart
Every one of them ends in ThemeManager::apply(cx), which writes the resolved
theme into the Theme global and calls refresh_windows. Editing the manager
directly (cx.global_mut::<ThemeManager>()) means calling apply yourself.
Nothing panics when no manager is installed: it is opt-in, so the mutators are
no-ops and guise::theme::manager(cx) returns None.
Following the OS
// in the window's init, once per window
ThemeManager::watch(window, cx);
That records the window's appearance and subscribes to changes. Under
ThemeChoice::System the app restyles when macOS flips at sundown; under a
fixed choice the appearance is still recorded but nothing moves. gpui's four
appearances collapse to two schemes — vibrancy is a material, not a scheme.
System resolves through the registry's light/dark pair, which is the ids
light and dark by default. An app with its own two-theme identity
re-registers those ids and gets the follow behaviour for free; an app whose
pair lives elsewhere says so:
ThemeManager::new().with_presets().pair("solarizedlight", "nord")
Persisting the choice
The manager doesn't write your config file — the same call the
settings module makes. ThemeChoice is Display + FromStr,
so it round trips through one string in whatever format the app already uses:
save("theme", manager.selection().to_string()); // "system" | "theme:dracula"
let choice: ThemeChoice = load("theme").parse().unwrap(); // Infallible
A bare id parses too, so a hand-written config that just says dracula works.
A choice naming a theme that is no longer registered falls back to the
light/dark pair rather than panicking — a user can delete a theme file.
A themes folder
with_dir / load_dir read every *.json in a directory as a
theme file, keyed by file stem, named by the file's name
key (or the title-cased stem). A missing directory is not an error, and one bad
file doesn't cost you the other nine — load_dir returns the failures:
let mut manager = ThemeManager::new();
for failure in manager.load_dir(&dir) {
eprintln!("skipping {failure}");
}
manager.install(cx);
This is the only part of theme/ that touches the disk. An app that would
rather bring its own bytes — an include_str!, a file it already read — uses
register_json(id, source) instead.
ThemePicker
The picker reads the registry and writes the choice back, so there is no state to hold and nothing to wire:
ThemePicker::new()
.layout(ThemePickerLayout::Grid) // or List (the default)
.system_option(true) // offer "System" first
.on_change(|choice, _window, _cx| save("theme", choice.to_string()))
Each row previews the theme it offers — the swatch is painted from that theme's body, surface, border and primary, which is the only honest way to show a theme you aren't wearing. The System row shows the light/dark pair side by side. Without a manager installed the picker draws nothing.
| Type | What it is |
|---|---|
ThemeManager |
the registry + the choice, a gpui Global |
ThemeEntry |
one registered theme: id, name, source, theme |
ThemeSource |
Builtin / Custom / File(PathBuf) |
ThemeChoice |
System or Fixed(id); Display + FromStr |
ThemeLoadError |
a theme file load_dir skipped |
ThemePicker |
the picker component |