T09: mouse: selection by cell, word and line, Shift extends; reports for X10, normal, button and any tracking in X10, UTF-8 and SGR bytes, mode 9 followed by the tap; the wheel reports, sends arrows or scrolls history; copy, bracketed paste, copy on select, OSC 52; Cmd-press opens OSC 8 targets and URLs; a release goes out only for a press the terminal saw; shot posts mouse gestures and never activates
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Failing after 8m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 15:32:17 -04:00
1 parent fdb1caa26e
commit 9f81f1f704
19 files changed
+971 -27

No files matched your search

+41 -1
View File
@@ -1,6 +1,7 @@
use alacritty_terminal::{
event::EventListener,
grid::Dimensions,
index::Point,
term::{Term, cell::Cell, cell::Flags, color::Colors},
vte::ansi::CursorShape,
};
@@ -92,7 +93,19 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
for indexed in content.display_iter {
let (point, cell) = (indexed.point, indexed.cell);
let row = (point.line.0 + offset) as usize;
let look = style(cell, ink, content.colors);
let mut look = style(cell, ink, content.colors);
let selected = content.selection.as_ref().is_some_and(|range| {
let (line, column) = (point.line, point.column);
range.contains(point)
|| (cell.flags.contains(Flags::WIDE_CHAR)
&& range.contains(Point::new(line, column + 1)))
|| (cell.flags.contains(Flags::WIDE_CHAR_SPACER)
&& range.contains(Point::new(line, column - 1)))
});
if selected {
look.bg = Some(ink.selection);
look.fg = ink.selection_text.unwrap_or(look.fg);
}
let spacer = cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
@@ -292,6 +305,33 @@ mod tests {
assert!(hidden.rows[0].alone.is_empty(), "hidden lines draw nothing");
}
#[test]
fn selected_cells_wear_the_selection_colors() {
use alacritty_terminal::{
index::{Column, Line, Side},
selection::{Selection, SelectionType},
};
let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, "ab\u{4e2d}d".as_bytes());
let start = Point::new(Line(0), Column(1));
let mut selection = Selection::new(SelectionType::Simple, start, Side::Left);
selection.update(Point::new(Line(0), Column(3)), Side::Left);
term.selection = Some(selection);
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
let frame = frame(&term, &ink);
let grounds: Vec<_> = frame.rows[0]
.runs
.iter()
.map(|(bytes, style)| (*bytes, style.bg))
.collect();
assert_eq!(
&grounds[..3],
[(1, None), (5, Some(ink.selection)), (8, None)],
"b and both halves of the wide cell, whose spacer is picked"
);
}
#[test]
fn hidden_cells_show_nothing_but_their_ground() {
let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes());
+5
View File
@@ -14,6 +14,9 @@ pub struct Ink {
pub bg: Hsla,
pub cursor: Hsla,
pub cursor_text: Hsla,
pub selection: Hsla,
/// None keeps each cell's own color.
pub selection_text: Option<Hsla>,
}
impl Ink {
@@ -31,6 +34,8 @@ impl Ink {
bg: named(NamedColor::Background).unwrap_or(theme.background),
cursor: named(NamedColor::Cursor).unwrap_or(theme.cursor),
cursor_text: theme.cursor_text,
selection: theme.selection_background,
selection_text: theme.selection_foreground,
}
}
+23 -4
View File
@@ -3,7 +3,7 @@ use std::ops::Range;
use alacritty_terminal::{grid::Scroll, term::TermMode};
use gpui::{
App, Bounds, Context, EntityInputHandler, KeyBinding, KeyDownEvent, KeyUpEvent, Keystroke,
NoAction, Pixels, Point, UTF16Selection, Window,
NoAction, Pixels, Point, UTF16Selection, Window, actions,
};
use super::{
@@ -11,11 +11,22 @@ use super::{
view::TerminalView,
};
actions!(elyterminal, [Copy, Paste]);
/// The key context a terminal's root sets.
pub const CONTEXT: &str = "Terminal";
/// Ely's focus keys go to the terminal's listeners instead.
/// Copy and paste; Ely's focus keys go to the terminal's listeners instead.
pub fn bind_keys(cx: &mut App) {
let (copy, paste) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v")
} else {
("ctrl-shift-c", "ctrl-shift-v")
};
cx.bind_keys([
KeyBinding::new(copy, Copy, Some(CONTEXT)),
KeyBinding::new(paste, Paste, Some(CONTEXT)),
]);
cx.bind_keys(
["tab", "shift-tab", "f6", "shift-f6"]
.map(|key| KeyBinding::new(key, NoAction, Some(CONTEXT))),
@@ -23,9 +34,12 @@ pub fn bind_keys(cx: &mut App) {
}
impl TerminalView {
/// Bytes from the keyboard: back to the newest line, then to the program.
/// Bytes from the keyboard: back to the newest line, no selection, then to the program.
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
self.pty.term.lock().scroll_display(Scroll::Bottom);
let mut term = self.pty.term.lock();
term.scroll_display(Scroll::Bottom);
term.selection = None;
drop(term);
self.pty.write(bytes);
cx.notify();
}
@@ -39,6 +53,7 @@ impl TerminalView {
if event.prefer_character_input {
return;
}
self.held.insert(event.keystroke.key.clone());
let facts = self.facts();
let action = if event.is_held || facts.repeat {
Action::Repeat
@@ -50,7 +65,11 @@ impl TerminalView {
}
}
/// A release goes out only for a press this terminal saw: none for one a binding took.
pub(super) fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context<Self>) {
if !self.held.remove(&event.keystroke.key) {
return;
}
let facts = self.facts();
if self.send(&event.keystroke, Action::Release, facts, cx) {
cx.stop_propagation();
+10
View File
@@ -51,6 +51,16 @@ pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option<Vec<u8>> {
}
}
/// Pasted text as the program gets it; bracketed text loses ESC and Ctrl-C, as alacritty's does.
pub fn paste(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let text = text.replace(['\x1b', '\x03'], "");
format!("\x1b[200~{text}\x1b[201~").into_bytes()
} else {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
}
}
/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4, super 8.
fn code(key: &Key) -> u32 {
1 + u32::from(key.shift)
+9
View File
@@ -321,3 +321,12 @@ fn super_is_kittys_eighth_bit() {
Some("\x1b[27;9;13~")
);
}
#[test]
fn pastes_end_lines_with_cr_or_go_bracketed_without_escapes() {
assert_eq!(paste("a\r\nb\nc", false), b"a\rb\rc");
assert_eq!(
paste("x\n\x1b[201~rm\x03", true),
b"\x1b[200~x\n[201~rm\x1b[201~"
);
}
+86
View File
@@ -0,0 +1,86 @@
use alacritty_terminal::{
index::{Boundary, Point},
term::{Term, cell::Flags},
};
use ely_gpui_component::terminal::{Target, links};
/// The link at `point`: the program's OSC 8 target, else a URL in its line, wrapped rows joined.
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
let grid = term.grid();
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
point.column -= 1;
}
if let Some(link) = grid[point].hyperlink() {
return Some(link.uri().to_string());
}
let end = term.line_search_right(point);
let mut cell = term.line_search_left(point);
let (mut text, mut at) = (String::new(), 0);
loop {
if cell == point {
at = text.len();
}
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
if !grid[cell].flags.intersects(spacer) {
text.push(grid[cell].c);
}
if cell == end {
break;
}
cell = cell.add(term, Boundary::Grid, 1);
}
links(&text)
.into_iter()
.find_map(|(bytes, target)| match target {
Target::Url(url) if bytes.contains(&at) => Some(url.to_string()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use alacritty_terminal::{
event::VoidListener,
index::{Column, Line},
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use super::*;
fn link(bytes: &str, line: i32, column: usize) -> Option<String> {
let mut term = Term::new(Config::default(), &TermSize::new(12, 4), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, bytes.as_bytes());
link_at(&term, Point::new(Line(line), Column(column)))
}
#[test]
fn an_osc_8_link_names_its_target() {
let text = "a \x1b]8;;https://ely.dev/x\x1b\\ely\x1b]8;;\x1b\\ b";
assert_eq!(link(text, 0, 3).as_deref(), Some("https://ely.dev/x"));
assert_eq!(link(text, 0, 0), None);
}
#[test]
fn a_url_ends_before_its_sentence_does() {
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
let text = "(http://a.b)";
assert_eq!(link(text, 0, 4).as_deref(), Some("http://a.b"));
assert_eq!(link(text, 0, 11), None);
}
#[test]
fn a_url_runs_across_wrapped_rows_and_wide_cells() {
let text = "\u{4e2d} https://ely.dev/abc";
assert_eq!(link(text, 1, 3).as_deref(), Some("https://ely.dev/abc"));
assert_eq!(link(text, 0, 3).as_deref(), Some("https://ely.dev/abc"));
assert_eq!(link(text, 0, 1), None);
let wide = "file://a/\u{4e2d} x";
assert_eq!(
link(wide, 0, 10).as_deref(),
Some("file://a/\u{4e2d}"),
"a wide cell's spacer is its char"
);
}
}
+3
View File
@@ -3,9 +3,12 @@ pub mod frame;
pub mod ink;
pub mod input;
pub mod keys;
mod links;
#[cfg(target_os = "macos")]
pub mod mac;
pub mod mouse;
mod paint;
mod pointer;
mod pty;
pub mod shell;
mod tap;
+240
View File
@@ -0,0 +1,240 @@
use alacritty_terminal::term::TermMode;
/// A button as reports name it; the wheel's two turns count as buttons.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Button {
Left,
Middle,
Right,
WheelUp,
WheelDown,
}
/// What the mouse did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
Press,
Release,
Motion,
}
/// A mouse event a program may ask for.
#[derive(Clone, Copy, Debug)]
pub struct Report {
/// None for motion with no button down.
pub button: Option<Button>,
pub gesture: Gesture,
/// Column and row on screen, from 0.
pub cell: (usize, usize),
pub shift: bool,
pub alt: bool,
pub ctrl: bool,
}
/// The mouse events a program asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tracking {
Off,
/// Mode 9: presses alone, no modifiers.
X10,
/// Mode 1000: presses and releases.
Normal,
/// Mode 1002: and motion with a button down.
Button,
/// Mode 1003: and all motion.
Any,
}
impl Tracking {
/// The tracking `mode` sets; `x10` is mode 9, which the tap follows.
pub fn new(mode: TermMode, x10: bool) -> Self {
if x10 {
Self::X10
} else if mode.contains(TermMode::MOUSE_MOTION) {
Self::Any
} else if mode.contains(TermMode::MOUSE_DRAG) {
Self::Button
} else if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
Self::Normal
} else {
Self::Off
}
}
}
/// The bytes `report` sends; none when the program did not ask for it or its format cannot hold the cell.
pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
let clicked = matches!(
report.button,
Some(Button::Left | Button::Middle | Button::Right)
);
let wanted = match tracking {
Tracking::Off => false,
Tracking::X10 => report.gesture == Gesture::Press && clicked,
Tracking::Normal => report.gesture != Gesture::Motion,
Tracking::Button => report.gesture != Gesture::Motion || report.button.is_some(),
Tracking::Any => true,
};
if !wanted {
return None;
}
let sgr = mode.contains(TermMode::SGR_MOUSE);
let mut code = match report.button {
None => 3,
Some(_) if report.gesture == Gesture::Release && !sgr => 3,
Some(Button::Left) => 0,
Some(Button::Middle) => 1,
Some(Button::Right) => 2,
Some(Button::WheelUp) => 64,
Some(Button::WheelDown) => 65,
};
if tracking != Tracking::X10 {
code +=
4 * u32::from(report.shift) + 8 * u32::from(report.alt) + 16 * u32::from(report.ctrl);
}
if report.gesture == Gesture::Motion {
code += 32;
}
let (column, row) = (report.cell.0 + 1, report.cell.1 + 1);
if sgr {
let end = if report.gesture == Gesture::Release {
'm'
} else {
'M'
};
return Some(format!("\x1b[<{code};{column};{row}{end}").into_bytes());
}
let utf8 = mode.contains(TermMode::UTF8_MOUSE);
let most = if utf8 { 0x7FF - 32 } else { 0xFF - 32 };
if column > most || row > most {
log::debug!("mouse: cell {column},{row} is past what the format holds");
return None;
}
let mut bytes = b"\x1b[M".to_vec();
bytes.push(32 + code as u8);
for value in [column, row] {
let value = 32 + value as u32;
if utf8 {
let ch = char::from_u32(value).expect("a code point under U+0800");
bytes.extend(ch.to_string().as_bytes());
} else {
bytes.push(value as u8);
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(button: Option<Button>, gesture: Gesture, cell: (usize, usize)) -> Report {
Report {
button,
gesture,
cell,
shift: false,
alt: false,
ctrl: false,
}
}
fn sent(report: Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
encode(&report, tracking, mode)
}
const LEFT: Option<Button> = Some(Button::Left);
#[test]
fn normal_tracking_sends_presses_and_releases_as_x10_bytes() {
let mode = TermMode::MOUSE_REPORT_CLICK;
let tracking = Tracking::new(mode, false);
assert_eq!(tracking, Tracking::Normal);
let press = report(LEFT, Gesture::Press, (0, 0));
assert_eq!(sent(press, tracking, mode), Some(b"\x1b[M !!".to_vec()));
let release = report(LEFT, Gesture::Release, (3, 2));
assert_eq!(sent(release, tracking, mode), Some(b"\x1b[M#$#".to_vec()));
let motion = report(LEFT, Gesture::Motion, (1, 1));
assert_eq!(sent(motion, tracking, mode), None);
}
#[test]
fn sgr_names_the_released_button_and_the_modifiers() {
let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
let press = Report {
shift: true,
ctrl: true,
..report(Some(Button::Right), Gesture::Press, (4, 2))
};
assert_eq!(
sent(press, Tracking::Normal, mode),
Some(b"\x1b[<22;5;3M".to_vec())
);
let release = report(Some(Button::Middle), Gesture::Release, (300, 9));
assert_eq!(
sent(release, Tracking::Normal, mode),
Some(b"\x1b[<1;301;10m".to_vec())
);
let alt = Report {
alt: true,
..report(Some(Button::WheelDown), Gesture::Press, (0, 0))
};
assert_eq!(
sent(alt, Tracking::Normal, mode),
Some(b"\x1b[<73;1;1M".to_vec())
);
}
#[test]
fn motion_needs_button_or_any_tracking() {
let mode = TermMode::SGR_MOUSE;
let held = report(LEFT, Gesture::Motion, (1, 0));
let free = report(None, Gesture::Motion, (1, 0));
assert_eq!(Tracking::new(TermMode::MOUSE_DRAG, false), Tracking::Button);
assert_eq!(
sent(held, Tracking::Button, mode),
Some(b"\x1b[<32;2;1M".to_vec())
);
assert_eq!(sent(free, Tracking::Button, mode), None);
assert_eq!(Tracking::new(TermMode::MOUSE_MOTION, false), Tracking::Any);
assert_eq!(
sent(free, Tracking::Any, mode),
Some(b"\x1b[<35;2;1M".to_vec())
);
}
#[test]
fn x10_tracking_sends_presses_alone_without_modifiers() {
let mode = TermMode::MOUSE_REPORT_CLICK;
let tracking = Tracking::new(mode, true);
assert_eq!(tracking, Tracking::X10);
let press = Report {
shift: true,
..report(LEFT, Gesture::Press, (0, 0))
};
assert_eq!(sent(press, tracking, mode), Some(b"\x1b[M !!".to_vec()));
let release = report(LEFT, Gesture::Release, (0, 0));
assert_eq!(sent(release, tracking, mode), None);
let wheel = report(Some(Button::WheelUp), Gesture::Press, (0, 0));
assert_eq!(sent(wheel, tracking, mode), None);
assert_eq!(Tracking::new(TermMode::empty(), false), Tracking::Off);
}
#[test]
fn cells_past_the_format_go_utf8_or_nowhere() {
let mode = TermMode::MOUSE_REPORT_CLICK;
let edge = report(LEFT, Gesture::Press, (222, 0));
assert_eq!(
sent(edge, Tracking::Normal, mode),
Some(vec![0x1b, b'[', b'M', 32, 255, 33])
);
let past = report(LEFT, Gesture::Press, (223, 0));
assert_eq!(sent(past, Tracking::Normal, mode), None);
let utf8 = mode | TermMode::UTF8_MOUSE;
let mut wide = b"\x1b[M ".to_vec();
wide.extend("\u{100}!".as_bytes());
assert_eq!(sent(past, Tracking::Normal, utf8), Some(wide));
let far = report(LEFT, Gesture::Press, (2015, 0));
assert_eq!(sent(far, Tracking::Normal, utf8), None);
}
}
+1
View File
@@ -50,6 +50,7 @@ pub fn grid(
let lines = (bounds.size.height / cell.height).floor() as usize;
let origin = snapped(bounds.origin, window);
fit.update(cx, |view, cx| {
(view.pointer.origin, view.pointer.cell) = (origin, cell);
if let Some((row, column)) = at {
let corner =
origin + point(cell.width * column as f32, cell.height * row as f32);
+296
View File
@@ -0,0 +1,296 @@
use alacritty_terminal::{
grid::Scroll,
index::{Column, Line, Point as Cell, Side},
selection::{Selection, SelectionType},
term::TermMode,
};
use gpui::{
ClipboardItem, Context, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Window,
};
use super::{
keys,
links::link_at,
mouse::{self, Button, Gesture, Report, Tracking},
view::TerminalView,
};
use crate::settings::SettingsStore;
/// Lines one wheel notch scrolls, as Ghostty's discrete wheel.
const NOTCH: f32 = 3.;
/// Where the grid sits, and what the mouse is doing to it.
#[derive(Default)]
pub struct Pointer {
/// The grid's top-left in the window, from the last layout.
pub origin: Point<Pixels>,
pub cell: Size<Pixels>,
/// The left button is down and drawing a selection.
selecting: bool,
/// The button whose press went to the program.
pressed: Option<Button>,
/// The cell of the last report; motion inside it sends nothing.
last: Option<(usize, usize)>,
/// Wheel travel short of a line.
rest: Pixels,
}
fn button(button: MouseButton) -> Option<Button> {
match button {
MouseButton::Left => Some(Button::Left),
MouseButton::Middle => Some(Button::Middle),
MouseButton::Right => Some(Button::Right),
MouseButton::Navigate(_) => None,
}
}
impl TerminalView {
/// The screen cell under a window position, clamped to the grid; its grid point; its half.
fn cell_at(&self, position: Point<Pixels>) -> ((usize, usize), Cell, Side) {
let Pointer { origin, cell, .. } = self.pointer;
let x = ((position.x - origin.x) / cell.width).max(0.);
let y = ((position.y - origin.y) / cell.height).max(0.);
let (columns, lines) = self.size;
let (column, row) = ((x as usize).min(columns - 1), (y as usize).min(lines - 1));
let side = if x as usize >= columns || x.fract() >= 0.5 {
Side::Right
} else {
Side::Left
};
let offset = self.pty.term.lock().grid().display_offset() as i32;
let point = Cell::new(Line(row as i32 - offset), Column(column));
((column, row), point, side)
}
/// What the program tracks; none while Shift holds the mouse for selecting.
fn tracking(&self, modifiers: &Modifiers) -> Tracking {
if modifiers.shift {
return Tracking::Off;
}
Tracking::new(*self.pty.term.lock().mode(), self.x10)
}
fn report(
&mut self,
button: Option<Button>,
gesture: Gesture,
cell: (usize, usize),
modifiers: &Modifiers,
tracking: Tracking,
) -> bool {
let report = Report {
button,
gesture,
cell,
shift: modifiers.shift,
alt: modifiers.alt,
ctrl: modifiers.control,
};
let mode = *self.pty.term.lock().mode();
let Some(bytes) = mouse::encode(&report, tracking, mode) else {
return false;
};
log::trace!("terminal {}: {report:?} sends {bytes:?}", self.pty.pid);
self.pty.write(bytes);
self.pointer.last = Some(cell);
true
}
pub(super) fn mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
window.focus(&self.focus, cx);
let (cell, point, side) = self.cell_at(event.position);
let opens = if cfg!(target_os = "macos") {
event.modifiers.platform
} else {
event.modifiers.control
};
if event.button == MouseButton::Left && opens {
let link = link_at(&self.pty.term.lock(), point);
if let Some(link) = link {
log::info!("terminal {}: opens {link}", self.pty.pid);
cx.open_url(&link);
return;
}
}
let tracking = self.tracking(&event.modifiers);
if tracking != Tracking::Off {
let Some(pressed) = button(event.button) else {
return;
};
if self.report(
Some(pressed),
Gesture::Press,
cell,
&event.modifiers,
tracking,
) {
self.pointer.pressed = Some(pressed);
}
return;
}
if event.button != MouseButton::Left {
return;
}
let mut term = self.pty.term.lock();
match (
term.selection.as_mut(),
event.modifiers.shift,
event.click_count,
) {
(Some(selection), true, 1) => selection.update(point, side),
_ => {
let kind = match event.click_count {
2 => SelectionType::Semantic,
3.. => SelectionType::Lines,
_ => SelectionType::Simple,
};
term.selection = Some(Selection::new(kind, point, side));
}
}
drop(term);
self.pointer.selecting = true;
cx.notify();
}
pub(super) fn mouse_move(
&mut self,
event: &MouseMoveEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
let (cell, point, side) = self.cell_at(event.position);
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
if let Some(selection) = self.pty.term.lock().selection.as_mut() {
selection.update(point, side);
}
cx.notify();
return;
}
let tracking = self.tracking(&event.modifiers);
if tracking != Tracking::Off && self.pointer.last != Some(cell) {
let held = self.pointer.pressed;
self.report(held, Gesture::Motion, cell, &event.modifiers, tracking);
}
}
pub(super) fn mouse_up(
&mut self,
event: &MouseUpEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if event.button == MouseButton::Left && std::mem::take(&mut self.pointer.selecting) {
let selected = self.selected();
if let Some(text) = selected.filter(|_| SettingsStore::get(cx).copy_on_select) {
self.copy_text(text, cx);
}
}
let released = button(event.button);
if released.is_some() && self.pointer.pressed == released {
self.pointer.pressed = None;
let (cell, ..) = self.cell_at(event.position);
let tracking = Tracking::new(*self.pty.term.lock().mode(), self.x10);
self.report(released, Gesture::Release, cell, &event.modifiers, tracking);
}
}
pub(super) fn wheel(
&mut self,
event: &ScrollWheelEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
let height = self.pointer.cell.height;
if height <= Pixels::ZERO {
return;
}
cx.stop_propagation();
self.pointer.rest += match event.delta {
ScrollDelta::Pixels(delta) => delta.y,
ScrollDelta::Lines(delta) => height * delta.y * NOTCH,
};
let lines = (self.pointer.rest / height).trunc() as i32;
if lines == 0 {
return;
}
self.pointer.rest -= height * lines as f32;
log::trace!("terminal {}: wheel {lines} lines", self.pty.pid);
let (cell, ..) = self.cell_at(event.position);
let tracking = self.tracking(&event.modifiers);
let mode = *self.pty.term.lock().mode();
if tracking != Tracking::Off {
let turn = if lines > 0 {
Button::WheelUp
} else {
Button::WheelDown
};
for _ in 0..lines.unsigned_abs() {
self.report(Some(turn), Gesture::Press, cell, &event.modifiers, tracking);
}
} else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) {
(true, true) => b"\x1bOA",
(true, false) => b"\x1b[A",
(false, true) => b"\x1bOB",
(false, false) => b"\x1b[B",
};
self.pty.write(arrow.repeat(lines.unsigned_abs() as usize));
} else {
self.pty.term.lock().scroll_display(Scroll::Delta(lines));
cx.notify();
}
}
/// The selected text, without the newline alacritty ends a whole line with, as Ghostty copies it.
fn selected(&self) -> Option<String> {
let mut text = self.pty.term.lock().selection_to_string()?;
if text.ends_with('\n') {
text.pop();
}
(!text.is_empty()).then_some(text)
}
/// 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),
None => log::debug!("terminal {}: nothing selected to copy", self.pty.pid),
}
}
fn copy_text(&self, text: String, cx: &mut Context<Self>) {
log::info!(
"terminal {}: copied {} characters",
self.pty.pid,
text.chars().count()
);
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
/// The clipboard's text to the program, bracketed when it asked.
pub(super) fn paste(&mut self, cx: &mut Context<Self>) {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
log::debug!("terminal {}: no text to paste", self.pty.pid);
return;
};
let bracketed = self
.pty
.term
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
log::info!(
"terminal {}: pastes {} characters{}",
self.pty.pid,
text.chars().count(),
if bracketed { ", bracketed" } else { "" }
);
self.input(keys::paste(&text, bracketed), cx);
}
}
+4 -2
View File
@@ -14,11 +14,13 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::tap::Tapped;
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty: the grid's events and the folder the shell reports.
/// What a session hears from its pty: the grid's events, the folder the shell reports, and mode 9.
#[derive(Debug)]
pub enum PtyEvent {
Term(Event),
Folder(PathBuf),
/// X10 mouse reporting turned on or off.
X10(bool),
}
/// Hands the grid's events to the session; `pid` names the program in logs.
@@ -248,7 +250,7 @@ mod tests {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) => {}
PtyEvent::Term(_) | PtyEvent::X10(_) => {}
}
}
(folders, success)
+102 -1
View File
@@ -12,10 +12,11 @@ use super::pty::PtyEvent;
/// The longest OSC body kept; longer ones are dropped whole.
const MOST: usize = 4096;
/// A pty whose reader hands each byte to an OSC 7 scanner before the parser sees it.
/// A pty whose reader hands each byte to the OSC 7 and mode 9 scanners before the parser sees it.
pub(crate) struct Tapped<P> {
pub inner: P,
scanner: Osc7,
x10: Mode9,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -26,6 +27,7 @@ impl<P> Tapped<P> {
Self {
inner,
scanner: Osc7::default(),
x10: Mode9::default(),
events,
pid,
last: None,
@@ -51,6 +53,15 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
log::debug!("pty {pid}: a folder report after its session closed");
}
});
if let Some(on) = self.x10.feed(&buf[..count]) {
log::info!(
"pty {pid}: X10 mouse reporting {}",
if on { "on" } else { "off" }
);
if events.unbounded_send(PtyEvent::X10(on)).is_err() {
log::debug!("pty {pid}: a mouse mode after its session closed");
}
}
Ok(count)
}
}
@@ -174,6 +185,82 @@ impl Osc7 {
}
}
/// The most parameters one private mode sequence keeps, as vte's.
const PARAMS: usize = 32;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Csi {
#[default]
Ground,
Escape,
Bracket,
Private,
}
/// Follows `CSI ? 9 h` and `l`, X10 mouse reporting, across reads: alacritty's parser drops mode 9.
#[derive(Default)]
pub(crate) struct Mode9 {
state: Csi,
params: Vec<u32>,
on: bool,
}
impl Mode9 {
/// The mode after `bytes`, when they changed it.
pub fn feed(&mut self, bytes: &[u8]) -> Option<bool> {
let was = self.on;
let mut rest = bytes;
while !rest.is_empty() {
if self.state == Csi::Ground {
let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else {
break;
};
self.state = Csi::Escape;
rest = &rest[at + 1..];
continue;
}
let byte = rest[0];
rest = &rest[1..];
self.state = match (self.state, byte) {
(_, 0x1b) => Csi::Escape,
(Csi::Escape, b'[') => Csi::Bracket,
(Csi::Escape, b'c') => {
self.on = false;
Csi::Ground
}
(Csi::Bracket, b'?') => {
self.params = vec![0];
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'));
Csi::Private
}
(Csi::Private, b';') if self.params.len() < PARAMS => {
self.params.push(0);
Csi::Private
}
(Csi::Private, b'h' | b'l') => {
let set = byte == b'h';
for mode in &self.params {
match mode {
9 => self.on = set,
1000 | 1002 | 1003 if set => self.on = false,
_ => {}
}
}
Csi::Ground
}
_ => Csi::Ground,
};
}
(self.on != was).then_some(self.on)
}
}
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
let Some(rest) = body.strip_prefix(b"file://") else {
@@ -271,6 +358,20 @@ mod tests {
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
}
#[test]
fn mode_9_follows_its_sets_resets_and_the_modes_that_replace_it() {
let mut mode = Mode9::default();
assert_eq!(mode.feed(b"a\x1b[?"), None);
assert_eq!(mode.feed(b"1006;9h"), Some(true));
assert_eq!(mode.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
assert_eq!(mode.feed(b"l"), Some(false));
assert_eq!(mode.feed(b"\x1b[?9h\x1b[?1000h"), None, "1000 replaced it");
assert_eq!(mode.feed(b"\x1b[?9h"), Some(true));
assert_eq!(mode.feed(b"\x1b[?1000l"), None);
assert_eq!(mode.feed(b"\x1bc"), Some(false), "a reset ends it");
assert_eq!(mode.feed(b"\x1b[?09;\x1b[?9h"), Some(true));
}
#[test]
fn a_local_uri_gives_its_decoded_folder() {
assert_eq!(
+32 -5
View File
@@ -1,18 +1,19 @@
use std::{path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use alacritty_terminal::{event::Event, grid::Dimensions, vte::ansi::CursorShape};
use futures::StreamExt;
use gpui::{
Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures,
FontStyle, FontWeight, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task,
Window, div, prelude::*, px, size,
FontStyle, FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled,
Subscription, Task, Window, div, prelude::*, px, size,
};
use super::{
frame::frame,
ink::Ink,
input,
input::{self, Copy, Paste},
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
};
use crate::{
@@ -46,7 +47,12 @@ pub struct TerminalView {
pub(super) marked: Option<String>,
/// The cursor's cell in the window, from the last layout.
pub(super) caret: Bounds<Pixels>,
size: (usize, usize),
pub(super) pointer: Pointer,
/// X10 mouse reporting, mode 9, as the tap follows it.
pub(super) x10: bool,
/// Keys whose press this terminal saw, so their releases may go out.
pub(super) held: HashSet<String>,
pub(super) size: (usize, usize),
cell: (u16, u16),
blinking: bool,
shown: bool,
@@ -76,6 +82,9 @@ impl TerminalView {
focus: cx.focus_handle().tab_stop(true),
marked: None,
caret: Bounds::default(),
pointer: Pointer::default(),
x10: false,
held: HashSet::new(),
size,
cell: (8, 16),
blinking: false,
@@ -146,6 +155,7 @@ impl TerminalView {
match event {
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
PtyEvent::Term(event) => self.term_event(event, cx),
PtyEvent::X10(on) => self.x10 = on,
}
}
cx.notify();
@@ -159,6 +169,11 @@ impl TerminalView {
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::ClipboardLoad(..) => log::error!(
@@ -261,10 +276,22 @@ impl Render for TerminalView {
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::key_down))
.on_key_up(cx.listener(Self::key_up))
.on_action(cx.listener(|view, _: &Copy, _, cx| view.copy(cx)))
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
.on_any_mouse_down(cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
.on_scroll_wheel(cx.listener(Self::wheel))
.cursor_text()
.size_full()
.px(px(settings.padding_x))
.py(px(settings.padding_y))
.bg(ink.bg.alpha(settings.background_opacity));
let root = [MouseButton::Left, MouseButton::Middle, MouseButton::Right]
.into_iter()
.fold(root, |root, button| {
root.on_mouse_up(button, cx.listener(Self::mouse_up))
.on_mouse_up_out(button, cx.listener(Self::mouse_up))
});
match look(&settings, &ink, window) {
Ok(look) => {
self.cell = (