From 2aa5d5be49c39cf9d25ee8a0cb29250e6e116286 Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 19:46:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=AD=97=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=94=B9=E6=88=90=E5=8F=AF=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=9A=84=E8=A7=84=E5=88=99=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=80=BB=E5=BC=80=E5=85=B3=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置页分三组:常规(启用关键字高亮,默认关闭)、预览(当前终端字体和 配色下的一段示例日志)、规则(N)。 - 规则表在行里直接改,不再弹对话框:拖动把手排序、启用、正则表达式、 备注、颜色(取色器加 #rrggbb 输入框)、通知、删除。规则都是正则,区分 大小写;颜色可任意取,不跟主题;去掉加粗。通知标题用备注。 - 预置 ERROR、WARN、IPv4 三条示例,各自启用、不通知,打开总开关就能用。 - 取色器自带一行与调色板不重复的常用色:gpui-kit 默认的常用色也在调色板 里,同色两格是重复的无障碍节点,调试版会因此崩溃。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +- docs/manual.md | 4 +- src/app/actions.rs | 8 - src/app/assets.rs | 3 +- src/settings/apply.rs | 14 +- src/settings/highlight_dialog.rs | 277 ---------------- src/settings/highlight_rules.rs | 498 +++++++++++++++++++++++++++++ src/settings/mod.rs | 3 +- src/settings/model.rs | 59 ++-- src/settings/settings_panel.rs | 258 ++++++--------- src/shared/segmented_control.rs | 20 -- src/terminal/engine.rs | 11 +- src/terminal/highlight.rs | 521 +++++++++++++++++-------------- src/terminal/mod.rs | 3 +- src/terminal/notices.rs | 16 +- src/terminal/terminal_view.rs | 9 +- src/workspace/highlights.rs | 84 ----- src/workspace/mod.rs | 1 - src/workspace/notices.rs | 6 +- src/workspace/workspace_view.rs | 5 +- tests/ui/settings.rs | 268 +++++++++------- tests/ui/terminal.rs | 11 +- 22 files changed, 1121 insertions(+), 968 deletions(-) delete mode 100644 src/settings/highlight_dialog.rs create mode 100644 src/settings/highlight_rules.rs delete mode 100644 src/workspace/highlights.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4b0fe38..46e9aa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,10 +57,10 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 -- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面。规则存在 `settings.json`,所有终端共用。 - - 一条规则是「关键字 / 正则 + 颜色(主题的六种色)+ 加粗 + 是否通知」。关键字按 smart case(同查找),正则按写法。重叠时靠前的优先,暂不做排序、按主机区分和背景色。 - - 首次(文件里没有这一项)预置三条示例:ERROR 红粗、WARN 黄、IPv4 蓝,都不通知;用户删光后保持为空。 - - 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次。全屏程序(备用屏)里不高亮也不通知。 +- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。 + - 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。 + - 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。 + - 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次,标题用备注。全屏程序(备用屏)里不高亮也不通知。 - **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。 - 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。 - 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。 @@ -166,6 +166,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **按上一帧的尺寸重排**(路径标签、监控的竖条、图片预览的画框):render 时拿不到布局尺寸。放一个 `canvas`,在 prepaint 记下尺寸,变了就 `on_next_frame` 通知重画。绝对定位的 `canvas` 必须 `top_0().left_0()`。 - **`img` 会给自己的盒子加上图片的宽高比**,压过百分比高度。要控制尺寸就给宽和高都写绝对值。 - **`Select` 必须放进有尺寸的容器**,否则会盖住整行。`ListItem` 里会增长的文字要 `w_0().flex_grow(1.).overflow_x_hidden()`,否则会把 `suffix` 挤出去。 +- **`ColorPicker` 要自己给常用色**(`featured_colors`):默认的第一行是主题色,它们也在下面的调色板里。gpui-kit 按十六进制给色块起 id,同一颜色出现两格就是重复的无障碍节点,调试版在辅助技术(或借助它的窗口管理工具)连着时直接 panic;测试平台不建无障碍树,测不出来。常用色不能取调色板(shadcn 的 stone、red … pink)里的颜色。 - **要彩色就不能用 `Icon`**:它把 SVG 当成单色遮罩画。系统徽章是「主题色圆底 + 单色 logo」。 - **`px(..)` 只用在 API 要 `Pixels` 的地方**,其余用 rem 助手和 `cx.theme()` 的 token。 - **标签页全部可关。** 关闭走 `DockArea::remove_panel`(`TabGroup::close_panel` 拒绝关最后一个)。中间区面板的 `closable()` 返回 `false`,否则「…」菜单会多一个绕过确认的「关闭」。标题用 `ClosableTabTitle`,它带标签菜单,双击切换侧栏。`panel_name()` 是持久化键,不能改。 @@ -259,6 +260,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。 - 规则由 `settings::apply` 编译成全局量 `TerminalHighlights`,引擎 `observe_global` 后换进与解析线程共用的槽;重启运行时也要把槽传过去。 - `settings.json` 里读不了的规则单条丢掉(`readable_rules`),否则整个设置文件会回退成默认值。 + - 规则表的输入框和取色器状态归 `HighlightRulesEditor`(行 id 不随位置变,拖动后字段跟着走),每次改动整表写回设置;设置被别处改了才重建行。 - **同步更新(DEC 2026)要解析线程自己收尾**:vte 只管攒住 `\e[?2026h` 之后的输出,超时(150 ms)后调 `stop_sync` 是调用方的事,alacritty 自己的事件循环就是这么做的。解析线程等输出时带着截止时间(`receive`,用 `async_io` 的定时器),到点就画出来;输出一直不停时在 `advance` 前补查;退出、出错追加提示前也先 `stop_sync`。否则程序死在一帧中间、连接断在一帧中间时,屏幕会一直冻住。 - **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。 - **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:` / `terminal:remote:`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。 diff --git a/docs/manual.md b/docs/manual.md index 317c1c9..b5b2c44 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -105,7 +105,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。 - **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。 - **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 -- **关键字高亮**:终端里按规则给文字上色、匹配时提醒,见「终端」一节。 +- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。 - **外部 CLI**:见下一节。 - **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。 @@ -177,7 +177,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。 - **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。 - **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。 -- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按所选颜色(红、黄、绿、青、蓝、紫)和粗细显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。规则按「关键字」匹配(按字面;含大写字母时区分大小写,全小写时不区分)或按「正则表达式」匹配(区分大小写,开头加 `(?i)` 不区分);几条规则匹配到同一处时,列表里靠前的优先。初始有三条示例:`ERROR` 红色加粗、`WARN` 黄色、IPv4 地址蓝色,可以编辑或删除。勾选「匹配时通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:规则」,正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:调整规则顺序、按主机区分规则、背景色样式。** +- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。** - **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。 ## 右侧栏 diff --git a/src/app/actions.rs b/src/app/actions.rs index a52e1a8..1fc7adf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -107,8 +107,6 @@ gpui_kit::actions!( OpenSettings, /// Close the settings tab. CloseSettings, - /// Open the dialog for a new 关键字高亮 rule. - NewHighlightRule, /// Increase the application base font (interface zoom). ZoomIn, /// Decrease the application base font (interface zoom). @@ -275,12 +273,6 @@ id_actions! { /// Ask for confirmation, then delete a credential. The hosts using it /// go back to logging in on their own. DeleteCredential(CredentialId); - - /// Open the dialog of the 关键字高亮 rule at this position. - EditHighlightRule(usize); - /// Ask for confirmation, then delete the 关键字高亮 rule at this - /// position. - DeleteHighlightRule(usize); } /// Move a host-tree row by dropping it beside a peer or into a group. diff --git a/src/app/assets.rs b/src/app/assets.rs index 6adad7a..48e69c7 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -73,7 +73,8 @@ icon_assets!( ZoomOut, Zap, ExternalLink, - Highlighter + Highlighter, + GripVertical ] ); diff --git a/src/settings/apply.rs b/src/settings/apply.rs index 5b488b6..eb0de5f 100644 --- a/src/settings/apply.rs +++ b/src/settings/apply.rs @@ -44,10 +44,16 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { // Compiled once here for every terminal, which observe it; only when // the rules change, not on every change of the system appearance. - let rules = settings.terminal_highlight.rules; - let current = cx.try_global::(); - if current.map(|highlights| highlights.set().rules()) != Some(rules.as_slice()) { - cx.set_global(TerminalHighlights::new(rules)); + let highlight = settings.terminal_highlight; + let unchanged = cx + .try_global::() + .is_some_and(|current| { + current + .set() + .is_made_of(highlight.enabled, &highlight.rules) + }); + if !unchanged { + cx.set_global(TerminalHighlights::new(highlight.enabled, highlight.rules)); } if cx.try_global::() != Some(&settings.file_size_format) { diff --git a/src/settings/highlight_dialog.rs b/src/settings/highlight_dialog.rs deleted file mode 100644 index d733552..0000000 --- a/src/settings/highlight_dialog.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! The dialog of a 关键字高亮 rule: a new one, or one to change. - -use gpui_kit::component::{ - ActiveTheme as _, Sizable as _, WindowExt as _, - checkbox::Checkbox, - form::{Field, Form}, - input::{Input, InputEvent, InputState}, -}; -use gpui_kit::prelude::FluentBuilder as _; -use gpui_kit::*; - -use crate::shared::{ - Segment, SegmentedControl, commit_footer, dismiss_form_error, form_error_notification, -}; -use crate::terminal::{HighlightColor, HighlightRule, PatternKind}; - -use super::SettingsStore; - -/// How wide the dialog is: six colors side by side, and a pattern of a fair -/// length on one line. -const DIALOG_WIDTH: f32 = 32.; - -/// The ways a pattern reads, in the order the dialog offers them. -const KINDS: [(PatternKind, &str); 2] = [ - (PatternKind::Keyword, "关键字"), - (PatternKind::Regex, "正则表达式"), -]; - -/// The body of the dialog. Checks the pattern on commit; the settings are -/// only written when it compiles. -pub struct HighlightForm { - store: Entity, - /// The position of the rule being changed, and the rule as it was. - editing: Option<(usize, HighlightRule)>, - pattern: Entity, - kind: PatternKind, - color: HighlightColor, - bold: bool, - notify: bool, - _pattern_changes: Subscription, -} - -impl HighlightForm { - fn new( - editing: Option, - store: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let editing = editing.and_then(|ix| { - let rule = store - .read(cx) - .settings() - .terminal_highlight - .rules - .get(ix)? - .clone(); - Some((ix, rule)) - }); - let rule = editing - .as_ref() - .map(|(_, rule)| rule.clone()) - .unwrap_or_default(); - let pattern = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("例如 ERROR") - .default_value(rule.pattern.clone()) - }); - // The preview shows what is typed. - let pattern_changes = cx.subscribe(&pattern, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }); - Self { - store, - editing, - pattern, - kind: rule.kind, - color: rule.color, - bold: rule.bold, - notify: rule.notify, - _pattern_changes: pattern_changes, - } - } - - fn rule(&self, cx: &App) -> HighlightRule { - HighlightRule { - pattern: self.pattern.read(cx).value().to_string(), - kind: self.kind, - color: self.color, - bold: self.bold, - notify: self.notify, - } - } - - /// Check the pattern and write the rule. Returns whether the dialog may - /// close. - fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { - let rule = self.rule(cx); - if let Err(error) = rule.compile() { - window.push_notification(form_error_notification(error), cx); - return false; - } - let editing = self.editing.clone(); - self.store.update(cx, |store, cx| { - store.update( - |settings| { - let rules = &mut settings.terminal_highlight.rules; - match editing { - // Only over the rule the dialog opened on. - Some((ix, original)) if rules.get(ix) == Some(&original) => { - rules[ix] = rule; - } - _ => rules.push(rule), - } - }, - cx, - ) - }); - true - } -} - -impl Render for HighlightForm { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let description = match self.kind { - PatternKind::Keyword => "按字面匹配。含大写字母时区分大小写,全小写时不区分。", - PatternKind::Regex => "区分大小写,开头加 (?i) 不区分。", - }; - let pattern = self.pattern.read(cx).value(); - let sample = if pattern.is_empty() { - "示例文字".into() - } else { - pattern - }; - Form::new() - .child( - Field::new().label("匹配方式").child( - SegmentedControl::new("highlight-kind") - .selected_index(KINDS.iter().position(|(kind, _)| *kind == self.kind)) - .on_change(cx.listener(|this, ix: &usize, _, cx| { - if let Some((kind, _)) = KINDS.get(*ix) { - this.kind = *kind; - cx.notify(); - } - })) - .segments(KINDS.map(|(_, label)| Segment::new(label))), - ), - ) - .child( - Field::new() - .label("内容") - .required(true) - .description(description) - .child( - Input::new(&self.pattern) - .id("highlight-pattern") - .small() - .font_family(cx.theme().mono_font_family.clone()), - ), - ) - .child( - Field::new().label("颜色").child( - SegmentedControl::new("highlight-color") - .selected_index( - HighlightColor::ALL - .iter() - .position(|color| *color == self.color), - ) - .on_change(cx.listener(|this, ix: &usize, _, cx| { - if let Some(color) = HighlightColor::ALL.get(*ix) { - this.color = *color; - cx.notify(); - } - })) - .segments( - HighlightColor::ALL - .map(|color| Segment::new(color.label()).swatch(color.hsla(cx))), - ), - ), - ) - .child( - Field::new().child( - Checkbox::new("highlight-bold") - .label("加粗") - .checked(self.bold) - .small() - .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.bold = *checked; - cx.notify(); - })), - ), - ) - .child( - Field::new().label("预览").child( - // On the terminal's background, in its font. - div() - .id("highlight-preview") - .test_support() - .w_full() - .px_2() - .py_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .font_family(cx.theme().mono_font_family.clone()) - .text_sm() - .truncate() - .text_color(self.color.hsla(cx)) - .when(self.bold, |preview| preview.font_weight(FontWeight::BOLD)) - .child(sample), - ), - ) - .child( - Field::new().child( - Checkbox::new("highlight-notify") - .label("匹配时通知") - .checked(self.notify) - .small() - .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.notify = *checked; - cx.notify(); - })) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - "看不到这个终端时提醒:ShellRS 在后台,或终端在别的标签。\ - 只检查换了行的新输出,全屏程序里不提醒;\ - 同一终端 10 秒内只提醒一次。", - ), - ), - ), - ) - } -} - -/// Open the dialog for a new rule, or for the rule at `editing`. -pub fn open_highlight_dialog( - editing: Option, - store: Entity, - window: &mut Window, - cx: &mut App, -) { - let form = cx.new(|cx| HighlightForm::new(editing, store, window, cx)); - let (title, commit): (SharedString, SharedString) = if form.read(cx).editing.is_some() { - ("编辑高亮规则".into(), "保存".into()) - } else { - ("添加高亮规则".into(), "添加".into()) - }; - window.open_dialog(cx, { - let form = form.clone(); - move |dialog, window, _| { - dialog - .title(title.clone()) - // Dialog geometry is an API boundary that takes `Pixels`; the - // width follows the interface zoom through the rem. - .w(rems(DIALOG_WIDTH).to_pixels(window.rem_size())) - // Closed by its buttons or Escape, not by a click beside it. - .overlay_closable(false) - .child(form.clone()) - .footer(commit_footer("commit", commit.clone())) - .on_ok({ - let form = form.clone(); - move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx)) - }) - .on_close(|_, window, cx| dismiss_form_error(window, cx)) - } - }); - // Straight to typing: in the same update as the opening, or the - // dialog's own focus wins. - let pattern = form.read(cx).pattern.clone(); - pattern.update(cx, |pattern, cx| pattern.focus(window, cx)); -} diff --git a/src/settings/highlight_rules.rs b/src/settings/highlight_rules.rs new file mode 100644 index 0000000..b557855 --- /dev/null +++ b/src/settings/highlight_rules.rs @@ -0,0 +1,498 @@ +//! 设置 › 关键字高亮 › 规则: the rules as a table edited in place. Every +//! change goes to the settings at once, so the preview above and the +//! terminals follow as the user types. + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + checkbox::Checkbox, + color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState}, + h_flex, + input::{Input, InputEvent, InputState}, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::app::CatalogIcon; +use crate::terminal::{HighlightColor, HighlightRule}; + +use super::SettingsStore; + +/// The colors the picker offers first, the examples' among them. +/// +/// None may be a color of the picker's own palette (shadcn's stone, red, +/// orange, yellow, green, cyan, blue, purple and pink): gpui-kit keys each +/// swatch by its hex, so one color in both rows is one accessibility node +/// twice, which a debug build stops on while assistive technology (or a +/// window manager using it) is connected. Its default first row, the +/// theme's colors, is just such a duplicate. +const FEATURED_COLORS: [HighlightColor; 10] = [ + HighlightColor::RED, + HighlightColor::from_rgb(0xf76b15), + HighlightColor::AMBER, + HighlightColor::from_rgb(0x2fa86b), + HighlightColor::from_rgb(0x0fa3a3), + HighlightColor::BLUE, + HighlightColor::from_rgb(0x5b5bd6), + HighlightColor::from_rgb(0xa855d6), + HighlightColor::from_rgb(0xe0489c), + HighlightColor::from_rgb(0x8a8f98), +]; + +/// The rule table. The rows' text fields and color pickers live here, so +/// they keep the caret and an open picker while the settings change under +/// them; the settings hold the rules themselves. +pub struct HighlightRulesEditor { + store: Entity, + rows: Vec, + /// The next row's id: a row keeps it, whatever its place, so a dragged + /// row keeps its fields. + next_id: usize, + _store_changes: Subscription, +} + +struct RuleRow { + id: usize, + rule: HighlightRule, + /// Why the pattern matches nothing, when it does not compile. + error: Option<&'static str>, + pattern: Entity, + note: Entity, + hex: Entity, + picker: Entity, + _subscriptions: [Subscription; 4], +} + +/// A row on its way to another place. +#[derive(Clone)] +struct DraggedRule { + id: usize, + /// Its place when the drag began; nothing moves until it ends. + ix: usize, + pattern: SharedString, +} + +impl Render for DraggedRule { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .font_family(cx.theme().mono_font_family.clone()) + .text_sm() + .child(self.pattern.clone()) + } +} + +impl HighlightRulesEditor { + pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { + // The rows' own edits come back as the same rules; anything else + // (the settings file read anew) lays the rows out again. + let store_changes = cx.observe_in(&store, window, |this, store, window, cx| { + let rules = store.read(cx).settings().terminal_highlight.rules; + if rules != this.rules() { + this.rows = Vec::new(); + for rule in rules { + let row = this.new_row(rule, window, cx); + this.rows.push(row); + } + cx.notify(); + } + }); + let mut this = Self { + store: store.clone(), + rows: Vec::new(), + next_id: 0, + _store_changes: store_changes, + }; + for rule in store.read(cx).settings().terminal_highlight.rules { + let row = this.new_row(rule, window, cx); + this.rows.push(row); + } + this + } + + fn rules(&self) -> Vec { + self.rows.iter().map(|row| row.rule.clone()).collect() + } + + fn new_row( + &mut self, + rule: HighlightRule, + window: &mut Window, + cx: &mut Context, + ) -> RuleRow { + let id = self.next_id; + self.next_id += 1; + let pattern = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(r"例如:ERROR|WARN 或 failed\s+\d+") + .default_value(rule.pattern.clone()) + }); + let note = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("例如:错误告警") + .default_value(rule.note.clone()) + }); + let hex = cx.new(|cx| InputState::new(window, cx).default_value(rule.color.to_hex())); + let picker = + cx.new(|cx| ColorPickerState::new(window, cx).default_value(rule.color.hsla())); + let subscriptions = [ + cx.subscribe(&pattern, move |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let pattern = input.read(cx).value().to_string(); + this.edit(id, |rule| rule.pattern = pattern, cx); + } + }), + cx.subscribe(¬e, move |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let note = input.read(cx).value().to_string(); + this.edit(id, |rule| rule.note = note, cx); + } + }), + // A color typed in whole takes effect; left half typed, the + // field shows the rule's color again. + cx.subscribe_in( + &hex, + window, + move |this, input, event: &InputEvent, window, cx| match event { + InputEvent::Change => { + if let Some(color) = HighlightColor::from_hex(&input.read(cx).value()) { + this.set_color(id, color, ColorSource::Hex, window, cx); + } + } + InputEvent::Blur => { + if let Some(row) = this.row(id) { + let hex = row.rule.color.to_hex(); + input.update(cx, |input, cx| input.set_value(hex, window, cx)); + } + } + _ => {} + }, + ), + cx.subscribe_in( + &picker, + window, + move |this, _, event: &ColorPickerEvent, window, cx| { + if let ColorPickerEvent::Change(Some(color)) = event { + let color = HighlightColor::from_hsla(*color); + this.set_color(id, color, ColorSource::Picker, window, cx); + } + }, + ), + ]; + RuleRow { + id, + error: rule.compile().err(), + rule, + pattern, + note, + hex, + picker, + _subscriptions: subscriptions, + } + } + + fn row(&self, id: usize) -> Option<&RuleRow> { + self.rows.iter().find(|row| row.id == id) + } + + /// Change the rule of row `id` and write the rules. + fn edit(&mut self, id: usize, change: impl FnOnce(&mut HighlightRule), cx: &mut Context) { + let Some(row) = self.rows.iter_mut().find(|row| row.id == id) else { + return; + }; + change(&mut row.rule); + row.error = row.rule.compile().err(); + self.write(cx); + } + + /// Give row `id` `color`, and show it in whichever of its color field + /// and picker did not set it. + fn set_color( + &mut self, + id: usize, + color: HighlightColor, + source: ColorSource, + window: &mut Window, + cx: &mut Context, + ) { + let Some(row) = self.row(id) else { + return; + }; + match source { + ColorSource::Hex => row.picker.update(cx, |picker, cx| { + picker.set_value(color.hsla(), window, cx); + }), + ColorSource::Picker => row.hex.update(cx, |hex, cx| { + hex.set_value(color.to_hex(), window, cx); + }), + } + self.edit(id, |rule| rule.color = color, cx); + } + + fn write(&mut self, cx: &mut Context) { + let rules = self.rules(); + self.store.update(cx, |store, cx| { + store.update(|settings| settings.terminal_highlight.rules = rules, cx) + }); + cx.notify(); + } + + /// A rule at the end, enabled and empty, its pattern taking the keyboard. + fn add(&mut self, window: &mut Window, cx: &mut Context) { + let row = self.new_row(HighlightRule::default(), window, cx); + let pattern = row.pattern.clone(); + self.rows.push(row); + self.write(cx); + pattern.update(cx, |pattern, cx| pattern.focus(window, cx)); + } + + fn remove(&mut self, id: usize, cx: &mut Context) { + self.rows.retain(|row| row.id != id); + self.write(cx); + } + + /// Put row `id` where row `target` is: after it when it came from + /// above, before it when from below. + fn move_row(&mut self, id: usize, target: usize, cx: &mut Context) { + let from = self.rows.iter().position(|row| row.id == id); + let to = self.rows.iter().position(|row| row.id == target); + let (Some(from), Some(to)) = (from, to) else { + return; + }; + if from != to { + let row = self.rows.remove(from); + self.rows.insert(to, row); + self.write(cx); + } + } + + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + h_flex().w_full().justify_end().child( + Button::new("add-highlight-rule") + .outline() + .small() + .icon(Icon::new(IconName::Plus)) + .label("添加规则") + .on_click(cx.listener(|this, _, window, cx| this.add(window, cx))), + ) + } + + fn render_header(&self, cx: &App) -> impl IntoElement { + h_flex() + .w_full() + .gap_3() + .pb_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + // Over the drag handles. + .child(div().w_4().flex_shrink_0()) + .child(div().w_8().flex_shrink_0().child("启用")) + .child(div().flex_1().min_w_0().child("正则表达式")) + .child(div().w_40().flex_shrink_0().child("备注")) + .child(div().w_32().flex_shrink_0().child("颜色")) + .child(div().w_8().flex_shrink_0().child("通知")) + .child(div().w_6().flex_shrink_0()) + } + + fn render_row(&self, ix: usize, row: &RuleRow, cx: &mut Context) -> impl IntoElement { + let id = row.id; + let mono = cx.theme().mono_font_family.clone(); + h_flex() + .id(("highlight-rule", id)) + .test_support() + .w_full() + .gap_3() + .py_2() + .border_t_1() + .border_color(cx.theme().border) + // Where a dragged row lands: after this one when it came from + // above, before it when from below. + .drag_over::(move |style, dragged, _, cx| { + if dragged.id == id { + style + } else if dragged.ix < ix { + style.border_b_1().border_color(cx.theme().primary) + } else { + style.border_color(cx.theme().primary) + } + }) + .on_drop(cx.listener(move |this, dragged: &DraggedRule, _, cx| { + this.move_row(dragged.id, id, cx); + })) + // Only the handle starts a drag: elsewhere a press selects text. + .child( + div() + .id(("highlight-rule-handle", id)) + .test_support() + .w_4() + .flex_shrink_0() + .cursor_grab() + .text_color(cx.theme().muted_foreground) + .tooltip(|window, cx| Tooltip::new("拖动调整顺序").build(window, cx)) + .child(Icon::new(CatalogIcon::GripVertical).small()) + .on_drag( + DraggedRule { + id, + ix, + pattern: row.rule.pattern.clone().into(), + }, + |dragged, _, _, cx| cx.new(|_| dragged.clone()), + ), + ) + .child( + div().w_8().flex_shrink_0().child( + Checkbox::new(("highlight-rule-enabled", id)) + .checked(row.rule.enabled) + .small() + .on_click(cx.listener(move |this, checked: &bool, _, cx| { + let enabled = *checked; + this.edit(id, |rule| rule.enabled = enabled, cx); + })), + ), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + Input::new(&row.pattern) + .id(("highlight-rule-pattern", id)) + .small() + .font_family(mono.clone()), + ) + .when_some(row.error, |cell, error| { + cell.child( + div() + .id(("highlight-rule-error", id)) + .test_support() + .aria_label(error) + .text_xs() + .text_color(cx.theme().danger) + .child(error), + ) + }), + ) + .child( + div().w_40().flex_shrink_0().child( + Input::new(&row.note) + .id(("highlight-rule-note", id)) + .small(), + ), + ) + .child( + h_flex() + .w_32() + .flex_shrink_0() + .gap_2() + .child( + div() + .id(("highlight-rule-picker", id)) + .test_support() + .flex_shrink_0() + .child( + ColorPicker::new(&row.picker) + .featured_colors( + FEATURED_COLORS.iter().map(|color| color.hsla()).collect(), + ) + .small() + .accessibility_label("颜色"), + ), + ) + .child( + div().flex_1().min_w_0().child( + Input::new(&row.hex) + .id(("highlight-rule-color", id)) + .small() + .font_family(mono), + ), + ), + ) + .child( + div().w_8().flex_shrink_0().child( + Checkbox::new(("highlight-rule-notify", id)) + .checked(row.rule.notify) + .small() + .on_click(cx.listener(move |this, checked: &bool, _, cx| { + let notify = *checked; + this.edit(id, |rule| rule.notify = notify, cx); + })), + ), + ) + .child( + div().w_6().flex_shrink_0().child( + Button::new(("delete-highlight-rule", id)) + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::Trash)) + .tooltip("删除") + .on_click(cx.listener(move |this, _, _, cx| this.remove(id, cx))), + ), + ) + } +} + +/// Which of a row's two color controls the user changed. +#[derive(Clone, Copy)] +enum ColorSource { + Hex, + Picker, +} + +impl Render for HighlightRulesEditor { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let table = if self.rows.is_empty() { + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("还没有规则") + .into_any_element() + } else { + let rows: Vec<_> = self + .rows + .iter() + .enumerate() + .map(|(ix, row)| self.render_row(ix, row, cx).into_any_element()) + .collect(); + v_flex() + .w_full() + .child(self.render_header(cx)) + .children(rows) + .into_any_element() + }; + v_flex() + .id("highlight-rules") + .test_support() + .w_full() + .gap_3() + .child(self.render_toolbar(cx)) + .child(table) + } +} + +#[cfg(test)] +mod tests { + use gpui_kit::component::Colorize as _; + + use super::FEATURED_COLORS; + + #[test] + fn featured_colors_are_each_one_swatch() { + // The picker keys its swatches by hex. + let mut hexes: Vec<_> = FEATURED_COLORS + .iter() + .map(|color| color.hsla().to_hex()) + .collect(); + hexes.sort(); + hexes.dedup(); + assert_eq!(hexes.len(), FEATURED_COLORS.len()); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 825b5eb..250e988 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,13 +1,12 @@ //! 设置: the settings tab, the settings it edits and how they are applied. mod apply; -mod highlight_dialog; +mod highlight_rules; mod model; mod settings_panel; mod store; pub use apply::apply; -pub use highlight_dialog::open_highlight_dialog; pub use model::{ AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings, TerminalFontSettings, TerminalHighlightSettings, UpdateSettings, diff --git a/src/settings/model.rs b/src/settings/model.rs index 6872255..45e7a55 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -32,12 +32,15 @@ pub struct AppSettings { pub terminal_highlight: TerminalHighlightSettings, } -/// 关键字高亮: the rules every terminal colors its text by, in the order -/// they win where they overlap. A file without them gets the examples a new -/// installation starts with; one the user emptied stays empty. +/// 关键字高亮: whether terminals color their text by the rules, and the +/// rules, in the order they win where they overlap. Off until the user turns +/// it on. A file without the rules gets the examples a new installation +/// starts with, ready once it is on; one the user emptied stays empty. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct TerminalHighlightSettings { + /// 启用关键字高亮. Off, no rule colors anything or notifies. + pub enabled: bool, #[serde(deserialize_with = "readable_rules")] pub rules: Vec, } @@ -45,6 +48,7 @@ pub struct TerminalHighlightSettings { impl Default for TerminalHighlightSettings { fn default() -> Self { Self { + enabled: false, rules: default_rules(), } } @@ -133,10 +137,6 @@ impl AppSettings { /// file nor a typed value can give the terminal a size of zero. pub fn normalized(mut self) -> Self { self.terminal_font = self.terminal_font.normalized(); - // A rule with nothing to match matches nothing. - self.terminal_highlight - .rules - .retain(|rule| !rule.pattern.trim().is_empty()); self } } @@ -353,57 +353,54 @@ mod tests { #[test] fn highlight_rules_start_as_examples_and_stay_as_the_user_leaves_them() { + // Off, with the examples each on and quiet, ready for when it is. let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(!settings.terminal_highlight.enabled); assert_eq!(settings.terminal_highlight.rules, default_rules()); + assert!( + default_rules() + .iter() + .all(|rule| rule.enabled && !rule.notify) + ); let settings: AppSettings = - serde_json::from_str(r#"{"terminal_highlight":{"rules":[]}}"#).unwrap(); + serde_json::from_str(r#"{"terminal_highlight":{"enabled":true,"rules":[]}}"#).unwrap(); + assert!(settings.terminal_highlight.enabled); assert!(settings.terminal_highlight.rules.is_empty()); let json = serde_json::to_value(AppSettings::default()).unwrap(); assert_eq!( json["terminal_highlight"]["rules"][0], serde_json::json!({ + "enabled": true, "pattern": "ERROR", - "kind": "keyword", - "color": "red", - "bold": true, + "note": "错误", + "color": "#e5484d", "notify": false }) ); - assert_eq!(json["terminal_highlight"]["rules"][2]["kind"], "regex"); } #[test] fn a_rule_this_version_cannot_read_costs_only_itself() { let settings: AppSettings = serde_json::from_str( - r#"{ + r##"{ "appearance": "dark", "terminal_highlight": {"rules": [ {"pattern": "FATAL", "color": "orange"}, - {"pattern": "OOM", "color": "magenta", "notify": true}, + {"pattern": "OOM", "color": "#D946EF", "notify": true}, + {"pattern": ""}, 7 ]} - }"#, + }"##, ) .unwrap(); assert_eq!(settings.appearance, Appearance::Dark); let rules = &settings.terminal_highlight.rules; - assert_eq!(rules.len(), 1); + assert_eq!(rules.len(), 2); assert_eq!(rules[0].pattern, "OOM"); - assert!(rules[0].notify && !rules[0].bold); - } - - #[test] - fn a_rule_with_nothing_to_match_is_dropped() { - let mut settings = AppSettings::default(); - settings.terminal_highlight.rules[1].pattern = " ".into(); - let rules = settings.normalized().terminal_highlight.rules; - assert_eq!( - rules - .iter() - .map(|rule| rule.pattern.as_str()) - .collect::>(), - ["ERROR", r"\b\d{1,3}(\.\d{1,3}){3}\b"] - ); + assert_eq!(rules[0].color.to_hex(), "#d946ef"); + assert!(rules[0].enabled && rules[0].notify); + // A row added and not filled in yet stays, to be filled in. + assert_eq!(rules[1], HighlightRule::default()); } #[test] diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index c51b279..f1f8ffd 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -11,24 +11,23 @@ use gpui_kit::component::{ tooltip::Tooltip, v_flex, }; -use std::sync::Arc; - use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DeleteHighlightRule, - DownloadUpdate, EditHighlightRule, InstallAgentSkill, InstallCliCommand, NewHighlightRule, - OpenDownloadPage, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, ShowUpdate, + CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DownloadUpdate, + InstallAgentSkill, InstallCliCommand, OpenDownloadPage, RefreshCliIntegration, + RemoveAgentSkill, RemoveCliCommand, ShowUpdate, }; use crate::cli::{AgentKind, BinaryStatus, CliIntegration, IntegrationStatus, SkillStatus}; use crate::shared::{ClosableTabTitle, close_tab_items}; use crate::terminal::{ - FONT_SIZE_RANGE, HighlightSet, LINE_HEIGHT_RANGE, PatternKind, TerminalFontPreview, - TerminalHighlights, is_font_installed, monospace_font_families, + FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont, TerminalFontPreview, TerminalHighlights, + is_font_installed, monospace_font_families, }; use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform}; +use super::highlight_rules::HighlightRulesEditor; use super::{AppSettings, Choice, NotificationSettings, SettingsStore}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,6 +49,9 @@ pub struct SettingsPanel { integration: Entity, /// Where updating stands, for 关于. updater: Entity, + /// The rule table of 关键字高亮, whose fields keep their state across + /// the page's renders. + highlight_rules: Entity, /// The monospace families to choose the terminal font from; empty until /// the scan in the background comes back. font_families: &'static [SharedString], @@ -64,8 +66,10 @@ impl SettingsPanel { store: Entity, integration: Entity, updater: Entity, + window: &mut Window, cx: &mut Context, ) -> Self { + let highlight_rules = cx.new(|cx| HighlightRulesEditor::new(store.clone(), window, cx)); let subscriptions = [ cx.observe(&store, |_, _, cx| cx.notify()), cx.observe(&integration, |_, _, cx| cx.notify()), @@ -87,6 +91,7 @@ impl SettingsPanel { store, integration, updater, + highlight_rules, font_families: &[], focus_handle: cx.focus_handle(), tab_group: None, @@ -364,173 +369,98 @@ fn notification_group(store: &Entity) -> SettingGroup { ]) } -/// 关键字高亮: the rules every terminal colors its text by. Taken from the -/// rules in effect, which also know which of them do not compile. +/// What the 关键字高亮 preview shows: a service's log followed on a server, +/// with what the example rules color (levels and addresses) and lines they +/// leave alone. +const HIGHLIGHT_PREVIEW_LINES: &[&str] = &[ + "root@web-01:~# tail -f /var/log/orders/app.log", + "09:12:03 INFO orders listening on 0.0.0.0:8080", + "09:12:41 WARN slow query: 2.31s on table order_items", + "09:13:07 ERROR upstream 10.20.3.15:5432 refused connection", + "09:13:08 INFO retrying in 5s (attempt 2/5)", +]; + +/// 关键字高亮: the switch, a preview of the rules in the terminal's font and +/// colors, and the rules. fn highlight_groups(panel: &SettingsPanel, cx: &App) -> Vec { + let (reader, writer) = (panel.store.clone(), panel.store.clone()); + let count = panel + .store + .read(cx) + .settings() + .terminal_highlight + .rules + .len(); + let editor = panel.highlight_rules.clone(); vec![ - SettingGroup::new() - .title("规则") - .description( - "匹配的文字在所有终端里按所选样式显示:只改显示,不改服务器的输出,\ - 复制出的文字也不变。几条规则匹配到同一处时,靠前的优先。\ - 全屏程序(vim、less、tmux 等)里不高亮。", + SettingGroup::new().title("常规").items([SettingItem::new( + "启用关键字高亮", + SettingField::switch( + move |cx| reader.read(cx).settings().terminal_highlight.enabled, + move |enabled, cx| { + writer.update(cx, |store, cx| { + store.update(|settings| settings.terminal_highlight.enabled = enabled, cx) + }); + }, ) - .items([highlight_rules_item( - TerminalHighlights::current(cx), - panel.focus_handle.clone(), - )]), + .default_value(true), + ) + .description("全屏程序(vim、less、tmux 等)里不高亮。")]), + SettingGroup::new() + .title("预览") + .description("使用当前终端主题") + .items([highlight_preview_item()]), + SettingGroup::new() + .title(format!("规则({count})")) + .description("规则按从上到下应用,勾选「通知」的规则在看不到终端时弹出提醒。") + .items([ + SettingItem::render(move |_, _, _| editor.clone()).keywords([ + "规则", + "正则", + "关键字", + "高亮", + "颜色", + "备注", + "通知", + "排序", + ]), + ]), ] } -/// The rules, one row each with the pattern in its own style, and the -/// button that adds one. The buttons dispatch through the panel's focus -/// handle: after a dialog closes nothing has focus, and an action -/// dispatched from the window would never reach the workspace. -fn highlight_rules_item(rules: Arc, dispatch: FocusHandle) -> SettingItem { - SettingItem::render(move |options, _, cx| { - let size = options.size(); - let muted = cx.theme().muted_foreground; - let header = h_flex() - .w_full() - .gap_3() - .pb_1() - .text_xs() - .text_color(muted) - .child(div().flex_1().min_w_0().child("内容")) - .child(div().w_16().flex_shrink_0().child("匹配方式")) - .child(div().w_8().flex_shrink_0().child("通知")) - // Over the row buttons' lane. - .child(div().w_16().flex_shrink_0()); - let rows = rules.rules().iter().enumerate().map(|(ix, rule)| { - let error = rules.error(ix); - let dispatch_edit = dispatch.clone(); - let dispatch_buttons = (dispatch.clone(), dispatch.clone()); - h_flex() - .id(("highlight-rule", ix)) - .test_support() - .w_full() - .gap_3() - .py_1() - .border_t_1() - .border_color(cx.theme().border) - .on_click(move |event, window, cx| { - if event.click_count() == 2 { - dispatch_edit.dispatch_action(&EditHighlightRule(ix), window, cx); - } - }) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_2() - .child( - // The pattern as the terminal shows its matches. - div() - .min_w_0() - .truncate() - .text_sm() - .font_family(cx.theme().mono_font_family.clone()) - .text_color(rule.color.hsla(cx)) - .when(rule.bold, |text| text.font_weight(FontWeight::BOLD)) - .child(rule.pattern.clone()), - ) - .when_some(error, |row, error| { - row.child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().danger) - .child(format!("表达式无效:{error}")), - ) - }), - ) - .child( - div() - .w_16() - .flex_shrink_0() - .text_sm() - .text_color(muted) - .child(match rule.kind { - PatternKind::Keyword => "关键字", - PatternKind::Regex => "正则", - }), - ) - .child( - div() - .w_8() - .flex_shrink_0() - .text_sm() - .text_color(muted) - .child(if rule.notify { "是" } else { "—" }), - ) - .child( - h_flex() - .w_16() - .flex_shrink_0() - .justify_end() - .gap_1() - .child( - Button::new(("edit-highlight-rule", ix)) - .ghost() - .xsmall() - .icon(Icon::new(CatalogIcon::Pencil)) - .tooltip("编辑…") - .on_click(move |_, window, cx| { - dispatch_buttons.0.dispatch_action( - &EditHighlightRule(ix), - window, - cx, - ) - }), - ) - .child( - Button::new(("delete-highlight-rule", ix)) - .ghost() - .xsmall() - .icon(Icon::new(CatalogIcon::Trash)) - .tooltip("删除…") - .on_click(move |_, window, cx| { - dispatch_buttons.1.dispatch_action( - &DeleteHighlightRule(ix), - window, - cx, - ) - }), - ), - ) - }); - let add = dispatch.clone(); - v_flex() - .id("highlight-rules") +/// The preview: sample output in the terminal's font and colors, colored by +/// the rules in effect, as a terminal would show it. The group's outline is +/// its frame. +fn highlight_preview_item() -> SettingItem { + SettingItem::render(|_, window, cx| { + let rules = TerminalHighlights::current(cx); + let font = TerminalFont::current(cx); + div() + .id("highlight-preview") .test_support() .w_full() - .gap_3() - .child(if rules.rules().is_empty() { + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .font_family(font.family(cx)) + .text_size(font.size) + .line_height(font.row_height(window)) + .overflow_hidden() + .children(HIGHLIGHT_PREVIEW_LINES.iter().map(|line| { + let highlights = rules.spans(line).into_iter().map(|(range, color)| { + ( + range, + HighlightStyle { + color: Some(color.hsla()), + ..HighlightStyle::default() + }, + ) + }); div() - .text_sm() - .text_color(muted) - .child("还没有规则。") - .into_any_element() - } else { - v_flex() - .w_full() - .child(header) - .children(rows) - .into_any_element() - }) - .child( - h_flex().child( - Button::new("add-highlight-rule") - .with_size(size) - .icon(Icon::new(IconName::Plus)) - .label("添加规则…") - .on_click(move |_, window, cx| { - add.dispatch_action(&NewHighlightRule, window, cx) - }), - ), - ) + .whitespace_nowrap() + .child(StyledText::new(*line).with_highlights(highlights)) + })) }) - .keywords(["高亮", "关键字", "正则", "规则", "颜色", "通知"]) + .keywords(["预览", "高亮"]) } /// 外部 CLI: whether the `shellrs` command may use the saved hosts, the diff --git a/src/shared/segmented_control.rs b/src/shared/segmented_control.rs index 9e31fa8..817a45d 100644 --- a/src/shared/segmented_control.rs +++ b/src/shared/segmented_control.rs @@ -32,9 +32,6 @@ pub struct Segment { label: SharedString, /// How many there are of what it shows, quieter beside the label. count: Option, - /// A color it stands for, as a dot before the label: 关键字高亮's - /// colors, named as well as shown. - swatch: Option, disabled: bool, /// Why a disabled segment cannot be chosen. tooltip: Option, @@ -45,7 +42,6 @@ impl Segment { Self { label: label.into(), count: None, - swatch: None, disabled: false, tooltip: None, } @@ -57,12 +53,6 @@ impl Segment { self } - /// The color it stands for: 「● 红」. - pub fn swatch(mut self, color: Hsla) -> Self { - self.swatch = Some(color); - self - } - pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self @@ -203,16 +193,6 @@ impl RenderOnce for SegmentedControl { Tooltip::new(tooltip.clone()).build(window, cx) }) }) - .when_some(segment.swatch, |radio, color| { - radio.child( - div() - .flex_shrink_0() - .size_2() - .mr_1p5() - .rounded_full() - .bg(color), - ) - }) .child(div().truncate().child(segment.label)) .when_some(segment.count, |radio, count| { radio.child( diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 389642e..d32d31a 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -23,7 +23,7 @@ use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency}; use crate::host::HostOs; use super::highlight::{ - HighlightStyle, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights, + HighlightColor, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights, advance_watching, current, }; use super::links::{LinkMarker, url_search, visible_links}; @@ -46,8 +46,8 @@ pub struct TerminalCell { pub search: SearchMark, /// The link the cell belongs to, an index into the snapshot's `links`. pub link: Option, - /// How a 关键字高亮 rule shows the cell, when one matches it. - pub highlight: Option, + /// The color a 关键字高亮 rule shows the cell in, when one matches it. + pub highlight: Option, } #[derive(Clone, Debug)] @@ -224,8 +224,7 @@ impl TerminalEngine { } TerminalUiEventKind::Keyword(hit) => { return Some(TerminalEvent::Notice(TerminalNotice::keyword( - &hit.pattern, - &hit.line, + &hit.rule, &hit.line, ))); } } @@ -537,7 +536,7 @@ impl TerminalEngine { }), search: marker.mark(indexed.point), link: links.mark(indexed.point, indexed.cell.hyperlink()), - highlight: highlights.style( + highlight: highlights.color( (indexed.point.line.0 + display_offset) as usize, indexed.point.column.0, ), diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 8d9e7ba..1e0af79 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -26,9 +26,8 @@ use alacritty_terminal::index::{Column, Line, Point}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::{Term, TermMode}; use alacritty_terminal::vte::ansi::{Processor, Timeout as _}; -use gpui_kit::component::ActiveTheme as _; -use gpui_kit::{App, Global, Hsla}; -use regex::{Regex, RegexBuilder}; +use gpui_kit::{App, Global, Hsla, Rgba}; +use regex::Regex; use serde::{Deserialize, Serialize}; use super::search::MAX_WRAPPED_LINES; @@ -43,85 +42,90 @@ const RETELL_AFTER: Duration = Duration::from_secs(300); /// How many lines told of are remembered. const TOLD_LIMIT: usize = 64; -/// One rule: what to match, how to show it, and whether to tell of it. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// One rule: a regular expression, the color its matches show in, and +/// whether to tell of them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct HighlightRule { + pub enabled: bool, + /// A regular expression, case and all; `(?i)` ignores case. Empty in a + /// row just added, when it matches nothing. pub pattern: String, - pub kind: PatternKind, + /// What the rule is for, in the user's words; a notification's title. + pub note: String, pub color: HighlightColor, - pub bold: bool, /// Tell the user when a finished line of new output matches, while they /// cannot see the terminal. pub notify: bool, } -/// How a rule's pattern reads. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PatternKind { - /// Literally, in upper and lower case alike unless it has a capital, as - /// find has it. - #[default] - Keyword, - /// As a regular expression, case and all; `(?i)` ignores case. - Regex, +impl Default for HighlightRule { + fn default() -> Self { + Self { + enabled: true, + pattern: String::new(), + note: String::new(), + color: HighlightColor::RED, + notify: false, + } + } } -/// The colors a rule may show its matches in: the theme's, so they read on -/// a light background and a dark one alike, as the links' blue does. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum HighlightColor { - #[default] - Red, - Yellow, - Green, - Cyan, - Blue, - Magenta, -} +/// The color a rule shows its matches in, as the user picked it: data, not +/// a theme role, so it is the same in a light theme and a dark one. Written +/// `#rrggbb` in the settings file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct HighlightColor(u32); impl HighlightColor { - /// Every color, in the order the dialog offers them. - pub const ALL: [Self; 6] = [ - Self::Red, - Self::Yellow, - Self::Green, - Self::Cyan, - Self::Blue, - Self::Magenta, - ]; + /// A new rule's color, and the examples'. + pub const RED: Self = Self(0xe5484d); + pub const AMBER: Self = Self(0xf5b51c); + pub const BLUE: Self = Self(0x2d8ce6); - pub fn label(self) -> &'static str { - match self { - Self::Red => "红", - Self::Yellow => "黄", - Self::Green => "绿", - Self::Cyan => "青", - Self::Blue => "蓝", - Self::Magenta => "紫", - } + /// `0xrrggbb`. + pub const fn from_rgb(rgb: u32) -> Self { + Self(rgb & 0xffffff) } - pub fn hsla(self, cx: &App) -> Hsla { - let theme = cx.theme(); - match self { - Self::Red => theme.red, - Self::Yellow => theme.yellow, - Self::Green => theme.green, - Self::Cyan => theme.cyan, - Self::Blue => theme.blue, - Self::Magenta => theme.magenta, - } + /// `#rrggbb` (or `rrggbb`), in either case. + pub fn from_hex(text: &str) -> Option { + let digits = text.trim().strip_prefix('#').unwrap_or(text.trim()); + (digits.len() == 6 && digits.chars().all(|digit| digit.is_ascii_hexdigit())) + .then(|| u32::from_str_radix(digits, 16).ok().map(Self)) + .flatten() + } + + /// `#rrggbb`, in lower case. + pub fn to_hex(self) -> String { + format!("#{:06x}", self.0) + } + + /// The nearest color to `color` this can hold: its alpha is dropped. + pub fn from_hsla(color: Hsla) -> Self { + let Rgba { r, g, b, .. } = color.to_rgb(); + let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u32; + Self((channel(r) << 16) | (channel(g) << 8) | channel(b)) + } + + pub fn hsla(self) -> Hsla { + gpui_kit::rgb(self.0).into() } } -/// How a match shows. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HighlightStyle { - pub color: HighlightColor, - pub bold: bool, +impl TryFrom for HighlightColor { + type Error = String; + + fn try_from(text: String) -> Result { + Self::from_hex(&text).ok_or_else(|| format!("not a #rrggbb color: {text}")) + } +} + +impl From for String { + fn from(color: HighlightColor) -> Self { + color.to_hex() + } } /// The rules a new installation starts with, none of them notifying. @@ -129,55 +133,49 @@ pub fn default_rules() -> Vec { vec![ HighlightRule { pattern: "ERROR".into(), - kind: PatternKind::Keyword, - color: HighlightColor::Red, - bold: true, - notify: false, + note: "错误".into(), + ..HighlightRule::default() }, HighlightRule { pattern: "WARN".into(), - kind: PatternKind::Keyword, - color: HighlightColor::Yellow, - bold: false, - notify: false, + note: "警告".into(), + color: HighlightColor::AMBER, + ..HighlightRule::default() }, HighlightRule { pattern: r"\b\d{1,3}(\.\d{1,3}){3}\b".into(), - kind: PatternKind::Regex, - color: HighlightColor::Blue, - bold: false, - notify: false, + note: "IPv4 地址".into(), + color: HighlightColor::BLUE, + ..HighlightRule::default() }, ] } impl HighlightRule { - pub fn style(&self) -> HighlightStyle { - HighlightStyle { - color: self.color, - bold: self.bold, - } - } - - /// The pattern, ready to match; or why it cannot be, as the dialog says - /// it. - pub fn compile(&self) -> Result { - if self.pattern.trim().is_empty() { - return Err("请输入要匹配的内容"); + /// The pattern, ready to match: `None` while it is empty, and so + /// matches nothing; or why it cannot match, as the rule's row says it. + pub fn compile(&self) -> Result, &'static str> { + if self.pattern.is_empty() { + return Ok(None); } if self.pattern.chars().count() > PATTERN_LIMIT { - return Err("内容不能超过 500 个字符"); + return Err("不能超过 500 个字符"); + } + Regex::new(&self.pattern) + .map(Some) + .map_err(|error| match error { + regex::Error::CompiledTooBig(_) => "正则表达式过于复杂", + _ => "正则表达式写法有误", + }) + } + + /// What a notification calls the rule: its note, or its pattern. + fn title(&self) -> &str { + if self.note.trim().is_empty() { + &self.pattern + } else { + &self.note } - let built = match self.kind { - PatternKind::Keyword => RegexBuilder::new(®ex::escape(&self.pattern)) - .case_insensitive(!self.pattern.chars().any(char::is_uppercase)) - .build(), - PatternKind::Regex => Regex::new(&self.pattern), - }; - built.map_err(|error| match error { - regex::Error::CompiledTooBig(_) => "正则表达式过于复杂", - _ => "正则表达式写法有误", - }) } } @@ -187,12 +185,14 @@ fn finds_text(regex: &Regex, text: &str) -> bool { regex.find_iter(text).any(|found| !found.is_empty()) } -/// The rules, compiled once for every terminal. A rule that does not compile -/// (only a hand-edited settings file has one) is left out of matching. +/// The rules in effect, compiled once for every terminal: none while +/// 启用关键字高亮 is off. A rule turned off, empty or that does not compile +/// (only a hand-edited settings file has one) is left out. #[derive(Debug, Default)] pub struct HighlightSet { + enabled: bool, rules: Vec, - compiled: Vec>, + compiled: Vec>, } // Shared by the UI thread and every terminal's parser thread. @@ -202,19 +202,27 @@ const _: fn() = || { }; impl HighlightSet { - pub fn new(rules: Vec) -> Self { - let compiled = rules.iter().map(HighlightRule::compile).collect(); - Self { rules, compiled } + pub fn new(enabled: bool, rules: Vec) -> Self { + let compiled = rules + .iter() + .map(|rule| { + if enabled && rule.enabled { + rule.compile().ok().flatten() + } else { + None + } + }) + .collect(); + Self { + enabled, + rules, + compiled, + } } - /// The rules this set was compiled from, in their order. - pub fn rules(&self) -> &[HighlightRule] { - &self.rules - } - - /// Why the rule at `ix` matches nothing, when it does not compile. - pub fn error(&self, ix: usize) -> Option<&'static str> { - self.compiled.get(ix)?.as_ref().err().copied() + /// Whether this was made from `rules` with highlighting `enabled`. + pub fn is_made_of(&self, enabled: bool, rules: &[HighlightRule]) -> bool { + self.enabled == enabled && self.rules == rules } /// Whether any rule tells of the lines it matches. @@ -222,33 +230,59 @@ impl HighlightSet { self.notifying().next().is_some() } - /// The rules that compiled, by their index. + /// The rules that match, by their index. fn active(&self) -> impl DoubleEndedIterator { self.rules .iter() .zip(&self.compiled) .enumerate() - .filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref().ok()?))) + .filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref()?))) } fn notifying(&self) -> impl Iterator { self.active().filter(|(_, rule, _)| rule.notify) } - /// How each cell in view shows. Nothing while a full-screen program has - /// the alternate screen: it redraws itself, and an editor's `error` + /// The runs of `text` the rules color, in order, by byte range. Where + /// rules overlap, the earlier one keeps its color. + pub fn spans(&self, text: &str) -> Vec<(Range, HighlightColor)> { + if self.active().next().is_none() { + return Vec::new(); + } + let mut colors = vec![None; text.len()]; + // The last rule first, so an earlier one paints over it. + for (_, rule, regex) in self.active().rev() { + for found in regex.find_iter(text) { + colors[found.range()].fill(Some(rule.color)); + } + } + let mut spans: Vec<(Range, HighlightColor)> = Vec::new(); + for (offset, color) in colors.into_iter().enumerate() { + let Some(color) = color else { continue }; + match spans.last_mut() { + Some((range, last)) if range.end == offset && *last == color => { + range.end = offset + 1; + } + _ => spans.push((offset..offset + 1, color)), + } + } + spans + } + + /// The color of each cell in view. Nothing while a full-screen program + /// has the alternate screen: it redraws itself, and an editor's `error` /// identifier is not news. pub fn screen(&self, term: &Term) -> ScreenHighlights { let columns = term.columns(); let rows = term.screen_lines(); let mut screen = ScreenHighlights { columns, - styles: Vec::new(), + colors: Vec::new(), }; if self.active().next().is_none() || term.mode().contains(TermMode::ALT_SCREEN) { return screen; } - screen.styles = vec![None; columns * rows]; + screen.colors = vec![None; columns * rows]; let top = Line(-(term.grid().display_offset() as i32)); let bottom = Line(top.0 + rows as i32 - 1); // From where the line the top row is part of starts, so a match that @@ -257,12 +291,8 @@ impl HighlightSet { while line <= bottom { let end = line_end(term, line); let text = LineText::read(term, line, end); - // The last rule first, so an earlier one paints over it where - // they overlap. - for (_, rule, regex) in self.active().rev() { - for found in regex.find_iter(&text.text) { - screen.mark(&text, found.range(), rule.style(), top); - } + for (span, color) in self.spans(&text.text) { + screen.mark(&text, span, color, top); } line = Line(end.0 + 1); } @@ -278,8 +308,8 @@ pub struct TerminalHighlights(Arc); impl Global for TerminalHighlights {} impl TerminalHighlights { - pub fn new(rules: Vec) -> Self { - Self(Arc::new(HighlightSet::new(rules))) + pub fn new(enabled: bool, rules: Vec) -> Self { + Self(Arc::new(HighlightSet::new(enabled, rules))) } /// The rules in effect; none before settings are applied. @@ -305,24 +335,24 @@ pub(super) fn current(shared: &SharedHighlights) -> Arc { .clone() } -/// How each cell in view shows, by its row on screen and column. +/// The color of each cell in view, by its row on screen and column. pub struct ScreenHighlights { columns: usize, /// Row by row; empty when nothing is highlighted. - styles: Vec>, + colors: Vec>, } impl ScreenHighlights { - pub fn style(&self, row: usize, column: usize) -> Option { - self.styles + pub fn color(&self, row: usize, column: usize) -> Option { + self.colors .get(row * self.columns + column) .copied() .flatten() } - /// Show the characters of `text` in the byte range `found` with - /// `style`, those in view: the view's top row is `top`. - fn mark(&mut self, text: &LineText, found: Range, style: HighlightStyle, top: Line) { + /// Show the characters of `text` in the byte range `found` in `color`, + /// those in view: the view's top row is `top`. + fn mark(&mut self, text: &LineText, found: Range, color: HighlightColor, top: Line) { let first = text.cells.partition_point(|cell| cell.offset < found.start); let last = text.cells.partition_point(|cell| cell.offset < found.end); for cell in &text.cells[first..last] { @@ -332,8 +362,8 @@ impl ScreenHighlights { if row < 0 || column >= self.columns { continue; } - if let Some(slot) = self.styles.get_mut(row as usize * self.columns + column) { - *slot = Some(style); + if let Some(slot) = self.colors.get_mut(row as usize * self.columns + column) { + *slot = Some(color); } } } @@ -464,10 +494,11 @@ pub(super) fn advance_watching( finished } -/// A finished line of new output that a notifying rule matched. +/// A finished line of new output that a notifying rule matched: the rule, +/// by its note or pattern, and the line. #[derive(Clone, Debug, PartialEq, Eq)] pub struct KeywordHit { - pub pattern: String, + pub rule: String, pub line: String, } @@ -521,7 +552,7 @@ impl LineWatcher { } self.told.push_back((ix, line.clone(), now)); return Some(KeywordHit { - pattern: rule.pattern.clone(), + rule: rule.title().to_string(), line, }); } @@ -539,7 +570,11 @@ mod tests { use super::*; use crate::terminal::TerminalSize; - fn keyword(pattern: &str, color: HighlightColor) -> HighlightRule { + const RED: HighlightColor = HighlightColor(0xff0000); + const AMBER: HighlightColor = HighlightColor(0xffaa00); + const BLUE: HighlightColor = HighlightColor(0x0000ff); + + fn rule(pattern: &str, color: HighlightColor) -> HighlightRule { HighlightRule { pattern: pattern.into(), color, @@ -547,13 +582,6 @@ mod tests { } } - fn regex(pattern: &str, color: HighlightColor) -> HighlightRule { - HighlightRule { - kind: PatternKind::Regex, - ..keyword(pattern, color) - } - } - fn notifying(rule: HighlightRule) -> HighlightRule { HighlightRule { notify: true, @@ -561,6 +589,10 @@ mod tests { } } + fn set(rules: Vec) -> HighlightSet { + HighlightSet::new(true, rules) + } + /// A terminal fed by one parser, as a terminal's parser thread feeds it. struct Screen { term: Term, @@ -597,7 +629,7 @@ mod tests { /// color. A run goes on from the end of one row to the start of the /// next, as a wrapped match does. fn marks(&self, rules: Vec) -> Vec<(String, HighlightColor)> { - let screen = HighlightSet::new(rules).screen(&self.term); + let screen = set(rules).screen(&self.term); let offset = self.term.grid().display_offset() as i32; let mut runs: Vec<(String, HighlightColor)> = Vec::new(); let mut open = false; @@ -605,11 +637,10 @@ mod tests { let line = &self.term.grid()[Line(row as i32 - offset)]; for column in 0..self.term.columns() { let cell = &line[Column(column)]; - match screen.style(row, column) { - Some(style) => { - if !open || runs.last().is_some_and(|(_, color)| *color != style.color) - { - runs.push((String::new(), style.color)); + match screen.color(row, column) { + Some(color) => { + if !open || runs.last().is_some_and(|(_, last)| *last != color) { + runs.push((String::new(), color)); } open = true; if !cell.flags.contains(Flags::WIDE_CHAR_SPACER) { @@ -631,46 +662,77 @@ mod tests { .collect() } - use HighlightColor::{Blue, Red, Yellow}; - #[test] - fn keywords_are_literal_and_smart_case_regexes_are_as_written() { - let compile = |rule: HighlightRule| rule.compile().unwrap(); - let dotted = compile(keyword("a.b", Red)); - assert!(dotted.is_match("x a.b y") && !dotted.is_match("axb")); - assert!(compile(regex("a.b", Red)).is_match("axb")); - // A keyword in lower case finds any case; with a capital, its own. - assert!(compile(keyword("error", Red)).is_match("ERROR: x")); - assert!(!compile(keyword("ERROR", Red)).is_match("error: x")); - assert!(!compile(regex("error", Red)).is_match("ERROR")); - assert!(compile(regex("(?i)error", Red)).is_match("ERROR")); - let address = compile(default_rules().remove(2)); - assert!(address.is_match("from 10.0.0.12 port 22")); - assert!(!address.is_match("1234.5.6.7")); + fn colors_are_written_as_hex() { + assert_eq!( + HighlightColor::from_hex("#D0021B"), + Some(HighlightColor(0xd0021b)) + ); + assert_eq!( + HighlightColor::from_hex("f59e0b"), + Some(HighlightColor(0xf59e0b)) + ); + assert_eq!(HighlightColor(0xd0021b).to_hex(), "#d0021b"); + for text in ["", "#fff", "#12345g", "#1234567"] { + assert_eq!(HighlightColor::from_hex(text), None, "{text}"); + } + let color = HighlightColor(0x3b82f6); + assert_eq!(HighlightColor::from_hsla(color.hsla()), color); } #[test] - fn a_rule_that_cannot_match_says_why() { + fn a_pattern_is_a_regular_expression_as_written() { + let compile = |pattern: &str| rule(pattern, RED).compile(); + let regex = compile("a.b").unwrap().unwrap(); + assert!(regex.is_match("axb")); + assert!(!compile("error").unwrap().unwrap().is_match("ERROR")); + assert!(compile("(?i)error").unwrap().unwrap().is_match("ERROR")); + let address = default_rules().remove(2).compile().unwrap().unwrap(); + assert!(address.is_match("from 10.0.0.12 port 22")); + assert!(!address.is_match("1234.5.6.7")); + // A row just added matches nothing, and says nothing of it. + assert!(matches!(compile(""), Ok(None))); + assert_eq!(compile("(ERROR").err(), Some("正则表达式写法有误")); assert_eq!( - keyword(" ", Red).compile().err(), - Some("请输入要匹配的内容") - ); - assert_eq!( - regex("(ERROR", Red).compile().err(), - Some("正则表达式写法有误") - ); - assert_eq!( - regex(r"(\w{1000}){1000}", Red).compile().err(), + compile(r"(\w{1000}){1000}").err(), Some("正则表达式过于复杂") ); let long = "x".repeat(PATTERN_LIMIT + 1); - assert_eq!( - keyword(&long, Red).compile().err(), - Some("内容不能超过 500 个字符") + assert_eq!(compile(&long).err(), Some("不能超过 500 个字符")); + } + + #[test] + fn rules_turned_off_or_unfinished_match_nothing() { + let rules = vec![ + HighlightRule { + enabled: false, + ..rule("ERROR", RED) + }, + rule("", AMBER), + rule("(", AMBER), + rule("WARN", BLUE), + ]; + assert_eq!(set(rules.clone()).spans("ERROR WARN"), [(6..10, BLUE)]); + // 启用关键字高亮 off, nothing at all. + assert!( + HighlightSet::new(false, rules) + .spans("ERROR WARN") + .is_empty() + ); + } + + #[test] + fn spans_keep_the_earlier_rule_where_rules_overlap() { + let rules = set(vec![rule("Memory", BLUE), rule(r"\w+Error", RED)]); + assert_eq!( + rules.spans("OutOfMemoryError!"), + [(0..5, RED), (5..11, BLUE), (11..16, RED)] + ); + // Byte ranges, whole characters. + assert_eq!( + set(vec![rule("错误", RED)]).spans("连接错误"), + [(6..12, RED)] ); - let set = HighlightSet::new(vec![regex("(", Red), keyword("ok", Red)]); - assert_eq!(set.error(0), Some("正则表达式写法有误")); - assert_eq!(set.error(1), None); } #[test] @@ -678,14 +740,14 @@ mod tests { let mut screen = Screen::new(30, 4); screen.feed("ok ERROR x WARN\r\n"); assert_eq!( - screen.marks(vec![keyword("ERROR", Red), keyword("WARN", Yellow)]), - runs(&[("ERROR", Red), ("WARN", Yellow)]) + screen.marks(vec![rule("ERROR", RED), rule("WARN", AMBER)]), + runs(&[("ERROR", RED), ("WARN", AMBER)]) ); // A word split between two colors is one word on screen. screen.feed("E\x1b[31mRR\x1b[0mOR\r\n"); assert_eq!( - screen.marks(vec![keyword("ERROR", Red)]), - runs(&[("ERROR", Red), ("ERROR", Red)]) + screen.marks(vec![rule("ERROR", RED)]), + runs(&[("ERROR", RED), ("ERROR", RED)]) ); } @@ -694,22 +756,19 @@ mod tests { let mut screen = Screen::new(30, 5); screen.feed("a ERROR\r\nERROR b\r\n2024-10 ERROR\r\n"); assert_eq!( - screen.marks(vec![regex("^ERROR", Red)]), - runs(&[("ERROR", Red)]) + screen.marks(vec![rule("^ERROR", RED)]), + runs(&[("ERROR", RED)]) ); // The blanks padding a row are not text before `$`. assert_eq!( - screen.marks(vec![regex("ERROR$", Red)]), - runs(&[("ERROR", Red), ("ERROR", Red)]) + screen.marks(vec![rule("ERROR$", RED)]), + runs(&[("ERROR", RED), ("ERROR", RED)]) ); assert_eq!( - screen.marks(vec![regex(r"^\d+", Blue)]), - runs(&[("2024", Blue)]) - ); - assert_eq!( - screen.marks(vec![regex(r"\bRR", Red)]), - Vec::<(String, HighlightColor)>::new() + screen.marks(vec![rule(r"^\d+", BLUE)]), + runs(&[("2024", BLUE)]) ); + assert!(screen.marks(vec![rule(r"\bRR", RED)]).is_empty()); } #[test] @@ -717,18 +776,18 @@ mod tests { // `1234567ERR` and `OR tail`. let mut screen = Screen::new(10, 6); screen.feed("1234567ERROR tail\r\n"); - let rules = || vec![keyword("ERROR", Red)]; - assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + let rules = || vec![rule("ERROR", RED)]; + assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)])); // Narrowed, Alacritty reflows them to `1234567E`, pushed into the // scrollback, and `RROR tai` on the screen's top row. screen.resize(8, 6); - assert_eq!(screen.marks(rules()), runs(&[("RROR", Red)])); + assert_eq!(screen.marks(rules()), runs(&[("RROR", RED)])); screen.term.scroll_display(Scroll::Delta(1)); - assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)])); // Widened, one row again. screen.term.scroll_display(Scroll::Bottom); screen.resize(40, 6); - assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)])); } #[test] @@ -736,12 +795,12 @@ mod tests { // `xxxxE` and `RROR` in the scrollback, `next` and `last` on screen. let mut screen = Screen::new(5, 2); screen.feed("xxxxERROR\r\nnext\r\nlast"); - assert!(screen.marks(vec![keyword("ERROR", Red)]).is_empty()); + assert!(screen.marks(vec![rule("ERROR", RED)]).is_empty()); // Scrolled back a row, the view starts at `RROR`. screen.term.scroll_display(Scroll::Delta(1)); assert_eq!( - screen.marks(vec![keyword("ERROR", Red)]), - runs(&[("RROR", Red)]) + screen.marks(vec![rule("ERROR", RED)]), + runs(&[("RROR", RED)]) ); } @@ -750,18 +809,8 @@ mod tests { let mut screen = Screen::new(20, 2); screen.feed("连接错误 x"); assert_eq!( - screen.marks(vec![keyword("错误", Red)]), - runs(&[("错误", Red)]) - ); - } - - #[test] - fn the_first_rule_wins_where_rules_overlap() { - let mut screen = Screen::new(30, 2); - screen.feed("OutOfMemoryError"); - assert_eq!( - screen.marks(vec![keyword("Memory", Blue), regex(r"\w+Error", Red)]), - runs(&[("OutOf", Red), ("Memory", Blue), ("Error", Red)]) + screen.marks(vec![rule("错误", RED)]), + runs(&[("错误", RED)]) ); } @@ -769,12 +818,12 @@ mod tests { fn nothing_is_marked_on_the_alternate_screen() { let mut screen = Screen::new(20, 3); screen.feed("\x1b[?1049hERROR"); - assert!(screen.marks(vec![keyword("ERROR", Red)]).is_empty()); + assert!(screen.marks(vec![rule("ERROR", RED)]).is_empty()); screen.feed("\x1b[?1049l"); screen.feed("ERROR"); assert_eq!( - screen.marks(vec![keyword("ERROR", Red)]), - runs(&[("ERROR", Red)]) + screen.marks(vec![rule("ERROR", RED)]), + runs(&[("ERROR", RED)]) ); } @@ -811,19 +860,23 @@ mod tests { #[test] fn a_notifying_rule_tells_of_a_line_once_in_a_while() { - let set = Arc::new(HighlightSet::new(vec![ - keyword("WARN", Yellow), - notifying(keyword("ERROR", Red)), + let rules = Arc::new(set(vec![ + rule("WARN", AMBER), + notifying(HighlightRule { + note: "错误".into(), + ..rule("ERROR", RED) + }), ])); - let mut watcher = LineWatcher::new(set); + let mut watcher = LineWatcher::new(rules); assert!(watcher.is_watching()); let start = Instant::now(); let lines = |lines: &[&str]| lines.iter().map(|line| line.to_string()).collect(); assert_eq!(watcher.check(lines(&["WARN low disk"]), start), None); + // Named by its note. assert_eq!( watcher.check(lines(&["ok", "1 ERROR a", "2 ERROR b"]), start), Some(KeywordHit { - pattern: "ERROR".into(), + rule: "错误".into(), line: "1 ERROR a".into() }) ); @@ -832,19 +885,21 @@ mod tests { assert!(watcher.check(lines(&["2 ERROR b"]), start).is_some()); let later = start + RETELL_AFTER; assert!(watcher.check(lines(&["1 ERROR a"]), later).is_some()); - // New rules start afresh. - watcher.sync(Arc::new(HighlightSet::new(vec![notifying(keyword( - "ERROR", Red, - ))]))); - assert!(watcher.check(lines(&["1 ERROR a"]), later).is_some()); - watcher.sync(Arc::new(HighlightSet::new(vec![keyword("ERROR", Red)]))); + // New rules start afresh; without a note, the pattern names it. + watcher.sync(Arc::new(set(vec![notifying(rule("ERROR", RED))]))); + assert_eq!( + watcher + .check(lines(&["1 ERROR a"]), later) + .map(|hit| hit.rule), + Some("ERROR".into()) + ); + watcher.sync(Arc::new(set(vec![rule("ERROR", RED)]))); assert!(!watcher.is_watching()); } #[test] fn a_match_of_nothing_tells_of_nothing() { - let set = Arc::new(HighlightSet::new(vec![notifying(regex(r"\b", Red))])); - let mut watcher = LineWatcher::new(set); + let mut watcher = LineWatcher::new(Arc::new(set(vec![notifying(rule(r"\b", RED))]))); assert_eq!( watcher.check(vec!["any words".into()], Instant::now()), None diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 8ab18f5..51c0f0c 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -22,8 +22,7 @@ pub use font::{ TerminalFontPreview, is_font_installed, monospace_font_families, }; pub use highlight::{ - HighlightColor, HighlightRule, HighlightSet, HighlightStyle, PATTERN_LIMIT, PatternKind, - TerminalHighlights, default_rules, + HighlightColor, HighlightRule, HighlightSet, TerminalHighlights, default_rules, }; pub use local_pty::LocalPtyTransportFactory; pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent}; diff --git a/src/terminal/notices.rs b/src/terminal/notices.rs index 83b03d9..a57a5f6 100644 --- a/src/terminal/notices.rs +++ b/src/terminal/notices.rs @@ -10,8 +10,8 @@ use alacritty_terminal::vte::{Parser, Perform}; /// The longest title and body a notification shows, in characters. const TITLE_LIMIT: usize = 80; const BODY_LIMIT: usize = 240; -/// The longest pattern a notification's title names, in characters. -const PATTERN_LIMIT: usize = 40; +/// The longest rule name a notification's title has, in characters. +const RULE_LIMIT: usize = 40; /// Something a terminal asks the user to look at. #[derive(Clone, Debug, PartialEq, Eq)] @@ -22,8 +22,8 @@ pub enum TerminalNotice { /// for an answer usually has its question on. Bell { line: String }, /// A line of new output matched a 关键字高亮 rule that notifies: the - /// rule's pattern, and the line. - Keyword { pattern: String, line: String }, + /// rule, by its note or pattern, and the line. + Keyword { rule: String, line: String }, } impl TerminalNotice { @@ -34,10 +34,10 @@ impl TerminalNotice { } } - /// A rule with `pattern` matched `line`. - pub fn keyword(pattern: &str, line: &str) -> Self { + /// The rule called `rule` matched `line`. + pub fn keyword(rule: &str, line: &str) -> Self { TerminalNotice::Keyword { - pattern: clean_text(pattern, PATTERN_LIMIT), + rule: clean_text(rule, RULE_LIMIT), line: clean_text(line, BODY_LIMIT), } } @@ -207,7 +207,7 @@ mod tests { assert_eq!( TerminalNotice::keyword(" ERROR ", "12:00\tERROR\x1b boom"), TerminalNotice::Keyword { - pattern: "ERROR".into(), + rule: "ERROR".into(), line: "12:00 ERROR boom".into() } ); diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 64d3d83..c1bdc23 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -1228,17 +1228,12 @@ impl Element for TerminalElement { } let character = painted_cell_text(&cell.character, cell.flags); - // A 关键字高亮 rule's bold adds to the program's. - let flags = match cell.highlight { - Some(highlight) if highlight.bold => cell.flags | Flags::BOLD, - _ => cell.flags, - }; - let font = terminal_font(text_style.font(), flags); + let font = terminal_font(text_style.font(), cell.flags); let len = character.len(); text.push_str(character); // A rule's color, dimmed as the program dimmed the text. let highlight = cell.highlight.map(|highlight| { - let color = highlight.color.hsla(cx); + let color = highlight.hsla(); if cell.flags.contains(Flags::DIM) { color.opacity(0.66) } else { diff --git a/src/workspace/highlights.rs b/src/workspace/highlights.rs deleted file mode 100644 index 9c7518f..0000000 --- a/src/workspace/highlights.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! The workspace's share of 关键字高亮: the commands of the rule list in -//! the settings, which open the rule dialog and ask before deleting. - -use std::rc::Rc; - -use gpui_kit::*; - -use crate::app::{DeleteHighlightRule, EditHighlightRule, NewHighlightRule}; -use crate::settings::open_highlight_dialog; -use crate::shared::confirm_delete; - -use super::workspace_view::Workspace; - -impl Workspace { - pub(super) fn on_new_highlight_rule( - &mut self, - _: &NewHighlightRule, - window: &mut Window, - cx: &mut Context, - ) { - open_highlight_dialog(None, self.settings().clone(), window, cx); - } - - pub(super) fn on_edit_highlight_rule( - &mut self, - action: &EditHighlightRule, - window: &mut Window, - cx: &mut Context, - ) { - let exists = action.0 - < self - .settings() - .read(cx) - .settings() - .terminal_highlight - .rules - .len(); - if exists { - open_highlight_dialog(Some(action.0), self.settings().clone(), window, cx); - } - } - - pub(super) fn on_delete_highlight_rule( - &mut self, - action: &DeleteHighlightRule, - window: &mut Window, - cx: &mut Context, - ) { - let ix = action.0; - let Some(rule) = self - .settings() - .read(cx) - .settings() - .terminal_highlight - .rules - .get(ix) - .cloned() - else { - return; - }; - let settings = self.settings().clone(); - let pattern = rule.pattern.clone(); - confirm_delete( - &pattern, - None, - Rc::new(move |_, cx| { - settings.update(cx, |settings, cx| { - settings.update( - |settings| { - // Only the rule that was asked about. - let rules = &mut settings.terminal_highlight.rules; - if rules.get(ix) == Some(&rule) { - rules.remove(ix); - } - }, - cx, - ) - }); - }), - window, - cx, - ); - } -} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 92b6b35..e3d5731 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -6,7 +6,6 @@ mod credentials; mod dock_skin; mod editors; mod forwards; -mod highlights; mod links; mod notices; mod recent_hosts; diff --git a/src/workspace/notices.rs b/src/workspace/notices.rs index 0955042..7e2dfff 100644 --- a/src/workspace/notices.rs +++ b/src/workspace/notices.rs @@ -68,7 +68,7 @@ pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String } => (format!("{tab}:{title}"), body.clone()), TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()), TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()), - TerminalNotice::Keyword { pattern, line } => (format!("{tab}:{pattern}"), line.clone()), + TerminalNotice::Keyword { rule, line } => (format!("{tab}:{rule}"), line.clone()), } } @@ -225,7 +225,7 @@ mod tests { } fn keyword() -> TerminalNotice { - TerminalNotice::keyword("ERROR", "12:00 ERROR boom") + TerminalNotice::keyword("错误", "12:00 ERROR boom") } #[test] @@ -265,7 +265,7 @@ mod tests { ); assert_eq!( notice_text("web-01", &keyword()), - ("web-01:ERROR".to_string(), "12:00 ERROR boom".to_string()) + ("web-01:错误".to_string(), "12:00 ERROR boom".to_string()) ); } diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 264efbb..86ac371 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -1140,7 +1140,7 @@ impl Workspace { // Something may have been installed or removed since last time. integration.update(cx, |integration, cx| integration.refresh(cx)); let updater = self.updater.clone(); - let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, cx)); + let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, window, cx)); let subscription = cx.subscribe_in( &panel, window, @@ -2470,9 +2470,6 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_refresh_history)) .on_action(cx.listener(Self::on_enter_command)) .on_action(cx.listener(Self::on_copy_command)) - .on_action(cx.listener(Self::on_new_highlight_rule)) - .on_action(cx.listener(Self::on_edit_highlight_rule)) - .on_action(cx.listener(Self::on_delete_highlight_rule)) .on_action(cx.listener(Self::on_new_snippet)) .on_action(cx.listener(Self::on_new_snippet_in)) .on_action(cx.listener(Self::on_edit_snippet)) diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index ec7964f..9a934db 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -318,70 +318,12 @@ fn the_appearance_setting_drives_the_theme_and_the_title_bar_switch(cx: &mut Tes .unwrap(); } -/// The patterns of the 关键字高亮 rules, in order. -fn highlight_patterns(workspace: &Entity, cx: &App) -> Vec { - workspace - .read(cx) - .settings() - .read(cx) - .settings() - .terminal_highlight - .rules - .iter() - .map(|rule| rule.pattern.clone()) - .collect() -} - -#[gpui_kit::test] -async fn highlight_rules_are_added_edited_and_deleted_on_their_page(cx: &mut TestAppContext) { - use shellrs::terminal::{HighlightColor, HighlightRule, PatternKind, TerminalHighlights}; - - let (handle, workspace) = open_workspace(cx); - in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); - // 关键字高亮 comes right after 终端, with the examples a new - // installation starts with. - in_frame(cx, handle, |window, cx| { - window.within("settings").click("0-2", cx) - }); - in_frame(cx, handle, |window, _| { - assert!(window.find(("highlight-rule", 2usize)).visible()); - assert!(window.try_find(("highlight-rule", 3usize)).is_none()); - }); - let examples = [ - "ERROR".to_string(), - "WARN".into(), - r"\b\d{1,3}(\.\d{1,3}){3}\b".into(), - ]; - assert_eq!(cx.update(|cx| highlight_patterns(&workspace, cx)), examples); - - // A new rule takes the keyboard at once; a regex that does not compile - // is refused, the dialog staying open. - in_frame(cx, handle, |window, cx| { - window.click("add-highlight-rule", cx) - }); - in_frame(cx, handle, |window, cx| { - assert_eq!(window.find("highlight-pattern").focused(), Some(true)); - window.input("OutOf(Memory", cx); - window.within("highlight-kind").click(1usize, cx); - window.click("commit", cx); - }); - cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { - window - .try_find("form-error") - .is_some_and(|error| error.label() == Some("正则表达式写法有误")) - }) - .await; - in_frame(cx, handle, |window, cx| { - window.click("highlight-pattern", cx); - window.press("cmd-a", cx); - window.input(r"OutOfMemory\w*", cx); - window.within("highlight-color").click(5usize, cx); - window.click("highlight-bold", cx); - window.click("highlight-notify", cx); - window.click("commit", cx); - }); - cx.run_until_parked(); - let rules = cx.update(|cx| { +/// The 关键字高亮 rules, as the settings hold them. +fn highlight_rules( + workspace: &Entity, + cx: &mut TestAppContext, +) -> Vec { + cx.update(|cx| { workspace .read(cx) .settings() @@ -389,50 +331,166 @@ async fn highlight_rules_are_added_edited_and_deleted_on_their_page(cx: &mut Tes .settings() .terminal_highlight .rules - }); - assert_eq!( - rules.last(), - Some(&HighlightRule { - pattern: r"OutOfMemory\w*".into(), - kind: PatternKind::Regex, - color: HighlightColor::Magenta, - bold: true, - notify: true, - }) - ); + }) +} - // With the dialog closed nothing has focus, and the row's button still - // reaches the workspace. - in_frame(cx, handle, |window, cx| { - window.click(("edit-highlight-rule", 3usize), cx) - }); - in_frame(cx, handle, |window, cx| { - assert_eq!(window.find("highlight-pattern").focused(), Some(true)); - window.press("cmd-a", cx); - window.input("OOM", cx); - window.within("highlight-kind").click(0usize, cx); - window.click("commit", cx); - }); - cx.run_until_parked(); - assert_eq!(cx.update(|cx| highlight_patterns(&workspace, cx))[3], "OOM"); +fn patterns(rules: &[shellrs::terminal::HighlightRule]) -> Vec<&str> { + rules.iter().map(|rule| rule.pattern.as_str()).collect() +} - // Deleted once confirmed; the terminals follow. +/// Scroll the settings page down to the rules, below the window's fold. +fn scroll_to_highlight_rules(cx: &mut TestAppContext, handle: WindowHandle) { in_frame(cx, handle, |window, cx| { - window.click(("delete-highlight-rule", 0usize), cx) - }); - in_frame(cx, handle, |window, cx| window.click("ok", cx)); - cx.run_until_parked(); - cx.update(|cx| { - let patterns = highlight_patterns(&workspace, cx); - assert_eq!( - patterns, - [examples[1].clone(), examples[2].clone(), "OOM".into()] + let position = window.find("highlight-preview").bounds().center(); + window.dispatch_event( + gpui_kit::ScrollWheelEvent { + position, + delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(-600.))), + modifiers: gpui_kit::Modifiers::default(), + touch_phase: gpui_kit::TouchPhase::Moved, + } + .to_platform_input(), + cx, + ); + }); +} + +#[gpui_kit::test] +async fn highlight_rules_are_edited_in_place_on_their_page(cx: &mut TestAppContext) { + use shellrs::terminal::{HighlightColor, TerminalHighlights}; + + let (handle, workspace) = open_workspace(cx); + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + // 关键字高亮 comes right after 终端, with the examples a new + // installation starts with: rows 0 to 2. + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-2", cx) + }); + let ip = r"\b\d{1,3}(\.\d{1,3}){3}\b"; + assert_eq!( + patterns(&highlight_rules(&workspace, cx)), + ["ERROR", "WARN", ip] + ); + in_frame(cx, handle, |window, _| { + assert!(window.find(("highlight-rule", 2usize)).visible()); + assert!(window.find("highlight-preview").visible()); + }); + + // Off until turned on: the examples color nothing before. + let colored = |cx: &mut TestAppContext| { + cx.update(|cx| !TerminalHighlights::current(cx).spans("ERROR").is_empty()) + }; + assert!(!colored(cx)); + in_frame(cx, handle, |window, cx| { + // 常规 › 启用关键字高亮. + let switch = window + .within("settings") + .within("group-0") + .within("item-0") + .find("check"); + assert_eq!(switch.checked(), Some(false)); + window + .within("settings") + .within("group-0") + .within("item-0") + .click("check", cx); + }); + cx.run_until_parked(); + assert!(colored(cx)); + + // A new row takes the keyboard at once; what does not compile says so + // under it, and the settings have it as typed. + in_frame(cx, handle, |window, cx| { + window.click("add-highlight-rule", cx) + }); + scroll_to_highlight_rules(cx, handle); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find(("highlight-rule-pattern", 3usize)).focused(), + Some(true) + ); + window.input("OutOf(Memory", cx); + }); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find(("highlight-rule-error", 3usize)).label(), + Some("正则表达式写法有误") + ); + window.press("cmd-a", cx); + window.input(r"OutOfMemory\w*", cx); + }); + in_frame(cx, handle, |window, cx| { + assert!(window.try_find(("highlight-rule-error", 3usize)).is_none()); + window.click(("highlight-rule-note", 3usize), cx); + window.input("内存溢出", cx); + window.click(("highlight-rule-color", 3usize), cx); + window.press("cmd-a", cx); + window.input("#D946EF", cx); + window.click(("highlight-rule-notify", 3usize), cx); + window.click(("highlight-rule-enabled", 0usize), cx); + }); + cx.run_until_parked(); + let rules = highlight_rules(&workspace, cx); + assert_eq!(rules[3].pattern, r"OutOfMemory\w*"); + assert_eq!(rules[3].note, "内存溢出"); + assert_eq!(rules[3].color, HighlightColor::from_hex("#d946ef").unwrap()); + assert!(rules[3].enabled && rules[3].notify); + assert!(!rules[0].enabled); + + // The color picker opens on its palette. (Its swatches are keyed by + // color; the a11y tree that would catch two of one color is not built + // in tests, see FEATURED_COLORS.) + in_frame(cx, handle, |window, cx| { + window.click(("highlight-rule-picker", 3usize), cx) + }); + in_frame(cx, handle, |window, cx| window.press("escape", cx)); + + // Dragged by its handle, the new row goes first. + in_frame(cx, handle, |window, cx| { + window.drag_to( + ("highlight-rule-handle", 3usize), + ("highlight-rule", 0usize), + cx, + ) + }); + cx.run_until_parked(); + assert_eq!( + patterns(&highlight_rules(&workspace, cx)), + [r"OutOfMemory\w*", "ERROR", "WARN", ip] + ); + + // Deleted at once, WARN goes; the terminals follow every change. + in_frame(cx, handle, |window, cx| { + window.click(("delete-highlight-rule", 1usize), cx) + }); + cx.run_until_parked(); + assert_eq!( + patterns(&highlight_rules(&workspace, cx)), + [r"OutOfMemory\w*", "ERROR", ip] + ); + cx.update(|cx| { + let colored = TerminalHighlights::current(cx).spans("OutOfMemoryError ERROR 10.0.0.1"); + // ERROR is off; the new rule and the address color. + assert_eq!( + colored + .iter() + .map(|(range, _)| range.clone()) + .collect::>(), + [0..16, 23..31] + ); + }); + + // 启用关键字高亮 off, nothing colors. + let settings = cx.update(|cx| workspace.read(cx).settings().clone()); + settings.update(cx, |settings, cx| { + settings.update(|settings| settings.terminal_highlight.enabled = false, cx) + }); + cx.run_until_parked(); + cx.update(|cx| { + assert!( + TerminalHighlights::current(cx) + .spans("OutOfMemoryError") + .is_empty() ); - let in_effect: Vec<_> = TerminalHighlights::current(cx) - .rules() - .iter() - .map(|rule| rule.pattern.clone()) - .collect(); - assert_eq!(in_effect, patterns); }); } diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 3ad25e2..49d9a40 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -1001,7 +1001,8 @@ async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) { }); } -/// Have the settings hold `rules`, as the 关键字高亮 page writes them. +/// Have the settings hold `rules`, as the 关键字高亮 page writes them, with +/// highlighting on. fn set_highlight_rules( cx: &mut TestAppContext, workspace: &Entity, @@ -1009,7 +1010,13 @@ fn set_highlight_rules( ) { let settings = cx.update(|cx| workspace.read(cx).settings().clone()); settings.update(cx, |settings, cx| { - settings.update(|settings| settings.terminal_highlight.rules = rules, cx) + settings.update( + |settings| { + settings.terminal_highlight.enabled = true; + settings.terminal_highlight.rules = rules; + }, + cx, + ) }); cx.run_until_parked(); }