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}