T59: a plain click on the line the shell reads moves its cursor there, which a user asked for after 0.1.6, as Ghostty's cursor-click-to-move: while OSC 133 says the shell reads a line, a click that stays in its cell, past the prompt the cursor's line shows, sends Left or Right arrows through the key encoder, one per character, soft wraps joined; never on the alternate screen, under mouse tracking or with a selection

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 15:18:17 -04:00
1 parent 8ef91855cd
commit 82cfe91810
12 files changed
+234 -4

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
//! A click on the line the shell reads moves its cursor there with arrows, as Ghostty's `cursor-click-to-move`.
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Line, Point},
term::{
Term, TermMode,
cell::{Cell, Flags},
},
};
use gpui::Context;
use super::{prompts::prompted, view::TerminalView};
/// Arrows from the cursor to `click`, right positive; none when `click` lies off the input after the cursor's prompt, soft wraps joined.
pub fn arrows<T>(term: &Term<T>, click: Point) -> Option<i32> {
if term.mode().contains(TermMode::ALT_SCREEN) {
return None;
}
let grid = term.grid();
let columns = grid.columns();
let cursor = grid.cursor.point;
let wraps = |line: i32| {
grid[Line(line)][Column(columns - 1)]
.flags
.contains(Flags::WRAPLINE)
};
let mut first = cursor.line.0;
while first > grid.topmost_line().0 && wraps(first - 1) {
first -= 1;
}
let mut last = cursor.line.0;
while last < grid.bottommost_line().0 && wraps(last) {
last += 1;
}
if !(first..=last).contains(&click.line.0) {
return None;
}
let cells: Vec<&Cell> = (first..=last)
.flat_map(|line| (0..columns).map(move |column| &grid[Line(line)][Column(column)]))
.collect();
let at = |point: Point| (point.line.0 - first) as usize * columns + point.column.0;
let (here, there) = (at(cursor), at(click));
// Input starts past the prompt the cursor's line shows before it.
let start = cells[..here].iter().rposition(|cell| prompted(cell))? + 1;
if there < start {
return None;
}
let inked = |cell: &&Cell| !prompted(cell) && !matches!(cell.c, ' ' | '\t');
let end = cells[start..]
.iter()
.rposition(inked)
.map_or(here, |last| here.max(start + last + 1));
let mut target = there.min(end);
if cells[target].flags.contains(Flags::WIDE_CHAR_SPACER) {
target -= 1;
}
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
let typed = |cell: &&&Cell| !prompted(cell) && !cell.flags.intersects(spacer);
let count = |from: usize, to: usize| cells[from..to].iter().filter(typed).count() as i32;
Some(if target >= here {
count(here, target)
} else {
-count(target, here)
})
}
impl TerminalView {
/// Moves the shell's cursor to `click`, a press and release of an arrow per character, while the shell reads a line.
pub(super) fn click_to_move(&mut self, click: Point, cx: &mut Context<Self>) {
let pid = self.pty.pid;
if !self.reading {
log::debug!("terminal {pid}: a click at {click:?}, no line read");
return;
}
let Some(count) = arrows(&self.pty.term.lock(), click) else {
log::debug!("terminal {pid}: a click at {click:?}, off the input");
return;
};
log::debug!("terminal {pid}: a click at {click:?} moves the cursor {count}");
let arrow = if count < 0 { "left" } else { "right" };
for _ in 0..count.unsigned_abs() {
self.tap_key(arrow, cx);
}
}
}
#[cfg(test)]
mod tests;
+91
View File
@@ -0,0 +1,91 @@
use alacritty_terminal::{
event::VoidListener,
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use super::*;
use crate::term::prompts::Prompts;
const PROMPT: &str = "\x1b]133;A\x07$ \x1b]133;B\x07";
/// A terminal `columns` wide after `text` passed the prompts' rewrite, as the tap passes it.
fn term(columns: usize, text: &str) -> Term<VoidListener> {
let mut term = Term::new(Config::default(), &TermSize::new(columns, 4), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, Prompts::default().pass(text.as_bytes()));
term
}
fn click(line: i32, column: usize) -> Point {
Point::new(Line(line), Column(column))
}
#[test]
fn a_click_on_the_typed_line_counts_the_characters_to_it() {
let typed = term(20, &format!("{PROMPT}echo hello"));
assert_eq!(arrows(&typed, click(0, 7)), Some(-5), "back to hello");
assert_eq!(
arrows(&typed, click(0, 2)),
Some(-10),
"the first character"
);
assert_eq!(
arrows(&typed, click(0, 12)),
Some(0),
"the cursor's own cell"
);
let moved = term(20, &format!("{PROMPT}echo hello\x1b[5D"));
assert_eq!(arrows(&moved, click(0, 11)), Some(4), "on to the last o");
assert_eq!(
arrows(&moved, click(0, 18)),
Some(5),
"past the end, the end"
);
}
#[test]
fn a_click_off_the_input_moves_nothing() {
let typed = term(20, &format!("{PROMPT}ls"));
assert_eq!(arrows(&typed, click(0, 0)), None, "on the prompt");
assert_eq!(arrows(&typed, click(0, 1)), None, "on the prompt's space");
assert_eq!(arrows(&typed, click(2, 3)), None, "another line");
let ran = term(20, &format!("{PROMPT}cat\r\nhello"));
assert_eq!(arrows(&ran, click(1, 2)), None, "a line no prompt shows");
let output = term(20, "$ plain text");
assert_eq!(arrows(&output, click(0, 3)), None, "no marks at all");
let full = term(20, &format!("{PROMPT}vim\x1b[?1049h"));
assert_eq!(arrows(&full, click(0, 3)), None, "the alternate screen");
}
#[test]
fn a_wide_character_takes_one_arrow() {
let wide = term(20, &format!("{PROMPT}中文ab"));
assert_eq!(arrows(&wide, click(0, 3)), Some(-4), "the right half of 中");
assert_eq!(arrows(&wide, click(0, 4)), Some(-3), "文");
assert_eq!(arrows(&wide, click(0, 7)), Some(-1), "b");
}
#[test]
fn soft_wrapped_rows_are_one_line() {
let wrapped = term(10, &format!("{PROMPT}abcdefghijkl"));
assert_eq!(wrapped.grid().cursor.point, click(1, 4));
assert_eq!(arrows(&wrapped, click(0, 3)), Some(-11), "back to b");
let back = term(10, &format!("{PROMPT}abcdefghijkl\x1b[3D"));
assert_eq!(arrows(&back, click(0, 3)), Some(-8), "up to b");
assert_eq!(arrows(&back, click(1, 3)), Some(2), "on to l");
}
#[test]
fn trailing_spaces_count_only_up_to_the_cursor() {
let spaced = term(20, &format!("{PROMPT}ls "));
assert_eq!(arrows(&spaced, click(0, 15)), Some(0));
assert_eq!(arrows(&spaced, click(0, 4)), Some(-2));
}
#[test]
fn a_prompt_over_two_lines_keeps_its_input_on_the_last() {
let two = term(20, "\x1b]133;A\x07~/src\r\n> \x1b]133;B\x07make");
assert_eq!(arrows(&two, click(1, 2)), Some(-4));
assert_eq!(arrows(&two, click(0, 3)), None, "the prompt's first line");
}
+12
View File
@@ -121,6 +121,18 @@ impl TerminalView {
}
}
/// Presses and lets go of `name` without modifiers, as the keyboard would.
pub(super) fn tap_key(&mut self, name: &str, cx: &mut Context<Self>) {
let keystroke = Keystroke {
modifiers: Default::default(),
key: name.into(),
key_char: None,
};
let facts = Facts::default();
let record = self.send(&keystroke, Action::Press, facts, false, cx) == Some(true);
self.send(&keystroke, Action::Release, facts, record, cx);
}
/// Sends what `keystroke` means to the program: none when it is text the input method types, else whether it went as Windows' record; `pressed` says that of a held key's press.
fn send(
&mut self,
+1 -1
View File
@@ -50,7 +50,7 @@ const LEFT_OPTION: u64 = 0x20;
const RIGHT_OPTION: u64 = 0x40;
/// What gpui drops from the key event AppKit is handling.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct KeyFacts {
pub left_option: bool,
pub right_option: bool,
+1
View File
@@ -1,4 +1,5 @@
mod boxes;
mod click;
mod find;
pub mod frame;
mod herdr;
+12
View File
@@ -28,6 +28,8 @@ pub struct Pointer {
pub cell: Size<Pixels>,
/// The left button is down and drawing a selection.
selecting: bool,
/// A plain press's cell, until it drags off it; its release may move the shell's cursor.
click: Option<Cell>,
/// Buttons whose press went to the program.
pressed: Vec<Button>,
/// The cell of the last report; motion inside it sends nothing.
@@ -149,6 +151,8 @@ impl TerminalView {
if event.button != MouseButton::Left {
return;
}
let plain = event.click_count == 1 && !event.modifiers.modified();
self.pointer.click = plain.then_some(point);
let pid = self.pty.pid;
let mut term = self.pty.term.lock();
match (
@@ -183,6 +187,9 @@ impl TerminalView {
) {
let (cell, point, side) = self.cell_at(event.position);
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
if self.pointer.click.is_some_and(|click| click != point) {
self.pointer.click = None;
}
if let Some(selection) = self.pty.term.lock().selection.as_mut() {
log::trace!("terminal {}: the selection runs to {point:?}", self.pty.pid);
selection.update(point, side);
@@ -207,6 +214,10 @@ impl TerminalView {
) {
if event.button == MouseButton::Left {
self.end_selection(cx);
let click = self.pointer.click.take();
if let Some(click) = click.filter(|_| self.selected().is_none()) {
self.click_to_move(click, cx);
}
}
if let Some(released) = button(event.button) {
let (cell, ..) = self.cell_at(event.position);
@@ -217,6 +228,7 @@ impl TerminalView {
/// Ends the mouse's gesture as releases would: a hidden terminal hears none.
pub fn let_go(&mut self, cx: &mut Context<Self>) {
self.end_selection(cx);
self.pointer.click = None;
for held in self.pointer.pressed.clone() {
log::debug!("terminal {}: hidden with {held:?} down", self.pty.pid);
let cell = self
+6
View File
@@ -271,6 +271,12 @@ fn plain(target: &[u8]) -> Vec<u8> {
link
}
/// Whether `cell` belongs to a prompt, its link's id carrying a prompt's number.
pub(super) fn prompted(cell: &Cell) -> bool {
cell.hyperlink()
.is_some_and(|link| link.id().starts_with(NUMBERED))
}
/// The number of the prompt `row` shows, as a link's id carries it.
fn prompt(row: &Row<Cell>) -> Option<u32> {
row.into_iter().find_map(|cell| {
+6 -2
View File
@@ -85,6 +85,8 @@ pub struct TerminalView {
pub(super) ended: Option<i32>,
/// A command started, as OSC 133 C says, since the last one ended.
pub(super) ran: bool,
/// The shell reads a line: OSC 133 B came, and no A or C since.
pub(super) reading: bool,
/// The system notifications it posted.
pub(super) notices: Notices,
/// The program's title; empty while it names none.
@@ -146,6 +148,7 @@ impl TerminalView {
records: Records::default(),
ended: None,
ran: false,
reading: false,
notices: Notices::default(),
title: String::new(),
front: true,
@@ -274,11 +277,12 @@ impl TerminalView {
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
// A new prompt ends what was working, waiting or idle; done and error stay.
PtyEvent::Mark(Mark::Prompt) => {
self.reading = false;
self.records.prompt();
self.restate(cx);
}
PtyEvent::Mark(Mark::Output) => self.ran = true,
PtyEvent::Mark(_) => {}
PtyEvent::Mark(Mark::Output) => (self.ran, self.reading) = (true, false),
PtyEvent::Mark(Mark::Input) => self.reading = true,
PtyEvent::Status(status) => {
self.status(status);
self.restate(cx);