T59: a shell whose prompt says click_events, as fish from 4.1 does, hears the click as an SGR press and moves its own cursor, since fish moves by code point and its redraws drop the wrap flags a count follows; a press let go in another cell, or released while the program tracks the mouse, moves nothing

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 15:34:17 -04:00
1 parent 82cfe91810
commit 0c55d41026
12 files changed
+242 -46

No files matched your search

+66 -4
View File
@@ -1,4 +1,4 @@
//! A click on the line the shell reads moves its cursor there with arrows, as Ghostty's `cursor-click-to-move`.
//! A click on the line the shell reads moves its cursor there, as Ghostty's `cursor-click-to-move`: arrows, or the press itself where the shell takes clicks.
use alacritty_terminal::{
grid::Dimensions,
@@ -10,7 +10,34 @@ use alacritty_terminal::{
};
use gpui::Context;
use super::{prompts::prompted, view::TerminalView};
use super::{
marks::Click,
mouse::Tracking,
prompts::{prompted, start},
view::TerminalView,
};
/// Where the shell stands, as its OSC 133 marks say.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Shell {
/// A command runs, or nothing said otherwise.
#[default]
Busy,
/// Its prompt draws; the line after it takes a click so.
Prompting(Click),
/// It reads a line, which takes a click so.
Reading(Click),
}
impl Shell {
/// The shell after B: it reads the line its prompt started, which takes arrows without one.
pub fn reads(self) -> Self {
match self {
Self::Prompting(click) | Self::Reading(click) => Self::Reading(click),
Self::Busy => Self::Reading(Click::Arrows),
}
}
}
/// 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> {
@@ -65,13 +92,48 @@ pub fn arrows<T>(term: &Term<T>, click: Point) -> Option<i32> {
})
}
/// The left press, SGR, a shell that takes clicks hears for `click`, its row the screen's or counted from its prompt's first, as kitty and Ghostty send it; none before that prompt.
pub fn press<T>(term: &Term<T>, click: Point, relative: bool) -> Option<Vec<u8>> {
if term.mode().contains(TermMode::ALT_SCREEN) {
return None;
}
let first = start(term, term.grid().cursor.point.line.0)?;
let opens = (0..term.columns())
.position(|column| prompted(&term.grid()[Line(first)][Column(column)]))
.expect("a prompt's first row holds its cells");
if (click.line.0, click.column.0) < (first, opens) {
return None;
}
let row = if relative {
click.line.0 - first
} else {
click.line.0
} + 1;
if row < 1 {
return None;
}
Some(format!("\x1b[<0;{};{row}M", click.column.0 + 1).into_bytes())
}
impl TerminalView {
/// Moves the shell's cursor to `click`, a press and release of an arrow per character, while the shell reads a line.
/// Moves the shell's cursor to `click` while it reads a line: arrows, each pressed and let go, or the press where the shell takes clicks.
pub(super) fn click_to_move(&mut self, click: Point, cx: &mut Context<Self>) {
let pid = self.pty.pid;
if !self.reading {
let Shell::Reading(takes) = self.shell else {
log::debug!("terminal {pid}: a click at {click:?}, no line read");
return;
};
if self.mouse.tracking != Tracking::Off {
log::debug!("terminal {pid}: a click at {click:?}, the program tracks the mouse");
return;
}
if takes != Click::Arrows {
let Some(bytes) = press(&self.pty.term.lock(), click, takes == Click::Relative) else {
log::debug!("terminal {pid}: a click at {click:?}, before the prompt");
return;
};
log::debug!("terminal {pid}: a click at {click:?} goes to the shell as {bytes:?}");
return self.input(bytes, cx);
}
let Some(count) = arrows(&self.pty.term.lock(), click) else {
log::debug!("terminal {pid}: a click at {click:?}, off the input");
+61
View File
@@ -89,3 +89,64 @@ fn a_prompt_over_two_lines_keeps_its_input_on_the_last() {
assert_eq!(arrows(&two, click(1, 2)), Some(-4));
assert_eq!(arrows(&two, click(0, 3)), None, "the prompt's first line");
}
/// The press `term` sends for a click at `at`, as text.
fn sent(term: &Term<VoidListener>, at: Point, relative: bool) -> Option<String> {
press(term, at, relative).map(|bytes| String::from_utf8(bytes).expect("ASCII"))
}
#[test]
fn a_shell_that_takes_clicks_hears_the_press_where_it_landed() {
let marked = "\x1b]133;A;click_events=1\x07$ \x1b]133;B\x07";
let typed = term(20, &format!("out\r\n{marked}echo hi"));
assert_eq!(
sent(&typed, click(1, 7), false).as_deref(),
Some("\x1b[<0;8;2M")
);
assert_eq!(
sent(&typed, click(1, 7), true).as_deref(),
Some("\x1b[<0;8;1M")
);
let below = sent(&typed, click(2, 3), true);
assert_eq!(
below.as_deref(),
Some("\x1b[<0;4;2M"),
"a row below goes too"
);
assert_eq!(sent(&typed, click(0, 1), false), None, "before the prompt");
let two = term(
20,
"\x1b]133;A;click_events=2\x07~/src\r\n> \x1b]133;B\x07ls",
);
let relative = sent(&two, click(1, 3), true);
assert_eq!(
relative.as_deref(),
Some("\x1b[<0;4;2M"),
"from the prompt's first row"
);
let full = term(20, &format!("{marked}vim\x1b[?1049h"));
assert_eq!(
sent(&full, click(0, 3), false),
None,
"the alternate screen"
);
let scrolled = term(20, &format!("{marked}a\r\nb\r\nc\r\nd\r\ne"));
assert_eq!(
sent(&scrolled, click(-1, 0), false),
None,
"a row in the history"
);
}
#[test]
fn input_reads_the_line_its_prompt_started() {
assert_eq!(Shell::Busy.reads(), Shell::Reading(Click::Arrows));
assert_eq!(
Shell::Prompting(Click::Absolute).reads(),
Shell::Reading(Click::Absolute)
);
assert_eq!(
Shell::Reading(Click::Relative).reads(),
Shell::Reading(Click::Relative)
);
}
+45 -6
View File
@@ -1,8 +1,8 @@
/// A semantic prompt mark a program sends, OSC 133.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mark {
/// A prompt starts: A, or N.
Prompt,
/// A prompt starts: A, or N, saying how its line takes a click.
Prompt(Click),
/// The prompt ends and input starts: B, or I.
Input,
/// Input ends and the command's output starts: C.
@@ -11,6 +11,17 @@ pub enum Mark {
Done(Option<i32>),
}
/// How the line a prompt reads takes a click, as its start's `click_events` says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Click {
/// Arrows the terminal counts.
Arrows,
/// A mouse press at the click's place on the screen, `click_events=1`.
Absolute,
/// A mouse press, its row counted from the prompt's first, `click_events=2`.
Relative,
}
/// The mark in an OSC 133 body past `133;`; none for one the terminal takes no action on.
pub(crate) fn read(body: &[u8]) -> Option<Mark> {
let shown = || String::from_utf8_lossy(body);
@@ -27,7 +38,7 @@ pub(crate) fn read(body: &[u8]) -> Option<Mark> {
}
};
match letter {
b'A' | b'N' => Some(Mark::Prompt),
b'A' | b'N' => Some(Mark::Prompt(click(options))),
b'B' | b'I' => Some(Mark::Input),
b'C' => Some(Mark::Output),
b'D' => Some(Mark::Done(status(options))),
@@ -43,6 +54,25 @@ pub(crate) fn read(body: &[u8]) -> Option<Mark> {
}
}
/// How A's options say its line takes a click; arrows without `click_events`.
fn click(options: &[u8]) -> Click {
let value = options
.split(|&byte| byte == b';')
.find_map(|option| option.strip_prefix(b"click_events="));
match value {
None | Some(b"0") => Click::Arrows,
Some(b"1") => Click::Absolute,
Some(b"2") => Click::Relative,
Some(other) => {
log::warn!(
"pty: OSC 133;A with click_events={}, no such mode, so arrows",
String::from_utf8_lossy(other)
);
Click::Arrows
}
}
}
/// D's status, its first option; none when the program gave none or no number.
fn status(options: &[u8]) -> Option<i32> {
let first = options.split(|&byte| byte == b';').next()?;
@@ -67,9 +97,18 @@ mod tests {
#[test]
fn each_mark_reads_with_its_options_as_ghostty_reads_it() {
assert_eq!(read(b"A"), Some(Mark::Prompt));
assert_eq!(read(b"A;click_events=1"), Some(Mark::Prompt));
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt));
assert_eq!(read(b"A"), Some(Mark::Prompt(Click::Arrows)));
assert_eq!(
read(b"A;click_events=1"),
Some(Mark::Prompt(Click::Absolute))
);
assert_eq!(
read(b"A;aid=3;click_events=2"),
Some(Mark::Prompt(Click::Relative))
);
assert_eq!(read(b"A;click_events=0"), Some(Mark::Prompt(Click::Arrows)));
assert_eq!(read(b"A;cl=line"), Some(Mark::Prompt(Click::Arrows)));
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt(Click::Arrows)));
assert_eq!(read(b"B"), Some(Mark::Input));
assert_eq!(read(b"I"), Some(Mark::Input));
assert_eq!(read(b"C;cmdline_url=false"), Some(Mark::Output));
+8 -3
View File
@@ -214,9 +214,14 @@ 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);
let (_, point, _) = self.cell_at(event.position);
match self.pointer.click.take() {
Some(click) if click != point => log::debug!(
"terminal {}: a press at {click:?} let go at {point:?}",
self.pty.pid
),
Some(click) if self.selected().is_none() => self.click_to_move(click, cx),
_ => {}
}
}
if let Some(released) = button(event.button) {
+20 -14
View File
@@ -285,26 +285,32 @@ fn prompt(row: &Row<Cell>) -> Option<u32> {
})
}
/// The first row of the nearest prompt at or above `line`, blank rows within it passed.
pub(super) fn start<T>(term: &Term<T>, line: i32) -> Option<i32> {
let first = term.topmost_line().0;
let number = |line: i32| prompt(&term.grid()[Line(line)]);
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
let (found, own) = (first..=line)
.rev()
.find_map(|line| number(line).map(|own| (line, own)))?;
let mut start = found;
for above in (first..found).rev() {
match number(above) {
Some(other) if other != own => break,
Some(_) => start = above,
None => {}
}
}
Some(start)
}
/// The display offset that puts the prompt before or after the view's top at the top, as Ghostty jumps.
pub fn jump<T>(term: &Term<T>, up: bool) -> Option<usize> {
let top = -(term.grid().display_offset() as i32);
let (first, last) = (term.topmost_line().0, term.bottommost_line().0);
let number = |line: i32| prompt(&term.grid()[Line(line)]);
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
let found = if up {
// The nearest prompt above the view's top, then its first row, blank rows within it passed.
let (line, own) = (first..top)
.rev()
.find_map(|line| number(line).map(|own| (line, own)))?;
let mut start = line;
for above in (first..line).rev() {
match number(above) {
Some(other) if other != own => break,
Some(_) => start = above,
None => {}
}
}
start
start(term, top - 1)?
} else {
// The prompt at or above the view's top may go on below it; the next one starts.
let here = (first..=top).rev().find_map(number);
+1 -1
View File
@@ -63,7 +63,7 @@ fn the_rewrite_reads_a_mark_as_the_session_hears_it() {
"A", "A;k=i", "N", "B", "I", "C;x", "D;1", "D;abc", "D", "", "AB", "L", "P;k=i", "Q", "a",
];
for body in bodies {
let heard = marks::read(body.as_bytes()).map(|mark| mark == Mark::Prompt);
let heard = marks::read(body.as_bytes()).map(|mark| matches!(mark, Mark::Prompt(_)));
assert_eq!(mark(body.as_bytes()), heard, "{body}");
}
}
+7 -1
View File
@@ -5,6 +5,7 @@ use alacritty_terminal::{
use futures::StreamExt;
use super::*;
use crate::term::marks::Click;
use crate::term::{
notify::Ask,
status::{Record, State},
@@ -115,7 +116,12 @@ fn osc_133_reaches_the_session_as_marks() {
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
(40, 4),
);
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
let marks = [
Mark::Prompt(Click::Arrows),
Mark::Input,
Mark::Output,
Mark::Done(Some(3)),
];
assert_eq!(ran.marks, marks);
assert_eq!(ran.text, ">x");
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
+23 -7
View File
@@ -9,7 +9,11 @@ use futures::channel::mpsc::TryRecvError;
use super::*;
use crate::{
settings::Settings,
term::{Events, Pty, PtyEvent, config, marks::Mark, spawn},
term::{
Events, Pty, PtyEvent, config,
marks::{Click, Mark},
spawn,
},
};
/// A folder name with a space, a percent, a bell and a newline.
@@ -146,9 +150,9 @@ impl Run {
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
fn walk(&mut self, probe: &str) {
let first = [Mark::Prompt, Mark::Input];
let first = [Mark::Prompt(Click::Arrows), Mark::Input];
self.until("the first prompt's marks", |run| {
run.marks.starts_with(&first)
run.marks.len() >= 2 && ends_like(&run.marks[..2], &first)
});
let start = self.start.clone();
self.send(probe);
@@ -173,7 +177,7 @@ impl Run {
let from = self.marks.len();
self.send(line);
self.until(&format!("the marks of {line}"), |run| {
run.marks[from..].ends_with(marks)
ends_like(&run.marks[from..], marks)
});
}
@@ -222,11 +226,22 @@ fn ended(status: i32) -> [Mark; 4] {
[
Mark::Output,
Mark::Done(Some(status)),
Mark::Prompt,
Mark::Prompt(Click::Arrows),
Mark::Input,
]
}
/// Whether `heard` ends as `wanted` does, a prompt's way of taking a click aside: fish from 4.1 takes clicks itself.
fn ends_like(heard: &[Mark], wanted: &[Mark]) -> bool {
let Some(start) = heard.len().checked_sub(wanted.len()) else {
return false;
};
heard[start..].iter().zip(wanted).all(|pair| match pair {
(Mark::Prompt(_), Mark::Prompt(_)) => true,
(heard, wanted) => heard == wanted,
})
}
#[test]
fn the_scripts_install_once_and_mend_a_changed_one() {
let dir = folder("install");
@@ -289,7 +304,8 @@ fn zsh_says_why_it_marks_no_prompt_without_prompt_percent() {
run.send("false");
let ended = [Mark::Output, Mark::Done(Some(1))];
run.until("the command's marks", |run| run.marks.ends_with(&ended));
assert!(!run.marks.contains(&Mark::Prompt), "{:?}", run.marks);
let prompted = run.marks.iter().any(|mark| matches!(mark, Mark::Prompt(_)));
assert!(!prompted, "{:?}", run.marks);
run.finish();
}
@@ -349,7 +365,7 @@ fn bash_says_why_it_marks_no_command_without_promptvars() {
run.shows("ELYTerminal: bash marks no command while promptvars is off")
});
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
run.ends("false", &[Mark::Prompt, Mark::Input]);
run.ends("false", &[Mark::Prompt(Click::Arrows), Mark::Input]);
run.finish();
}
+8 -7
View File
@@ -18,6 +18,7 @@ use gpui::{
};
use super::{
click::Shell,
find::Finder,
frame::frame,
ink::Ink,
@@ -85,8 +86,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,
/// Where the shell stands, as its OSC 133 marks say.
pub(super) shell: Shell,
/// The system notifications it posted.
pub(super) notices: Notices,
/// The program's title; empty while it names none.
@@ -148,7 +149,7 @@ impl TerminalView {
records: Records::default(),
ended: None,
ran: false,
reading: false,
shell: Shell::Busy,
notices: Notices::default(),
title: String::new(),
front: true,
@@ -276,13 +277,13 @@ impl TerminalView {
PtyEvent::Win32(on) => self.win32 = on,
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;
PtyEvent::Mark(Mark::Prompt(click)) => {
self.shell = Shell::Prompting(click);
self.records.prompt();
self.restate(cx);
}
PtyEvent::Mark(Mark::Output) => (self.ran, self.reading) = (true, false),
PtyEvent::Mark(Mark::Input) => self.reading = true,
PtyEvent::Mark(Mark::Output) => (self.ran, self.shell) = (true, Shell::Busy),
PtyEvent::Mark(Mark::Input) => self.shell = self.shell.reads(),
PtyEvent::Status(status) => {
self.status(status);
self.restate(cx);