diff --git a/AGENTS.md b/AGENTS.md index 6423ebf..108bef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. +- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance. +- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). ## Rules diff --git a/assets/themes/LICENSE b/assets/themes/LICENSE new file mode 100644 index 0000000..a2c3ef2 --- /dev/null +++ b/assets/themes/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2011 to Present Mark Badolato + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license covers the iTerm-Color-Schemes repository collection of themes. + +The copyright/license for each individual theme belongs to the author of that theme. diff --git a/assets/themes/catppuccin-frappe b/assets/themes/catppuccin-frappe new file mode 100644 index 0000000..34a5e73 --- /dev/null +++ b/assets/themes/catppuccin-frappe @@ -0,0 +1,22 @@ +palette = 0=#51576d +palette = 1=#e78284 +palette = 2=#a6d189 +palette = 3=#e5c890 +palette = 4=#8caaee +palette = 5=#f4b8e4 +palette = 6=#81c8be +palette = 7=#b5bfe2 +palette = 8=#626880 +palette = 9=#eda0a2 +palette = 10=#b9dba2 +palette = 11=#ecd7ae +palette = 12=#adc2f3 +palette = 13=#f38ed8 +palette = 14=#98d2ca +palette = 15=#a5adce +background = #303446 +foreground = #c6d0f5 +cursor-color = #f2d5cf +cursor-text = #303446 +selection-background = #f2d5cf +selection-foreground = #303446 diff --git a/assets/themes/catppuccin-latte b/assets/themes/catppuccin-latte new file mode 100644 index 0000000..d35df69 --- /dev/null +++ b/assets/themes/catppuccin-latte @@ -0,0 +1,22 @@ +palette = 0=#bcc0cc +palette = 1=#d20f39 +palette = 2=#40a02b +palette = 3=#df8e1d +palette = 4=#1e66f5 +palette = 5=#ea76cb +palette = 6=#179299 +palette = 7=#5c5f77 +palette = 8=#acb0be +palette = 9=#e7103f +palette = 10=#46b02f +palette = 11=#e49931 +palette = 12=#3878f6 +palette = 13=#ef95d7 +palette = 14=#19a1a8 +palette = 15=#6c6f85 +background = #eff1f5 +foreground = #4c4f69 +cursor-color = #dc8a78 +cursor-text = #eff1f5 +selection-background = #dc8a78 +selection-foreground = #eff1f5 diff --git a/assets/themes/catppuccin-macchiato b/assets/themes/catppuccin-macchiato new file mode 100644 index 0000000..ee7cdc6 --- /dev/null +++ b/assets/themes/catppuccin-macchiato @@ -0,0 +1,22 @@ +palette = 0=#494d64 +palette = 1=#ed8796 +palette = 2=#a6da95 +palette = 3=#eed49f +palette = 4=#8aadf4 +palette = 5=#f5bde6 +palette = 6=#8bd5ca +palette = 7=#b8c0e0 +palette = 8=#5b6078 +palette = 9=#f2a7b2 +palette = 10=#bde3b0 +palette = 11=#f4e3c1 +palette = 12=#adc5f7 +palette = 13=#f493da +palette = 14=#a5ded6 +palette = 15=#a5adcb +background = #24273a +foreground = #cad3f5 +cursor-color = #f4dbd6 +cursor-text = #24273a +selection-background = #f4dbd6 +selection-foreground = #24273a diff --git a/assets/themes/catppuccin-mocha b/assets/themes/catppuccin-mocha new file mode 100644 index 0000000..fead545 --- /dev/null +++ b/assets/themes/catppuccin-mocha @@ -0,0 +1,22 @@ +palette = 0=#45475a +palette = 1=#f38ba8 +palette = 2=#a6e3a1 +palette = 3=#f9e2af +palette = 4=#89b4fa +palette = 5=#f5c2e7 +palette = 6=#94e2d5 +palette = 7=#bac2de +palette = 8=#585b70 +palette = 9=#f7aec2 +palette = 10=#c2ecbf +palette = 11=#fcd682 +palette = 12=#aeccfc +palette = 13=#f398da +palette = 14=#b1eae1 +palette = 15=#a6adc8 +background = #1e1e2e +foreground = #cdd6f4 +cursor-color = #f5e0dc +cursor-text = #1e1e2e +selection-background = #f5e0dc +selection-foreground = #1e1e2e diff --git a/assets/themes/dracula b/assets/themes/dracula new file mode 100644 index 0000000..341ec58 --- /dev/null +++ b/assets/themes/dracula @@ -0,0 +1,22 @@ +palette = 0=#21222c +palette = 1=#ff5555 +palette = 2=#50fa7b +palette = 3=#f1fa8c +palette = 4=#bd93f9 +palette = 5=#ff79c6 +palette = 6=#8be9fd +palette = 7=#f8f8f2 +palette = 8=#6272a4 +palette = 9=#ff6e6e +palette = 10=#69ff94 +palette = 11=#ffffa5 +palette = 12=#d6acff +palette = 13=#ff92df +palette = 14=#a4ffff +palette = 15=#ffffff +background = #282a36 +foreground = #f8f8f2 +cursor-color = #f8f8f2 +cursor-text = #282a36 +selection-background = #44475a +selection-foreground = #ffffff diff --git a/assets/themes/gruvbox-dark b/assets/themes/gruvbox-dark new file mode 100644 index 0000000..e5c63f1 --- /dev/null +++ b/assets/themes/gruvbox-dark @@ -0,0 +1,22 @@ +palette = 0=#282828 +palette = 1=#cc241d +palette = 2=#98971a +palette = 3=#d79921 +palette = 4=#458588 +palette = 5=#b16286 +palette = 6=#689d6a +palette = 7=#a89984 +palette = 8=#928374 +palette = 9=#fb4934 +palette = 10=#b8bb26 +palette = 11=#fabd2f +palette = 12=#83a598 +palette = 13=#d3869b +palette = 14=#8ec07c +palette = 15=#ebdbb2 +background = #282828 +foreground = #ebdbb2 +cursor-color = #ebdbb2 +cursor-text = #282828 +selection-background = #665c54 +selection-foreground = #ebdbb2 diff --git a/assets/themes/kanagawa-wave b/assets/themes/kanagawa-wave new file mode 100644 index 0000000..268b9b8 --- /dev/null +++ b/assets/themes/kanagawa-wave @@ -0,0 +1,22 @@ +palette = 0=#090618 +palette = 1=#c34043 +palette = 2=#76946a +palette = 3=#c0a36e +palette = 4=#7e9cd8 +palette = 5=#957fb8 +palette = 6=#6a9589 +palette = 7=#c8c093 +palette = 8=#727169 +palette = 9=#e82424 +palette = 10=#98bb6c +palette = 11=#e6c384 +palette = 12=#7fb4ca +palette = 13=#938aa9 +palette = 14=#7aa89f +palette = 15=#dcd7ba +background = #1f1f28 +foreground = #dcd7ba +cursor-color = #dcd7ba +cursor-text = #1f1f28 +selection-background = #dcd7ba +selection-foreground = #1f1f28 diff --git a/assets/themes/nord b/assets/themes/nord new file mode 100644 index 0000000..d83a55d --- /dev/null +++ b/assets/themes/nord @@ -0,0 +1,22 @@ +palette = 0=#3b4252 +palette = 1=#bf616a +palette = 2=#a3be8c +palette = 3=#ebcb8b +palette = 4=#81a1c1 +palette = 5=#b48ead +palette = 6=#88c0d0 +palette = 7=#e5e9f0 +palette = 8=#596377 +palette = 9=#bf616a +palette = 10=#a3be8c +palette = 11=#ebcb8b +palette = 12=#81a1c1 +palette = 13=#b48ead +palette = 14=#8fbcbb +palette = 15=#eceff4 +background = #2e3440 +foreground = #d8dee9 +cursor-color = #eceff4 +cursor-text = #282828 +selection-background = #eceff4 +selection-foreground = #4c566a diff --git a/assets/themes/one-light b/assets/themes/one-light new file mode 100644 index 0000000..5e0c617 --- /dev/null +++ b/assets/themes/one-light @@ -0,0 +1,22 @@ +palette = 0=#000000 +palette = 1=#de3e35 +palette = 2=#3f953a +palette = 3=#d2b67c +palette = 4=#2f5af3 +palette = 5=#950095 +palette = 6=#3f953a +palette = 7=#bbbbbb +palette = 8=#000000 +palette = 9=#de3e35 +palette = 10=#3f953a +palette = 11=#d2b67c +palette = 12=#2f5af3 +palette = 13=#a00095 +palette = 14=#3f953a +palette = 15=#ffffff +background = #f9f9f9 +foreground = #2a2c33 +cursor-color = #bbbbbb +cursor-text = #ffffff +selection-background = #d0dffc +selection-foreground = #2a2c33 diff --git a/assets/themes/rose-pine b/assets/themes/rose-pine new file mode 100644 index 0000000..64216a7 --- /dev/null +++ b/assets/themes/rose-pine @@ -0,0 +1,22 @@ +palette = 0=#26233a +palette = 1=#eb6f92 +palette = 2=#31748f +palette = 3=#f6c177 +palette = 4=#9ccfd8 +palette = 5=#c4a7e7 +palette = 6=#ebbcba +palette = 7=#e0def4 +palette = 8=#6e6a86 +palette = 9=#eb6f92 +palette = 10=#31748f +palette = 11=#f6c177 +palette = 12=#9ccfd8 +palette = 13=#c4a7e7 +palette = 14=#ebbcba +palette = 15=#e0def4 +background = #191724 +foreground = #e0def4 +cursor-color = #e0def4 +cursor-text = #191724 +selection-background = #403d52 +selection-foreground = #e0def4 diff --git a/assets/themes/solarized-light b/assets/themes/solarized-light new file mode 100644 index 0000000..f9fa8f7 --- /dev/null +++ b/assets/themes/solarized-light @@ -0,0 +1,22 @@ +palette = 0=#073642 +palette = 1=#dc322f +palette = 2=#859900 +palette = 3=#b58900 +palette = 4=#268bd2 +palette = 5=#d33682 +palette = 6=#2aa198 +palette = 7=#bbb5a2 +palette = 8=#002b36 +palette = 9=#cb4b16 +palette = 10=#586e75 +palette = 11=#657b83 +palette = 12=#839496 +palette = 13=#6c71c4 +palette = 14=#93a1a1 +palette = 15=#fdf6e3 +background = #fdf6e3 +foreground = #657b83 +cursor-color = #657b83 +cursor-text = #eee8d5 +selection-background = #eee8d5 +selection-foreground = #586e75 diff --git a/assets/themes/tokyo-night b/assets/themes/tokyo-night new file mode 100644 index 0000000..9406133 --- /dev/null +++ b/assets/themes/tokyo-night @@ -0,0 +1,22 @@ +palette = 0=#15161e +palette = 1=#f7768e +palette = 2=#9ece6a +palette = 3=#e0af68 +palette = 4=#7aa2f7 +palette = 5=#bb9af7 +palette = 6=#7dcfff +palette = 7=#a9b1d6 +palette = 8=#414868 +palette = 9=#f7768e +palette = 10=#9ece6a +palette = 11=#e0af68 +palette = 12=#7aa2f7 +palette = 13=#bb9af7 +palette = 14=#7dcfff +palette = 15=#c0caf5 +background = #1a1b26 +foreground = #c0caf5 +cursor-color = #c0caf5 +cursor-text = #15161e +selection-background = #33467c +selection-foreground = #c0caf5 diff --git a/src/lib.rs b/src/lib.rs index 6e98cef..1100627 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,2 @@ pub mod settings; +pub mod themes; diff --git a/src/main.rs b/src/main.rs index 265b981..969c9e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,5 @@ -use ely_gpui_component::{ - Assets, - primitives::FocusScope, - theme::{ActiveTheme, Mode, Theme}, -}; -use elyterminal::settings::SettingsStore; +use ely_gpui_component::{Assets, primitives::FocusScope, theme::ActiveTheme}; +use elyterminal::{settings::SettingsStore, themes}; use gpui::{ App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions, Window, WindowBounds, WindowOptions, px, size, @@ -42,7 +38,7 @@ fn main() { log::error!("{error:#}"); std::process::exit(1); } - Theme::set_mode_now(Mode::Dark, cx); + themes::apply(cx); let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), @@ -54,6 +50,9 @@ fn main() { }; cx.open_window(options, |window, cx| { cx.new(|cx| { + window + .observe_window_appearance(|_, cx| themes::apply(cx)) + .detach(); let focus = cx.focus_handle(); window.focus(&focus, cx); Workspace { focus } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 03689d3..80d0242 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -4,6 +4,8 @@ use std::ops::RangeInclusive; use serde::{Deserialize, Serialize}; +use crate::themes; + pub use store::{SettingsStore, load, save}; /// Where a new session starts. @@ -107,7 +109,7 @@ pub const PADDING: RangeInclusive = 0.0..=64.0; pub const SCROLLBACK: RangeInclusive = 0..=100_000; impl Settings { - /// Every value inside its range, every name filled; the error names the field. + /// Every value inside its range, every theme built in; the error names the field. pub fn validate(&self) -> anyhow::Result<()> { within("font_size", self.font_size, &FONT_SIZE)?; within("line_height", self.line_height, &LINE_HEIGHT)?; @@ -115,11 +117,17 @@ impl Settings { within("padding_x", self.padding_x, &PADDING)?; within("padding_y", self.padding_y, &PADDING)?; within("scrollback", self.scrollback, &SCROLLBACK)?; - anyhow::ensure!(!self.theme.is_empty(), "theme is empty"); - anyhow::ensure!( - self.dark_theme.as_ref().is_none_or(|name| !name.is_empty()), - "dark_theme is empty" - ); + for (field, id) in [ + ("theme", Some(&self.theme)), + ("dark_theme", self.dark_theme.as_ref()), + ] { + if let Some(id) = id { + anyhow::ensure!( + themes::find(id).is_some(), + "{field} is {id:?}, no built-in theme" + ); + } + } anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty"); Ok(()) } @@ -177,14 +185,22 @@ mod tests { } #[test] - fn an_empty_name_fails() { + fn an_unknown_theme_fails() { let settings = Settings { dark_theme: Some(String::new()), ..Settings::default() }; assert_eq!( settings.validate().expect_err("empty").to_string(), - "dark_theme is empty" + "dark_theme is \"\", no built-in theme" + ); + let settings = Settings { + theme: "mocha".into(), + ..Settings::default() + }; + assert_eq!( + settings.validate().expect_err("unknown").to_string(), + "theme is \"mocha\", no built-in theme" ); } } diff --git a/src/themes/chrome.rs b/src/themes/chrome.rs new file mode 100644 index 0000000..40727b5 --- /dev/null +++ b/src/themes/chrome.rs @@ -0,0 +1,84 @@ +use ely_gpui_component::theme::{Mix, Mode, Palette, Syntax, Theme}; +use gpui::App; + +use super::{TerminalTheme, active}; +use crate::settings::SettingsStore; + +/// The chrome's palette for a terminal theme; none for Ely's own, which Ely draws. +pub fn palette(theme: &TerminalTheme) -> Option { + if theme.ely { + return None; + } + let (bg, fg, ansi) = (theme.background, theme.foreground, theme.ansi); + let base = if theme.is_dark() { + Palette::dark(false) + } else { + Palette::light(false) + }; + let tint = |share: f32| bg.mix(&fg, share); + let wash = |color: gpui::Hsla| bg.mix(&color, 0.18); + Some(Palette { + bg, + surface: bg, + sunken: tint(0.03), + overlay: tint(0.07), + hover: tint(0.1), + active: tint(0.15), + border: tint(0.14), + border_strong: tint(0.26), + fg, + fg_muted: tint(0.8), + fg_subtle: tint(0.62), + fg_disabled: tint(0.36), + accent: fg, + accent_hover: tint(0.85), + on_accent: bg, + focus: ansi[4], + link: ansi[12], + selection: theme.selection_background.alpha(0.4), + success: ansi[2], + warning: ansi[3], + danger: ansi[1], + info: ansi[4], + success_subtle: wash(ansi[2]), + warning_subtle: wash(ansi[3]), + danger_subtle: wash(ansi[1]), + info_subtle: wash(ansi[4]), + glass: bg.alpha(base.glass.a), + tooltip_bg: tint(0.2), + tooltip_fg: fg, + chart: [ + ansi[4], ansi[6], ansi[3], ansi[5], ansi[2], ansi[1], ansi[12], ansi[14], + ], + ansi, + syntax: Syntax { + keyword: ansi[5], + string: ansi[2], + number: ansi[3], + comment: tint(0.62), + function: ansi[4], + type_name: ansi[6], + constant: ansi[3], + property: ansi[1], + tag: ansi[1], + attribute: ansi[3], + operator: tint(0.8), + punctuation: tint(0.8), + variable: fg, + }, + ..base + }) +} + +/// Dresses the app in the theme the settings choose for the system's appearance. +pub fn apply(cx: &mut App) { + let theme = active(SettingsStore::get(cx), cx.window_appearance()); + let mode = if theme.is_dark() { + Mode::Dark + } else { + Mode::Light + }; + Theme::set_palette(mode, palette(theme), cx); + Theme::set_mode_now(mode, cx); + log::info!("theme: {} in {mode:?}", theme.id); +} diff --git a/src/themes/mod.rs b/src/themes/mod.rs new file mode 100644 index 0000000..1d1f5ee --- /dev/null +++ b/src/themes/mod.rs @@ -0,0 +1,204 @@ +mod chrome; + +use std::sync::LazyLock; + +use anyhow::Context as _; +use ely_gpui_component::theme::Palette; +use gpui::{Hsla, Rgba, WindowAppearance}; + +pub use chrome::{apply, palette}; + +use crate::settings::Settings; + +/// What a terminal paints with. Ely's own themes keep Ely's palette for the chrome. +#[derive(Clone, Debug, PartialEq)] +pub struct TerminalTheme { + pub id: &'static str, + pub name: &'static str, + pub background: Hsla, + pub foreground: Hsla, + pub cursor: Hsla, + pub cursor_text: Hsla, + pub selection_background: Hsla, + /// None keeps each cell's own color. + pub selection_foreground: Option, + pub ansi: [Hsla; 16], + pub ely: bool, +} + +impl TerminalTheme { + pub fn is_dark(&self) -> bool { + self.background.l < 0.5 + } +} + +/// Upstream Ghostty files from iTerm2-Color-Schemes: id, shown name, text. +const FILES: [(&str, &str, &str); 12] = [ + ( + "catppuccin-mocha", + "Catppuccin Mocha", + include_str!("../../assets/themes/catppuccin-mocha"), + ), + ( + "catppuccin-macchiato", + "Catppuccin Macchiato", + include_str!("../../assets/themes/catppuccin-macchiato"), + ), + ( + "catppuccin-frappe", + "Catppuccin Frappé", + include_str!("../../assets/themes/catppuccin-frappe"), + ), + ( + "catppuccin-latte", + "Catppuccin Latte", + include_str!("../../assets/themes/catppuccin-latte"), + ), + ( + "tokyo-night", + "Tokyo Night", + include_str!("../../assets/themes/tokyo-night"), + ), + ( + "dracula", + "Dracula", + include_str!("../../assets/themes/dracula"), + ), + ("nord", "Nord", include_str!("../../assets/themes/nord")), + ( + "gruvbox-dark", + "Gruvbox Dark", + include_str!("../../assets/themes/gruvbox-dark"), + ), + ( + "rose-pine", + "Rosé Pine", + include_str!("../../assets/themes/rose-pine"), + ), + ( + "solarized-light", + "Solarized Light", + include_str!("../../assets/themes/solarized-light"), + ), + ( + "one-light", + "One Light", + include_str!("../../assets/themes/one-light"), + ), + ( + "kanagawa-wave", + "Kanagawa Wave", + include_str!("../../assets/themes/kanagawa-wave"), + ), +]; + +static THEMES: LazyLock> = LazyLock::new(|| { + let mut themes = vec![ + ely("ely-dark", "Ely Dark", Palette::dark(false)), + ely("ely-light", "Ely Light", Palette::light(false)), + ]; + themes.extend(FILES.iter().map(|(id, name, text)| { + parse(id, name, text).unwrap_or_else(|error| panic!("bundled theme {id}: {error:#}")) + })); + themes +}); + +/// Every built-in theme, Ely's first. +pub fn all() -> &'static [TerminalTheme] { + &THEMES +} + +pub fn find(id: &str) -> Option<&'static TerminalTheme> { + all().iter().find(|theme| theme.id == id) +} + +/// The theme the settings choose under the system's appearance. +pub fn active(settings: &Settings, appearance: WindowAppearance) -> &'static TerminalTheme { + let dark = matches!( + appearance, + WindowAppearance::Dark | WindowAppearance::VibrantDark + ); + let id = match (&settings.dark_theme, dark) { + (Some(id), true) => id, + _ => &settings.theme, + }; + find(id).unwrap_or_else(|| panic!("settings name theme {id}, which validation admits")) +} + +fn ely(id: &'static str, name: &'static str, palette: Palette) -> TerminalTheme { + TerminalTheme { + id, + name, + background: palette.bg, + foreground: palette.fg, + cursor: palette.focus, + cursor_text: palette.bg, + selection_background: palette.selection, + selection_foreground: None, + ansi: palette.ansi, + ely: true, + } +} + +/// Reads Ghostty's `key = value` theme format; every key it needs must be there. +fn parse(id: &'static str, name: &'static str, text: &str) -> anyhow::Result { + let mut ansi: [Option; 16] = [None; 16]; + let mut keys: Vec<(&str, Hsla)> = Vec::new(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + let (key, value) = line + .split_once('=') + .with_context(|| format!("no `=` in `{line}`"))?; + let (key, value) = (key.trim(), value.trim()); + if key == "palette" { + let (index, color) = value + .split_once('=') + .with_context(|| format!("no index in `{line}`"))?; + let index: usize = index.trim().parse().with_context(|| format!("`{line}`"))?; + anyhow::ensure!(index < 16, "palette index {index} past 15"); + ansi[index] = Some(hex(color.trim())?); + } else { + keys.push((key, hex(value)?)); + } + } + let get = |key: &str| { + keys.iter() + .find(|(name, _)| *name == key) + .map(|(_, color)| *color) + .with_context(|| format!("no `{key}`")) + }; + let mut colors = [Hsla::default(); 16]; + for (index, color) in ansi.iter().enumerate() { + colors[index] = color.with_context(|| format!("no palette {index}"))?; + } + Ok(TerminalTheme { + id, + name, + background: get("background")?, + foreground: get("foreground")?, + cursor: get("cursor-color")?, + cursor_text: get("cursor-text")?, + selection_background: get("selection-background")?, + selection_foreground: Some(get("selection-foreground")?), + ansi: colors, + ely: false, + }) +} + +fn hex(text: &str) -> anyhow::Result { + let digits = text + .strip_prefix('#') + .with_context(|| format!("`{text}` is not #rrggbb"))?; + anyhow::ensure!(digits.len() == 6, "`{text}` is not #rrggbb"); + let value = u32::from_str_radix(digits, 16).with_context(|| format!("`{text}`"))?; + let channel = |shift: u32| ((value >> shift) & 0xff) as f32 / 255.0; + Ok(Rgba { + r: channel(16), + g: channel(8), + b: channel(0), + a: 1.0, + } + .into()) +} + +#[cfg(test)] +mod tests; diff --git a/src/themes/tests.rs b/src/themes/tests.rs new file mode 100644 index 0000000..2f4dd7a --- /dev/null +++ b/src/themes/tests.rs @@ -0,0 +1,124 @@ +use gpui::{Rgba, WindowAppearance}; + +use super::*; + +fn rgb(hex: u32) -> Hsla { + let channel = |shift: u32| ((hex >> shift) & 0xff) as f32 / 255.0; + Rgba { + r: channel(16), + g: channel(8), + b: channel(0), + a: 1.0, + } + .into() +} + +#[test] +fn every_bundled_file_parses_with_unique_ids() { + assert_eq!(all().len(), 14); + let mut ids: Vec<_> = all().iter().map(|theme| theme.id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 14); + let folder = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/themes"); + for entry in std::fs::read_dir(folder).expect("themes folder") { + let name = entry + .expect("entry") + .file_name() + .into_string() + .expect("utf-8"); + if name != "LICENSE" { + assert!(find(&name).is_some(), "{name} is bundled but not listed"); + } + } +} + +#[test] +fn catppuccin_mocha_reads_its_upstream_colors() { + let mocha = find("catppuccin-mocha").expect("mocha"); + assert_eq!(mocha.background, rgb(0x1e1e2e)); + assert_eq!(mocha.foreground, rgb(0xcdd6f4)); + assert_eq!(mocha.cursor, rgb(0xf5e0dc)); + assert_eq!(mocha.ansi[1], rgb(0xf38ba8)); + assert_eq!(mocha.ansi[15], rgb(0xa6adc8)); + assert_eq!(mocha.selection_foreground, Some(rgb(0x1e1e2e))); +} + +#[test] +fn light_and_dark_follow_the_background() { + let light: Vec<_> = all() + .iter() + .filter(|theme| !theme.is_dark()) + .map(|theme| theme.id) + .collect(); + assert_eq!( + light, + [ + "ely-light", + "catppuccin-latte", + "solarized-light", + "one-light" + ] + ); +} + +#[test] +fn a_missing_key_or_bad_color_names_it() { + let text = include_str!("../../assets/themes/nord"); + let cut: String = text + .lines() + .filter(|line| !line.starts_with("cursor-text")) + .collect::>() + .join("\n"); + let error = parse("x", "X", &cut).expect_err("no cursor-text"); + assert_eq!(error.to_string(), "no `cursor-text`"); + let cut: String = text + .lines() + .filter(|line| !line.starts_with("palette = 7=")) + .collect::>() + .join("\n"); + assert_eq!( + parse("x", "X", &cut).expect_err("no 7").to_string(), + "no palette 7" + ); + let bad = text.replace("background = #", "background = "); + assert!(parse("x", "X", &bad).is_err()); + assert!(parse("x", "X", "palette = 16=#000000").is_err()); +} + +#[test] +fn the_dark_theme_wins_only_in_dark_mode() { + let settings = Settings { + theme: "catppuccin-latte".into(), + dark_theme: Some("catppuccin-mocha".into()), + ..Settings::default() + }; + assert_eq!( + active(&settings, WindowAppearance::Light).id, + "catppuccin-latte" + ); + assert_eq!( + active(&settings, WindowAppearance::VibrantDark).id, + "catppuccin-mocha" + ); + let single = Settings { + dark_theme: None, + ..settings + }; + assert_eq!( + active(&single, WindowAppearance::Dark).id, + "catppuccin-latte" + ); +} + +#[test] +fn the_chrome_wears_the_theme() { + let dracula = find("dracula").expect("dracula"); + let chrome = palette(dracula).expect("a mapped palette"); + assert_eq!(chrome.bg, dracula.background); + assert_eq!(chrome.fg, dracula.foreground); + assert_eq!(chrome.ansi, dracula.ansi); + assert_eq!(chrome.danger, dracula.ansi[1]); + assert!(chrome.glass.a < 1.0); + assert!(palette(find("ely-dark").expect("ely")).is_none()); +}