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:
12 files changed
+234
-4
No files matched your search
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
mod boxes;
|
||||
mod click;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
mod herdr;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user