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 = 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 = 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 mut variant = nt.pick_variant(false).unwrap().clone();
31//! variant.resolve();
32//! let resolved = variant.validate().unwrap();
33//! let theme = to_theme(&resolved, "My App");
34//! ```
35//!
36//! # Theme Field Coverage
37//!
38//! The connector maps a subset of [`ResolvedThemeVariant`] to iced's theming system:
39//!
40//! | Target | Fields | Source |
41//! |--------|--------|--------|
42//! | `Palette` (6 fields) | background, text, primary, success, warning, danger | `defaults.*` |
43//! | `Extended` overrides (4) | secondary.base.color/text, background.weak.color/text | button.bg/fg, defaults.surface/foreground |
44//! | Widget metrics | button/input padding, border radius, scrollbar width | Per-widget resolved fields |
45//! | Typography | font family/size/weight, mono family/size/weight, line height | `defaults.font.*`, `defaults.mono_font.*` |
46//!
47//! Per-widget geometry beyond padding/radius (e.g., min-width, disabled-opacity)
48//! is not mapped because iced applies these via inline widget configuration,
49//! not through the theme system. Users can read these directly from the
50//! `ResolvedThemeVariant` they pass to [`to_theme()`].
51
52#![warn(missing_docs)]
53#![forbid(unsafe_code)]
54#![deny(clippy::unwrap_used)]
55#![deny(clippy::expect_used)]
56
57pub mod extended;
58pub mod icons;
59pub mod palette;
60
61// Re-export native-theme types that appear in public signatures.
62pub use native_theme::{
63    AnimatedIcon, IconData, IconProvider, IconRole, IconSet, ResolvedThemeVariant, SystemTheme,
64    ThemeSpec, ThemeVariant,
65};
66
67/// Create an iced [`iced_core::theme::Theme`] from a [`native_theme::ResolvedThemeVariant`].
68///
69/// Builds a custom theme using `Theme::custom_with_fn()`, which:
70/// 1. Maps the 6 Palette fields from resolved theme colors via [`palette::to_palette()`]
71/// 2. Generates an Extended palette, then overrides secondary and background.weak
72///    entries via [`extended::apply_overrides()`]
73///
74/// The resulting theme carries the mapped Palette and Extended palette. iced's
75/// built-in Catalog trait implementations for all 8 core widgets (Button,
76/// Container, TextInput, Scrollable, Checkbox, Slider, ProgressBar, Tooltip)
77/// automatically derive their Style structs from this palette. No explicit
78/// Catalog implementations are needed.
79///
80/// The `name` sets the theme's display name (visible in theme pickers).
81/// For the common case, use [`from_preset()`] to derive the name automatically.
82#[must_use]
83pub fn to_theme(
84    resolved: &native_theme::ResolvedThemeVariant,
85    name: &str,
86) -> iced_core::theme::Theme {
87    let pal = palette::to_palette(resolved);
88
89    // Clone the resolved fields we need into the closure.
90    let resolved_clone = resolved.clone();
91
92    iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
93        let mut ext = iced_core::theme::palette::Extended::generate(p);
94        extended::apply_overrides(&mut ext, &resolved_clone);
95        ext
96    })
97}
98
99/// Load a bundled preset and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
100///
101/// Handles the full pipeline: load preset, pick variant, resolve, validate, convert.
102/// The preset name is used as the theme display name.
103///
104/// # Errors
105///
106/// Returns an error if the preset name is not recognized or if resolution fails.
107pub fn from_preset(name: &str, is_dark: bool) -> native_theme::Result<iced_core::theme::Theme> {
108    let spec = native_theme::ThemeSpec::preset(name)?;
109    let variant = spec
110        .pick_variant(is_dark)
111        .ok_or_else(|| native_theme::Error::Format(format!("preset '{name}' has no variants")))?;
112    let resolved = variant.clone().into_resolved()?;
113    Ok(to_theme(&resolved, name))
114}
115
116/// Detect the OS theme and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
117///
118/// # Errors
119///
120/// Returns an error if the platform theme cannot be read.
121pub fn from_system() -> native_theme::Result<iced_core::theme::Theme> {
122    let sys = native_theme::SystemTheme::from_system()?;
123    Ok(to_theme(sys.active(), &sys.name))
124}
125
126/// Extension trait for converting a [`SystemTheme`] to an iced theme.
127pub trait SystemThemeExt {
128    /// Convert this system theme to an iced [`Theme`](iced_core::theme::Theme).
129    fn to_iced_theme(&self) -> iced_core::theme::Theme;
130}
131
132impl SystemThemeExt for native_theme::SystemTheme {
133    fn to_iced_theme(&self) -> iced_core::theme::Theme {
134        to_theme(self.active(), &self.name)
135    }
136}
137
138/// Returns button padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
139///
140/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
141#[must_use]
142pub fn button_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
143    iced_core::Padding::from([
144        resolved.button.padding_vertical,
145        resolved.button.padding_horizontal,
146    ])
147}
148
149/// Returns text input padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
150///
151/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
152#[must_use]
153pub fn input_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
154    iced_core::Padding::from([
155        resolved.input.padding_vertical,
156        resolved.input.padding_horizontal,
157    ])
158}
159
160/// Returns the standard border radius from the resolved theme.
161#[must_use]
162pub fn border_radius(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
163    resolved.defaults.radius
164}
165
166/// Returns the large border radius from the resolved theme.
167#[must_use]
168pub fn border_radius_lg(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
169    resolved.defaults.radius_lg
170}
171
172/// Returns the scrollbar width from the resolved theme.
173#[must_use]
174pub fn scrollbar_width(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
175    resolved.scrollbar.width
176}
177
178/// Returns the primary UI font family name from the resolved theme.
179#[must_use]
180pub fn font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
181    &resolved.defaults.font.family
182}
183
184/// Returns the primary UI font size in logical pixels from the resolved theme.
185///
186/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
187/// is applied.
188#[must_use]
189pub fn font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
190    resolved.defaults.font.size
191}
192
193/// Returns the monospace font family name from the resolved theme.
194#[must_use]
195pub fn mono_font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
196    &resolved.defaults.mono_font.family
197}
198
199/// Returns the monospace font size in logical pixels from the resolved theme.
200///
201/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
202/// is applied.
203#[must_use]
204pub fn mono_font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
205    resolved.defaults.mono_font.size
206}
207
208/// Returns the primary UI font weight (CSS 100-900) from the resolved theme.
209#[must_use]
210pub fn font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
211    resolved.defaults.font.weight
212}
213
214/// Returns the monospace font weight (CSS 100-900) from the resolved theme.
215#[must_use]
216pub fn mono_font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
217    resolved.defaults.mono_font.weight
218}
219
220/// Returns the primary UI line height in logical pixels from the resolved theme.
221///
222/// This is the computed value (`defaults.line_height * font.size`), not the
223/// raw multiplier.
224#[must_use]
225pub fn line_height(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
226    resolved.defaults.line_height * resolved.defaults.font.size
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used, clippy::expect_used)]
231mod tests {
232    use super::*;
233    use native_theme::ThemeSpec;
234
235    fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
236        let nt = ThemeSpec::preset("catppuccin-mocha").unwrap();
237        let mut variant = nt.pick_variant(is_dark).unwrap().clone();
238        variant.resolve();
239        variant.validate().unwrap()
240    }
241
242    // === to_theme tests ===
243
244    #[test]
245    fn to_theme_produces_non_default_theme() {
246        let resolved = make_resolved(true);
247        let theme = to_theme(&resolved, "Test Theme");
248
249        assert_ne!(theme, iced_core::theme::Theme::Light);
250        assert_ne!(theme, iced_core::theme::Theme::Dark);
251
252        let palette = theme.palette();
253        // Verify palette was applied from resolved theme
254        assert!(
255            palette.primary.r > 0.0 || palette.primary.g > 0.0 || palette.primary.b > 0.0,
256            "primary should be non-zero"
257        );
258    }
259
260    #[test]
261    fn to_theme_from_preset() {
262        let resolved = make_resolved(false);
263        let theme = to_theme(&resolved, "Default");
264
265        let palette = theme.palette();
266        // Default preset has white-ish background for light
267        assert!(palette.background.r > 0.9);
268    }
269
270    // === Widget metric helper tests ===
271
272    #[test]
273    fn border_radius_returns_resolved_value() {
274        let resolved = make_resolved(false);
275        let r = border_radius(&resolved);
276        assert!(r > 0.0, "resolved radius should be > 0");
277    }
278
279    #[test]
280    fn border_radius_lg_returns_resolved_value() {
281        let resolved = make_resolved(false);
282        let r = border_radius_lg(&resolved);
283        assert!(r > 0.0, "resolved radius_lg should be > 0");
284        assert!(
285            r >= border_radius(&resolved),
286            "radius_lg should be >= radius"
287        );
288    }
289
290    #[test]
291    fn scrollbar_width_returns_resolved_value() {
292        let resolved = make_resolved(false);
293        let w = scrollbar_width(&resolved);
294        assert!(w > 0.0, "scrollbar width should be > 0");
295    }
296
297    #[test]
298    fn button_padding_returns_iced_padding() {
299        let resolved = make_resolved(false);
300        let pad = button_padding(&resolved);
301        assert!(pad.top > 0.0, "button vertical (top) padding should be > 0");
302        assert!(
303            pad.right > 0.0,
304            "button horizontal (right) padding should be > 0"
305        );
306        // vertical maps to top+bottom, horizontal maps to left+right
307        assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
308        assert_eq!(pad.left, pad.right, "left and right should be equal");
309    }
310
311    #[test]
312    fn input_padding_returns_iced_padding() {
313        let resolved = make_resolved(false);
314        let pad = input_padding(&resolved);
315        assert!(pad.top > 0.0, "input vertical (top) padding should be > 0");
316        assert!(
317            pad.right > 0.0,
318            "input horizontal (right) padding should be > 0"
319        );
320    }
321
322    // === Font helper tests ===
323
324    #[test]
325    fn font_family_returns_concrete_value() {
326        let resolved = make_resolved(false);
327        let ff = font_family(&resolved);
328        assert!(!ff.is_empty(), "font family should not be empty");
329    }
330
331    #[test]
332    fn font_size_returns_concrete_value() {
333        let resolved = make_resolved(false);
334        let fs = font_size(&resolved);
335        assert!(fs > 0.0, "font size should be > 0");
336    }
337
338    #[test]
339    fn mono_font_family_returns_concrete_value() {
340        let resolved = make_resolved(false);
341        let mf = mono_font_family(&resolved);
342        assert!(!mf.is_empty(), "mono font family should not be empty");
343    }
344
345    #[test]
346    fn mono_font_size_returns_concrete_value() {
347        let resolved = make_resolved(false);
348        let ms = mono_font_size(&resolved);
349        assert!(ms > 0.0, "mono font size should be > 0");
350    }
351
352    #[test]
353    fn font_weight_returns_concrete_value() {
354        let resolved = make_resolved(false);
355        let w = font_weight(&resolved);
356        assert!(
357            (100..=900).contains(&w),
358            "font weight should be 100-900, got {}",
359            w
360        );
361    }
362
363    #[test]
364    fn mono_font_weight_returns_concrete_value() {
365        let resolved = make_resolved(false);
366        let w = mono_font_weight(&resolved);
367        assert!(
368            (100..=900).contains(&w),
369            "mono font weight should be 100-900, got {}",
370            w
371        );
372    }
373
374    #[test]
375    fn line_height_returns_concrete_value() {
376        let resolved = make_resolved(false);
377        let lh = line_height(&resolved);
378        assert!(lh > 0.0, "line height should be > 0");
379    }
380
381    // === Convenience API tests ===
382
383    #[test]
384    fn from_preset_valid_light() {
385        let theme = from_preset("catppuccin-mocha", false).expect("preset should load");
386        // Should produce a valid custom theme (not Light or Dark built-in)
387        assert_ne!(theme, iced_core::theme::Theme::Light);
388    }
389
390    #[test]
391    fn from_preset_valid_dark() {
392        let theme = from_preset("catppuccin-mocha", true).expect("preset should load");
393        assert_ne!(theme, iced_core::theme::Theme::Dark);
394    }
395
396    #[test]
397    fn from_preset_invalid_name() {
398        let result = from_preset("nonexistent-preset", false);
399        assert!(result.is_err(), "invalid preset should return Err");
400    }
401
402    #[test]
403    fn system_theme_ext_to_iced_theme() {
404        // May fail on CI — skip gracefully
405        let Ok(sys) = native_theme::SystemTheme::from_system() else {
406            return;
407        };
408        let _theme = sys.to_iced_theme();
409    }
410
411    #[test]
412    fn from_system_does_not_panic() {
413        let _ = from_system();
414    }
415}