主题:可选让界面也按主题配色,默认保持中性
- 「外观 › 终端主题」改名「主题」,组里第一项「界面跟随主题」,默认 关闭:界面是中性的黑白灰,只有终端按所选主题配色。 - 打开后侧栏、标签栏、对话框等也换成主题的底色和文字色,成功、警告、 错误等状态用主题的绿、黄、红;次要文字在正文偏淡的主题里也保持可读。 ShellRS Light / Dark 就是原来的界面。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d791dad8ae
commit
613287aedd
9 files changed
+472
-23
No files matched your search
@@ -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
@@ -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**:见下一节。
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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]
|
||||
|
||||
@@ -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)]),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user