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