T03: built-in themes, chrome palette, dark-mode choice
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Successful in 9m33s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 11:44:00 -04:00
1 parent 653f654b42
commit 7d4d53547e
20 files changed
+734 -15

No files matched your search

+1
View File
@@ -1 +1,2 @@
pub mod settings;
pub mod themes;
+6 -7
View File
@@ -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 }
+24 -8
View File
@@ -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<f32> = 0.0..=64.0;
pub const SCROLLBACK: RangeInclusive<u32> = 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"
);
}
}
+84
View File
@@ -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<Palette> {
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);
}
+204
View File
@@ -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<Hsla>,
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<Vec<TerminalTheme>> = 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<TerminalTheme> {
let mut ansi: [Option<Hsla>; 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<Hsla> {
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;
+124
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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());
}