Vectrum
Sign In

Themes page with Tokyo Night, Catppuccin, GitHub and other color schemes

Adds a Themes settings page like T3 Code's: a Light/Dark/System mode and
a light and a dark theme picked from live miniature previews. Besides
MetalUI's Bone and Graphite there are Tokyo Night (Night, Storm, Day),
Catppuccin (Mocha, Macchiato, Frappe, Latte), GitHub (Dark, Dark Dimmed,
Light), One Dark/Light, Nord, Gruvbox Dark/Light, Rose Pine (Main, Moon,
Dawn), Solarized Dark/Light and Dracula.

Each scheme becomes a full MetalUI palette via Palette::from_scheme:
surfaces step off the scheme's background the way Graphite's or Bone's
do, inks are nudged until they meet WCAG contrast on every surface, and
the shadow recipes are Graphite's, or Bone's retinted to the scheme's hue.

The colorway setting gives way to theme_mode, light_theme and dark_theme;
an old colorway value still picks the starting mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 0781cf9commit62df439

Showing 8 changed files with 936 additions and 187 deletions.

+11−3README.mdView File
@@ -13,8 +13,9 @@ talk to `pi --mode rpc` over its JSONL RPC protocol.
1313 The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the
1414 "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the
1515 conversation sits on a raised sheet, buttons are caps that sink when pressed, code and tool
16- output sit in inset wells, and labels are engraved in mono caps. It comes in MetalUI's two colorways, Bone and Graphite, or Auto to
17- follow the desktop.
16+ output sit in inset wells, and labels are engraved in mono caps. It comes in MetalUI's two colorways, Bone and Graphite,
17+ plus popular color schemes rebuilt in the same style: Tokyo Night, Catppuccin, GitHub, One, Nord,
18+ Gruvbox, Rosé Pine, Solarized and Dracula.
1819
1920 ![Vectrum Code in Bone](docs/screenshot.png)
2021
@@ -60,7 +61,12 @@ follow the desktop.
6061 open thread), folding finished turns, sending with Ctrl+Enter, showing costs, the
6162 editor "Open in" uses, and desktop notifications when a background thread finishes or
6263 waits for you.
63- - **Appearance**: Bone, Graphite or Auto colorway, and comfortable or compact thread cards.
64+ - **Appearance**: comfortable or compact thread cards.
65+ - **Themes**: Light, Dark or System mode (System follows the desktop), and a light and a
66+ dark theme picked from live previews: Bone and Graphite; Tokyo Night (Night, Storm, Day);
67+ Catppuccin (Mocha, Macchiato, Frappé, Latte); GitHub (Dark, Dark Dimmed, Light); One Dark
68+ and One Light; Nord; Gruvbox Dark and Light; Rosé Pine (Main, Moon, Dawn); Solarized Dark
69+ and Light; and Dracula.
6470 - **Providers**: Claude's account and models, Pi's status with a refresh, and a path
6571 override for each binary.
6672 - **Keybindings**, an **Archive** of settled threads to restore or delete, and **About**.
@@ -126,6 +132,8 @@ Threads started without a project run in `~/.local/share/vectrum-code/scratch`.
126132 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
127133 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
128134 keycaps, status LEDs, segmented switch).
135+ - `src/ui/themes.rs`: the color schemes, the palette builder that turns each into a full
136+ MetalUI palette, the light/dark/system selection, and the Themes page.
129137 - `assets/fonts/`: Geist, Geist Mono and Geist Pixel, embedded in the binary
130138 (SIL Open Font License, see `assets/fonts/OFL.txt`).
131139
+0−1src/main.rsView File
@@ -56,7 +56,6 @@ fn main() {
5656 .run(move |cx| {
5757 gpui_kit::init(cx);
5858 metal::load_fonts(cx);
59- metal::restore_colorway(cx);
6059 models::Catalog::init(cx);
6160
6261 cx.bind_keys([
+56−8src/settings.rsView File
@@ -50,6 +50,17 @@ pub enum ProjectOrder {
5050 Added,
5151 }
5252
53+ /// Which of the two chosen themes shows.
54+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55+ #[serde(rename_all = "snake_case")]
56+ pub enum ThemeMode {
57+ Light,
58+ Dark,
59+ /// Whichever the desktop is using.
60+ #[default]
61+ System,
62+ }
63+
5364 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
5465 #[serde(rename_all = "snake_case")]
5566 pub enum TimeFormat {
@@ -163,8 +174,11 @@ pub struct UsageHub {
163174 #[derive(Clone, Debug, Serialize, Deserialize)]
164175 #[serde(default)]
165176 pub struct Settings {
166- /// `bone`, `auto` or `graphite`.
167- pub colorway: Option<String>,
177+ pub theme_mode: ThemeMode,
178+ /// The theme light mode uses, by id (`bone`, `catppuccin-latte`).
179+ pub light_theme: String,
180+ /// The theme dark mode uses, by id (`graphite`, `tokyo-night`).
181+ pub dark_theme: String,
168182 /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
169183 /// order they were starred.
170184 pub favorites: Vec<String>,
@@ -267,12 +281,17 @@ pub struct Settings {
267281 claude_bin: Option<String>,
268282 #[serde(skip_serializing)]
269283 pi_bin: Option<String>,
284+ /// Superseded by the theme fields: `bone`, `auto` or `graphite`.
285+ #[serde(skip_serializing)]
286+ colorway: Option<String>,
270287 }
271288
272289 impl Default for Settings {
273290 fn default() -> Self {
274291 Self {
275- colorway: None,
292+ theme_mode: ThemeMode::System,
293+ light_theme: "bone".into(),
294+ dark_theme: "graphite".into(),
276295 favorites: Vec::new(),
277296 default_model: None,
278297 default_reasoning: None,
@@ -321,14 +340,22 @@ impl Default for Settings {
321340 usage_hubs: Vec::new(),
322341 claude_bin: None,
323342 pi_bin: None,
343+ colorway: None,
324344 }
325345 }
326346 }
327347
328348 impl Settings {
329349 /// Makes sure there's at least one instance of each kind, carrying old
330- /// binary overrides over.
350+ /// binary overrides over, and turns an old colorway into a theme mode.
331351 fn normalize(&mut self) {
352+ if let Some(colorway) = self.colorway.take() {
353+ self.theme_mode = match colorway.as_str() {
354+ "bone" => ThemeMode::Light,
355+ "graphite" => ThemeMode::Dark,
356+ _ => ThemeMode::System,
357+ };
358+ }
332359 for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] {
333360 if !self.providers.iter().any(|instance| instance.kind == kind) {
334361 let mut instance = ProviderInstance::new(kind, kind.key());
@@ -353,10 +380,12 @@ pub fn path() -> Option<PathBuf> {
353380 }
354381
355382 fn read_file() -> Settings {
356- let mut settings: Settings = path()
357- .and_then(|path| std::fs::read_to_string(path).ok())
358- .and_then(|text| serde_json::from_str(&text).ok())
359- .unwrap_or_default();
383+ parse(&path().and_then(|path| std::fs::read_to_string(path).ok()).unwrap_or_default())
384+ }
385+
386+ /// Reads a settings file's text, falling back to the defaults.
387+ fn parse(text: &str) -> Settings {
388+ let mut settings: Settings = serde_json::from_str(text).unwrap_or_default();
360389 settings.normalize();
361390 settings
362391 }
@@ -427,3 +456,22 @@ pub fn data_dir() -> Option<PathBuf> {
427456 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
428457 Some(base.join("vectrum-code"))
429458 }
459+
460+ #[cfg(test)]
461+ mod tests {
462+ use super::{ThemeMode, parse};
463+
464+ #[test]
465+ fn old_colorways_become_theme_modes() {
466+ for (colorway, mode) in [("bone", ThemeMode::Light), ("graphite", ThemeMode::Dark), ("auto", ThemeMode::System)] {
467+ let settings = parse(&format!(r#"{{"colorway": "{colorway}"}}"#));
468+ assert_eq!(settings.theme_mode, mode);
469+ assert_eq!(settings.light_theme, "bone");
470+ assert_eq!(settings.dark_theme, "graphite");
471+ assert!(!serde_json::to_string(&settings).unwrap().contains("colorway"));
472+ }
473+ assert_eq!(parse("{}").theme_mode, ThemeMode::System);
474+ let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#);
475+ assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord"));
476+ }
477+ }
+1−0src/ui/icons.rsView File
@@ -48,6 +48,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
4848 SlidersHorizontal,
4949 Sparkles,
5050 SquarePen,
51+ SwatchBook,
5152 Star,
5253 Trash,
5354 Zap,
+24−73src/ui/metal.rsView File
@@ -4,6 +4,7 @@
44 //!
55 //! The colors and shadows below are copied from MetalUI's CSS custom
66 //! properties (`--mu-*`), so they can be checked against the source verbatim.
7+ //! The other themes are built from popular color schemes in `themes`.
78
89 use std::{borrow::Cow, rc::Rc, sync::LazyLock, time::Duration};
910
@@ -14,6 +15,8 @@ use gpui_kit::component::{
1415 use gpui_kit::prelude::FluentBuilder as _;
1516 use gpui_kit::*;
1617
18+ use crate::ui::themes;
19+
1720 pub const SANS: &str = "Geist";
1821 pub const MONO: &str = "Geist Mono";
1922 /// A dot-matrix face for numeric readouts, MetalUI's "pixel" voice.
@@ -42,7 +45,8 @@ pub fn load_fonts(cx: &App) {
4245 }
4346
4447 /// One MetalUI colorway: surfaces, inks, accents and the shadow recipes that
45- /// make a surface read as raised, pressed or sunk.
48+ /// make a surface read as raised, pressed or sunk. Themes other than Bone and
49+ /// Graphite derive theirs with `Palette::from_scheme`.
4650 pub struct Palette {
4751 pub dark: bool,
4852 /// The table everything sits on.
@@ -251,9 +255,13 @@ pub static GRAPHITE: LazyLock<Palette> = LazyLock::new(|| Palette {
251255 led_off_sh: shadows("0 0 0 .5px #00000040"),
252256 });
253257
254- /// The palette of the active colorway.
258+ /// The palette of the active theme.
255259 pub fn palette(cx: &App) -> &'static Palette {
256- if cx.theme().is_dark() { &GRAPHITE } else { &BONE }
260+ match cx.try_global::<AppliedTheme>() {
261+ Some(applied) => applied.0,
262+ None if cx.theme().is_dark() => &GRAPHITE,
263+ None => &BONE,
264+ }
257265 }
258266
259267 /// Parses a CSS hex color: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
@@ -303,87 +311,30 @@ pub fn vertical(colors: (Hsla, Hsla)) -> Background {
303311 linear_gradient(180., linear_color_stop(colors.0, 0.), linear_color_stop(colors.1, 1.))
304312 }
305313
306- /// Which colorway the user picked. `Auto` follows the window's appearance.
307- #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
308- pub enum Colorway {
309- Bone,
310- #[default]
311- Auto,
312- Graphite,
313- }
314-
315- impl Colorway {
316- pub const ALL: [Colorway; 3] = [Colorway::Bone, Colorway::Auto, Colorway::Graphite];
317-
318- pub fn label(self) -> &'static str {
319- match self {
320- Colorway::Bone => "Bone",
321- Colorway::Auto => "Auto",
322- Colorway::Graphite => "Graphite",
323- }
324- }
325- }
326-
327- #[derive(Default)]
328- struct ColorwayState {
329- choice: Colorway,
330- /// Whether the theme currently applied is Graphite, once one is.
331- applied_dark: Option<bool>,
332- }
333-
334- impl Global for ColorwayState {}
335-
336- pub fn colorway(cx: &App) -> Colorway {
337- cx.try_global::<ColorwayState>().map(|state| state.choice).unwrap_or_default()
338- }
339-
340- /// Switches colorway, repaints every themed component to match, and
341- /// remembers the choice.
342- pub fn set_colorway(colorway: Colorway, window: &Window, cx: &mut App) {
343- cx.default_global::<ColorwayState>().choice = colorway;
344- sync_appearance(window, cx);
345- crate::settings::update(|settings| settings.colorway = Some(colorway.label().to_lowercase()));
346- }
314+ /// The palette on screen, once a theme is applied.
315+ struct AppliedTheme(&'static Palette);
347316
348- /// Picks up the colorway saved last time. Call before the window opens.
349- pub fn restore_colorway(cx: &mut App) {
350- let saved = crate::settings::load().colorway;
351- let choice = Colorway::ALL
352- .into_iter()
353- .find(|colorway| saved.as_deref() == Some(colorway.label().to_lowercase().as_str()))
354- .unwrap_or_default();
355- cx.default_global::<ColorwayState>().choice = choice;
356- }
317+ impl Global for AppliedTheme {}
357318
358- /// Re-reads the saved colorway and applies it, after the settings were reset.
359- pub fn reload_colorway(window: &Window, cx: &mut App) {
360- restore_colorway(cx);
361- sync_appearance(window, cx);
362- }
363-
364- /// Applies the chosen colorway, resolving `Auto` against the window's current
365- /// appearance. Does nothing when that colorway is already applied.
319+ /// Applies the theme the settings pick, resolving the System mode against the
320+ /// window's current appearance. Does nothing when that theme is already
321+ /// applied, so it's cheap to call after any change.
366322 pub fn sync_appearance(window: &Window, cx: &mut App) {
367323 let system_dark = matches!(
368324 window.appearance(),
369325 WindowAppearance::Dark | WindowAppearance::VibrantDark
370326 );
371- let state = cx.default_global::<ColorwayState>();
372- let dark = match state.choice {
373- Colorway::Bone => false,
374- Colorway::Graphite => true,
375- Colorway::Auto => system_dark,
376- };
377- if state.applied_dark.replace(dark) != Some(dark) {
378- apply_theme(dark, cx);
327+ let p = themes::chosen(system_dark);
328+ if !cx.try_global::<AppliedTheme>().is_some_and(|applied| std::ptr::eq(applied.0, p)) {
329+ cx.set_global(AppliedTheme(p));
330+ apply_theme(p, cx);
379331 }
380332 }
381333
382- /// Maps the colorway onto gpui-component's theme so the stock widgets (menus,
334+ /// Maps the palette onto gpui-component's theme so the stock widgets (menus,
383335 /// tooltips, the composer's text area, markdown) match the custom chrome.
384- fn apply_theme(dark: bool, cx: &mut App) {
385- Theme::change(if dark { ThemeMode::Dark } else { ThemeMode::Light }, None, cx);
386- let p: &Palette = if dark { &GRAPHITE } else { &BONE };
336+ fn apply_theme(p: &Palette, cx: &mut App) {
337+ Theme::change(if p.dark { ThemeMode::Dark } else { ThemeMode::Light }, None, cx);
387338 Theme::update(cx, |theme| {
388339 theme.font_family = SANS.into();
389340 theme.mono_font_family = MONO.into();
+1−0src/ui/mod.rsView File
@@ -4,4 +4,5 @@ pub mod icons;
44 pub mod metal;
55 pub mod pickers;
66 pub mod settings;
7+ pub mod themes;
78 pub mod workspace;
+22−102src/ui/settings.rsView File
@@ -29,8 +29,9 @@ use crate::settings::{
2929 };
3030 use crate::ui::chat::display_path;
3131 use crate::ui::icons::Lucide;
32- use crate::ui::metal::{self, BONE, Colorway, GRAPHITE, Led, MetalButton, MetalStyled as _, Palette};
32+ use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
3333 use crate::ui::pickers;
34+ use crate::ui::themes;
3435 use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
3536
3637 const PAGE_WIDTH: f32 = 760.;
@@ -40,6 +41,7 @@ pub enum Page {
4041 General,
4142 Behavior,
4243 Appearance,
44+ Themes,
4345 Providers,
4446 Keybindings,
4547 Archive,
@@ -47,10 +49,11 @@ pub enum Page {
4749 }
4850
4951 impl Page {
50- const ALL: [Page; 7] = [
52+ const ALL: [Page; 8] = [
5153 Page::General,
5254 Page::Behavior,
5355 Page::Appearance,
56+ Page::Themes,
5457 Page::Providers,
5558 Page::Keybindings,
5659 Page::Archive,
@@ -62,6 +65,7 @@ impl Page {
6265 Page::General => "General",
6366 Page::Behavior => "Behavior",
6467 Page::Appearance => "Appearance",
68+ Page::Themes => "Themes",
6569 Page::Providers => "Providers",
6670 Page::Keybindings => "Keybindings",
6771 Page::Archive => "Archive",
@@ -74,6 +78,7 @@ impl Page {
7478 Page::General => Icon::new(Lucide::SlidersHorizontal),
7579 Page::Behavior => Icon::new(Lucide::Bell),
7680 Page::Appearance => Icon::new(IconName::Palette),
81+ Page::Themes => Icon::new(Lucide::SwatchBook),
7782 Page::Providers => Icon::new(Lucide::Plug),
7883 Page::Keybindings => Icon::new(Lucide::Keyboard),
7984 Page::Archive => Icon::new(Lucide::Archive),
@@ -191,7 +196,7 @@ impl SettingsView {
191196
192197 fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context<Self>) {
193198 settings::restore_defaults();
194- metal::reload_colorway(window, cx);
199+ metal::sync_appearance(window, cx);
195200 self.reload_inputs(window, cx);
196201 self.changed(window, cx);
197202 }
@@ -294,7 +299,7 @@ impl SettingsView {
294299
295300 fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
296301 let p = metal::palette(cx);
297- let resettable = matches!(self.page, Page::General | Page::Behavior | Page::Appearance);
302+ let resettable = matches!(self.page, Page::General | Page::Behavior | Page::Appearance | Page::Themes);
298303 v_flex()
299304 .flex_none()
300305 .child(
@@ -341,6 +346,7 @@ impl SettingsView {
341346 Page::General => self.render_general(window, cx),
342347 Page::Behavior => self.render_behavior(cx),
343348 Page::Appearance => self.render_appearance(cx),
349+ Page::Themes => self.render_themes(cx),
344350 Page::Providers => self.render_providers(window, cx),
345351 Page::Keybindings => self.render_keybindings(cx),
346352 Page::Archive => self.render_archive(cx),
@@ -852,60 +858,9 @@ impl SettingsView {
852858
853859 fn render_appearance(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
854860 let p = metal::palette(cx);
855- let current = metal::colorway(cx);
856861 let view = cx.weak_entity();
857862 let compact = settings::get(|settings| settings.compact_threads);
858863
859- let swatches = h_flex().p(px(18.)).gap(px(16.)).children(Colorway::ALL.into_iter().map(|colorway| {
860- let selected = colorway == current;
861- let view = view.clone();
862- let preview = match colorway {
863- Colorway::Bone => colorway_preview(&BONE).flex_1(),
864- Colorway::Graphite => colorway_preview(&GRAPHITE).flex_1(),
865- Colorway::Auto => h_flex()
866- .flex_1()
867- .h_full()
868- .child(colorway_preview(&BONE).flex_1().rounded_r(px(0.)))
869- .child(colorway_preview(&GRAPHITE).flex_1().rounded_l(px(0.))),
870- };
871- let mut ring = vec![BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))];
872- if selected {
873- ring = vec![BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))];
874- }
875- v_flex()
876- .id(SharedString::from(format!("colorway-{}", colorway.label())))
877- .flex_1()
878- .gap(px(10.))
879- .cursor_pointer()
880- .on_click(move |_, window, cx| {
881- metal::set_colorway(colorway, window, cx);
882- let _ = view.update(cx, |_, cx| cx.notify());
883- })
884- .child(h_flex().h(px(96.)).rounded(px(14.)).shadow(ring).child(preview))
885- .child(
886- h_flex()
887- .gap(px(8.))
888- .child(radio(selected, p))
889- .child(
890- div()
891- .text_size(px(13.5))
892- .font_weight(FontWeight::MEDIUM)
893- .text_color(p.ink)
894- .child(colorway.label()),
895- )
896- .when(colorway == Colorway::Auto, |this| {
897- this.child(div().text_size(px(12.)).text_color(p.ink3).child("Follows the desktop"))
898- }),
899- )
900- }));
901-
902- let colorways = section(
903- "Colorway",
904- Some("MetalUI's two finishes: warm Bone and dark Graphite."),
905- vec![swatches.into_any_element()],
906- p,
907- );
908-
909864 let sidebar = section(
910865 "Sidebar",
911866 None,
@@ -924,7 +879,17 @@ impl SettingsView {
924879 p,
925880 );
926881
927- vec![colorways, sidebar]
882+ vec![sidebar]
883+ }
884+
885+ fn render_themes(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
886+ let view = cx.weak_entity();
887+ themes::page(
888+ cx,
889+ Rc::new(move |_, cx| {
890+ let _ = view.update(cx, |_, cx| cx.notify());
891+ }),
892+ )
928893 }
929894
930895 // ---- providers ---------------------------------------------------------
@@ -2076,7 +2041,7 @@ fn binary_status(found: Option<&Path>, instance: &ProviderInstance) -> String {
20762041 }
20772042
20782043 /// A radio dot: a well, with a raised green bead when picked.
2079- fn radio(selected: bool, p: &Palette) -> Div {
2044+ pub(crate) fn radio(selected: bool, p: &Palette) -> Div {
20802045 div()
20812046 .flex()
20822047 .flex_none()
@@ -2090,51 +2055,6 @@ fn radio(selected: bool, p: &Palette) -> Div {
20902055 })
20912056 }
20922057
2093- /// A miniature of the app in a colorway: the sheet on the table, a thread
2094- /// list and a composer.
2095- fn colorway_preview(c: &Palette) -> Div {
2096- let line = |width: f32, color: Hsla| div().h(px(4.)).w(px(width)).rounded_full().bg(color);
2097- h_flex()
2098- .h_full()
2099- .overflow_hidden()
2100- .rounded(px(14.))
2101- .bg(c.page)
2102- .p(px(8.))
2103- .gap(px(8.))
2104- .child(
2105- v_flex()
2106- .w(px(26.))
2107- .gap(px(6.))
2108- .pt(px(4.))
2109- .child(line(22., c.ink2.opacity(0.6)))
2110- .child(line(16., c.ink3.opacity(0.5)))
2111- .child(line(20., c.ink3.opacity(0.5))),
2112- )
2113- .child(
2114- v_flex()
2115- .flex_1()
2116- .h_full()
2117- .p(px(8.))
2118- .gap(px(6.))
2119- .rounded(px(10.))
2120- .bg(metal::vertical((c.s_hi, c.s)))
2121- .shadow(c.raise_sm.clone())
2122- .child(line(40., c.ink.opacity(0.7)))
2123- .child(line(56., c.ink3.opacity(0.5)))
2124- .child(div().flex_1())
2125- .child(
2126- h_flex()
2127- .h(px(16.))
2128- .px(px(4.))
2129- .rounded(px(6.))
2130- .bg(metal::vertical(c.row_on))
2131- .shadow(c.raise_sm.clone())
2132- .child(div().flex_1())
2133- .child(div().size(px(8.)).rounded_full().bg(metal::vertical(c.primary))),
2134- ),
2135- )
2136- }
2137-
21382058 fn open_folder(path: &Path) {
21392059 let Some(program) = workspace::find_program(&["xdg-open"]) else { return };
21402060 let spawned = Command::new(program)
+821−0src/ui/themes.rsaddedView File
@@ -0,0 +1,821 @@
1+ //! Themes: MetalUI's Bone and Graphite, plus popular color schemes (Tokyo
2+ //! Night, Catppuccin, GitHub and more) rebuilt as MetalUI palettes, so caps
3+ //! still rise over sunk wells in every one of them. Also the Themes page.
4+
5+ use std::{rc::Rc, sync::LazyLock};
6+
7+ use gpui_kit::component::{h_flex, v_flex};
8+ use gpui_kit::prelude::FluentBuilder as _;
9+ use gpui_kit::*;
10+
11+ use crate::settings::{self, ThemeMode};
12+ use crate::ui::metal::{self, BONE, GRAPHITE, Palette, hex, shadows, vertical};
13+ use crate::ui::settings::{radio, row, section};
14+
15+ /// A published color scheme, by the few colors a palette is built from.
16+ pub struct Scheme {
17+ pub id: &'static str,
18+ pub name: &'static str,
19+ pub dark: bool,
20+ /// The editor background, which the sheet takes.
21+ pub bg: &'static str,
22+ /// The panels around the editor, which the page takes.
23+ pub panel: &'static str,
24+ pub fg: &'static str,
25+ /// Secondary text: comments, descriptions.
26+ pub muted: &'static str,
27+ /// Primary buttons.
28+ pub accent: &'static str,
29+ pub green: &'static str,
30+ pub red: &'static str,
31+ pub yellow: &'static str,
32+ pub blue: &'static str,
33+ /// Orange, pink, teal, magenta, blue and violet, standing in for MetalUI's
34+ /// ember, blush, tide, spark, dusk and iris.
35+ pub tints: [&'static str; 6],
36+ }
37+
38+ /// The schemes, from their published palettes. Dark ones first, then light,
39+ /// in the order the Themes page shows them.
40+ pub const SCHEMES: &[Scheme] = &[
41+ Scheme {
42+ id: "tokyo-night",
43+ name: "Tokyo Night",
44+ dark: true,
45+ bg: "#1a1b26",
46+ panel: "#16161e",
47+ fg: "#c0caf5",
48+ muted: "#737aa2",
49+ accent: "#7aa2f7",
50+ green: "#9ece6a",
51+ red: "#f7768e",
52+ yellow: "#e0af68",
53+ blue: "#7aa2f7",
54+ tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"],
55+ },
56+ Scheme {
57+ id: "tokyo-night-storm",
58+ name: "Tokyo Night Storm",
59+ dark: true,
60+ bg: "#24283b",
61+ panel: "#1f2335",
62+ fg: "#c0caf5",
63+ muted: "#737aa2",
64+ accent: "#7aa2f7",
65+ green: "#9ece6a",
66+ red: "#f7768e",
67+ yellow: "#e0af68",
68+ blue: "#7aa2f7",
69+ tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"],
70+ },
71+ Scheme {
72+ id: "catppuccin-mocha",
73+ name: "Catppuccin Mocha",
74+ dark: true,
75+ bg: "#1e1e2e",
76+ panel: "#181825",
77+ fg: "#cdd6f4",
78+ muted: "#7f849c",
79+ accent: "#cba6f7",
80+ green: "#a6e3a1",
81+ red: "#f38ba8",
82+ yellow: "#f9e2af",
83+ blue: "#89b4fa",
84+ tints: ["#fab387", "#f5c2e7", "#94e2d5", "#cba6f7", "#89b4fa", "#b4befe"],
85+ },
86+ Scheme {
87+ id: "catppuccin-macchiato",
88+ name: "Catppuccin Macchiato",
89+ dark: true,
90+ bg: "#24273a",
91+ panel: "#1e2030",
92+ fg: "#cad3f5",
93+ muted: "#8087a2",
94+ accent: "#c6a0f6",
95+ green: "#a6da95",
96+ red: "#ed8796",
97+ yellow: "#eed49f",
98+ blue: "#8aadf4",
99+ tints: ["#f5a97f", "#f5bde6", "#8bd5ca", "#c6a0f6", "#8aadf4", "#b7bdf8"],
100+ },
101+ Scheme {
102+ id: "catppuccin-frappe",
103+ name: "Catppuccin Frappé",
104+ dark: true,
105+ bg: "#303446",
106+ panel: "#292c3c",
107+ fg: "#c6d0f5",
108+ muted: "#838ba7",
109+ accent: "#ca9ee6",
110+ green: "#a6d189",
111+ red: "#e78284",
112+ yellow: "#e5c890",
113+ blue: "#8caaee",
114+ tints: ["#ef9f76", "#f4b8e4", "#81c8be", "#ca9ee6", "#8caaee", "#babbf1"],
115+ },
116+ Scheme {
117+ id: "github-dark",
118+ name: "GitHub Dark",
119+ dark: true,
120+ bg: "#0d1117",
121+ panel: "#010409",
122+ fg: "#e6edf3",
123+ muted: "#7d8590",
124+ accent: "#238636",
125+ green: "#3fb950",
126+ red: "#f85149",
127+ yellow: "#d29922",
128+ blue: "#4493f8",
129+ tints: ["#ffa657", "#db61a2", "#a5d6ff", "#d2a8ff", "#79c0ff", "#a371f7"],
130+ },
131+ Scheme {
132+ id: "github-dark-dimmed",
133+ name: "GitHub Dark Dimmed",
134+ dark: true,
135+ bg: "#22272e",
136+ panel: "#1c2128",
137+ fg: "#adbac7",
138+ muted: "#768390",
139+ accent: "#347d39",
140+ green: "#57ab5a",
141+ red: "#e5534b",
142+ yellow: "#c69026",
143+ blue: "#539bf5",
144+ tints: ["#f69d50", "#c96198", "#96d0ff", "#dcbdfb", "#6cb6ff", "#986ee2"],
145+ },
146+ Scheme {
147+ id: "one-dark",
148+ name: "One Dark",
149+ dark: true,
150+ bg: "#282c34",
151+ panel: "#21252b",
152+ fg: "#abb2bf",
153+ muted: "#7f848e",
154+ accent: "#61afef",
155+ green: "#98c379",
156+ red: "#e06c75",
157+ yellow: "#e5c07b",
158+ blue: "#61afef",
159+ tints: ["#d19a66", "#e06c75", "#56b6c2", "#c678dd", "#61afef", "#e5c07b"],
160+ },
161+ Scheme {
162+ id: "nord",
163+ name: "Nord",
164+ dark: true,
165+ bg: "#3b4252",
166+ panel: "#2e3440",
167+ fg: "#eceff4",
168+ muted: "#616e88",
169+ accent: "#88c0d0",
170+ green: "#a3be8c",
171+ red: "#bf616a",
172+ yellow: "#ebcb8b",
173+ blue: "#81a1c1",
174+ tints: ["#d08770", "#bf616a", "#8fbcbb", "#b48ead", "#81a1c1", "#5e81ac"],
175+ },
176+ Scheme {
177+ id: "gruvbox-dark",
178+ name: "Gruvbox Dark",
179+ dark: true,
180+ bg: "#282828",
181+ panel: "#1d2021",
182+ fg: "#ebdbb2",
183+ muted: "#a89984",
184+ accent: "#fe8019",
185+ green: "#b8bb26",
186+ red: "#fb4934",
187+ yellow: "#fabd2f",
188+ blue: "#83a598",
189+ tints: ["#fe8019", "#fb4934", "#8ec07c", "#d3869b", "#83a598", "#fabd2f"],
190+ },
191+ Scheme {
192+ id: "rose-pine",
193+ name: "Rosé Pine",
194+ dark: true,
195+ bg: "#1f1d2e",
196+ panel: "#191724",
197+ fg: "#e0def4",
198+ muted: "#908caa",
199+ accent: "#ebbcba",
200+ green: "#9ccfd8",
201+ red: "#eb6f92",
202+ yellow: "#f6c177",
203+ blue: "#c4a7e7",
204+ tints: ["#ebbcba", "#eb6f92", "#9ccfd8", "#c4a7e7", "#31748f", "#f6c177"],
205+ },
206+ Scheme {
207+ id: "rose-pine-moon",
208+ name: "Rosé Pine Moon",
209+ dark: true,
210+ bg: "#2a273f",
211+ panel: "#232136",
212+ fg: "#e0def4",
213+ muted: "#908caa",
214+ accent: "#ea9a97",
215+ green: "#9ccfd8",
216+ red: "#eb6f92",
217+ yellow: "#f6c177",
218+ blue: "#c4a7e7",
219+ tints: ["#ea9a97", "#eb6f92", "#9ccfd8", "#c4a7e7", "#3e8fb0", "#f6c177"],
220+ },
221+ Scheme {
222+ id: "solarized-dark",
223+ name: "Solarized Dark",
224+ dark: true,
225+ bg: "#002b36",
226+ panel: "#00212b",
227+ fg: "#93a1a1",
228+ muted: "#586e75",
229+ accent: "#268bd2",
230+ green: "#859900",
231+ red: "#dc322f",
232+ yellow: "#b58900",
233+ blue: "#268bd2",
234+ tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"],
235+ },
236+ Scheme {
237+ id: "dracula",
238+ name: "Dracula",
239+ dark: true,
240+ bg: "#282a36",
241+ panel: "#21222c",
242+ fg: "#f8f8f2",
243+ muted: "#6272a4",
244+ accent: "#bd93f9",
245+ green: "#50fa7b",
246+ red: "#ff5555",
247+ yellow: "#f1fa8c",
248+ blue: "#8be9fd",
249+ tints: ["#ffb86c", "#ff79c6", "#8be9fd", "#bd93f9", "#50fa7b", "#f1fa8c"],
250+ },
251+ Scheme {
252+ id: "tokyo-night-day",
253+ name: "Tokyo Night Day",
254+ dark: false,
255+ bg: "#e1e2e7",
256+ panel: "#d0d5e3",
257+ fg: "#3760bf",
258+ muted: "#6172b0",
259+ accent: "#2e7de9",
260+ green: "#587539",
261+ red: "#f52a65",
262+ yellow: "#8c6c3e",
263+ blue: "#2e7de9",
264+ tints: ["#b15c00", "#f52a65", "#118c74", "#9854f1", "#2e7de9", "#7847bd"],
265+ },
266+ Scheme {
267+ id: "catppuccin-latte",
268+ name: "Catppuccin Latte",
269+ dark: false,
270+ bg: "#eff1f5",
271+ panel: "#e6e9ef",
272+ fg: "#4c4f69",
273+ muted: "#8c8fa1",
274+ accent: "#8839ef",
275+ green: "#40a02b",
276+ red: "#d20f39",
277+ yellow: "#df8e1d",
278+ blue: "#1e66f5",
279+ tints: ["#fe640b", "#ea76cb", "#179299", "#8839ef", "#1e66f5", "#7287fd"],
280+ },
281+ Scheme {
282+ id: "github-light",
283+ name: "GitHub Light",
284+ dark: false,
285+ bg: "#ffffff",
286+ panel: "#f6f8fa",
287+ fg: "#1f2328",
288+ muted: "#656d76",
289+ accent: "#1f883d",
290+ green: "#1a7f37",
291+ red: "#d1242f",
292+ yellow: "#9a6700",
293+ blue: "#0969da",
294+ tints: ["#bc4c00", "#bf3989", "#1b7c83", "#8250df", "#0550ae", "#6639ba"],
295+ },
296+ Scheme {
297+ id: "one-light",
298+ name: "One Light",
299+ dark: false,
300+ bg: "#fafafa",
301+ panel: "#eaeaeb",
302+ fg: "#383a42",
303+ muted: "#a0a1a7",
304+ accent: "#4078f2",
305+ green: "#50a14f",
306+ red: "#e45649",
307+ yellow: "#c18401",
308+ blue: "#4078f2",
309+ tints: ["#986801", "#e45649", "#0184bc", "#a626a4", "#4078f2", "#c18401"],
310+ },
311+ Scheme {
312+ id: "gruvbox-light",
313+ name: "Gruvbox Light",
314+ dark: false,
315+ bg: "#fbf1c7",
316+ panel: "#f2e5bc",
317+ fg: "#3c3836",
318+ muted: "#7c6f64",
319+ accent: "#af3a03",
320+ green: "#79740e",
321+ red: "#9d0006",
322+ yellow: "#b57614",
323+ blue: "#076678",
324+ tints: ["#af3a03", "#9d0006", "#427b58", "#8f3f71", "#076678", "#b57614"],
325+ },
326+ Scheme {
327+ id: "rose-pine-dawn",
328+ name: "Rosé Pine Dawn",
329+ dark: false,
330+ bg: "#fffaf3",
331+ panel: "#faf4ed",
332+ fg: "#575279",
333+ muted: "#797593",
334+ accent: "#d7827e",
335+ green: "#56949f",
336+ red: "#b4637a",
337+ yellow: "#ea9d34",
338+ blue: "#907aa9",
339+ tints: ["#d7827e", "#b4637a", "#56949f", "#907aa9", "#286983", "#ea9d34"],
340+ },
341+ Scheme {
342+ id: "solarized-light",
343+ name: "Solarized Light",
344+ dark: false,
345+ bg: "#fdf6e3",
346+ panel: "#eee8d5",
347+ fg: "#586e75",
348+ muted: "#93a1a1",
349+ accent: "#268bd2",
350+ green: "#859900",
351+ red: "#dc322f",
352+ yellow: "#b58900",
353+ blue: "#268bd2",
354+ tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"],
355+ },
356+ ];
357+
358+ impl Palette {
359+ /// Builds a full MetalUI palette from a scheme. Every surface steps off
360+ /// the scheme's background the way Graphite's (dark) or Bone's (light)
361+ /// step off theirs, inks are nudged until they read on those surfaces, and
362+ /// the shadow recipes are Graphite's, or Bone's tinted to the scheme's hue.
363+ pub fn from_scheme(scheme: &Scheme) -> Palette {
364+ let dark = scheme.dark;
365+ let bg = hex(scheme.bg);
366+ let page = hex(scheme.panel);
367+ let (s_hi, s_lo, well, cap, pressed, row_on, kbd) = if dark {
368+ (
369+ lighten(bg, 0.029),
370+ dim(bg, 0.87),
371+ (dim(bg, 0.54), dim(bg, 0.66)),
372+ (lighten(bg, 0.049), lighten(bg, 0.014)),
373+ (dim(bg, 0.66), dim(bg, 0.77)),
374+ (lighten(bg, 0.059), lighten(bg, 0.021)),
375+ (lighten(bg, 0.07), lighten(bg, 0.021)),
376+ )
377+ } else {
378+ (
379+ lighten(bg, 0.023),
380+ lighten(bg, -0.026),
381+ (lighten(bg, -0.067), lighten(bg, -0.039)),
382+ (lighten(bg, 0.06), lighten(bg, -0.008)),
383+ (lighten(bg, -0.057), lighten(bg, -0.029)),
384+ (lighten(bg, 0.06), bg),
385+ (lighten(bg, 0.06), lighten(bg, -0.015)),
386+ )
387+ };
388+
389+ // Inks get lighter on dark themes and darker on light ones until they
390+ // read on everything text sits on.
391+ let behind = [page, s_hi, bg, row_on.0, well.0];
392+ let ink = legible(hex(scheme.fg), &behind, 4.5, dark);
393+ let ink3 = legible(hex(scheme.muted), &behind, 3.5, dark);
394+ let ink2 = legible(mix(ink, ink3, 0.5), &behind, 4.5, dark);
395+ let text = |color: &str, ratio: f32| legible(hex(color), &[s_hi, bg], ratio, dark);
396+
397+ let accent = hex(scheme.accent);
398+ let primary_ink = [white(), if dark { page } else { ink }]
399+ .into_iter()
400+ .max_by(|a, b| contrast(*a, accent).total_cmp(&contrast(*b, accent)))
401+ .unwrap_or(white());
402+ // Danger caps carry white text.
403+ let danger = legible(hex(scheme.red), &[white()], 3.5, false);
404+ let (primary_sh, primary_pressed_sh) = colored_cap(accent, dark);
405+ let (danger_sh, danger_pressed_sh) = colored_cap(danger, dark);
406+
407+ // Bone's shadows are warm browns; these take the scheme's hue instead.
408+ // Graphite's are black and white, so tinting leaves them be.
409+ let tone = hsla(bg.h, bg.s.min(0.25), 0.14, 1.);
410+ let metal: &Palette = if dark { &GRAPHITE } else { &BONE };
411+
412+ Palette {
413+ dark,
414+ page,
415+ s_hi,
416+ s: bg,
417+ s_lo,
418+ well_top: well.0,
419+ well_bot: well.1,
420+ ink,
421+ ink2,
422+ ink3,
423+ icon: mix(ink, ink2, 0.4),
424+ engrave: ink.alpha(if dark { 0.5 } else { 0.65 }),
425+ tag_ring: ink.alpha(if dark { 0.16 } else { 0.2 }),
426+ rule: black().alpha(if dark { 0.45 } else { 0.075 }),
427+ // A near-black sheet swallows a dark rule, so its lip carries it.
428+ rule_lip: white().alpha(if dark { 0.035 + (0.14 - bg.l).max(0.) * 0.5 } else { 0.8 }),
429+ row_hover: white().alpha(if dark { 0.06 } else { 0.7 }),
430+ menu_bg: if dark { mix(bg, s_lo, 0.6) } else { lighten(bg, 0.015) }.alpha(0.98),
431+ menu_row_hover: if dark { white().alpha(0.06) } else { black().alpha(0.05) },
432+ field_ink: mix(ink3, bg, 0.2),
433+ cap,
434+ pressed,
435+ row_on,
436+ kbd,
437+ primary: (lighten(accent, 0.04), lighten(accent, -0.04)),
438+ primary_pressed: (lighten(accent, -0.1), lighten(accent, -0.06)),
439+ primary_ink,
440+ danger: (lighten(danger, 0.04), lighten(danger, -0.04)),
441+ danger_pressed: (lighten(danger, -0.1), lighten(danger, -0.045)),
442+ region: if dark {
443+ (white().alpha(0.02), white().alpha(0.012))
444+ } else {
445+ (tone.alpha(0.028), tone.alpha(0.012))
446+ },
447+ green_deep: hex(scheme.green),
448+ green_ink: text(scheme.green, 4.5),
449+ red: text(scheme.red, 4.),
450+ amber: hex(scheme.yellow),
451+ amber_ink: text(scheme.yellow, 4.5),
452+ blue_ink: text(scheme.blue, 4.5),
453+ led_off: if dark {
454+ (mix(ink, bg, 0.45), mix(ink, bg, 0.8))
455+ } else {
456+ (lighten(bg, -0.13), lighten(bg, -0.18))
457+ },
458+ tints: scheme.tints.map(|tint| text(tint, 3.)),
459+ raise: retint(&metal.raise, tone),
460+ raise_lite: retint(&metal.raise_lite, tone),
461+ raise_sm: retint(&metal.raise_sm, tone),
462+ btn: retint(&metal.btn, tone),
463+ pressed_sh: retint(&metal.pressed_sh, tone),
464+ well: retint(&metal.well, tone),
465+ region_sh: retint(&metal.region_sh, tone),
466+ primary_sh,
467+ primary_pressed_sh,
468+ danger_sh,
469+ danger_pressed_sh,
470+ led_off_sh: metal.led_off_sh.clone(),
471+ }
472+ }
473+ }
474+
475+ /// The raised and pressed shadows of a cap in a color of its own. On light
476+ /// themes they're MetalUI's danger recipe, shaded in the cap's hue; on dark
477+ /// ones the page is darker than any shade of it, so they fall black.
478+ fn colored_cap(color: Hsla, dark: bool) -> (Vec<BoxShadow>, Vec<BoxShadow>) {
479+ let shade = |l: f32, a: f32| css(hsla(color.h, color.s, l, a));
480+ let (edge, near, far, deep, ring) = if dark {
481+ let black = |a: f32| css(black().alpha(a));
482+ (black(0.6), black(0.35), black(0.45), black(0.35), black(0.6))
483+ } else {
484+ (shade(0.25, 0.3), shade(0.22, 0.14), shade(0.37, 0.24), shade(0.16, 0.45), shade(0.22, 0.55))
485+ };
486+ (
487+ shadows(&format!(
488+ "inset 0 0 6px 1px #ffffff24, inset 1px 2px 2px -1px #ffffff52, 0 0 0 .5px {edge}, 0 1px 2px {near}, \
489+ 0 5px 12px -4px {far}"
490+ )),
491+ shadows(&format!("inset 0 1px 3px {deep}, 0 0 0 .5px {ring}")),
492+ )
493+ }
494+
495+ /// Turns a recipe's tinted shadows to `tone`'s hue and saturation, keeping
496+ /// their lightness and alpha. Black and white layers stay as they are.
497+ fn retint(layers: &[BoxShadow], tone: Hsla) -> Vec<BoxShadow> {
498+ layers
499+ .iter()
500+ .map(|layer| {
501+ let mut layer = layer.clone();
502+ if layer.color.s > 0. {
503+ layer.color = hsla(tone.h, tone.s, layer.color.l, layer.color.a);
504+ }
505+ layer
506+ })
507+ .collect()
508+ }
509+
510+ // ---- color math ----------------------------------------------------------------
511+
512+ /// Shifts lightness by `by`, darker when negative.
513+ fn lighten(color: Hsla, by: f32) -> Hsla {
514+ Hsla { l: (color.l + by).clamp(0., 1.), ..color }
515+ }
516+
517+ /// Scales lightness, for darkening an already dark color in proportion.
518+ fn dim(color: Hsla, factor: f32) -> Hsla {
519+ Hsla { l: color.l * factor, ..color }
520+ }
521+
522+ /// Blends `a` toward `b` in RGB.
523+ fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
524+ let (a, b) = (a.to_rgb(), b.to_rgb());
525+ let lerp = |x: f32, y: f32| x + (y - x) * t;
526+ Rgba { r: lerp(a.r, b.r), g: lerp(a.g, b.g), b: lerp(a.b, b.b), a: lerp(a.a, b.a) }.into()
527+ }
528+
529+ /// `#rrggbbaa`, as `metal::shadows` reads it.
530+ fn css(color: Hsla) -> String {
531+ let c = color.to_rgb();
532+ let byte = |v: f32| (v.clamp(0., 1.) * 255.).round() as u8;
533+ format!("#{:02x}{:02x}{:02x}{:02x}", byte(c.r), byte(c.g), byte(c.b), byte(c.a))
534+ }
535+
536+ /// WCAG relative luminance.
537+ fn luminance(color: Hsla) -> f32 {
538+ let c = color.to_rgb();
539+ let linear = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
540+ 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b)
541+ }
542+
543+ /// WCAG contrast ratio, 1 to 21.
544+ pub fn contrast(a: Hsla, b: Hsla) -> f32 {
545+ let (a, b) = (luminance(a), luminance(b));
546+ (a.max(b) + 0.05) / (a.min(b) + 0.05)
547+ }
548+
549+ /// Moves `color` lighter (or darker) until it reads at `ratio` on every one
550+ /// of `surfaces`.
551+ fn legible(mut color: Hsla, surfaces: &[Hsla], ratio: f32, lighter: bool) -> Hsla {
552+ for _ in 0..100 {
553+ if surfaces.iter().all(|surface| contrast(color, *surface) >= ratio) {
554+ break;
555+ }
556+ color = lighten(color, if lighter { 0.01 } else { -0.01 });
557+ }
558+ color
559+ }
560+
561+ // ---- the catalog -----------------------------------------------------------------
562+
563+ /// A theme on the Themes page.
564+ pub struct Theme {
565+ pub id: &'static str,
566+ pub name: &'static str,
567+ pub palette: &'static Palette,
568+ }
569+
570+ static THEMES: LazyLock<Vec<Theme>> = LazyLock::new(|| {
571+ let metal = [("graphite", "Graphite", &*GRAPHITE), ("bone", "Bone", &*BONE)];
572+ let schemes = SCHEMES.iter().map(|scheme| {
573+ let palette: &'static Palette = Box::leak(Box::new(Palette::from_scheme(scheme)));
574+ (scheme.id, scheme.name, palette)
575+ });
576+ metal.into_iter().chain(schemes).map(|(id, name, palette)| Theme { id, name, palette }).collect()
577+ });
578+
579+ /// Every theme, Bone and Graphite first.
580+ pub fn all() -> &'static [Theme] {
581+ &THEMES
582+ }
583+
584+ /// The palette of the theme with this id, if it's of the right kind, or
585+ /// Graphite or Bone.
586+ pub fn find(id: &str, dark: bool) -> &'static Palette {
587+ all()
588+ .iter()
589+ .find(|theme| theme.id == id && theme.palette.dark == dark)
590+ .map_or(if dark { &*GRAPHITE } else { &*BONE }, |theme| theme.palette)
591+ }
592+
593+ /// The palette the settings pick: the dark or light theme, by mode.
594+ pub fn chosen(system_dark: bool) -> &'static Palette {
595+ let (mode, light, dark) =
596+ settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone()));
597+ match mode {
598+ ThemeMode::Light => find(&light, false),
599+ ThemeMode::Dark => find(&dark, true),
600+ ThemeMode::System if system_dark => find(&dark, true),
601+ ThemeMode::System => find(&light, false),
602+ }
603+ }
604+
605+ /// Switches between light, dark and the desktop's choice, and applies it.
606+ pub fn set_mode(mode: ThemeMode, window: &Window, cx: &mut App) {
607+ settings::update(|settings| settings.theme_mode = mode);
608+ metal::sync_appearance(window, cx);
609+ }
610+
611+ /// Makes `theme` the light or dark theme, switching to its mode unless the
612+ /// mode follows the desktop, and applies it.
613+ pub fn pick(theme: &Theme, window: &Window, cx: &mut App) {
614+ let (id, dark) = (theme.id.to_string(), theme.palette.dark);
615+ settings::update(|settings| {
616+ if dark {
617+ settings.dark_theme = id;
618+ } else {
619+ settings.light_theme = id;
620+ }
621+ if settings.theme_mode != ThemeMode::System {
622+ settings.theme_mode = if dark { ThemeMode::Dark } else { ThemeMode::Light };
623+ }
624+ });
625+ metal::sync_appearance(window, cx);
626+ }
627+
628+ // ---- the Themes page -------------------------------------------------------------
629+
630+ const MODES: &[(ThemeMode, &str)] =
631+ &[(ThemeMode::Light, "Light"), (ThemeMode::Dark, "Dark"), (ThemeMode::System, "System")];
632+
633+ /// Repaints the settings view after a change.
634+ pub type Changed = Rc<dyn Fn(&mut Window, &mut App)>;
635+
636+ /// The Themes settings page: the mode, then a card per theme, dark and light.
637+ pub fn page(cx: &App, changed: Changed) -> Vec<AnyElement> {
638+ let p = metal::palette(cx);
639+ let (mode, light, dark) =
640+ settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone()));
641+
642+ let on_mode = changed.clone();
643+ let mode = section(
644+ "Theme",
645+ None,
646+ vec![row(
647+ "Mode",
648+ "Light and Dark keep to the themes picked below. System switches between them with the desktop.",
649+ metal::segmented("theme-mode", MODES, mode, p, move |mode, window, cx| {
650+ set_mode(mode, window, cx);
651+ on_mode(window, cx);
652+ }),
653+ p,
654+ )],
655+ p,
656+ );
657+ let dark_themes = section(
658+ "Dark themes",
659+ Some("Shown in dark mode, and in System mode while the desktop is dark."),
660+ vec![cards(find(&dark, true), p, &changed)],
661+ p,
662+ );
663+ let light_themes = section(
664+ "Light themes",
665+ Some("Shown in light mode, and in System mode while the desktop is light."),
666+ vec![cards(find(&light, false), p, &changed)],
667+ p,
668+ );
669+ vec![mode, dark_themes, light_themes]
670+ }
671+
672+ /// A grid of the themes of `chosen`'s kind, `chosen` ringed.
673+ fn cards(chosen: &'static Palette, p: &'static Palette, changed: &Changed) -> AnyElement {
674+ let themes = all().iter().filter(|theme| theme.palette.dark == chosen.dark);
675+ div()
676+ .grid()
677+ .grid_cols(3)
678+ .gap(px(16.))
679+ .p(px(18.))
680+ .children(themes.map(|theme| {
681+ let selected = std::ptr::eq(theme.palette, chosen);
682+ let shown = std::ptr::eq(theme.palette, p);
683+ let ring = if selected {
684+ BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))
685+ } else {
686+ BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))
687+ };
688+ let changed = changed.clone();
689+ v_flex()
690+ .id(SharedString::from(format!("theme-{}", theme.id)))
691+ .min_w_0()
692+ .gap(px(10.))
693+ .cursor_pointer()
694+ .on_click(move |_, window, cx| {
695+ pick(theme, window, cx);
696+ changed(window, cx);
697+ })
698+ .child(div().h(px(96.)).rounded(px(14.)).shadow(vec![ring]).child(preview(theme.palette)))
699+ .child(
700+ h_flex()
701+ .gap(px(8.))
702+ .child(radio(selected, p))
703+ .child(
704+ div()
705+ .flex_1()
706+ .min_w_0()
707+ .truncate()
708+ .text_size(px(13.5))
709+ .font_weight(FontWeight::MEDIUM)
710+ .text_color(p.ink)
711+ .child(theme.name),
712+ )
713+ .when(shown, |this| this.child(metal::tag("On", p))),
714+ )
715+ }))
716+ .into_any_element()
717+ }
718+
719+ /// A miniature of the app in a theme: the sheet on the table, a thread list
720+ /// with one thread picked, a code well and a composer.
721+ pub fn preview(c: &Palette) -> Div {
722+ let line = |width: f32, color: Hsla| div().flex_none().h(px(4.)).w(px(width)).rounded_full().bg(color);
723+ h_flex()
724+ .size_full()
725+ .overflow_hidden()
726+ .rounded(px(14.))
727+ .bg(c.page)
728+ .p(px(8.))
729+ .gap(px(8.))
730+ .child(
731+ v_flex()
732+ .w(px(30.))
733+ .gap(px(6.))
734+ .child(
735+ div()
736+ .p(px(3.))
737+ .rounded(px(4.))
738+ .bg(vertical(c.row_on))
739+ .shadow(c.raise_sm.clone())
740+ .child(line(20., c.ink2)),
741+ )
742+ .child(div().px(px(3.)).child(line(16., c.ink3.opacity(0.7))))
743+ .child(div().px(px(3.)).child(line(20., c.ink3.opacity(0.7)))),
744+ )
745+ .child(
746+ v_flex()
747+ .flex_1()
748+ .h_full()
749+ .p(px(8.))
750+ .gap(px(5.))
751+ .rounded(px(10.))
752+ .bg(vertical((c.s_hi, c.s)))
753+ .shadow(c.raise_sm.clone())
754+ .child(line(40., c.ink.opacity(0.8)))
755+ .child(line(56., c.ink3.opacity(0.7)))
756+ .child(
757+ v_flex()
758+ .gap(px(3.))
759+ .p(px(4.))
760+ .rounded(px(5.))
761+ .bg(vertical((c.well_top, c.well_bot)))
762+ .shadow(c.well.clone())
763+ .child(h_flex().gap(px(3.)).child(line(10., c.blue_ink)).child(line(18., c.green_ink)))
764+ .child(h_flex().gap(px(3.)).child(line(14., c.tints[3])).child(line(10., c.amber_ink))),
765+ )
766+ .child(div().flex_1())
767+ .child(
768+ h_flex()
769+ .h(px(16.))
770+ .px(px(4.))
771+ .rounded(px(6.))
772+ .bg(vertical(c.row_on))
773+ .shadow(c.raise_sm.clone())
774+ .child(div().flex_1())
775+ .child(div().size(px(8.)).rounded_full().bg(vertical(c.primary))),
776+ ),
777+ )
778+ }
779+
780+ #[cfg(test)]
781+ mod tests {
782+ use super::{SCHEMES, all, contrast, find, luminance};
783+ use crate::ui::metal::{BONE, GRAPHITE};
784+ use gpui_kit::white;
785+
786+ #[test]
787+ fn every_theme_reads() {
788+ for theme in all() {
789+ let (id, p) = (theme.id, theme.palette);
790+ for surface in [p.page, p.s_hi, p.s] {
791+ assert!(contrast(p.ink, surface) >= 4.5, "{id}: ink on {surface:?}");
792+ assert!(contrast(p.ink2, surface) >= 3., "{id}: ink2 on {surface:?}");
793+ assert!(contrast(p.ink3, surface) >= 3., "{id}: ink3 on {surface:?}");
794+ assert!(contrast(p.blue_ink, surface) >= 3., "{id}: blue ink on {surface:?}");
795+ assert!(contrast(p.green_ink, surface) >= 3., "{id}: green ink on {surface:?}");
796+ }
797+ assert!(contrast(p.primary_ink, p.primary.1) >= 3., "{id}: primary cap");
798+ assert!(contrast(white(), p.danger.1) >= 3., "{id}: danger cap");
799+ assert!(luminance(p.page) < luminance(p.s_hi), "{id}: the sheet should stand off the page");
800+ assert_eq!(p.dark, luminance(p.s) < 0.18, "{id}: dark or light");
801+ }
802+ }
803+
804+ #[test]
805+ fn theme_ids_are_unique() {
806+ let themes = all();
807+ assert_eq!(themes.len(), SCHEMES.len() + 2);
808+ for (ix, theme) in themes.iter().enumerate() {
809+ assert!(themes[ix + 1..].iter().all(|other| other.id != theme.id), "{} twice", theme.id);
810+ }
811+ }
812+
813+ #[test]
814+ fn unknown_themes_fall_back() {
815+ assert!(std::ptr::eq(find("graphite", true), &*GRAPHITE));
816+ assert!(std::ptr::eq(find("no-such-theme", true), &*GRAPHITE));
817+ assert!(std::ptr::eq(find("tokyo-night", false), &*BONE));
818+ let latte = find("catppuccin-latte", false);
819+ assert!(!latte.dark && !std::ptr::eq(latte, &*BONE));
820+ }
821+ }