The look as data
Themes & tokens
In libLuna the look is not scattered across widgets. It lives in Theme: the palette, spacing, radii, typography and control metrics. Changing a theme means swapping data — not rewriting components.
Three layers of appearance
Variant
ThemePreference: System, Light or Dark. Chooses the base palette.
Accent
Accent: 9 colours. Replaces colors.accent and accent_fg.
Metrics
Metrics: per-widget heights, padding and sizes. Geometry is a token too.
Theme in code
use libluna::prelude::*;
// Two ready-made variants
let light = Theme::light();
let dark = Theme::dark();
// The same theme with another accent
let purple = Theme::light().accented(
Accent::Purple.color(),
Accent::Purple.foreground(),
);
// User preference + system state → a concrete theme
let theme = ThemePreference::System.resolve(true, Accent::Teal);
Theme field | Type | Contains |
|---|---|---|
colors | ColorScheme | Light or dark palette. |
spacing | Spacing | 3/6/12/18/24 spacing scale. |
radius | Radius | 6/9/12/pill radii. |
typography | Typography | Styles: title, heading, body, caption. |
metrics | Metrics | Geometry for 32 widget groups. |
appearance | Appearance | Light / Dark. |
Palette
Token names are shared by both variants; only the values differ. Below is the light palette:
In the dark theme window_bg is #242424, card_bg is #303030 and text is #ffffff. The values come from ColorScheme::dark().
Accent colours — try them here
Clicking changes this page's accent (and stores it in localStorage). In an app, Frame::set_accent(...) does the same. The chosen accent feeds into Theme::accented.
for accent in Accent::ALL {
println!("{} → {:?}", accent.name(), accent.color());
}
frame.set_accent(Accent::Purple);
frame.set_preference(ThemePreference::Dark);
Spacing, radii, typography
Spacing
xs— 3 pxsm— 6 pxmd— 12 pxlg— 18 pxxl— 24 px
Radius
small— 6 pxmedium— 9 pxlarge— 12 pxpill— 999 px
Typography
title— 20 px / 700 / 26heading— 15 px / 700 / 20body— 14 px / 400 / 20caption— 12 px / 400 / 16
FontStyle
size— logical pxweight— 400 / 500 / 700line_height— line heightmonospace— fixed-width face
let pad = theme.spacing.md;
let radius = theme.radius.large;
let title = theme.typography.title;
scene.fill_rounded_rect(rect, radius, theme.colors.card_bg);
scene.text(
TextRun::new(Point::new(rect.x() + pad, rect.y() + pad + title.size),
"Title", title.size)
.with_weight(title.weight)
.with_color(theme.colors.text),
);
Control metrics
Widgets do not hard-code heights. They read them from theme.metrics, so a style can change the title bar height or switch size. A few standard values:
| Group | Key | Default |
|---|---|---|
headerbar | height / control | 38 / 30 px |
button | height / pad_x | 34 / 14 px |
switch | width / height | 44 / 24 px |
slider | track_height / knob_r | 6 / 9 px |
entry | height / pad_x | 34 / 12 px |
tabs | height / min_width | 34 / 120 px |
itemview | tile_w / tile_h | 100 / 76 px |
toast | height / margin_bottom | 40 / 40 px |
container | gap | 8 px |
The complete list of 32 groups is in the API index and rustdoc.
Styles from .theme files
You can also install a look the way GNOME/KDE do. The format is line-based with dotted keys; unknown keys are ignored (forward compatibility).
__name__ = Nord
appearance = dark
accent = teal
colors.card_bg = #3b4252
colors.text = #eceff4
colors.window_bg = #2e3440
colors.sidebar_bg = #2e3440
radius.small = 5
radius.large = 10
metrics.button.height = 36
metrics.headerbar.height = 48
metrics.switch.width = 48
| Directory | Priority |
|---|---|
~/.config/luna/themes | highest |
~/.local/share/luna/themes | medium |
/usr/share/luna/themes | system |
LUNA_THEMES_DIR | extra directories (dev/tests) |
use libluna::core::{available_themes, ThemePatch};
for style in available_themes() {
println!("{} ({})", style.name, style.id);
}
let patch = ThemePatch::from_file(path)?;
let theme = patch.build(Theme::light());
// or on an existing theme
let mut theme = Theme::dark();
patch.apply(&mut theme);
A style controls not only colours but geometry: metrics.* changes control sizes, while spacing.* and radius.* change padding and corner radii.
Changing the theme from an app
Frame holds the current ThemeSettings and saves changes immediately. The next frame receives the new theme.
if clicked_light {
frame.set_preference(ThemePreference::Light);
}
if clicked_dark {
frame.set_preference(ThemePreference::Dark);
}
if clicked_auto {
frame.set_preference(ThemePreference::System);
}
if clicked_purple {
frame.set_accent(Accent::Purple);
}
if clicked_custom_style {
frame.set_style(Some("nord".into()));
}
Settings go to ~/.config/luna/theme.conf. In System mode darkness comes from LUNA_SYSTEM_DARK (later via the D-Bus portal).