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