Skip to main content

native_theme_iced/
lib.rs

1//! iced toolkit connector for native-theme.
2//!
3//! Maps [`native_theme::ResolvedThemeVariant`] data to iced's theming system.
4//!
5//! # Quick Start
6//!
7//! ```ignore
8//! use native_theme_iced::from_preset;
9//!
10//! let (theme, resolved) = from_preset("catppuccin-mocha", true)?;
11//! ```
12//!
13//! Or from the OS-detected theme:
14//!
15//! ```ignore
16//! use native_theme_iced::from_system;
17//!
18//! let (theme, resolved) = from_system()?;
19//! ```
20//!
21//! # Manual Path
22//!
23//! For full control over the resolve/validate/convert pipeline:
24//!
25//! ```rust
26//! use native_theme::ThemeSpec;
27//! use native_theme_iced::to_theme;
28//!
29//! let nt = ThemeSpec::preset("catppuccin-mocha").unwrap();
30//! let resolved = nt.into_variant(false).unwrap().into_resolved().unwrap();
31//! let theme = to_theme(&resolved, "My App");
32//! ```
33//!
34//! # Font Configuration
35//!
36//! To use theme fonts with iced widgets, leak the family name to obtain
37//! the `&'static str` required by [`iced_core::font::Family::Name`]:
38//!
39//! ```ignore
40//! let name: &'static str = Box::leak(
41//!     native_theme_iced::font_family(&resolved).to_string().into_boxed_str()
42//! );
43//! let font = iced_core::Font {
44//!     family: iced_core::font::Family::Name(name),
45//!     weight: native_theme_iced::to_iced_weight(
46//!         native_theme_iced::font_weight(&resolved)
47//!     ),
48//!     ..Default::default()
49//! };
50//! ```
51//!
52//! This is the standard iced pattern for runtime font names. Each leak is
53//! ~10-20 bytes and persists for the app lifetime. Call once at theme init,
54//! not per-frame.
55//!
56//! # Theme Field Coverage
57//!
58//! The connector maps a subset of [`ResolvedThemeVariant`] to iced's theming system:
59//!
60//! | Target | Fields | Source |
61//! |--------|--------|--------|
62//! | `Palette` (6 fields) | background, text, primary, success, warning, danger | `defaults.*` |
63//! | `Extended` overrides (4) | secondary.base.color/text, background.weak.color/text | button.bg/fg, defaults.surface/foreground |
64//! | Widget metrics | button/input padding, border radius, scrollbar width | Per-widget resolved fields |
65//! | Typography | font family/size/weight, mono family/size/weight, line height | `defaults.font.*`, `defaults.mono_font.*` |
66//!
67//! Per-widget geometry beyond padding/radius (e.g., min-width, disabled-opacity)
68//! is not mapped because iced applies these via inline widget configuration,
69//! not through the theme system. Users can read these directly from the
70//! `ResolvedThemeVariant` they pass to [`to_theme()`].
71
72#![warn(missing_docs)]
73#![forbid(unsafe_code)]
74#![deny(clippy::unwrap_used)]
75#![deny(clippy::expect_used)]
76
77pub mod extended;
78pub mod icons;
79pub mod palette;
80
81// Re-export native-theme types that appear in public signatures.
82pub use native_theme::{
83    AnimatedIcon, Error, IconData, IconProvider, IconRole, IconSet, ResolvedThemeVariant, Result,
84    Rgba, SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
85};
86
87/// Create an iced [`iced_core::theme::Theme`] from a [`native_theme::ResolvedThemeVariant`].
88///
89/// Builds a custom theme using `Theme::custom_with_fn()`, which:
90/// 1. Maps the 6 Palette fields from resolved theme colors via [`palette::to_palette()`]
91/// 2. Generates an Extended palette, then overrides secondary and background.weak
92///    entries via [`extended::apply_overrides()`]
93///
94/// The resulting theme carries the mapped Palette and Extended palette. iced's
95/// built-in Catalog trait implementations for all 8 core widgets (Button,
96/// Container, TextInput, Scrollable, Checkbox, Slider, ProgressBar, Tooltip)
97/// automatically derive their Style structs from this palette. No explicit
98/// Catalog implementations are needed.
99///
100/// The `name` sets the theme's display name (visible in theme pickers).
101/// For the common case, use [`from_preset()`] to derive the name automatically.
102#[must_use]
103pub fn to_theme(
104    resolved: &native_theme::ResolvedThemeVariant,
105    name: &str,
106) -> iced_core::theme::Theme {
107    let pal = palette::to_palette(resolved);
108
109    // Capture only the 4 Rgba values (Copy, 4 bytes each) instead of
110    // cloning the entire ResolvedThemeVariant (~2KB with heap data).
111    let btn_bg = resolved.button.background;
112    let btn_fg = resolved.button.foreground;
113    let surface = resolved.defaults.surface;
114    let foreground = resolved.defaults.foreground;
115
116    iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
117        let mut ext = iced_core::theme::palette::Extended::generate(p);
118        ext.secondary.base.color = palette::to_color(btn_bg);
119        ext.secondary.base.text = palette::to_color(btn_fg);
120        ext.background.weak.color = palette::to_color(surface);
121        ext.background.weak.text = palette::to_color(foreground);
122        ext
123    })
124}
125
126/// Load a bundled preset and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
127///
128/// Handles the full pipeline: load preset, pick variant, resolve, validate, convert.
129/// The preset name is used as the theme display name.
130///
131/// # Errors
132///
133/// Returns an error if the preset name is not recognized or if resolution fails.
134#[must_use = "this returns the theme; it does not apply it"]
135pub fn from_preset(
136    name: &str,
137    is_dark: bool,
138) -> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
139    let spec = native_theme::ThemeSpec::preset(name)?;
140    let variant = spec.into_variant(is_dark).ok_or_else(|| {
141        native_theme::Error::Format(format!("preset '{name}' has no light or dark variant"))
142    })?;
143    let resolved = variant.into_resolved()?;
144    let theme = to_theme(&resolved, name);
145    Ok((theme, resolved))
146}
147
148/// Detect the OS theme and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
149///
150/// # Errors
151///
152/// Returns an error if the platform theme cannot be read.
153#[must_use = "this returns the theme; it does not apply it"]
154pub fn from_system()
155-> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
156    let sys = native_theme::SystemTheme::from_system()?;
157    let name = sys.name;
158    let resolved = if sys.is_dark { sys.dark } else { sys.light };
159    let theme = to_theme(&resolved, &name);
160    Ok((theme, resolved))
161}
162
163/// Extension trait for converting a [`SystemTheme`] to an iced theme.
164pub trait SystemThemeExt {
165    /// Convert this system theme to an iced [`Theme`](iced_core::theme::Theme).
166    #[must_use = "this returns the theme; it does not apply it"]
167    fn to_iced_theme(&self) -> iced_core::theme::Theme;
168}
169
170impl SystemThemeExt for native_theme::SystemTheme {
171    fn to_iced_theme(&self) -> iced_core::theme::Theme {
172        to_theme(self.active(), &self.name)
173    }
174}
175
176/// Returns button padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
177///
178/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
179#[must_use]
180pub fn button_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
181    iced_core::Padding::from([
182        resolved.button.padding_vertical,
183        resolved.button.padding_horizontal,
184    ])
185}
186
187/// Returns text input padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
188///
189/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
190#[must_use]
191pub fn input_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
192    iced_core::Padding::from([
193        resolved.input.padding_vertical,
194        resolved.input.padding_horizontal,
195    ])
196}
197
198/// Returns the standard border radius from the resolved theme.
199#[must_use]
200pub fn border_radius(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
201    resolved.defaults.radius
202}
203
204/// Returns the large border radius from the resolved theme.
205#[must_use]
206pub fn border_radius_lg(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
207    resolved.defaults.radius_lg
208}
209
210/// Returns the scrollbar width from the resolved theme.
211#[must_use]
212pub fn scrollbar_width(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
213    resolved.scrollbar.width
214}
215
216/// Returns the primary UI font family name from the resolved theme.
217#[must_use]
218pub fn font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
219    &resolved.defaults.font.family
220}
221
222/// Returns the primary UI font size in logical pixels from the resolved theme.
223///
224/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
225/// is applied.
226#[must_use]
227pub fn font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
228    resolved.defaults.font.size
229}
230
231/// Returns the monospace font family name from the resolved theme.
232#[must_use]
233pub fn mono_font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
234    &resolved.defaults.mono_font.family
235}
236
237/// Returns the monospace font size in logical pixels from the resolved theme.
238///
239/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
240/// is applied.
241#[must_use]
242pub fn mono_font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
243    resolved.defaults.mono_font.size
244}
245
246/// Returns the primary UI font weight (CSS 100-900) from the resolved theme.
247#[must_use]
248pub fn font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
249    resolved.defaults.font.weight
250}
251
252/// Returns the monospace font weight (CSS 100-900) from the resolved theme.
253#[must_use]
254pub fn mono_font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
255    resolved.defaults.mono_font.weight
256}
257
258/// Returns the line height multiplier from the resolved theme.
259///
260/// The raw multiplier (e.g., 1.4). Use with iced's
261/// `LineHeight::Relative(native_theme_iced::line_height_multiplier(&r))`
262/// for Text widgets. Font-size agnostic -- works correctly for both
263/// the primary UI font and monospace text.
264///
265/// For absolute pixels (layout math), multiply by the appropriate
266/// font size: `line_height_multiplier(&r) * font_size(&r)`.
267#[must_use]
268pub fn line_height_multiplier(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
269    resolved.defaults.line_height
270}
271
272/// Convert a CSS font weight (100-900) to an iced [`Weight`](iced_core::font::Weight) enum.
273///
274/// Non-standard weights are rounded to the nearest standard value
275/// (e.g., 350 -> Normal, 550 -> Semibold).
276///
277/// # Example
278///
279/// ```ignore
280/// let weight = native_theme_iced::to_iced_weight(
281///     native_theme_iced::font_weight(&resolved),
282/// );
283/// ```
284#[must_use]
285pub fn to_iced_weight(css_weight: u16) -> iced_core::font::Weight {
286    use iced_core::font::Weight;
287    match css_weight {
288        0..=149 => Weight::Thin,
289        150..=249 => Weight::ExtraLight,
290        250..=349 => Weight::Light,
291        350..=449 => Weight::Normal,
292        450..=549 => Weight::Medium,
293        550..=649 => Weight::Semibold,
294        650..=749 => Weight::Bold,
295        750..=849 => Weight::ExtraBold,
296        850.. => Weight::Black,
297    }
298}
299
300#[cfg(test)]
301#[allow(clippy::unwrap_used, clippy::expect_used)]
302mod tests {
303    use super::*;
304    use native_theme::ThemeSpec;
305
306    fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
307        ThemeSpec::preset("catppuccin-mocha")
308            .unwrap()
309            .into_variant(is_dark)
310            .unwrap()
311            .into_resolved()
312            .unwrap()
313    }
314
315    // === to_theme tests ===
316
317    #[test]
318    fn to_theme_produces_non_default_theme() {
319        let resolved = make_resolved(true);
320        let theme = to_theme(&resolved, "Test Theme");
321
322        assert_ne!(theme, iced_core::theme::Theme::Light);
323        assert_ne!(theme, iced_core::theme::Theme::Dark);
324
325        let palette = theme.palette();
326        // Verify palette was applied from resolved theme
327        assert!(
328            palette.primary.r > 0.0 || palette.primary.g > 0.0 || palette.primary.b > 0.0,
329            "primary should be non-zero"
330        );
331    }
332
333    #[test]
334    fn to_theme_from_preset() {
335        let resolved = make_resolved(false);
336        let theme = to_theme(&resolved, "Default");
337
338        let palette = theme.palette();
339        // Default preset has white-ish background for light
340        assert!(palette.background.r > 0.9);
341    }
342
343    // === Widget metric helper tests ===
344
345    #[test]
346    fn border_radius_returns_resolved_value() {
347        let resolved = make_resolved(false);
348        let r = border_radius(&resolved);
349        assert!(r > 0.0, "resolved radius should be > 0");
350    }
351
352    #[test]
353    fn border_radius_lg_returns_resolved_value() {
354        let resolved = make_resolved(false);
355        let r = border_radius_lg(&resolved);
356        assert!(r > 0.0, "resolved radius_lg should be > 0");
357        assert!(
358            r >= border_radius(&resolved),
359            "radius_lg should be >= radius"
360        );
361    }
362
363    #[test]
364    fn scrollbar_width_returns_resolved_value() {
365        let resolved = make_resolved(false);
366        let w = scrollbar_width(&resolved);
367        assert!(w > 0.0, "scrollbar width should be > 0");
368    }
369
370    #[test]
371    fn button_padding_returns_iced_padding() {
372        let resolved = make_resolved(false);
373        let pad = button_padding(&resolved);
374        assert!(pad.top > 0.0, "button vertical (top) padding should be > 0");
375        assert!(
376            pad.right > 0.0,
377            "button horizontal (right) padding should be > 0"
378        );
379        // vertical maps to top+bottom, horizontal maps to left+right
380        assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
381        assert_eq!(pad.left, pad.right, "left and right should be equal");
382    }
383
384    #[test]
385    fn input_padding_returns_iced_padding() {
386        let resolved = make_resolved(false);
387        let pad = input_padding(&resolved);
388        assert!(pad.top > 0.0, "input vertical (top) padding should be > 0");
389        assert!(
390            pad.right > 0.0,
391            "input horizontal (right) padding should be > 0"
392        );
393    }
394
395    // === Font helper tests ===
396
397    #[test]
398    fn font_family_returns_concrete_value() {
399        let resolved = make_resolved(false);
400        let ff = font_family(&resolved);
401        assert!(!ff.is_empty(), "font family should not be empty");
402    }
403
404    #[test]
405    fn font_size_returns_concrete_value() {
406        let resolved = make_resolved(false);
407        let fs = font_size(&resolved);
408        assert!(fs > 0.0, "font size should be > 0");
409    }
410
411    #[test]
412    fn mono_font_family_returns_concrete_value() {
413        let resolved = make_resolved(false);
414        let mf = mono_font_family(&resolved);
415        assert!(!mf.is_empty(), "mono font family should not be empty");
416    }
417
418    #[test]
419    fn mono_font_size_returns_concrete_value() {
420        let resolved = make_resolved(false);
421        let ms = mono_font_size(&resolved);
422        assert!(ms > 0.0, "mono font size should be > 0");
423    }
424
425    #[test]
426    fn font_weight_returns_concrete_value() {
427        let resolved = make_resolved(false);
428        let w = font_weight(&resolved);
429        assert!(
430            (100..=900).contains(&w),
431            "font weight should be 100-900, got {}",
432            w
433        );
434    }
435
436    #[test]
437    fn mono_font_weight_returns_concrete_value() {
438        let resolved = make_resolved(false);
439        let w = mono_font_weight(&resolved);
440        assert!(
441            (100..=900).contains(&w),
442            "mono font weight should be 100-900, got {}",
443            w
444        );
445    }
446
447    #[test]
448    fn line_height_multiplier_returns_concrete_value() {
449        let resolved = make_resolved(false);
450        let lh = line_height_multiplier(&resolved);
451        assert!(lh > 0.0, "line height multiplier should be > 0");
452        assert!(
453            lh < 5.0,
454            "line height multiplier should be a multiplier (e.g. 1.4), got {}",
455            lh
456        );
457    }
458
459    #[test]
460    fn to_iced_weight_standard_weights() {
461        use iced_core::font::Weight;
462        assert_eq!(to_iced_weight(100), Weight::Thin);
463        assert_eq!(to_iced_weight(200), Weight::ExtraLight);
464        assert_eq!(to_iced_weight(300), Weight::Light);
465        assert_eq!(to_iced_weight(400), Weight::Normal);
466        assert_eq!(to_iced_weight(500), Weight::Medium);
467        assert_eq!(to_iced_weight(600), Weight::Semibold);
468        assert_eq!(to_iced_weight(700), Weight::Bold);
469        assert_eq!(to_iced_weight(800), Weight::ExtraBold);
470        assert_eq!(to_iced_weight(900), Weight::Black);
471    }
472
473    #[test]
474    fn to_iced_weight_non_standard_rounds_correctly() {
475        use iced_core::font::Weight;
476        assert_eq!(to_iced_weight(350), Weight::Normal);
477        assert_eq!(to_iced_weight(450), Weight::Medium);
478        assert_eq!(to_iced_weight(550), Weight::Semibold);
479        assert_eq!(to_iced_weight(0), Weight::Thin);
480        assert_eq!(to_iced_weight(1000), Weight::Black);
481    }
482
483    // === Convenience API tests ===
484
485    #[test]
486    fn from_preset_valid_light() {
487        let (theme, resolved) = from_preset("catppuccin-mocha", false).expect("preset should load");
488        // Should produce a valid custom theme (not Light or Dark built-in)
489        assert_ne!(theme, iced_core::theme::Theme::Light);
490        // Should also return the resolved variant
491        assert!(!resolved.defaults.font.family.is_empty());
492    }
493
494    #[test]
495    fn from_preset_valid_dark() {
496        let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
497        assert_ne!(theme, iced_core::theme::Theme::Dark);
498    }
499
500    #[test]
501    fn from_preset_invalid_name() {
502        let result = from_preset("nonexistent-preset", false);
503        assert!(result.is_err(), "invalid preset should return Err");
504    }
505
506    #[test]
507    fn system_theme_ext_to_iced_theme() {
508        // May fail on CI — skip gracefully
509        let Ok(sys) = native_theme::SystemTheme::from_system() else {
510            return;
511        };
512        let _theme = sys.to_iced_theme();
513    }
514
515    #[test]
516    fn from_system_does_not_panic() {
517        let _ = from_system();
518    }
519}