Vectrum
Sign In
286 lines · 12.7 KB · Rust
//! The Appearance page, laid out like T3 Code's: Color scheme tiles
//! (System, Light, Dark), a card per theme family where each swatch makes
//! that theme the light or the dark one, then the Interface rows.

use gpui_kit::component::{Icon, IconName, h_flex, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::SettingsView;
use super::widgets::{Section, SettingRow, choice, choice_of, reset, switch};
use crate::settings::{self, ChatWidth, DiffColors, ThemeMode};
use crate::ui::metal::{self, MetalStyled as _, Palette, vertical};
use crate::ui::themes::{self, FAMILIES, Theme};

const CHAT_WIDTHS: &[(ChatWidth, &str)] =
    &[(ChatWidth::Comfortable, "Comfortable (default)"), (ChatWidth::Wide, "Wide"), (ChatWidth::Full, "Full")];

impl SettingsView {
    pub(super) fn render_appearance(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let saved = settings::load();
        let view = cx.weak_entity();

        let colors = v_flex()
            .gap(px(24.))
            .child(
                v_flex()
                    .gap(px(12.))
                    .child(label("Color scheme", p))
                    .child(div().id("color-scheme").child(scheme_tiles(saved.theme_mode, &view, cx))),
            )
            .child(
                v_flex()
                    .gap(px(12.))
                    .child(h_flex().min_h(px(32.)).child(label("Themes", p)))
                    .child(theme_cards(&saved.light_theme, &saved.dark_theme, &view, cx)),
            );
        let colors = Section::new("Colors & themes").id("appearance").row(colors).hide_title().render(cx);

        let ink = metal::diff_ink(p);
        let diff_dots = h_flex()
            .gap(px(3.))
            .child(div().size(px(8.)).rounded_full().bg(ink.removed))
            .child(div().size(px(8.)).rounded_full().bg(ink.added));
        let interface = Section::new("Interface")
            .id("appearance-interface")
            .row(
                SettingRow::new("Diff colors")
                    .id("diff-color-scheme")
                    .description("Choose colors for additions and deletions, including change counts.")
                    .reset(reset(&view, cx, |s| s.diff_colors, |s, colors| s.diff_colors = colors))
                    .control(h_flex().gap(px(8.)).child(diff_dots).child(choice_of(
                        "diff-colors",
                        saved.diff_colors,
                        vec![
                            (DiffColors::RedGreen, "Red & green (default)".into()),
                            (DiffColors::BlueOrange, "Blue & orange".into()),
                        ],
                        Some(match saved.diff_colors {
                            DiffColors::RedGreen => "Red & green".into(),
                            DiffColors::BlueOrange => "Blue & orange".into(),
                        }),
                        &view,
                        |settings, colors| settings.diff_colors = colors,
                    ))),
            )
            .row(
                SettingRow::new("Composer context")
                    .id("composer-context")
                    .description("Keep branch and worktree controls below the composer after a thread starts.")
                    .reset(reset(&view, cx, |s| s.composer_context, |s, on| s.composer_context = on))
                    .control(switch("composer-context", saved.composer_context, &view, p, |settings, on| {
                        settings.composer_context = on
                    })),
            )
            .row(
                SettingRow::new("Chat width")
                    .id("chat-width")
                    .description("Set how wide messages and the composer can grow on large screens.")
                    .reset(reset(&view, cx, |s| s.chat_width, |s, width| s.chat_width = width))
                    .control(choice("chat-width", saved.chat_width, CHAT_WIDTHS, &view, |settings, width| {
                        settings.chat_width = width
                    })),
            )
            .row(
                SettingRow::new("Thread cards")
                    .id("thread-cards")
                    .description("Comfortable cards show the project, branch and agent. Compact ones fit a thread on one line.")
                    .reset(reset(&view, cx, |s| s.compact_threads, |s, compact| s.compact_threads = compact))
                    .control(choice("thread-density", saved.compact_threads, &[(false, "Comfortable"), (true, "Compact")], &view, {
                        |settings, compact| settings.compact_threads = compact
                    })),
            )
            .render(cx);

        vec![colors, interface]
    }
}

/// A label over a group on the page, like T3 Code's `h3`s.
fn label(text: &'static str, p: &Palette) -> Div {
    div().px(px(16.)).text_size(px(14.)).text_color(p.ink2).child(text)
}

/// System, Light and Dark, each a miniature of the app in that mode.
fn scheme_tiles(mode: ThemeMode, view: &WeakEntity<SettingsView>, cx: &App) -> Div {
    let p = metal::palette(cx);
    let (light_id, dark_id) = settings::get(|settings| (settings.light_theme.clone(), settings.dark_theme.clone()));
    let light = themes::find(&light_id, false);
    let dark = themes::find(&dark_id, true);
    let tiles = [
        (ThemeMode::System, "System", "Follow the system appearance"),
        (ThemeMode::Light, "Light", "Use light mode"),
        (ThemeMode::Dark, "Dark", "Use dark mode"),
    ];
    div().grid().grid_cols(3).gap(px(12.)).children(tiles.into_iter().map(|(value, name, tooltip)| {
        let selected = value == mode;
        let view = view.clone();
        let preview = match value {
            ThemeMode::Light => themes::preview(light).into_any_element(),
            ThemeMode::Dark => themes::preview(dark).into_any_element(),
            // Light on the left, dark on the right.
            ThemeMode::System => h_flex()
                .size_full()
                .rounded(px(14.))
                .overflow_hidden()
                .child(div().w_1_2().h_full().child(themes::preview(light).rounded_r(px(0.))))
                .child(div().w_1_2().h_full().child(themes::preview(dark).rounded_l(px(0.))))
                .into_any_element(),
        };
        v_flex()
            .id(SharedString::from(format!("scheme-{name}")))
            .gap(px(8.))
            .p(px(8.))
            .rounded(px(18.))
            .cursor_pointer()
            .map(|this| if selected { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) })
            .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
            .on_click(move |_, window, cx| {
                themes::set_mode(value, window, cx);
                let _ = view.update(cx, |view, cx| view.changed(window, cx));
            })
            .child(
                div()
                    .h(px(140.))
                    .rounded(px(14.))
                    .shadow(vec![if selected {
                        BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))
                    } else {
                        BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))
                    }])
                    .child(preview),
            )
            .child(
                h_flex()
                    .px(px(4.))
                    .gap(px(8.))
                    .child(super::radio(selected, p))
                    .child(div().text_size(px(12.5)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(name)),
            )
    }))
}

/// A card per theme family: a swatch per variant (a sun or moon says which
/// mode it's for), the family's name, and which modes it currently owns.
fn theme_cards(light_id: &str, dark_id: &str, view: &WeakEntity<SettingsView>, cx: &App) -> Div {
    let p = metal::palette(cx);
    div().grid().grid_cols(3).gap(px(8.)).children(FAMILIES.iter().map(|family| {
        let members: Vec<&'static Theme> = family.members().collect();
        let owns_light = members.iter().any(|theme| !theme.palette.dark && theme.id == light_id);
        let owns_dark = members.iter().any(|theme| theme.palette.dark && theme.id == dark_id);
        let first_light = members.iter().copied().find(|theme| !theme.palette.dark);
        let first_dark = members.iter().copied().find(|theme| theme.palette.dark);
        let use_tooltip: SharedString = match (first_light, first_dark) {
            (Some(_), Some(_)) => "Use for both light and dark".into(),
            (Some(_), None) => "Use for light mode only".into(),
            _ => "Use for dark mode only".into(),
        };
        let use_view = view.clone();
        v_flex()
            .min_w_0()
            .rounded(px(18.))
            .raised(p)
            .child(
                h_flex()
                    .h(px(84.))
                    .justify_center()
                    .gap(px(10.))
                    .children(members.iter().map(|theme| {
                        let chosen = theme.id == if theme.palette.dark { dark_id } else { light_id };
                        swatch(theme, chosen, view, p)
                    })),
            )
            .child(
                h_flex()
                    .px(px(12.))
                    .pb(px(12.))
                    .pt(px(4.))
                    .gap(px(6.))
                    .child(
                        div()
                            .id(SharedString::from(format!("theme-family-{}", family.name)))
                            .flex_1()
                            .min_w_0()
                            .truncate()
                            .cursor_pointer()
                            .text_size(px(13.5))
                            .font_weight(FontWeight::MEDIUM)
                            .text_color(p.ink)
                            .hover(|style| style.text_color(p.ink2))
                            .tooltip(move |window, cx| Tooltip::new(use_tooltip.clone()).build(window, cx))
                            .on_click(move |_, window, cx| {
                                for theme in first_light.into_iter().chain(first_dark) {
                                    themes::assign(theme, window, cx);
                                }
                                let _ = use_view.update(cx, |view, cx| view.changed(window, cx));
                            })
                            .child(family.name),
                    )
                    .when(owns_light, |this| this.child(metal::tag("Light", p)))
                    .when(owns_dark, |this| this.child(metal::tag("Dark", p))),
            )
    }))
}

/// One theme as a round swatch: its page and sheet, a cap and its accent.
/// Ringed when it's the theme of its mode.
fn swatch(theme: &'static Theme, chosen: bool, view: &WeakEntity<SettingsView>, p: &Palette) -> impl IntoElement {
    let c = theme.palette;
    let view = view.clone();
    let tooltip: SharedString =
        format!("Use {} for {} mode", theme.name, if c.dark { "dark" } else { "light" }).into();
    div()
        .id(SharedString::from(format!("theme-{}", theme.id)))
        .relative()
        .size(px(52.))
        .flex_none()
        .cursor_pointer()
        .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
        .on_click(move |_, window, cx| {
            themes::pick(theme, window, cx);
            let _ = view.update(cx, |view, cx| view.changed(window, cx));
        })
        .child(
            div()
                .size_full()
                .rounded_full()
                .flex()
                .items_center()
                .justify_center()
                .bg(vertical((c.s_hi, c.page)))
                .shadow(c.raise_sm.clone())
                .child(
                    div()
                        .size(px(22.))
                        .rounded_full()
                        .flex()
                        .items_center()
                        .justify_center()
                        .bg(vertical(c.cap))
                        .shadow(c.btn.clone())
                        .child(div().size(px(7.)).rounded_full().bg(c.blue_ink)),
                ),
        )
        .when(chosen, |this| {
            this.child(
                div().absolute().top(px(-4.)).left(px(-4.)).size(px(60.)).rounded_full().border_2().border_color(p.green_deep),
            )
        })
        .child(
            div()
                .absolute()
                .right(px(-2.))
                .bottom(px(-2.))
                .size(px(18.))
                .rounded_full()
                .flex()
                .items_center()
                .justify_center()
                .bg(vertical(p.cap))
                .shadow(p.raise_sm.clone())
                .child(
                    Icon::new(if c.dark { IconName::Moon } else { IconName::Sun }).size(px(10.)).text_color(p.ink2),
                ),
        )
}