Skip to main content

aetna_core/
style.rs

1//! Style modifier methods on [`El`] — kind-aware via [`StyleProfile`].
2//!
3//! Each component declares its [`StyleProfile`] in its constructor.
4//! Style modifiers (`.primary`, `.success`, `.muted`, etc.) dispatch
5//! on the profile, not on `Kind`. That means adding a new component
6//! is a self-contained file change: declare a profile, the existing
7//! modifier vocabulary just works.
8//!
9//! Profile semantics:
10//!
11//! - [`StyleProfile::Solid`] — color modifiers produce solid fills
12//!   (Button, Toggle thumb, …).
13//! - [`StyleProfile::Tinted`] — color modifiers produce tinted alpha
14//!   fills with status-colored text (Badge, highlighted Card, …).
15//! - [`StyleProfile::Surface`] — color modifiers tint a subtle bg;
16//!   `.muted` swaps to a neutral surface (Card, TextField, Select, …).
17//! - [`StyleProfile::TextOnly`] — color modifiers only change text color
18//!   (Text, Heading, …).
19//!
20//! Modifier groups in this file:
21//!
22//! - **Color/status:** `primary`, `success`, `warning`, `destructive`, `info`
23//! - **Surface variants:** `secondary`, `ghost`, `outline`, `muted`
24//! - **Semantic states:** `selected`, `current`, `disabled`, `invalid`, `loading`
25//! - **Typography roles:** `caption`, `label`, `body`, `title`, `heading`, `display`, `code`
26//! - **Text shape:** `bold`, `semibold`, `small`, `xsmall`, `color`
27
28use crate::metrics::ComponentSize;
29use crate::tokens;
30use crate::tree::*;
31
32/// How a component reacts to style/color modifiers.
33///
34/// Set once in the component's constructor; the modifier methods dispatch
35/// on this rather than on [`Kind`], so adding a new component never
36/// requires editing this file.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38#[non_exhaustive]
39pub enum StyleProfile {
40    Solid,
41    Tinted,
42    Surface,
43    #[default]
44    TextOnly,
45}
46
47impl El {
48    // ===== Color / status (profile-aware) =====
49
50    pub fn primary(self) -> Self {
51        tint(self, tokens::PRIMARY)
52    }
53    pub fn success(self) -> Self {
54        tint(self, tokens::SUCCESS)
55    }
56    pub fn warning(self) -> Self {
57        tint(self, tokens::WARNING)
58    }
59    pub fn destructive(self) -> Self {
60        tint(self, tokens::DESTRUCTIVE)
61    }
62    pub fn info(self) -> Self {
63        tint(self, tokens::INFO)
64    }
65
66    // ===== Surface variants =====
67
68    /// Default-styled secondary surface. This is the default look for
69    /// `button(...)`; calling `.secondary()` makes intent explicit.
70    pub fn secondary(mut self) -> Self {
71        self.fill = Some(tokens::SECONDARY);
72        self.stroke = Some(tokens::BORDER);
73        self.stroke_width = 1.0;
74        set_content_color(&mut self, tokens::SECONDARY_FOREGROUND);
75        self.font_weight = FontWeight::Medium;
76        self
77    }
78
79    /// No fill, no border. Low-emphasis actions like "Cancel" alongside
80    /// a primary "Save".
81    pub fn ghost(mut self) -> Self {
82        self.fill = None;
83        self.stroke = None;
84        self.stroke_width = 0.0;
85        set_content_color(&mut self, tokens::MUTED_FOREGROUND);
86        self
87    }
88
89    /// Outline-only style: no fill, prominent border.
90    pub fn outline(mut self) -> Self {
91        self.fill = None;
92        self.stroke = Some(tokens::INPUT);
93        self.stroke_width = 1.0;
94        set_content_color(&mut self, tokens::FOREGROUND);
95        self
96    }
97
98    /// Muted/neutral emphasis. On surface profiles this swaps to a
99    /// neutral background; on text-only profiles it switches the text
100    /// color to muted-foreground.
101    pub fn muted(mut self) -> Self {
102        match self.style_profile {
103            StyleProfile::Solid | StyleProfile::Tinted | StyleProfile::Surface => {
104                self.fill = Some(tokens::MUTED);
105                self.stroke = Some(tokens::BORDER);
106                self.stroke_width = 1.0;
107                set_content_color(&mut self, tokens::MUTED_FOREGROUND);
108            }
109            StyleProfile::TextOnly => {
110                set_content_color(&mut self, tokens::MUTED_FOREGROUND);
111            }
112        }
113        self
114    }
115
116    // ===== Semantic states =====
117
118    /// Selected row/item treatment. Use for the item that is selected
119    /// inside a collection, not for transient keyboard focus.
120    pub fn selected(mut self) -> Self {
121        if text_only_leaf(&self) {
122            self.text_color = Some(tokens::PRIMARY);
123        } else if matches!(self.kind, Kind::Custom("item")) {
124            self.style_profile = StyleProfile::Surface;
125            self.surface_role = SurfaceRole::Selected;
126            self.fill = Some(tokens::PRIMARY.with_alpha(18));
127            self.stroke = Some(tokens::PRIMARY.with_alpha(90));
128            self.stroke_width = 1.0;
129            set_content_color(&mut self, tokens::FOREGROUND);
130            set_item_rail(&mut self, tokens::PRIMARY);
131        } else {
132            match self.style_profile {
133                StyleProfile::TextOnly => {}
134                StyleProfile::Solid | StyleProfile::Tinted | StyleProfile::Surface => {}
135            }
136            {
137                self.style_profile = StyleProfile::Surface;
138                self.surface_role = SurfaceRole::Selected;
139                self.fill = Some(tokens::PRIMARY.with_alpha(28));
140                self.stroke = Some(tokens::PRIMARY.with_alpha(90));
141                self.stroke_width = 1.0;
142                set_content_color(&mut self, tokens::FOREGROUND);
143            }
144        }
145        self
146    }
147
148    /// Current navigation/page treatment. Slightly quieter than
149    /// [`Self::selected`] so nav chrome does not compete with content.
150    pub fn current(mut self) -> Self {
151        if text_only_leaf(&self) {
152            self.text_color = Some(tokens::FOREGROUND);
153            self.font_weight = FontWeight::Semibold;
154        } else if matches!(self.kind, Kind::Custom("item")) {
155            self.style_profile = StyleProfile::Surface;
156            self.surface_role = SurfaceRole::Current;
157            self.fill = Some(tokens::ACCENT.with_alpha(24));
158            self.stroke = Some(tokens::BORDER);
159            self.stroke_width = 1.0;
160            set_content_color(&mut self, tokens::FOREGROUND);
161            set_item_rail(&mut self, tokens::PRIMARY);
162        } else {
163            self.style_profile = StyleProfile::Surface;
164            self.surface_role = SurfaceRole::Current;
165            self.fill = Some(tokens::ACCENT);
166            self.stroke = Some(tokens::BORDER);
167            self.stroke_width = 1.0;
168            set_content_color(&mut self, tokens::ACCENT_FOREGROUND);
169            self.font_weight = FontWeight::Semibold;
170        }
171        self
172    }
173
174    /// Disabled treatment for controls and rows. Also removes the node
175    /// from focus order and blocks pointer hits on this element.
176    pub fn disabled(mut self) -> Self {
177        self.opacity = tokens::DISABLED_ALPHA;
178        self.focusable = false;
179        self.block_pointer = true;
180        if text_only_leaf(&self) {
181            self.text_color = Some(tokens::MUTED_FOREGROUND);
182        }
183        self
184    }
185
186    /// Invalid/error treatment for inputs, rows, and validation badges.
187    pub fn invalid(mut self) -> Self {
188        if !text_only_leaf(&self) {
189            self.style_profile = StyleProfile::Surface;
190            self.surface_role = SurfaceRole::Danger;
191        }
192        self.stroke = Some(tokens::DESTRUCTIVE);
193        self.stroke_width = 1.0;
194        if text_only_leaf(&self) {
195            self.text_color = Some(tokens::DESTRUCTIVE);
196        }
197        self
198    }
199
200    /// Loading treatment for a direct text-bearing node. Container
201    /// widgets can still use this for opacity even when they do not
202    /// have their own label text.
203    pub fn loading(mut self) -> Self {
204        self.opacity = self.opacity.min(0.78);
205        if let Some(label) = &mut self.text {
206            label.push_str("...");
207        }
208        self
209    }
210
211    // ===== Typography roles =====
212
213    pub fn text_role(mut self, role: TextRole) -> Self {
214        self.text_role = role;
215        apply_text_role(&mut self);
216        self
217    }
218
219    pub fn caption(self) -> Self {
220        self.text_role(TextRole::Caption)
221    }
222
223    pub fn label(self) -> Self {
224        self.text_role(TextRole::Label)
225    }
226
227    pub fn body(self) -> Self {
228        self.text_role(TextRole::Body)
229    }
230
231    pub fn title(self) -> Self {
232        self.text_role(TextRole::Title)
233    }
234
235    pub fn heading(self) -> Self {
236        self.text_role(TextRole::Heading)
237    }
238
239    pub fn display(self) -> Self {
240        self.text_role(TextRole::Display)
241    }
242
243    // ===== Text shape =====
244
245    pub fn bold(mut self) -> Self {
246        self.font_weight = FontWeight::Bold;
247        self
248    }
249    pub fn semibold(mut self) -> Self {
250        self.font_weight = FontWeight::Semibold;
251        self
252    }
253    pub fn small(mut self) -> Self {
254        if text_only_leaf(&self) {
255            apply_type_token(&mut self, tokens::TEXT_SM);
256        } else {
257            self.component_size = Some(ComponentSize::Sm);
258        }
259        self
260    }
261    pub fn xsmall(mut self) -> Self {
262        if text_only_leaf(&self) {
263            apply_type_token(&mut self, tokens::TEXT_XS);
264        } else {
265            self.component_size = Some(ComponentSize::Xs);
266        }
267        self
268    }
269    /// Set an explicit text color.
270    pub fn color(mut self, c: Color) -> Self {
271        self.text_color = Some(c);
272        self
273    }
274}
275
276fn text_only_leaf(el: &El) -> bool {
277    matches!(el.style_profile, StyleProfile::TextOnly) && el.text.is_some()
278}
279
280fn apply_type_token(el: &mut El, token: tokens::TypeToken) {
281    el.font_size = token.size;
282    el.line_height = token.line_height;
283}
284
285fn apply_text_role(el: &mut El) {
286    // Non-Code roles default to the proportional face; explicit
287    // `.mono()` (which sets `explicit_mono`) wins so the natural
288    // reading order `text(s).mono().caption()` keeps the mono family.
289    // The Code role intentionally forces mono regardless — that's its
290    // whole purpose, and the explicit override would only be set true,
291    // never false, so there's no conflict to resolve.
292    let clear_mono = |el: &mut El| {
293        if !el.explicit_mono {
294            el.font_mono = false;
295        }
296    };
297    match el.text_role {
298        TextRole::Body => {
299            apply_type_token(el, tokens::TEXT_SM);
300            el.font_weight = FontWeight::Regular;
301            clear_mono(el);
302            el.text_color = Some(tokens::FOREGROUND);
303        }
304        TextRole::Caption => {
305            apply_type_token(el, tokens::TEXT_XS);
306            el.font_weight = FontWeight::Regular;
307            clear_mono(el);
308            el.text_color = Some(tokens::MUTED_FOREGROUND);
309        }
310        TextRole::Label => {
311            apply_type_token(el, tokens::TEXT_SM);
312            el.font_weight = FontWeight::Medium;
313            clear_mono(el);
314            el.text_color = Some(tokens::FOREGROUND);
315        }
316        TextRole::Title => {
317            apply_type_token(el, tokens::TEXT_BASE);
318            el.font_weight = FontWeight::Semibold;
319            clear_mono(el);
320            el.text_color = Some(tokens::FOREGROUND);
321        }
322        TextRole::Heading => {
323            apply_type_token(el, tokens::TEXT_2XL);
324            el.font_weight = FontWeight::Semibold;
325            clear_mono(el);
326            el.text_color = Some(tokens::FOREGROUND);
327        }
328        TextRole::Display => {
329            apply_type_token(el, tokens::TEXT_3XL);
330            el.font_weight = FontWeight::Bold;
331            clear_mono(el);
332            el.text_color = Some(tokens::FOREGROUND);
333        }
334        TextRole::Code => {
335            apply_type_token(el, tokens::TEXT_XS);
336            el.font_weight = FontWeight::Regular;
337            el.font_mono = true;
338            el.text_color = Some(tokens::FOREGROUND);
339        }
340    }
341}
342
343fn tint(mut el: El, c: Color) -> El {
344    match el.style_profile {
345        StyleProfile::Solid => {
346            el.fill = Some(c);
347            el.stroke = Some(c);
348            el.stroke_width = 1.0;
349            set_content_color(&mut el, text_on_solid(c));
350            el.font_weight = FontWeight::Semibold;
351        }
352        StyleProfile::Tinted => {
353            el.fill = Some(c.with_alpha(38));
354            el.stroke = Some(c.with_alpha(120));
355            el.stroke_width = 1.0;
356            set_content_color(&mut el, c);
357        }
358        StyleProfile::Surface => {
359            el.fill = Some(c.with_alpha(38));
360            el.stroke = Some(c.with_alpha(120));
361            el.stroke_width = 1.0;
362            set_content_color(&mut el, c);
363        }
364        StyleProfile::TextOnly => {
365            set_content_color(&mut el, c);
366        }
367    }
368    el
369}
370
371fn set_content_color(el: &mut El, color: Color) {
372    el.text_color = Some(color);
373    for child in &mut el.children {
374        if child.text.is_some() || child.icon.is_some() {
375            child.text_color = Some(color);
376        }
377    }
378}
379
380fn set_item_rail(el: &mut El, color: Color) {
381    for child in &mut el.children {
382        if matches!(child.kind, Kind::Custom("item_rail")) {
383            child.fill = Some(color);
384            child.opacity = 1.0;
385        }
386    }
387}
388
389/// Pick a contrasting text color for a solid background fill.
390///
391/// Rec. 601 luminance threshold tuned so light/saturated fills (accent
392/// blue, success green, warning yellow) get dark text, and darker
393/// saturated fills (destructive red) get light text.
394fn text_on_solid(c: Color) -> Color {
395    match c.token {
396        Some("primary") => return tokens::PRIMARY_FOREGROUND,
397        Some("secondary") => return tokens::SECONDARY_FOREGROUND,
398        Some("accent") => return tokens::ACCENT_FOREGROUND,
399        Some("destructive") => return tokens::DESTRUCTIVE_FOREGROUND,
400        Some("success") => return tokens::SUCCESS_FOREGROUND,
401        Some("warning") => return tokens::WARNING_FOREGROUND,
402        Some("info") => return tokens::INFO_FOREGROUND,
403        _ => {}
404    }
405
406    let lum = 0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32;
407    if lum > 150.0 {
408        Color::rgba(8, 16, 25, 255)
409    } else {
410        Color::rgba(250, 250, 252, 255)
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::{button, button_with_icon, icon_button, row, text};
418
419    #[test]
420    fn selected_marks_surface_with_accent_treatment() {
421        let el = row([text("Selected")]).selected();
422        assert_eq!(el.fill, Some(tokens::PRIMARY.with_alpha(28)));
423        assert_eq!(el.stroke, Some(tokens::PRIMARY.with_alpha(90)));
424        assert_eq!(el.stroke_width, 1.0);
425        assert_eq!(el.surface_role, SurfaceRole::Selected);
426    }
427
428    #[test]
429    fn current_marks_container_as_selected_surface_role() {
430        let el = row([text("Current")]).current();
431        assert_eq!(el.fill, Some(tokens::ACCENT));
432        assert_eq!(el.stroke, Some(tokens::BORDER));
433        assert_eq!(el.surface_role, SurfaceRole::Current);
434        assert_eq!(el.style_profile, StyleProfile::Surface);
435    }
436
437    #[test]
438    fn disabled_removes_focus_and_dims_control() {
439        let el = button("Disabled").disabled();
440        assert!(!el.focusable);
441        assert!(el.block_pointer);
442        assert_eq!(el.opacity, tokens::DISABLED_ALPHA);
443    }
444
445    #[test]
446    fn icon_button_uses_same_solid_style_surface_as_button() {
447        let el = icon_button("menu").primary();
448        assert_eq!(el.icon, Some(crate::IconSource::Builtin(IconName::Menu)));
449        assert_eq!(el.fill, Some(tokens::PRIMARY));
450        assert_eq!(el.text_color, Some(text_on_solid(tokens::PRIMARY)));
451        assert_eq!(el.surface_role, SurfaceRole::Raised);
452    }
453
454    #[test]
455    fn button_with_icon_propagates_variant_content_color() {
456        let el = button_with_icon("upload", "Publish").primary();
457        assert_eq!(el.fill, Some(tokens::PRIMARY));
458        assert_eq!(
459            el.children[0].icon,
460            Some(crate::IconSource::Builtin(IconName::Upload))
461        );
462        let expected = text_on_solid(tokens::PRIMARY);
463        assert_eq!(el.children[0].text_color, Some(expected));
464        assert_eq!(el.children[1].text.as_deref(), Some("Publish"));
465        assert_eq!(el.children[1].text_color, Some(expected));
466    }
467
468    #[test]
469    fn loading_appends_direct_label_text() {
470        let el = button("Save").loading();
471        assert_eq!(el.text.as_deref(), Some("Save..."));
472        assert_eq!(el.opacity, 0.78);
473    }
474
475    #[test]
476    fn text_roles_apply_inspectable_typographic_defaults() {
477        let caption = text("Caption").caption();
478        assert_eq!(caption.text_role, TextRole::Caption);
479        assert_eq!(caption.font_size, tokens::TEXT_XS.size);
480        assert_eq!(caption.line_height, tokens::TEXT_XS.line_height);
481        assert_eq!(caption.text_color, Some(tokens::MUTED_FOREGROUND));
482
483        let label = text("Label").label();
484        assert_eq!(label.text_role, TextRole::Label);
485        assert_eq!(label.font_size, tokens::TEXT_SM.size);
486        assert_eq!(label.line_height, tokens::TEXT_SM.line_height);
487        assert_eq!(label.font_weight, FontWeight::Medium);
488
489        let code = text("Code").code();
490        assert_eq!(code.text_role, TextRole::Code);
491        assert_eq!(code.font_size, tokens::TEXT_XS.size);
492        assert_eq!(code.line_height, tokens::TEXT_XS.line_height);
493        assert_eq!(code.font_weight, FontWeight::Regular);
494        assert_eq!(code.text_color, Some(tokens::FOREGROUND));
495        assert!(code.font_mono);
496    }
497
498    #[test]
499    fn explicit_mono_survives_subsequent_role_modifier() {
500        // gh#12. The natural reading order `text(s).mono().caption()`
501        // ("small mono caption") used to silently render in the
502        // proportional face — `.caption()` reset `font_mono = false`
503        // because non-Code roles bake the proportional family in. The
504        // `explicit_mono` flag set by `.mono()` now suppresses that
505        // reset for every non-Code role.
506        let mono_first = text("+2").mono().caption();
507        assert!(
508            mono_first.font_mono,
509            "`.mono()` chained before `.caption()` must keep mono on",
510        );
511        // Caption's other defaults still apply.
512        assert_eq!(mono_first.font_size, tokens::TEXT_XS.size);
513        assert_eq!(mono_first.text_color, Some(tokens::MUTED_FOREGROUND));
514
515        // Reversed order — the canonical order — also keeps mono on.
516        let role_first = text("+2").caption().mono();
517        assert!(role_first.font_mono);
518
519        // Same gating across the rest of the role family.
520        for el in [
521            text("+1").mono().body(),
522            text("+1").mono().label(),
523            text("+1").mono().title(),
524            text("+1").mono().heading(),
525            text("+1").mono().display(),
526        ] {
527            assert!(
528                el.font_mono,
529                "explicit .mono() must survive every non-Code role",
530            );
531        }
532
533        // The Code role is unconditionally mono — no explicit_mono
534        // gating needed, but verify nothing regressed.
535        assert!(text("x").mono().code().font_mono);
536    }
537}