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//! ```rust,no_run
8//! use native_theme_iced::from_preset;
9//!
10//! let (theme, resolved) = from_preset("catppuccin-mocha", true).unwrap();
11//! ```
12//!
13//! Or from the OS-detected theme:
14//!
15//! ```rust,no_run
16//! use native_theme_iced::from_system;
17//!
18//! let (theme, resolved, is_dark) = from_system().unwrap();
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 resolved = nt.into_variant(false).unwrap().into_resolved().unwrap();
31//! let theme = to_theme(&resolved, "My App");
32//! ```
33//!
34//! # Font Configuration
35//!
36//! To use theme fonts with iced widgets, leak the family name to obtain
37//! the `&'static str` required by [`iced_core::font::Family::Name`]:
38//!
39//! ```rust,no_run
40//! let (_, resolved) = native_theme_iced::from_preset("catppuccin-mocha", true).unwrap();
41//! let name: &'static str = Box::leak(
42//!     native_theme_iced::font_family(&resolved).to_string().into_boxed_str()
43//! );
44//! let font = iced_core::Font {
45//!     family: iced_core::font::Family::Name(name),
46//!     weight: native_theme_iced::to_iced_weight(
47//!         native_theme_iced::font_weight(&resolved)
48//!     ),
49//!     ..Default::default()
50//! };
51//! ```
52//!
53//! This is the standard iced pattern for runtime font names. Each leak is
54//! ~10-20 bytes and persists for the app lifetime. Call once at theme init,
55//! not per-frame.
56//!
57//! # Theme Field Coverage
58//!
59//! The connector maps a subset of [`ResolvedThemeVariant`] to iced's theming system:
60//!
61//! | Target | Fields | Source |
62//! |--------|--------|--------|
63//! | `Palette` (6 fields) | background, text, primary, success, warning, danger | `defaults.*` |
64//! | `Extended` overrides (8) | secondary.base.color/text, background.weak.color/text, primary/success/danger/warning.base.text | button.bg/fg, defaults.surface/foreground, `*_foreground` |
65//! | Widget metrics | button/input padding, border radius, scrollbar width | Per-widget resolved fields |
66//! | Typography | font family/size/weight, mono family/size/weight, line height | `defaults.font.*`, `defaults.mono_font.*` |
67//! | Color helpers | border, link, selection, info, info_foreground, warning_foreground, focus_ring | `defaults.*` |
68//! | Geometry helpers | spacing (7 tiers), icon_sizes, disabled_opacity | `defaults.*` |
69//!
70//! Per-widget geometry beyond padding/radius (e.g., min-width, disabled-opacity)
71//! is not mapped because iced applies these via inline widget configuration,
72//! not through the theme system. Users can read these directly from the
73//! `ResolvedThemeVariant` they pass to [`to_theme()`].
74
75#![warn(missing_docs)]
76#![forbid(unsafe_code)]
77#![deny(clippy::unwrap_used)]
78#![deny(clippy::expect_used)]
79
80pub(crate) mod extended;
81pub mod icons;
82pub mod palette;
83
84// Re-export native-theme types that appear in public signatures.
85pub use native_theme::{
86    AnimatedIcon, DialogButtonOrder, Error, IconData, IconProvider, IconRole, IconSet,
87    ResolvedThemeVariant, Result, Rgba, SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
88};
89
90#[cfg(target_os = "linux")]
91pub use native_theme::LinuxDesktop;
92
93/// Create an iced [`iced_core::theme::Theme`] from a [`native_theme::ResolvedThemeVariant`].
94///
95/// Builds a custom theme using `Theme::custom_with_fn()`, which:
96/// 1. Maps the 6 Palette fields from resolved theme colors via [`palette::to_palette()`]
97/// 2. Generates an Extended palette, then overrides secondary, background.weak,
98///    and status-family `.base.text` entries via `extended::apply_overrides()`
99///
100/// The resulting theme carries the mapped Palette and Extended palette. iced's
101/// built-in Catalog trait implementations for all 8 core widgets (Button,
102/// Container, TextInput, Scrollable, Checkbox, Slider, ProgressBar, Tooltip)
103/// automatically derive their Style structs from this palette. No explicit
104/// Catalog implementations are needed.
105///
106/// The `name` sets the theme's display name (visible in theme pickers).
107/// For the common case, use [`from_preset()`] to derive the name automatically.
108///
109/// Note: iced has no `info` color family in its Extended palette, so
110/// `info` / `info_foreground` are not mapped automatically. Use
111/// [`info_color()`] and [`info_foreground_color()`] helpers to access them.
112#[must_use = "this returns the theme; it does not apply it"]
113pub fn to_theme(
114    resolved: &native_theme::ResolvedThemeVariant,
115    name: &str,
116) -> iced_core::theme::Theme {
117    let pal = palette::to_palette(resolved);
118
119    // Capture only the Rgba values (Copy, 4 bytes each) instead of
120    // cloning the entire ResolvedThemeVariant (~2KB with heap data).
121    let colors = extended::OverrideColors {
122        btn_bg: resolved.button.background_color,
123        btn_fg: resolved.button.font.color,
124        surface: resolved.defaults.surface_color,
125        foreground: resolved.defaults.text_color,
126        accent_fg: resolved.defaults.accent_text_color,
127        success_fg: resolved.defaults.success_text_color,
128        danger_fg: resolved.defaults.danger_text_color,
129        warning_fg: resolved.defaults.warning_text_color,
130        success_bg: resolved.defaults.success_color,
131        danger_bg: resolved.defaults.danger_color,
132        warning_bg: resolved.defaults.warning_color,
133    };
134
135    iced_core::theme::Theme::custom_with_fn(name.to_string(), pal, move |p| {
136        let mut ext = iced_core::theme::palette::Extended::generate(p);
137        extended::apply_overrides(&mut ext, &colors);
138        ext
139    })
140}
141
142/// Load a bundled preset and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
143///
144/// Handles the full pipeline: load preset, pick variant, resolve, validate, convert.
145/// The `ThemeSpec` display name is used as the theme display name.
146///
147/// # Errors
148///
149/// Returns an error if the preset name is not recognized or if resolution fails.
150#[must_use = "this returns the theme; it does not apply it"]
151pub fn from_preset(
152    name: &str,
153    is_dark: bool,
154) -> native_theme::Result<(iced_core::theme::Theme, native_theme::ResolvedThemeVariant)> {
155    let spec = native_theme::ThemeSpec::preset(name)?;
156    let display_name = spec.name.clone();
157    let mode = if is_dark { "dark" } else { "light" };
158    let variant = spec.into_variant(is_dark).ok_or_else(|| {
159        native_theme::Error::Format(format!(
160            "preset '{name}' has no usable variant (requested {mode}, fallback also empty)"
161        ))
162    })?;
163    let resolved = variant.into_resolved()?;
164    let theme = to_theme(&resolved, &display_name);
165    Ok((theme, resolved))
166}
167
168/// Detect the OS theme and convert it to an iced [`Theme`](iced_core::theme::Theme) in one call.
169///
170/// Returns the iced theme, the resolved variant, and whether the system is in
171/// dark mode. The `is_dark` flag comes from the OS preference, not from
172/// background color analysis.
173///
174/// # Errors
175///
176/// Returns an error if the platform theme cannot be read.
177#[must_use = "this returns the theme; it does not apply it"]
178pub fn from_system() -> native_theme::Result<(
179    iced_core::theme::Theme,
180    native_theme::ResolvedThemeVariant,
181    bool,
182)> {
183    let sys = native_theme::SystemTheme::from_system()?;
184    let is_dark = sys.is_dark;
185    let name = sys.name;
186    let resolved = if is_dark { sys.dark } else { sys.light };
187    let theme = to_theme(&resolved, &name);
188    Ok((theme, resolved, is_dark))
189}
190
191/// Extension trait for converting a [`SystemTheme`] to an iced theme.
192pub trait SystemThemeExt {
193    /// Convert this system theme to an iced [`iced_core::theme::Theme`] and its [`ResolvedThemeVariant`].
194    ///
195    /// Returns both the iced theme and the resolved variant, so callers can
196    /// access per-widget metrics without re-resolving.
197    #[must_use = "this returns the theme; it does not apply it"]
198    fn to_iced_theme(&self) -> (iced_core::theme::Theme, native_theme::ResolvedThemeVariant);
199}
200
201impl SystemThemeExt for native_theme::SystemTheme {
202    fn to_iced_theme(&self) -> (iced_core::theme::Theme, native_theme::ResolvedThemeVariant) {
203        let resolved = self.active().clone();
204        let theme = to_theme(&resolved, &self.name);
205        (theme, resolved)
206    }
207}
208
209/// Returns button padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
210///
211/// Maps `padding_vertical` to top/bottom and `padding_horizontal` to left/right.
212#[must_use]
213pub fn button_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
214    iced_core::Padding::from([
215        resolved.button.border.padding_vertical,
216        resolved.button.border.padding_horizontal,
217    ])
218}
219
220/// Returns text input padding from the resolved theme as an iced [`Padding`](iced_core::Padding).
221///
222/// Maps `border.padding_vertical` to top/bottom and `border.padding_horizontal` to left/right.
223#[must_use]
224pub fn input_padding(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Padding {
225    iced_core::Padding::from([
226        resolved.input.border.padding_vertical,
227        resolved.input.border.padding_horizontal,
228    ])
229}
230
231/// Returns the standard border radius from the resolved theme.
232#[must_use]
233pub fn border_radius(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
234    resolved.defaults.border.corner_radius
235}
236
237/// Returns the large border radius from the resolved theme.
238#[must_use]
239pub fn border_radius_lg(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
240    resolved.defaults.border.corner_radius_lg
241}
242
243/// Returns the scrollbar groove width from the resolved theme.
244#[must_use]
245pub fn scrollbar_width(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
246    resolved.scrollbar.groove_width
247}
248
249/// Returns the primary UI font family name from the resolved theme.
250#[must_use]
251pub fn font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
252    &resolved.defaults.font.family
253}
254
255/// Returns the primary UI font size in logical pixels from the resolved theme.
256///
257/// ResolvedFontSpec.size is in logical pixels (conversion from platform points
258/// is handled by the resolution step).
259#[must_use]
260pub fn font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
261    resolved.defaults.font.size
262}
263
264/// Returns the monospace font family name from the resolved theme.
265#[must_use]
266pub fn mono_font_family(resolved: &native_theme::ResolvedThemeVariant) -> &str {
267    &resolved.defaults.mono_font.family
268}
269
270/// Returns the monospace font size in logical pixels from the resolved theme.
271///
272/// ResolvedFontSpec.size is in logical pixels (conversion from platform points
273/// is handled by the resolution step).
274#[must_use]
275pub fn mono_font_size(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
276    resolved.defaults.mono_font.size
277}
278
279/// Returns the primary UI font weight (CSS 100-900) from the resolved theme.
280#[must_use]
281pub fn font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
282    resolved.defaults.font.weight
283}
284
285/// Returns the monospace font weight (CSS 100-900) from the resolved theme.
286#[must_use]
287pub fn mono_font_weight(resolved: &native_theme::ResolvedThemeVariant) -> u16 {
288    resolved.defaults.mono_font.weight
289}
290
291/// Returns the border/divider color from the resolved theme.
292#[must_use]
293pub fn border_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
294    palette::to_color(resolved.defaults.border.color)
295}
296
297/// Returns the disabled control opacity from the resolved theme.
298#[must_use]
299pub fn disabled_opacity(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
300    resolved.defaults.disabled_opacity
301}
302
303/// Returns the focus ring indicator color from the resolved theme.
304#[must_use]
305pub fn focus_ring_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
306    palette::to_color(resolved.defaults.focus_ring_color)
307}
308
309/// Returns the hyperlink color from the resolved theme.
310#[must_use]
311pub fn link_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
312    palette::to_color(resolved.defaults.link_color)
313}
314
315/// Returns the selection highlight background color from the resolved theme.
316#[must_use]
317pub fn selection_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
318    palette::to_color(resolved.defaults.selection_background)
319}
320
321/// Returns the info/attention color from the resolved theme.
322///
323/// Note: iced has no `info` family in its Extended palette, so this color
324/// is not mapped automatically. Use this helper to access it directly.
325#[must_use]
326pub fn info_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
327    palette::to_color(resolved.defaults.info_color)
328}
329
330/// Returns the text color for info-colored backgrounds from the resolved theme.
331#[must_use]
332pub fn info_foreground_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
333    palette::to_color(resolved.defaults.info_text_color)
334}
335
336/// Returns the warning foreground text color from the resolved theme.
337///
338/// The warning base color is already mapped to `palette.warning`. This returns
339/// the text color intended for use on warning-colored backgrounds.
340#[must_use]
341pub fn warning_foreground_color(resolved: &native_theme::ResolvedThemeVariant) -> iced_core::Color {
342    palette::to_color(resolved.defaults.warning_text_color)
343}
344
345/// Returns a reference to the per-context icon sizes from the resolved theme.
346#[must_use]
347pub fn icon_sizes(
348    resolved: &native_theme::ResolvedThemeVariant,
349) -> &native_theme::ResolvedIconSizes {
350    &resolved.defaults.icon_sizes
351}
352
353/// Returns the line height multiplier from the resolved theme.
354///
355/// The raw multiplier (e.g., 1.4). Use with iced's
356/// `LineHeight::Relative(native_theme_iced::line_height_multiplier(&r))`
357/// for Text widgets. Font-size agnostic -- works correctly for both
358/// the primary UI font and monospace text.
359///
360/// For absolute pixels (layout math), multiply by the appropriate
361/// font size: `line_height_multiplier(&r) * font_size(&r)`.
362#[must_use]
363pub fn line_height_multiplier(resolved: &native_theme::ResolvedThemeVariant) -> f32 {
364    resolved.defaults.line_height
365}
366
367/// Convert a CSS font weight (100-900) to an iced [`Weight`](iced_core::font::Weight) enum.
368///
369/// Non-standard weights are rounded to the nearest standard value
370/// (e.g., 350 -> Normal, 550 -> Semibold).
371///
372/// # Example
373///
374/// ```rust,no_run
375/// let (_, resolved) = native_theme_iced::from_preset("catppuccin-mocha", true).unwrap();
376/// let weight = native_theme_iced::to_iced_weight(
377///     native_theme_iced::font_weight(&resolved),
378/// );
379/// ```
380#[must_use]
381pub fn to_iced_weight(css_weight: u16) -> iced_core::font::Weight {
382    use iced_core::font::Weight;
383    match css_weight {
384        0..=149 => Weight::Thin,
385        150..=249 => Weight::ExtraLight,
386        250..=349 => Weight::Light,
387        350..=449 => Weight::Normal,
388        450..=549 => Weight::Medium,
389        550..=649 => Weight::Semibold,
390        650..=749 => Weight::Bold,
391        750..=849 => Weight::ExtraBold,
392        850.. => Weight::Black,
393    }
394}
395
396#[cfg(test)]
397#[allow(clippy::unwrap_used, clippy::expect_used)]
398mod tests {
399    use super::*;
400    use native_theme::ThemeSpec;
401
402    fn make_resolved_preset(name: &str, is_dark: bool) -> native_theme::ResolvedThemeVariant {
403        ThemeSpec::preset(name)
404            .unwrap()
405            .into_variant(is_dark)
406            .unwrap()
407            .into_resolved()
408            .unwrap()
409    }
410
411    fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
412        make_resolved_preset("catppuccin-mocha", is_dark)
413    }
414
415    // === to_theme tests ===
416
417    #[test]
418    fn to_theme_produces_non_default_theme() {
419        let resolved = make_resolved(true);
420        let theme = to_theme(&resolved, "Test Theme");
421
422        assert_ne!(theme, iced_core::theme::Theme::Light);
423        assert_ne!(theme, iced_core::theme::Theme::Dark);
424
425        let palette = theme.palette();
426        // Catppuccin Mocha dark primary should be non-trivial
427        let expected = palette::to_color(resolved.defaults.accent_color);
428        assert_eq!(palette.primary, expected, "primary should match accent");
429    }
430
431    #[test]
432    fn to_theme_from_preset() {
433        let resolved = make_resolved(false);
434        let theme = to_theme(&resolved, "Default");
435
436        let palette = theme.palette();
437        // Light variant has white-ish background
438        assert!(
439            palette.background.r > 0.9,
440            "light background should be bright"
441        );
442    }
443
444    #[test]
445    fn to_theme_dark_variant() {
446        let resolved = make_resolved(true);
447        let theme = to_theme(&resolved, "Dark Test");
448
449        let palette = theme.palette();
450        assert!(palette.background.r < 0.3, "dark background should be dark");
451    }
452
453    #[test]
454    fn to_theme_different_presets_differ() {
455        let r1 = ThemeSpec::preset("catppuccin-mocha")
456            .unwrap()
457            .into_variant(true)
458            .unwrap()
459            .into_resolved()
460            .unwrap();
461        let r2 = ThemeSpec::preset("dracula")
462            .unwrap()
463            .into_variant(true)
464            .unwrap()
465            .into_resolved()
466            .unwrap();
467
468        let t1 = to_theme(&r1, "mocha");
469        let t2 = to_theme(&r2, "dracula");
470
471        // Different presets should produce different palette colors
472        assert_ne!(t1.palette().primary, t2.palette().primary);
473    }
474
475    #[test]
476    fn to_theme_with_adwaita_preset() {
477        let resolved = make_resolved_preset("adwaita", false);
478        let theme = to_theme(&resolved, "Adwaita");
479        let palette = theme.palette();
480        assert!(palette.primary.a > 0.0, "adwaita primary should be visible");
481    }
482
483    // === Widget metric helper tests ===
484
485    #[test]
486    fn border_radius_returns_resolved_value() {
487        let resolved = make_resolved(false);
488        let r = border_radius(&resolved);
489        assert!(r > 0.0, "resolved radius should be > 0");
490    }
491
492    #[test]
493    fn border_radius_lg_returns_resolved_value() {
494        let resolved = make_resolved(false);
495        let r = border_radius_lg(&resolved);
496        assert!(r > 0.0, "resolved radius_lg should be > 0");
497        assert!(
498            r >= border_radius(&resolved),
499            "radius_lg should be >= radius"
500        );
501    }
502
503    #[test]
504    fn scrollbar_width_returns_resolved_value() {
505        let resolved = make_resolved(false);
506        let w = scrollbar_width(&resolved);
507        assert!(w > 0.0, "scrollbar width should be > 0");
508    }
509
510    #[test]
511    fn button_padding_returns_iced_padding() {
512        let resolved = make_resolved(false);
513        let pad = button_padding(&resolved);
514        // Padding values come from border sub-struct; >= 0 is the valid range.
515        // Phase 51 will wire per-widget border padding from presets.
516        assert!(
517            pad.top >= 0.0,
518            "button vertical (top) padding should be >= 0"
519        );
520        assert!(
521            pad.right >= 0.0,
522            "button horizontal (right) padding should be >= 0"
523        );
524        // vertical maps to top+bottom, horizontal maps to left+right
525        assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
526        assert_eq!(pad.left, pad.right, "left and right should be equal");
527    }
528
529    #[test]
530    fn input_padding_returns_iced_padding() {
531        let resolved = make_resolved(false);
532        let pad = input_padding(&resolved);
533        // Padding values come from border sub-struct; >= 0 is the valid range.
534        // Phase 51 will wire per-widget border padding from presets.
535        assert!(
536            pad.top >= 0.0,
537            "input vertical (top) padding should be >= 0"
538        );
539        assert!(
540            pad.right >= 0.0,
541            "input horizontal (right) padding should be >= 0"
542        );
543        // Symmetry: vertical maps to top+bottom, horizontal maps to left+right
544        assert_eq!(pad.top, pad.bottom, "top and bottom should be equal");
545        assert_eq!(pad.left, pad.right, "left and right should be equal");
546    }
547
548    // === Color helper tests ===
549
550    #[test]
551    fn border_color_returns_concrete_value() {
552        let resolved = make_resolved(false);
553        let c = border_color(&resolved);
554        assert!(c.a > 0.0, "border color should have non-zero alpha");
555    }
556
557    #[test]
558    fn disabled_opacity_returns_value() {
559        let resolved = make_resolved(false);
560        let o = disabled_opacity(&resolved);
561        assert!(
562            o > 0.0 && o <= 1.0,
563            "disabled opacity should be in (0, 1], got {o}"
564        );
565    }
566
567    #[test]
568    fn focus_ring_color_returns_concrete_value() {
569        let resolved = make_resolved(false);
570        let c = focus_ring_color(&resolved);
571        assert!(c.a > 0.0, "focus ring color should have non-zero alpha");
572    }
573
574    #[test]
575    fn link_color_returns_concrete_value() {
576        let resolved = make_resolved(false);
577        let c = link_color(&resolved);
578        assert!(
579            c.r > 0.0 || c.g > 0.0 || c.b > 0.0,
580            "link color should be non-black"
581        );
582    }
583
584    #[test]
585    fn selection_color_returns_concrete_value() {
586        let resolved = make_resolved(false);
587        let c = selection_color(&resolved);
588        assert!(c.a > 0.0, "selection color should have non-zero alpha");
589    }
590
591    #[test]
592    fn info_color_returns_concrete_value() {
593        let resolved = make_resolved(false);
594        let c = info_color(&resolved);
595        assert!(
596            c.r > 0.0 || c.g > 0.0 || c.b > 0.0,
597            "info color should be non-black"
598        );
599    }
600
601    #[test]
602    fn info_foreground_color_returns_concrete_value() {
603        let resolved = make_resolved(false);
604        let c = info_foreground_color(&resolved);
605        assert!(c.a > 0.0, "info foreground should have non-zero alpha");
606    }
607
608    #[test]
609    fn warning_foreground_color_returns_concrete_value() {
610        let resolved = make_resolved(false);
611        let c = warning_foreground_color(&resolved);
612        assert!(c.a > 0.0, "warning foreground should have non-zero alpha");
613    }
614
615    #[test]
616    fn icon_sizes_returns_concrete_values() {
617        let resolved = make_resolved(false);
618        let is = icon_sizes(&resolved);
619        assert!(is.small > 0.0, "small icon size should be > 0");
620        assert!(is.toolbar > 0.0, "toolbar icon size should be > 0");
621    }
622
623    // === Font helper tests ===
624
625    #[test]
626    fn font_family_returns_concrete_value() {
627        let resolved = make_resolved(false);
628        let ff = font_family(&resolved);
629        assert!(!ff.is_empty(), "font family should not be empty");
630    }
631
632    #[test]
633    fn font_size_returns_concrete_value() {
634        let resolved = make_resolved(false);
635        let fs = font_size(&resolved);
636        assert!(fs > 0.0, "font size should be > 0");
637    }
638
639    #[test]
640    fn mono_font_family_returns_concrete_value() {
641        let resolved = make_resolved(false);
642        let mf = mono_font_family(&resolved);
643        assert!(!mf.is_empty(), "mono font family should not be empty");
644    }
645
646    #[test]
647    fn mono_font_size_returns_concrete_value() {
648        let resolved = make_resolved(false);
649        let ms = mono_font_size(&resolved);
650        assert!(ms > 0.0, "mono font size should be > 0");
651    }
652
653    #[test]
654    fn font_weight_returns_concrete_value() {
655        let resolved = make_resolved(false);
656        let w = font_weight(&resolved);
657        assert!(
658            (100..=900).contains(&w),
659            "font weight should be 100-900, got {}",
660            w
661        );
662    }
663
664    #[test]
665    fn mono_font_weight_returns_concrete_value() {
666        let resolved = make_resolved(false);
667        let w = mono_font_weight(&resolved);
668        assert!(
669            (100..=900).contains(&w),
670            "mono font weight should be 100-900, got {}",
671            w
672        );
673    }
674
675    #[test]
676    fn line_height_multiplier_returns_concrete_value() {
677        let resolved = make_resolved(false);
678        let lh = line_height_multiplier(&resolved);
679        assert!(lh > 0.0, "line height multiplier should be > 0");
680        assert!(
681            lh < 5.0,
682            "line height multiplier should be a multiplier (e.g. 1.4), got {}",
683            lh
684        );
685    }
686
687    #[test]
688    fn to_iced_weight_standard_weights() {
689        use iced_core::font::Weight;
690        assert_eq!(to_iced_weight(100), Weight::Thin);
691        assert_eq!(to_iced_weight(200), Weight::ExtraLight);
692        assert_eq!(to_iced_weight(300), Weight::Light);
693        assert_eq!(to_iced_weight(400), Weight::Normal);
694        assert_eq!(to_iced_weight(500), Weight::Medium);
695        assert_eq!(to_iced_weight(600), Weight::Semibold);
696        assert_eq!(to_iced_weight(700), Weight::Bold);
697        assert_eq!(to_iced_weight(800), Weight::ExtraBold);
698        assert_eq!(to_iced_weight(900), Weight::Black);
699    }
700
701    #[test]
702    fn to_iced_weight_non_standard_rounds_correctly() {
703        use iced_core::font::Weight;
704        assert_eq!(to_iced_weight(350), Weight::Normal);
705        assert_eq!(to_iced_weight(450), Weight::Medium);
706        assert_eq!(to_iced_weight(550), Weight::Semibold);
707        assert_eq!(to_iced_weight(0), Weight::Thin);
708        assert_eq!(to_iced_weight(1000), Weight::Black);
709    }
710
711    // === Convenience API tests ===
712
713    #[test]
714    fn from_preset_valid_light() {
715        let (theme, resolved) = from_preset("catppuccin-mocha", false).expect("preset should load");
716        assert_ne!(theme, iced_core::theme::Theme::Light);
717        assert!(!resolved.defaults.font.family.is_empty());
718        // Light variant should have bright background
719        let palette = theme.palette();
720        assert!(
721            palette.background.r > 0.9,
722            "light variant should have bright background, got r={}",
723            palette.background.r
724        );
725    }
726
727    #[test]
728    fn from_preset_valid_dark() {
729        let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
730        assert_ne!(theme, iced_core::theme::Theme::Dark);
731        // Dark variant should have dark background
732        let palette = theme.palette();
733        assert!(
734            palette.background.r < 0.3,
735            "dark variant should have dark background, got r={}",
736            palette.background.r
737        );
738    }
739
740    #[test]
741    fn from_preset_invalid_name() {
742        let result = from_preset("nonexistent-preset", false);
743        assert!(result.is_err(), "invalid preset should return Err");
744    }
745
746    #[test]
747    fn from_preset_error_shows_requested_mode() {
748        // This tests the error path -- an actually empty preset cannot be
749        // created through the public API, but we verify the format of
750        // the success/error paths.
751        let result = from_preset("nonexistent-preset", true);
752        assert!(result.is_err());
753    }
754
755    #[test]
756    fn system_theme_ext_to_iced_theme() {
757        // May fail on CI -- skip gracefully
758        let Ok(sys) = native_theme::SystemTheme::from_system() else {
759            return;
760        };
761        let (_theme, _resolved) = sys.to_iced_theme();
762    }
763
764    #[test]
765    fn from_system_does_not_panic() {
766        let _ = from_system();
767    }
768
769    #[test]
770    fn from_system_returns_is_dark() {
771        // If system theme is available, verify it returns a triple
772        if let Ok((_theme, _resolved, is_dark)) = from_system() {
773            // is_dark should be a valid bool (always true, but verify the return)
774            let _ = is_dark;
775        }
776    }
777
778    #[test]
779    fn to_theme_extended_overrides_take_effect() {
780        let resolved = make_resolved(true);
781        let theme = to_theme(&resolved, "test");
782        let ext = theme.extended_palette();
783        // Generate what the Extended palette would be without overrides
784        let auto_palette = iced_core::theme::palette::Extended::generate(theme.palette());
785        // apply_overrides sets secondary.base from button.bg/fg which differs
786        // from the auto-generated value
787        assert_ne!(
788            ext.secondary.base.color, auto_palette.secondary.base.color,
789            "secondary.base.color should be overridden, not auto-generated"
790        );
791    }
792
793    // Integration-level: exercises the full from_preset -> to_theme pipeline for all presets
794
795    #[test]
796    fn all_presets_produce_valid_themes() {
797        for name in ThemeSpec::list_presets() {
798            for is_dark in [false, true] {
799                let spec = ThemeSpec::preset(name).unwrap();
800                if let Some(variant) = spec.into_variant(is_dark) {
801                    let resolved = variant.into_resolved().unwrap();
802                    let theme = to_theme(&resolved, name);
803                    let palette = theme.palette();
804                    // Basic sanity: all palette colors have valid alpha
805                    assert!(
806                        palette.background.a > 0.0,
807                        "{name}/{is_dark}: background alpha"
808                    );
809                    assert!(palette.text.a > 0.0, "{name}/{is_dark}: text alpha");
810                    assert!(palette.primary.a > 0.0, "{name}/{is_dark}: primary alpha");
811                    assert!(palette.success.a > 0.0, "{name}/{is_dark}: success alpha");
812                    assert!(palette.warning.a > 0.0, "{name}/{is_dark}: warning alpha");
813                    assert!(palette.danger.a > 0.0, "{name}/{is_dark}: danger alpha");
814                }
815            }
816        }
817    }
818
819    // === Tripwire: iced Palette field count ===
820
821    #[test]
822    fn palette_field_count_tripwire() {
823        // iced_core::theme::Palette has 6 Color fields. If upstream adds more,
824        // this test fails so we know to update to_palette().
825        let field_count = std::mem::size_of::<iced_core::theme::Palette>()
826            / std::mem::size_of::<iced_core::Color>();
827        assert_eq!(
828            field_count, 6,
829            "iced Palette field count changed from 6 to {field_count} -- update to_palette()"
830        );
831    }
832}