From 8bb276335e29089b547c756751531f36a740de6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 16:09:28 -0400 Subject: [PATCH] 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 --- AGENTS.md | 4 +- src/term/pointer.rs | 23 ++++-- src/term/tap.rs | 112 ++++++-------------------- src/term/tap/tests.rs | 180 ++++++++++++++++++++++++++++++++++++++++++ src/term/view.rs | 20 +++-- 5 files changed, 232 insertions(+), 107 deletions(-) create mode 100644 src/term/tap/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 469bf7d..80962b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it. - 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it. - 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window. -- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. Motion reports the first held button: left, right, middle. +- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. The tap's scanner reads controls inside a sequence as vte does: C0 runs, DEL and high bytes are skipped, CAN and SUB cancel, ESC restarts, subparameters are skipped. Motion reports the first held button: left, right, middle. - 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise. -- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. +- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard. - 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/src/term/pointer.rs b/src/term/pointer.rs index 22e4fb7..c06ea34 100644 --- a/src/term/pointer.rs +++ b/src/term/pointer.rs @@ -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) { 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) { + /// 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) { + 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. diff --git a/src/term/tap.rs b/src/term/tap.rs index cf963c4..77a888d 100644 --- a/src/term/tap.rs +++ b/src/term/tap.rs @@ -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, + /// 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 { } #[cfg(test)] -mod tests { - use super::*; - - fn scan(chunks: &[&[u8]]) -> Vec { - 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; diff --git a/src/term/tap/tests.rs b/src/term/tap/tests.rs new file mode 100644 index 0000000..24df109 --- /dev/null +++ b/src/term/tap/tests.rs @@ -0,0 +1,180 @@ +use super::*; + +fn scan(chunks: &[&[u8]]) -> Vec { + 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[?10001000h\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}"); + } + } +} diff --git a/src/term/view.rs b/src/term/view.rs index 8163d7d..d8b1006 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -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",