guise
Docs / Foundations
Previewrendered with guise's palette
Blue 6Violet 6Teal 6

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 #228be6 can'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