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}