From 613287aedd497f3decefb10a3e660c82344e39f4 Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 22:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E9=A2=98=EF=BC=9A=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E8=AE=A9=E7=95=8C=E9=9D=A2=E4=B9=9F=E6=8C=89=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E9=85=8D=E8=89=B2=EF=BC=8C=E9=BB=98=E8=AE=A4=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E4=B8=AD=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「外观 › 终端主题」改名「主题」,组里第一项「界面跟随主题」,默认 关闭:界面是中性的黑白灰,只有终端按所选主题配色。 - 打开后侧栏、标签栏、对话框等也换成主题的底色和文字色,成功、警告、 错误等状态用主题的绿、黄、红;次要文字在正文偏淡的主题里也保持可读。 ShellRS Light / Dark 就是原来的界面。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- docs/manual.md | 2 +- src/settings/app_theme.rs | 323 ++++++++++++++++++++++++++++++++ src/settings/apply.rs | 26 ++- src/settings/mod.rs | 1 + src/settings/model.rs | 48 ++++- src/settings/settings_panel.rs | 8 +- src/settings/terminal_themes.rs | 34 +++- tests/ui/settings.rs | 50 +++++ 9 files changed, 472 insertions(+), 23 deletions(-) create mode 100644 src/settings/app_theme.rs diff --git a/CLAUDE.md b/CLAUDE.md index 771c993..0529a01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 -- **终端主题**:「应用外观」(浅色 / 深色 / 跟随系统)照旧管界面;终端另有主题,在「外观 › 终端主题」左右两栏里浅色、深色各选一套,按当前外观生效。 +- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的黑白灰(gpui-kit 默认主题),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。页面「重置」对卡片和开关各管各的。 - 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。 - 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。 - **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。 @@ -257,6 +257,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。 - **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。 - **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。 +- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light / Dark 和「界面跟随主题」关闭时都直接用 gpui-kit 的默认主题(`TerminalThemeSettings::app_theme`)。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。 - **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 - **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。 - **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。 diff --git a/docs/manual.md b/docs/manual.md index 25a424d..c04dedc 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -104,7 +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 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。** +- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的黑白灰;打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 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/app_theme.rs b/src/settings/app_theme.rs new file mode 100644 index 0000000..23d7639 --- /dev/null +++ b/src/settings/app_theme.rs @@ -0,0 +1,323 @@ +//! The app's colors from the chosen theme, so the window around a terminal +//! matches it: gpui-kit's default light or dark theme, recolored. Each gray +//! keeps its place between the default's background and text, now between +//! the theme's; each color becomes the theme's ANSI color of its hue. What +//! gpui-kit derives from these (hover, active, tab bar, status bar …) +//! follows. The theme's own default is gpui-kit's theme unchanged. + +use std::rc::Rc; + +use gpui_kit::component::{ThemeConfig, ThemeMode, ThemeRegistry, try_parse_color}; +use gpui_kit::*; + +use crate::terminal::TerminalTheme; + +/// The name the app's theme has while `theme` is chosen, to tell whether it +/// is already in effect without building it again. +pub(super) fn ui_theme_name(theme: &'static TerminalTheme, cx: &App) -> SharedString { + if is_default(theme) { + default_config(theme.mode(), cx).name.clone() + } else { + theme.name().into() + } +} + +/// The app's theme while `theme` is chosen. +pub(super) fn ui_theme(theme: &'static TerminalTheme, cx: &App) -> Rc { + let default = default_config(theme.mode(), cx); + if is_default(theme) { + return default.clone(); + } + Rc::new(recolor(default, theme)) +} + +fn is_default(theme: &'static TerminalTheme) -> bool { + theme == TerminalTheme::default_for(theme.mode()) +} + +fn default_config(mode: ThemeMode, cx: &App) -> &Rc { + let registry = ThemeRegistry::global(cx); + match mode { + ThemeMode::Light => registry.default_light_theme(), + ThemeMode::Dark => registry.default_dark_theme(), + } +} + +/// `default` with its colors moved onto `theme`. Everything else (radius, +/// shadow, syntax highlighting) stays the default's. +fn recolor(default: &ThemeConfig, theme: &TerminalTheme) -> ThemeConfig { + let mut config = serde_json::to_value(default).expect("a theme is plain data"); + config["name"] = theme.name().into(); + config["is_default"] = false.into(); + if let Some(colors) = config["colors"].as_object_mut() { + let color = |key: &str| { + colors + .get(key) + .and_then(|value| value.as_str()) + .and_then(|value| try_parse_color(value).ok()) + .map(|color| color.to_rgb()) + }; + if let (Some(background), Some(foreground)) = (color("background"), color("foreground")) { + let line = Line { + from: (background, foreground), + to: (theme.background().to_rgb(), theme.foreground().to_rgb()), + }; + colors.retain(|key, value| { + let Some(color) = value.as_str().and_then(|text| try_parse_color(text).ok()) else { + return true; + }; + match recolor_one(key, color.to_rgb(), &line, theme) { + Some(color) => { + *value = hex(color).into(); + true + } + // Left to what gpui-kit derives in its place. + None => false, + } + }); + } + } + serde_json::from_value(config).expect("written from a theme") +} + +/// The line from background to text, in the default and in the theme. +struct Line { + from: (Rgba, Rgba), + to: (Rgba, Rgba), +} + +/// How much of its text's contrast a theme keeps for the default's readable +/// grays, such as secondary text, at most what reads well anywhere. +const TEXT_CONTRAST_SHARE: f32 = 0.8; +const READABLE_CONTRAST: f32 = 4.5; + +/// One of the default's colors in the theme, or `None` to leave it to +/// gpui-kit: the lighter versions of the base colors, and colored text on a +/// colored background, which in the theme could be the same color. +fn recolor_one(key: &str, color: Rgba, line: &Line, theme: &TerminalTheme) -> Option { + let ansi = |index: u8| { + let mut ansi = theme.indexed(index).to_rgb(); + ansi.a = color.a; + Some(ansi) + }; + if let Some(base) = key.strip_prefix("base.") { + return match base { + "red" => ansi(1), + "green" => ansi(2), + "yellow" => ansi(3), + "blue" => ansi(4), + "magenta" => ansi(5), + "cyan" => ansi(6), + _ => None, + }; + } + if is_gray(color) { + let (background, foreground) = line.to; + let (from_background, from_foreground) = line.from; + let mut t = (gray_value(color) - gray_value(from_background)) + / (gray_value(from_foreground) - gray_value(from_background)); + // A gray the default gives text to read on its background stays + // readable even where the theme's own text is soft. + if contrast(color, from_background) >= READABLE_CONTRAST { + let floor = + (contrast(foreground, background) * TEXT_CONTRAST_SHARE).min(READABLE_CONTRAST); + t = least_reaching(background, foreground, t, floor); + } + let mut mixed = mix(background, foreground, t); + mixed.a = color.a; + return Some(mixed); + } + if key.ends_with("foreground") { + return None; + } + ansi(ansi_of_hue(color)) +} + +/// The ANSI color (red 1 … cyan 6) nearest in hue: 60° each, centered on +/// red at 0°, yellow, green, cyan, blue, magenta. +fn ansi_of_hue(color: Rgba) -> u8 { + let hue = Hsla::from(color).h * 360.; + match ((hue + 30.) / 60.).floor() as i32 % 6 { + 0 => 1, + 1 => 3, + 2 => 2, + 3 => 6, + 4 => 4, + _ => 5, + } +} + +fn is_gray(color: Rgba) -> bool { + let high = color.r.max(color.g).max(color.b); + let low = color.r.min(color.g).min(color.b); + high - low < 0.02 +} + +fn gray_value(color: Rgba) -> f32 { + (color.r + color.g + color.b) / 3. +} + +/// `t` of the way from `from` to `to`, past either end if `t` is. +fn mix(from: Rgba, to: Rgba, t: f32) -> Rgba { + let channel = |a: f32, b: f32| (a + (b - a) * t).clamp(0., 1.); + Rgba { + r: channel(from.r, to.r), + g: channel(from.g, to.g), + b: channel(from.b, to.b), + a: 1., + } +} + +/// The smallest step at or past `t` toward the text whose mix reaches +/// `target` contrast with the background; the text itself if none does. +fn least_reaching(background: Rgba, foreground: Rgba, t: f32, target: f32) -> f32 { + let reaches = |t: f32| contrast(mix(background, foreground, t), background) >= target; + if reaches(t) { + return t; + } + let (mut low, mut high) = (t, 1.); + for _ in 0..24 { + let middle = (low + high) / 2.; + if reaches(middle) { + high = middle; + } else { + low = middle; + } + } + high +} + +/// WCAG relative luminance. +fn luminance(color: Rgba) -> f32 { + let linear = |value: f32| { + if value <= 0.03928 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b) +} + +fn contrast(a: Rgba, b: Rgba) -> f32 { + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +/// `#rrggbbaa`, as gpui-kit reads it. +fn hex(color: Rgba) -> String { + let byte = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8; + format!( + "#{:02x}{:02x}{:02x}{:02x}", + byte(color.r), + byte(color.g), + byte(color.b), + byte(color.a) + ) +} + +#[cfg(test)] +mod tests { + use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode}; + use gpui_kit::{App, Rgba, TestAppContext, rgb}; + + use super::{READABLE_CONTRAST, ansi_of_hue, contrast, ui_theme, ui_theme_name}; + use crate::terminal::TerminalTheme; + + fn color(value: u32) -> Rgba { + rgb(value) + } + + /// The same color within a step of 8-bit rounding: colors pass through + /// hex and HSL on their way into the theme. + fn assert_close(a: Rgba, b: Rgba, what: &str) { + let near = |x: f32, y: f32| (x - y).abs() <= 1.5 / 255.; + assert!( + near(a.r, b.r) && near(a.g, b.g) && near(a.b, b.b), + "{what}: {a:?} is not {b:?}" + ); + } + + #[test] + fn colors_go_to_the_ansi_color_of_their_hue() { + // What the default themes use besides their base colors: Tailwind's + // red, yellow, green, cyan and blue, the selection's blue. + let cases = [ + (0xef4444, 1), + (0xf87171, 1), + (0xeab308, 3), + (0xfacc15, 3), + (0x22c55e, 2), + (0x4ade80, 2), + (0x06b6d4, 6), + (0x22d3ee, 6), + (0x55a0fc, 4), + (0x1d4ed8, 4), + (0x60a5fa, 4), + ]; + for (value, ansi) in cases { + assert_eq!(ansi_of_hue(color(value)), ansi, "{value:06x}"); + } + } + + fn apply(theme: &'static TerminalTheme, cx: &mut App) { + let config = ui_theme(theme, cx); + assert_eq!(config.name, ui_theme_name(theme, cx)); + let global = Theme::global_mut(cx); + match theme.mode() { + ThemeMode::Light => global.light_theme = config, + ThemeMode::Dark => global.dark_theme = config, + } + Theme::change(theme.mode(), None, cx); + } + + #[gpui_kit::test] + fn the_app_takes_each_themes_colors_and_keeps_text_readable(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_kit::component::init(cx); + for theme in TerminalTheme::all() { + apply(theme, cx); + let app = cx.theme(); + assert_eq!(app.mode, theme.mode()); + let name = theme.name(); + assert_close(app.background.to_rgb(), theme.background().to_rgb(), name); + assert_close(app.foreground.to_rgb(), theme.foreground().to_rgb(), name); + let text = contrast(theme.foreground().to_rgb(), theme.background().to_rgb()); + let muted = contrast(app.muted_foreground.to_rgb(), app.background.to_rgb()); + assert!( + muted >= (text * 0.8).min(READABLE_CONTRAST) - 0.05, + "{}: secondary text {muted:.2}", + theme.name() + ); + // The default keeps gpui-kit's own colors; the rest are the + // theme's. + if theme != TerminalTheme::default_for(theme.mode()) { + assert_close(app.danger.to_rgb(), theme.indexed(1).to_rgb(), name); + assert_close(app.success.to_rgb(), theme.indexed(2).to_rgb(), name); + } + } + }); + } + + #[gpui_kit::test] + fn the_default_themes_are_gpui_kits_own(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_kit::component::init(cx); + let white = cx.theme().background; + apply(&TerminalTheme::all()[1], cx); + assert_ne!(cx.theme().background, white); + for mode in [ThemeMode::Light, ThemeMode::Dark] { + apply(TerminalTheme::default_for(mode), cx); + assert_eq!( + cx.theme().theme_name().as_ref(), + match mode { + ThemeMode::Light => "Default Light", + ThemeMode::Dark => "Default Dark", + } + ); + } + apply(TerminalTheme::default_for(ThemeMode::Light), cx); + assert_eq!(cx.theme().background, white); + }); + } +} diff --git a/src/settings/apply.rs b/src/settings/apply.rs index 3dfbd34..10a1d26 100644 --- a/src/settings/apply.rs +++ b/src/settings/apply.rs @@ -1,27 +1,37 @@ -use gpui_kit::component::{ActiveTheme as _, Theme}; +use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode}; use gpui_kit::*; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed}; use super::AppSettings; +use super::app_theme::{ui_theme, ui_theme_name}; -/// 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. +/// Bring the window in line with the settings: the theme chosen for the +/// appearance, in the app and its terminals, 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) { + // Here, not only on a settings change: following the system, the theme + // changes with the appearance. let mode = settings.appearance.theme_mode(window.appearance()); - if cx.theme().mode != mode { + let chosen = settings.terminal_theme.theme(mode); + let app = settings.terminal_theme.app_theme(mode); + if cx.theme().mode != mode || *cx.theme().theme_name() != ui_theme_name(app, cx) { + let config = ui_theme(app, cx); + let theme = Theme::global_mut(cx); + match mode { + ThemeMode::Light => theme.light_theme = config, + ThemeMode::Dark => theme.dark_theme = config, + } Theme::change(mode, Some(window), cx); // The theme change resets the list hover to the theme's own. 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)); + let colors = TerminalColors::new(chosen); if cx.try_global::() != Some(&colors) { cx.set_global(colors); window.refresh(); diff --git a/src/settings/mod.rs b/src/settings/mod.rs index ac8e9a7..fb5fc22 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,5 +1,6 @@ //! 设置: the settings tab, the settings it edits and how they are applied. +mod app_theme; mod apply; mod highlight_rules; mod model; diff --git a/src/settings/model.rs b/src/settings/model.rs index 36103b7..d2bd9bb 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -34,13 +34,17 @@ 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. +/// 外观 → 主题: by key, the theme used while the app is light and the one +/// while it is dark, and whether the app takes its colors or only the +/// terminals do. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct TerminalThemeSettings { pub light: String, pub dark: String, + /// 界面跟随主题. Off until the user turns it on: the app keeps + /// gpui-kit's own neutral light or dark colors. + pub app_follows: bool, } impl Default for TerminalThemeSettings { @@ -48,11 +52,35 @@ impl Default for TerminalThemeSettings { Self { light: TerminalTheme::default_for(ThemeMode::Light).key().into(), dark: TerminalTheme::default_for(ThemeMode::Dark).key().into(), + app_follows: false, } } } impl TerminalThemeSettings { + /// The theme the app's colors come from for `mode`: the chosen one, or + /// the default, which is gpui-kit's own, when the app does not follow. + pub fn app_theme(&self, mode: ThemeMode) -> &'static TerminalTheme { + if self.app_follows { + self.theme(mode) + } else { + TerminalTheme::default_for(mode) + } + } + + /// Whether both columns have their defaults, the switch aside. + pub fn is_default_choice(&self) -> bool { + let default = Self::default(); + self.light == default.light && self.dark == default.dark + } + + /// Both columns back to their defaults, the switch left as it is. + pub fn reset_choice(&mut self) { + let default = Self::default(); + self.light = default.light; + self.dark = default.dark; + } + /// 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. @@ -485,8 +513,22 @@ mod tests { ); assert_eq!( serde_json::to_value(&themes).unwrap(), - serde_json::json!({"light": "shellrs-light", "dark": dark.key()}) + serde_json::json!({"light": "shellrs-light", "dark": dark.key(), "app_follows": false}) ); + + // The app stays neutral, the default's colors, gpui-kit's own, + // unless told to follow; the terminals have the theme either way. + assert_eq!( + themes.app_theme(ThemeMode::Dark), + TerminalTheme::default_for(ThemeMode::Dark) + ); + let settings: AppSettings = + serde_json::from_str(r#"{"terminal_theme":{"dark":"nord","app_follows":true}}"#) + .unwrap(); + let themes = &settings.terminal_theme; + assert_eq!(themes.theme(ThemeMode::Dark).key(), "nord"); + assert_eq!(themes.app_theme(ThemeMode::Dark).key(), "nord"); + assert!(!themes.is_default_choice()); } #[test] diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index 94bd7aa..93b5909 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -28,7 +28,7 @@ use crate::terminal::{ 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::terminal_themes::{app_follows_item, terminal_theme_item}; use super::{AppSettings, Choice, NotificationSettings, SettingsStore}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -268,9 +268,9 @@ fn appearance_groups(panel: &SettingsPanel, _: &App) -> Vec { ), ]), SettingGroup::new() - .title("终端主题") - .description("浅色外观时终端用左栏选中的主题,深色外观时用右栏的。") - .items([terminal_theme_item(store)]), + .title("主题") + .description("浅色外观时用左栏选中的主题,深色外观时用右栏的。") + .items([app_follows_item(store), terminal_theme_item(store)]), ] } diff --git a/src/settings/terminal_themes.rs b/src/settings/terminal_themes.rs index 02723c8..7cb5962 100644 --- a/src/settings/terminal_themes.rs +++ b/src/settings/terminal_themes.rs @@ -1,12 +1,15 @@ -//! 外观 → 终端主题: the light themes and the dark ones side by side, a card +//! 外观 → 主题: 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. +//! follows the app's appearance, and the app and its terminals both take +//! its colors. 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, + scroll::ScrollableElement as _, + setting::{SettingField, SettingItem}, + v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; @@ -51,20 +54,39 @@ pub(super) fn terminal_theme_item(store: &Entity) -> SettingItem .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| !dirty.read(cx).settings().terminal_theme.is_default_choice(), move |_, cx| { reset.update(cx, |store, cx| { - store.update(|settings| settings.terminal_theme = Default::default(), cx) + store.update(|settings| settings.terminal_theme.reset_choice(), cx) }); }, ) } +/// 界面跟随主题: off, the app is neutral, gpui-kit's own black, white and +/// grays, and only the terminals take the theme's colors. +pub(super) fn app_follows_item(store: &Entity) -> SettingItem { + let (reader, writer) = (store.clone(), store.clone()); + SettingItem::new( + "界面跟随主题", + SettingField::switch( + move |cx| reader.read(cx).settings().terminal_theme.app_follows, + move |on, cx| { + writer.update(cx, |store, cx| { + store.update(|settings| settings.terminal_theme.app_follows = on, cx) + }); + }, + ) + .default_value(false), + ) + .description("关闭时界面用中性的黑白灰,只有终端按所选主题配色。") +} + /// One appearance's themes under its name, scrolling on their own. fn column( mode: ThemeMode, diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index 41c9bfa..3008c7d 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -366,7 +366,15 @@ fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestApp ) }); // The light column is the one in effect, so the terminals change now. + // The app stays neutral until 界面跟随主题 is on. assert_eq!(in_effect(cx), light.key()); + let neutral = |cx: &mut TestAppContext| { + cx.update(|cx| { + let name = cx.theme().theme_name().clone(); + name == "Default Light" || name == "Default Dark" + }) + }; + assert!(neutral(cx)); in_frame(cx, handle, |window, cx| { assert_eq!(window.find(card(light)).checked(), Some(true)); assert_eq!( @@ -378,10 +386,52 @@ fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestApp // 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()); + + // 主题 is the second group, the switch its first item. + let switch = |window: &mut gpui_kit::Window, cx: &mut App| { + window + .within("settings") + .within("group-1") + .within("item-0") + .click("check", cx) + }; + in_frame(cx, handle, |window, cx| { + let on = window + .within("settings") + .within("group-1") + .within("item-0") + .find("check") + .checked(); + assert_eq!(on, Some(false)); + switch(window, cx); + }); + assert!(cx.update(|cx| settings.read(cx).settings().terminal_theme.app_follows)); + // On, the app takes the theme's colors, for either appearance. + let background = |cx: &mut TestAppContext| cx.update(|cx| cx.theme().background.to_rgb()); + let near = |a: gpui_kit::Rgba, b: gpui_kit::Rgba| { + (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs() < 4. / 255. + }; + assert!(near(background(cx), light.background().to_rgb())); set_appearance(Appearance::Dark, cx); assert_eq!(in_effect(cx), dark.key()); + assert!(cx.update(|cx| cx.theme().is_dark())); + assert!(near(background(cx), dark.background().to_rgb())); set_appearance(Appearance::Light, cx); assert_eq!(in_effect(cx), light.key()); + assert!(near(background(cx), light.background().to_rgb())); + + // Off again, the app is neutral and the terminals keep the theme. + in_frame(cx, handle, |window, cx| switch(window, cx)); + assert_eq!(in_effect(cx), light.key()); + assert!(neutral(cx)); + in_frame(cx, handle, |window, cx| switch(window, cx)); + + // Back on the default, the app is gpui-kit's own light theme again. + in_frame(cx, handle, |window, cx| { + window.click("terminal-theme-shellrs-light", cx) + }); + assert_eq!(in_effect(cx), "shellrs-light"); + cx.update(|cx| assert_eq!(cx.theme().theme_name().as_ref(), "Default Light")); } /// The 关键字高亮 rules, as the settings hold them.