1mod color;
5mod config;
6pub mod fonts;
7mod motion;
8mod scope;
9mod tokens;
10
11pub use color::{contrast_ratio, hex, lightness, mix, to_hex, try_hex};
12pub use config::{SemanticColors, ThemeConfig, ThemeFonts};
13pub use motion::MotionTokens;
14pub use scope::ThemeScope;
15pub use tokens::{MetricTokens, ThemeTokens};
16
17use gpui_kit::{
18 App, BorrowAppContext as _, Global, Pixels, SharedString, Window, WindowAppearance,
19 base::{self, ThemeAppearance},
20 px,
21};
22
23use tokens::Metrics;
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ThemeMode {
28 #[default]
30 System,
31 Light,
33 Dark,
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum ReduceMotion {
40 #[default]
42 System,
43 On,
45 Off,
47}
48
49#[non_exhaustive]
59pub struct Theme {
60 pub mode: ThemeMode,
62 pub light: ThemeConfig,
64 pub dark: ThemeConfig,
66 pub radius: Pixels,
68 pub ui_font_size: Pixels,
71 pub code_font_size: Pixels,
73 pub reduce_motion: ReduceMotion,
75 pub motion: MotionTokens,
77 pub pointer_cursors: bool,
80 pub shadow: bool,
82 pub touch: bool,
87
88 system_appearance: ThemeAppearance,
89 tokens: ThemeTokens,
90 scopes: Vec<Scope>,
93 active_scope: std::cell::Cell<Option<usize>>,
97 reduce_motion_before_override: Option<bool>,
100}
101
102impl Global for Theme {}
103
104struct Scope {
106 key: SharedString,
107 config: ThemeConfig,
108 tokens: ThemeTokens,
109}
110
111impl Theme {
112 pub(crate) fn init(cx: &mut App) {
114 let mut theme = Self::new(appearance_of(cx.window_appearance()));
115 theme.resolve(cx);
116 cx.set_global(theme);
117 }
118
119 fn new(system_appearance: ThemeAppearance) -> Self {
120 let light = ThemeConfig::light();
121 let metrics = Metrics {
122 radius: px(10.),
123 ui_font_size: px(16.),
124 code_font_size: px(13.),
125 touch: base::is_mobile(),
126 };
127 Self {
128 mode: ThemeMode::System,
129 tokens: ThemeTokens::derive(&light, ThemeAppearance::Light, metrics),
130 light,
131 dark: ThemeConfig::dark(),
132 radius: metrics.radius,
133 ui_font_size: metrics.ui_font_size,
134 code_font_size: metrics.code_font_size,
135 reduce_motion: ReduceMotion::System,
136 motion: MotionTokens::default(),
137 pointer_cursors: false,
138 shadow: true,
139 touch: metrics.touch,
140 system_appearance,
141 reduce_motion_before_override: None,
142 scopes: Vec::new(),
143 active_scope: std::cell::Cell::new(None),
144 }
145 }
146
147 pub fn global(cx: &App) -> &Self {
149 cx.global::<Self>()
150 }
151
152 pub fn holds_still(cx: &App) -> bool {
155 cx.reduce_motion() || Self::global(cx).motion.slow.is_zero()
156 }
157
158 pub fn tokens(&self) -> &ThemeTokens {
160 &self.tokens
161 }
162
163 pub fn active_tokens(&self) -> &ThemeTokens {
166 self.active_scope
167 .get()
168 .and_then(|index| self.scopes.get(index))
169 .map_or(&self.tokens, |scope| &scope.tokens)
170 }
171
172 pub fn set_scope(cx: &mut App, key: impl Into<SharedString>, config: ThemeConfig) {
179 let key = key.into();
180 Self::update(cx, |theme| {
181 if let Some(scope) = theme.scopes.iter_mut().find(|scope| scope.key == key) {
182 scope.config = config;
183 } else {
184 theme.scopes.push(Scope {
185 key,
186 tokens: theme.tokens.clone(),
187 config,
188 });
189 }
190 });
191 }
192
193 pub fn scope(&self, key: &str) -> Option<&ThemeTokens> {
195 self.scopes
196 .iter()
197 .find(|scope| scope.key.as_ref() == key)
198 .map(|scope| &scope.tokens)
199 }
200
201 fn scope_index(&self, key: &str) -> Option<usize> {
203 self.scopes
204 .iter()
205 .position(|scope| scope.key.as_ref() == key)
206 }
207
208 pub fn appearance(&self) -> ThemeAppearance {
210 match self.mode {
211 ThemeMode::System => self.system_appearance,
212 ThemeMode::Light => ThemeAppearance::Light,
213 ThemeMode::Dark => ThemeAppearance::Dark,
214 }
215 }
216
217 pub fn is_dark(&self) -> bool {
219 self.appearance() == ThemeAppearance::Dark
220 }
221
222 pub fn active_config(&self) -> &ThemeConfig {
224 if self.is_dark() {
225 &self.dark
226 } else {
227 &self.light
228 }
229 }
230
231 pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
234 let result = cx.update_global::<Self, _>(|theme, cx| {
235 let result = edit(theme);
236 theme.resolve(cx);
237 result
238 });
239 cx.refresh_windows();
240 result
241 }
242
243 pub fn change(mode: ThemeMode, cx: &mut App) {
245 Self::update(cx, |theme| theme.mode = mode);
246 }
247
248 pub fn sync_system_appearance(window: Option<&Window>, cx: &mut App) {
251 let appearance = window
252 .map(|window| window.appearance())
253 .unwrap_or_else(|| cx.window_appearance());
254 let appearance = appearance_of(appearance);
255 if Self::global(cx).system_appearance == appearance {
256 return;
257 }
258 Self::update(cx, |theme| theme.system_appearance = appearance);
259 }
260
261 pub fn set_ui_font_size(cx: &mut App, size: Pixels) {
263 Self::update(cx, |theme| {
264 let ratio = f32::from(theme.code_font_size) / f32::from(theme.ui_font_size);
265 theme.ui_font_size = size;
266 theme.code_font_size = size * ratio;
267 });
268 }
269
270 fn resolve(&mut self, cx: &mut App) {
273 let metrics = Metrics {
274 radius: self.radius,
275 ui_font_size: self.ui_font_size,
276 code_font_size: self.code_font_size,
277 touch: self.touch,
278 };
279 self.tokens = ThemeTokens::derive(self.active_config(), self.appearance(), metrics);
280 for scope in &mut self.scopes {
281 let appearance = if lightness(scope.config.surface) < 0.5 {
282 ThemeAppearance::Dark
283 } else {
284 ThemeAppearance::Light
285 };
286 scope.tokens = ThemeTokens::derive(&scope.config, appearance, metrics);
287 }
288 self.sync_base(cx);
289 self.apply_reduce_motion(cx);
290 }
291
292 fn sync_base(&self, cx: &mut App) {
300 let metrics = &self.tokens.metrics;
301 let radius = self.tokens.radius_full();
302 let thumb = |width: Pixels| {
303 move |style: base::ScrollbarThumbStyle| {
304 style
305 .width(width)
306 .inset(metrics.scrollbar_inset)
307 .radius(radius)
308 }
309 };
310 let scrollbar = base::ScrollbarTheme::new()
311 .with_mode(base::ScrollbarMode::Scrolling)
312 .with_styles(
313 base::ScrollbarStyles::default()
314 .thumb(thumb(metrics.scrollbar_thumb))
315 .thumb_hover(thumb(metrics.scrollbar_thumb_active))
316 .thumb_active(thumb(metrics.scrollbar_thumb_active)),
317 );
318 let base_theme = base::Theme::global_mut(cx);
319 base_theme.appearance = self.tokens.appearance;
320 base_theme.tokens = self.tokens.base.clone();
321 base_theme.scrollbar = scrollbar;
322 base_theme.resizable = base::ResizableTheme {
323 handle: Some(self.tokens.border()),
324 active_handle: Some(self.tokens.ring()),
325 };
326 }
327
328 fn apply_reduce_motion(&mut self, cx: &mut App) {
329 match self.reduce_motion {
330 ReduceMotion::System => {
331 if let Some(previous) = self.reduce_motion_before_override.take() {
332 cx.set_reduce_motion(previous);
333 base::apply_system_reduce_motion(cx);
334 }
335 }
336 ReduceMotion::On | ReduceMotion::Off => {
337 if self.reduce_motion_before_override.is_none() {
338 self.reduce_motion_before_override = Some(cx.reduce_motion());
339 }
340 cx.set_reduce_motion(self.reduce_motion == ReduceMotion::On);
341 }
342 }
343 }
344}
345
346#[cfg(test)]
348pub(crate) fn test_tokens(config: &ThemeConfig, appearance: ThemeAppearance) -> ThemeTokens {
349 ThemeTokens::derive(
350 config,
351 appearance,
352 Metrics {
353 radius: px(10.),
354 ui_font_size: px(16.),
355 code_font_size: px(13.),
356 touch: false,
357 },
358 )
359}
360
361fn appearance_of(appearance: WindowAppearance) -> ThemeAppearance {
362 match appearance {
363 WindowAppearance::Dark | WindowAppearance::VibrantDark => ThemeAppearance::Dark,
364 WindowAppearance::Light | WindowAppearance::VibrantLight => ThemeAppearance::Light,
365 }
366}
367
368pub trait ActiveTheme {
370 fn theme(&self) -> &ThemeTokens;
372}
373
374impl ActiveTheme for App {
375 #[inline(always)]
376 fn theme(&self) -> &ThemeTokens {
377 Theme::global(self).active_tokens()
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use gpui_kit::TestAppContext;
385
386 #[gpui_kit::test]
387 fn init_owns_base_and_projects_tokens(cx: &mut TestAppContext) {
388 cx.update(|cx| {
389 gpui_kit::init(cx);
390 crate::init(cx);
391 let theme = Theme::global(cx);
392 assert_eq!(theme.mode, ThemeMode::System);
393 let base_theme = base::Theme::global(cx);
394 assert_eq!(base_theme.tokens.colors, theme.tokens().base.colors);
395 assert_eq!(base_theme.appearance, theme.appearance());
396 assert_eq!(base_theme.tokens.radius.lg, px(10.));
397 });
398 }
399
400 #[gpui_kit::test]
401 fn change_switches_tokens_and_base(cx: &mut TestAppContext) {
402 cx.update(|cx| {
403 gpui_kit::init(cx);
404 crate::init(cx);
405 Theme::change(ThemeMode::Dark, cx);
406 assert!(cx.theme().is_dark());
407 assert_eq!(cx.theme().background(), hex("#181818"));
408 assert_eq!(base::Theme::global(cx).appearance, ThemeAppearance::Dark);
409 Theme::change(ThemeMode::Light, cx);
410 assert_eq!(cx.theme().background(), hex("#ffffff"));
411 assert_eq!(base::Theme::global(cx).appearance, ThemeAppearance::Light);
412 });
413 }
414
415 #[gpui_kit::test]
416 fn update_re_derives_from_edited_inputs(cx: &mut TestAppContext) {
417 cx.update(|cx| {
418 gpui_kit::init(cx);
419 crate::init(cx);
420 Theme::update(cx, |theme| {
421 theme.mode = ThemeMode::Light;
422 theme.light.accent = hex("#ff0000");
423 theme.radius = px(5.);
424 });
425 assert_eq!(cx.theme().ring(), hex("#ff0000"));
426 assert_eq!(cx.theme().radius_md(), px(4.));
427 assert_eq!(base::Theme::global(cx).tokens.colors.ring, hex("#ff0000"));
428 });
429 }
430
431 #[gpui_kit::test]
432 fn font_size_scales_typography_together(cx: &mut TestAppContext) {
433 cx.update(|cx| {
434 gpui_kit::init(cx);
435 crate::init(cx);
436 Theme::set_ui_font_size(cx, px(20.));
437 let theme = Theme::global(cx);
438 assert_eq!(theme.ui_font_size, px(20.));
439 assert_eq!(theme.code_font_size, px(16.25));
440 assert_eq!(cx.theme().base.typography.md.size, px(20.));
441 assert_eq!(base::Theme::global(cx).tokens.typography.md.size, px(20.));
442 });
443 }
444
445 #[gpui_kit::test]
446 fn reduce_motion_preference_overrides_and_hands_back(cx: &mut TestAppContext) {
447 cx.update(|cx| {
448 gpui_kit::init(cx);
449 crate::init(cx);
450 assert!(!cx.reduce_motion());
451 Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::On);
452 assert!(cx.reduce_motion());
453 Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::Off);
454 assert!(!cx.reduce_motion());
455 Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::On);
456 assert!(cx.reduce_motion());
457 Theme::update(cx, |theme| theme.reduce_motion = ReduceMotion::System);
458 assert!(!cx.reduce_motion());
461 });
462 }
463}