Skip to main content

khora_infra/ui/egui/
theme.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Convert [`UiTheme`] to egui [`Visuals`] and apply to an egui context.
16
17use khora_core::ui::UiTheme;
18
19/// Key under which [`apply_theme`] stashes the X/Y/Z axis colours in the egui
20/// context.
21///
22/// `UiBuilder::vec3_editor` is a stock widget with no access to a [`UiTheme`],
23/// but it must tint its axes with the theme's own tokens — otherwise the
24/// inspector's X/Y/Z and the viewport gizmo's X/Y/Z drift apart, and the two
25/// stop reading as the same thing. Rather than widen the trait or hard-code a
26/// second copy of the palette in this backend, the theme leaves the colours
27/// here for the widget to pick up.
28pub(crate) const AXIS_COLORS_KEY: &str = "khora.axis_colors";
29
30fn c(color: [f32; 4]) -> egui::Color32 {
31    egui::Color32::from_rgba_unmultiplied(
32        (color[0] * 255.0) as u8,
33        (color[1] * 255.0) as u8,
34        (color[2] * 255.0) as u8,
35        (color[3] * 255.0) as u8,
36    )
37}
38
39/// Applies an [`UiTheme`] to the given egui context.
40pub fn apply_theme(ctx: &egui::Context, theme: &UiTheme) {
41    let mut visuals = egui::Visuals::dark();
42
43    // ── Surfaces ─────────────────────────────────────
44    visuals.panel_fill = c(theme.surface);
45    visuals.window_fill = c(theme.surface);
46    visuals.faint_bg_color = c(theme.surface_elevated);
47    visuals.extreme_bg_color = c(theme.background);
48    visuals.code_bg_color = c(theme.surface_elevated);
49
50    // ── Text ─────────────────────────────────────────
51    visuals.override_text_color = Some(c(theme.text));
52    visuals.warn_fg_color = c(theme.warning);
53    visuals.error_fg_color = c(theme.error);
54
55    // ── Selection / hyperlinks ───────────────────────
56    visuals.selection.bg_fill = c(theme.primary).gamma_multiply(0.20);
57    visuals.selection.stroke = egui::Stroke::new(1.0_f32, c(theme.primary));
58    visuals.hyperlink_color = c(theme.accent_b);
59
60    // ── Window chrome ────────────────────────────────
61    visuals.window_shadow = egui::Shadow {
62        offset: [0, 8],
63        blur: 24,
64        spread: 0,
65        color: egui::Color32::from_black_alpha(140),
66    };
67    visuals.window_stroke = egui::Stroke::new(1.0_f32, c(theme.border));
68    visuals.popup_shadow = egui::Shadow {
69        offset: [0, 4],
70        blur: 12,
71        spread: 0,
72        color: egui::Color32::from_black_alpha(110),
73    };
74
75    // ── Widget radii ─────────────────────────────────
76    let radius_md = egui::CornerRadius::same(theme.radius_md.round().clamp(0.0, 30.0) as u8);
77    let radius_sm = egui::CornerRadius::same(theme.radius_sm.round().clamp(0.0, 30.0) as u8);
78
79    // ── Widget states ────────────────────────────────
80    visuals.widgets.noninteractive.bg_fill = c(theme.surface);
81    visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0_f32, c(theme.text_dim));
82    visuals.widgets.noninteractive.corner_radius = radius_md;
83    visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(0.5_f32, c(theme.separator));
84
85    visuals.widgets.inactive.bg_fill = c(theme.surface_interactive);
86    visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0_f32, c(theme.text));
87    visuals.widgets.inactive.corner_radius = radius_md;
88    visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0_f32, c(theme.border));
89
90    visuals.widgets.hovered.bg_fill = c(theme.surface_active);
91    visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0_f32, c(theme.text));
92    visuals.widgets.hovered.corner_radius = radius_md;
93    visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0_f32, c(theme.border_strong));
94
95    visuals.widgets.active.bg_fill = c(theme.primary).gamma_multiply(0.32);
96    visuals.widgets.active.fg_stroke = egui::Stroke::new(1.5_f32, c(theme.text));
97    visuals.widgets.active.corner_radius = radius_md;
98    visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0_f32, c(theme.primary));
99
100    visuals.widgets.open.bg_fill = c(theme.surface_elevated);
101    visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0_f32, c(theme.text));
102    visuals.widgets.open.corner_radius = radius_md;
103
104    // Menu / popup backgrounds use the elevated surface so they pop above
105    // panels without looking out of place.
106    visuals.menu_corner_radius = radius_sm;
107
108    visuals.striped = false;
109    visuals.slider_trailing_fill = true;
110
111    ctx.set_visuals(visuals);
112
113    // Hand the axis tints to `vec3_editor` (see AXIS_COLORS_KEY).
114    let axes: [egui::Color32; 3] = [c(theme.axis_x), c(theme.axis_y), c(theme.axis_z)];
115    ctx.data_mut(|d| d.insert_temp(egui::Id::new(AXIS_COLORS_KEY), axes));
116
117    // ── Spacing & sizing ─────────────────────────────
118    let mut style = (*ctx.global_style()).clone();
119    style.spacing.item_spacing = egui::vec2(theme.pad_row * 0.75, theme.pad_row * 0.5);
120    style.spacing.button_padding = egui::vec2(10.0, 4.0);
121    style.spacing.indent = 14.0;
122    style.spacing.scroll.bar_width = 8.0;
123    style.spacing.window_margin = egui::Margin::same(8);
124    style.spacing.menu_margin = egui::Margin::symmetric(8, 6);
125    // Make panel resize handles much easier to grab. The default 4px hot
126    // zone is hard to hit and our panel content paints right up to the
127    // edge, so users were reporting the resize "didn't work".
128    style.interaction.resize_grab_radius_side = 8.0;
129    style.interaction.resize_grab_radius_corner = 10.0;
130
131    // Default font sizes per text style — drives any RichText that does not
132    // override its size manually.
133    use egui::{FontFamily, FontId, TextStyle};
134    style.text_styles.insert(
135        TextStyle::Heading,
136        FontId::new(theme.font_size_display, FontFamily::Proportional),
137    );
138    style.text_styles.insert(
139        TextStyle::Body,
140        FontId::new(theme.font_size_body, FontFamily::Proportional),
141    );
142    style.text_styles.insert(
143        TextStyle::Button,
144        FontId::new(theme.font_size_body, FontFamily::Proportional),
145    );
146    style.text_styles.insert(
147        TextStyle::Small,
148        FontId::new(theme.font_size_caption, FontFamily::Proportional),
149    );
150    style.text_styles.insert(
151        TextStyle::Monospace,
152        FontId::new(theme.font_size_body - 0.5, FontFamily::Monospace),
153    );
154
155    ctx.set_global_style(style);
156}