Skip to main content

damascene_core/theme/
mod.rs

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