1use std::rc::Rc;
2
3use gpui_kit::{
4 App, Axis, BorderStyle, Bounds, ContentMask, Corners, ElementId, Hsla, InteractiveElement as _,
5 IntoElement, ParentElement, Pixels, RenderOnce, Size, StyleRefinement, Styled, Window,
6 base::{self, Disableable, Interpolate, StyledExt as _, TestSupportExt as _, transition},
7 canvas, div, fill, point,
8 prelude::FluentBuilder as _,
9 px, quad, size,
10};
11
12use crate::{
13 ActiveTheme as _, Theme, ThemeConfig, ThemeMode,
14 theme::{lightness, mix},
15};
16
17type ChangeHandler = Rc<dyn Fn(&ThemeMode, &mut Window, &mut App)>;
18
19const MODES: [ThemeMode; 3] = [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark];
20
21#[derive(IntoElement)]
42pub struct ThemeModePicker {
43 id: ElementId,
44 style: StyleRefinement,
45 value: ThemeMode,
46 disabled: bool,
47 on_change: Option<ChangeHandler>,
48}
49
50impl ThemeModePicker {
51 pub fn new(id: impl Into<ElementId>) -> Self {
53 Self {
54 id: id.into(),
55 style: StyleRefinement::default(),
56 value: ThemeMode::System,
57 disabled: false,
58 on_change: None,
59 }
60 }
61
62 pub fn value(mut self, value: ThemeMode) -> Self {
64 self.value = value;
65 self
66 }
67
68 pub fn on_change(
72 mut self,
73 handler: impl Fn(&ThemeMode, &mut Window, &mut App) + 'static,
74 ) -> Self {
75 self.on_change = Some(Rc::new(handler));
76 self
77 }
78
79 pub fn option_id(id: &ElementId, mode: ThemeMode) -> ElementId {
82 ElementId::NamedChild(id.clone().into(), option_name(mode).into())
83 }
84}
85
86impl Disableable for ThemeModePicker {
87 fn disabled(mut self, disabled: bool) -> Self {
88 self.disabled = disabled;
89 self
90 }
91}
92
93impl Styled for ThemeModePicker {
94 fn style(&mut self) -> &mut StyleRefinement {
95 &mut self.style
96 }
97}
98
99fn option_label(mode: ThemeMode) -> &'static str {
100 match mode {
101 ThemeMode::System => "System",
102 ThemeMode::Light => "Light",
103 ThemeMode::Dark => "Dark",
104 }
105}
106
107fn option_name(mode: ThemeMode) -> &'static str {
108 match mode {
109 ThemeMode::System => "system",
110 ThemeMode::Light => "light",
111 ThemeMode::Dark => "dark",
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq)]
124struct Palette {
125 desktop: Hsla,
126 window: Hsla,
127 rim: Hsla,
128 heading: Hsla,
129 subheading: Hsla,
130 card: Hsla,
131 row_bar: Hsla,
132 row_line: Hsla,
133 separator: Hsla,
134}
135
136impl Palette {
137 fn derive(config: &ThemeConfig, far: bool) -> Self {
138 let step = |amount: f32| mix(config.surface, config.ink, amount);
139 let dark = lightness(config.ink) > lightness(config.surface);
140 let lighter = if dark { config.ink } else { config.surface };
141 let rim = |edge: Hsla| mix(edge, lighter, 0.09);
142 match (dark, far) {
143 (false, far) => {
148 let desktop = step(0.385);
149 let window = step(0.046);
150 Self {
151 desktop,
152 window,
153 rim: rim(if far { desktop } else { window }),
154 heading: step(0.195),
155 subheading: step(0.125),
156 card: config.surface,
157 row_bar: step(0.125),
158 row_line: if far { window } else { step(0.034) },
159 separator: step(0.034),
160 }
161 }
162 (true, false) => {
166 let window = step(0.339);
167 Self {
168 desktop: window,
169 window,
170 rim: rim(window),
171 heading: step(0.622),
172 subheading: step(0.556),
173 card: config.ink,
174 row_bar: step(0.877),
175 row_line: step(0.965),
176 separator: step(0.965),
177 }
178 }
179 (true, true) => {
183 let desktop = step(0.339);
184 let bar = step(0.449);
185 Self {
186 desktop,
187 window: step(0.169),
188 rim: rim(desktop),
189 heading: bar,
190 subheading: step(0.556),
191 card: step(0.275),
192 row_bar: bar,
193 row_line: bar,
194 separator: bar,
195 }
196 }
197 }
198 }
199}
200
201const CARD: Size<f32> = Size {
205 width: 248.,
206 height: 175.,
207};
208
209const CARD_RADIUS: f32 = 10.;
211
212const RIM_WIDTH: f32 = 1.;
214const RING_WIDTH: f32 = 2.;
215
216#[derive(Clone, Copy, Debug, PartialEq)]
218struct Shape {
219 x: f32,
220 y: f32,
221 w: f32,
222 h: Height,
223 radius: f32,
224 color: Hsla,
225}
226
227#[derive(Clone, Copy, Debug, PartialEq)]
228enum Height {
229 Fixed(f32),
230 ToBottom,
233}
234
235fn pill(x: f32, y: f32, w: f32, h: f32, color: Hsla) -> Shape {
236 Shape {
237 x,
238 y,
239 w,
240 h: Height::Fixed(h),
241 radius: h / 2.,
242 color,
243 }
244}
245
246fn rect(x: f32, y: f32, w: f32, h: f32, color: Hsla) -> Shape {
247 Shape {
248 x,
249 y,
250 w,
251 h: Height::Fixed(h),
252 radius: 0.,
253 color,
254 }
255}
256
257fn panel(x: f32, y: f32, w: f32, radius: f32, color: Hsla) -> Shape {
258 Shape {
259 x,
260 y,
261 w,
262 h: Height::ToBottom,
263 radius,
264 color,
265 }
266}
267
268fn near(p: &Palette) -> Vec<Shape> {
271 let mut shapes = vec![
272 Shape {
273 x: 0.,
274 y: 0.,
275 w: CARD.width,
276 h: Height::Fixed(CARD.height),
277 radius: CARD_RADIUS,
278 color: p.window,
279 },
280 pill(68., 38.5, 112., 8.5, p.heading),
281 pill(39., 51.5, 170., 5.5, p.subheading),
282 panel(23., 64.5, 202., 10., p.card),
283 ];
284 for row in 0..3 {
285 let y = 34.5 * row as f32;
286 shapes.push(pill(33.5, 82. + y, 64., 8., p.row_bar));
287 shapes.push(pill(33., 97.5 + y, 94., 2.5, p.row_line));
288 shapes.push(rect(23., 110.5 + y, 202., 1.5, p.separator));
289 }
290 shapes
291}
292
293fn far(p: &Palette, mirrored: bool) -> Vec<Shape> {
298 let mut shapes = vec![
299 Shape {
300 x: 0.,
301 y: 0.,
302 w: CARD.width,
303 h: Height::Fixed(CARD.height),
304 radius: CARD_RADIUS,
305 color: p.desktop,
306 },
307 panel(13., 50.5, 222., 12., p.window),
308 pill(103., 86.5, 37.5, 8., p.heading),
309 pill(78.5, 99., 91., 4., p.subheading),
310 panel(40., 112., 168., 8., p.card),
311 ];
312 for row in 0..3 {
313 let y = 37. * row as f32;
314 let bar = if mirrored { 150. } else { 48.5 };
315 shapes.push(pill(bar, 123.5 + y, 49.5, 8., p.row_bar));
316 shapes.push(pill(48.5, 139. + y, 151., 2.5, p.row_line));
317 shapes.push(rect(40., 151.5 + y, 168., 1.5, p.separator));
318 }
319 shapes
320}
321
322enum Picture {
323 Whole(Palette),
324 Split { light: Palette, dark: Palette },
325}
326
327fn paint_shapes(
328 shapes: &[Shape],
329 bounds: Bounds<Pixels>,
330 mask: Bounds<Pixels>,
331 scale: f32,
332 window: &mut Window,
333) {
334 window.with_content_mask(Some(ContentMask { bounds: mask }), |window| {
335 for shape in shapes {
336 let origin = bounds.origin + point(px(shape.x * scale), px(shape.y * scale));
337 let (height, corners) = match shape.h {
338 Height::Fixed(h) => (px(h * scale), Corners::all(px(shape.radius * scale))),
339 Height::ToBottom => (
340 bounds.bottom() - origin.y,
341 Corners {
342 top_left: px(shape.radius * scale),
343 top_right: px(shape.radius * scale),
344 bottom_right: px(0.),
345 bottom_left: px(0.),
346 },
347 ),
348 };
349 if height <= px(0.) {
350 continue;
351 }
352 let quad = Bounds::new(origin, size(px(shape.w * scale), height));
353 window.paint_quad(fill(quad, shape.color).corner_radii(corners));
354 }
355 });
356}
357
358fn paint_edge(bounds: Bounds<Pixels>, width: f32, color: Hsla, scale: f32, window: &mut Window) {
359 window.paint_quad(quad(
360 bounds,
361 px(CARD_RADIUS * scale),
362 gpui_kit::transparent_black(),
363 px(width * scale),
364 color,
365 BorderStyle::Solid,
366 ));
367}
368
369fn paint_card(picture: &Picture, ring: Hsla, bounds: Bounds<Pixels>, window: &mut Window) {
370 let scale = f32::from(bounds.size.width) / CARD.width;
371 match picture {
372 Picture::Whole(palette) => {
373 paint_shapes(&near(palette), bounds, bounds, scale, window);
374 paint_edge(bounds, RIM_WIDTH, palette.rim, scale, window);
375 }
376 Picture::Split { light, dark } => {
377 let half = bounds.size.width / 2.;
378 let left = Bounds::new(bounds.origin, size(half, bounds.size.height));
379 let right = Bounds::new(
380 bounds.origin + point(half, px(0.)),
381 size(bounds.size.width - half, bounds.size.height),
382 );
383 paint_shapes(&far(light, false), bounds, left, scale, window);
384 window.with_content_mask(Some(ContentMask { bounds: left }), |window| {
385 paint_edge(bounds, RIM_WIDTH, light.rim, scale, window);
386 });
387 paint_shapes(&far(dark, true), bounds, right, scale, window);
388 window.with_content_mask(Some(ContentMask { bounds: right }), |window| {
389 paint_edge(bounds, RIM_WIDTH, dark.rim, scale, window);
390 });
391 }
392 }
393 if ring.a > 0. {
394 paint_edge(bounds, RING_WIDTH, ring, scale, window);
395 }
396}
397
398impl RenderOnce for ThemeModePicker {
399 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
400 let settings = Theme::global(cx);
401 let fast = settings.motion.fast_transition();
402 let pointer_cursors = settings.pointer_cursors;
403 let light = &settings.light;
404 let dark = &settings.dark;
405 let pictures = [
406 Picture::Split {
407 light: Palette::derive(light, true),
408 dark: Palette::derive(dark, true),
409 },
410 Picture::Whole(Palette::derive(light, false)),
411 Picture::Whole(Palette::derive(dark, false)),
412 ];
413 let theme = cx.theme();
414 let look = Look {
415 ring: theme.foreground(),
418 label: theme.foreground(),
419 muted_label: theme.muted_foreground(),
420 focus_ring: theme.focus_ring(),
421 focus_ring_spread: theme.metrics.focus_ring,
422 focus_radius: theme.radius_lg(),
423 disabled_opacity: theme.disabled_opacity,
424 text_size: theme.text_control.size,
425 line_height: theme.text_control.line_height,
426 };
427 let disabled = self.disabled;
428 let value = self.value;
429 let on_change = self.on_change;
430 let id = self.id;
431
432 div()
435 .id(id.clone())
436 .test_support()
437 .w_full()
438 .refine_style(&self.style)
439 .child(
440 base::RadioGroup::new(ElementId::NamedChild(id.clone().into(), "group".into()))
441 .axis(Axis::Horizontal)
442 .flex()
443 .items_start()
444 .gap_3()
445 .w_full()
446 .children(MODES.into_iter().zip(pictures).enumerate().map(
447 |(ix, (mode, picture))| {
448 let option_id = ThemeModePicker::option_id(&id, mode);
449 let chosen = mode == value;
450 let weight = transition(
453 ElementId::NamedChild(option_id.clone().into(), "chosen".into()),
454 if chosen { 1. } else { 0. },
455 fast.clone(),
456 window,
457 cx,
458 );
459 let ring = look.ring.alpha(look.ring.a * weight);
460 let label = look.muted_label.interpolate(&look.label, weight);
461 let focus_handle = window
462 .use_keyed_state(option_id.clone(), cx, |_, cx| cx.focus_handle())
463 .read(cx)
464 .clone();
465 let focus_visible =
466 focus_handle.is_focused(window) && window.last_input_was_keyboard();
467 let on_change = on_change.clone();
468 base::Radio::new(option_id)
469 .track_focus(&focus_handle)
470 .checked(chosen)
471 .disabled(disabled)
472 .accessibility_label(option_label(mode))
473 .set_position(ix + 1, MODES.len())
474 .flex()
475 .flex_col()
476 .flex_1()
477 .min_w_0()
478 .items_center()
479 .gap_2p5()
480 .map(|this| {
481 if !disabled && pointer_cursors {
482 this.cursor_pointer()
483 } else {
484 this.cursor_default()
485 }
486 })
487 .when(disabled, |this| this.opacity(look.disabled_opacity))
488 .child(
489 div()
490 .relative()
491 .w_full()
492 .aspect_ratio(CARD.width / CARD.height)
493 .rounded(look.focus_radius)
494 .when(focus_visible, |this| {
495 this.shadow(vec![gpui_kit::BoxShadow {
496 color: look.focus_ring,
497 offset: point(px(0.), px(0.)),
498 blur_radius: px(0.),
499 spread_radius: look.focus_ring_spread,
500 inset: false,
501 }])
502 })
503 .child(
504 canvas(
505 |_, _, _| {},
506 move |bounds, _, window, _| {
507 paint_card(&picture, ring, bounds, window)
508 },
509 )
510 .absolute()
511 .inset_0(),
512 ),
513 )
514 .child(
515 div()
516 .text_size(look.text_size)
517 .line_height(look.line_height)
518 .text_color(label)
519 .child(option_label(mode)),
520 )
521 .when_some(on_change, |this, on_change| {
522 this.on_change(move |_, _, window, cx| {
523 on_change(&mode, window, cx)
524 })
525 })
526 },
527 )),
528 )
529 }
530}
531
532struct Look {
533 ring: Hsla,
534 label: Hsla,
535 muted_label: Hsla,
536 focus_ring: Hsla,
537 focus_ring_spread: Pixels,
538 focus_radius: Pixels,
539 disabled_opacity: f32,
540 text_size: Pixels,
541 line_height: Pixels,
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::theme::to_hex;
548
549 #[test]
550 fn light_picture_reproduces_the_reference() {
551 let palette = Palette::derive(&ThemeConfig::light(), false);
552 assert_eq!(to_hex(palette.window), "#f3f3f3");
553 assert_eq!(to_hex(palette.rim), "#f4f4f4");
554 assert_eq!(to_hex(palette.heading), "#cdcdce");
555 assert_eq!(to_hex(palette.subheading), "#dfdfdf");
556 assert_eq!(to_hex(palette.card), "#ffffff");
557 assert_eq!(to_hex(palette.row_bar), "#dfdfdf");
558 assert_eq!(to_hex(palette.row_line), "#f6f6f6");
559 assert_eq!(to_hex(palette.desktop), "#9e9fa0");
560 let far = Palette::derive(&ThemeConfig::light(), true);
561 assert_eq!(
562 far.window, palette.window,
563 "the light window is the same on the desktop"
564 );
565 assert_eq!(
566 far.rim,
567 mix(far.desktop, ThemeConfig::light().surface, 0.09)
568 );
569 assert_eq!(far.row_line, far.window, "thin bars on the desktop");
570 }
571
572 #[test]
573 fn dark_pictures_reproduce_the_reference() {
574 let near = Palette::derive(&ThemeConfig::dark(), false);
575 assert_eq!(to_hex(near.window), "#5d5d5d");
576 assert_eq!(to_hex(near.rim), "#6a6a6a");
577 assert_eq!(to_hex(near.heading), "#9f9f9f");
578 assert_eq!(to_hex(near.subheading), "#8f8f8f");
579 assert_eq!(to_hex(near.card), "#ffffff");
580 assert_eq!(to_hex(near.row_bar), "#dfdfdf");
581 assert_eq!(to_hex(near.row_line), "#f6f6f6");
582 let far = Palette::derive(&ThemeConfig::dark(), true);
583 assert_eq!(to_hex(far.desktop), "#5d5d5d");
584 assert_eq!(to_hex(far.window), "#393939");
585 assert_eq!(to_hex(far.heading), "#767676");
586 assert_eq!(to_hex(far.subheading), "#8f8f8f");
587 assert_eq!(to_hex(far.card), "#4f4f4f");
588 assert_eq!(to_hex(far.row_bar), "#767676");
589 assert_eq!(far.separator, far.row_bar);
590 assert_eq!(far.row_line, far.row_bar);
591 }
592
593 #[test]
594 fn pictures_fit_the_card_and_run_off_its_bottom() {
595 let light = Palette::derive(&ThemeConfig::light(), false);
596 let plain = far(&light, false);
597 let mirrored = far(&light, true);
598 for shape in near(&light).iter().chain(&plain).chain(&mirrored) {
599 assert!(
600 shape.x >= 0. && shape.x + shape.w <= CARD.width,
601 "{shape:?}"
602 );
603 assert!(shape.y >= 0., "{shape:?}");
604 }
605 assert!(plain.last().unwrap().y > CARD.height, "the clip drops it");
606 assert_eq!(mirrored[1].x, plain[1].x);
607 assert_eq!(mirrored[2].x, plain[2].x);
608 assert_eq!(mirrored[4].x, plain[4].x);
609 assert_eq!(mirrored[6], plain[6]);
610 assert!((mirrored[5].x - (CARD.width - plain[5].x - plain[5].w)).abs() < 1.);
611 assert!(mirrored[5].x > CARD.width / 2.);
612 }
613
614 #[test]
615 fn a_custom_config_is_told_apart_by_its_ink() {
616 let mut config = ThemeConfig::light();
617 config.surface = crate::theme::hex("#101820");
618 config.ink = crate::theme::hex("#f0f0f0");
619 let palette = Palette::derive(&config, false);
620 assert_eq!(
621 palette.card, config.ink,
622 "a dark config gets the dark picture"
623 );
624 assert!(lightness(palette.window) > lightness(config.surface));
625 }
626
627 #[test]
628 fn option_ids_are_named_children_of_the_picker() {
629 let id = ElementId::from("theme-mode");
630 assert_eq!(
631 ThemeModePicker::option_id(&id, ThemeMode::Dark),
632 ElementId::NamedChild(id.into(), "dark".into())
633 );
634 }
635}