material_color_rs/
lib.rs

1//! # Material Color Utilities
2//!
3//! Material Design 3 color utilities for Rust, including **HCT** (Hue/Chroma/Tone),
4//! tonal palettes, and dynamic color schemes.
5//!
6//! ## Quick start
7//!
8//! Generate a theme from a seed color and query tokens by name:
9//!
10//! ```rust
11//! use material_color_rs::generate_theme_from_color;
12//!
13//! # fn main() -> Result<(), String> {
14//! let theme = generate_theme_from_color("#39C5BB")?;
15//! let light = &theme.schemes["light"];
16//! let primary = light.get_argb("primary").unwrap();
17//! assert_ne!(primary, 0);
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! ## Data model
23//!
24//! - [`MaterialTheme`] stores colors as `u32` **ARGB** (`0xAARRGGBB`) for fast access.
25//! - [`MaterialThemeJson`] is a hex-string representation intended for import/export.
26//!
27//! ## Feature flags
28//!
29//! - `iced`: enables [`SchemeColors::get_iced`] for converting tokens to `iced::Color`.
30
31pub mod contrast;
32pub mod dynamiccolor;
33pub mod hct;
34pub mod palettes;
35pub mod scheme;
36pub mod utils;
37
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40
41// Re-export commonly used types
42pub use hct::Hct;
43pub use palettes::TonalPalette;
44pub use scheme::{DynamicScheme, Variant};
45
46/// Parses a hex RGB string (`"#RRGGBB"` or `"RRGGBB"`) into an opaque ARGB color (`0xFFRRGGBB`).
47///
48/// Returns an error string if the input is not valid hexadecimal.
49pub fn hex_to_argb(hex: &str) -> Result<u32, String> {
50    let hex = hex.trim_start_matches('#');
51
52    let rgb = u32::from_str_radix(hex, 16).map_err(|e| format!("Invalid hex color: {}", e))?;
53
54    // Add full opacity (0xFF) in alpha channel
55    Ok(0xFF000000 | rgb)
56}
57
58/// Converts an ARGB integer to an uppercase hex RGB string (`"#RRGGBB"`).
59///
60/// The alpha channel is ignored.
61pub fn argb_to_hex(argb: u32) -> String {
62    format!("#{:06X}", argb & 0x00FFFFFF)
63}
64
65/// Converts an ARGB integer to an `(r, g, b)` tuple.
66#[inline]
67pub fn argb_to_rgb(argb: u32) -> (u8, u8, u8) {
68    (
69        ((argb >> 16) & 0xFF) as u8,
70        ((argb >> 8) & 0xFF) as u8,
71        (argb & 0xFF) as u8,
72    )
73}
74
75/// Converts an ARGB integer to an `[r, g, b, a]` array.
76#[inline]
77pub fn argb_to_rgba(argb: u32) -> [u8; 4] {
78    [
79        ((argb >> 16) & 0xFF) as u8, // R
80        ((argb >> 8) & 0xFF) as u8,  // G
81        (argb & 0xFF) as u8,         // B
82        ((argb >> 24) & 0xFF) as u8, // A
83    ]
84}
85
86/// Converts an ARGB integer to an `[r, g, b, a]` float array (each channel in `0.0..=1.0`).
87#[inline]
88pub fn argb_to_rgba_f32(argb: u32) -> [f32; 4] {
89    [
90        ((argb >> 16) & 0xFF) as f32 / 255.0,
91        ((argb >> 8) & 0xFF) as f32 / 255.0,
92        (argb & 0xFF) as f32 / 255.0,
93        ((argb >> 24) & 0xFF) as f32 / 255.0,
94    ]
95}
96
97// ============= Core Data Structures (u32 ARGB format) =============
98
99/// A token map for a single scheme (e.g. `"light"` or `"dark"`).
100///
101/// Keys are Material token names such as `"primary"` or `"onPrimaryContainer"`.
102/// Values are `u32` ARGB (`0xAARRGGBB`).
103///
104/// Note: token *function* names in [`crate::dynamiccolor::MaterialDynamicColors`] use `snake_case`,
105/// while these scheme keys follow the JSON-style `camelCase` names used by common theme exports.
106#[derive(Debug, Clone)]
107pub struct SchemeColors {
108    colors: HashMap<String, u32>,
109}
110
111impl SchemeColors {
112    /// Creates a new token map from a pre-built `HashMap`.
113    pub fn new(colors: HashMap<String, u32>) -> Self {
114        Self { colors }
115    }
116
117    /// Returns the token value in ARGB format (`0xAARRGGBB`).
118    pub fn get_argb(&self, role: &str) -> Option<u32> {
119        self.colors.get(role).copied()
120    }
121
122    /// Returns the token value as an `(r, g, b)` tuple.
123    pub fn get_rgb(&self, role: &str) -> Option<(u8, u8, u8)> {
124        self.get_argb(role).map(argb_to_rgb)
125    }
126
127    /// Returns the token value as an `[r, g, b, a]` byte array.
128    pub fn get_rgba(&self, role: &str) -> Option<[u8; 4]> {
129        self.get_argb(role).map(argb_to_rgba)
130    }
131
132    /// Returns the token value as an `[r, g, b, a]` float array (each channel in `0.0..=1.0`).
133    pub fn get_rgba_f32(&self, role: &str) -> Option<[f32; 4]> {
134        self.get_argb(role).map(argb_to_rgba_f32)
135    }
136
137    /// Returns an iterator over all token names in this scheme.
138    ///
139    /// Ordering is unspecified.
140    pub fn roles(&self) -> impl Iterator<Item = &String> {
141        self.colors.keys()
142    }
143}
144
145// ============= GUI Framework Adapters =============
146
147#[cfg(feature = "iced")]
148impl SchemeColors {
149    /// Returns the token value as an `iced::Color`.
150    ///
151    /// This is a direct conversion from ARGB channels to normalized floats; no parsing is involved.
152    pub fn get_iced(&self, role: &str) -> Option<iced::Color> {
153        self.get_rgba_f32(role)
154            .map(|[r, g, b, a]| iced::Color::from_rgba(r, g, b, a))
155    }
156}
157
158/// A generated Material theme.
159///
160/// This is the “fast path” representation: colors are stored as ARGB integers and token lookups
161/// are string-keyed in [`SchemeColors`].
162#[derive(Debug, Clone)]
163pub struct MaterialTheme {
164    /// Descriptive header used by some exporters.
165    pub description: String,
166    /// The seed color used to generate palettes and schemes.
167    pub seed_color: u32,
168    /// A minimal set of “core” colors (currently includes `"primary"`).
169    pub core_colors: HashMap<String, u32>,
170    /// Named schemes such as `"light"`, `"dark"`, and contrast variants.
171    pub schemes: HashMap<String, SchemeColors>,
172    /// Named tonal palettes (`"primary"`, `"secondary"`, `"tertiary"`, `"neutral"`, `"neutral-variant"`).
173    pub palettes: HashMap<String, TonalPalette>,
174}
175
176// ============= JSON Serialization Layer =============
177
178/// Material theme JSON structure, compatible with common Material Theme exports.
179///
180/// This type is intended for import/export. For in-memory work, prefer [`MaterialTheme`].
181#[derive(Debug, Serialize, Deserialize)]
182pub struct MaterialThemeJson {
183    pub description: String,
184    pub seed: String,
185    #[serde(rename = "coreColors")]
186    pub core_colors: HashMap<String, String>,
187    #[serde(rename = "extendedColors")]
188    pub extended_colors: Vec<serde_json::Value>,
189    pub schemes: HashMap<String, HashMap<String, String>>,
190    pub palettes: HashMap<String, HashMap<String, String>>,
191}
192
193// ============= Conversion Methods =============
194
195impl MaterialTheme {
196    /// Converts this theme into a JSON-friendly representation.
197    ///
198    /// Notes:
199    /// - Colors are encoded as `"#RRGGBB"` strings.
200    /// - `extendedColors` is currently emitted as an empty array.
201    pub fn to_json(&self) -> MaterialThemeJson {
202        MaterialThemeJson {
203            description: self.description.clone(),
204            seed: argb_to_hex(self.seed_color),
205            core_colors: self
206                .core_colors
207                .iter()
208                .map(|(k, &v)| (k.clone(), argb_to_hex(v)))
209                .collect(),
210            extended_colors: vec![],
211            schemes: self
212                .schemes
213                .iter()
214                .map(|(name, scheme)| {
215                    let colors = scheme
216                        .colors
217                        .iter()
218                        .map(|(k, &v)| (k.clone(), argb_to_hex(v)))
219                        .collect();
220                    (name.clone(), colors)
221                })
222                .collect(),
223            palettes: self
224                .palettes
225                .iter()
226                .map(|(name, palette)| {
227                    let tones = [
228                        0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100,
229                    ];
230                    let tone_map = tones
231                        .iter()
232                        .map(|&t| (t.to_string(), argb_to_hex(palette.get(t))))
233                        .collect();
234                    (name.clone(), tone_map)
235                })
236                .collect(),
237        }
238    }
239
240    /// Reconstructs a theme from [`MaterialThemeJson`].
241    ///
242    /// Palette reconstruction uses tone `50` as a representative color to infer hue/chroma, which
243    /// is sufficient for most workflows but may not exactly reproduce the original palette cache.
244    pub fn from_json(json: MaterialThemeJson) -> Result<Self, String> {
245        let seed_color = hex_to_argb(&json.seed)?;
246
247        let core_colors = json
248            .core_colors
249            .iter()
250            .map(|(k, v)| hex_to_argb(v).map(|argb| (k.clone(), argb)))
251            .collect::<Result<HashMap<_, _>, _>>()?;
252
253        let schemes = json
254            .schemes
255            .iter()
256            .map(|(name, colors)| -> Result<(String, SchemeColors), String> {
257                let argb_colors = colors
258                    .iter()
259                    .map(|(k, v)| hex_to_argb(v).map(|argb| (k.clone(), argb)))
260                    .collect::<Result<HashMap<_, _>, _>>()?;
261                Ok((name.clone(), SchemeColors::new(argb_colors)))
262            })
263            .collect::<Result<HashMap<_, _>, _>>()?;
264
265        // Reconstruct palettes from tone map
266        let palettes = json
267            .palettes
268            .iter()
269            .map(
270                |(name, tone_map)| -> Result<(String, TonalPalette), String> {
271                    // Get a representative color to determine hue/chroma
272                    let tone_50_hex = tone_map
273                        .get("50")
274                        .ok_or_else(|| format!("Missing tone 50 in palette {}", name))?;
275                    let argb_50 = hex_to_argb(tone_50_hex)?;
276                    let hct = Hct::from_int(argb_50);
277                    let palette = TonalPalette::of(hct.hue(), hct.chroma());
278                    Ok((name.clone(), palette))
279                },
280            )
281            .collect::<Result<HashMap<_, _>, _>>()?;
282
283        Ok(MaterialTheme {
284            description: json.description,
285            seed_color,
286            core_colors,
287            schemes,
288            palettes,
289        })
290    }
291}
292
293/// Generates a Material theme from a seed color.
294///
295/// The returned theme includes:
296/// - 6 schemes: `light`, `light-medium-contrast`, `light-high-contrast`, `dark`,
297///   `dark-medium-contrast`, `dark-high-contrast`
298/// - 5 palettes: `primary`, `secondary`, `tertiary`, `neutral`, `neutral-variant`
299pub fn generate_theme_from_color(hex_color: &str) -> Result<MaterialTheme, String> {
300    let argb = hex_to_argb(hex_color)?;
301    let source = Hct::from_int(argb);
302
303    // Generate 6 schemes with different contrast levels
304    // Light: normal (0.0), medium (0.5), high (1.0)
305    // Dark: normal (0.0), medium (0.5), high (1.0)
306    let light_scheme = DynamicScheme::tonal_spot(source.clone(), false, 0.0);
307    let light_medium_scheme = DynamicScheme::tonal_spot(source.clone(), false, 0.5);
308    let light_high_scheme = DynamicScheme::tonal_spot(source.clone(), false, 1.0);
309
310    let dark_scheme = DynamicScheme::tonal_spot(source.clone(), true, 0.0);
311    let dark_medium_scheme = DynamicScheme::tonal_spot(source.clone(), true, 0.5);
312    let dark_high_scheme = DynamicScheme::tonal_spot(source.clone(), true, 1.0);
313
314    // Build scheme colors for each contrast level
315    let mut schemes = HashMap::new();
316    schemes.insert("light".to_string(), build_scheme_colors(&light_scheme));
317    schemes.insert(
318        "light-medium-contrast".to_string(),
319        build_scheme_colors(&light_medium_scheme),
320    );
321    schemes.insert(
322        "light-high-contrast".to_string(),
323        build_scheme_colors(&light_high_scheme),
324    );
325    schemes.insert("dark".to_string(), build_scheme_colors(&dark_scheme));
326    schemes.insert(
327        "dark-medium-contrast".to_string(),
328        build_scheme_colors(&dark_medium_scheme),
329    );
330    schemes.insert(
331        "dark-high-contrast".to_string(),
332        build_scheme_colors(&dark_high_scheme),
333    );
334
335    // Build palettes (use light scheme for palette generation)
336    let palettes = build_palettes(&light_scheme);
337
338    let mut core_colors = HashMap::new();
339    core_colors.insert("primary".to_string(), argb);
340
341    Ok(MaterialTheme {
342        description: "TYPE: CUSTOM\nMaterial Theme export".to_string(),
343        seed_color: argb,
344        core_colors,
345        schemes,
346        palettes,
347    })
348}
349
350fn build_scheme_colors(scheme: &DynamicScheme) -> SchemeColors {
351    use dynamiccolor::MaterialDynamicColors;
352
353    let mut colors = HashMap::new();
354
355    // Use MaterialDynamicColors for all colors - automatically adapts to contrast level
356    colors.insert(
357        "primary".to_string(),
358        MaterialDynamicColors::primary().get_argb(scheme),
359    );
360    colors.insert(
361        "onPrimary".to_string(),
362        MaterialDynamicColors::on_primary().get_argb(scheme),
363    );
364    colors.insert(
365        "primaryContainer".to_string(),
366        MaterialDynamicColors::primary_container().get_argb(scheme),
367    );
368    colors.insert(
369        "onPrimaryContainer".to_string(),
370        MaterialDynamicColors::on_primary_container().get_argb(scheme),
371    );
372
373    colors.insert(
374        "secondary".to_string(),
375        MaterialDynamicColors::secondary().get_argb(scheme),
376    );
377    colors.insert(
378        "onSecondary".to_string(),
379        MaterialDynamicColors::on_secondary().get_argb(scheme),
380    );
381    colors.insert(
382        "secondaryContainer".to_string(),
383        MaterialDynamicColors::secondary_container().get_argb(scheme),
384    );
385    colors.insert(
386        "onSecondaryContainer".to_string(),
387        MaterialDynamicColors::on_secondary_container().get_argb(scheme),
388    );
389
390    colors.insert(
391        "tertiary".to_string(),
392        MaterialDynamicColors::tertiary().get_argb(scheme),
393    );
394    colors.insert(
395        "onTertiary".to_string(),
396        MaterialDynamicColors::on_tertiary().get_argb(scheme),
397    );
398    colors.insert(
399        "tertiaryContainer".to_string(),
400        MaterialDynamicColors::tertiary_container().get_argb(scheme),
401    );
402    colors.insert(
403        "onTertiaryContainer".to_string(),
404        MaterialDynamicColors::on_tertiary_container().get_argb(scheme),
405    );
406
407    colors.insert(
408        "error".to_string(),
409        MaterialDynamicColors::error().get_argb(scheme),
410    );
411    colors.insert(
412        "onError".to_string(),
413        MaterialDynamicColors::on_error().get_argb(scheme),
414    );
415    colors.insert(
416        "errorContainer".to_string(),
417        MaterialDynamicColors::error_container().get_argb(scheme),
418    );
419    colors.insert(
420        "onErrorContainer".to_string(),
421        MaterialDynamicColors::on_error_container().get_argb(scheme),
422    );
423
424    colors.insert(
425        "background".to_string(),
426        MaterialDynamicColors::background().get_argb(scheme),
427    );
428    colors.insert(
429        "onBackground".to_string(),
430        MaterialDynamicColors::on_background().get_argb(scheme),
431    );
432
433    colors.insert(
434        "surface".to_string(),
435        MaterialDynamicColors::surface().get_argb(scheme),
436    );
437    colors.insert(
438        "onSurface".to_string(),
439        MaterialDynamicColors::on_surface().get_argb(scheme),
440    );
441    colors.insert(
442        "surfaceVariant".to_string(),
443        MaterialDynamicColors::surface_variant().get_argb(scheme),
444    );
445    colors.insert(
446        "onSurfaceVariant".to_string(),
447        MaterialDynamicColors::on_surface_variant().get_argb(scheme),
448    );
449
450    colors.insert(
451        "outline".to_string(),
452        MaterialDynamicColors::outline().get_argb(scheme),
453    );
454    colors.insert(
455        "outlineVariant".to_string(),
456        MaterialDynamicColors::outline_variant().get_argb(scheme),
457    );
458    colors.insert(
459        "shadow".to_string(),
460        MaterialDynamicColors::shadow().get_argb(scheme),
461    );
462    colors.insert(
463        "scrim".to_string(),
464        MaterialDynamicColors::scrim().get_argb(scheme),
465    );
466
467    colors.insert(
468        "inverseSurface".to_string(),
469        MaterialDynamicColors::inverse_surface().get_argb(scheme),
470    );
471    colors.insert(
472        "inverseOnSurface".to_string(),
473        MaterialDynamicColors::inverse_on_surface().get_argb(scheme),
474    );
475    colors.insert(
476        "inversePrimary".to_string(),
477        MaterialDynamicColors::inverse_primary().get_argb(scheme),
478    );
479
480    // Fixed colors
481    colors.insert(
482        "primaryFixed".to_string(),
483        MaterialDynamicColors::primary_fixed().get_argb(scheme),
484    );
485    colors.insert(
486        "onPrimaryFixed".to_string(),
487        MaterialDynamicColors::on_primary_fixed().get_argb(scheme),
488    );
489    colors.insert(
490        "primaryFixedDim".to_string(),
491        MaterialDynamicColors::primary_fixed_dim().get_argb(scheme),
492    );
493    colors.insert(
494        "onPrimaryFixedVariant".to_string(),
495        MaterialDynamicColors::on_primary_fixed_variant().get_argb(scheme),
496    );
497
498    colors.insert(
499        "secondaryFixed".to_string(),
500        MaterialDynamicColors::secondary_fixed().get_argb(scheme),
501    );
502    colors.insert(
503        "onSecondaryFixed".to_string(),
504        MaterialDynamicColors::on_secondary_fixed().get_argb(scheme),
505    );
506    colors.insert(
507        "secondaryFixedDim".to_string(),
508        MaterialDynamicColors::secondary_fixed_dim().get_argb(scheme),
509    );
510    colors.insert(
511        "onSecondaryFixedVariant".to_string(),
512        MaterialDynamicColors::on_secondary_fixed_variant().get_argb(scheme),
513    );
514
515    colors.insert(
516        "tertiaryFixed".to_string(),
517        MaterialDynamicColors::tertiary_fixed().get_argb(scheme),
518    );
519    colors.insert(
520        "onTertiaryFixed".to_string(),
521        MaterialDynamicColors::on_tertiary_fixed().get_argb(scheme),
522    );
523    colors.insert(
524        "tertiaryFixedDim".to_string(),
525        MaterialDynamicColors::tertiary_fixed_dim().get_argb(scheme),
526    );
527    colors.insert(
528        "onTertiaryFixedVariant".to_string(),
529        MaterialDynamicColors::on_tertiary_fixed_variant().get_argb(scheme),
530    );
531
532    // Surface variants
533    colors.insert(
534        "surfaceDim".to_string(),
535        MaterialDynamicColors::surface_dim().get_argb(scheme),
536    );
537    colors.insert(
538        "surfaceBright".to_string(),
539        MaterialDynamicColors::surface_bright().get_argb(scheme),
540    );
541    colors.insert(
542        "surfaceContainerLowest".to_string(),
543        MaterialDynamicColors::surface_container_lowest().get_argb(scheme),
544    );
545    colors.insert(
546        "surfaceContainerLow".to_string(),
547        MaterialDynamicColors::surface_container_low().get_argb(scheme),
548    );
549    colors.insert(
550        "surfaceContainer".to_string(),
551        MaterialDynamicColors::surface_container().get_argb(scheme),
552    );
553    colors.insert(
554        "surfaceContainerHigh".to_string(),
555        MaterialDynamicColors::surface_container_high().get_argb(scheme),
556    );
557    colors.insert(
558        "surfaceContainerHighest".to_string(),
559        MaterialDynamicColors::surface_container_highest().get_argb(scheme),
560    );
561    colors.insert(
562        "surfaceTint".to_string(),
563        MaterialDynamicColors::surface_tint().get_argb(scheme),
564    );
565
566    SchemeColors::new(colors)
567}
568
569fn build_palettes(scheme: &DynamicScheme) -> HashMap<String, TonalPalette> {
570    let mut palettes = HashMap::new();
571
572    // Build palettes using "Content" mode, which matches Material Theme Builder
573    // Reference: CorePalette.contentOf in official implementation
574    let source_hct = &scheme.source_color_hct;
575    let source_hue = source_hct.hue();
576    let source_chroma = source_hct.chroma();
577
578    // Primary palette uses source color's actual hue and chroma
579    let primary_palette = TonalPalette::from_hct(source_hct);
580    palettes.insert("primary".to_string(), primary_palette);
581
582    // Secondary palette uses source hue with chroma / 3
583    let secondary_palette = TonalPalette::of(source_hue, source_chroma / 3.0);
584    palettes.insert("secondary".to_string(), secondary_palette);
585
586    // Tertiary palette uses source hue + 60 degrees with chroma / 2
587    let tertiary_hue = (source_hue + 60.0) % 360.0;
588    let tertiary_palette = TonalPalette::of(tertiary_hue, source_chroma / 2.0);
589    palettes.insert("tertiary".to_string(), tertiary_palette);
590
591    // Neutral palette uses source hue with min(chroma / 12, 4)
592    let neutral_palette = TonalPalette::of(source_hue, (source_chroma / 12.0).min(4.0));
593    palettes.insert("neutral".to_string(), neutral_palette);
594
595    // Neutral variant palette uses source hue with min(chroma / 6, 8)
596    let neutral_variant_palette = TonalPalette::of(source_hue, (source_chroma / 6.0).min(8.0));
597    palettes.insert("neutral-variant".to_string(), neutral_variant_palette);
598
599    palettes
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn test_hex_conversion() {
608        let argb = hex_to_argb("#39C5BB").unwrap();
609        assert_eq!(argb, 0xFF39C5BB);
610        assert_eq!(argb_to_hex(argb), "#39C5BB");
611    }
612
613    #[test]
614    fn test_generate_theme() {
615        let theme = generate_theme_from_color("#39C5BB").unwrap();
616        assert!(theme.schemes.contains_key("light"));
617        assert!(theme.schemes.contains_key("dark"));
618        assert!(theme.palettes.contains_key("primary"));
619    }
620}