Skip to main content

aetna_core/
theme.rs

1//! Theme-level shader routing.
2//!
3//! Aetna widgets expose familiar style knobs (`fill`, `stroke`, `radius`,
4//! `shadow`) while the renderer resolves those facts into shader bindings.
5//! `Theme` is the indirection layer between those two worlds: an app can
6//! keep using stock widgets and globally swap the shader recipe that paints
7//! implicit surfaces.
8//!
9//! This is intentionally shader-first. Token colors are still authored as
10//! constants today, but surface appearance can already move from
11//! `stock::rounded_rect` to a custom material without rewriting every
12//! `button`, `card`, or `text_input`.
13
14use std::collections::BTreeMap;
15
16use crate::metrics::{ComponentSize, ThemeMetrics};
17use crate::palette::Palette;
18use crate::shader::{ShaderHandle, StockShader, UniformBlock, UniformValue};
19use crate::tokens;
20use crate::tree::{Color, FontFamily, SurfaceRole};
21use crate::vector::IconMaterial;
22
23/// Runtime paint theme for implicit widget visuals.
24#[derive(Clone, Debug)]
25pub struct Theme {
26    palette: Palette,
27    metrics: ThemeMetrics,
28    surface: SurfaceTheme,
29    roles: BTreeMap<SurfaceRole, SurfaceTheme>,
30    icon_material: IconMaterial,
31    font_family: FontFamily,
32    mono_font_family: FontFamily,
33}
34
35impl Theme {
36    /// Current default: stock rounded-rect surfaces with the Aetna Dark
37    /// palette (copied from shadcn/ui zinc dark) and compact desktop
38    /// metrics.
39    pub fn aetna_dark() -> Self {
40        Self::default().with_palette(Palette::aetna_dark())
41    }
42
43    /// Stock rounded-rect surfaces with the Aetna Light palette (copied
44    /// from shadcn/ui zinc light). Drop-in alternative to
45    /// [`Self::aetna_dark`] — token references swap rgba at paint time
46    /// without rebuilding the widget tree.
47    pub fn aetna_light() -> Self {
48        Self::default().with_palette(Palette::aetna_light())
49    }
50
51    /// Stock rounded-rect surfaces with a Radix Colors slate + blue
52    /// dark palette.
53    pub fn radix_slate_blue_dark() -> Self {
54        Self::default().with_palette(Palette::radix_slate_blue_dark())
55    }
56
57    /// Stock rounded-rect surfaces with a Radix Colors slate + blue
58    /// light palette.
59    pub fn radix_slate_blue_light() -> Self {
60        Self::default().with_palette(Palette::radix_slate_blue_light())
61    }
62
63    /// Stock rounded-rect surfaces with a Radix Colors sand + amber
64    /// dark palette — warm sepia neutrals with a bright amber accent.
65    pub fn radix_sand_amber_dark() -> Self {
66        Self::default().with_palette(Palette::radix_sand_amber_dark())
67    }
68
69    /// Stock rounded-rect surfaces with a Radix Colors sand + amber
70    /// light palette.
71    pub fn radix_sand_amber_light() -> Self {
72        Self::default().with_palette(Palette::radix_sand_amber_light())
73    }
74
75    /// Stock rounded-rect surfaces with a Radix Colors mauve + violet
76    /// dark palette — purple-tinged neutrals with a violet accent.
77    pub fn radix_mauve_violet_dark() -> Self {
78        Self::default().with_palette(Palette::radix_mauve_violet_dark())
79    }
80
81    /// Stock rounded-rect surfaces with a Radix Colors mauve + violet
82    /// light palette.
83    pub fn radix_mauve_violet_light() -> Self {
84        Self::default().with_palette(Palette::radix_mauve_violet_light())
85    }
86
87    /// Replace the runtime color palette. Token references resolve
88    /// through the active palette at paint time, so this swaps surface
89    /// rgba without rebuilding the widget tree.
90    pub fn with_palette(mut self, palette: Palette) -> Self {
91        self.palette = palette;
92        self
93    }
94
95    /// The active runtime palette.
96    pub fn palette(&self) -> &Palette {
97        &self.palette
98    }
99
100    /// The active layout metrics used to resolve stock widget defaults.
101    pub fn metrics(&self) -> &ThemeMetrics {
102        &self.metrics
103    }
104
105    /// The default proportional UI font family applied to text nodes
106    /// that do not set `.font_family(...)` themselves.
107    pub fn font_family(&self) -> FontFamily {
108        self.font_family
109    }
110
111    /// Set the default proportional UI font family.
112    pub fn with_font_family(mut self, family: FontFamily) -> Self {
113        self.font_family = family;
114        self
115    }
116
117    /// The default monospace font family applied to text nodes that
118    /// render as code (`font_mono = true`, `TextRole::Code`) and do
119    /// not set `.mono_font_family(...)` themselves. Independent of
120    /// [`Self::font_family`] — swapping the proportional face leaves
121    /// the code face alone, and vice versa.
122    pub fn mono_font_family(&self) -> FontFamily {
123        self.mono_font_family
124    }
125
126    /// Set the default monospace font family for code-tagged text.
127    pub fn with_mono_font_family(mut self, family: FontFamily) -> Self {
128        self.mono_font_family = family;
129        self
130    }
131
132    /// Replace the runtime layout metrics.
133    pub fn with_metrics(mut self, metrics: ThemeMetrics) -> Self {
134        self.metrics = metrics;
135        self
136    }
137
138    /// Set the default t-shirt size for stock controls.
139    pub fn with_default_component_size(mut self, size: ComponentSize) -> Self {
140        self.metrics = self.metrics.with_default_component_size(size);
141        self
142    }
143
144    pub fn with_button_size(mut self, size: ComponentSize) -> Self {
145        self.metrics = self.metrics.with_button_size(size);
146        self
147    }
148
149    pub fn with_input_size(mut self, size: ComponentSize) -> Self {
150        self.metrics = self.metrics.with_input_size(size);
151        self
152    }
153
154    pub fn with_badge_size(mut self, size: ComponentSize) -> Self {
155        self.metrics = self.metrics.with_badge_size(size);
156        self
157    }
158
159    pub fn with_tab_size(mut self, size: ComponentSize) -> Self {
160        self.metrics = self.metrics.with_tab_size(size);
161        self
162    }
163
164    pub fn with_choice_size(mut self, size: ComponentSize) -> Self {
165        self.metrics = self.metrics.with_choice_size(size);
166        self
167    }
168
169    pub fn with_slider_size(mut self, size: ComponentSize) -> Self {
170        self.metrics = self.metrics.with_slider_size(size);
171        self
172    }
173
174    pub fn with_progress_size(mut self, size: ComponentSize) -> Self {
175        self.metrics = self.metrics.with_progress_size(size);
176        self
177    }
178
179    pub(crate) fn apply_metrics(&self, root: &mut crate::El) {
180        self.metrics.apply_to_tree(root);
181        apply_font_family(root, self.font_family);
182        apply_mono_font_family(root, self.mono_font_family);
183    }
184
185    /// Shorthand for `self.palette().resolve(c)`. Library code that
186    /// derives a color from a token (e.g. via `darken`/`lighten`/`mix`)
187    /// should resolve through the palette **before** applying the op
188    /// so the derivation is computed against the active palette's rgb,
189    /// not the token's compile-time fallback.
190    pub fn resolve(&self, c: Color) -> Color {
191        self.palette.resolve(c)
192    }
193
194    /// Route all implicit surfaces through a custom shader.
195    ///
196    /// The draw-op pass still emits the familiar rounded-rect uniforms
197    /// (`fill`, `stroke`, `radius`, `shadow`, `focus_color`, …). When
198    /// `rounded_rect_slots` is enabled, those values are also copied into
199    /// `vec_a`..`vec_d`, matching the cross-backend [`crate::paint::QuadInstance`]
200    /// ABI so custom shaders can be drop-in material replacements.
201    pub fn with_surface_shader(mut self, shader: &'static str) -> Self {
202        self.surface.handle = ShaderHandle::Custom(shader);
203        self.surface.rounded_rect_slots = true;
204        self
205    }
206
207    /// Add a uniform to every implicit surface draw. Existing node
208    /// uniforms win, so a local widget override can still specialize a
209    /// shader parameter.
210    pub fn with_surface_uniform(mut self, key: &'static str, value: UniformValue) -> Self {
211        self.surface.uniforms.insert(key, value);
212        self
213    }
214
215    /// Route a specific semantic surface role through a custom shader.
216    /// Roles without an override use the global surface recipe.
217    pub fn with_role_shader(mut self, role: SurfaceRole, shader: &'static str) -> Self {
218        self.role_mut(role).handle = ShaderHandle::Custom(shader);
219        self.role_mut(role).rounded_rect_slots = true;
220        self
221    }
222
223    /// Add a uniform to a specific semantic surface role.
224    pub fn with_role_uniform(
225        mut self,
226        role: SurfaceRole,
227        key: &'static str,
228        value: UniformValue,
229    ) -> Self {
230        self.role_mut(role).uniforms.insert(key, value);
231        self
232    }
233
234    /// Select the stock material used by native vector icon painters.
235    /// Backends without vector icon support may ignore this while still
236    /// preserving the theme value for API parity.
237    pub fn with_icon_material(mut self, material: IconMaterial) -> Self {
238        self.icon_material = material;
239        self
240    }
241
242    pub fn icon_material(&self) -> IconMaterial {
243        self.icon_material
244    }
245
246    pub(crate) fn surface_handle(&self, role: SurfaceRole) -> ShaderHandle {
247        self.role_theme(role).handle
248    }
249
250    pub(crate) fn apply_surface_uniforms(&self, role: SurfaceRole, uniforms: &mut UniformBlock) {
251        let surface = self.role_theme(role);
252        uniforms
253            .entry("surface_role")
254            .or_insert(UniformValue::F32(role.uniform_id()));
255        apply_role_material(role, uniforms, &self.palette);
256        if surface.rounded_rect_slots {
257            add_rounded_rect_slots(uniforms);
258        }
259        for (key, value) in &surface.uniforms {
260            uniforms.entry(*key).or_insert(*value);
261        }
262    }
263
264    fn role_mut(&mut self, role: SurfaceRole) -> &mut SurfaceTheme {
265        self.roles
266            .entry(role)
267            .or_insert_with(|| self.surface.clone())
268    }
269
270    fn role_theme(&self, role: SurfaceRole) -> &SurfaceTheme {
271        self.roles.get(&role).unwrap_or(&self.surface)
272    }
273}
274
275impl Default for Theme {
276    fn default() -> Self {
277        Self {
278            palette: Palette::default(),
279            metrics: ThemeMetrics::default(),
280            surface: SurfaceTheme {
281                handle: ShaderHandle::Stock(StockShader::RoundedRect),
282                uniforms: UniformBlock::new(),
283                rounded_rect_slots: false,
284            },
285            roles: BTreeMap::new(),
286            icon_material: IconMaterial::Flat,
287            font_family: FontFamily::default(),
288            mono_font_family: FontFamily::JetBrainsMono,
289        }
290    }
291}
292
293#[derive(Clone, Debug)]
294struct SurfaceTheme {
295    handle: ShaderHandle,
296    uniforms: UniformBlock,
297    rounded_rect_slots: bool,
298}
299
300fn add_rounded_rect_slots(uniforms: &mut UniformBlock) {
301    if let Some(fill) = uniforms.get("fill").copied() {
302        uniforms.entry("vec_a").or_insert(fill);
303    }
304    if let Some(stroke) = uniforms.get("stroke").copied() {
305        uniforms.entry("vec_b").or_insert(stroke);
306    }
307
308    let stroke_width = as_f32(uniforms.get("stroke_width")).unwrap_or(0.0);
309    let radius = as_f32(uniforms.get("radius")).unwrap_or(0.0);
310    let shadow = as_f32(uniforms.get("shadow")).unwrap_or(0.0);
311    let focus_width = as_f32(uniforms.get("focus_width")).unwrap_or(0.0);
312    uniforms.entry("vec_c").or_insert(UniformValue::Vec4([
313        stroke_width,
314        radius,
315        shadow,
316        focus_width,
317    ]));
318
319    if let Some(focus_color) = uniforms.get("focus_color").copied() {
320        uniforms.entry("vec_d").or_insert(focus_color);
321    }
322}
323
324fn apply_role_material(role: SurfaceRole, uniforms: &mut UniformBlock, palette: &Palette) {
325    // Sunken/Input fill is derived from `muted` by darken, so the
326    // base must be palette-resolved *before* the op — otherwise the
327    // op runs on the compile-time dark fallback and the surface stays
328    // dark even with a light palette active. Same shape for any future
329    // role that derives an rgb-modified color from a token.
330    match role {
331        SurfaceRole::None => {}
332        SurfaceRole::Panel => {
333            set_color(uniforms, "stroke", tokens::BORDER.with_alpha(210));
334            set_f32(uniforms, "stroke_width", 1.0);
335            set_f32(uniforms, "shadow", tokens::SHADOW_SM);
336        }
337        SurfaceRole::Raised => {
338            default_color(uniforms, "stroke", tokens::BORDER);
339            default_f32(uniforms, "stroke_width", 1.0);
340            default_f32(uniforms, "shadow", tokens::SHADOW_SM * 0.5);
341        }
342        SurfaceRole::Sunken | SurfaceRole::Input => {
343            set_color(
344                uniforms,
345                "fill",
346                palette.resolve(tokens::MUTED).darken(0.08),
347            );
348            set_color(uniforms, "stroke", tokens::INPUT.with_alpha(190));
349            set_f32(uniforms, "stroke_width", 1.0);
350            set_f32(uniforms, "shadow", 0.0);
351        }
352        SurfaceRole::Popover => {
353            set_color(uniforms, "stroke", tokens::INPUT);
354            set_f32(uniforms, "stroke_width", 1.0);
355            set_f32(uniforms, "shadow", tokens::SHADOW_LG);
356        }
357        SurfaceRole::Selected => {
358            default_color(uniforms, "fill", tokens::PRIMARY.with_alpha(28));
359            set_color(uniforms, "stroke", tokens::PRIMARY.with_alpha(110));
360            set_f32(uniforms, "stroke_width", 1.0);
361            set_f32(uniforms, "shadow", 0.0);
362        }
363        SurfaceRole::Current => {
364            default_color(uniforms, "fill", tokens::ACCENT);
365            set_color(uniforms, "stroke", tokens::BORDER.with_alpha(180));
366            set_f32(uniforms, "stroke_width", 1.0);
367            set_f32(uniforms, "shadow", 0.0);
368        }
369        SurfaceRole::Danger => {
370            set_color(uniforms, "stroke", tokens::DESTRUCTIVE);
371            set_f32(uniforms, "stroke_width", 1.0);
372            set_f32(uniforms, "shadow", 0.0);
373        }
374    }
375}
376
377fn apply_font_family(node: &mut crate::El, family: FontFamily) {
378    if !node.explicit_font_family {
379        node.font_family = family;
380    }
381    for child in &mut node.children {
382        apply_font_family(child, family);
383    }
384}
385
386fn apply_mono_font_family(node: &mut crate::El, family: FontFamily) {
387    if !node.explicit_mono_font_family {
388        node.mono_font_family = family;
389    }
390    for child in &mut node.children {
391        apply_mono_font_family(child, family);
392    }
393}
394
395fn default_color(uniforms: &mut UniformBlock, key: &'static str, color: Color) {
396    uniforms.entry(key).or_insert(UniformValue::Color(color));
397}
398
399fn set_color(uniforms: &mut UniformBlock, key: &'static str, color: Color) {
400    uniforms.insert(key, UniformValue::Color(color));
401}
402
403fn default_f32(uniforms: &mut UniformBlock, key: &'static str, value: f32) {
404    uniforms.entry(key).or_insert(UniformValue::F32(value));
405}
406
407fn set_f32(uniforms: &mut UniformBlock, key: &'static str, value: f32) {
408    uniforms.insert(key, UniformValue::F32(value));
409}
410
411fn as_f32(value: Option<&UniformValue>) -> Option<f32> {
412    match value {
413        Some(UniformValue::F32(v)) => Some(*v),
414        _ => None,
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::tree::column;
422    use crate::widgets::text::text;
423
424    #[test]
425    fn theme_can_route_icon_material() {
426        let theme = Theme::default().with_icon_material(IconMaterial::Relief);
427        assert_eq!(theme.icon_material(), IconMaterial::Relief);
428    }
429
430    #[test]
431    fn theme_font_family_applies_to_unset_text_nodes() {
432        let mut root = column([text("Themed")]);
433        Theme::default()
434            .with_font_family(FontFamily::Inter)
435            .apply_metrics(&mut root);
436
437        assert_eq!(root.children[0].font_family, FontFamily::Inter);
438    }
439
440    #[test]
441    fn explicit_font_family_survives_theme_default() {
442        let mut root = column([text("Pinned").roboto()]);
443        Theme::default()
444            .with_font_family(FontFamily::Inter)
445            .apply_metrics(&mut root);
446
447        assert_eq!(root.children[0].font_family, FontFamily::Roboto);
448    }
449
450    #[test]
451    fn theme_mono_font_family_applies_to_unset_text_nodes() {
452        let mut root = column([text("code()").code()]);
453        Theme::default().apply_metrics(&mut root);
454
455        // Default theme value is JetBrainsMono — propagated through
456        // the `apply_mono_font_family` walk to every text-bearing node
457        // that didn't pin its own.
458        assert_eq!(root.children[0].mono_font_family, FontFamily::JetBrainsMono);
459    }
460
461    #[test]
462    fn theme_mono_font_family_swap_is_independent_from_proportional() {
463        let mut root = column([text("body"), text("code()").code()]);
464        Theme::default()
465            .with_font_family(FontFamily::Inter)
466            .with_mono_font_family(FontFamily::Roboto)
467            .apply_metrics(&mut root);
468
469        assert_eq!(root.children[0].font_family, FontFamily::Inter);
470        assert_eq!(root.children[1].mono_font_family, FontFamily::Roboto);
471        // Proportional slot stays Inter even on the code node.
472        assert_eq!(root.children[1].font_family, FontFamily::Inter);
473    }
474
475    #[test]
476    fn explicit_mono_font_family_survives_theme_default() {
477        let mut root = column([text("Pinned").code().jetbrains_mono()]);
478        Theme::default()
479            .with_mono_font_family(FontFamily::Roboto)
480            .apply_metrics(&mut root);
481
482        assert_eq!(root.children[0].mono_font_family, FontFamily::JetBrainsMono);
483    }
484}