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) = 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").unwrap();
30//! let variant = nt.into_variant(false).unwrap();
31//! let resolved = variant.into_resolved().unwrap();
32//! let theme = to_theme(&resolved, "Catppuccin Mocha", false);
33//! ```
34//!
35//! # Theme Field Coverage
36//!
37//! The connector maps a subset of [`ResolvedThemeVariant`] fields to gpui-component's
38//! `ThemeColor` (108 color fields) and `ThemeConfig` (font/geometry).
39//!
40//! | Category | Mapped | Notes |
41//! |----------|--------|-------|
42//! | `defaults` colors | All 20+ | background, foreground, accent, danger, etc. |
43//! | `defaults` geometry | radius, radius_lg, shadow | Font family/size also mapped |
44//! | `button` | 4 of 14 | primary_background/foreground, background/foreground (colors only) |
45//! | `tab` | 5 of 9 | All colors, sizing not mapped |
46//! | `sidebar` | 2 of 2 | background, foreground |
47//! | `window` | 2 of 10 | title_bar_background, border |
48//! | `input` | 2 of 12 | border, caret |
49//! | `scrollbar` | 2 of 7 | thumb, thumb_hover |
50//! | `slider`, `switch` | 2 each | fill/thumb colors |
51//! | `progress_bar` | 1 of 5 | fill |
52//! | `list` | 1 of 11 | alternate_row |
53//! | `popover` | 2 of 4 | background, foreground |
54//! | 14 other widgets | 0 fields | checkbox, menu, tooltip, dialog, etc. |
55//!
56//! **Why the gap:** gpui-component's `ThemeColor` is a flat color bag with no per-widget
57//! geometry. The connector cannot map most sizing/spacing data because the target type
58//! has no corresponding fields. Users who need per-widget geometry can read it directly
59//! from the `ResolvedThemeVariant` they passed to [`to_theme()`].
60
61#![warn(missing_docs)]
62#![forbid(unsafe_code)]
63#![deny(clippy::unwrap_used)]
64#![deny(clippy::expect_used)]
65
66pub(crate) mod colors;
67pub(crate) mod config;
68pub(crate) mod derive;
69pub mod icons;
70
71// Re-export native-theme types that appear in public signatures so downstream
72// crates don't need native-theme as a direct dependency.
73pub use native_theme::{
74    AnimatedIcon, Error, IconData, IconProvider, IconRole, IconSet, ResolvedThemeVariant,
75    SystemTheme, ThemeSpec, ThemeVariant, TransformAnimation,
76};
77
78#[cfg(target_os = "linux")]
79pub use native_theme::LinuxDesktop;
80
81use gpui::{SharedString, px};
82use gpui_component::theme::{Theme, ThemeMode};
83use std::rc::Rc;
84
85/// Convert a [`ResolvedThemeVariant`] into a gpui-component [`Theme`].
86///
87/// Builds a complete Theme by:
88/// 1. Mapping all 108 ThemeColor fields via `colors::to_theme_color`
89/// 2. Setting font, geometry, and mode fields directly on the Theme
90/// 3. Storing a ThemeConfig in light_theme/dark_theme Rc for gpui-component switching
91///
92/// All Theme fields are set explicitly -- no `apply_config` call is used.
93/// This avoids the fragile apply-then-restore pattern where `apply_config`
94/// would overwrite all 108 color fields with defaults.
95#[must_use = "this returns the theme; it does not apply it"]
96pub fn to_theme(resolved: &ResolvedThemeVariant, name: &str, is_dark: bool) -> Theme {
97    let theme_color = colors::to_theme_color(resolved, is_dark);
98    let mode = if is_dark {
99        ThemeMode::Dark
100    } else {
101        ThemeMode::Light
102    };
103    let d = &resolved.defaults;
104
105    let mut theme = Theme::from(&theme_color);
106    theme.mode = mode;
107    theme.font_family = SharedString::from(d.font.family.clone());
108    theme.font_size = px(d.font.size);
109    theme.mono_font_family = SharedString::from(d.mono_font.family.clone());
110    theme.mono_font_size = px(d.mono_font.size);
111    theme.radius = px(d.radius);
112    theme.radius_lg = px(d.radius_lg);
113    theme.shadow = d.shadow_enabled;
114
115    // Store config for gpui-component's theme switching
116    let config: Rc<_> = Rc::new(config::to_theme_config(resolved, name, mode));
117    if mode == ThemeMode::Dark {
118        theme.dark_theme = config;
119    } else {
120        theme.light_theme = config;
121    }
122    theme
123}
124
125/// Load a bundled preset and convert it to a gpui-component [`Theme`] in one call.
126///
127/// This is the primary entry point for most users. It handles the full pipeline:
128/// load preset, pick variant, resolve, validate, and convert to gpui Theme.
129///
130/// Returns both the gpui Theme and the [`ResolvedThemeVariant`] so callers can
131/// access per-widget metrics (button padding, scrollbar width, etc.) that the
132/// flat `ThemeColor` cannot represent.
133///
134/// The preset name is used as the theme display name.
135///
136/// # Errors
137///
138/// Returns an error if the preset name is not recognized or if resolution fails.
139///
140/// # Examples
141///
142/// ```ignore
143/// let (dark_theme, resolved) = native_theme_gpui::from_preset("dracula", true)?;
144/// let (light_theme, _) = native_theme_gpui::from_preset("catppuccin-latte", false)?;
145/// ```
146#[must_use = "this returns the theme; it does not apply it"]
147pub fn from_preset(
148    name: &str,
149    is_dark: bool,
150) -> native_theme::Result<(Theme, ResolvedThemeVariant)> {
151    let spec = ThemeSpec::preset(name)?;
152    let variant = spec.into_variant(is_dark).ok_or_else(|| {
153        native_theme::Error::Format(format!("preset '{name}' has no light or dark variant"))
154    })?;
155    let resolved = variant.into_resolved()?;
156    let theme = to_theme(&resolved, name, is_dark);
157    Ok((theme, resolved))
158}
159
160/// Detect the OS theme and convert it to a gpui-component [`Theme`] in one call.
161///
162/// Combines [`SystemTheme::from_system()`](native_theme::SystemTheme::from_system)
163/// with [`to_theme()`] using the system-detected name and dark-mode preference.
164///
165/// Returns both the gpui Theme and the [`ResolvedThemeVariant`] so callers can
166/// access per-widget metrics that the flat `ThemeColor` cannot represent.
167///
168/// # Errors
169///
170/// Returns an error if the platform theme cannot be read (e.g., unsupported platform,
171/// missing desktop environment).
172///
173/// # Examples
174///
175/// ```ignore
176/// let (theme, resolved) = native_theme_gpui::from_system()?;
177/// ```
178#[must_use = "this returns the theme; it does not apply it"]
179pub fn from_system() -> native_theme::Result<(Theme, ResolvedThemeVariant)> {
180    let sys = SystemTheme::from_system()?;
181    let is_dark = sys.is_dark;
182    let theme = to_theme(sys.active(), &sys.name, sys.is_dark);
183    let resolved = if is_dark { sys.dark } else { sys.light };
184    Ok((theme, resolved))
185}
186
187/// Extension trait for converting a [`SystemTheme`] to a gpui-component [`Theme`].
188///
189/// Useful when you already have a `SystemTheme` and want method syntax:
190///
191/// ```ignore
192/// use native_theme_gpui::SystemThemeExt;
193///
194/// let sys = native_theme::SystemTheme::from_system()?;
195/// let theme = sys.to_gpui_theme();
196/// ```
197pub trait SystemThemeExt {
198    /// Convert this system theme to a gpui-component [`Theme`].
199    ///
200    /// Uses the active variant (based on `is_dark`), the theme name,
201    /// and the dark-mode flag from the `SystemTheme`.
202    #[must_use = "this returns the theme; it does not apply it"]
203    fn to_gpui_theme(&self) -> Theme;
204}
205
206impl SystemThemeExt for SystemTheme {
207    fn to_gpui_theme(&self) -> Theme {
208        to_theme(self.active(), &self.name, self.is_dark)
209    }
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, clippy::expect_used)]
214mod tests {
215    use super::*;
216
217    fn test_resolved() -> ResolvedThemeVariant {
218        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
219        let variant = nt
220            .into_variant(false)
221            .expect("preset must have light variant");
222        variant
223            .into_resolved()
224            .expect("resolved preset must validate")
225    }
226
227    #[test]
228    fn to_theme_produces_valid_theme() {
229        let resolved = test_resolved();
230        let theme = to_theme(&resolved, "Test", false);
231
232        // Theme should have the correct mode
233        assert!(!theme.is_dark());
234    }
235
236    #[test]
237    fn to_theme_dark_mode() {
238        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
239        let variant = nt
240            .into_variant(true)
241            .expect("preset must have dark variant");
242        let resolved = variant
243            .into_resolved()
244            .expect("resolved preset must validate");
245        let theme = to_theme(&resolved, "DarkTest", true);
246
247        assert!(theme.is_dark());
248    }
249
250    #[test]
251    fn to_theme_applies_font_and_geometry() {
252        let resolved = test_resolved();
253        let theme = to_theme(&resolved, "Test", false);
254
255        assert_eq!(theme.font_family.to_string(), resolved.defaults.font.family);
256        assert_eq!(theme.font_size, px(resolved.defaults.font.size));
257        assert_eq!(
258            theme.mono_font_family.to_string(),
259            resolved.defaults.mono_font.family
260        );
261        assert_eq!(theme.mono_font_size, px(resolved.defaults.mono_font.size));
262        assert_eq!(theme.radius, px(resolved.defaults.radius));
263        assert_eq!(theme.radius_lg, px(resolved.defaults.radius_lg));
264        assert_eq!(theme.shadow, resolved.defaults.shadow_enabled);
265    }
266
267    // -- from_preset tests --
268
269    #[test]
270    fn from_preset_valid_light() {
271        let (theme, _resolved) =
272            from_preset("catppuccin-mocha", false).expect("preset should load");
273        assert!(!theme.is_dark());
274    }
275
276    #[test]
277    fn from_preset_valid_dark() {
278        let (theme, _resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
279        assert!(theme.is_dark());
280    }
281
282    #[test]
283    fn from_preset_returns_resolved() {
284        let (_theme, resolved) = from_preset("catppuccin-mocha", true).expect("preset should load");
285        // ResolvedThemeVariant should have populated defaults
286        assert!(resolved.defaults.font.size > 0.0);
287    }
288
289    #[test]
290    fn from_preset_invalid_name() {
291        let result = from_preset("nonexistent-preset", false);
292        assert!(result.is_err(), "invalid preset should return Err");
293    }
294
295    // -- SystemThemeExt + from_system tests --
296
297    #[test]
298    fn system_theme_ext_to_gpui_theme() {
299        // from_system() may fail on CI (no desktop env) -- skip gracefully
300        let Ok(sys) = SystemTheme::from_system() else {
301            return;
302        };
303        let theme = sys.to_gpui_theme();
304        assert_eq!(
305            theme.is_dark(),
306            sys.is_dark,
307            "to_gpui_theme() is_dark should match SystemTheme.is_dark"
308        );
309    }
310
311    #[test]
312    fn from_system_does_not_panic() {
313        // Just verify no panic -- result may be Err on CI
314        let _ = from_system();
315    }
316
317    #[test]
318    fn from_system_returns_tuple() {
319        let Ok((theme, resolved)) = from_system() else {
320            return;
321        };
322        // Theme and resolved should agree on basic properties
323        assert!(resolved.defaults.font.size > 0.0);
324        // Theme mode should be set
325        let _ = theme.is_dark();
326    }
327
328    #[test]
329    fn from_system_matches_manual_path() {
330        let Ok(sys) = SystemTheme::from_system() else {
331            return;
332        };
333        let via_convenience = sys.to_gpui_theme();
334        let via_manual = to_theme(sys.active(), &sys.name, sys.is_dark);
335        // Both paths should produce identical results
336        assert_eq!(
337            via_convenience.is_dark(),
338            via_manual.is_dark(),
339            "convenience and manual paths should agree on is_dark"
340        );
341    }
342}