T09: the mouse mode scanner reads controls as vte does: C0 runs and DEL is skipped mid-sequence, CAN and SUB cancel, subparameters count toward its 32 values; a byte-by-byte test against vte's parser; copy on select and OSC 52 primary writes go to Linux's selection
This commit is contained in:
5 files changed
+232
-107
No files matched your search
+17
-6
@@ -203,7 +203,7 @@ impl TerminalView {
|
||||
self.pty.pid
|
||||
);
|
||||
if let Some(text) = selected.filter(|_| SettingsStore::get(cx).copy_on_select) {
|
||||
self.copy_text(text, cx);
|
||||
self.copy_text(text, true, cx);
|
||||
}
|
||||
}
|
||||
let Some(released) = button(event.button) else {
|
||||
@@ -282,18 +282,29 @@ impl TerminalView {
|
||||
/// The selection to the clipboard.
|
||||
pub(super) fn copy(&mut self, cx: &mut Context<Self>) {
|
||||
match self.selected() {
|
||||
Some(text) => self.copy_text(text, cx),
|
||||
Some(text) => self.copy_text(text, false, cx),
|
||||
None => log::debug!("terminal {}: nothing selected to copy", self.pty.pid),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_text(&self, text: String, cx: &mut Context<Self>) {
|
||||
/// Text to the clipboard; `primary` text goes to Linux's selection, as Ghostty puts it.
|
||||
pub(super) fn copy_text(&self, text: String, primary: bool, cx: &mut Context<Self>) {
|
||||
let count = text.chars().count();
|
||||
let item = ClipboardItem::new_string(text);
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
if primary {
|
||||
log::info!(
|
||||
"terminal {}: copied {count} characters to the selection",
|
||||
self.pty.pid
|
||||
);
|
||||
return cx.write_to_primary(item);
|
||||
}
|
||||
log::info!(
|
||||
"terminal {}: copied {} characters",
|
||||
"terminal {}: copied {count} characters{}",
|
||||
self.pty.pid,
|
||||
text.chars().count()
|
||||
if primary { ", no primary here" } else { "" }
|
||||
);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
cx.write_to_clipboard(item);
|
||||
}
|
||||
|
||||
/// The clipboard's text to the program, bracketed when it asked.
|
||||
|
||||
+24
-88
@@ -182,7 +182,7 @@ impl Osc7 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most parameters one private mode sequence keeps, as vte's.
|
||||
/// The most values, subparameters too, one sequence holds before vte drops it.
|
||||
const PARAMS: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -194,11 +194,15 @@ enum Csi {
|
||||
Private,
|
||||
}
|
||||
|
||||
/// The mouse modes as xterm holds them; alacritty drops 9 and splits the rest.
|
||||
/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MouseModes {
|
||||
state: Csi,
|
||||
params: Vec<u32>,
|
||||
/// Values begun, subparameters too.
|
||||
fields: usize,
|
||||
/// Past a `:`, whose subparameters the parser's mode sets skip.
|
||||
sub: bool,
|
||||
mode: MouseMode,
|
||||
}
|
||||
|
||||
@@ -219,7 +223,10 @@ impl MouseModes {
|
||||
let byte = rest[0];
|
||||
rest = &rest[1..];
|
||||
self.state = match (self.state, byte) {
|
||||
(_, 0x18 | 0x1a) => Csi::Ground,
|
||||
(_, 0x1b) => Csi::Escape,
|
||||
// Mid-sequence, C0 runs and DEL is skipped, as in vte.
|
||||
(state, 0x00..=0x1f | 0x7f..=0xff) => state,
|
||||
(Csi::Escape, b'[') => Csi::Bracket,
|
||||
(Csi::Escape, b'c') => {
|
||||
self.mode = MouseMode::default();
|
||||
@@ -227,17 +234,25 @@ impl MouseModes {
|
||||
}
|
||||
(Csi::Bracket, b'?') => {
|
||||
self.params = vec![0];
|
||||
self.fields = 1;
|
||||
self.sub = false;
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'0'..=b'9') => {
|
||||
let last = self.params.last_mut().expect("a parameter in progress");
|
||||
*last = last
|
||||
.saturating_mul(10)
|
||||
.saturating_add(u32::from(byte - b'0'));
|
||||
if !self.sub {
|
||||
let last = self.params.last_mut().expect("a parameter in progress");
|
||||
*last = last
|
||||
.saturating_mul(10)
|
||||
.saturating_add(u32::from(byte - b'0'));
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b';') if self.params.len() < PARAMS => {
|
||||
self.params.push(0);
|
||||
(Csi::Private, b':' | b';') if self.fields < PARAMS => {
|
||||
self.fields += 1;
|
||||
self.sub = byte == b':';
|
||||
if !self.sub {
|
||||
self.params.push(0);
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'h' | b'l') => {
|
||||
@@ -313,83 +328,4 @@ fn decode(bytes: &[u8]) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
let mut scanner = Osc7::default();
|
||||
let mut found = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, &mut |body| {
|
||||
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
|
||||
});
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
|
||||
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_and_cancels_pass() {
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_body_is_dropped() {
|
||||
let mut long = b"\x1b]7;".to_vec();
|
||||
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
||||
long.push(0x07);
|
||||
assert!(scan(&[&long]).is_empty());
|
||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
assert_eq!(modes.feed(b"a\x1b[?"), None);
|
||||
assert!(modes.feed(b"1006;9h").is_some());
|
||||
assert_eq!(modes.mode.format, Format::Sgr);
|
||||
assert_eq!(modes.mode.tracking, Tracking::X10);
|
||||
assert_eq!(modes.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
||||
assert!(modes.feed(b"l").is_some());
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off);
|
||||
modes.feed(b"\x1b[?1000h\x1b[?9h\x1b[?9l");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off, "9 replaced 1000");
|
||||
modes.feed(b"\x1b[?1002h");
|
||||
let reset = modes.feed(b"\x1bc");
|
||||
assert_eq!(reset, Some(MouseMode::default()), "a reset ends it");
|
||||
modes.feed(b"\x1b[?09;\x1b[?1003h");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Any);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_uri_gives_its_decoded_folder() {
|
||||
assert_eq!(
|
||||
folder(b"file:///tmp/ely%20dir"),
|
||||
Some(PathBuf::from("/tmp/ely dir"))
|
||||
);
|
||||
assert_eq!(folder(b"file://localhost/x"), Some(PathBuf::from("/x")));
|
||||
let host = gethostname::gethostname().into_string().expect("host");
|
||||
let named = format!("file://{host}/y");
|
||||
assert_eq!(folder(named.as_bytes()), Some(PathBuf::from("/y")));
|
||||
assert_eq!(
|
||||
folder("file:///%E4%B8%AD".as_bytes()),
|
||||
Some(PathBuf::from("/中"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_hosts_and_bad_uris_give_none() {
|
||||
assert_eq!(folder(b"file://elsewhere.invalid/tmp"), None);
|
||||
assert_eq!(folder(b"http://x/y"), None);
|
||||
assert_eq!(folder(b"file:///%FF"), None);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,180 @@
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
let mut scanner = Osc7::default();
|
||||
let mut found = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, &mut |body| {
|
||||
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
|
||||
});
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
|
||||
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_and_cancels_pass() {
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_body_is_dropped() {
|
||||
let mut long = b"\x1b]7;".to_vec();
|
||||
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
||||
long.push(0x07);
|
||||
assert!(scan(&[&long]).is_empty());
|
||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
assert_eq!(modes.feed(b"a\x1b[?"), None);
|
||||
assert!(modes.feed(b"1006;9h").is_some());
|
||||
assert_eq!(modes.mode.format, Format::Sgr);
|
||||
assert_eq!(modes.mode.tracking, Tracking::X10);
|
||||
assert_eq!(modes.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
||||
assert!(modes.feed(b"l").is_some());
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off);
|
||||
modes.feed(b"\x1b[?1000h\x1b[?9h\x1b[?9l");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off, "9 replaced 1000");
|
||||
modes.feed(b"\x1b[?1002h");
|
||||
let reset = modes.feed(b"\x1bc");
|
||||
assert_eq!(reset, Some(MouseMode::default()), "a reset ends it");
|
||||
modes.feed(b"\x1b[?09;\x1b[?1003h");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Any);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_read_through_controls_as_the_parser_does() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
modes.feed(b"\x1b[?1000\x00h\x1b[?1006");
|
||||
modes.feed(b"\x00h");
|
||||
let on = MouseMode {
|
||||
tracking: Tracking::Normal,
|
||||
format: Format::Sgr,
|
||||
};
|
||||
assert_eq!(
|
||||
modes.mode, on,
|
||||
"NUL runs inside a sequence, across reads too"
|
||||
);
|
||||
modes.feed(b"\x1b[?1000\x00l\x1b[?1006\x7f\xc3\xa9l");
|
||||
assert_eq!(
|
||||
modes.mode,
|
||||
MouseMode::default(),
|
||||
"DEL and high bytes are skipped"
|
||||
);
|
||||
modes.feed(b"\x1b\x07[?1002;12:9");
|
||||
modes.feed(b"9:1;9h");
|
||||
assert_eq!(
|
||||
modes.mode.tracking,
|
||||
Tracking::X10,
|
||||
"subparameters are skipped"
|
||||
);
|
||||
modes.feed(b"\x1b[?1003\x18h\x1b[?1003\x1ah");
|
||||
assert_eq!(modes.mode.tracking, Tracking::X10, "CAN and SUB cancel");
|
||||
let normal = |subparameters: usize| {
|
||||
[b"\x1b[?1000".as_slice(), &b":0".repeat(subparameters), b"h"].concat()
|
||||
};
|
||||
modes.feed(&normal(32));
|
||||
assert_eq!(
|
||||
modes.mode.tracking,
|
||||
Tracking::X10,
|
||||
"33 values: vte drops it"
|
||||
);
|
||||
modes.feed(&normal(31));
|
||||
assert_eq!(modes.mode.tracking, Tracking::Normal, "32 values hold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_uri_gives_its_decoded_folder() {
|
||||
assert_eq!(
|
||||
folder(b"file:///tmp/ely%20dir"),
|
||||
Some(PathBuf::from("/tmp/ely dir"))
|
||||
);
|
||||
assert_eq!(folder(b"file://localhost/x"), Some(PathBuf::from("/x")));
|
||||
let host = gethostname::gethostname().into_string().expect("host");
|
||||
let named = format!("file://{host}/y");
|
||||
assert_eq!(folder(named.as_bytes()), Some(PathBuf::from("/y")));
|
||||
assert_eq!(
|
||||
folder("file:///%E4%B8%AD".as_bytes()),
|
||||
Some(PathBuf::from("/中"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_hosts_and_bad_uris_give_none() {
|
||||
assert_eq!(folder(b"file://elsewhere.invalid/tmp"), None);
|
||||
assert_eq!(folder(b"http://x/y"), None);
|
||||
assert_eq!(folder(b"file:///%FF"), None);
|
||||
}
|
||||
|
||||
/// vte's own reading of `bytes`, under the same mode rules.
|
||||
fn parsed(bytes: &[u8]) -> MouseMode {
|
||||
use alacritty_terminal::vte::{Params, Parser, Perform};
|
||||
struct Modes(MouseMode);
|
||||
impl Perform for Modes {
|
||||
fn csi_dispatch(&mut self, params: &Params, between: &[u8], ignore: bool, action: char) {
|
||||
if !ignore && between == b"?" && matches!(action, 'h' | 'l') {
|
||||
for param in params.iter() {
|
||||
self.0.apply(u32::from(param[0]), action == 'h');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn esc_dispatch(&mut self, between: &[u8], _: bool, byte: u8) {
|
||||
if between.is_empty() && byte == b'c' {
|
||||
self.0 = MouseMode::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut modes = Modes(MouseMode::default());
|
||||
Parser::new().advance(&mut modes, bytes);
|
||||
modes.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
|
||||
let cases: [&[u8]; 10] = [
|
||||
b"\x1b[?1000;1006h\x1b[?1000\x00l",
|
||||
b"\x1b[?1000\x00h\x1b[?1006h\x1b[?1005\x0bh",
|
||||
b"\x1b\x00\x7f[?\x07100\xc3\xa90h\x1b[?1006\x08l",
|
||||
b"\x1b[?1002;12:99:1;9h\x1b[?:9h",
|
||||
b"\x1b[?1003\x18h\x1b[?1003\x1ah\x1b[?1003\x1b[?1002h",
|
||||
b"\x1b[?9h\x1b[?1000$h\x1b[?1000<h\x1b[>1000h\x1b[1000h",
|
||||
b"\x1b]0;x\x1b[?1000h\x07\x1bP?9h\x1b\\\x1b(c\x1bc",
|
||||
b"\x1b[?1000h\x1b[?1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20;21;22;23;24;25;26;27;28;29;30;31;1003h",
|
||||
b"\x1b[?1000h\x1b[?1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;16;17;18;19;20;21;22;23;24;25;26;27;28;29;30;31;32;1003l",
|
||||
b"\x1b[?65537;1000h\x1b[?01002h\x1b[?h\x1b[?;1003h",
|
||||
];
|
||||
let values = |subparameters: usize| {
|
||||
[
|
||||
b"\x1b[?1000h\x1b[?1003".as_slice(),
|
||||
&b":0".repeat(subparameters),
|
||||
b"h",
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
for case in cases
|
||||
.map(<[u8]>::to_vec)
|
||||
.into_iter()
|
||||
.chain([values(31), values(32)])
|
||||
{
|
||||
let case = case.as_slice();
|
||||
let mut modes = MouseModes::default();
|
||||
for end in 1..=case.len() {
|
||||
modes.feed(&case[end - 1..end]);
|
||||
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-11
@@ -1,11 +1,13 @@
|
||||
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
|
||||
|
||||
use alacritty_terminal::{event::Event, grid::Dimensions, vte::ansi::CursorShape};
|
||||
use alacritty_terminal::{
|
||||
event::Event, grid::Dimensions, term::ClipboardType, vte::ansi::CursorShape,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures,
|
||||
FontStyle, FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled,
|
||||
Subscription, Task, Window, div, prelude::*, px, size,
|
||||
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle,
|
||||
FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription,
|
||||
Task, Window, div, prelude::*, px, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -169,13 +171,9 @@ impl TerminalView {
|
||||
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
|
||||
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
|
||||
Event::Bell => cx.emit(TerminalEvent::Bell),
|
||||
Event::ClipboardStore(_, text) => {
|
||||
log::info!(
|
||||
"terminal {}: OSC 52 copied {} characters",
|
||||
self.pty.pid,
|
||||
text.chars().count()
|
||||
);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text))
|
||||
Event::ClipboardStore(kind, text) => {
|
||||
log::info!("terminal {}: OSC 52 writes the {kind:?}", self.pty.pid);
|
||||
self.copy_text(text, kind == ClipboardType::Selection, cx);
|
||||
}
|
||||
Event::ClipboardLoad(..) => log::error!(
|
||||
"terminal {}: a clipboard read arrived though OSC 52 reads are off",
|
||||
|
||||
Reference in new issue
Block a user