Skip to main content

gpui_cn/
theme_mode_picker.rs

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/// A choice of [`ThemeMode`] on `gpui_base::RadioGroup`: three cards, each
22/// a small picture of the appearance it stands for, with the chosen one
23/// ringed and its label in the foreground color.
24///
25/// Base owns focus, Tab order, Enter and Space activation, the disabled
26/// contract, and the radio roles. This type owns the look: the pictures,
27/// the ring, and the labels. The pictures come from the theme's light and
28/// dark configs, so a custom theme shows itself. The System card is the
29/// light picture on its left half and the dark picture on its right.
30///
31/// ```
32/// use gpui_cn::{Theme, ThemeMode, ThemeModePicker};
33///
34/// let _ = ThemeModePicker::new("theme-mode")
35///     .value(ThemeMode::System)
36///     .on_change(|mode, _, cx| Theme::change(*mode, cx));
37/// ```
38///
39/// The id keys focus and motion, so it must be stable across frames and
40/// unique under the nearest stateful ancestor.
41#[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    /// A picker with a stable id, showing [`ThemeMode::System`] chosen.
52    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    /// The chosen mode.
63    pub fn value(mut self, value: ThemeMode) -> Self {
64        self.value = value;
65        self
66    }
67
68    /// Called with the mode of a card that is activated while another is
69    /// chosen. Activating the chosen card does nothing. The mode comes by
70    /// reference so `cx.listener` fits.
71    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    /// The id of one card: the picker's id with the mode's name as the
80    /// child, so tests and callers can find it.
81    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/// The colors of one picture, each a step from the config's surface toward
116/// its ink in OKLab.
117///
118/// The amounts reproduce the reference app's appearance settings, sampled
119/// at 2x. A light config draws one picture whether the window fills the
120/// card or sits on a desktop; a dark config draws a lifted gray window with
121/// a white card when it fills the card, and a dark window with a gray card
122/// on the desktop, as the reference does.
123#[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            // Light: window #f3f3f3 with a #f4f4f4 rim, heading #cdcdcd,
144            // subheading and bars #dfdfdf, separators #f6f6f6, and a
145            // #9f9f9f desktop. The thin bars are #f6f6f6 in the window
146            // filling the card and #f3f3f3 on the desktop.
147            (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            // Dark, filling the card: window #5d5d5d with a #6a6a6a rim,
163            // heading #9f9f9f, subheading #8f8f8f, a white card with
164            // #dfdfdf bars and #f6f6f6 lines.
165            (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            // Dark, on the desktop: desktop #5d5d5d, window #393939,
180            // heading, bars, and separators #767676, subheading #8f8f8f,
181            // card #4f4f4f.
182            (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
201/// The size the reference app draws one card at, in points: 248 by 175.
202/// Every picture is measured in this space, and it scales with the width
203/// the card gets; the card keeps this ratio.
204const CARD: Size<f32> = Size {
205    width: 248.,
206    height: 175.,
207};
208
209/// The card's corner radius in reference points.
210const CARD_RADIUS: f32 = 10.;
211
212/// The rim at rest and the ring of the chosen card, in reference points.
213const RIM_WIDTH: f32 = 1.;
214const RING_WIDTH: f32 = 2.;
215
216/// One rounded rectangle of a picture, in reference points.
217#[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    /// To the bottom of the card, with square bottom corners: the
231    /// reference clips its panels there.
232    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
268/// The window filling the card: a heading and a subheading centered over
269/// a card of three rows. Measured from the reference at 2x.
270fn 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
293/// The window on a desktop: the near picture pulled back so the window's
294/// edge shows. Measured from the reference at 2x. The System card's dark
295/// half has its thick bars `mirrored` to the right side of the rows, so
296/// both halves show them beside the split; the rest is centered.
297fn 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            // The reference rings the chosen card in the ink and paints
416            // the other labels a step short of it.
417            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        // The group is a base element without observation, so the picker's
433        // id goes on an observed wrapper around it.
434        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                            // The choice animates, not the colors, so a theme
451                            // change repaints the ring and label at once.
452                            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}