From baa6745c1a503298c857b7209795f9de42b4be70 Mon Sep 17 00:00:00 2001 From: Xuz Date: Wed, 7 Oct 2026 20:04:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=B7=B1=E8=89=B2=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=8F=82=E7=85=A7=E5=BE=AE=E4=BF=A1=E7=9A=84=E7=82=AD?= =?UTF-8?q?=E7=81=B0=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=98=AF=E8=BF=91=E9=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShellRS Dark 的界面和终端:底色 #1e1e1f,标题栏、标签栏、状态栏 #2f2f30,边框和悬停 #3b3b3d,文字 #e0e0e4;gpui-kit 的每级灰按对照表换掉,彩色不动 - 次要文字 #929296,比微信的亮一些,对比度保持在 4.5 以上 - 终端选区和 ANSI 黑按新底色重算;「界面跟随主题」关闭时的深色界面同样生效 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- docs/manual.md | 2 +- src/settings/app_theme.rs | 109 +++++++++++++++++++++++++++++++------- src/settings/model.rs | 2 +- src/terminal/theme.rs | 16 +++--- tests/ui/settings.rs | 2 +- 6 files changed, 105 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 63b121c..1cb9f93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,8 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 -- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的黑白灰(gpui-kit 默认主题),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。页面「重置」对卡片和开关各管各的。 +- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。 + - 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。页面「重置」对卡片和开关各管各的。 - 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的 `blue`)对勾。 - 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。 - **键盘快捷键**:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。 @@ -294,7 +295,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`。 +- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`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 bba7bc0..e0fd9b9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -106,7 +106,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)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 - **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。 - **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。 diff --git a/src/settings/app_theme.rs b/src/settings/app_theme.rs index 23d7639..6ad4395 100644 --- a/src/settings/app_theme.rs +++ b/src/settings/app_theme.rs @@ -3,7 +3,8 @@ //! 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. +//! follows. ShellRS Light is gpui-kit's light theme unchanged, ShellRS Dark +//! its dark theme with the grays lifted off black. use std::rc::Rc; @@ -15,7 +16,7 @@ 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) { + if is_gpui_kits(theme) { default_config(theme.mode(), cx).name.clone() } else { theme.name().into() @@ -25,14 +26,78 @@ pub(super) fn ui_theme_name(theme: &'static TerminalTheme, cx: &App) -> SharedSt /// 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(); + if is_gpui_kits(theme) { + default.clone() + } else if theme == TerminalTheme::default_for(ThemeMode::Dark) { + Rc::new(soften(default, theme)) + } else { + Rc::new(recolor(default, theme)) } - Rc::new(recolor(default, theme)) } -fn is_default(theme: &'static TerminalTheme) -> bool { - theme == TerminalTheme::default_for(theme.mode()) +/// ShellRS Light: the app is gpui-kit's light theme as it is. +fn is_gpui_kits(theme: &'static TerminalTheme) -> bool { + theme == TerminalTheme::default_for(ThemeMode::Light) +} + +/// ShellRS Dark's grays, from WeChat's dark mode, which is easy on the eyes +/// where gpui-kit's near black glares: each of gpui-kit's neutral grays (by +/// value) and what it becomes. Grays between two are mixed from them. +const SOFT_DARK: [(u8, u32); 13] = [ + // Overlays stay black. + (0x00, 0x000000), + // neutral-950: the background, sidebars, dialogs. + (0x0a, 0x1e1e1f), + // neutral-900: the title, tab and status bars. + (0x17, 0x2f2f30), + // neutral-800: borders, hover, selected tabs and rows. + (0x26, 0x3b3b3d), + (0x40, 0x48484a), + // neutral-600: scrollbars. + (0x52, 0x626263), + // neutral-500: the focus ring. + (0x73, 0x7b7b7f), + // neutral-400: secondary text, lighter than WeChat's to stay readable. + (0xa3, 0x929296), + (0xd4, 0xc4c4c8), + (0xe5, 0xd2d2d6), + (0xf5, 0xdcdce0), + // neutral-50: the text. + (0xfa, 0xe0e0e4), + (0xff, 0xe8e8ec), +]; + +/// ShellRS Dark: gpui-kit's dark theme with its grays moved onto +/// `SOFT_DARK`. Its colors (selection, success, danger …) stay gpui-kit's. +fn soften(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() { + for value in colors.values_mut() { + let Some(color) = value.as_str().and_then(|text| try_parse_color(text).ok()) else { + continue; + }; + let color = color.to_rgb(); + if is_gray(color) { + *value = hex(soft_gray(color)).into(); + } + } + } + serde_json::from_value(config).expect("written from a theme") +} + +fn soft_gray(color: Rgba) -> Rgba { + let value = gray_value(color) * 255.; + let pair = SOFT_DARK + .windows(2) + .find(|pair| value <= f32::from(pair[1].0)) + .unwrap_or(&SOFT_DARK[SOFT_DARK.len() - 2..]); + let ((from, from_color), (to, to_color)) = (pair[0], pair[1]); + let t = (value - f32::from(from)) / f32::from(to - from); + let mut soft = mix(rgb(from_color), rgb(to_color), t); + soft.a = color.a; + soft } fn default_config(mode: ThemeMode, cx: &App) -> &Rc { @@ -300,24 +365,32 @@ mod tests { } #[gpui_kit::test] - fn the_default_themes_are_gpui_kits_own(cx: &mut TestAppContext) { + fn the_light_default_is_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().theme_name().as_ref(), "Default Light"); assert_eq!(cx.theme().background, white); }); } + + #[gpui_kit::test] + fn the_dark_default_is_charcoal_with_gpui_kits_colors(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_kit::component::init(cx); + apply(TerminalTheme::default_for(ThemeMode::Dark), cx); + let app = cx.theme(); + assert_eq!(app.theme_name().as_ref(), "ShellRS Dark"); + // Bars a step lighter than the background, borders another. + assert_close(app.background.to_rgb(), color(0x1e1e1f), "background"); + assert_close(app.title_bar.to_rgb(), color(0x2f2f30), "title bar"); + assert_close(app.border.to_rgb(), color(0x3b3b3d), "border"); + assert_close(app.foreground.to_rgb(), color(0xe0e0e4), "text"); + assert_close(app.selection.to_rgb(), color(0x1d4ed8), "selection"); + assert_close(app.danger.to_rgb(), color(0xf87171), "danger"); + }); + } } diff --git a/src/settings/model.rs b/src/settings/model.rs index b82c650..d97b0d0 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -86,7 +86,7 @@ impl Default for TerminalThemeSettings { 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. + /// the default, ShellRS Light or Dark, when the app does not follow. pub fn app_theme(&self, mode: ThemeMode) -> &'static TerminalTheme { if self.app_follows { self.theme(mode) diff --git a/src/terminal/theme.rs b/src/terminal/theme.rs index 95fe402..ac8276d 100644 --- a/src/terminal/theme.rs +++ b/src/terminal/theme.rs @@ -170,19 +170,19 @@ const SHELLRS_LIGHT: TerminalTheme = TerminalTheme { ], }; -/// The app's dark theme around a terminal: `neutral-950`, `neutral-50` +/// The app's dark theme around a terminal: its charcoal background and soft /// text and cursor, the `selection` over the background, with ANSI colors -/// light enough to read on it. +/// light enough to read on it; black is the app's borders. const SHELLRS_DARK: TerminalTheme = TerminalTheme { key: "shellrs-dark", name: "ShellRS Dark", mode: ThemeMode::Dark, - foreground: 0xfafafa, - background: 0x0a0a0a, - cursor: 0xfafafa, - selection: 0x101e48, + foreground: 0xe0e0e4, + background: 0x1e1e1f, + cursor: 0xe0e0e4, + selection: 0x1e2c56, ansi: [ - 0x262626, 0xf87171, 0x4ade80, 0xfacc15, 0x60a5fa, 0xe879f9, 0x22d3ee, 0xd4d4d4, 0x737373, + 0x3b3b3d, 0xf87171, 0x4ade80, 0xfacc15, 0x60a5fa, 0xe879f9, 0x22d3ee, 0xd4d4d4, 0x737373, 0xfca5a5, 0x86efac, 0xfde047, 0x93c5fd, 0xf0abfc, 0x67e8f9, 0xfafafa, ], }; @@ -610,7 +610,7 @@ mod tests { let dark = TerminalTheme::default_for(ThemeMode::Dark); assert_eq!( dark.query_color(NamedColor::Background as usize), - rgb(0x0a0a0a) + rgb(0x1e1e1f) ); } } diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index e2e11e1..677514c 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -373,7 +373,7 @@ fn terminal_themes_follow_the_appearance_and_a_card_chooses_one(cx: &mut TestApp let neutral = |cx: &mut TestAppContext| { cx.update(|cx| { let name = cx.theme().theme_name().clone(); - name == "Default Light" || name == "Default Dark" + name == "Default Light" || name == "ShellRS Dark" }) }; assert!(neutral(cx));