diff --git a/CLAUDE.md b/CLAUDE.md index 46e9aa0..771c993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 +- **终端主题**:「应用外观」(浅色 / 深色 / 跟随系统)照旧管界面;终端另有主题,在「外观 › 终端主题」左右两栏里浅色、深色各选一套,按当前外观生效。 + - 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。 + - 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。 - **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。 - 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。 - 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。 @@ -253,7 +256,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。 - **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。 - **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。 -- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 +- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。 +- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 - **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。 - **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。 - 不用 alacritty 的 `RegexSearch`(查找、链接用的那套):它的 DFA 编不了 `\b`,`^` 只在第一行生效,`$` 会碰到行尾补的空格。 diff --git a/docs/manual.md b/docs/manual.md index b5b2c44..25a424d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -104,6 +104,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。 - **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。 +- **外观 → 终端主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时终端用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,底色与界面一致。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。** - **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 - **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。 - **外部 CLI**:见下一节。 diff --git a/src/settings/apply.rs b/src/settings/apply.rs index eb0de5f..3dfbd34 100644 --- a/src/settings/apply.rs +++ b/src/settings/apply.rs @@ -2,13 +2,13 @@ use gpui_kit::component::{ActiveTheme as _, Theme}; use gpui_kit::*; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; -use crate::terminal::{TerminalFont, TerminalHighlights, is_font_installed}; +use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed}; use super::AppSettings; -/// Bring the window in line with the settings: the theme, the locale, the -/// terminal font and highlight rules, the SFTP size format and whether SFTP -/// shows hidden files. +/// Bring the window in line with the settings: the theme and the terminal +/// theme for it, the locale, the terminal font and highlight rules, the SFTP +/// size format and whether SFTP shows hidden files. /// Does nothing where they already agree, so it can run on every settings /// change and every change of the system appearance. pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { @@ -19,6 +19,14 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { crate::app::deepen_list_hover(cx); } + // Here too, not only on a settings change: following the system, the + // terminals change theme when the appearance does. + let colors = TerminalColors::new(settings.terminal_theme.theme(mode)); + if cx.try_global::() != Some(&colors) { + cx.set_global(colors); + window.refresh(); + } + let locale = settings .language .locale(sys_locale::get_locale().as_deref()); diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 250e988..ac8e9a7 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -5,11 +5,12 @@ mod highlight_rules; mod model; mod settings_panel; mod store; +mod terminal_themes; pub use apply::apply; pub use model::{ AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings, - TerminalFontSettings, TerminalHighlightSettings, UpdateSettings, + TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings, UpdateSettings, }; pub use settings_panel::{SettingsPanel, SettingsPanelEvent}; pub use store::{SettingsStore, SettingsStoreEvent}; diff --git a/src/settings/model.rs b/src/settings/model.rs index 45e7a55..36103b7 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; use crate::terminal::{ DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE, - default_rules, + TerminalTheme, default_rules, }; use crate::update::Channel; @@ -17,6 +17,8 @@ use crate::update::Channel; pub struct AppSettings { pub language: InterfaceLanguage, pub appearance: Appearance, + /// 外观 → 终端主题. + pub terminal_theme: TerminalThemeSettings, pub terminal_font: TerminalFontSettings, pub external_cli: ExternalCliSettings, pub update: UpdateSettings, @@ -32,6 +34,48 @@ pub struct AppSettings { pub terminal_highlight: TerminalHighlightSettings, } +/// 外观 → 终端主题: by key, the theme terminals use while the app is light +/// and the one while it is dark. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TerminalThemeSettings { + pub light: String, + pub dark: String, +} + +impl Default for TerminalThemeSettings { + fn default() -> Self { + Self { + light: TerminalTheme::default_for(ThemeMode::Light).key().into(), + dark: TerminalTheme::default_for(ThemeMode::Dark).key().into(), + } + } +} + +impl TerminalThemeSettings { + /// The theme for `mode`. A key this version does not know (from a newer + /// one, or a typo), or one of a theme for the other appearance, gives + /// way to the default rather than failing the file. + pub fn theme(&self, mode: ThemeMode) -> &'static TerminalTheme { + let key = match mode { + ThemeMode::Light => &self.light, + ThemeMode::Dark => &self.dark, + }; + TerminalTheme::find(key) + .filter(|theme| theme.mode() == mode) + .unwrap_or_else(|| TerminalTheme::default_for(mode)) + } + + /// Use `theme` for the appearance it is made for. + pub fn choose(&mut self, theme: &TerminalTheme) { + let key = theme.key().to_string(); + match theme.mode() { + ThemeMode::Light => self.light = key, + ThemeMode::Dark => self.dark = key, + } + } +} + /// 关键字高亮: whether terminals color their text by the rules, and the /// rules, in the order they win where they overlap. Off until the user turns /// it on. A file without the rules gets the examples a new installation @@ -403,6 +447,48 @@ mod tests { assert_eq!(rules[1], HighlightRule::default()); } + #[test] + fn terminal_themes_fall_back_to_the_default_for_their_appearance() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + let themes = &settings.terminal_theme; + assert_eq!(themes.light, "shellrs-light"); + assert_eq!(themes.dark, "shellrs-dark"); + for mode in [ThemeMode::Light, ThemeMode::Dark] { + assert_eq!(themes.theme(mode), TerminalTheme::default_for(mode)); + } + + // A key from a newer version, and a dark theme where a light one + // belongs, each give way on their own side only. + let settings: AppSettings = serde_json::from_str( + r#"{"appearance":"dark","terminal_theme":{"light":"shellrs-dark","dark":"sepia"}}"#, + ) + .unwrap(); + assert_eq!(settings.appearance, Appearance::Dark); + let themes = &settings.terminal_theme; + assert_eq!( + themes.theme(ThemeMode::Light), + TerminalTheme::default_for(ThemeMode::Light) + ); + assert_eq!( + themes.theme(ThemeMode::Dark), + TerminalTheme::default_for(ThemeMode::Dark) + ); + + // Choosing a theme sets the side it is made for, by key. + let mut themes = TerminalThemeSettings::default(); + let dark = TerminalTheme::for_mode(ThemeMode::Dark).last().unwrap(); + themes.choose(dark); + assert_eq!(themes.theme(ThemeMode::Dark), dark); + assert_eq!( + themes.theme(ThemeMode::Light), + TerminalTheme::default_for(ThemeMode::Light) + ); + assert_eq!( + serde_json::to_value(&themes).unwrap(), + serde_json::json!({"light": "shellrs-light", "dark": dark.key()}) + ); + } + #[test] fn automatic_updates_are_on_unless_the_file_says_otherwise() { let settings: AppSettings = serde_json::from_str("{}").unwrap(); diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index f1f8ffd..94bd7aa 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -22,12 +22,13 @@ use crate::app::{ use crate::cli::{AgentKind, BinaryStatus, CliIntegration, IntegrationStatus, SkillStatus}; use crate::shared::{ClosableTabTitle, close_tab_items}; use crate::terminal::{ - FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont, TerminalFontPreview, TerminalHighlights, - is_font_installed, monospace_font_families, + FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalColors, TerminalFont, TerminalFontPreview, + TerminalHighlights, is_font_installed, monospace_font_families, }; use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform}; use super::highlight_rules::HighlightRulesEditor; +use super::terminal_themes::terminal_theme_item; use super::{AppSettings, Choice, NotificationSettings, SettingsStore}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -266,6 +267,10 @@ fn appearance_groups(panel: &SettingsPanel, _: &App) -> Vec { ), ), ]), + SettingGroup::new() + .title("终端主题") + .description("浅色外观时终端用左栏选中的主题,深色外观时用右栏的。") + .items([terminal_theme_item(store)]), ] } @@ -317,7 +322,7 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec { .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) - .bg(cx.theme().background) + .bg(TerminalColors::current(cx).background()) .overflow_hidden() .child(TerminalFontPreview::new( "terminal-font-preview", @@ -428,19 +433,22 @@ fn highlight_groups(panel: &SettingsPanel, cx: &App) -> Vec { ] } -/// The preview: sample output in the terminal's font and colors, colored by +/// The preview: sample output in the terminal's font and theme, colored by /// the rules in effect, as a terminal would show it. The group's outline is -/// its frame. +/// its frame; the theme's background is set in from it. fn highlight_preview_item() -> SettingItem { SettingItem::render(|_, window, cx| { let rules = TerminalHighlights::current(cx); let font = TerminalFont::current(cx); + let colors = TerminalColors::current(cx); div() .id("highlight-preview") .test_support() .w_full() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) + .p_3() + .rounded(cx.theme().radius) + .bg(colors.background()) + .text_color(colors.foreground()) .font_family(font.family(cx)) .text_size(font.size) .line_height(font.row_height(window)) diff --git a/src/settings/terminal_themes.rs b/src/settings/terminal_themes.rs new file mode 100644 index 0000000..02723c8 --- /dev/null +++ b/src/settings/terminal_themes.rs @@ -0,0 +1,226 @@ +//! 外观 → 终端主题: the light themes and the dark ones side by side, a card +//! each showing a few lines of a shell in its colors, the one in use marked. +//! Choosing a card writes the settings at once; which column is in effect +//! follows the app's appearance. + +use gpui_kit::base::{Radio, RadioGroup}; +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, ThemeMode, h_flex, + scroll::ScrollableElement as _, setting::SettingItem, v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::terminal::{TerminalFont, TerminalTheme}; + +use super::{SettingsStore, TerminalThemeSettings}; + +/// How tall each column's list is, in rems: about three cards, the third +/// cut off so the list shows that it scrolls. +const LIST_HEIGHT: f32 = 24.; + +/// What a card shows: a prompt and `ls -l`, as Debian colors them, in the +/// theme's green, blue and cyan. Each part names the ANSI color it is +/// drawn in, or the theme's text color. +const SAMPLE: &[&[(&str, Option)]] = &[ + &[ + ("root@shellrs", Some(2)), + (":", None), + ("~", Some(4)), + ("$ ls -l", None), + ], + &[("drwxr-xr-x 2 root ", None), ("boot", Some(4))], + &[ + ("lrwxrwxrwx 1 root ", None), + ("bin", Some(6)), + (" -> usr/bin", None), + ], +]; + +/// The group's one item: both columns, and the page's 重置 putting both +/// back to the defaults. +pub(super) fn terminal_theme_item(store: &Entity) -> SettingItem { + let (reader, dirty, reset) = (store.clone(), store.clone(), store.clone()); + SettingItem::render(move |_, _, cx| { + let chosen = reader.read(cx).settings().terminal_theme; + h_flex() + .w_full() + .items_start() + .gap_4() + .child(column(ThemeMode::Light, &chosen, &reader, cx)) + .child(column(ThemeMode::Dark, &chosen, &reader, cx)) + }) + .keywords( + ["终端主题", "主题", "配色", "颜色", "浅色", "深色"] + .into_iter() + .chain(TerminalTheme::all().iter().map(TerminalTheme::name)), + ) + .on_reset( + move |cx| dirty.read(cx).settings().terminal_theme != TerminalThemeSettings::default(), + move |_, cx| { + reset.update(cx, |store, cx| { + store.update(|settings| settings.terminal_theme = Default::default(), cx) + }); + }, + ) +} + +/// One appearance's themes under its name, scrolling on their own. +fn column( + mode: ThemeMode, + chosen: &TerminalThemeSettings, + store: &Entity, + cx: &App, +) -> impl IntoElement { + let title = match mode { + ThemeMode::Light => "浅色", + ThemeMode::Dark => "深色", + }; + let chosen = chosen.theme(mode); + let themes: Vec<_> = TerminalTheme::for_mode(mode).collect(); + let total = themes.len(); + v_flex() + .flex_1() + .min_w_0() + .gap_2() + .child(div().text_sm().font_medium().child(title)) + .child( + div() + .id(column_id("terminal-themes", mode)) + .w_full() + .h(rems(LIST_HEIGHT)) + .child( + RadioGroup::new(column_id("terminal-theme-group", mode)) + .axis(Axis::Vertical) + .flex() + .flex_col() + .gap_1() + // The scrollbar's lane, so it never covers a card's + // check mark. + .pr_3() + .children(themes.into_iter().enumerate().map(|(ix, theme)| { + card(theme, theme == chosen, (ix + 1, total), store, cx) + })), + ) + .overflow_y_scrollbar() + .id(column_id("terminal-themes-scroll", mode)), + ) +} + +/// `prefix-light` or `prefix-dark`. +fn column_id(prefix: &str, mode: ThemeMode) -> ElementId { + SharedString::from(format!("{prefix}-{}", mode.name())).into() +} + +/// A theme's name over its sample. The chosen one stands out like a chosen +/// segment and carries the check mark. +fn card( + theme: &'static TerminalTheme, + checked: bool, + (position, total): (usize, usize), + store: &Entity, + cx: &App, +) -> impl IntoElement { + let colors = cx.theme(); + let store = store.clone(); + Radio::new(SharedString::from(format!( + "terminal-theme-{}", + theme.key() + ))) + .checked(checked) + .accessibility_label(theme.name()) + .set_position(position, total) + .w_full() + .flex() + .flex_col() + .gap_2() + .p_3() + .rounded(colors.radius_lg) + // Every card has the border, so choosing one moves nothing. + .border_1() + .border_color(colors.transparent) + .styles(|styles| styles.checked(|style| style.bg(colors.accent))) + // Half of what choosing it would show. + .when(!checked, |radio| { + radio.hover(|style| style.bg(colors.accent.opacity(0.5))) + }) + .focus_visible(|style| style.border_color(colors.ring)) + // A click chooses without taking the keyboard from where it was, + // as gpui-component's radios do. + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_change(move |_, _, _, cx| { + store.update(cx, |store, cx| { + store.update(|settings| settings.terminal_theme.choose(theme), cx) + }); + }) + .child( + h_flex() + .w_full() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .child(theme.name()), + ) + // A fixed slot, so the name does not move with the mark: a disc + // in the theme's blue, the hue of selection, the check in the + // page's color so it shows on the disc in light and dark. + .child( + div() + .flex_shrink_0() + .size_5() + .flex() + .items_center() + .justify_center() + .rounded(colors.radius_full()) + .when(checked, |slot| { + slot.bg(colors.blue).child( + Icon::new(IconName::Check) + .xsmall() + .text_color(colors.background), + ) + }), + ), + ) + .child(sample(theme, cx)) +} + +/// The sample lines in the terminal font and the theme's colors, framed by +/// a hairline so a background like the page's still shows its edge. +fn sample(theme: &'static TerminalTheme, cx: &App) -> impl IntoElement { + let font = TerminalFont::current(cx); + v_flex() + .w_full() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(theme.background()) + .text_color(theme.foreground()) + .font_family(font.family(cx)) + .text_sm() + .overflow_hidden() + .children(SAMPLE.iter().map(|parts| { + let mut text = String::new(); + let mut highlights = Vec::new(); + for (part, color) in parts.iter() { + if let Some(color) = color { + highlights.push(( + text.len()..text.len() + part.len(), + HighlightStyle { + color: Some(theme.indexed(*color)), + ..HighlightStyle::default() + }, + )); + } + text.push_str(part); + } + div() + .whitespace_nowrap() + .child(StyledText::new(text).with_highlights(highlights)) + })) +} diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index d32d31a..836ca5c 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -30,8 +30,8 @@ use super::links::{LinkMarker, url_search, visible_links}; use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice}; use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; use super::{ - ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, - TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, + ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalColors, TerminalLifecycle, + TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, }; pub type AlacrittyTerm = Term; @@ -208,8 +208,9 @@ impl TerminalEngine { return Some(TerminalEvent::HostOsDetected(os)); } TerminalUiEventKind::ColorRequest(index, formatter) => { + // A color the program set itself, or else the theme's. let color = self.runtime.term.lock().colors()[index] - .unwrap_or_else(|| default_query_color(index)); + .unwrap_or_else(|| TerminalColors::current(cx).query_color(index)); self.runtime.write(formatter(color).into_bytes()); } TerminalUiEventKind::ClipboardStore(text) => { @@ -978,22 +979,6 @@ fn sanitize_title(title: &str) -> Option { (!title.trim().is_empty()).then_some(title) } -fn default_query_color(index: usize) -> Rgb { - if index == alacritty_terminal::vte::ansi::NamedColor::Background as usize { - Rgb { - r: 30, - g: 30, - b: 30, - } - } else { - Rgb { - r: 224, - g: 224, - b: 224, - } - } -} - fn encode_paste(text: &str, mode: TermMode) -> Vec { let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); if mode.contains(TermMode::BRACKETED_PASTE) { diff --git a/src/terminal/font.rs b/src/terminal/font.rs index f0de4d7..38ed41e 100644 --- a/src/terminal/font.rs +++ b/src/terminal/font.rs @@ -8,6 +8,8 @@ use gpui_kit::component::ActiveTheme as _; use gpui_kit::*; use unicode_width::UnicodeWidthChar as _; +use super::TerminalColors; + /// The size terminals had before it could be set: the theme's `mono_md`. pub const DEFAULT_FONT_SIZE: f32 = 13.; /// The rows of 20 px that terminals had at the default size. @@ -122,7 +124,7 @@ impl RenderOnce for TerminalFontPreview { let family = font.family(cx); let row_height = font.row_height(window); let lines = self.lines; - let color = cx.theme().foreground; + let color = TerminalColors::current(cx).foreground(); div() .id(self.id) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 51c0f0c..9609059 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -13,6 +13,7 @@ mod notices; mod search; mod terminal_panel; mod terminal_view; +mod theme; mod transport; pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot}; @@ -36,6 +37,7 @@ pub(crate) use terminal_view::terminal_key_bindings; pub use terminal_view::{ TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, TerminalMenuItems, TerminalView, }; +pub use theme::{TerminalColors, TerminalTheme}; pub(crate) use transport::send_event; pub use transport::{ ExecRequest, ExecResult, FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider, diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index c1bdc23..cf06f25 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -30,8 +30,8 @@ use crate::connection::{ConnectionPromptReply, Latency}; use super::mouse::{self, MouseReport, ReportButton, ReportKind}; use super::search::SearchMark; use super::{ - SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, TerminalFont, - TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, + SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent, + TerminalFont, TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, TerminalTheme, }; pub const TERMINAL_KEY_CONTEXT: &str = "Terminal"; @@ -960,6 +960,9 @@ impl Render for TerminalView { .relative() .size_full() .p_2() + // The margin and the part of a cell the grid does not fill are + // the terminal's too. + .bg(TerminalColors::current(cx).background()) .child(terminal) .when_some(self.find.as_ref(), |container, find| { container.child(self.render_find_bar(find, cx)) @@ -1205,7 +1208,7 @@ impl Element for TerminalElement { &self.snapshot.colors, ); let cell_background = if cell.selected { - cx.theme().selection + palette.selection } else { match cell.search { SearchMark::Focused => cx.theme().warning, @@ -1241,15 +1244,10 @@ impl Element for TerminalElement { } }); // Links always show as links, blue whatever color the - // program gave them. Not the theme's `link`: that is the - // color of text. - let link = cell.link.map(|link| { - if hovered_link == Some(link) { - cx.theme().blue_light - } else { - cx.theme().blue - } - }); + // program gave them. + let link = cell + .link + .map(|link| palette.link(hovered_link == Some(link))); // A rule colors a link's text too; the underline still says // it is one. let text_color = highlight.or(link).unwrap_or(foreground); @@ -1333,7 +1331,7 @@ impl Element for TerminalElement { text_style.font(), font_size, &palette, - cx.theme().selection, + palette.selection, window, ); let cursor = self @@ -1865,21 +1863,33 @@ fn grid_point( ) } +/// The colors of the terminal theme in effect, for one frame. struct TerminalPalette { + theme: &'static TerminalTheme, foreground: Hsla, background: Hsla, cursor: Hsla, + selection: Hsla, } impl TerminalPalette { fn new(cx: &App) -> Self { + let theme = TerminalColors::current(cx); Self { - foreground: cx.theme().foreground, - background: cx.theme().background, - cursor: cx.theme().primary, + theme, + foreground: theme.foreground(), + background: theme.background(), + cursor: theme.cursor(), + selection: theme.selection(), } } + /// A link's color, and a hovered link's: the theme's blue and bright + /// blue, so they read on its background. + fn link(&self, hovered: bool) -> Hsla { + self.theme.indexed(if hovered { 12 } else { 4 }) + } + fn cell_colors( &self, foreground: Color, @@ -1907,21 +1917,22 @@ impl TerminalPalette { if let Some(color) = overrides.get(index).copied().flatten() { return rgb_to_hsla(color); } + let dim = |index: u8| self.theme.indexed(index).opacity(0.66); match color { Color::Named(NamedColor::Foreground | NamedColor::BrightForeground) => self.foreground, Color::Named(NamedColor::DimForeground) => self.foreground.opacity(0.66), Color::Named(NamedColor::Background) => self.background, Color::Named(NamedColor::Cursor) => self.cursor, - Color::Named(NamedColor::DimBlack) => indexed_color(0).opacity(0.66), - Color::Named(NamedColor::DimRed) => indexed_color(1).opacity(0.66), - Color::Named(NamedColor::DimGreen) => indexed_color(2).opacity(0.66), - Color::Named(NamedColor::DimYellow) => indexed_color(3).opacity(0.66), - Color::Named(NamedColor::DimBlue) => indexed_color(4).opacity(0.66), - Color::Named(NamedColor::DimMagenta) => indexed_color(5).opacity(0.66), - Color::Named(NamedColor::DimCyan) => indexed_color(6).opacity(0.66), - Color::Named(NamedColor::DimWhite) => indexed_color(7).opacity(0.66), - Color::Named(named) => indexed_color(named as u8), - Color::Indexed(index) => indexed_color(index), + Color::Named(NamedColor::DimBlack) => dim(0), + Color::Named(NamedColor::DimRed) => dim(1), + Color::Named(NamedColor::DimGreen) => dim(2), + Color::Named(NamedColor::DimYellow) => dim(3), + Color::Named(NamedColor::DimBlue) => dim(4), + Color::Named(NamedColor::DimMagenta) => dim(5), + Color::Named(NamedColor::DimCyan) => dim(6), + Color::Named(NamedColor::DimWhite) => dim(7), + Color::Named(named) => self.theme.indexed(named as u8), + Color::Indexed(index) => self.theme.indexed(index), Color::Spec(_) => unreachable!(), } } @@ -1931,34 +1942,6 @@ fn rgb_to_hsla(color: Rgb) -> Hsla { rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into() } -fn indexed_color(index: u8) -> Hsla { - const ANSI: [u32; 16] = [ - 0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5, 0x7f7f7f, - 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff, - ]; - let value = match index { - 0..=15 => ANSI[index as usize], - 16..=231 => { - let index = index - 16; - let component = |value: u8| { - if value == 0 { - 0 - } else { - 55 + value as u32 * 40 - } - }; - (component(index / 36) << 16) - | (component((index % 36) / 6) << 8) - | component(index % 6) - } - 232..=255 => { - let value = 8 + (index as u32 - 232) * 10; - (value << 16) | (value << 8) | value - } - }; - rgb(value).into() -} - pub(crate) fn encode_key(keystroke: &Keystroke, mode: TermMode) -> Option> { if keystroke.modifiers.platform { return None; diff --git a/src/terminal/theme.rs b/src/terminal/theme.rs new file mode 100644 index 0000000..95fe402 --- /dev/null +++ b/src/terminal/theme.rs @@ -0,0 +1,616 @@ +//! 终端主题: the colors a terminal draws with — its text, background, +//! cursor, selection and the 16 ANSI colors. The settings choose one for the +//! light appearance and one for the dark and set the one in effect as a +//! global; every terminal view reads it, so a change reaches open tabs at +//! once. Also what a program is told when it asks for a color (OSC 4, 10, +//! 11, 12). + +use alacritty_terminal::vte::ansi::{NamedColor, Rgb}; +use gpui_kit::component::{ActiveTheme as _, ThemeMode}; +use gpui_kit::*; + +/// A built-in theme. Its colors are `0xrrggbb`: data, like a picture's, not +/// theme roles, so they are the same whatever the app's appearance. +#[derive(Debug, PartialEq, Eq)] +pub struct TerminalTheme { + /// What the settings file stores. Never changes once released. + key: &'static str, + name: &'static str, + /// The appearance it is made for: the column it is listed in. + mode: ThemeMode, + foreground: u32, + background: u32, + cursor: u32, + /// Opaque: drawn under selected text in place of its background. + selection: u32, + /// Black, red, green, yellow, blue, magenta, cyan, white, then their + /// bright versions. + ansi: [u32; 16], +} + +impl TerminalTheme { + /// The built-in themes: the light ones, then the dark ones. + pub fn all() -> &'static [TerminalTheme] { + &THEMES + } + + /// The themes made for `mode`, in the order they are listed. + pub fn for_mode(mode: ThemeMode) -> impl Iterator { + THEMES.iter().filter(move |theme| theme.mode == mode) + } + + pub fn find(key: &str) -> Option<&'static TerminalTheme> { + THEMES.iter().find(|theme| theme.key == key) + } + + /// What terminals use for `mode` until another is chosen: the look they + /// had before themes could be chosen, the app's own background and text. + pub fn default_for(mode: ThemeMode) -> &'static TerminalTheme { + Self::for_mode(mode) + .next() + .expect("each appearance has its themes") + } + + pub fn key(&self) -> &'static str { + self.key + } + + pub fn name(&self) -> &'static str { + self.name + } + + pub fn mode(&self) -> ThemeMode { + self.mode + } + + pub fn foreground(&self) -> Hsla { + rgb(self.foreground).into() + } + + pub fn background(&self) -> Hsla { + rgb(self.background).into() + } + + pub fn cursor(&self) -> Hsla { + rgb(self.cursor).into() + } + + pub fn selection(&self) -> Hsla { + rgb(self.selection).into() + } + + /// One of the 256 colors a program picks by number: the 16 ANSI colors + /// are the theme's, the 6×6×6 cube and the gray ramp above them xterm's. + pub fn indexed(&self, index: u8) -> Hsla { + rgb(self.indexed_rgb(index)).into() + } + + /// The answer to a program asking for color `index` it has not set + /// itself, numbered as alacritty numbers its colors: 0–255 by number, + /// then the named ones (foreground, background, cursor …). + pub fn query_color(&self, index: usize) -> Rgb { + let value = match index { + 0..=255 => self.indexed_rgb(index as u8), + _ if index == NamedColor::Background as usize => self.background, + _ if index == NamedColor::Cursor as usize => self.cursor, + // The foreground, and the dim and bright versions of it and of + // the ANSI colors, which programs cannot ask for by OSC. + _ => self.foreground, + }; + Rgb { + r: (value >> 16) as u8, + g: (value >> 8) as u8, + b: value as u8, + } + } + + fn indexed_rgb(&self, index: u8) -> u32 { + match index { + 0..=15 => self.ansi[index as usize], + 16..=231 => { + let index = index - 16; + let component = |value: u8| { + if value == 0 { + 0 + } else { + 55 + value as u32 * 40 + } + }; + (component(index / 36) << 16) + | (component((index % 36) / 6) << 8) + | component(index % 6) + } + 232..=255 => { + let value = 8 + (index as u32 - 232) * 10; + (value << 16) | (value << 8) | value + } + } + } +} + +/// The theme terminals draw with now, set by the settings for the app's +/// current appearance. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TerminalColors(&'static TerminalTheme); + +impl Global for TerminalColors {} + +impl TerminalColors { + pub fn new(theme: &'static TerminalTheme) -> Self { + Self(theme) + } + + /// The theme in effect; before the settings set one, the default for + /// the app's appearance. + pub fn current(cx: &App) -> &'static TerminalTheme { + cx.try_global::() + .map(|colors| colors.0) + .unwrap_or_else(|| TerminalTheme::default_for(cx.theme().mode)) + } + + pub fn theme(&self) -> &'static TerminalTheme { + self.0 + } +} + +/// The app's light theme around a terminal: white, `neutral-950` text, the +/// `primary` cursor and the `selection` over white, with ANSI colors from +/// the same Tailwind palette, dark enough to read on white. +const SHELLRS_LIGHT: TerminalTheme = TerminalTheme { + key: "shellrs-light", + name: "ShellRS Light", + mode: ThemeMode::Light, + foreground: 0x0a0a0a, + background: 0xffffff, + cursor: 0x171717, + selection: 0xcce2fe, + ansi: [ + 0x171717, 0xdc2626, 0x15803d, 0xa16207, 0x2563eb, 0xc026d3, 0x0e7490, 0xa3a3a3, 0x737373, + 0xef4444, 0x16a34a, 0xd97706, 0x3b82f6, 0xd946ef, 0x0891b2, 0xd4d4d4, + ], +}; + +/// The app's dark theme around a terminal: `neutral-950`, `neutral-50` +/// text and cursor, the `selection` over the background, with ANSI colors +/// light enough to read on it. +const SHELLRS_DARK: TerminalTheme = TerminalTheme { + key: "shellrs-dark", + name: "ShellRS Dark", + mode: ThemeMode::Dark, + foreground: 0xfafafa, + background: 0x0a0a0a, + cursor: 0xfafafa, + selection: 0x101e48, + ansi: [ + 0x262626, 0xf87171, 0x4ade80, 0xfacc15, 0x60a5fa, 0xe879f9, 0x22d3ee, 0xd4d4d4, 0x737373, + 0xfca5a5, 0x86efac, 0xfde047, 0x93c5fd, 0xf0abfc, 0x67e8f9, 0xfafafa, + ], +}; + +/// Each column's themes in the order it lists them, its default first. +/// The light and dark versions of one theme share a row; a theme without the +/// other version comes after them. +static THEMES: [TerminalTheme; 20] = [ + SHELLRS_LIGHT, + SOLARIZED_LIGHT, + TOKYO_DAY, + CATPPUCCIN_LATTE, + FLEXOKI_LIGHT, + ONE_HALF_LIGHT, + GRUVBOX_LIGHT, + KANAGAWA_LOTUS, + ROSE_PINE_DAWN, + EVERFOREST_LIGHT, + SHELLRS_DARK, + SOLARIZED_DARK, + TOKYO_NIGHT, + CATPPUCCIN_MOCHA, + FLEXOKI_DARK, + ONE_HALF_DARK, + GRUVBOX_DARK, + KANAGAWA_WAVE, + DRACULA, + NORD, +]; + +// The rest, each as its own project's terminal port has it. + +/// https://raw.githubusercontent.com/altercation/solarized/master/README.md (MIT). +/// The palette table of the README, laid out as the official iTerm2 files +/// lay it out. +const SOLARIZED_LIGHT: TerminalTheme = TerminalTheme { + key: "solarized-light", + name: "Solarized Light", + mode: ThemeMode::Light, + foreground: 0x657b83, + background: 0xfdf6e3, + cursor: 0x657b83, + selection: 0xeee8d5, + ansi: [ + 0x073642, 0xdc322f, 0x859900, 0xb58900, 0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5, 0x002b36, + 0xcb4b16, 0x586e75, 0x657b83, 0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3, + ], +}; + +/// https://raw.githubusercontent.com/folke/tokyonight.nvim/main/extras/ghostty/tokyonight_day (Apache-2.0). +/// The project calls it Tokyo Night Day. +const TOKYO_DAY: TerminalTheme = TerminalTheme { + key: "tokyo-day", + name: "Tokyo Day", + mode: ThemeMode::Light, + foreground: 0x3760bf, + background: 0xe1e2e7, + cursor: 0x3760bf, + selection: 0xb7c1e3, + ansi: [ + 0xb4b5b9, 0xf52a65, 0x587539, 0x8c6c3e, 0x2e7de9, 0x9854f1, 0x007197, 0x6172b0, 0xa1a6c5, + 0xff4774, 0x5c8524, 0xa27629, 0x358aff, 0xa463ff, 0x007ea8, 0x3760bf, + ], +}; + +/// https://raw.githubusercontent.com/catppuccin/ghostty/main/themes/catppuccin-latte.conf (MIT). +const CATPPUCCIN_LATTE: TerminalTheme = TerminalTheme { + key: "catppuccin-latte", + name: "Catppuccin Latte", + mode: ThemeMode::Light, + foreground: 0x4c4f69, + background: 0xeff1f5, + cursor: 0xdc8a78, + selection: 0xd8dae1, + ansi: [ + 0x5c5f77, 0xd20f39, 0x40a02b, 0xdf8e1d, 0x1e66f5, 0xea76cb, 0x179299, 0xacb0be, 0x6c6f85, + 0xd20f39, 0x40a02b, 0xdf8e1d, 0x1e66f5, 0xea76cb, 0x179299, 0xbcc0cc, + ], +}; + +/// https://raw.githubusercontent.com/kepano/flexoki/main/iterm2/flexoki_light.itermcolors (MIT). +/// The selection is the kitty port's: this file selects by inverting. +const FLEXOKI_LIGHT: TerminalTheme = TerminalTheme { + key: "flexoki-light", + name: "Flexoki Light", + mode: ThemeMode::Light, + foreground: 0x100f0f, + background: 0xfffcf0, + cursor: 0x100f0f, + selection: 0xcecdc3, + ansi: [ + 0x100f0f, 0xaf3029, 0x66800b, 0xad8301, 0x205ea6, 0xa02f6f, 0x24837b, 0x6f6e69, 0xb7b5ac, + 0xd14d41, 0x879a39, 0xd0a215, 0x4385be, 0xce5d97, 0x3aa99f, 0xcecdc3, + ], +}; + +/// https://raw.githubusercontent.com/sonph/onehalf/master/iterm/OneHalfLight.itermcolors (MIT). +const ONE_HALF_LIGHT: TerminalTheme = TerminalTheme { + key: "one-half-light", + name: "One Half Light", + mode: ThemeMode::Light, + foreground: 0x383a42, + background: 0xfafafa, + cursor: 0xbfceff, + selection: 0xbfceff, + ansi: [ + 0x383a42, 0xe45649, 0x50a14f, 0xc18401, 0x0184bc, 0xa626a4, 0x0997b3, 0xfafafa, 0x4f525e, + 0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xffffff, + ], +}; + +/// https://raw.githubusercontent.com/morhetz/gruvbox-contrib/master/termite/gruvbox-light (MIT). +/// No cursor there: it is the text. The selection is `bg3`, what gruvbox.vim +/// selects with when not inverting. +const GRUVBOX_LIGHT: TerminalTheme = TerminalTheme { + key: "gruvbox-light", + name: "Gruvbox Light", + mode: ThemeMode::Light, + foreground: 0x3c3836, + background: 0xfbf1c7, + cursor: 0x3c3836, + selection: 0xbdae93, + ansi: [ + 0xfbf1c7, 0xcc241d, 0x98971a, 0xd79921, 0x458588, 0xb16286, 0x689d6a, 0x7c6f64, 0x928374, + 0x9d0006, 0x79740e, 0xb57614, 0x076678, 0x8f3f71, 0x427b58, 0x3c3836, + ], +}; + +/// https://raw.githubusercontent.com/rebelot/kanagawa.nvim/master/extras/ghostty/kanagawa-lotus (MIT). +const KANAGAWA_LOTUS: TerminalTheme = TerminalTheme { + key: "kanagawa-lotus", + name: "Kanagawa Lotus", + mode: ThemeMode::Light, + foreground: 0x545464, + background: 0xf2ecbc, + cursor: 0x43436c, + selection: 0xc9cbd1, + ansi: [ + 0x1f1f28, 0xc84053, 0x6f894e, 0x77713f, 0x4d699b, 0xb35b79, 0x597b75, 0x545464, 0x8a8980, + 0xd7474b, 0x6e915f, 0x836f4a, 0x6693bf, 0x624c83, 0x5e857a, 0x43436c, + ], +}; + +/// https://raw.githubusercontent.com/rose-pine/ghostty/main/dist/rose-pine-dawn (MIT). +const ROSE_PINE_DAWN: TerminalTheme = TerminalTheme { + key: "rose-pine-dawn", + name: "Rosé Pine Dawn", + mode: ThemeMode::Light, + foreground: 0x575279, + background: 0xfaf4ed, + cursor: 0x575279, + selection: 0xdfdad9, + ansi: [ + 0xf2e9e1, 0xb4637a, 0x286983, 0xea9d34, 0x56949f, 0x907aa9, 0xd7827e, 0x575279, 0x9893a5, + 0xb4637a, 0x286983, 0xea9d34, 0x56949f, 0x907aa9, 0xd7827e, 0x575279, + ], +}; + +/// https://raw.githubusercontent.com/sainnhe/everforest/master/colors/everforest.vim +/// and autoload/everforest.vim (MIT): the medium light palette, the 16 +/// colors as the color scheme gives them to Vim's terminal, `bg_visual` for +/// the selection. It sets no cursor color by default: the cursor is the +/// text. +const EVERFOREST_LIGHT: TerminalTheme = TerminalTheme { + key: "everforest-light", + name: "Everforest Light", + mode: ThemeMode::Light, + foreground: 0x5c6a72, + background: 0xfdf6e3, + cursor: 0x5c6a72, + selection: 0xeaedc8, + ansi: [ + 0x5c6a72, 0xf85552, 0x8da101, 0xdfa000, 0x3a94c5, 0xdf69ba, 0x35a77c, 0xe6e2cc, 0x5c6a72, + 0xf85552, 0x8da101, 0xdfa000, 0x3a94c5, 0xdf69ba, 0x35a77c, 0xe6e2cc, + ], +}; + +/// https://raw.githubusercontent.com/altercation/solarized/master/README.md (MIT). +/// The palette table of the README, laid out as the official iTerm2 files +/// lay it out. +const SOLARIZED_DARK: TerminalTheme = TerminalTheme { + key: "solarized-dark", + name: "Solarized Dark", + mode: ThemeMode::Dark, + foreground: 0x839496, + background: 0x002b36, + cursor: 0x839496, + selection: 0x073642, + ansi: [ + 0x073642, 0xdc322f, 0x859900, 0xb58900, 0x268bd2, 0xd33682, 0x2aa198, 0xeee8d5, 0x002b36, + 0xcb4b16, 0x586e75, 0x657b83, 0x839496, 0x6c71c4, 0x93a1a1, 0xfdf6e3, + ], +}; + +/// https://raw.githubusercontent.com/folke/tokyonight.nvim/main/extras/ghostty/tokyonight_night (Apache-2.0). +const TOKYO_NIGHT: TerminalTheme = TerminalTheme { + key: "tokyo-night", + name: "Tokyo Night", + mode: ThemeMode::Dark, + foreground: 0xc0caf5, + background: 0x1a1b26, + cursor: 0xc0caf5, + selection: 0x283457, + ansi: [ + 0x15161e, 0xf7768e, 0x9ece6a, 0xe0af68, 0x7aa2f7, 0xbb9af7, 0x7dcfff, 0xa9b1d6, 0x414868, + 0xff899d, 0x9fe044, 0xfaba4a, 0x8db0ff, 0xc7a9ff, 0xa4daff, 0xc0caf5, + ], +}; + +/// https://raw.githubusercontent.com/catppuccin/ghostty/main/themes/catppuccin-mocha.conf (MIT). +const CATPPUCCIN_MOCHA: TerminalTheme = TerminalTheme { + key: "catppuccin-mocha", + name: "Catppuccin Mocha", + mode: ThemeMode::Dark, + foreground: 0xcdd6f4, + background: 0x1e1e2e, + cursor: 0xf5e0dc, + selection: 0x353749, + ansi: [ + 0x45475a, 0xf38ba8, 0xa6e3a1, 0xf9e2af, 0x89b4fa, 0xf5c2e7, 0x94e2d5, 0xa6adc8, 0x585b70, + 0xf38ba8, 0xa6e3a1, 0xf9e2af, 0x89b4fa, 0xf5c2e7, 0x94e2d5, 0xbac2de, + ], +}; + +/// https://raw.githubusercontent.com/kepano/flexoki/main/iterm2/flexoki_dark.itermcolors (MIT). +/// The selection is the kitty port's: this file selects by inverting. +const FLEXOKI_DARK: TerminalTheme = TerminalTheme { + key: "flexoki-dark", + name: "Flexoki Dark", + mode: ThemeMode::Dark, + foreground: 0xcecdc3, + background: 0x100f0f, + cursor: 0xcecdc3, + selection: 0x403e3c, + ansi: [ + 0x100f0f, 0xd14d41, 0x879a39, 0xd0a215, 0x4385be, 0xce5d97, 0x3aa99f, 0x878580, 0x575653, + 0xaf3029, 0x66800b, 0xad8301, 0x205ea6, 0xa02f6f, 0x24837b, 0xcecdc3, + ], +}; + +/// https://raw.githubusercontent.com/sonph/onehalf/master/iterm/OneHalfDark.itermcolors (MIT). +/// Bright black is Windows Terminal's `#5a6374`: here it is the +/// background, which hides text in it. +const ONE_HALF_DARK: TerminalTheme = TerminalTheme { + key: "one-half-dark", + name: "One Half Dark", + mode: ThemeMode::Dark, + foreground: 0xdcdfe4, + background: 0x282c34, + cursor: 0xa3b3cc, + selection: 0x474e5d, + ansi: [ + 0x282c34, 0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xdcdfe4, 0x5a6374, + 0xe06c75, 0x98c379, 0xe5c07b, 0x61afef, 0xc678dd, 0x56b6c2, 0xdcdfe4, + ], +}; + +/// https://raw.githubusercontent.com/morhetz/gruvbox-contrib/master/termite/gruvbox-dark (MIT). +/// No cursor there: it is the text. The selection is `bg3`, what gruvbox.vim +/// selects with when not inverting. +const GRUVBOX_DARK: TerminalTheme = TerminalTheme { + key: "gruvbox-dark", + name: "Gruvbox Dark", + mode: ThemeMode::Dark, + foreground: 0xebdbb2, + background: 0x282828, + cursor: 0xebdbb2, + selection: 0x665c54, + ansi: [ + 0x282828, 0xcc241d, 0x98971a, 0xd79921, 0x458588, 0xb16286, 0x689d6a, 0xa89984, 0x928374, + 0xfb4934, 0xb8bb26, 0xfabd2f, 0x83a598, 0xd3869b, 0x8ec07c, 0xebdbb2, + ], +}; + +/// https://raw.githubusercontent.com/rebelot/kanagawa.nvim/master/extras/ghostty/kanagawa-wave (MIT). +const KANAGAWA_WAVE: TerminalTheme = TerminalTheme { + key: "kanagawa-wave", + name: "Kanagawa Wave", + mode: ThemeMode::Dark, + foreground: 0xdcd7ba, + background: 0x1f1f28, + cursor: 0xc8c093, + selection: 0x2d4f67, + ansi: [ + 0x16161d, 0xc34043, 0x76946a, 0xc0a36e, 0x7e9cd8, 0x957fb8, 0x6a9589, 0xc8c093, 0x727169, + 0xe82424, 0x98bb6c, 0xe6c384, 0x7fb4ca, 0x938aa9, 0x7aa89f, 0xdcd7ba, + ], +}; + +/// https://raw.githubusercontent.com/dracula/windows-terminal/master/dracula.json (MIT). +const DRACULA: TerminalTheme = TerminalTheme { + key: "dracula", + name: "Dracula", + mode: ThemeMode::Dark, + foreground: 0xf8f8f2, + background: 0x282a36, + cursor: 0xf8f8f2, + selection: 0x44475a, + ansi: [ + 0x21222c, 0xff5555, 0x50fa7b, 0xf1fa8c, 0xbd93f9, 0xff79c6, 0x8be9fd, 0xf8f8f2, 0x6272a4, + 0xff6e6e, 0x69ff94, 0xffffa5, 0xd6acff, 0xff92df, 0xa4ffff, 0xffffff, + ], +}; + +/// https://raw.githubusercontent.com/nordtheme/alacritty/main/src/nord.yaml (MIT). +const NORD: TerminalTheme = TerminalTheme { + key: "nord", + name: "Nord", + mode: ThemeMode::Dark, + foreground: 0xd8dee9, + background: 0x2e3440, + cursor: 0xd8dee9, + selection: 0x4c566a, + ansi: [ + 0x3b4252, 0xbf616a, 0xa3be8c, 0xebcb8b, 0x81a1c1, 0xb48ead, 0x88c0d0, 0xe5e9f0, 0x4c566a, + 0xbf616a, 0xa3be8c, 0xebcb8b, 0x81a1c1, 0xb48ead, 0x8fbcbb, 0xeceff4, + ], +}; + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use alacritty_terminal::vte::ansi::{NamedColor, Rgb}; + use gpui_kit::component::ThemeMode; + + use super::TerminalTheme; + + /// WCAG relative luminance. + fn luminance(color: u32) -> f32 { + let channel = |shift: u32| { + let value = ((color >> shift) & 0xff) as f32 / 255.; + if value <= 0.03928 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0) + } + + fn contrast(a: u32, b: u32) -> f32 { + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) + } + + #[test] + fn keys_are_unique_and_each_theme_suits_its_column() { + let mut keys = HashSet::new(); + for theme in TerminalTheme::all() { + assert!(keys.insert(theme.key), "{} twice", theme.key); + assert_eq!(TerminalTheme::find(theme.key), Some(theme)); + assert_eq!( + luminance(theme.background) > 0.5, + theme.mode == ThemeMode::Light, + "{} has a background of the other appearance", + theme.name + ); + // Solarized is soft by design, about 4:1; this catches a text + // color swapped for another. + assert!( + contrast(theme.foreground, theme.background) >= 3., + "{}'s text is hard to read", + theme.name + ); + } + for mode in [ThemeMode::Light, ThemeMode::Dark] { + let default = TerminalTheme::default_for(mode); + assert_eq!(default.mode, mode); + assert_eq!(TerminalTheme::for_mode(mode).next(), Some(default)); + } + } + + #[test] + fn the_default_themes_colors_read_on_their_background() { + for mode in [ThemeMode::Light, ThemeMode::Dark] { + let theme = TerminalTheme::default_for(mode); + // Black, white and their bright versions are meant to fade into + // one background or the other. + for index in (1..=6).chain(9..=14) { + let ratio = contrast(theme.ansi[index], theme.background); + assert!(ratio >= 3., "{} color {index}: {ratio:.2}", theme.name); + } + // zsh's suggestions and many prompts' quiet parts are bright + // black. + assert!(contrast(theme.ansi[8], theme.background) >= 3.); + } + } + + #[test] + fn colors_above_the_sixteen_are_xterms() { + let theme = TerminalTheme::default_for(ThemeMode::Light); + assert_eq!(theme.indexed_rgb(16), 0x000000); + assert_eq!(theme.indexed_rgb(21), 0x0000ff); + assert_eq!(theme.indexed_rgb(196), 0xff0000); + assert_eq!(theme.indexed_rgb(231), 0xffffff); + assert_eq!(theme.indexed_rgb(232), 0x080808); + assert_eq!(theme.indexed_rgb(255), 0xeeeeee); + assert_eq!(theme.indexed_rgb(4), theme.ansi[4]); + } + + #[test] + fn programs_asking_for_colors_are_told_the_themes() { + let light = TerminalTheme::default_for(ThemeMode::Light); + let rgb = |value: u32| Rgb { + r: (value >> 16) as u8, + g: (value >> 8) as u8, + b: value as u8, + }; + // vim asks for the background to choose its light or dark colors. + assert_eq!( + light.query_color(NamedColor::Background as usize), + rgb(0xffffff) + ); + assert_eq!( + light.query_color(NamedColor::Foreground as usize), + rgb(light.foreground) + ); + assert_eq!( + light.query_color(NamedColor::Cursor as usize), + rgb(light.cursor) + ); + assert_eq!(light.query_color(1), rgb(light.ansi[1])); + assert_eq!(light.query_color(232), rgb(0x080808)); + let dark = TerminalTheme::default_for(ThemeMode::Dark); + assert_eq!( + dark.query_color(NamedColor::Background as usize), + rgb(0x0a0a0a) + ); + } +} diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index 9a934db..41c9bfa 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -318,6 +318,72 @@ fn the_appearance_setting_drives_the_theme_and_the_title_bar_switch(cx: &mut Tes .unwrap(); } +#[gpui_kit::test] +fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestAppContext) { + use gpui_kit::component::ThemeMode; + use shellrs::terminal::{TerminalColors, TerminalTheme}; + + let (handle, workspace) = open_workspace(cx); + let settings = cx.update(|cx| workspace.read(cx).settings().clone()); + let in_effect = |cx: &mut TestAppContext| cx.update(|cx| TerminalColors::current(cx).key()); + let set_appearance = |appearance, cx: &mut TestAppContext| { + cx.update(|cx| { + settings.update(cx, |settings, cx| { + settings.update(|settings| settings.appearance = appearance, cx) + }) + }); + cx.run_until_parked(); + }; + let card = |theme: &TerminalTheme| { + gpui_kit::SharedString::from(format!("terminal-theme-{}", theme.key())) + }; + let light = TerminalTheme::for_mode(ThemeMode::Light).nth(1).unwrap(); + let dark = TerminalTheme::for_mode(ThemeMode::Dark).nth(1).unwrap(); + + set_appearance(Appearance::Light, cx); + assert_eq!(in_effect(cx), "shellrs-light"); + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + // 外观 is the first category; its second group holds both columns. + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-0", cx) + }); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find("terminal-theme-shellrs-light").checked(), + Some(true) + ); + assert_eq!( + window.find("terminal-theme-shellrs-dark").checked(), + Some(true) + ); + assert_eq!(window.find(card(light)).checked(), Some(false)); + window.click(card(light), cx); + }); + cx.update(|cx| { + assert_eq!( + settings.read(cx).settings().terminal_theme.light, + light.key() + ) + }); + // The light column is the one in effect, so the terminals change now. + assert_eq!(in_effect(cx), light.key()); + in_frame(cx, handle, |window, cx| { + assert_eq!(window.find(card(light)).checked(), Some(true)); + assert_eq!( + window.find("terminal-theme-shellrs-light").checked(), + Some(false) + ); + window.click(card(dark), cx); + }); + // The dark one waits for the dark appearance. + cx.update(|cx| assert_eq!(settings.read(cx).settings().terminal_theme.dark, dark.key())); + assert_eq!(in_effect(cx), light.key()); + set_appearance(Appearance::Dark, cx); + assert_eq!(in_effect(cx), dark.key()); + set_appearance(Appearance::Light, cx); + assert_eq!(in_effect(cx), light.key()); +} + /// The 关键字高亮 rules, as the settings hold them. fn highlight_rules( workspace: &Entity,