终端识别链接、支持鼠标上报和 OSC 52 远程复制

- 链接:输出里的 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 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-06 13:26:21 +08:00
1 parent d53bb559e6
commit befdfc7b02
9 files changed
+1073 -37

No files matched your search

+3
View File
@@ -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`,主选择区忽略。
## 测试约定
+3
View File
@@ -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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
## 右侧栏
+163 -9
View File
@@ -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<usize>,
}
#[derive(Clone, Debug)]
@@ -47,6 +51,8 @@ pub struct TerminalSnapshot {
pub cursor_blinking: bool,
pub display_offset: usize,
pub colors: [Option<Rgb>; COUNT],
/// The addresses of the links on screen, by the cells' `link`.
pub links: Vec<gpui_kit::SharedString>,
}
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<Option<TerminalSearch>>,
/// Finds the web addresses on screen, built once.
url_search: RefCell<RegexSearch>,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
@@ -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<TerminalEvent> {
fn handle_event(
&mut self,
event: TerminalUiEvent,
cx: &mut gpui_kit::App,
) -> Option<TerminalEvent> {
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<String> },
Exited {
code: u32,
signal: Option<String>,
},
Failed(String),
Prompt(ConnectionPrompt),
HostOs(HostOs),
Latency(Latency),
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
/// A program's copy, by OSC 52.
ClipboardStore(String),
}
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, 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<TerminalTransportCommand>,
mpsc::Receiver<TerminalUiEvent>,
) {
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<String> = 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),
+166
View File
@@ -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<T>(term: &Term<T>, 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<usize>,
marked: Vec<(Hyperlink, usize)>,
links: Vec<SharedString>,
}
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<Hyperlink>) -> Option<usize> {
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<SharedString> {
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"));
}
}
+2
View File
@@ -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;
+185
View File
@@ -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<Vec<u8>> {
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));
}
}
+408 -27
View File
@@ -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<dyn Fn(PopupMenu, &TerminalLifecycle) -> 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<Pixels>) -> 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<Pixels>) -> (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<Pixels>) -> (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<Pixels>,
}
pub struct TerminalView {
engine: Entity<TerminalEngine>,
element_id: ElementId,
@@ -162,6 +210,14 @@ pub struct TerminalView {
find: Option<FindBar>,
cursor_visible: bool,
focused: bool,
hovered_link: Option<HoveredLink>,
/// The hovered link's address shows, the pointer having rested on it.
link_tooltip: bool,
link_tooltip_task: Option<Task<()>>,
/// The button the program was told went down, until it hears it go up.
reported_button: Option<ReportButton>,
/// The cell the program last heard of: motion is reported once a cell.
reported_cell: Option<(usize, usize)>,
_subscriptions: Vec<Subscription>,
_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<Self>) {
@@ -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<Self>) {
fn scroll(
&mut self,
delta: ScrollDelta,
line_height: Pixels,
cell: (usize, usize),
modifiers: Modifiers,
cx: &mut Context<Self>,
) {
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<Self>,
) -> 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<Self>,
) {
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<HoveredLink>, cx: &mut Context<Self>) {
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<Vec<u8>>, cx: &mut Context<Self>) {
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<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<Pixels>,
) -> 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<Option<usize>>,
links: Vec<SharedString>,
}
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<Pixels>,
bounds: Bounds<Pixels>,
+12
View File
@@ -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,
+131 -1
View File
@@ -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<gpui_kit::Pixels> {
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<Root>,
Entity<Workspace>,
Arc<FakeTerminalFactory>,
) {
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<Root>,
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;
}