Vectrum
Sign In

Merge themes: Tokyo Night, Catppuccin, GitHub and more, with light, dark and system modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 9f24103 62df439commit4cdc379

Showing 11 changed files with 943 additions and 192 deletions.

Merge commit — showing changes against the first parent.

+14−4README.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
@@ -86,11 +87,18 @@ follow the desktop.
8687 streaming, turn folding and costs, proactive diffs, diff defaults, the composer (skills in
8788 the `/` menu, collapsing on scroll, send key, follow-up behavior), update checks,
8889 continuing after restarts, background activity, and the editor "Open in" uses.
89- - **Appearance**: the colorway and thread card density.
90+ - **Appearance**: comfortable or compact thread cards.
91+ - **Themes**: Light, Dark or System mode (System follows the desktop), and a light and a
92+ dark theme picked from live previews: Bone and Graphite; Tokyo Night (Night, Storm, Day);
93+ Catppuccin (Mocha, Macchiato, Frappé, Latte); GitHub (Dark, Dark Dimmed, Light); One Dark
94+ and One Light; Nord; Gruvbox Dark and Light; Rosé Pine (Main, Moon, Dawn); Solarized Dark
95+ and Light; and Dracula.
9096 - **Providers**: every agent instance (several Claude or Pi instances side by side, say two
9197 accounts), each with a switch, its version, login and plan, display name, binary, config
9298 folder, launch arguments and environment variables, plus updates and the health check
93- interval.
99+ interval, and usage hubs (CLIProxyAPI servers, with their management key).
100+ - **Usage**: cost, tokens and plan limits over the past day, week, month or quarter, read
101+ from Claude Code's and Pi's local logs and any usage hubs, by provider, model and day.
94102 - **Connections**, **Keybindings**, an **Archive** of settled threads, and **About**.
95103
96104 ## Requirements
@@ -155,6 +163,8 @@ in its `scratch` folder.
155163 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
156164 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
157165 keycaps, status LEDs, segmented switch).
166+ - `src/ui/themes.rs`: the color schemes, the palette builder that turns each into a full
167+ MetalUI palette, the light/dark/system selection, and the Themes page.
158168 - `assets/fonts/`: Geist, Geist Mono and Geist Pixel, embedded in the binary
159169 (SIL Open Font License, see `assets/fonts/OFL.txt`).
160170
+0−1src/main.rsView File
@@ -58,7 +58,6 @@ fn main() {
5858 .run(move |cx| {
5959 gpui_kit::init(cx);
6060 metal::load_fonts(cx);
61- metal::restore_colorway(cx);
6261 models::Catalog::init(cx);
6362
6463 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 {
@@ -181,8 +192,11 @@ pub struct UsageHub {
181192 #[derive(Clone, Debug, Serialize, Deserialize)]
182193 #[serde(default)]
183194 pub struct Settings {
184- /// `bone`, `auto` or `graphite`.
185- pub colorway: Option<String>,
195+ pub theme_mode: ThemeMode,
196+ /// The theme light mode uses, by id (`bone`, `catppuccin-latte`).
197+ pub light_theme: String,
198+ /// The theme dark mode uses, by id (`graphite`, `tokyo-night`).
199+ pub dark_theme: String,
186200 /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
187201 /// order they were starred.
188202 pub favorites: Vec<String>,
@@ -294,12 +308,17 @@ pub struct Settings {
294308 claude_bin: Option<String>,
295309 #[serde(skip_serializing)]
296310 pi_bin: Option<String>,
311+ /// Superseded by the theme fields: `bone`, `auto` or `graphite`.
312+ #[serde(skip_serializing)]
313+ colorway: Option<String>,
297314 }
298315
299316 impl Default for Settings {
300317 fn default() -> Self {
301318 Self {
302- colorway: None,
319+ theme_mode: ThemeMode::System,
320+ light_theme: "bone".into(),
321+ dark_theme: "graphite".into(),
303322 favorites: Vec::new(),
304323 default_model: None,
305324 default_reasoning: None,
@@ -352,14 +371,22 @@ impl Default for Settings {
352371 remotes: Vec::new(),
353372 claude_bin: None,
354373 pi_bin: None,
374+ colorway: None,
355375 }
356376 }
357377 }
358378
359379 impl Settings {
360380 /// Makes sure there's at least one instance of each kind, carrying old
361- /// binary overrides over.
381+ /// binary overrides over, and turns an old colorway into a theme mode.
362382 fn normalize(&mut self) {
383+ if let Some(colorway) = self.colorway.take() {
384+ self.theme_mode = match colorway.as_str() {
385+ "bone" => ThemeMode::Light,
386+ "graphite" => ThemeMode::Dark,
387+ _ => ThemeMode::System,
388+ };
389+ }
363390 for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] {
364391 if !self.providers.iter().any(|instance| instance.kind == kind) {
365392 let mut instance = ProviderInstance::new(kind, kind.key());
@@ -384,10 +411,12 @@ pub fn path() -> Option<PathBuf> {
384411 }
385412
386413 fn read_file() -> Settings {
387- let mut settings: Settings = path()
388- .and_then(|path| std::fs::read_to_string(path).ok())
389- .and_then(|text| serde_json::from_str(&text).ok())
390- .unwrap_or_default();
414+ parse(&path().and_then(|path| std::fs::read_to_string(path).ok()).unwrap_or_default())
415+ }
416+
417+ /// Reads a settings file's text, falling back to the defaults.
418+ fn parse(text: &str) -> Settings {
419+ let mut settings: Settings = serde_json::from_str(text).unwrap_or_default();
391420 settings.normalize();
392421 settings
393422 }
@@ -466,3 +495,22 @@ pub fn data_dir() -> Option<PathBuf> {
466495 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
467496 Some(base.join("vectrum-code"))
468497 }
498+
499+ #[cfg(test)]
500+ mod tests {
501+ use super::{ThemeMode, parse};
502+
503+ #[test]
504+ fn old_colorways_become_theme_modes() {
505+ for (colorway, mode) in [("bone", ThemeMode::Light), ("graphite", ThemeMode::Dark), ("auto", ThemeMode::System)] {
506+ let settings = parse(&format!(r#"{{"colorway": "{colorway}"}}"#));
507+ assert_eq!(settings.theme_mode, mode);
508+ assert_eq!(settings.light_theme, "bone");
509+ assert_eq!(settings.dark_theme, "graphite");
510+ assert!(!serde_json::to_string(&settings).unwrap().contains("colorway"));
511+ }
512+ assert_eq!(parse("{}").theme_mode, ThemeMode::System);
513+ let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#);
514+ assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord"));
515+ }
516+ }
+1−1src/ui/chat.rsView File
@@ -1224,7 +1224,7 @@ fn format_tokens(tokens: u64) -> String {
12241224 }
12251225
12261226 /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
1227- /// the theme, which `metal` keeps in step with the colorway.
1227+ /// the theme, which `metal` keeps in step with the theme.
12281228 fn markdown_style(p: &Palette) -> TextViewStyle {
12291229 let mut code_block = StyleRefinement::default()
12301230 .rounded(px(14.))
+1−0src/ui/icons.rsView File
@@ -53,6 +53,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
5353 Space,
5454 Sparkles,
5555 SquarePen,
56+ SwatchBook,
5657 Star,
5758 Trash,
5859 Zap,
+2−2src/ui/markdown_input.rsView File
@@ -61,9 +61,9 @@ mod flag {
6161 type Span = (Range<usize>, u16);
6262
6363 thread_local! {
64- /// The colorway the composer paints with. [`InputHighlighter::styles`]
64+ /// The theme the composer paints with. [`InputHighlighter::styles`]
6565 /// gets no `App`, so [`MarkdownInput`] records the palette here on every
66- /// render, ahead of the editor's paint in the same frame. One colorway is
66+ /// render, ahead of the editor's paint in the same frame. One theme is
6767 /// active app-wide, so one cell serves every composer.
6868 static PALETTE: Cell<Option<&'static Palette>> = const { Cell::new(None) };
6969 }
+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
@@ -6,5 +6,6 @@ pub mod markdown_input;
66 pub mod metal;
77 pub mod pickers;
88 pub mod settings;
9+ pub mod themes;
910 pub mod usage;
1011 pub mod workspace;
+22−102src/ui/settings.rsView File
@@ -30,8 +30,9 @@ use crate::settings::{
3030 };
3131 use crate::ui::chat::display_path;
3232 use crate::ui::icons::Lucide;
33- use crate::ui::metal::{self, BONE, Colorway, GRAPHITE, Led, MetalButton, MetalStyled as _, Palette};
33+ use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
3434 use crate::ui::pickers;
35+ use crate::ui::themes;
3536 use crate::ui::usage::{HubSettings, UsageView};
3637 use crate::usage::UsageSources;
3738 use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
@@ -43,6 +44,7 @@ pub enum Page {
4344 General,
4445 Behavior,
4546 Appearance,
47+ Themes,
4648 Providers,
4749 Usage,
4850 Connections,
@@ -52,10 +54,11 @@ pub enum Page {
5254 }
5355
5456 impl Page {
55- const ALL: [Page; 9] = [
57+ const ALL: [Page; 10] = [
5658 Page::General,
5759 Page::Behavior,
5860 Page::Appearance,
61+ Page::Themes,
5962 Page::Providers,
6063 Page::Usage,
6164 Page::Connections,
@@ -69,6 +72,7 @@ impl Page {
6972 Page::General => "General",
7073 Page::Behavior => "Behavior",
7174 Page::Appearance => "Appearance",
75+ Page::Themes => "Themes",
7276 Page::Providers => "Providers",
7377 Page::Usage => "Usage",
7478 Page::Connections => "Connections",
@@ -83,6 +87,7 @@ impl Page {
8387 Page::General => Icon::new(Lucide::SlidersHorizontal),
8488 Page::Behavior => Icon::new(Lucide::Bell),
8589 Page::Appearance => Icon::new(IconName::Palette),
90+ Page::Themes => Icon::new(Lucide::SwatchBook),
8691 Page::Providers => Icon::new(Lucide::Plug),
8792 Page::Usage => Icon::new(Lucide::Gauge),
8893 Page::Connections => Icon::new(Lucide::Network),
@@ -233,7 +238,7 @@ impl SettingsView {
233238
234239 fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context<Self>) {
235240 settings::restore_defaults();
236- metal::reload_colorway(window, cx);
241+ metal::sync_appearance(window, cx);
237242 self.reload_inputs(window, cx);
238243 self.changed(window, cx);
239244 }
@@ -339,7 +344,7 @@ impl SettingsView {
339344
340345 fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
341346 let p = metal::palette(cx);
342- let resettable = matches!(self.page, Page::General | Page::Behavior | Page::Appearance);
347+ let resettable = matches!(self.page, Page::General | Page::Behavior | Page::Appearance | Page::Themes);
343348 v_flex()
344349 .flex_none()
345350 .child(
@@ -386,6 +391,7 @@ impl SettingsView {
386391 Page::General => self.render_general(window, cx),
387392 Page::Behavior => self.render_behavior(cx),
388393 Page::Appearance => self.render_appearance(cx),
394+ Page::Themes => self.render_themes(cx),
389395 Page::Providers => self.render_providers(window, cx),
390396 Page::Usage => vec![self.usage.clone().into_any_element()],
391397 Page::Connections => self.render_connections(window, cx),
@@ -899,60 +905,9 @@ impl SettingsView {
899905
900906 fn render_appearance(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
901907 let p = metal::palette(cx);
902- let current = metal::colorway(cx);
903908 let view = cx.weak_entity();
904909 let compact = settings::get(|settings| settings.compact_threads);
905910
906- let swatches = h_flex().p(px(18.)).gap(px(16.)).children(Colorway::ALL.into_iter().map(|colorway| {
907- let selected = colorway == current;
908- let view = view.clone();
909- let preview = match colorway {
910- Colorway::Bone => colorway_preview(&BONE).flex_1(),
911- Colorway::Graphite => colorway_preview(&GRAPHITE).flex_1(),
912- Colorway::Auto => h_flex()
913- .flex_1()
914- .h_full()
915- .child(colorway_preview(&BONE).flex_1().rounded_r(px(0.)))
916- .child(colorway_preview(&GRAPHITE).flex_1().rounded_l(px(0.))),
917- };
918- let mut ring = vec![BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))];
919- if selected {
920- ring = vec![BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))];
921- }
922- v_flex()
923- .id(SharedString::from(format!("colorway-{}", colorway.label())))
924- .flex_1()
925- .gap(px(10.))
926- .cursor_pointer()
927- .on_click(move |_, window, cx| {
928- metal::set_colorway(colorway, window, cx);
929- let _ = view.update(cx, |_, cx| cx.notify());
930- })
931- .child(h_flex().h(px(96.)).rounded(px(14.)).shadow(ring).child(preview))
932- .child(
933- h_flex()
934- .gap(px(8.))
935- .child(radio(selected, p))
936- .child(
937- div()
938- .text_size(px(13.5))
939- .font_weight(FontWeight::MEDIUM)
940- .text_color(p.ink)
941- .child(colorway.label()),
942- )
943- .when(colorway == Colorway::Auto, |this| {
944- this.child(div().text_size(px(12.)).text_color(p.ink3).child("Follows the desktop"))
945- }),
946- )
947- }));
948-
949- let colorways = section(
950- "Colorway",
951- Some("MetalUI's two finishes: warm Bone and dark Graphite."),
952- vec![swatches.into_any_element()],
953- p,
954- );
955-
956911 let sidebar = section(
957912 "Sidebar",
958913 None,
@@ -971,7 +926,17 @@ impl SettingsView {
971926 p,
972927 );
973928
974- vec![colorways, sidebar]
929+ vec![sidebar]
930+ }
931+
932+ fn render_themes(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
933+ let view = cx.weak_entity();
934+ themes::page(
935+ cx,
936+ Rc::new(move |_, cx| {
937+ let _ = view.update(cx, |_, cx| cx.notify());
938+ }),
939+ )
975940 }
976941
977942 // ---- providers ---------------------------------------------------------
@@ -2435,7 +2400,7 @@ fn binary_status(found: Option<&Path>, instance: &ProviderInstance) -> String {
24352400 }
24362401
24372402 /// A radio dot: a well, with a raised green bead when picked.
2438- fn radio(selected: bool, p: &Palette) -> Div {
2403+ pub(crate) fn radio(selected: bool, p: &Palette) -> Div {
24392404 div()
24402405 .flex()
24412406 .flex_none()
@@ -2449,51 +2414,6 @@ fn radio(selected: bool, p: &Palette) -> Div {
24492414 })
24502415 }
24512416
2452- /// A miniature of the app in a colorway: the sheet on the table, a thread
2453- /// list and a composer.
2454- fn colorway_preview(c: &Palette) -> Div {
2455- let line = |width: f32, color: Hsla| div().h(px(4.)).w(px(width)).rounded_full().bg(color);
2456- h_flex()
2457- .h_full()
2458- .overflow_hidden()
2459- .rounded(px(14.))
2460- .bg(c.page)
2461- .p(px(8.))
2462- .gap(px(8.))
2463- .child(
2464- v_flex()
2465- .w(px(26.))
2466- .gap(px(6.))
2467- .pt(px(4.))
2468- .child(line(22., c.ink2.opacity(0.6)))
2469- .child(line(16., c.ink3.opacity(0.5)))
2470- .child(line(20., c.ink3.opacity(0.5))),
2471- )
2472- .child(
2473- v_flex()
2474- .flex_1()
2475- .h_full()
2476- .p(px(8.))
2477- .gap(px(6.))
2478- .rounded(px(10.))
2479- .bg(metal::vertical((c.s_hi, c.s)))
2480- .shadow(c.raise_sm.clone())
2481- .child(line(40., c.ink.opacity(0.7)))
2482- .child(line(56., c.ink3.opacity(0.5)))
2483- .child(div().flex_1())
2484- .child(
2485- h_flex()
2486- .h(px(16.))
2487- .px(px(4.))
2488- .rounded(px(6.))
2489- .bg(metal::vertical(c.row_on))
2490- .shadow(c.raise_sm.clone())
2491- .child(div().flex_1())
2492- .child(div().size(px(8.)).rounded_full().bg(metal::vertical(c.primary))),
2493- ),
2494- )
2495- }
2496-
24972417 fn open_folder(path: &Path) {
24982418 let Some(program) = workspace::find_program(&["xdg-open"]) else { return };
24992419 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+ }
+1−1src/ui/workspace.rsView File
@@ -215,7 +215,7 @@ impl Workspace {
215215 }
216216 async {}
217217 }),
218- // The Auto colorway follows the desktop's light/dark setting.
218+ // The System theme mode follows the desktop's light/dark setting.
219219 cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)),
220220 cx.observe_window_activation(window, |this, window, cx| {
221221 if window.is_window_active() {