Skip to main content

native_theme_iced/
lib.rs

1//! iced toolkit connector for native-theme.
2//!
3//! Maps [`native_theme::ResolvedTheme`] data to iced's theming system.
4//!
5//! # Overview
6//!
7//! This crate provides a thin mapping layer from `native_theme::ResolvedTheme`
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("catppuccin-mocha").unwrap();
23//! let mut variant = nt.pick_variant(false).unwrap().clone();
24//! variant.resolve();
25//! let resolved = variant.validate().unwrap();
26//! let theme = to_theme(&resolved, "My App");
27//! // Use `theme` as your iced application theme
28//! ```
29
30#![warn(missing_docs)]
31#![forbid(unsafe_code)]
32#![deny(clippy::unwrap_used)]
33#![deny(clippy::expect_used)]
34
35pub mod extended;
36pub mod icons;
37pub mod palette;
38
39/// Select light or dark variant from a [`native_theme::NativeTheme`], with cross-fallback.
40///
41/// When `is_dark` is true, prefers `theme.dark` and falls back to `theme.light`.
42/// When `is_dark` is false, prefers `theme.light` and falls back to `theme.dark`.
43///
44/// Returns `None` only if the theme has no variants at all.
45#[deprecated(since = "0.3.2", note = "Use NativeTheme::pick_variant() instead")]
46#[allow(deprecated)]
47pub fn pick_variant(
48    theme: &native_theme::NativeTheme,
49    is_dark: bool,
50) -> Option<&native_theme::ThemeVariant> {
51    theme.pick_variant(is_dark)
52}
53
54/// Create an iced [`iced_core::theme::Theme`] from a [`native_theme::ResolvedTheme`].
55///
56/// Builds a custom theme using `Theme::custom_with_fn()`, which:
57/// 1. Maps the 6 Palette fields from resolved theme colors via [`palette::to_palette()`]
58/// 2. Generates an Extended palette, then overrides secondary and background.weak
59///    entries via [`extended::apply_overrides()`]
60///
61/// The resulting theme carries the mapped Palette and Extended palette. iced's
62/// built-in Catalog trait implementations for all 8 core widgets (Button,
63/// Container, TextInput, Scrollable, Checkbox, Slider, ProgressBar, Tooltip)
64/// automatically derive their Style structs from this palette. No explicit
65/// Catalog implementations are needed.
66pub fn to_theme(resolved: &native_theme::ResolvedTheme, name: &str) -> iced_core::theme::Theme {
67    let pal = palette::to_palette(resolved);
68
69    // Clone the resolved fields we need into the closure.
70    let button_bg = resolved.button.background;
71    let button_fg = resolved.button.foreground;
72    let surface = resolved.defaults.surface;
73    let foreground = resolved.defaults.foreground;
74
75    iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
76        let mut ext = iced_core::theme::palette::Extended::generate(p);
77
78        ext.secondary.base.color = palette::to_color(button_bg);
79        ext.secondary.base.text = palette::to_color(button_fg);
80        ext.background.weak.color = palette::to_color(surface);
81        ext.background.weak.text = palette::to_color(foreground);
82
83        ext
84    })
85}
86
87/// Returns button padding as `[horizontal, vertical]` from the resolved theme.
88pub fn button_padding(resolved: &native_theme::ResolvedTheme) -> [f32; 2] {
89    [
90        resolved.button.padding_horizontal,
91        resolved.button.padding_vertical,
92    ]
93}
94
95/// Returns text input padding as `[horizontal, vertical]` from the resolved theme.
96pub fn input_padding(resolved: &native_theme::ResolvedTheme) -> [f32; 2] {
97    [
98        resolved.input.padding_horizontal,
99        resolved.input.padding_vertical,
100    ]
101}
102
103/// Returns the standard border radius from the resolved theme.
104pub fn border_radius(resolved: &native_theme::ResolvedTheme) -> f32 {
105    resolved.defaults.radius
106}
107
108/// Returns the large border radius from the resolved theme.
109pub fn border_radius_lg(resolved: &native_theme::ResolvedTheme) -> f32 {
110    resolved.defaults.radius_lg
111}
112
113/// Returns the scrollbar width from the resolved theme.
114pub fn scrollbar_width(resolved: &native_theme::ResolvedTheme) -> f32 {
115    resolved.scrollbar.width
116}
117
118/// Returns the primary UI font family name from the resolved theme.
119pub fn font_family(resolved: &native_theme::ResolvedTheme) -> &str {
120    &resolved.defaults.font.family
121}
122
123/// Returns the primary UI font size in logical pixels from the resolved theme.
124///
125/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
126/// is applied.
127pub fn font_size(resolved: &native_theme::ResolvedTheme) -> f32 {
128    resolved.defaults.font.size
129}
130
131/// Returns the monospace font family name from the resolved theme.
132pub fn mono_font_family(resolved: &native_theme::ResolvedTheme) -> &str {
133    &resolved.defaults.mono_font.family
134}
135
136/// Returns the monospace font size in logical pixels from the resolved theme.
137///
138/// ResolvedFontSpec.size is already in logical pixels -- no pt-to-px conversion
139/// is applied.
140pub fn mono_font_size(resolved: &native_theme::ResolvedTheme) -> f32 {
141    resolved.defaults.mono_font.size
142}
143
144#[cfg(test)]
145#[allow(deprecated)]
146#[allow(clippy::unwrap_used, clippy::expect_used)]
147mod tests {
148    use super::*;
149    use native_theme::{NativeTheme, ThemeVariant};
150
151    fn make_resolved(is_dark: bool) -> native_theme::ResolvedTheme {
152        let nt = NativeTheme::preset("catppuccin-mocha").unwrap();
153        let mut variant = nt.pick_variant(is_dark).unwrap().clone();
154        variant.resolve();
155        variant.validate().unwrap()
156    }
157
158    // === pick_variant tests ===
159
160    #[test]
161    fn pick_variant_light_preferred_returns_light() {
162        let mut theme = NativeTheme::new("Test");
163        theme.light = Some(ThemeVariant::default());
164        theme.dark = Some(ThemeVariant::default());
165
166        let result = pick_variant(&theme, false);
167        assert!(result.is_some());
168        assert!(std::ptr::eq(result.unwrap(), theme.light.as_ref().unwrap()));
169    }
170
171    #[test]
172    fn pick_variant_dark_preferred_returns_dark() {
173        let mut theme = NativeTheme::new("Test");
174        theme.light = Some(ThemeVariant::default());
175        theme.dark = Some(ThemeVariant::default());
176
177        let result = pick_variant(&theme, true);
178        assert!(result.is_some());
179        assert!(std::ptr::eq(result.unwrap(), theme.dark.as_ref().unwrap()));
180    }
181
182    #[test]
183    fn pick_variant_falls_back_to_light_when_no_dark() {
184        let mut theme = NativeTheme::new("Test");
185        theme.light = Some(ThemeVariant::default());
186
187        let result = pick_variant(&theme, true);
188        assert!(result.is_some());
189        assert!(std::ptr::eq(result.unwrap(), theme.light.as_ref().unwrap()));
190    }
191
192    #[test]
193    fn pick_variant_falls_back_to_dark_when_no_light() {
194        let mut theme = NativeTheme::new("Test");
195        theme.dark = Some(ThemeVariant::default());
196
197        let result = pick_variant(&theme, false);
198        assert!(result.is_some());
199        assert!(std::ptr::eq(result.unwrap(), theme.dark.as_ref().unwrap()));
200    }
201
202    #[test]
203    fn pick_variant_returns_none_when_empty() {
204        let theme = NativeTheme::new("Test");
205        assert!(pick_variant(&theme, false).is_none());
206        assert!(pick_variant(&theme, true).is_none());
207    }
208
209    // === to_theme tests ===
210
211    #[test]
212    fn to_theme_produces_non_default_theme() {
213        let resolved = make_resolved(true);
214        let theme = to_theme(&resolved, "Test Theme");
215
216        assert_ne!(theme, iced_core::theme::Theme::Light);
217        assert_ne!(theme, iced_core::theme::Theme::Dark);
218
219        let palette = theme.palette();
220        // Verify palette was applied from resolved theme
221        assert!(
222            palette.primary.r > 0.0 || palette.primary.g > 0.0 || palette.primary.b > 0.0,
223            "primary should be non-zero"
224        );
225    }
226
227    #[test]
228    fn to_theme_from_preset() {
229        let resolved = make_resolved(false);
230        let theme = to_theme(&resolved, "Default");
231
232        let palette = theme.palette();
233        // Default preset has white-ish background for light
234        assert!(palette.background.r > 0.9);
235    }
236
237    // === Widget metric helper tests ===
238
239    #[test]
240    fn border_radius_returns_resolved_value() {
241        let resolved = make_resolved(false);
242        let r = border_radius(&resolved);
243        assert!(r > 0.0, "resolved radius should be > 0");
244    }
245
246    #[test]
247    fn border_radius_lg_returns_resolved_value() {
248        let resolved = make_resolved(false);
249        let r = border_radius_lg(&resolved);
250        assert!(r > 0.0, "resolved radius_lg should be > 0");
251        assert!(
252            r >= border_radius(&resolved),
253            "radius_lg should be >= radius"
254        );
255    }
256
257    #[test]
258    fn scrollbar_width_returns_resolved_value() {
259        let resolved = make_resolved(false);
260        let w = scrollbar_width(&resolved);
261        assert!(w > 0.0, "scrollbar width should be > 0");
262    }
263
264    #[test]
265    fn button_padding_returns_resolved_values() {
266        let resolved = make_resolved(false);
267        let [h, v] = button_padding(&resolved);
268        assert!(h > 0.0, "button horizontal padding should be > 0");
269        assert!(v > 0.0, "button vertical padding should be > 0");
270    }
271
272    #[test]
273    fn input_padding_returns_resolved_values() {
274        let resolved = make_resolved(false);
275        let [h, v] = input_padding(&resolved);
276        assert!(h > 0.0, "input horizontal padding should be > 0");
277        assert!(v > 0.0, "input vertical padding should be > 0");
278    }
279
280    // === Font helper tests ===
281
282    #[test]
283    fn font_family_returns_concrete_value() {
284        let resolved = make_resolved(false);
285        let ff = font_family(&resolved);
286        assert!(!ff.is_empty(), "font family should not be empty");
287    }
288
289    #[test]
290    fn font_size_returns_concrete_value() {
291        let resolved = make_resolved(false);
292        let fs = font_size(&resolved);
293        assert!(fs > 0.0, "font size should be > 0");
294    }
295
296    #[test]
297    fn mono_font_family_returns_concrete_value() {
298        let resolved = make_resolved(false);
299        let mf = mono_font_family(&resolved);
300        assert!(!mf.is_empty(), "mono font family should not be empty");
301    }
302
303    #[test]
304    fn mono_font_size_returns_concrete_value() {
305        let resolved = make_resolved(false);
306        let ms = mono_font_size(&resolved);
307        assert!(ms > 0.0, "mono font size should be > 0");
308    }
309}