Skip to main content

native_theme_gpui/
lib.rs

1//! gpui toolkit connector for native-theme.
2//!
3//! Maps [`native_theme::ResolvedThemeVariant`] data to gpui-component's theming system.
4//!
5//! # Quick Start
6//!
7//! ```ignore
8//! use native_theme_gpui::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_gpui::from_system;
17//!
18//! let (theme, resolved, is_dark) = from_system()?;
19//! ```
20//!
21//! # Manual Path
22//!
23//! For full control over the resolve/validate/convert pipeline:
24//!
25//! ```ignore
26//! use native_theme::ThemeSpec;
27//! use native_theme_gpui::to_theme;
28//!
29//! let nt = ThemeSpec::preset("catppuccin-mocha")?;
30//! let variant = nt.into_variant(true).ok_or("no dark variant")?;
31//! let resolved = variant.into_resolved()?;
32//! let theme = to_theme(&resolved, "Catppuccin Mocha", true);
33//! ```
34//!
35//! # Single-Mode Behavior
36//!
37//! Each call to [`from_preset()`] or [`to_theme()`] produces a theme for exactly
38//! one mode (light or dark). The `is_dark` parameter selects which variant to
39//! load from the preset's TOML. Presets that only define one variant will return
40//! that variant for both `is_dark=true` and `is_dark=false` (the resolution
41//! pipeline falls back to the available variant). To support runtime light/dark
42//! switching, call [`from_preset()`] twice and swap the resulting themes.
43//!
44//! # Theme Field Coverage
45//!
46//! The connector maps a subset of [`ResolvedThemeVariant`] fields to gpui-component's
47//! `ThemeColor` (108 color fields) and `ThemeConfig` (font/geometry).
48//!
49//! | Category | Mapped | Notes |
50//! |----------|--------|-------|
51//! | `defaults` colors | All 20+ | background, foreground, accent, danger, etc. |
52//! | `defaults` geometry | radius, radius_lg, shadow | Font family/size also mapped |
53//! | `button` | 4 of 14 | primary_background/foreground, background/foreground (colors only) |
54//! | `tab` | 5 of 9 | All colors, sizing not mapped |
55//! | `sidebar` | 2 of 2 | background, foreground |
56//! | `window` | 2 of 10 | title_bar_background, border |
57//! | `input` | 2 of 12 | border, caret |
58//! | `scrollbar` | 3 of 7 | track, thumb, thumb_hover |
59//! | `slider`, `switch` | 2 each | fill/thumb colors |
60//! | `progress_bar` | 1 of 5 | fill |
61//! | `list` | 1 of 11 | alternate_row |
62//! | `popover` | 2 of 4 | background, foreground |
63//! | 14 other widgets | 0 fields | checkbox, menu, tooltip, dialog, etc. |
64//!
65//! **Why the gap:** gpui-component's `ThemeColor` is a flat color bag with no per-widget
66//! geometry. The connector cannot map most sizing/spacing data because the target type
67//! has no corresponding fields. Users who need per-widget geometry can read it directly
68//! from the `ResolvedThemeVariant` they passed to [`to_theme()`].
69
70#![warn(missing_docs)]
71#![forbid(unsafe_code)]
72#![deny(clippy::unwrap_used)]
73#![deny(clippy::expect_used)]
74
75pub(crate) mod colors;
76pub(crate) mod config;
77pub(crate) mod derive;
78pub mod icons;
79
80// Re-export native-theme types that appear in public signatures so downstream
81// crates don't need native-theme as a direct dependency.
82// Issue 8 + 48: re-export Result, Rgba, Error, DialogButtonOrder
83pub use native_theme::{
84    AnimatedIcon, DialogButtonOrder, Error, IconData, IconProvider, IconRole, IconSet,
85    ResolvedThemeVariant, Result, Rgba, SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
86};
87
88#[cfg(target_os = "linux")]
89pub use native_theme::LinuxDesktop;
90
91use gpui::{SharedString, px};
92use gpui_component::scroll::ScrollbarShow;
93use gpui_component::theme::{Theme, ThemeMode};
94use std::rc::Rc;
95
96/// Convert a [`ResolvedThemeVariant`] into a gpui-component [`Theme`].
97///
98/// Builds a complete Theme by:
99/// 1. Mapping all 108 ThemeColor fields via `colors::to_theme_color`
100/// 2. Setting font, geometry, and mode fields directly on the Theme
101/// 3. Storing a ThemeConfig in light_theme/dark_theme Rc for gpui-component switching
102///
103/// All Theme fields are set explicitly -- no `apply_config` call is used.
104/// This avoids the fragile apply-then-restore pattern where `apply_config`
105/// would overwrite all 108 color fields with defaults.
106///
107/// The `is_dark` parameter is required rather than auto-derived because
108/// several presets (e.g. solarized, gruvbox) have borderline lightness
109/// values where auto-detection would disagree with the user's intent.
110/// To auto-derive: `let is_dark = resolved.defaults.background_color` lightness < 0.5
111/// via [`is_dark_resolved()`].
112///
113/// Note: `is_dark` is an explicit parameter here, unlike the iced connector
114/// which derives it from background luminance. Planned for unification in v0.6.0.
115#[must_use = "this returns the theme; it does not apply it"]
116pub fn to_theme(resolved: &ResolvedThemeVariant, name: &str, is_dark: bool) -> Theme {
117    let theme_color = colors::to_theme_color(resolved, is_dark);
118    let mode = if is_dark {
119        ThemeMode::Dark
120    } else {
121        ThemeMode::Light
122    };
123    let d = &resolved.defaults;
124
125    let mut theme = Theme::from(&theme_color);
126    // Issue 53: Theme.transparent is set by Theme::from() to
127    // Hsla::transparent_black() and is intentionally left unchanged.
128    // It's used internally by gpui-component for transparent overlays.
129    theme.mode = mode;
130    theme.font_family = SharedString::from(d.font.family.clone());
131    theme.font_size = px(d.font.size);
132    theme.mono_font_family = SharedString::from(d.mono_font.family.clone());
133    theme.mono_font_size = px(d.mono_font.size);
134    // Issue 14: clamp radius to non-negative
135    theme.radius = px(d.border.corner_radius.max(0.0));
136    theme.radius_lg = px(d.border.corner_radius_lg.max(0.0));
137    theme.shadow = d.border.shadow_enabled;
138
139    // Issue 43: set scrollbar_show from resolved overlay_mode
140    theme.scrollbar_show = if resolved.scrollbar.overlay_mode {
141        ScrollbarShow::Scrolling
142    } else {
143        ScrollbarShow::Always
144    };
145
146    // Issue 43/44: set highlight_theme based on is_dark so syntax highlighting
147    // uses appropriate colors (dark themes get dark highlight, light themes get light).
148    theme.highlight_theme = if is_dark {
149        gpui_component::highlighter::HighlightTheme::default_dark()
150    } else {
151        gpui_component::highlighter::HighlightTheme::default_light()
152    };
153
154    // Store config for gpui-component's theme switching
155    let config: Rc<_> = Rc::new(config::to_theme_config(resolved, name, mode));
156    if mode == ThemeMode::Dark {
157        theme.dark_theme = config;
158    } else {
159        theme.light_theme = config;
160    }
161    theme
162}
163
164/// Load a bundled preset and convert it to a gpui-component [`Theme`] in one call.
165///
166/// This is the primary entry point for most users. It handles the full pipeline:
167/// load preset, pick variant, resolve, validate, and convert to gpui Theme.
168///
169/// Returns both the gpui Theme and the [`ResolvedThemeVariant`] so callers can
170/// access per-widget metrics (button padding, scrollbar width, etc.) that the
171/// flat `ThemeColor` cannot represent.
172///
173/// The preset name is used as the theme display name.
174///
175/// # Errors
176///
177/// Returns an error if the preset name is not recognized or if resolution fails.
178///
179/// # Examples
180///
181/// ```ignore
182/// let (dark_theme, resolved) = native_theme_gpui::from_preset("dracula", true)?;
183/// let (light_theme, _) = native_theme_gpui::from_preset("catppuccin-latte", false)?;
184/// ```
185#[must_use = "this returns the theme; it does not apply it"]
186pub fn from_preset(
187    name: &str,
188    is_dark: bool,
189) -> native_theme::Result<(Theme, ResolvedThemeVariant)> {
190    let spec = ThemeSpec::preset(name)?;
191    let display_name = spec.name.clone();
192    let mode_str = if is_dark { "dark" } else { "light" };
193    let variant = spec.into_variant(is_dark).ok_or_else(|| {
194        native_theme::Error::Format(format!("preset '{name}' has no {mode_str} variant"))
195    })?;
196    let resolved = variant.into_resolved()?;
197    let theme = to_theme(&resolved, &display_name, is_dark);
198    Ok((theme, resolved))
199}
200
201/// Detect the OS theme and convert it to a gpui-component [`Theme`] in one call.
202///
203/// Combines [`SystemTheme::from_system()`](native_theme::SystemTheme::from_system)
204/// with [`to_theme()`] using the system-detected name and dark-mode preference.
205///
206/// Returns both the gpui Theme and the [`ResolvedThemeVariant`] so callers can
207/// access per-widget metrics that the flat `ThemeColor` cannot represent.
208///
209/// **Ownership note** (Issue 19/31): this function takes ownership of the
210/// `SystemTheme`'s active variant. The non-active variant (light when dark
211/// is active, or vice versa) is dropped. If you need both variants, use
212/// `SystemTheme::from_system()` directly and call [`to_theme()`] on each.
213///
214/// **Performance note:** `SystemTheme::from_system()` resolves both light
215/// and dark variants before this function picks one. If you only need one
216/// variant and want to avoid the cost of resolving both, use
217/// `SystemTheme::from_system()` directly and resolve only the variant you need.
218///
219/// # Errors
220///
221/// Returns an error if the platform theme cannot be read (e.g., unsupported platform,
222/// missing desktop environment).
223///
224/// # Examples
225///
226/// ```ignore
227/// let (theme, resolved, is_dark) = native_theme_gpui::from_system()?;
228/// ```
229#[must_use = "this returns the theme; it does not apply it"]
230pub fn from_system() -> native_theme::Result<(Theme, ResolvedThemeVariant, bool)> {
231    let sys = SystemTheme::from_system()?;
232    let is_dark = sys.is_dark;
233    let name = sys.name; // K-5: move instead of clone
234    let resolved = if is_dark { sys.dark } else { sys.light };
235    let theme = to_theme(&resolved, &name, is_dark);
236    Ok((theme, resolved, is_dark))
237}
238
239/// Extension trait for converting a [`SystemTheme`] to a gpui-component [`Theme`].
240///
241/// Useful when you already have a `SystemTheme` and want method syntax:
242///
243/// ```ignore
244/// use native_theme_gpui::SystemThemeExt;
245///
246/// let sys = native_theme::SystemTheme::from_system()?;
247/// let theme = sys.to_gpui_theme();
248/// ```
249pub trait SystemThemeExt {
250    /// Convert this system theme to a gpui-component [`Theme`].
251    ///
252    /// Uses the active variant (based on `is_dark`), the theme name,
253    /// and the dark-mode flag from the `SystemTheme`.
254    #[must_use = "this returns the theme; it does not apply it"]
255    fn to_gpui_theme(&self) -> Theme;
256}
257
258impl SystemThemeExt for SystemTheme {
259    fn to_gpui_theme(&self) -> Theme {
260        to_theme(self.active(), &self.name, self.is_dark)
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Helper functions (Issues 15, 17, 25, 32, 36, 37, 47, 48, 13)
266// ---------------------------------------------------------------------------
267
268/// Derive `is_dark` from a [`ResolvedThemeVariant`]'s background lightness.
269///
270/// Returns `true` when the background lightness is below 0.5. This is a
271/// convenience for callers that do not have an explicit dark-mode flag.
272/// Some presets (e.g. solarized, gruvbox) have borderline values where the
273/// auto-detected result may differ from the user's intent.
274#[must_use]
275pub fn is_dark_resolved(resolved: &ResolvedThemeVariant) -> bool {
276    colors::rgba_to_hsla(resolved.defaults.background_color).l < 0.5
277}
278
279// --- Issue 32: Accessibility helpers ---
280
281/// Returns `true` if the resolved theme is considered dark.
282///
283/// Equivalent to [`is_dark_resolved()`] -- delegates to background lightness.
284#[must_use]
285pub fn is_dark(resolved: &ResolvedThemeVariant) -> bool {
286    is_dark_resolved(resolved)
287}
288
289/// Whether the user/theme has requested reduced motion.
290#[must_use]
291pub fn is_reduced_motion(resolved: &ResolvedThemeVariant) -> bool {
292    resolved.defaults.reduce_motion
293}
294
295/// Whether the theme is in high-contrast mode.
296#[must_use]
297pub fn is_high_contrast(resolved: &ResolvedThemeVariant) -> bool {
298    resolved.defaults.high_contrast
299}
300
301/// Whether the user/theme has requested reduced transparency.
302#[must_use]
303pub fn is_reduced_transparency(resolved: &ResolvedThemeVariant) -> bool {
304    resolved.defaults.reduce_transparency
305}
306
307// --- Issue 15: Defaults field accessors ---
308
309/// Frame/border width from the resolved theme defaults.
310#[must_use]
311pub fn frame_width(resolved: &ResolvedThemeVariant) -> f32 {
312    resolved.defaults.border.line_width
313}
314
315/// Disabled control opacity from the resolved theme defaults.
316#[must_use]
317pub fn disabled_opacity(resolved: &ResolvedThemeVariant) -> f32 {
318    resolved.defaults.disabled_opacity
319}
320
321/// Border opacity multiplier from the resolved theme defaults.
322#[must_use]
323pub fn border_opacity(resolved: &ResolvedThemeVariant) -> f32 {
324    resolved.defaults.border.opacity
325}
326
327/// Whether drop shadows are enabled.
328#[must_use]
329pub fn shadow_enabled(resolved: &ResolvedThemeVariant) -> bool {
330    resolved.defaults.border.shadow_enabled
331}
332
333/// Text scaling factor (1.0 = no scaling).
334#[must_use]
335pub fn text_scaling_factor(resolved: &ResolvedThemeVariant) -> f32 {
336    resolved.defaults.text_scaling_factor
337}
338
339// --- Issue 17: Spacing / icon-size / text-scale accessors ---
340
341/// Access the per-context icon sizes from the resolved theme.
342///
343/// Returns icon sizes for toolbar, small, large, dialog, and panel contexts.
344#[must_use]
345pub fn icon_sizes(resolved: &ResolvedThemeVariant) -> &native_theme::ResolvedIconSizes {
346    &resolved.defaults.icon_sizes
347}
348
349/// Access the text scale entries from the resolved theme.
350///
351/// Returns the 4-entry text scale (caption, section_heading, dialog_title, display).
352#[must_use]
353pub fn text_scale(resolved: &ResolvedThemeVariant) -> &native_theme::ResolvedTextScale {
354    &resolved.text_scale
355}
356
357// --- Issue 36: Line height multiplier ---
358
359/// Line height multiplier from the resolved theme defaults.
360#[must_use]
361pub fn line_height_multiplier(resolved: &ResolvedThemeVariant) -> f32 {
362    resolved.defaults.line_height
363}
364
365// --- Issue 13: Font weight helper ---
366
367/// Default font weight from the resolved theme.
368///
369/// Returns the CSS font weight value (100-900).
370#[must_use]
371pub fn font_weight(resolved: &ResolvedThemeVariant) -> u16 {
372    resolved.defaults.font.weight
373}
374
375// --- Issue 47: Mono font weight helper ---
376
377/// Monospace font weight from the resolved theme.
378///
379/// Returns the CSS font weight value (100-900).
380#[must_use]
381pub fn mono_font_weight(resolved: &ResolvedThemeVariant) -> u16 {
382    resolved.defaults.mono_font.weight
383}
384
385// --- Issue 48: Dialog button order helper ---
386
387/// The platform-appropriate dialog button order.
388///
389/// Returns whether affirmative buttons should appear on the leading (left)
390/// or trailing (right) side of a dialog. KDE uses leading-affirmative;
391/// most other platforms use trailing-affirmative.
392#[must_use]
393pub fn dialog_button_order(resolved: &ResolvedThemeVariant) -> DialogButtonOrder {
394    resolved.dialog.button_order
395}
396
397// --- Issue 37: Padding/geometry helpers ---
398
399/// Dialog content padding in logical pixels.
400///
401/// Returns the horizontal padding from the dialog's border spec.
402#[must_use]
403pub fn dialog_content_padding(resolved: &ResolvedThemeVariant) -> f32 {
404    resolved.dialog.border.padding_horizontal
405}
406
407/// Dialog button gap in logical pixels.
408#[must_use]
409pub fn dialog_button_spacing(resolved: &ResolvedThemeVariant) -> f32 {
410    resolved.dialog.button_gap
411}
412
413/// Scrollbar groove width in logical pixels.
414#[must_use]
415pub fn scrollbar_width(resolved: &ResolvedThemeVariant) -> f32 {
416    resolved.scrollbar.groove_width
417}
418
419/// Selection text color (foreground for selected content).
420#[must_use]
421pub fn selection_foreground(resolved: &ResolvedThemeVariant) -> Rgba {
422    resolved.defaults.selection_text_color
423}
424
425/// Selection background when window is unfocused.
426#[must_use]
427pub fn selection_inactive(resolved: &ResolvedThemeVariant) -> Rgba {
428    resolved.defaults.selection_inactive_background
429}
430
431/// Foreground color for disabled elements.
432#[must_use]
433pub fn disabled_foreground(resolved: &ResolvedThemeVariant) -> Rgba {
434    resolved.defaults.disabled_text_color
435}
436
437/// Focus ring stroke width in logical pixels.
438#[must_use]
439pub fn focus_ring_width(resolved: &ResolvedThemeVariant) -> f32 {
440    resolved.defaults.focus_ring_width
441}
442
443/// Gap between element edge and focus ring.
444#[must_use]
445pub fn focus_ring_offset(resolved: &ResolvedThemeVariant) -> f32 {
446    resolved.defaults.focus_ring_offset
447}
448
449#[cfg(test)]
450#[allow(clippy::unwrap_used, clippy::expect_used)]
451mod tests {
452    use super::*;
453
454    /// Issue 1: fixed to use into_variant(true) for catppuccin-mocha (dark theme).
455    fn test_resolved() -> ResolvedThemeVariant {
456        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
457        let variant = nt
458            .into_variant(true)
459            .expect("preset must have dark variant");
460        variant
461            .into_resolved()
462            .expect("resolved preset must validate")
463    }
464
465    #[test]
466    fn to_theme_produces_valid_theme() {
467        let resolved = test_resolved();
468        let theme = to_theme(&resolved, "Test", true);
469
470        // Theme should have the correct mode
471        assert!(theme.is_dark());
472    }
473
474    #[test]
475    fn to_theme_dark_mode() {
476        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
477        let variant = nt
478            .into_variant(true)
479            .expect("preset must have dark variant");
480        let resolved = variant
481            .into_resolved()
482            .expect("resolved preset must validate");
483        let theme = to_theme(&resolved, "DarkTest", true);
484
485        assert!(theme.is_dark());
486    }
487
488    #[test]
489    fn to_theme_applies_font_and_geometry() {
490        let resolved = test_resolved();
491        let theme = to_theme(&resolved, "Test", true);
492
493        assert_eq!(theme.font_family.to_string(), resolved.defaults.font.family);
494        assert_eq!(theme.font_size, px(resolved.defaults.font.size));
495        assert_eq!(
496            theme.mono_font_family.to_string(),
497            resolved.defaults.mono_font.family
498        );
499        assert_eq!(theme.mono_font_size, px(resolved.defaults.mono_font.size));
500        assert_eq!(
501            theme.radius,
502            px(resolved.defaults.border.corner_radius.max(0.0))
503        );
504        assert_eq!(
505            theme.radius_lg,
506            px(resolved.defaults.border.corner_radius_lg.max(0.0))
507        );
508        assert_eq!(theme.shadow, resolved.defaults.border.shadow_enabled);
509    }
510
511    // Issue 43: scrollbar_show set from overlay_mode
512    #[test]
513    fn scrollbar_show_from_overlay_mode() {
514        let resolved = test_resolved();
515        let theme = to_theme(&resolved, "Scroll", true);
516        if resolved.scrollbar.overlay_mode {
517            assert!(
518                matches!(theme.scrollbar_show, ScrollbarShow::Scrolling),
519                "overlay_mode=true should set Scrolling"
520            );
521        } else {
522            assert!(
523                matches!(theme.scrollbar_show, ScrollbarShow::Always),
524                "overlay_mode=false should set Always"
525            );
526        }
527    }
528
529    // Issue 43/44: highlight_theme matches is_dark
530    #[test]
531    fn highlight_theme_matches_is_dark() {
532        let resolved = test_resolved();
533        let dark_theme = to_theme(&resolved, "Dark", true);
534        assert_eq!(
535            dark_theme.highlight_theme.appearance,
536            ThemeMode::Dark,
537            "dark theme should use dark highlight"
538        );
539
540        let light_resolved = {
541            let spec = ThemeSpec::preset("catppuccin-latte").expect("preset must exist");
542            let variant = spec.into_variant(false).expect("light variant");
543            variant.into_resolved().expect("must validate")
544        };
545        let light_theme = to_theme(&light_resolved, "Light", false);
546        assert_eq!(
547            light_theme.highlight_theme.appearance,
548            ThemeMode::Light,
549            "light theme should use light highlight"
550        );
551    }
552
553    // -- from_preset tests --
554
555    #[test]
556    fn from_preset_valid_light() {
557        let (theme, _resolved) =
558            from_preset("catppuccin-latte", false).expect("preset should load");
559        assert!(!theme.is_dark());
560    }
561
562    #[test]
563    fn from_preset_valid_dark() {
564        let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
565        assert!(theme.is_dark());
566    }
567
568    #[test]
569    fn from_preset_returns_resolved() {
570        let (_theme, resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
571        // ResolvedThemeVariant should have populated defaults
572        assert!(resolved.defaults.font.size > 0.0);
573    }
574
575    #[test]
576    fn from_preset_invalid_name() {
577        let result = from_preset("nonexistent-preset", false);
578        assert!(result.is_err(), "invalid preset should return Err");
579    }
580
581    // Issue 23: error message includes the mode
582    #[test]
583    fn from_preset_error_message_includes_mode() {
584        // Both modes should load for catppuccin-mocha (it has both variants)
585        let _ = from_preset("catppuccin-mocha", true).expect("dark should work");
586        let _ = from_preset("catppuccin-mocha", false).expect("light should work");
587    }
588
589    // -- SystemThemeExt + from_system tests --
590
591    #[test]
592    fn system_theme_ext_to_gpui_theme() {
593        // from_system() may fail on CI (no desktop env) -- skip gracefully
594        let Ok(sys) = SystemTheme::from_system() else {
595            return;
596        };
597        let theme = sys.to_gpui_theme();
598        assert_eq!(
599            theme.is_dark(),
600            sys.is_dark,
601            "to_gpui_theme() is_dark should match SystemTheme.is_dark"
602        );
603    }
604
605    #[test]
606    fn from_system_does_not_panic() {
607        // Just verify no panic -- result may be Err on CI
608        let _ = from_system();
609    }
610
611    #[test]
612    fn from_system_returns_tuple() {
613        let Ok((theme, resolved, _is_dark)) = from_system() else {
614            return;
615        };
616        // Theme and resolved should agree on basic properties
617        assert!(resolved.defaults.font.size > 0.0);
618        // Theme mode should be set
619        let _ = theme.is_dark();
620    }
621
622    #[test]
623    fn from_system_matches_manual_path() {
624        let Ok(sys) = SystemTheme::from_system() else {
625            return;
626        };
627        let via_convenience = sys.to_gpui_theme();
628        let via_manual = to_theme(sys.active(), &sys.name, sys.is_dark);
629        // Both paths should produce identical results
630        assert_eq!(
631            via_convenience.is_dark(),
632            via_manual.is_dark(),
633            "convenience and manual paths should agree on is_dark"
634        );
635        // Issue 39: verify the resolved variant has meaningful content.
636        // from_system() may return Err on systems without a desktop (CI),
637        // but if we reach here, the active variant should have at least
638        // accent or background populated.
639        let resolved = sys.active();
640        assert!(
641            resolved.defaults.accent_color != native_theme::Rgba::default()
642                || resolved.defaults.background_color != native_theme::Rgba::default(),
643            "resolved variant should have at least accent or background populated"
644        );
645    }
646
647    // -- Issue 25/32: helper function tests --
648
649    #[test]
650    fn is_dark_resolved_matches_background() {
651        let resolved = test_resolved();
652        let bg = colors::rgba_to_hsla(resolved.defaults.background_color);
653        assert_eq!(
654            is_dark_resolved(&resolved),
655            bg.l < 0.5,
656            "is_dark_resolved should match background lightness"
657        );
658    }
659
660    #[test]
661    fn accessibility_helpers() {
662        let resolved = test_resolved();
663        // Just verify they return without panic and give sensible values
664        let _ = is_reduced_motion(&resolved);
665        let _ = is_high_contrast(&resolved);
666        let _ = is_reduced_transparency(&resolved);
667    }
668
669    #[test]
670    fn defaults_field_helpers() {
671        let resolved = test_resolved();
672        assert!(frame_width(&resolved) >= 0.0);
673        assert!(disabled_opacity(&resolved) >= 0.0);
674        assert!(disabled_opacity(&resolved) <= 1.0);
675        assert!(border_opacity(&resolved) >= 0.0);
676        assert!(text_scaling_factor(&resolved) > 0.0);
677    }
678
679    #[test]
680    fn icon_sizes_helper() {
681        let resolved = test_resolved();
682        let sizes = icon_sizes(&resolved);
683        assert!(sizes.toolbar > 0.0, "toolbar icon size should be positive");
684    }
685
686    #[test]
687    fn text_scale_helper() {
688        let resolved = test_resolved();
689        let ts = text_scale(&resolved);
690        assert!(ts.caption.size > 0.0, "caption size should be positive");
691    }
692
693    #[test]
694    fn font_weight_helper() {
695        let resolved = test_resolved();
696        let w = font_weight(&resolved);
697        assert!((100..=900).contains(&w), "font weight should be 100-900");
698    }
699
700    #[test]
701    fn mono_font_weight_helper() {
702        let resolved = test_resolved();
703        let w = mono_font_weight(&resolved);
704        assert!(
705            (100..=900).contains(&w),
706            "mono font weight should be 100-900"
707        );
708    }
709
710    #[test]
711    fn dialog_button_order_helper() {
712        let resolved = test_resolved();
713        let _order = dialog_button_order(&resolved);
714        // Just verify it doesn't panic
715    }
716
717    #[test]
718    fn line_height_helper() {
719        let resolved = test_resolved();
720        assert!(
721            line_height_multiplier(&resolved) > 0.0,
722            "line height should be positive"
723        );
724    }
725
726    #[test]
727    fn geometry_helpers() {
728        let resolved = test_resolved();
729        assert!(dialog_content_padding(&resolved) >= 0.0);
730        assert!(dialog_button_spacing(&resolved) >= 0.0);
731        assert!(scrollbar_width(&resolved) > 0.0);
732    }
733
734    #[test]
735    fn selection_and_disabled_helpers() {
736        let resolved = test_resolved();
737        let _ = selection_foreground(&resolved);
738        let _ = selection_inactive(&resolved);
739        let _ = disabled_foreground(&resolved);
740    }
741
742    #[test]
743    fn focus_ring_helpers() {
744        let resolved = test_resolved();
745        assert!(focus_ring_width(&resolved) >= 0.0);
746        assert!(focus_ring_offset(&resolved) >= 0.0);
747    }
748
749    // -- Issue 26: integration tests for all 16 presets in both modes --
750
751    #[test]
752    fn all_presets_dark_mode_no_panic() {
753        let presets = ThemeSpec::list_presets();
754        for name in presets {
755            let result = from_preset(name, true);
756            assert!(
757                result.is_ok(),
758                "from_preset({name}, true) failed: {:?}",
759                result.err()
760            );
761        }
762    }
763
764    #[test]
765    fn all_presets_light_mode_no_panic() {
766        let presets = ThemeSpec::list_presets();
767        for name in presets {
768            let result = from_preset(name, false);
769            assert!(
770                result.is_ok(),
771                "from_preset({name}, false) failed: {:?}",
772                result.err()
773            );
774        }
775    }
776}