From befdfc7b02a015fb4e779560d157b785e63c81e8 Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 13:26:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E8=AF=86=E5=88=AB=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E3=80=81=E6=94=AF=E6=8C=81=E9=BC=A0=E6=A0=87=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E5=92=8C=20OSC=2052=20=E8=BF=9C=E7=A8=8B=E5=A4=8D?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 链接:输出里的 http / https 地址和程序用 OSC 8 标出的链接一直画成蓝色加 下划线,⌘(其他平台 Ctrl)单击用浏览器打开,悬停显示真实地址;末尾标点 和不成对的括号不算,折行的长地址算一个,其他协议不当链接。 - 鼠标上报:vim、htop、tmux 等要求鼠标时,单击、拖动和滚轮交给程序 (1000 / 1002 / 1003,默认、UTF-8、SGR 编码);按住 Shift 照旧选文字和 翻回滚,右键始终是 ShellRS 的菜单。 - OSC 52:远程程序复制的内容进本机剪贴板,只写不读,主选择区忽略。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 + docs/manual.md | 3 + src/terminal/engine.rs | 172 +++++++++++++- src/terminal/links.rs | 166 +++++++++++++ src/terminal/mod.rs | 2 + src/terminal/mouse.rs | 185 +++++++++++++++ src/terminal/terminal_view.rs | 435 +++++++++++++++++++++++++++++++--- tests/ui/support/terminal.rs | 12 + tests/ui/terminal.rs | 132 ++++++++++- 9 files changed, 1073 insertions(+), 37 deletions(-) create mode 100644 src/terminal/links.rs create mode 100644 src/terminal/mouse.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5e55589..919443d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,6 +246,9 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。 - **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。 - **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。 +- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 +- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。 +- **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。 ## 测试约定 diff --git a/docs/manual.md b/docs/manual.md index 175a76a..14afa26 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -171,6 +171,9 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。 - **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。 +- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。 +- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。 +- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。 ## 右侧栏 diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 7561f4b..2319b08 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -12,12 +12,14 @@ use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::COUNT; -use alacritty_terminal::term::{Config, TermMode}; +use alacritty_terminal::term::search::RegexSearch; +use alacritty_terminal::term::{ClipboardType, Config, Osc52, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb}; use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency}; use crate::host::HostOs; +use super::links::{LinkMarker, url_search, visible_links}; use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; use super::{ ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, @@ -34,6 +36,8 @@ pub struct TerminalCell { pub flags: Flags, pub selected: bool, pub search: SearchMark, + /// The link the cell belongs to, an index into the snapshot's `links`. + pub link: Option, } #[derive(Clone, Debug)] @@ -47,6 +51,8 @@ pub struct TerminalSnapshot { pub cursor_blinking: bool, pub display_offset: usize, pub colors: [Option; COUNT], + /// The addresses of the links on screen, by the cells' `link`. + pub links: Vec, } impl TerminalSnapshot { @@ -76,6 +82,8 @@ pub struct TerminalEngine { /// The open find, if any. Rendering reads it through `&self` and still /// has to advance the search engine's cache, hence the `RefCell`. search: RefCell>, + /// Finds the web addresses on screen, built once. + url_search: RefCell, generation: u64, size: TerminalSize, event_sender: mpsc::Sender, @@ -99,6 +107,7 @@ impl TerminalEngine { title: None, latency: None, search: RefCell::new(None), + url_search: RefCell::new(url_search()), generation, size, event_sender, @@ -122,7 +131,7 @@ impl TerminalEngine { && this .update(cx, |this, cx| { for event in batch { - if let Some(event) = this.handle_event(event) { + if let Some(event) = this.handle_event(event, cx) { cx.emit(event); } } @@ -138,7 +147,11 @@ impl TerminalEngine { /// Fold one transport event into the engine's state. Anything the view /// must hear about comes back as an event to emit. - fn handle_event(&mut self, event: TerminalUiEvent) -> Option { + fn handle_event( + &mut self, + event: TerminalUiEvent, + cx: &mut gpui_kit::App, + ) -> Option { if event.generation != self.generation { return None; } @@ -170,6 +183,9 @@ impl TerminalEngine { .unwrap_or_else(|| default_query_color(index)); self.runtime.write(formatter(color).into_bytes()); } + TerminalUiEventKind::ClipboardStore(text) => { + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text)); + } } None } @@ -453,6 +469,7 @@ impl TerminalEngine { .map(|search| search.visible(&term)) .unwrap_or_default(); let mut marker = MatchMarker::new(&matches, focused.as_ref()); + let mut links = LinkMarker::new(visible_links(&term, &mut self.url_search.borrow_mut())); let mut cells: Vec<_> = content .display_iter .map(|indexed| { @@ -469,6 +486,7 @@ impl TerminalEngine { range.contains_cell(&indexed, cursor_point, cursor_shape) }), search: marker.mark(indexed.point), + link: links.mark(indexed.point, indexed.cell.hyperlink()), } }) .collect(); @@ -485,6 +503,7 @@ impl TerminalEngine { cursor_blinking: term.cursor_style().blinking, display_offset: content.display_offset, colors: std::array::from_fn(|index| content.colors[index]), + links: links.into_links(), } } } @@ -665,7 +684,8 @@ fn terminal_config() -> Config { shape: CursorShape::Block, blinking: true, }, - osc52: alacritty_terminal::term::Osc52::Disabled, + // Programs may copy to the clipboard (vim, tmux), never read it. + osc52: Osc52::OnlyCopy, kitty_keyboard: false, ..Config::default() } @@ -717,6 +737,11 @@ impl EventListener for TerminalEventProxy { | Event::Bell | Event::Exit | Event::ChildExit(_) => self.wakeup(), + // Only the clipboard: a program setting the primary selection + // on every selection must not overwrite it. + Event::ClipboardStore(ClipboardType::Clipboard, text) => { + self.send(TerminalUiEventKind::ClipboardStore(text)); + } Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {} } } @@ -748,12 +773,17 @@ enum TerminalUiEventKind { Wakeup, Title(String), ResetTitle, - Exited { code: u32, signal: Option }, + Exited { + code: u32, + signal: Option, + }, Failed(String), Prompt(ConnectionPrompt), HostOs(HostOs), Latency(Latency), ColorRequest(usize, Arc String + Send + Sync>), + /// A program's copy, by OSC 52. + ClipboardStore(String), } fn append_message(term: &Arc>, message: &str) { @@ -871,11 +901,24 @@ mod tests { use alacritty_terminal::vte::ansi::NamedColor; fn test_term(columns: usize, rows: usize) -> AlacrittyTerm { + test_term_with_channels(columns, rows).0 + } + + /// A terminal, with what it writes back to the program and what it + /// tells the UI. + fn test_term_with_channels( + columns: usize, + rows: usize, + ) -> ( + AlacrittyTerm, + mpsc::Receiver, + mpsc::Receiver, + ) { let size = TerminalSize::new(columns, rows, 8, 16); - let (commands, _command_receiver) = mpsc::channel(); - let (ui_events, _ui_receiver) = mpsc::channel(); + let (commands, command_receiver) = mpsc::channel(); + let (ui_events, ui_receiver) = mpsc::channel(); let config = terminal_config(); - AlacrittyTerm::new( + let term = AlacrittyTerm::new( config, &size, TerminalEventProxy { @@ -885,7 +928,117 @@ mod tests { size: Arc::new(Mutex::new(size)), wakeup_pending: Arc::new(AtomicBool::new(false)), }, - ) + ); + (term, command_receiver, ui_receiver) + } + + /// Each link on screen: the text of its cells and where it leads. + fn screen_links(term: &AlacrittyTerm) -> Vec<(String, String)> { + let mut marker = LinkMarker::new(visible_links(term, &mut url_search())); + let mut texts: Vec = Vec::new(); + for indexed in term.renderable_content().display_iter { + let Some(link) = marker.mark(indexed.point, indexed.cell.hyperlink()) else { + continue; + }; + if texts.len() <= link { + texts.resize(link + 1, String::new()); + } + if !indexed.cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + texts[link].push(indexed.cell.c); + } + } + let links = marker.into_links(); + texts + .into_iter() + .zip(links.iter().map(ToString::to_string)) + .collect() + } + + fn link(text: &str, uri: &str) -> (String, String) { + (text.to_string(), uri.to_string()) + } + + #[test] + fn web_addresses_in_the_text_are_links_without_the_sentence_around_them() { + let mut term = test_term(80, 4); + feed( + &mut term, + "Docs: https://help.ubuntu.com. (see https://a.b/c)\r\n详见https://a.b/d,谢谢\r\n" + .as_bytes(), + ); + feed( + &mut term, + b"https://en.wikipedia.org/wiki/Rust_(lang) ftp://a.b www.c.d\r\n", + ); + assert_eq!( + screen_links(&term), + [ + link("https://help.ubuntu.com", "https://help.ubuntu.com"), + link("https://a.b/c", "https://a.b/c"), + link("https://a.b/d", "https://a.b/d"), + link( + "https://en.wikipedia.org/wiki/Rust_(lang)", + "https://en.wikipedia.org/wiki/Rust_(lang)" + ), + ] + ); + } + + #[test] + fn a_wrapped_address_is_one_link() { + let mut term = test_term(20, 4); + feed(&mut term, b"go https://example.com/a/long/path now\r\n"); + let url = "https://example.com/a/long/path"; + assert_eq!(screen_links(&term), [link(url, url)]); + } + + #[test] + fn a_marked_link_leads_where_it_says_and_only_to_the_web() { + let mut term = test_term(80, 4); + feed( + &mut term, + "\x1b]8;;https://example.com/real\x1b\\示例\x1b]8;;\x1b\\ ".as_bytes(), + ); + // The text of a marked link is not looked at: it is the mark that + // says where it goes. + feed( + &mut term, + b"\x1b]8;;https://x.y/\x1b\\https://elsewhere.z\x1b]8;;\x1b\\\r\n", + ); + feed( + &mut term, + b"\x1b]8;;file://host/etc/hosts\x1b\\hosts\x1b]8;;\x1b\\\r\n", + ); + assert_eq!( + screen_links(&term), + [ + link("示例", "https://example.com/real"), + link("https://elsewhere.z", "https://x.y/"), + ] + ); + } + + #[test] + fn programs_may_copy_to_the_clipboard_but_not_read_it() { + let (mut term, commands, ui_events) = test_term_with_channels(20, 2); + feed(&mut term, b"\x1b]52;c;aGVsbG8=\x07"); + let copied: Vec<_> = ui_events + .try_iter() + .filter_map(|event| match event.kind { + TerminalUiEventKind::ClipboardStore(text) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(copied, ["hello"]); + // The primary selection is left alone, and asking for the clipboard + // gets no answer. + feed(&mut term, b"\x1b]52;p;aGVsbG8=\x07\x1b]52;c;?\x07"); + assert!( + ui_events + .try_iter() + .all(|event| !matches!(event.kind, TerminalUiEventKind::ClipboardStore(_))) + ); + assert!(commands.try_iter().next().is_none()); } fn feed(term: &mut AlacrittyTerm, bytes: &[u8]) { @@ -1168,6 +1321,7 @@ mod tests { } else { SearchMark::None }, + link: None, }; let mut cells = vec![ cell(Flags::WIDE_CHAR, true), diff --git a/src/terminal/links.rs b/src/terminal/links.rs new file mode 100644 index 0000000..f741e5b --- /dev/null +++ b/src/terminal/links.rs @@ -0,0 +1,166 @@ +//! Links in the terminal: web addresses found in the text, and the ones a +//! program marks itself with OSC 8 (`ls --hyperlink`, gcc, systemd), whose +//! text may differ from where they lead. +//! +//! Only `http` and `https` count. Any other scheme names something on the +//! server (`file://`) or starts a local program, neither of which a click in +//! a remote terminal should open. + +use alacritty_terminal::index::{Boundary, Point}; +use alacritty_terminal::term::Term; +use alacritty_terminal::term::cell::Hyperlink; +use alacritty_terminal::term::search::{Match, RegexSearch}; +use gpui_kit::SharedString; + +use super::search::visible_matches; + +/// A web address in the text: the characters RFC 3986 allows after +/// `http://` or `https://`. Written in lower case, so the search ignores +/// case. Anything else, a space or a Chinese comma, ends it. +const URL_PATTERN: &str = r"https?://[a-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+"; + +pub fn url_search() -> RegexSearch { + RegexSearch::new(URL_PATTERN).expect("the link pattern is valid") +} + +pub fn is_web_link(uri: &str) -> bool { + let lower = uri.get(..8).unwrap_or(uri).to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") +} + +/// `text` without what ends a sentence rather than the address: a full +/// stop, a comma, a quote, or a closing bracket with no opening one inside. +/// `None` when nothing but the scheme is left. +fn trim_url(text: &str) -> Option<&str> { + let mut url = text; + while let Some(last) = url.chars().last() { + let unbalanced = |open: char, close: char| { + last == close && url.matches(open).count() < url.matches(close).count() + }; + if ".,;:!?'\"*".contains(last) || unbalanced('(', ')') || unbalanced('[', ']') { + url = &url[..url.len() - last.len_utf8()]; + } else { + break; + } + } + let (_, rest) = url.split_once("://")?; + (!rest.is_empty()).then_some(url) +} + +/// The web addresses on screen, in reading order, each with the cells it +/// covers. One that wraps onto the next line is one link. +pub fn visible_links(term: &Term, regex: &mut RegexSearch) -> Vec<(Match, SharedString)> { + visible_matches(term, regex) + .into_iter() + .filter_map(|found| { + let text = term.bounds_to_string(*found.start(), *found.end()); + let url = trim_url(&text)?; + // What was cut is ASCII punctuation, one cell a character. + let cut = text[url.len()..].chars().count(); + let end = found.end().sub(term, Boundary::Grid, cut); + Some((*found.start()..=end, SharedString::from(url.to_string()))) + }) + .collect() +} + +/// Tells, cell by cell in reading order, which link a cell belongs to, and +/// collects the links' addresses. A link marked with OSC 8 takes its cells +/// over any address found in their text; the cells of one such link (the +/// same id and address) are one link wherever they are, as the spec asks. +pub struct LinkMarker { + found: Vec<(Match, SharedString)>, + next: usize, + /// The link the found address `next` became, once a cell of it is marked. + next_link: Option, + marked: Vec<(Hyperlink, usize)>, + links: Vec, +} + +impl LinkMarker { + /// `found` must be in reading order, as `visible_links` returns it. + pub fn new(found: Vec<(Match, SharedString)>) -> Self { + Self { + found, + next: 0, + next_link: None, + marked: Vec::new(), + links: Vec::new(), + } + } + + /// The link of the cell at `point`, which carries `hyperlink`. Points + /// must come in reading order. + pub fn mark(&mut self, point: Point, hyperlink: Option) -> Option { + if let Some(hyperlink) = hyperlink.filter(|link| is_web_link(link.uri())) { + if let Some((_, link)) = self.marked.iter().find(|(seen, _)| *seen == hyperlink) { + return Some(*link); + } + let link = self.push(hyperlink.uri()); + self.marked.push((hyperlink, link)); + return Some(link); + } + while self + .found + .get(self.next) + .is_some_and(|(found, _)| *found.end() < point) + { + self.next += 1; + self.next_link = None; + } + let (found, url) = self.found.get(self.next)?; + if !found.contains(&point) { + return None; + } + if self.next_link.is_none() { + let url = url.clone(); + self.next_link = Some(self.push(&url)); + } + self.next_link + } + + /// The addresses, by the numbers `mark` gave. + pub fn into_links(self) -> Vec { + self.links + } + + fn push(&mut self, uri: &str) -> usize { + self.links.push(uri.to_string().into()); + self.links.len() - 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sentence_punctuation_and_unmatched_brackets_end_an_address() { + assert_eq!( + trim_url("https://help.ubuntu.com."), + Some("https://help.ubuntu.com") + ); + assert_eq!( + trim_url("https://example.com/a?b=1,"), + Some("https://example.com/a?b=1") + ); + assert_eq!(trim_url("https://a.b/c)"), Some("https://a.b/c")); + assert_eq!(trim_url("https://a.b/c)."), Some("https://a.b/c")); + assert_eq!( + trim_url("https://en.wikipedia.org/wiki/Rust_(programming_language)"), + Some("https://en.wikipedia.org/wiki/Rust_(programming_language)") + ); + assert_eq!(trim_url("https://a.b/[1]"), Some("https://a.b/[1]")); + assert_eq!(trim_url("https://a.b/c'"), Some("https://a.b/c")); + assert_eq!(trim_url("https://."), None); + assert_eq!(trim_url("http://"), None); + } + + #[test] + fn only_web_addresses_are_links() { + assert!(is_web_link("https://example.com")); + assert!(is_web_link("HTTP://example.com")); + assert!(!is_web_link("file://host/etc/passwd")); + assert!(!is_web_link("mailto:a@b.c")); + assert!(!is_web_link("http")); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index c0009d9..0b71817 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -3,9 +3,11 @@ mod engine; mod exec; mod font; +mod links; mod local_pty; mod local_terminal_panel; mod model; +mod mouse; mod search; mod terminal_panel; mod terminal_view; diff --git a/src/terminal/mouse.rs b/src/terminal/mouse.rs new file mode 100644 index 0000000..3ee87fa --- /dev/null +++ b/src/terminal/mouse.rs @@ -0,0 +1,185 @@ +//! Mouse reporting: what a program that asked for the mouse (vim with +//! `mouse=a`, htop, tmux) is told of a click, a drag or the wheel, in the +//! encodings xterm defines. + +use alacritty_terminal::term::TermMode; + +/// The button a report names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReportButton { + Left, + Middle, + WheelUp, + WheelDown, + /// Motion with no button held, which only 1003 asks for. + None, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReportKind { + Press, + Release, + Motion, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MouseReport { + pub button: ReportButton, + pub kind: ReportKind, + /// The cell, counted from the top left of the screen from 0. + pub column: usize, + pub row: usize, + pub alt: bool, + pub control: bool, +} + +/// Whether the program asked to hear of `kind`: 1000 reports presses, +/// releases and the wheel, 1002 motion while a button is held as well, 1003 +/// all motion. +pub fn wants(mode: TermMode, kind: ReportKind, button_held: bool) -> bool { + match kind { + ReportKind::Press | ReportKind::Release => mode.intersects(TermMode::MOUSE_MODE), + ReportKind::Motion => { + mode.contains(TermMode::MOUSE_MOTION) + || (button_held && mode.contains(TermMode::MOUSE_DRAG)) + } + } +} + +/// The bytes that tell the program of `report`. `None` where the encoding +/// in use cannot say it: the default one stops at column and row 223. +pub fn encode(report: MouseReport, mode: TermMode) -> Option> { + let button = match report.button { + ReportButton::Left => 0, + ReportButton::Middle => 1, + ReportButton::None => 3, + ReportButton::WheelUp => 64, + ReportButton::WheelDown => 65, + }; + let modifiers = 8 * u32::from(report.alt) + 16 * u32::from(report.control); + let motion = if report.kind == ReportKind::Motion { + 32 + } else { + 0 + }; + let column = report.column as u32 + 1; + let row = report.row as u32 + 1; + if mode.contains(TermMode::SGR_MOUSE) { + let end = if report.kind == ReportKind::Release { + 'm' + } else { + 'M' + }; + let code = button + modifiers + motion; + return Some(format!("\x1b[<{code};{column};{row}{end}").into_bytes()); + } + // The older encodings cannot tell which button went up. + let code = if report.kind == ReportKind::Release { + 3 + modifiers + } else { + button + modifiers + motion + }; + let mut bytes = b"\x1b[M".to_vec(); + bytes.push(32 + code as u8); + for value in [column, row] { + let value = 32 + value; + if mode.contains(TermMode::UTF8_MOUSE) { + // Two UTF-8 bytes at most, as in xterm. + let character = char::from_u32(value).filter(|_| value < 0x800)?; + let mut buffer = [0; 4]; + bytes.extend_from_slice(character.encode_utf8(&mut buffer).as_bytes()); + } else { + bytes.push(u8::try_from(value).ok()?); + } + } + Some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(button: ReportButton, kind: ReportKind, column: usize, row: usize) -> MouseReport { + MouseReport { + button, + kind, + column, + row, + alt: false, + control: false, + } + } + + fn sgr() -> TermMode { + TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE + } + + #[test] + fn sgr_reports_name_the_button_both_ways_and_count_from_one() { + let press = report(ReportButton::Left, ReportKind::Press, 2, 0); + assert_eq!(encode(press, sgr()).unwrap(), b"\x1b[<0;3;1M"); + let release = report(ReportButton::Left, ReportKind::Release, 2, 0); + assert_eq!(encode(release, sgr()).unwrap(), b"\x1b[<0;3;1m"); + let middle = report(ReportButton::Middle, ReportKind::Press, 0, 4); + assert_eq!(encode(middle, sgr()).unwrap(), b"\x1b[<1;1;5M"); + let drag = report(ReportButton::Left, ReportKind::Motion, 9, 9); + assert_eq!(encode(drag, sgr()).unwrap(), b"\x1b[<32;10;10M"); + let wheel = report(ReportButton::WheelDown, ReportKind::Press, 0, 0); + assert_eq!(encode(wheel, sgr()).unwrap(), b"\x1b[<65;1;1M"); + let held = MouseReport { + alt: true, + control: true, + ..report(ReportButton::WheelUp, ReportKind::Press, 0, 0) + }; + assert_eq!(encode(held, sgr()).unwrap(), b"\x1b[<88;1;1M"); + // SGR has no limit. + let far = report(ReportButton::Left, ReportKind::Press, 499, 299); + assert_eq!(encode(far, sgr()).unwrap(), b"\x1b[<0;500;300M"); + } + + #[test] + fn the_default_encoding_adds_32_and_stops_at_223() { + let mode = TermMode::MOUSE_REPORT_CLICK; + let press = report(ReportButton::Left, ReportKind::Press, 2, 0); + assert_eq!(encode(press, mode).unwrap(), b"\x1b[M #!"); + // A release does not say which button. + let release = MouseReport { + control: true, + ..report(ReportButton::Middle, ReportKind::Release, 2, 0) + }; + assert_eq!(encode(release, mode).unwrap(), b"\x1b[M3#!"); + let edge = report(ReportButton::Left, ReportKind::Press, 222, 0); + assert_eq!(encode(edge, mode).unwrap(), b"\x1b[M \xff!"); + let beyond = report(ReportButton::Left, ReportKind::Press, 223, 0); + assert_eq!(encode(beyond, mode), None); + } + + #[test] + fn the_utf8_encoding_writes_large_coordinates_as_characters() { + let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::UTF8_MOUSE; + let near = report(ReportButton::Left, ReportKind::Press, 2, 0); + assert_eq!(encode(near, mode).unwrap(), b"\x1b[M #!"); + let far = report(ReportButton::Left, ReportKind::Press, 299, 0); + let mut expected = b"\x1b[M ".to_vec(); + expected.extend_from_slice("\u{14c}!".as_bytes()); + assert_eq!(encode(far, mode).unwrap(), expected); + let beyond = report(ReportButton::Left, ReportKind::Press, 2015, 0); + assert_eq!(encode(beyond, mode), None); + } + + #[test] + fn each_mode_asks_for_its_own_events() { + let click = TermMode::MOUSE_REPORT_CLICK; + let drag = TermMode::MOUSE_DRAG; + let motion = TermMode::MOUSE_MOTION; + for mode in [click, drag, motion] { + assert!(wants(mode, ReportKind::Press, false)); + assert!(wants(mode, ReportKind::Release, true)); + } + assert!(!wants(click, ReportKind::Motion, true)); + assert!(wants(drag, ReportKind::Motion, true)); + assert!(!wants(drag, ReportKind::Motion, false)); + assert!(wants(motion, ReportKind::Motion, false)); + assert!(!wants(TermMode::empty(), ReportKind::Press, false)); + } +} diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 89b700e..33831fe 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -14,6 +14,8 @@ use gpui_kit::component::{ h_flex, input::{Input, InputEvent, InputState}, menu::PopupMenu, + tooltip::Tooltip, + v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; @@ -25,6 +27,7 @@ use crate::app::{ }; use crate::connection::{ConnectionPromptReply, Latency}; +use super::mouse::{self, MouseReport, ReportButton, ReportKind}; use super::search::SearchMark; use super::{ SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, TerminalFont, @@ -40,6 +43,25 @@ pub const TERMINAL_FIND_KEY_CONTEXT: &str = "TerminalFind"; /// the query to settle rather than running on every keystroke. const FIND_DEBOUNCE: Duration = Duration::from_millis(80); +/// How long the pointer rests on a link before its address shows. +const LINK_TOOLTIP_DELAY: Duration = Duration::from_millis(500); + +/// The key held to open a link with a click, as the tooltip names it. +#[cfg(target_os = "macos")] +const OPEN_LINK_KEY: &str = "⌘"; +#[cfg(not(target_os = "macos"))] +const OPEN_LINK_KEY: &str = "Ctrl"; + +/// Whether `modifiers` hold the key that opens a link: ⌘, or Ctrl where +/// there is no ⌘. A plain click selects text, as it always has. +fn opens_links(modifiers: &Modifiers) -> bool { + if cfg!(target_os = "macos") { + modifiers.platform + } else { + modifiers.control + } +} + /// The commands a terminal's owner adds to the bottom of its context menu, /// built from the terminal's lifecycle when the menu opens. pub type TerminalMenuItems = Rc PopupMenu>; @@ -65,6 +87,8 @@ pub(crate) fn terminal_key_bindings() -> [KeyBinding; 2] { enum ScrollTarget { History, AlternateScreen, + /// The program asked for the mouse: the wheel is reported to it. + Report, } #[derive(Clone, Copy, Debug, Default)] @@ -130,6 +154,21 @@ struct TerminalViewport { } impl TerminalViewport { + /// The cell on screen under `position`, if it is over the grid. + fn cell_at(&self, position: gpui_kit::Point) -> Option<(usize, usize)> { + screen_cell(&self.geometry, self.columns, self.rows, position) + } + + /// The cell on screen nearest `position`, which may be outside: where a + /// drag that left the terminal is reported. + fn nearest_cell(&self, position: gpui_kit::Point) -> (usize, usize) { + let (point, _) = self.grid_point(position); + ( + point.column.0, + (point.line.0 + self.display_offset as i32).max(0) as usize, + ) + } + fn grid_point(&self, position: gpui_kit::Point) -> (TerminalPoint, Side) { grid_point( position, @@ -143,6 +182,15 @@ impl TerminalViewport { } } +/// The link under the pointer, and where the pointer is. +#[derive(Clone, Debug, PartialEq)] +struct HoveredLink { + /// Which link of the snapshot, as the cells number them. + index: usize, + uri: SharedString, + position: gpui_kit::Point, +} + pub struct TerminalView { engine: Entity, element_id: ElementId, @@ -162,6 +210,14 @@ pub struct TerminalView { find: Option, cursor_visible: bool, focused: bool, + hovered_link: Option, + /// The hovered link's address shows, the pointer having rested on it. + link_tooltip: bool, + link_tooltip_task: Option>, + /// The button the program was told went down, until it hears it go up. + reported_button: Option, + /// The cell the program last heard of: motion is reported once a cell. + reported_cell: Option<(usize, usize)>, _subscriptions: Vec, _blink_task: Task<()>, } @@ -236,6 +292,11 @@ impl TerminalView { find: None, cursor_visible: true, focused: false, + hovered_link: None, + link_tooltip: false, + link_tooltip_task: None, + reported_button: None, + reported_cell: None, _subscriptions: subscriptions, _blink_task: blink_task, } @@ -280,6 +341,11 @@ impl TerminalView { self.context_menu_subscription = None; self.find = None; self.cursor_visible = true; + self.hovered_link = None; + self.link_tooltip = false; + self.link_tooltip_task = None; + self.reported_button = None; + self.reported_cell = None; } pub fn shutdown(&mut self, cx: &mut Context) { @@ -496,9 +562,19 @@ impl TerminalView { self.engine.update(cx, |engine, cx| engine.resize(size, cx)); } - fn scroll(&mut self, delta: ScrollDelta, line_height: Pixels, cx: &mut Context) { + fn scroll( + &mut self, + delta: ScrollDelta, + line_height: Pixels, + cell: (usize, usize), + modifiers: Modifiers, + cx: &mut Context, + ) { let mode = self.engine.read(cx).mode(); - let target = if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) { + // Shift scrolls the history even while the program has the mouse. + let target = if mode.intersects(TermMode::MOUSE_MODE) && !modifiers.shift { + ScrollTarget::Report + } else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) { ScrollTarget::AlternateScreen } else { ScrollTarget::History @@ -507,7 +583,18 @@ impl TerminalView { if lines == 0 { return; } - if target == ScrollTarget::AlternateScreen { + // The text under the pointer moves on. + self.hover_link(None, cx); + if target == ScrollTarget::Report { + let button = if lines > 0 { + ReportButton::WheelUp + } else { + ReportButton::WheelDown + }; + for _ in 0..lines.unsigned_abs().min(12) { + self.report_mouse(button, ReportKind::Press, cell, modifiers, cx); + } + } else if target == ScrollTarget::AlternateScreen { let sequence = if lines > 0 { b"\x1bOA" } else { b"\x1bOB" }; for _ in 0..lines.unsigned_abs().min(12) { self.engine.read(cx).write(sequence.to_vec()); @@ -536,7 +623,87 @@ impl TerminalView { .update(cx, |engine, cx| engine.update_selection(point, side, cx)); } + /// Tell the program of the mouse, if it asked to hear of it. Shift keeps + /// the mouse for selecting text, unless a reported button is down. + /// Whether the program was told. + fn report_mouse( + &mut self, + button: ReportButton, + kind: ReportKind, + cell: (usize, usize), + modifiers: Modifiers, + cx: &mut Context, + ) -> bool { + let mode = self.engine.read(cx).mode(); + let held = self.reported_button.is_some(); + if (modifiers.shift && !held) || !mouse::wants(mode, kind, held) { + return false; + } + let button = match kind { + ReportKind::Motion if self.reported_cell == Some(cell) => return true, + ReportKind::Motion => self.reported_button.unwrap_or(ReportButton::None), + _ => button, + }; + let report = MouseReport { + button, + kind, + column: cell.0, + row: cell.1, + alt: modifiers.alt, + control: modifiers.control, + }; + if let Some(bytes) = mouse::encode(report, mode) { + self.engine.read(cx).write(bytes); + } + self.reported_cell = Some(cell); + if kind == ReportKind::Press && matches!(button, ReportButton::Left | ReportButton::Middle) + { + self.reported_button = Some(button); + } + true + } + + /// A button went up: the program hears of it if it heard it go down. + fn release_mouse( + &mut self, + button: ReportButton, + cell: (usize, usize), + modifiers: Modifiers, + cx: &mut Context, + ) { + if self.reported_button == Some(button) { + self.report_mouse(button, ReportKind::Release, cell, modifiers, cx); + self.reported_button = None; + } + } + + /// The pointer moved onto a link or off one. Its address shows once the + /// pointer rests there. + fn hover_link(&mut self, link: Option, cx: &mut Context) { + let same = match (&self.hovered_link, &link) { + (Some(hovered), Some(link)) => hovered.index == link.index && hovered.uri == link.uri, + (None, None) => true, + _ => false, + }; + if same { + return; + } + self.link_tooltip = false; + self.link_tooltip_task = link.as_ref().map(|_| { + cx.spawn(async move |this, cx| { + cx.background_executor().timer(LINK_TOOLTIP_DELAY).await; + let _ = this.update(cx, |this, cx| { + this.link_tooltip = true; + cx.notify(); + }); + }) + }); + self.hovered_link = link; + cx.notify(); + } + fn send_user_input(&mut self, bytes: impl Into>, cx: &mut Context) { + self.hover_link(None, cx); self.selecting = false; self.scroll_accumulator.reset(); self.cursor_visible = true; @@ -730,7 +897,7 @@ impl EntityInputHandler for TerminalView { } impl Render for TerminalView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let snapshot = self.engine.read(cx).snapshot(); let view = cx.entity(); let context_menu = self.context_menu.clone(); @@ -773,7 +940,22 @@ impl Render for TerminalView { ) .with_priority(gpui_kit::base::POPUP_PRIORITY), ) - }); + }) + // Below the pointer, so the pointer stays on the link. + .when_some( + self.hovered_link.clone().filter(|_| self.link_tooltip), + |terminal, link| { + terminal.child( + deferred( + anchored() + .position(link.position + point(px(0.), px(20.))) + .snap_to_window_with_margin(px(8.)) + .child(link_tooltip(link.uri, window, cx)), + ) + .with_priority(1), + ) + }, + ); // The find bar is the terminal's sibling, not its child: keys typed // into it must not bubble to the terminal's key handler, which would @@ -789,6 +971,24 @@ impl Render for TerminalView { } } +/// Where a link leads and how to open it: the address may not be its text. +fn link_tooltip(uri: SharedString, window: &mut Window, cx: &mut App) -> AnyView { + Tooltip::element(move |_, cx| { + v_flex() + .id("terminal-link-tooltip") + .test_support() + .aria_label(uri.clone()) + .max_w(rems(32.)) + .child(uri.clone()) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(format!("{OPEN_LINK_KEY} 单击打开")), + ) + }) + .build(window, cx) +} + impl TerminalView { fn render_find_bar(&self, find: &FindBar, cx: &App) -> impl IntoElement { let has_query = !find.input.read(cx).value().is_empty(); @@ -975,6 +1175,19 @@ impl Element for TerminalElement { cell_width, line_height, }; + // The link under the pointer, lit a little brighter. + let hovered_link = screen_cell( + &geometry, + self.snapshot.columns, + self.snapshot.rows, + window.mouse_position(), + ) + .and_then(|(column, row)| { + self.snapshot + .cells + .get(row * self.snapshot.columns + column)? + .link + }); let mut lines = Vec::with_capacity(self.snapshot.rows); let mut backgrounds = Vec::new(); for (row_index, row) in self @@ -1022,6 +1235,16 @@ impl Element for TerminalElement { let font = terminal_font(text_style.font(), cell.flags); let len = character.len(); text.push_str(character); + // Links always show as links, blue whatever color the + // program gave them. Not the theme's `link`: that is the + // color of text. + let link = cell.link.map(|link| { + if hovered_link == Some(link) { + cx.theme().blue_light + } else { + cx.theme().blue + } + }); runs.push(TextRun { len, font, @@ -1030,16 +1253,25 @@ impl Element for TerminalElement { } else if cell.search == SearchMark::Focused { cx.theme().warning_foreground } else { - foreground + link.unwrap_or(foreground) }, background_color: None, - underline: cell.flags.intersects(Flags::ALL_UNDERLINES).then_some( - UnderlineStyle { + underline: match link { + Some(color) => Some(UnderlineStyle { thickness: px(1.), - color: Some(foreground), - wavy: cell.flags.contains(Flags::UNDERCURL), - }, - ), + color: Some(color), + wavy: false, + }), + None => { + cell.flags + .intersects(Flags::ALL_UNDERLINES) + .then_some(UnderlineStyle { + thickness: px(1.), + color: Some(foreground), + wavy: cell.flags.contains(Flags::UNDERCURL), + }) + } + }, strikethrough: cell.flags.contains(Flags::STRIKEOUT).then_some( StrikethroughStyle { thickness: px(1.), @@ -1176,21 +1408,125 @@ impl Element for TerminalElement { let hitbox = state.hitbox.clone(); let view = self.view.clone(); let viewport = state.viewport; - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if event.button != MouseButton::Left || !phase.bubble() || !hitbox.is_hovered(window) { - return; + let links = Rc::new(ScreenLinks::new(&self.snapshot)); + + // A hand over a link while the key that opens it is held; pressing + // or letting go of the key changes it at once. + let pointer_on_link = hitbox.is_hovered(window) + && viewport + .cell_at(window.mouse_position()) + .and_then(|cell| links.at(cell)) + .is_some(); + if pointer_on_link { + if opens_links(&window.modifiers()) { + window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + } + let view = view.clone(); + window.on_modifiers_changed(move |_, _, cx| view.update(cx, |_, cx| cx.notify())); + } + + // A click opens a link with ⌘ held; otherwise it goes to the program + // that asked for the mouse, or selects text. + window.on_mouse_event({ + let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone()); + move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() || !hitbox.is_hovered(window) { + return; + } + let Some(cell) = viewport.cell_at(event.position) else { + return; + }; + let button = match event.button { + MouseButton::Left => ReportButton::Left, + MouseButton::Middle => ReportButton::Middle, + _ => return, + }; + if button == ReportButton::Left + && opens_links(&event.modifiers) + && let Some((_, uri)) = links.at(cell) + { + cx.open_url(uri); + view.update(cx, |view, cx| view.hover_link(None, cx)); + cx.stop_propagation(); + return; + } + let reported = view.update(cx, |view, cx| { + view.focus(window, cx); + view.report_mouse(button, ReportKind::Press, cell, event.modifiers, cx) + }); + if reported { + cx.stop_propagation(); + } else if button == ReportButton::Left { + let (point, side) = viewport.grid_point(event.position); + let selection_type = match event.click_count { + 2 => SelectionType::Semantic, + count if count >= 3 => SelectionType::Lines, + _ => SelectionType::Simple, + }; + view.update(cx, |view, cx| { + view.start_selection(point, side, selection_type, cx); + }); + cx.stop_propagation(); + } + } + }); + + // The link under the pointer, for its tooltip; and motion, for a + // program that asked for it. A drag that leaves the terminal is + // reported at its edge. + window.on_mouse_event({ + let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone()); + move |event: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + let cell = hitbox + .is_hovered(window) + .then(|| viewport.cell_at(event.position)) + .flatten(); + let link = cell + .and_then(|cell| links.at(cell)) + .map(|(index, uri)| HoveredLink { + index, + uri: uri.clone(), + position: event.position, + }); + view.update(cx, |view, cx| { + view.hover_link(link, cx); + let cell = match (cell, view.reported_button) { + (_, Some(_)) => Some(viewport.nearest_cell(event.position)), + (cell, None) => cell, + }; + if let Some(cell) = cell { + view.report_mouse( + ReportButton::None, + ReportKind::Motion, + cell, + event.modifiers, + cx, + ); + } + }); + } + }); + + // A reported button going up is reported wherever it is let go. + window.on_mouse_event({ + let view = view.clone(); + move |event: &MouseUpEvent, phase, _, cx| { + if !phase.capture() { + return; + } + let button = match event.button { + MouseButton::Left => ReportButton::Left, + MouseButton::Middle => ReportButton::Middle, + _ => return, + }; + let cell = viewport.nearest_cell(event.position); + view.update(cx, |view, cx| { + view.release_mouse(button, cell, event.modifiers, cx) + }); } - let (point, side) = viewport.grid_point(event.position); - let selection_type = match event.click_count { - 2 => SelectionType::Semantic, - count if count >= 3 => SelectionType::Lines, - _ => SelectionType::Simple, - }; - view.update(cx, |view, cx| { - view.focus(window, cx); - view.start_selection(point, side, selection_type, cx); - }); - cx.stop_propagation(); }); let view = self.view.clone(); @@ -1224,8 +1560,15 @@ impl Element for TerminalElement { if !phase.bubble() || !hitbox.should_handle_scroll(window) { return; } + let cell = viewport.nearest_cell(event.position); view.update(cx, |view, cx| { - view.scroll(event.delta, viewport.geometry.line_height, cx) + view.scroll( + event.delta, + viewport.geometry.line_height, + cell, + event.modifiers, + cx, + ) }); cx.stop_propagation(); }); @@ -1443,6 +1786,44 @@ fn cursor_quad( Some(fill(cursor, palette.cursor.opacity(0.75))) } +/// The cell on screen under `position`, if it is over the grid. +fn screen_cell( + geometry: &TerminalGeometry, + columns: usize, + rows: usize, + position: gpui_kit::Point, +) -> Option<(usize, usize)> { + let local = position - geometry.bounds.origin; + if local.x < px(0.) || local.y < px(0.) || columns == 0 || rows == 0 { + return None; + } + let column = (local.x / geometry.cell_width).floor() as usize; + let row = (local.y / geometry.line_height).floor() as usize; + (column < columns && row < rows).then_some((column, row)) +} + +/// Which link each cell on screen belongs to, for the pointer's handlers. +struct ScreenLinks { + columns: usize, + cells: Vec>, + links: Vec, +} + +impl ScreenLinks { + fn new(snapshot: &TerminalSnapshot) -> Self { + Self { + columns: snapshot.columns, + cells: snapshot.cells.iter().map(|cell| cell.link).collect(), + links: snapshot.links.clone(), + } + } + + fn at(&self, (column, row): (usize, usize)) -> Option<(usize, &SharedString)> { + let link = (*self.cells.get(row * self.columns + column)?)?; + Some((link, self.links.get(link)?)) + } +} + fn grid_point( position: gpui_kit::Point, bounds: Bounds, diff --git a/tests/ui/support/terminal.rs b/tests/ui/support/terminal.rs index fa7e8f5..fc4b895 100644 --- a/tests/ui/support/terminal.rs +++ b/tests/ui/support/terminal.rs @@ -10,6 +10,8 @@ pub enum FakeBehavior { FailFirst, ReportsOs(HostOs), ReportsLatency(Latency), + /// Prints this after its prompt line, as a program would. + Prints(&'static str), } #[derive(Default)] @@ -68,6 +70,13 @@ impl FakeTerminalFactory { } } + pub fn printing(text: &'static str) -> Self { + Self { + behavior: FakeBehavior::Prints(text), + ..Self::default() + } + } + pub fn reports_latency(latency: Latency) -> Self { Self { behavior: FakeBehavior::ReportsLatency(latency), @@ -139,6 +148,9 @@ impl TerminalTransport for FakeTerminalTransport { ) .into_bytes(), ))?; + if let FakeBehavior::Prints(text) = self.behavior { + events.send_blocking(TerminalTransportEvent::Output(text.as_bytes().to_vec()))?; + } if matches!(self.behavior, FakeBehavior::ExitFirst) && self.run == 1 { events.send_blocking(TerminalTransportEvent::Exited { code: 9, diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index ae65d32..2eb9fcd 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -1,5 +1,5 @@ //! Terminals: local terminals, keys and input, selection, find, clearing, -//! the font and the size. +//! the font and the size, links, the mouse for programs and their copies. use crate::support::*; @@ -759,3 +759,133 @@ async fn the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestApp }) .await; } + +/// Where the cell `(column, row)` of the first local terminal is, by the cell +/// size the fake shell was told of. +fn cell_center( + window: &mut gpui_kit::Window, + factory: &FakeTerminalFactory, + column: usize, + row: usize, +) -> gpui_kit::Point { + let size = *factory + .resizes + .lock() + .unwrap_or_else(|error| error.into_inner()) + .last() + .expect("the terminal has been laid out"); + let bounds = window.find(("local-terminal", 1_u64)).bounds(); + point( + bounds.left() + px(size.cell_width() as f32 * (column as f32 + 0.5)), + bounds.top() + px(size.cell_height() as f32 * (row as f32 + 0.5)), + ) +} + +/// A local terminal whose shell has printed `text`, which ends in `marker`, +/// laid out. +async fn terminal_printing( + cx: &mut TestAppContext, + text: &'static str, + marker: &'static str, +) -> ( + WindowHandle, + Entity, + Arc, +) { + let factory = Arc::new(FakeTerminalFactory::printing(text)); + let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await; + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + local_screen(&workspace, cx).contains(marker) && last_cell_height(&factory).is_some() + }) + .await; + (handle, workspace, factory) +} + +#[gpui_kit::test] +async fn links_open_with_the_command_key_and_show_where_they_lead(cx: &mut TestAppContext) { + // The prompt line is row 0; the link starts row 1. + let (handle, _, factory) = + terminal_printing(cx, "https://example.com/docs. 文档\r\n", "文档").await; + + // A plain click selects, as it always has, and opens nothing. + in_frame(cx, handle, |window, cx| { + let link = cell_center(window, &factory, 2, 1); + click_at(window, link, gpui_kit::Modifiers::none(), cx); + }); + assert_eq!(cx.opened_url(), None); + + // Resting on it shows where it leads, without the full stop. + cx.background_executor + .advance_clock(Duration::from_millis(600)); + cx.run_until_parked(); + in_frame(cx, handle, |window, cx| { + assert_eq!( + window.find("terminal-link-tooltip").label(), + Some("https://example.com/docs") + ); + let link = cell_center(window, &factory, 2, 1); + click_at(window, link, gpui_kit::Modifiers::secondary_key(), cx); + }); + assert_eq!(cx.opened_url().as_deref(), Some("https://example.com/docs")); +} + +/// Wait until the fake shell has been sent `expected`, which it records on +/// its own thread, and forget it. +async fn take_written( + cx: &mut TestAppContext, + handle: WindowHandle, + factory: &FakeTerminalFactory, + expected: &str, +) { + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + factory.written_text() == expected + }) + .await; + factory + .writes + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); +} + +#[gpui_kit::test] +async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut TestAppContext) { + // 1000: clicks, 1006: SGR encoding. + let (handle, _, factory) = + terminal_printing(cx, "\x1b[?1000h\x1b[?1006hmouse on", "mouse on").await; + in_frame(cx, handle, |window, cx| { + let cell = cell_center(window, &factory, 2, 0); + click_at(window, cell, gpui_kit::Modifiers::none(), cx); + }); + take_written(cx, handle, &factory, "\x1b[<0;3;1M\x1b[<0;3;1m").await; + + // Shift keeps the mouse for selecting text: the program hears only the + // wheel after it. + in_frame(cx, handle, |window, cx| { + let cell = cell_center(window, &factory, 2, 0); + click_at(window, cell, gpui_kit::Modifiers::shift(), cx); + let position = cell_center(window, &factory, 4, 1); + window.dispatch_event( + gpui_kit::ScrollWheelEvent { + position, + delta: gpui_kit::ScrollDelta::Lines(point(0., 1.)), + modifiers: gpui_kit::Modifiers::default(), + touch_phase: gpui_kit::TouchPhase::Moved, + } + .to_platform_input(), + cx, + ); + }); + take_written(cx, handle, &factory, "\x1b[<64;5;2M").await; +} + +#[gpui_kit::test] +async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) { + // 「你好」 in base64. + let (handle, _, _) = terminal_printing(cx, "\x1b]52;c;5L2g5aW9\x07copied", "copied").await; + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + cx.read_from_clipboard().and_then(|item| item.text()) == Some("你好".into()) + }) + .await; +}