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
97#[must_use = "this returns the theme; it does not apply it"]
98pub fn to_theme(resolved: &ResolvedThemeVariant, name: &str, is_dark: bool) -> Theme {
99    let theme_color = colors::to_theme_color(resolved, is_dark);
100    let mode = if is_dark {
101        ThemeMode::Dark
102    } else {
103        ThemeMode::Light
104    };
105    let theme_config = config::to_theme_config(resolved, name, mode);
106
107    // gpui-component's `apply_config` sets non-color fields we need: font_family,
108    // font_size, radius, shadow, mode, light_theme/dark_theme Rc, and highlight_theme.
109    // However, `ThemeColor::apply_config` (called internally) overwrites ALL color
110    // fields with defaults, since our ThemeConfig has no explicit color overrides.
111    // We restore our carefully-mapped colors after. This is a known gpui-component
112    // API limitation -- there is no way to apply only non-color config fields.
113    let mut theme = Theme::from(&theme_color);
114    theme.apply_config(&theme_config.into());
115    theme.colors = theme_color;
116    theme
117}
118
119/// Load a bundled preset and convert it to a gpui-component [`Theme`] in one call.
120///
121/// This is the primary entry point for most users. It handles the full pipeline:
122/// load preset, pick variant, resolve, validate, and convert to gpui Theme.
123///
124/// The preset name is used as the theme display name.
125///
126/// # Errors
127///
128/// Returns an error if the preset name is not recognized or if resolution fails.
129///
130/// # Examples
131///
132/// ```ignore
133/// let dark_theme = native_theme_gpui::from_preset("dracula", true)?;
134/// let light_theme = native_theme_gpui::from_preset("catppuccin-latte", false)?;
135/// ```
136#[must_use = "this returns the theme; it does not apply it"]
137pub fn from_preset(name: &str, is_dark: bool) -> native_theme::Result<Theme> {
138    let spec = ThemeSpec::preset(name)?;
139    let variant = spec
140        .pick_variant(is_dark)
141        .ok_or_else(|| native_theme::Error::Format(format!("preset '{name}' has no variants")))?;
142    let resolved = variant.clone().into_resolved()?;
143    Ok(to_theme(&resolved, name, is_dark))
144}
145
146/// Detect the OS theme and convert it to a gpui-component [`Theme`] in one call.
147///
148/// Combines [`SystemTheme::from_system()`](native_theme::SystemTheme::from_system)
149/// with [`to_theme()`] using the system-detected name and dark-mode preference.
150///
151/// # Errors
152///
153/// Returns an error if the platform theme cannot be read (e.g., unsupported platform,
154/// missing desktop environment).
155///
156/// # Examples
157///
158/// ```ignore
159/// let theme = native_theme_gpui::from_system()?;
160/// ```
161#[must_use = "this returns the theme; it does not apply it"]
162pub fn from_system() -> native_theme::Result<Theme> {
163    let sys = SystemTheme::from_system()?;
164    Ok(to_theme(sys.active(), &sys.name, sys.is_dark))
165}
166
167/// Extension trait for converting a [`SystemTheme`] to a gpui-component [`Theme`].
168///
169/// Useful when you already have a `SystemTheme` and want method syntax:
170///
171/// ```ignore
172/// use native_theme_gpui::SystemThemeExt;
173///
174/// let sys = native_theme::SystemTheme::from_system()?;
175/// let theme = sys.to_gpui_theme();
176/// ```
177pub trait SystemThemeExt {
178    /// Convert this system theme to a gpui-component [`Theme`].
179    ///
180    /// Uses the active variant (based on `is_dark`), the theme name,
181    /// and the dark-mode flag from the `SystemTheme`.
182    #[must_use = "this returns the theme; it does not apply it"]
183    fn to_gpui_theme(&self) -> Theme;
184}
185
186impl SystemThemeExt for SystemTheme {
187    fn to_gpui_theme(&self) -> Theme {
188        to_theme(self.active(), &self.name, self.is_dark)
189    }
190}
191
192#[cfg(test)]
193#[allow(deprecated)]
194#[allow(clippy::unwrap_used, clippy::expect_used)]
195mod tests {
196    use super::*;
197
198    fn test_resolved() -> ResolvedThemeVariant {
199        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
200        let mut v = nt
201            .pick_variant(false)
202            .expect("preset must have light variant")
203            .clone();
204        v.resolve();
205        v.validate().expect("resolved preset must validate")
206    }
207
208    #[test]
209    fn pick_variant_light_first() {
210        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
211        let picked = pick_variant(&nt, false);
212        assert!(picked.is_some());
213    }
214
215    #[test]
216    fn pick_variant_dark_first() {
217        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
218        let picked = pick_variant(&nt, true);
219        assert!(picked.is_some());
220    }
221
222    #[test]
223    fn pick_variant_empty_returns_none() {
224        let theme = ThemeSpec::new("Empty");
225        assert!(pick_variant(&theme, false).is_none());
226        assert!(pick_variant(&theme, true).is_none());
227    }
228
229    #[test]
230    fn to_theme_produces_valid_theme() {
231        let resolved = test_resolved();
232        let theme = to_theme(&resolved, "Test", false);
233
234        // Theme should have the correct mode
235        assert!(!theme.is_dark());
236    }
237
238    #[test]
239    fn to_theme_dark_mode() {
240        let nt = ThemeSpec::preset("catppuccin-mocha").expect("preset must exist");
241        let mut v = nt
242            .pick_variant(true)
243            .expect("preset must have dark variant")
244            .clone();
245        v.resolve();
246        let resolved = v.validate().expect("resolved preset must validate");
247        let theme = to_theme(&resolved, "DarkTest", true);
248
249        assert!(theme.is_dark());
250    }
251
252    // -- from_preset tests --
253
254    #[test]
255    fn from_preset_valid_light() {
256        let theme = from_preset("catppuccin-mocha", false).expect("preset should load");
257        assert!(!theme.is_dark());
258    }
259
260    #[test]
261    fn from_preset_valid_dark() {
262        let theme = from_preset("catppuccin-mocha", true).expect("preset should load");
263        assert!(theme.is_dark());
264    }
265
266    #[test]
267    fn from_preset_invalid_name() {
268        let result = from_preset("nonexistent-preset", false);
269        assert!(result.is_err(), "invalid preset should return Err");
270    }
271
272    // -- SystemThemeExt + from_system tests --
273    // SystemTheme has pub(crate) fields, so it can only be obtained via
274    // SystemTheme::from_system(). These tests verify the trait and function
275    // when a system theme is available, and gracefully skip when not.
276
277    #[test]
278    fn system_theme_ext_to_gpui_theme() {
279        // from_system() may fail on CI (no desktop env) — skip gracefully
280        let Ok(sys) = SystemTheme::from_system() else {
281            return;
282        };
283        let theme = sys.to_gpui_theme();
284        assert_eq!(
285            theme.is_dark(),
286            sys.is_dark,
287            "to_gpui_theme() is_dark should match SystemTheme.is_dark"
288        );
289    }
290
291    #[test]
292    fn from_system_does_not_panic() {
293        // Just verify no panic — result may be Err on CI
294        let _ = from_system();
295    }
296
297    #[test]
298    fn from_system_matches_manual_path() {
299        let Ok(sys) = SystemTheme::from_system() else {
300            return;
301        };
302        let via_convenience = sys.to_gpui_theme();
303        let via_manual = to_theme(sys.active(), &sys.name, sys.is_dark);
304        // Both paths should produce identical results
305        assert_eq!(
306            via_convenience.is_dark(),
307            via_manual.is_dark(),
308            "convenience and manual paths should agree on is_dark"
309        );
310    }
311}