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