终端识别链接、支持鼠标上报和 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:
1 parent
d53bb559e6
commit
befdfc7b02
9 files changed
+1073
-37
No files matched your search
@@ -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`,主选择区忽略。
|
||||
|
||||
## 测试约定
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user