Theming System
Renzora has two independent theming layers — the editor's ember widgets and the in-game renzora_game_ui HUD — each a swappable resource painted from color tokens, with no egui anywhere.
Two layers
Theming is split by where the UI runs. The editor and the shipped game use entirely separate types, so a game's HUD theme never inherits the editor's chrome colors.
| Layer | Where | Types | Crate | Repaint |
|---|---|---|---|---|
| Editor widgets & chrome | The editor session | style::Theme + theme::Palette/StyleSheet | renzora_ember | Styled + apply_theme (live), or re-spawn for the Palette |
| In-game UI (HUD, menus) | The shipped game | UiTheme + UiThemed | renzora_game_ui | ui_theme_system (live) |
The editor's theme files, picker, and live color editing are owned by a third piece, renzora_theme::ThemeManager, which feeds the ember layer through a bridge (below).
⚠️
egui/bevy_eguiwere removed from the engine. There is noEditorThemeresource, noColor32/FontId, noregister_theme, noThemeManager::cycle_next/set_theme. Any doc or example using those is from a dead API. All colors are bevyColor(or the hex-serializedRgba/ThemeColorwrappers), and custom themes live in the project'sthemes/directory (noteditor_themes/).
Editor theming (ember)
The editor uses two cooperating mechanisms in renzora_ember. Both default to the built-in dark look and both are runtime-safe (no egui), so an exported game that pulls in ember widgets renders with the same colors.
The runtime palette — renzora_ember::theme
Palette is a process-wide set of base colors held in a LazyLock<RwLock<…>>. Spawn functions only have &mut Commands (not the world), so they resolve the current colors through lowercase accessor functions instead of threading a resource through every signature:
use renzora_ember::theme::{accent, panel_bg, rgb, text_primary, text_muted};
// Resolve colors at spawn time.
commands.spawn((
Text::new("Hello"),
TextColor(rgb(text_primary())), // rgb((u8,u8,u8)) -> bevy Color
BackgroundColor(rgb(panel_bg())),
));
A representative slice of the accessors (see renzora_ember::theme for all 24):
| Accessor | Default (dark) | Role |
|---|---|---|
window_bg() | (24, 24, 30) | Chrome: top bar, doc tabs, status bar, dock gaps |
panel_bg() | (26, 26, 31) | Panel leaf content |
header_bg() | (30, 30, 36) | Tab headers |
accent() | (80, 140, 255) | Buttons, active underline, selection |
text_primary() | (230, 230, 240) | Main text |
text_muted() | (148, 148, 160) | Labels, hints |
border() | (60, 60, 74) | Input / panel borders |
selection() | (50, 54, 66) | Selected rows |
play_green() / warn_amber() | (89,191,115) / (224,170,72) | Semantic success / warning |
Because these are read at spawn, changing the palette does not retroactively recolor already-spawned widgets — the shell re-spawns the chrome when the active theme switches (see the bridge). renzora_ember::theme also carries a parallel process-wide StyleSheet (per-widget-type geometry + typography, resolved with style(Role)), set the same way.
use renzora_ember::theme::{set_palette, palette, Palette};
// Replace the live palette (the theme bridge does this; you rarely call it directly).
set_palette(Palette { accent: (0, 255, 200), ..palette() });
The per-widget Theme resource — renzora_ember::style
style::Theme is a Reflect + Serde resource holding one StyleToken per widget Role. Unlike the palette, this path repaints live: any widget carrying a Styled component is repainted by the apply_theme system whenever the Theme resource (or the widget's own Styled) changes — no re-spawn. Both are registered by style::ThemePlugin, which is part of EmberPlugin.
use renzora_ember::style::{Styled, Role, WidgetState};
// A themeable button: paints from theme.token(Role::Button) for its state.
commands.spawn((
Node { padding: UiRect::all(Val::Px(8.0)), ..default() },
BackgroundColor::default(), // apply_theme fills this
BorderColor::all(Color::NONE), // and this
Styled::new(Role::Button),
));
// Change the widget's state and apply_theme repaints it next frame.
fn on_hover(mut q: Query<&mut Styled>) {
for mut s in &mut q {
s.state = WidgetState::Hover;
}
}
apply_theme writes the token's bg_for(state) into BackgroundColor, sets Node border width / radius / padding, and (if present) BorderColor via border_for(state).
WidgetState: Normal, Hover, Pressed, Active (selected/on/focused), Disabled.
Role (each maps to a StyleToken via Theme::token): Button, ButtonAccent, IconButton, Input, Checkbox, Segment, Toggle, Card, Badge, Alert, Toast, Tab, Panel, Menu.
Each StyleToken is a box style with per-state fills plus geometry and text:
| Field | Type | Meaning |
|---|---|---|
bg, bg_hover, bg_pressed, bg_active, bg_disabled | Rgba | Fill per WidgetState |
border, border_active | Rgba | Border (active = focus color) |
border_width, radius | f32 | Border thickness, corner radius (px) |
pad_x, pad_y | f32 | Inner padding (px) |
text, text_muted | Rgba | Foreground colors |
weight | f32 | Variable-font weight for the widget's text (100–900, 400 = normal) |
letter_spacing | f32 | Letter spacing in logical px (0 = font default; negatives tighten) |
line_height | f32 | Line height as a multiple of the font size (1.2 = default) |
The three typography fields are applied live by the apply_themed_text system,
which writes weight onto the TextFont of the text directly under each themed
widget and inserts LetterSpacing / LineHeight components. Font size is
deliberately not in the token — it stays with each ui_font(size) call and
the global UI font-size scale, so theming typography is additive and never
fights existing sizing. All three are serde(default), so themes saved before
they existed still load.
Beyond the per-Role tokens, Theme also has bespoke multi-element styles as direct fields — node_graph (NodeGraphStyle), asset_tile (AssetTileStyle), dock (DockStyle: leaf/tab-bar/divider/shadow chrome), top_bar / doc_tabs / status_bar (BarStyle), and timeline (TimelineStyle). These let one element (e.g. a node-graph cable) be retargeted without smearing across the rest of the widget.
Rgbais the theme color type: sRGBA bytes serialized to/from#RRGGBB/#RRGGBBAA.Rgba::rgb((r,g,b))builds an opaque color;.color()converts to a bevyColor.
Code-editor syntax colors — theme::SyntaxPalette
The built-in code editor resolves its token and chrome colors through a second
process-wide palette in renzora_ember::theme, SyntaxPalette, set the same way
as Palette (a LazyLock<RwLock<…>> via set_syntax_palette, read with
syntax_palette()). This keeps the editor widget language- and theme-agnostic:
it never depends on renzora_theme; the bridge pushes colors in.
The colors come from a theme's [syntax] section (renzora_theme::SyntaxColors).
Token categories are opaque; chrome colors that overlay text carry alpha:
| Group | Keys |
|---|---|
| Tokens | normal, keyword, type, function, number, string, comment, operator, constant, punctuation |
| Chrome | line_number, line_number_active, current_line, selection, cursor, indent_guide, bracket_match, find_match |
# themes/cyberpunk.toml — code colors
[syntax]
normal = "#DCDCFF"
keyword = "#FF3250"
type = "#FFC800"
function = "#00FFC8"
string = "#00FF64"
comment = "#5A5A78"
selection = "#4F8CFF4D" # RGBA — alpha keeps the text legible under it
cursor = "#00FFC8"
Editing any of these in Settings → Theme → Syntax Tokens / Editor Chrome
recolors the open editor live: the edit lands in ThemeManager.active_theme,
the bridge maps it into SyntaxPalette, and a watcher in the code-editor widget
repaints the visible lines and caret. Omitting [syntax] keeps the built-in
dark token palette (Light themes ship a tuned light variant).
Theme files — themes/*.toml
Custom editor themes are TOML files under the project's themes/ directory. A single themes/<name>.toml can hold two kinds of sections, and each loader reads only its own sections and ignores the rest:
- Editor color sections (
[meta],[semantic],[surfaces],[text],[widgets],[panels],[categories],[material],[viewport],[syntax]) — parsed intorenzora_theme::ThemebyThemeManager. The bridge maps these into the emberPalette(and, for[syntax], theSyntaxPalette). - Ember per-widget style sections (
[button],[input],[dock],[node_graph], …) — parsed intorenzora_ember::style::ThemebyTheme::from_toml.
Theme::from_toml cascades: it deep-merges your file over the palette-derived defaults, so a theme only needs to specify the elements it overrides (a whole [button] table, or just button.bg):
# themes/cyberpunk.toml
# ── Editor color sections (renzora_theme::Theme) → ember Palette ──
[meta]
name = "Cyberpunk"
[semantic]
accent = "#00FFC8"
success = "#00FF64"
warning = "#FFC800"
error = "#FF3250"
[surfaces]
window = "#0A0514" # → window_bg()
panel = "#140A23" # → panel_bg()
popup = "#1E1432" # → popup_bg()
[text]
primary = "#DCDCFF"
muted = "#9696C8"
# ── Ember per-widget overrides (renzora_ember::style::Theme) ──
[button]
bg = "#1E1432"
bg_hover = "#2A1E46"
bg_pressed = "#00FFC8"
radius = 6.0
[input]
border = "#321E50"
border_active = "#00FFC8"
[dock]
leaf_radius = 8.0
shadow = true
Colors are hex strings.
#RRGGBBis opaque; add a fourth byte (#RRGGBBAA) for alpha. Numbers (radius,pad_x, …) are plain floats in logical px. Any section you omit keeps its built-in default.
Switching & editing themes — renzora_theme::ThemeManager
ThemeManager (an editor-only resource, initialized by renzora_editor_framework) owns theme discovery, the active theme, and persistence. The Settings overlay → Theme tab edits active_theme live with color pickers; the status-bar theme switcher calls load_theme to swap. Its real API:
| Method / field | Purpose |
|---|---|
active_theme: Theme | The live renzora_theme::Theme (color sections) |
active_theme_name: String | Name of the active theme |
available_themes: Vec<String> | "Dark", "Light", plus custom file stems |
set_project_path(&Path) | Point at the project; scans themes/*.toml |
scan_themes() | Refresh available_themes |
load_theme(name) -> bool | Activate a built-in or a themes/<name>.toml |
save_theme(name) -> Option<PathBuf> | Serialize active_theme to themes/<name>.toml |
duplicate_theme(new_name) -> bool | Copy the active theme to a new file |
delete_theme(name) -> bool | Delete a custom theme (built-ins refuse) |
is_builtin(name) -> bool | true for "Dark" / "Light" |
mark_modified() / has_unsaved_changes | Dirty tracking for the editor |
The only built-in editor themes are Dark and Light. (High Contrast is a game-UI built-in — see below.) File-based custom themes are native-only; on WASM
ThemeManagerexposes just the two built-ins.
The bridge — how it all connects
renzora_shell's theme_bridge system glues the layers together each frame:
- It maps
ThemeManager.active_theme(arenzora_theme::Theme) into an emberPaletteviapalette_from_themeand pushes it withset_palette— e.g.surfaces.window → window_bg,surfaces.panel → panel_bg,surfaces.extreme → header_bg,semantic.accent → accent,panels.tab_active → tab_active. - It likewise maps the theme's
[syntax]section into the emberSyntaxPaletteviasyntax_palette_from_theme+set_syntax_palette, so the code editor recolors on theme switch and on live syntax-color edits (tracked separately so a syntax-only edit doesn't churn the main palette). - On a theme switch, it rebuilds the ember
style::Themewithbuild_ember_theme(which callsTheme::from_tomlon the samethemes/<name>.toml) andinsert_resources it, then despawns and re-spawns the chrome so palette-derived widgets pick up the new colors.apply_themethen repaints everyStyledwidget live. - Individual color edits update the palette but do not rebuild (that would close the color picker every frame).
Game UI theming (renzora_game_ui)
The shipped game's HUD and menus use a completely separate, self-contained theme: the UiTheme resource plus the UiThemed marker. This layer is registered by GameUiPlugin (runtime scope), which inserts a default UiTheme and runs ui_theme_system.
UiTheme is a flat resource of semantic tokens (a Reflect + Serde resource, so it serializes and can be saved in a scene):
use bevy::prelude::*;
use renzora_game_ui::components::{UiTheme, UiThemed};
// Swap the whole theme — every UiThemed widget re-syncs next frame.
fn use_light_ui(mut commands: Commands) {
commands.insert_resource(UiTheme::light());
}
// Or build a custom one from the dark base.
fn use_custom_ui(mut commands: Commands) {
commands.insert_resource(UiTheme {
accent: Color::srgb(0.0, 0.85, 0.6),
health_fill: Color::srgb(0.2, 0.8, 0.2),
health_low: Color::srgb(0.9, 0.2, 0.2),
..UiTheme::dark()
});
}
// Mark a widget so its colors follow the active UiTheme.
fn spawn_button(mut commands: Commands) {
commands.spawn((Node::default(), UiThemed));
}
Built-in constructors: UiTheme::dark() (the default), UiTheme::light(), UiTheme::high_contrast().
Key tokens (see the struct for the full set): surfaces (surface, surface_raised, surface_overlay), text (text_primary, text_secondary, text_muted, text_on_accent), interactive (accent, accent_hovered, accent_pressed), semantic (success, warning, error, info), widget tokens (border, track, thumb, progress_fill, health_fill, health_low, toggle_on, toggle_off, scrollbar, tooltip_bg, modal_backdrop, title_bar), typography (font_size_sm/md/lg/xl), and geometry (border_radius, border_width, spacing).
When the UiTheme resource changes, ui_theme_system re-derives each UiThemed widget's style from theme.widget_style(widget_type) and theme.interaction_style(), updating its style components (UiFill, UiStroke, UiBorderRadius, UiTextStyle, UiInteractionStyle, …) and the per-widget data colors (slider track/fill/thumb, checkbox, toggle, tooltip, modal backdrop, window title bar).
Unlike the editor layer,
UiThemeis set in code (or carried in a scene) — there is noThemeManager/themes/*.tomlpicker for the game HUD. Insert or replace the resource and marked widgets follow it.