aetna-core 0.3.4

Aetna — backend-agnostic UI library core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! Theme-level shader routing.
//!
//! Aetna widgets expose familiar style knobs (`fill`, `stroke`, `radius`,
//! `shadow`) while the renderer resolves those facts into shader bindings.
//! `Theme` is the indirection layer between those two worlds: an app can
//! keep using stock widgets and globally swap the shader recipe that paints
//! implicit surfaces.
//!
//! This is intentionally shader-first. Token colors are still authored as
//! constants today, but surface appearance can already move from
//! `stock::rounded_rect` to a custom material without rewriting every
//! `button`, `card`, or `text_input`.

use std::collections::BTreeMap;

use crate::metrics::{ComponentSize, ThemeMetrics};
use crate::palette::Palette;
use crate::shader::{ShaderHandle, StockShader, UniformBlock, UniformValue};
use crate::tokens;
use crate::tree::{Color, FontFamily, SurfaceRole};
use crate::vector::IconMaterial;

/// Runtime paint theme for implicit widget visuals.
#[derive(Clone, Debug)]
pub struct Theme {
    palette: Palette,
    metrics: ThemeMetrics,
    surface: SurfaceTheme,
    roles: BTreeMap<SurfaceRole, SurfaceTheme>,
    icon_material: IconMaterial,
    font_family: FontFamily,
    mono_font_family: FontFamily,
}

impl Theme {
    /// Current default: stock rounded-rect surfaces with the Aetna Dark
    /// palette (copied from shadcn/ui zinc dark) and compact desktop
    /// metrics.
    pub fn aetna_dark() -> Self {
        Self::default().with_palette(Palette::aetna_dark())
    }

    /// Stock rounded-rect surfaces with the Aetna Light palette (copied
    /// from shadcn/ui zinc light). Drop-in alternative to
    /// [`Self::aetna_dark`] — token references swap rgba at paint time
    /// without rebuilding the widget tree.
    pub fn aetna_light() -> Self {
        Self::default().with_palette(Palette::aetna_light())
    }

    /// Stock rounded-rect surfaces with a Radix Colors slate + blue
    /// dark palette.
    pub fn radix_slate_blue_dark() -> Self {
        Self::default().with_palette(Palette::radix_slate_blue_dark())
    }

    /// Stock rounded-rect surfaces with a Radix Colors slate + blue
    /// light palette.
    pub fn radix_slate_blue_light() -> Self {
        Self::default().with_palette(Palette::radix_slate_blue_light())
    }

    /// Stock rounded-rect surfaces with a Radix Colors sand + amber
    /// dark palette — warm sepia neutrals with a bright amber accent.
    pub fn radix_sand_amber_dark() -> Self {
        Self::default().with_palette(Palette::radix_sand_amber_dark())
    }

    /// Stock rounded-rect surfaces with a Radix Colors sand + amber
    /// light palette.
    pub fn radix_sand_amber_light() -> Self {
        Self::default().with_palette(Palette::radix_sand_amber_light())
    }

    /// Stock rounded-rect surfaces with a Radix Colors mauve + violet
    /// dark palette — purple-tinged neutrals with a violet accent.
    pub fn radix_mauve_violet_dark() -> Self {
        Self::default().with_palette(Palette::radix_mauve_violet_dark())
    }

    /// Stock rounded-rect surfaces with a Radix Colors mauve + violet
    /// light palette.
    pub fn radix_mauve_violet_light() -> Self {
        Self::default().with_palette(Palette::radix_mauve_violet_light())
    }

    /// Replace the runtime color palette. Token references resolve
    /// through the active palette at paint time, so this swaps surface
    /// rgba without rebuilding the widget tree.
    pub fn with_palette(mut self, palette: Palette) -> Self {
        self.palette = palette;
        self
    }

    /// The active runtime palette.
    pub fn palette(&self) -> &Palette {
        &self.palette
    }

    /// The active layout metrics used to resolve stock widget defaults.
    pub fn metrics(&self) -> &ThemeMetrics {
        &self.metrics
    }

    /// The default proportional UI font family applied to text nodes
    /// that do not set `.font_family(...)` themselves.
    pub fn font_family(&self) -> FontFamily {
        self.font_family
    }

    /// Set the default proportional UI font family.
    pub fn with_font_family(mut self, family: FontFamily) -> Self {
        self.font_family = family;
        self
    }

    /// The default monospace font family applied to text nodes that
    /// render as code (`font_mono = true`, `TextRole::Code`) and do
    /// not set `.mono_font_family(...)` themselves. Independent of
    /// [`Self::font_family`] — swapping the proportional face leaves
    /// the code face alone, and vice versa.
    pub fn mono_font_family(&self) -> FontFamily {
        self.mono_font_family
    }

    /// Set the default monospace font family for code-tagged text.
    pub fn with_mono_font_family(mut self, family: FontFamily) -> Self {
        self.mono_font_family = family;
        self
    }

    /// Replace the runtime layout metrics.
    pub fn with_metrics(mut self, metrics: ThemeMetrics) -> Self {
        self.metrics = metrics;
        self
    }

    /// Set the default t-shirt size for stock controls.
    pub fn with_default_component_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_default_component_size(size);
        self
    }

    pub fn with_button_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_button_size(size);
        self
    }

    pub fn with_input_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_input_size(size);
        self
    }

    pub fn with_badge_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_badge_size(size);
        self
    }

    pub fn with_tab_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_tab_size(size);
        self
    }

    pub fn with_choice_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_choice_size(size);
        self
    }

    pub fn with_slider_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_slider_size(size);
        self
    }

    pub fn with_progress_size(mut self, size: ComponentSize) -> Self {
        self.metrics = self.metrics.with_progress_size(size);
        self
    }

    pub(crate) fn apply_metrics(&self, root: &mut crate::El) {
        self.metrics.apply_to_tree(root);
        apply_font_family(root, self.font_family);
        apply_mono_font_family(root, self.mono_font_family);
    }

    /// Shorthand for `self.palette().resolve(c)`. Library code that
    /// derives a color from a token (e.g. via `darken`/`lighten`/`mix`)
    /// should resolve through the palette **before** applying the op
    /// so the derivation is computed against the active palette's rgb,
    /// not the token's compile-time fallback.
    pub fn resolve(&self, c: Color) -> Color {
        self.palette.resolve(c)
    }

    /// Route all implicit surfaces through a custom shader.
    ///
    /// The draw-op pass still emits the familiar rounded-rect uniforms
    /// (`fill`, `stroke`, `radius`, `shadow`, `focus_color`, …). When
    /// `rounded_rect_slots` is enabled, those values are also copied into
    /// `vec_a`..`vec_d`, matching the cross-backend [`crate::paint::QuadInstance`]
    /// ABI so custom shaders can be drop-in material replacements.
    pub fn with_surface_shader(mut self, shader: &'static str) -> Self {
        self.surface.handle = ShaderHandle::Custom(shader);
        self.surface.rounded_rect_slots = true;
        self
    }

    /// Add a uniform to every implicit surface draw. Existing node
    /// uniforms win, so a local widget override can still specialize a
    /// shader parameter.
    pub fn with_surface_uniform(mut self, key: &'static str, value: UniformValue) -> Self {
        self.surface.uniforms.insert(key, value);
        self
    }

    /// Route a specific semantic surface role through a custom shader.
    /// Roles without an override use the global surface recipe.
    pub fn with_role_shader(mut self, role: SurfaceRole, shader: &'static str) -> Self {
        self.role_mut(role).handle = ShaderHandle::Custom(shader);
        self.role_mut(role).rounded_rect_slots = true;
        self
    }

    /// Add a uniform to a specific semantic surface role.
    pub fn with_role_uniform(
        mut self,
        role: SurfaceRole,
        key: &'static str,
        value: UniformValue,
    ) -> Self {
        self.role_mut(role).uniforms.insert(key, value);
        self
    }

    /// Select the stock material used by native vector icon painters.
    /// Backends without vector icon support may ignore this while still
    /// preserving the theme value for API parity.
    pub fn with_icon_material(mut self, material: IconMaterial) -> Self {
        self.icon_material = material;
        self
    }

    pub fn icon_material(&self) -> IconMaterial {
        self.icon_material
    }

    pub(crate) fn surface_handle(&self, role: SurfaceRole) -> ShaderHandle {
        self.role_theme(role).handle
    }

    pub(crate) fn apply_surface_uniforms(&self, role: SurfaceRole, uniforms: &mut UniformBlock) {
        let surface = self.role_theme(role);
        uniforms
            .entry("surface_role")
            .or_insert(UniformValue::F32(role.uniform_id()));
        apply_role_material(role, uniforms, &self.palette);
        if surface.rounded_rect_slots {
            add_rounded_rect_slots(uniforms);
        }
        for (key, value) in &surface.uniforms {
            uniforms.entry(*key).or_insert(*value);
        }
    }

    fn role_mut(&mut self, role: SurfaceRole) -> &mut SurfaceTheme {
        self.roles
            .entry(role)
            .or_insert_with(|| self.surface.clone())
    }

    fn role_theme(&self, role: SurfaceRole) -> &SurfaceTheme {
        self.roles.get(&role).unwrap_or(&self.surface)
    }
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            palette: Palette::default(),
            metrics: ThemeMetrics::default(),
            surface: SurfaceTheme {
                handle: ShaderHandle::Stock(StockShader::RoundedRect),
                uniforms: UniformBlock::new(),
                rounded_rect_slots: false,
            },
            roles: BTreeMap::new(),
            icon_material: IconMaterial::Flat,
            font_family: FontFamily::default(),
            mono_font_family: FontFamily::JetBrainsMono,
        }
    }
}

#[derive(Clone, Debug)]
struct SurfaceTheme {
    handle: ShaderHandle,
    uniforms: UniformBlock,
    rounded_rect_slots: bool,
}

fn add_rounded_rect_slots(uniforms: &mut UniformBlock) {
    if let Some(fill) = uniforms.get("fill").copied() {
        uniforms.entry("vec_a").or_insert(fill);
    }
    if let Some(stroke) = uniforms.get("stroke").copied() {
        uniforms.entry("vec_b").or_insert(stroke);
    }

    let stroke_width = as_f32(uniforms.get("stroke_width")).unwrap_or(0.0);
    let radius = as_f32(uniforms.get("radius")).unwrap_or(0.0);
    let shadow = as_f32(uniforms.get("shadow")).unwrap_or(0.0);
    let focus_width = as_f32(uniforms.get("focus_width")).unwrap_or(0.0);
    uniforms.entry("vec_c").or_insert(UniformValue::Vec4([
        stroke_width,
        radius,
        shadow,
        focus_width,
    ]));

    if let Some(focus_color) = uniforms.get("focus_color").copied() {
        uniforms.entry("vec_d").or_insert(focus_color);
    }
}

fn apply_role_material(role: SurfaceRole, uniforms: &mut UniformBlock, palette: &Palette) {
    // Sunken/Input fill is derived from `muted` by darken, so the
    // base must be palette-resolved *before* the op — otherwise the
    // op runs on the compile-time dark fallback and the surface stays
    // dark even with a light palette active. Same shape for any future
    // role that derives an rgb-modified color from a token.
    match role {
        SurfaceRole::None => {}
        SurfaceRole::Panel => {
            set_color(uniforms, "stroke", tokens::BORDER.with_alpha(210));
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", tokens::SHADOW_SM);
        }
        SurfaceRole::Raised => {
            default_color(uniforms, "stroke", tokens::BORDER);
            default_f32(uniforms, "stroke_width", 1.0);
            default_f32(uniforms, "shadow", tokens::SHADOW_SM * 0.5);
        }
        SurfaceRole::Sunken | SurfaceRole::Input => {
            set_color(
                uniforms,
                "fill",
                palette.resolve(tokens::MUTED).darken(0.08),
            );
            set_color(uniforms, "stroke", tokens::INPUT.with_alpha(190));
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", 0.0);
        }
        SurfaceRole::Popover => {
            set_color(uniforms, "stroke", tokens::INPUT);
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", tokens::SHADOW_LG);
        }
        SurfaceRole::Selected => {
            default_color(uniforms, "fill", tokens::PRIMARY.with_alpha(28));
            set_color(uniforms, "stroke", tokens::PRIMARY.with_alpha(110));
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", 0.0);
        }
        SurfaceRole::Current => {
            default_color(uniforms, "fill", tokens::ACCENT);
            set_color(uniforms, "stroke", tokens::BORDER.with_alpha(180));
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", 0.0);
        }
        SurfaceRole::Danger => {
            set_color(uniforms, "stroke", tokens::DESTRUCTIVE);
            set_f32(uniforms, "stroke_width", 1.0);
            set_f32(uniforms, "shadow", 0.0);
        }
    }
}

fn apply_font_family(node: &mut crate::El, family: FontFamily) {
    if !node.explicit_font_family {
        node.font_family = family;
    }
    for child in &mut node.children {
        apply_font_family(child, family);
    }
}

fn apply_mono_font_family(node: &mut crate::El, family: FontFamily) {
    if !node.explicit_mono_font_family {
        node.mono_font_family = family;
    }
    for child in &mut node.children {
        apply_mono_font_family(child, family);
    }
}

fn default_color(uniforms: &mut UniformBlock, key: &'static str, color: Color) {
    uniforms.entry(key).or_insert(UniformValue::Color(color));
}

fn set_color(uniforms: &mut UniformBlock, key: &'static str, color: Color) {
    uniforms.insert(key, UniformValue::Color(color));
}

fn default_f32(uniforms: &mut UniformBlock, key: &'static str, value: f32) {
    uniforms.entry(key).or_insert(UniformValue::F32(value));
}

fn set_f32(uniforms: &mut UniformBlock, key: &'static str, value: f32) {
    uniforms.insert(key, UniformValue::F32(value));
}

fn as_f32(value: Option<&UniformValue>) -> Option<f32> {
    match value {
        Some(UniformValue::F32(v)) => Some(*v),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tree::column;
    use crate::widgets::text::text;

    #[test]
    fn theme_can_route_icon_material() {
        let theme = Theme::default().with_icon_material(IconMaterial::Relief);
        assert_eq!(theme.icon_material(), IconMaterial::Relief);
    }

    #[test]
    fn theme_font_family_applies_to_unset_text_nodes() {
        let mut root = column([text("Themed")]);
        Theme::default()
            .with_font_family(FontFamily::Inter)
            .apply_metrics(&mut root);

        assert_eq!(root.children[0].font_family, FontFamily::Inter);
    }

    #[test]
    fn explicit_font_family_survives_theme_default() {
        let mut root = column([text("Pinned").roboto()]);
        Theme::default()
            .with_font_family(FontFamily::Inter)
            .apply_metrics(&mut root);

        assert_eq!(root.children[0].font_family, FontFamily::Roboto);
    }

    #[test]
    fn theme_mono_font_family_applies_to_unset_text_nodes() {
        let mut root = column([text("code()").code()]);
        Theme::default().apply_metrics(&mut root);

        // Default theme value is JetBrainsMono — propagated through
        // the `apply_mono_font_family` walk to every text-bearing node
        // that didn't pin its own.
        assert_eq!(root.children[0].mono_font_family, FontFamily::JetBrainsMono);
    }

    #[test]
    fn theme_mono_font_family_swap_is_independent_from_proportional() {
        let mut root = column([text("body"), text("code()").code()]);
        Theme::default()
            .with_font_family(FontFamily::Inter)
            .with_mono_font_family(FontFamily::Roboto)
            .apply_metrics(&mut root);

        assert_eq!(root.children[0].font_family, FontFamily::Inter);
        assert_eq!(root.children[1].mono_font_family, FontFamily::Roboto);
        // Proportional slot stays Inter even on the code node.
        assert_eq!(root.children[1].font_family, FontFamily::Inter);
    }

    #[test]
    fn explicit_mono_font_family_survives_theme_default() {
        let mut root = column([text("Pinned").code().jetbrains_mono()]);
        Theme::default()
            .with_mono_font_family(FontFamily::Roboto)
            .apply_metrics(&mut root);

        assert_eq!(root.children[0].mono_font_family, FontFamily::JetBrainsMono);
    }
}