主题:可选让界面也按主题配色,默认保持中性

- 「外观 › 终端主题」改名「主题」,组里第一项「界面跟随主题」,默认
  关闭:界面是中性的黑白灰,只有终端按所选主题配色。
- 打开后侧栏、标签栏、对话框等也换成主题的底色和文字色,成功、警告、
  错误等状态用主题的绿、黄、红;次要文字在正文偏淡的主题里也保持可读。
  ShellRS Light / Dark 就是原来的界面。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-06 22:27:23 +08:00
1 parent d791dad8ae
commit 613287aedd
9 files changed
+472 -23

No files matched your search

+2 -1
View File
@@ -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 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。
+1 -1
View File
@@ -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**:见下一节。
+323
View File
@@ -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<ThemeConfig> {
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<ThemeConfig> {
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<Rgba> {
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);
});
}
}
+18 -8
View File
@@ -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::<TerminalColors>() != Some(&colors) {
cx.set_global(colors);
window.refresh();
+1
View File
@@ -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;
+45 -3
View File
@@ -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]
+4 -4
View File
@@ -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> {
),
]),
SettingGroup::new()
.title("终端主题")
.description("浅色外观时终端用左栏选中的主题,深色外观时用右栏的。")
.items([terminal_theme_item(store)]),
.title("主题")
.description("浅色外观时用左栏选中的主题,深色外观时用右栏的。")
.items([app_follows_item(store), terminal_theme_item(store)]),
]
}
+28 -6
View File
@@ -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<SettingsStore>) -> 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<SettingsStore>) -> 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,
+50
View File
@@ -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.