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}