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 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.
107#[must_use = "this returns the theme; it does not apply it"]
108pub fn from_preset(
109    name: &str,
110    is_dark: bool,
111) -> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
112    let spec = native_theme::ThemeSpec::preset(name)?;
113    let variant = spec
114        .pick_variant(is_dark)
115        .ok_or_else(|| native_theme::Error::Format(format!("preset '{name}' has no variants")))?;
116    let resolved = variant.clone().into_resolved()?;
117    let theme = to_theme(&resolved, name);
118    Ok((theme, resolved))
119}
120
121/// Detect the OS theme and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
122///
123/// # Errors
124///
125/// Returns an error if the platform theme cannot be read.
126#[must_use = "this returns the theme; it does not apply it"]
127pub fn from_system()
128-> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
129    let sys = native_theme::SystemTheme::from_system()?;
130    let resolved = sys.active().clone();
131    let theme = to_theme(&resolved, &sys.name);
132    Ok((theme, resolved))
133}
134
135/// Extension trait for converting a [`SystemTheme`] to an iced theme.
136pub trait SystemThemeExt {
137    /// Convert this system theme to an iced [`Theme`](iced_core::theme::Theme).
138    #[must_use = "this returns the theme; it does not apply it"]
139    fn to_iced_theme(&self) -> iced_core::theme::Theme;
140}
141
142impl SystemThemeExt for native_theme::SystemTheme {
143    fn to_iced_theme(&self) -> iced_core::theme::Theme {
144        to_theme(self.active(), &self.name)
145    }
146}
147
148/// Returns button padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
149///
150/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
151#[must_use]
152pub fn button_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
153    iced_core::Padding::from([
154        resolved.button.padding_vertical,
155        resolved.button.padding_horizontal,
156    ])
157}
158
159/// Returns text input padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
160///
161/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
162#[must_use]
163pub fn input_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
164    iced_core::Padding::from([
165        resolved.input.padding_vertical,
166        resolved.input.padding_horizontal,
167    ])
168}
169
170/// Returns the standard border radius from the resolved theme.
171#[must_use]
172pub fn border_radius(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
173    resolved.defaults.radius
174}
175
176/// Returns the large border radius from the resolved theme.
177#[must_use]
178pub fn border_radius_lg(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
179    resolved.defaults.radius_lg
180}
181
182/// Returns the scrollbar width from the resolved theme.
183#[must_use]
184pub fn scrollbar_width(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
185    resolved.scrollbar.width
186}
187
188/// Returns the primary UI font family name from the resolved theme.
189#[must_use]
190pub fn font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
191    &resolved.defaults.font.family
192}
193
194/// Returns the primary UI font size in logical pixels from the resolved theme.
195///
196/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
197/// is applied.
198#[must_use]
199pub fn font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
200    resolved.defaults.font.size
201}
202
203/// Returns the monospace font family name from the resolved theme.
204#[must_use]
205pub fn mono_font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
206    &resolved.defaults.mono_font.family
207}
208
209/// Returns the monospace font size in logical pixels from the resolved theme.
210///
211/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
212/// is applied.
213#[must_use]
214pub fn mono_font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
215    resolved.defaults.mono_font.size
216}
217
218/// Returns the primary UI font weight (CSS 100-900) from the resolved theme.
219#[must_use]
220pub fn font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
221    resolved.defaults.font.weight
222}
223
224/// Returns the monospace font weight (CSS 100-900) from the resolved theme.
225#[must_use]
226pub fn mono_font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
227    resolved.defaults.mono_font.weight
228}
229
230/// Returns the primary UI line height in logical pixels from the resolved theme.
231///
232/// This is the computed value (`defaults.line_height * font.size`), not the
233/// raw multiplier.
234#[must_use]
235pub fn line_height(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
236    resolved.defaults.line_height * resolved.defaults.font.size
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used)]
241mod tests {
242    use super::*;
243    use native_theme::ThemeSpec;
244
245    fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
246        let nt = ThemeSpec::preset("catppuccin-mocha").unwrap();
247        let mut variant = nt.pick_variant(is_dark).unwrap().clone();
248        variant.resolve();
249        variant.validate().unwrap()
250    }
251
252    // === to_theme tests ===
253
254    #[test]
255    fn to_theme_produces_non_default_theme() {
256        let resolved = make_resolved(true);
257        let theme = to_theme(&resolved, "Test Theme");
258
259        assert_ne!(theme, iced_core::theme::Theme::Light);
260        assert_ne!(theme, iced_core::theme::Theme::Dark);
261
262        let palette = theme.palette();
263        // Verify palette was applied from resolved theme
264        assert!(
265            palette.primary.r > 0.0 || palette.primary.g > 0.0 || palette.primary.b > 0.0,
266            "primary should be non-zero"
267        );
268    }
269
270    #[test]
271    fn to_theme_from_preset() {
272        let resolved = make_resolved(false);
273        let theme = to_theme(&resolved, "Default");
274
275        let palette = theme.palette();
276        // Default preset has white-ish background for light
277        assert!(palette.background.r > 0.9);
278    }
279
280    // === Widget metric helper tests ===
281
282    #[test]
283    fn border_radius_returns_resolved_value() {
284        let resolved = make_resolved(false);
285        let r = border_radius(&resolved);
286        assert!(r > 0.0, "resolved radius should be > 0");
287    }
288
289    #[test]
290    fn border_radius_lg_returns_resolved_value() {
291        let resolved = make_resolved(false);
292        let r = border_radius_lg(&resolved);
293        assert!(r > 0.0, "resolved radius_lg should be > 0");
294        assert!(
295            r >= border_radius(&resolved),
296            "radius_lg should be >= radius"
297        );
298    }
299
300    #[test]
301    fn scrollbar_width_returns_resolved_value() {
302        let resolved = make_resolved(false);
303        let w = scrollbar_width(&resolved);
304        assert!(w > 0.0, "scrollbar width should be > 0");
305    }
306
307    #[test]
308    fn button_padding_returns_iced_padding() {
309        let resolved = make_resolved(false);
310        let pad = button_padding(&resolved);
311        assert!(pad.top > 0.0, "button vertical (top) padding should be > 0");
312        assert!(
313            pad.right > 0.0,
314            "button horizontal (right) padding should be > 0"
315        );
316        // vertical maps to top+bottom, horizontal maps to left+right
317        assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
318        assert_eq!(pad.left, pad.right, "left and right should be equal");
319    }
320
321    #[test]
322    fn input_padding_returns_iced_padding() {
323        let resolved = make_resolved(false);
324        let pad = input_padding(&resolved);
325        assert!(pad.top > 0.0, "input vertical (top) padding should be > 0");
326        assert!(
327            pad.right > 0.0,
328            "input horizontal (right) padding should be > 0"
329        );
330    }
331
332    // === Font helper tests ===
333
334    #[test]
335    fn font_family_returns_concrete_value() {
336        let resolved = make_resolved(false);
337        let ff = font_family(&resolved);
338        assert!(!ff.is_empty(), "font family should not be empty");
339    }
340
341    #[test]
342    fn font_size_returns_concrete_value() {
343        let resolved = make_resolved(false);
344        let fs = font_size(&resolved);
345        assert!(fs > 0.0, "font size should be > 0");
346    }
347
348    #[test]
349    fn mono_font_family_returns_concrete_value() {
350        let resolved = make_resolved(false);
351        let mf = mono_font_family(&resolved);
352        assert!(!mf.is_empty(), "mono font family should not be empty");
353    }
354
355    #[test]
356    fn mono_font_size_returns_concrete_value() {
357        let resolved = make_resolved(false);
358        let ms = mono_font_size(&resolved);
359        assert!(ms > 0.0, "mono font size should be > 0");
360    }
361
362    #[test]
363    fn font_weight_returns_concrete_value() {
364        let resolved = make_resolved(false);
365        let w = font_weight(&resolved);
366        assert!(
367            (100..=900).contains(&w),
368            "font weight should be 100-900, got {}",
369            w
370        );
371    }
372
373    #[test]
374    fn mono_font_weight_returns_concrete_value() {
375        let resolved = make_resolved(false);
376        let w = mono_font_weight(&resolved);
377        assert!(
378            (100..=900).contains(&w),
379            "mono font weight should be 100-900, got {}",
380            w
381        );
382    }
383
384    #[test]
385    fn line_height_returns_concrete_value() {
386        let resolved = make_resolved(false);
387        let lh = line_height(&resolved);
388        assert!(lh > 0.0, "line height should be > 0");
389    }
390
391    // === Convenience API tests ===
392
393    #[test]
394    fn from_preset_valid_light() {
395        let (theme, resolved) = from_preset("catppuccin-mocha", false).expect("preset should load");
396        // Should produce a valid custom theme (not Light or Dark built-in)
397        assert_ne!(theme, iced_core::theme::Theme::Light);
398        // Should also return the resolved variant
399        assert!(!resolved.defaults.font.family.is_empty());
400    }
401
402    #[test]
403    fn from_preset_valid_dark() {
404        let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
405        assert_ne!(theme, iced_core::theme::Theme::Dark);
406    }
407
408    #[test]
409    fn from_preset_invalid_name() {
410        let result = from_preset("nonexistent-preset", false);
411        assert!(result.is_err(), "invalid preset should return Err");
412    }
413
414    #[test]
415    fn system_theme_ext_to_iced_theme() {
416        // May fail on CI — skip gracefully
417        let Ok(sys) = native_theme::SystemTheme::from_system() else {
418            return;
419        };
420        let _theme = sys.to_iced_theme();
421    }
422
423    #[test]
424    fn from_system_does_not_panic() {
425        let _ = from_system();
426    }
427}