Skip to main content

native_theme_iced/
lib.rs

1//! iced toolkit connector for native-theme.
2//!
3//! Maps [`native_theme::NativeTheme`] data to iced's theming system.
4//!
5//! # Overview
6//!
7//! This crate provides a thin mapping layer from `native_theme::ThemeVariant`
8//! to `iced_core::theme::Theme`. The main entry point is [`to_theme()`], which
9//! produces a valid iced `Theme` with correct colors for all built-in widget
10//! styles via iced's Catalog system.
11//!
12//! Widget metrics (padding, border radius, scrollbar width) are exposed as
13//! helper functions rather than through the Catalog, since iced applies these
14//! on widget instances.
15//!
16//! # Example
17//!
18//! ```rust
19//! use native_theme::NativeTheme;
20//! use native_theme_iced::to_theme;
21//!
22//! let nt = NativeTheme::preset("default").unwrap();
23//! if let Some(variant) = nt.pick_variant(false) {
24//!     let theme = to_theme(variant, "My App");
25//!     // Use `theme` as your iced application theme
26//! }
27//! ```
28
29#![warn(missing_docs)]
30#![forbid(unsafe_code)]
31
32pub mod extended;
33pub mod icons;
34pub mod palette;
35
36/// Select light or dark variant from a [`native_theme::NativeTheme`], with cross-fallback.
37///
38/// When `is_dark` is true, prefers `theme.dark` and falls back to `theme.light`.
39/// When `is_dark` is false, prefers `theme.light` and falls back to `theme.dark`.
40///
41/// Returns `None` only if the theme has no variants at all.
42#[deprecated(since = "0.3.2", note = "Use NativeTheme::pick_variant() instead")]
43#[allow(deprecated)]
44pub fn pick_variant(
45    theme: &native_theme::NativeTheme,
46    is_dark: bool,
47) -> Option<&native_theme::ThemeVariant> {
48    theme.pick_variant(is_dark)
49}
50
51/// Create an iced [`iced_core::theme::Theme`] from a [`native_theme::ThemeVariant`].
52///
53/// Builds a custom theme using `Theme::custom_with_fn()`, which:
54/// 1. Maps the 6 Palette fields from native-theme colors via [`palette::to_palette()`]
55/// 2. Generates an Extended palette, then overrides secondary and background.weak
56///    entries via [`extended::apply_overrides()`]
57///
58/// The resulting theme carries the mapped Palette and Extended palette. iced's
59/// built-in Catalog trait implementations for all 8 core widgets (Button,
60/// Container, TextInput, Scrollable, Checkbox, Slider, ProgressBar, Tooltip)
61/// automatically derive their Style structs from this palette. No explicit
62/// Catalog implementations are needed.
63pub fn to_theme(variant: &native_theme::ThemeVariant, name: &str) -> iced_core::theme::Theme {
64    let pal = palette::to_palette(variant);
65
66    // Clone the variant reference data we need into the closure.
67    // The closure only needs the colors for extended palette overrides.
68    let colors = variant.colors.clone();
69
70    iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
71        let mut ext = iced_core::theme::palette::Extended::generate(p);
72
73        // Build a temporary ThemeVariant with just the colors for apply_overrides
74        let mut tmp = native_theme::ThemeVariant::default();
75        tmp.colors = colors;
76        extended::apply_overrides(&mut ext, &tmp);
77
78        ext
79    })
80}
81
82/// Returns button padding as `[horizontal, vertical]` from widget metrics.
83///
84/// Returns `None` if the variant has no widget metrics or if both padding
85/// fields are `None`.
86pub fn button_padding(variant: &native_theme::ThemeVariant) -> Option<[f32; 2]> {
87    let bm = &variant.widget_metrics.as_ref()?.button;
88    let h = bm.padding_horizontal?;
89    let v = bm.padding_vertical.unwrap_or(h * 0.5);
90    Some([h, v])
91}
92
93/// Returns text input padding as `[horizontal, vertical]` from widget metrics.
94///
95/// Returns `None` if the variant has no widget metrics or if the horizontal
96/// padding field is `None`.
97pub fn input_padding(variant: &native_theme::ThemeVariant) -> Option<[f32; 2]> {
98    let im = &variant.widget_metrics.as_ref()?.input;
99    let h = im.padding_horizontal?;
100    let v = im.padding_vertical.unwrap_or(h * 0.5);
101    Some([h, v])
102}
103
104/// Returns the standard border radius from geometry, defaulting to 4.0.
105pub fn border_radius(variant: &native_theme::ThemeVariant) -> f32 {
106    variant.geometry.radius.unwrap_or(4.0)
107}
108
109/// Returns the large border radius from geometry, defaulting to 8.0.
110pub fn border_radius_lg(variant: &native_theme::ThemeVariant) -> f32 {
111    variant.geometry.radius_lg.unwrap_or(8.0)
112}
113
114/// Returns the scrollbar width, checking geometry first, then widget metrics.
115///
116/// Falls back to 10.0 if neither source provides a value.
117pub fn scrollbar_width(variant: &native_theme::ThemeVariant) -> f32 {
118    // Prefer geometry.scroll_width, then widget_metrics.scrollbar.width
119    variant
120        .geometry
121        .scroll_width
122        .or_else(|| {
123            variant
124                .widget_metrics
125                .as_ref()
126                .and_then(|wm| wm.scrollbar.width)
127        })
128        .unwrap_or(10.0)
129}
130
131/// Returns the primary UI font family name from the theme variant.
132pub fn font_family(variant: &native_theme::ThemeVariant) -> Option<&str> {
133    variant.fonts.family.as_deref()
134}
135
136/// Returns the primary UI font size in pixels from the theme variant.
137///
138/// Native-theme stores font sizes in points; this converts to pixels
139/// using the standard 96 DPI factor (`pt * 96.0 / 72.0`).
140pub fn font_size(variant: &native_theme::ThemeVariant) -> Option<f32> {
141    variant.fonts.size.map(|pt| pt * (96.0 / 72.0))
142}
143
144/// Returns the monospace font family name from the theme variant.
145pub fn mono_font_family(variant: &native_theme::ThemeVariant) -> Option<&str> {
146    variant.fonts.mono_family.as_deref()
147}
148
149/// Returns the monospace font size in pixels from the theme variant.
150///
151/// Native-theme stores font sizes in points; this converts to pixels
152/// using the standard 96 DPI factor (`pt * 96.0 / 72.0`).
153pub fn mono_font_size(variant: &native_theme::ThemeVariant) -> Option<f32> {
154    variant.fonts.mono_size.map(|pt| pt * (96.0 / 72.0))
155}
156
157#[cfg(test)]
158#[allow(deprecated)]
159mod tests {
160    use super::*;
161    use native_theme::{NativeTheme, Rgba, ThemeVariant};
162
163    // === pick_variant tests ===
164
165    #[test]
166    fn pick_variant_light_preferred_returns_light() {
167        let mut theme = NativeTheme::new("Test");
168        theme.light = Some(ThemeVariant::default());
169        theme.dark = Some(ThemeVariant::default());
170
171        let result = pick_variant(&theme, false);
172        assert!(result.is_some());
173        // Should return the light variant (which is the same as dark here,
174        // but logically we check it's the light ref)
175        assert!(std::ptr::eq(result.unwrap(), theme.light.as_ref().unwrap()));
176    }
177
178    #[test]
179    fn pick_variant_dark_preferred_returns_dark() {
180        let mut theme = NativeTheme::new("Test");
181        theme.light = Some(ThemeVariant::default());
182        theme.dark = Some(ThemeVariant::default());
183
184        let result = pick_variant(&theme, true);
185        assert!(result.is_some());
186        assert!(std::ptr::eq(result.unwrap(), theme.dark.as_ref().unwrap()));
187    }
188
189    #[test]
190    fn pick_variant_falls_back_to_light_when_no_dark() {
191        let mut theme = NativeTheme::new("Test");
192        theme.light = Some(ThemeVariant::default());
193        // dark is None
194
195        let result = pick_variant(&theme, true);
196        assert!(result.is_some());
197        assert!(std::ptr::eq(result.unwrap(), theme.light.as_ref().unwrap()));
198    }
199
200    #[test]
201    fn pick_variant_falls_back_to_dark_when_no_light() {
202        let mut theme = NativeTheme::new("Test");
203        // light is None
204        theme.dark = Some(ThemeVariant::default());
205
206        let result = pick_variant(&theme, false);
207        assert!(result.is_some());
208        assert!(std::ptr::eq(result.unwrap(), theme.dark.as_ref().unwrap()));
209    }
210
211    #[test]
212    fn pick_variant_returns_none_when_empty() {
213        let theme = NativeTheme::new("Test");
214        assert!(pick_variant(&theme, false).is_none());
215        assert!(pick_variant(&theme, true).is_none());
216    }
217
218    // === to_theme tests ===
219
220    #[test]
221    fn to_theme_produces_non_default_theme() {
222        let mut variant = ThemeVariant::default();
223        variant.colors.accent = Some(Rgba::rgb(0, 120, 215));
224        variant.colors.background = Some(Rgba::rgb(30, 30, 30));
225        variant.colors.foreground = Some(Rgba::rgb(220, 220, 220));
226
227        let theme = to_theme(&variant, "Test Theme");
228
229        // The theme should not be equal to Light or Dark builtins
230        assert_ne!(theme, iced_core::theme::Theme::Light);
231        assert_ne!(theme, iced_core::theme::Theme::Dark);
232
233        // Verify the palette was applied
234        let palette = theme.palette();
235        assert!(
236            (palette.primary.r - 0.0).abs() < 0.01,
237            "primary.r should be ~0.0, got {}",
238            palette.primary.r
239        );
240    }
241
242    #[test]
243    fn to_theme_from_preset() {
244        let nt = NativeTheme::preset("default").unwrap();
245        let variant = pick_variant(&nt, false).unwrap();
246        let theme = to_theme(variant, "Default");
247
248        // Should be a valid custom theme
249        let palette = theme.palette();
250        // Default preset has white-ish background for light
251        assert!(palette.background.r > 0.9);
252    }
253
254    // === Widget metric helper tests ===
255
256    #[test]
257    fn border_radius_returns_geometry_value() {
258        let mut variant = ThemeVariant::default();
259        variant.geometry.radius = Some(6.0);
260
261        assert_eq!(border_radius(&variant), 6.0);
262    }
263
264    #[test]
265    fn border_radius_returns_default_when_none() {
266        let variant = ThemeVariant::default();
267        assert_eq!(border_radius(&variant), 4.0);
268    }
269
270    #[test]
271    fn border_radius_lg_returns_geometry_value() {
272        let mut variant = ThemeVariant::default();
273        variant.geometry.radius_lg = Some(12.0);
274
275        assert_eq!(border_radius_lg(&variant), 12.0);
276    }
277
278    #[test]
279    fn border_radius_lg_returns_default_when_none() {
280        let variant = ThemeVariant::default();
281        assert_eq!(border_radius_lg(&variant), 8.0);
282    }
283
284    #[test]
285    fn scrollbar_width_prefers_geometry() {
286        let mut variant = ThemeVariant::default();
287        variant.geometry.scroll_width = Some(14.0);
288
289        assert_eq!(scrollbar_width(&variant), 14.0);
290    }
291
292    #[test]
293    fn scrollbar_width_falls_back_to_widget_metrics() {
294        let mut variant = ThemeVariant::default();
295        let mut wm = native_theme::WidgetMetrics::default();
296        wm.scrollbar.width = Some(12.0);
297        variant.widget_metrics = Some(wm);
298
299        assert_eq!(scrollbar_width(&variant), 12.0);
300    }
301
302    #[test]
303    fn scrollbar_width_returns_default_when_none() {
304        let variant = ThemeVariant::default();
305        assert_eq!(scrollbar_width(&variant), 10.0);
306    }
307
308    #[test]
309    fn button_padding_returns_values_from_metrics() {
310        let mut variant = ThemeVariant::default();
311        let mut wm = native_theme::WidgetMetrics::default();
312        wm.button.padding_horizontal = Some(12.0);
313        wm.button.padding_vertical = Some(6.0);
314        variant.widget_metrics = Some(wm);
315
316        let result = button_padding(&variant).unwrap();
317        assert_eq!(result, [12.0, 6.0]);
318    }
319
320    #[test]
321    fn button_padding_returns_none_without_metrics() {
322        let variant = ThemeVariant::default();
323        assert!(button_padding(&variant).is_none());
324    }
325
326    #[test]
327    fn input_padding_returns_values_from_metrics() {
328        let mut variant = ThemeVariant::default();
329        let mut wm = native_theme::WidgetMetrics::default();
330        wm.input.padding_horizontal = Some(8.0);
331        wm.input.padding_vertical = Some(4.0);
332        variant.widget_metrics = Some(wm);
333
334        let result = input_padding(&variant).unwrap();
335        assert_eq!(result, [8.0, 4.0]);
336    }
337
338    #[test]
339    fn input_padding_returns_none_without_metrics() {
340        let variant = ThemeVariant::default();
341        assert!(input_padding(&variant).is_none());
342    }
343
344    // === Font helper tests ===
345
346    #[test]
347    fn font_family_returns_value() {
348        let mut variant = ThemeVariant::default();
349        variant.fonts.family = Some("Inter".into());
350        assert_eq!(font_family(&variant), Some("Inter"));
351    }
352
353    #[test]
354    fn font_family_returns_none_when_unset() {
355        let variant = ThemeVariant::default();
356        assert!(font_family(&variant).is_none());
357    }
358
359    #[test]
360    fn font_size_converts_points_to_pixels() {
361        let mut variant = ThemeVariant::default();
362        variant.fonts.size = Some(12.0);
363        let px = font_size(&variant).unwrap();
364        assert!((px - 16.0).abs() < 0.01, "12pt should be 16px, got {px}");
365    }
366
367    #[test]
368    fn font_size_returns_none_when_unset() {
369        let variant = ThemeVariant::default();
370        assert!(font_size(&variant).is_none());
371    }
372
373    #[test]
374    fn mono_font_family_returns_value() {
375        let mut variant = ThemeVariant::default();
376        variant.fonts.mono_family = Some("JetBrains Mono".into());
377        assert_eq!(mono_font_family(&variant), Some("JetBrains Mono"));
378    }
379
380    #[test]
381    fn mono_font_size_converts_points_to_pixels() {
382        let mut variant = ThemeVariant::default();
383        variant.fonts.mono_size = Some(10.0);
384        let px = mono_font_size(&variant).unwrap();
385        let expected = 10.0 * (96.0 / 72.0);
386        assert!(
387            (px - expected).abs() < 0.01,
388            "10pt should be {expected}px, got {px}"
389        );
390    }
391}