T35: Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top, as Ghostty jumps: the tap rewrites each OSC 133 mark in place into an OSC 8 link of the terminal's own, which the parser keeps in the prompt's cells through scroll, reflow and history, and links pass over; the tap's OSC scanner ends a body as vte does
This commit is contained in:
13 files changed
+419
-27
No files matched your search
@@ -14,7 +14,7 @@ use super::{OpenSettings, SettingsWindow, choice, set, switch};
|
||||
use crate::{
|
||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||
term::input::{self, Find},
|
||||
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
workspace::{
|
||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||
TogglePanel, ToggleSessions,
|
||||
@@ -141,6 +141,8 @@ fn listed() -> Vec<(&'static str, &'static str, Box<dyn Action>)> {
|
||||
("Edit", "Paste", Paste.boxed_clone()),
|
||||
("Edit", "Select all", SelectAll.boxed_clone()),
|
||||
("Edit", "Find", Find.boxed_clone()),
|
||||
("View", "Previous prompt", PreviousPrompt.boxed_clone()),
|
||||
("View", "Next prompt", NextPrompt.boxed_clone()),
|
||||
("View", "Toggle sessions", ToggleSessions.boxed_clone()),
|
||||
("View", "Toggle right panel", TogglePanel.boxed_clone()),
|
||||
("View", "Bigger", Bigger.boxed_clone()),
|
||||
|
||||
+15
-1
@@ -12,7 +12,7 @@ use super::{
|
||||
view::TerminalView,
|
||||
};
|
||||
|
||||
actions!(elyterminal, [Find, FindNewer]);
|
||||
actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]);
|
||||
|
||||
/// The key context a terminal's root sets.
|
||||
pub const CONTEXT: &str = "Terminal";
|
||||
@@ -32,6 +32,20 @@ pub fn bind_keys(cx: &mut App) {
|
||||
"ctrl-shift-f",
|
||||
)
|
||||
};
|
||||
// Ghostty's jumps: Cmd with or without Shift on macOS, Ctrl-Shift elsewhere.
|
||||
let (up, down): (&[&str], &[&str]) = if cfg!(target_os = "macos") {
|
||||
(&["cmd-up", "cmd-shift-up"], &["cmd-down", "cmd-shift-down"])
|
||||
} else {
|
||||
(&["ctrl-shift-up"], &["ctrl-shift-down"])
|
||||
};
|
||||
cx.bind_keys(
|
||||
up.iter()
|
||||
.map(|key| KeyBinding::new(key, PreviousPrompt, Some(CONTEXT))),
|
||||
);
|
||||
cx.bind_keys(
|
||||
down.iter()
|
||||
.map(|key| KeyBinding::new(key, NextPrompt, Some(CONTEXT))),
|
||||
);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new(copy, Copy, Some(CONTEXT)),
|
||||
KeyBinding::new(paste, Paste, Some(CONTEXT)),
|
||||
|
||||
+14
-2
@@ -4,13 +4,18 @@ use alacritty_terminal::{
|
||||
};
|
||||
use ely_gpui_component::terminal::{Target, links};
|
||||
|
||||
/// The OSC 8 target at `point`, else a URL in its wrapped line.
|
||||
use super::prompts;
|
||||
|
||||
/// The OSC 8 target at `point`, else a URL in its wrapped line; a prompt's own link is no target.
|
||||
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() {
|
||||
if let Some(link) = grid[point]
|
||||
.hyperlink()
|
||||
.filter(|link| link.uri() != prompts::LINK)
|
||||
{
|
||||
return Some(link.uri().to_string());
|
||||
}
|
||||
let end = term.line_search_right(point);
|
||||
@@ -63,6 +68,13 @@ mod tests {
|
||||
assert_eq!(link(text, 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_s_own_link_is_no_target() {
|
||||
let text = "\x1b]8;;\x7f\x1b\\$ https://ely.dev\x1b]8;;\x1b\\";
|
||||
assert_eq!(link(text, 0, 0), None);
|
||||
assert_eq!(link(text, 0, 4).as_deref(), Some("https://ely.dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_ends_before_its_sentence_does() {
|
||||
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
|
||||
|
||||
@@ -12,6 +12,7 @@ mod marks;
|
||||
pub mod mouse;
|
||||
mod paint;
|
||||
mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
pub mod shell;
|
||||
mod tap;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use alacritty_terminal::{
|
||||
grid::{Dimensions, Scroll},
|
||||
index::Line,
|
||||
term::{
|
||||
Term,
|
||||
cell::{Cell, Hyperlink},
|
||||
},
|
||||
};
|
||||
use gpui::Context;
|
||||
|
||||
use super::view::TerminalView;
|
||||
|
||||
/// The target of the OSC 8 link each prompt mark becomes; no program names a link so.
|
||||
pub(crate) const LINK: &str = "\u{7f}";
|
||||
|
||||
/// The most bytes a mark's start may span, ignored controls in it too.
|
||||
const MOST: usize = 64;
|
||||
|
||||
const ESC: u8 = 0x1b;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum State {
|
||||
#[default]
|
||||
Ground,
|
||||
/// An ESC at this index, which may start a mark.
|
||||
Escape(usize),
|
||||
/// Inside `ESC ]` from this index, with this much of `133;` matched.
|
||||
Body(usize, usize),
|
||||
/// Past a rewritten mark's letter, its options blanked to the end.
|
||||
Options,
|
||||
}
|
||||
|
||||
/// Turns each OSC 133 mark into an OSC 8 link as it passes to the parser, so the parser marks the cells each prompt prints.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Prompts {
|
||||
state: State,
|
||||
/// A mark's start, held from the parser until its letter shows.
|
||||
held: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Controls vte skips inside a sequence.
|
||||
fn ignored(byte: u8) -> bool {
|
||||
matches!(byte, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f)
|
||||
}
|
||||
|
||||
impl Prompts {
|
||||
/// The bytes held from the last read, which go before the next.
|
||||
pub fn held(&self) -> usize {
|
||||
self.held.len()
|
||||
}
|
||||
|
||||
/// Rewrites the marks in `buf[..held + read]`, the held bytes put first; returns how many bytes go on.
|
||||
pub fn pass(&mut self, buf: &mut [u8], read: usize) -> usize {
|
||||
let from = self.held.len();
|
||||
buf[..from].copy_from_slice(&self.held);
|
||||
self.held.clear();
|
||||
let end = from + read;
|
||||
for at in from..end {
|
||||
let byte = buf[at];
|
||||
self.state = match self.state {
|
||||
State::Ground if byte == ESC => State::Escape(at),
|
||||
State::Ground => State::Ground,
|
||||
State::Escape(start) => match byte {
|
||||
b']' => State::Body(start, 0),
|
||||
ESC => State::Escape(at),
|
||||
// vte runs these and stays in the escape.
|
||||
byte if byte == 0x07 || ignored(byte) => State::Escape(start),
|
||||
_ => State::Ground,
|
||||
},
|
||||
State::Body(start, seen) => match byte {
|
||||
ESC => State::Escape(at),
|
||||
0x07 | 0x18 | 0x1a => State::Ground,
|
||||
byte if ignored(byte) => State::Body(start, seen),
|
||||
byte if seen < 4 && byte == b"133;"[seen] => State::Body(start, seen + 1),
|
||||
byte if seen == 4 => match byte {
|
||||
b'A' | b'N' => link(&mut buf[start..=at], true),
|
||||
b'B' | b'I' | b'C' | b'D' => link(&mut buf[start..=at], false),
|
||||
_ => State::Ground,
|
||||
},
|
||||
_ => State::Ground,
|
||||
},
|
||||
State::Options => match byte {
|
||||
ESC => State::Escape(at),
|
||||
0x07 | 0x18 | 0x1a => State::Ground,
|
||||
byte if ignored(byte) => State::Options,
|
||||
_ => {
|
||||
buf[at] = 0;
|
||||
State::Options
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
match self.state {
|
||||
State::Escape(start) | State::Body(start, _) if end - start <= MOST => {
|
||||
self.held.extend_from_slice(&buf[start..end]);
|
||||
self.state = match self.state {
|
||||
State::Body(_, seen) => State::Body(0, seen),
|
||||
_ => State::Escape(0),
|
||||
};
|
||||
start
|
||||
}
|
||||
State::Escape(_) | State::Body(..) => {
|
||||
log::warn!("pty: a mark's start over {MOST} bytes passes as it came");
|
||||
self.state = State::Ground;
|
||||
end
|
||||
}
|
||||
State::Ground | State::Options => end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `ESC ] 133;X` an OSC 8 link of the terminal's own: opened at a prompt's start, else closed.
|
||||
fn link(span: &mut [u8], open: bool) -> State {
|
||||
let bytes = [b'8', b';', b';', if open { 0x7f } else { 0 }, 0];
|
||||
let body = span
|
||||
.iter()
|
||||
.position(|&byte| byte == b']')
|
||||
.expect("a mark holds its ]")
|
||||
+ 1;
|
||||
let mut next = bytes.into_iter();
|
||||
for byte in span[body..].iter_mut().filter(|byte| !ignored(**byte)) {
|
||||
*byte = next.next().expect("a mark's five bytes");
|
||||
}
|
||||
State::Options
|
||||
}
|
||||
|
||||
/// The prompt link a cell carries.
|
||||
fn prompt_link(cell: &Cell) -> Option<Hyperlink> {
|
||||
cell.hyperlink().filter(|link| link.uri() == LINK)
|
||||
}
|
||||
|
||||
/// The prompt `line` shows.
|
||||
fn prompt<T>(term: &Term<T>, line: Line) -> Option<Hyperlink> {
|
||||
term.grid()[line].into_iter().find_map(prompt_link)
|
||||
}
|
||||
|
||||
/// Whether `line` starts a prompt: it shows one the line above does not.
|
||||
fn starts<T>(term: &Term<T>, line: Line) -> bool {
|
||||
prompt(term, line).is_some_and(|link| {
|
||||
line == term.topmost_line() || prompt(term, line - 1).as_ref() != Some(&link)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 = Line(-(term.grid().display_offset() as i32));
|
||||
// ponytail: a scan of every row on the way; index prompt rows if long histories lag.
|
||||
let found = if up {
|
||||
let mut lines = (term.topmost_line().0..top.0).rev().map(Line);
|
||||
lines.find(|&line| starts(term, line))
|
||||
} else {
|
||||
let mut lines = (top.0 + 1..=term.bottommost_line().0).map(Line);
|
||||
lines.find(|&line| starts(term, line))
|
||||
}?;
|
||||
// A prompt on the screen means the bottom, which the view cannot pass.
|
||||
Some((-found.0).max(0) as usize)
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Scrolls to the prompt before or after the view's top; with none there, nothing moves.
|
||||
pub(super) fn scroll_to_prompt(&mut self, up: bool, cx: &mut Context<Self>) {
|
||||
let mut term = self.pty.term.lock();
|
||||
let Some(offset) = jump(&term, up) else {
|
||||
log::debug!(
|
||||
"terminal {}: no prompt {}",
|
||||
self.pty.pid,
|
||||
if up { "above" } else { "below" }
|
||||
);
|
||||
return;
|
||||
};
|
||||
let delta = offset as i32 - term.grid().display_offset() as i32;
|
||||
term.scroll_display(Scroll::Delta(delta));
|
||||
drop(term);
|
||||
log::debug!("terminal {}: to the prompt {offset} lines up", self.pty.pid);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
use alacritty_terminal::{
|
||||
event::VoidListener,
|
||||
index::Column,
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// What the parser gets from `chunks`, each one read.
|
||||
fn passed(chunks: &[&[u8]]) -> Vec<u8> {
|
||||
let mut prompts = Prompts::default();
|
||||
let mut out = Vec::new();
|
||||
for chunk in chunks {
|
||||
let held = prompts.held();
|
||||
let mut buf = vec![0; held + chunk.len()];
|
||||
buf[held..].copy_from_slice(chunk);
|
||||
let count = prompts.pass(&mut buf, chunk.len());
|
||||
out.extend_from_slice(&buf[..count]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_becomes_a_link_of_the_same_length() {
|
||||
assert_eq!(passed(&[b"\x1b]133;A\x07"]), b"\x1b]8;;\x7f\0\x07");
|
||||
assert_eq!(passed(&[b"\x1b]133;N\x07"]), b"\x1b]8;;\x7f\0\x07");
|
||||
assert_eq!(passed(&[b"\x1b]133;B\x1b\\"]), b"\x1b]8;;\0\0\x1b\\");
|
||||
assert_eq!(passed(&[b"\x1b]133;D;1\x07"]), b"\x1b]8;;\0\0\0\0\x07");
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A;k=i\x07x"]),
|
||||
b"\x1b]8;;\x7f\0\0\0\0\0\x07x"
|
||||
);
|
||||
assert_eq!(passed(&[b"\x1b]1\x0033;C\x07"]), b"\x1b]8\x00;;\0\0\x07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_split_across_reads_waits_for_its_letter() {
|
||||
let whole: &[u8] = b"a\x1b]133;A;k=i\x07$ \x1b]133;B\x1b\\b";
|
||||
let once = passed(&[whole]);
|
||||
for cut in 0..=whole.len() {
|
||||
let (first, second) = whole.split_at(cut);
|
||||
assert_eq!(passed(&[first, second]), once, "cut at {cut}");
|
||||
}
|
||||
let bytes: Vec<&[u8]> = whole.chunks(1).collect();
|
||||
assert_eq!(passed(&bytes), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_pass_as_they_came() {
|
||||
for bytes in [
|
||||
&b"\x1b]1337;A\x07"[..],
|
||||
b"\x1b]13;A\x07",
|
||||
b"\x1b]133;P;k=i\x07",
|
||||
b"\x1b]133;L\x07",
|
||||
b"\x1b]133;\x07",
|
||||
b"\x1b]133\x18;A\x07",
|
||||
b"\x1b]0;133;A\x07",
|
||||
b"\x1b[31m\x1bc",
|
||||
b"text",
|
||||
] {
|
||||
assert_eq!(
|
||||
passed(&[bytes]),
|
||||
bytes,
|
||||
"{}",
|
||||
String::from_utf8_lossy(bytes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_s_start_waits_no_longer_than_its_cap() {
|
||||
let mut long = b"\x1b]".to_vec();
|
||||
long.extend([0u8; MOST]);
|
||||
assert_eq!(passed(&[&long]), long);
|
||||
}
|
||||
|
||||
/// A terminal `columns` wide and two lines high after `bytes` passed through the rewrite.
|
||||
fn parsed(bytes: &[u8], columns: usize) -> Term<VoidListener> {
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(columns, 2), VoidListener);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, &passed(&[bytes]));
|
||||
term
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_s_cells_carry_its_link_and_its_input_none() {
|
||||
let term = parsed(b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls", 8);
|
||||
let cell = |line, column| prompt_link(&term.grid()[Line(line)][Column(column)]);
|
||||
assert!(cell(0, 0).is_none());
|
||||
assert!(
|
||||
cell(1, 0).is_some(),
|
||||
"a prompt keeps its link through SGR 0"
|
||||
);
|
||||
assert_eq!(cell(1, 0), cell(1, 1));
|
||||
assert!(cell(1, 2).is_none(), "input");
|
||||
assert!(starts(&term, Line(1)));
|
||||
assert!(!starts(&term, Line(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_over_two_rows_starts_at_its_first() {
|
||||
let term = parsed(b"\x1b]133;A\x07~/dir\r\n> \x1b]133;B\x07", 8);
|
||||
assert!(starts(&term, Line(0)));
|
||||
assert!(!starts(&term, Line(1)), "the same prompt goes on");
|
||||
let next = parsed(b"\x1b]133;A\x07$ \x1b]133;B\x07\r\n\x1b]133;A\x07$ ", 8);
|
||||
assert!(
|
||||
starts(&next, Line(1)),
|
||||
"an empty line's next prompt is its own"
|
||||
);
|
||||
}
|
||||
|
||||
/// Prompts on lines 0, 3 and 6 of a history four lines high, the last on the screen.
|
||||
fn history() -> Term<VoidListener> {
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
bytes.extend_from_slice(
|
||||
b"\x1b]133;A\x07$ \x1b]133;B\x07c\r\n\x1b]133;C\x07o\r\no\r\n\x1b]133;D;0\x07",
|
||||
);
|
||||
}
|
||||
bytes.extend_from_slice(b"\x1b]133;A\x07$ \x1b]133;B\x07");
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(8, 4), VoidListener);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, &passed(&[&bytes]));
|
||||
term
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_go_to_the_prompts_beside_the_view_s_top() {
|
||||
let mut term = history();
|
||||
assert_eq!(term.grid().history_size(), 6);
|
||||
// From the bottom: the prompt at the view's top (line 0) is passed, the one before it found.
|
||||
assert_eq!(jump(&term, true), Some(3));
|
||||
term.scroll_display(Scroll::Delta(3));
|
||||
assert_eq!(jump(&term, true), Some(6));
|
||||
term.scroll_display(Scroll::Delta(3));
|
||||
assert_eq!(jump(&term, true), None, "no prompt above the first");
|
||||
assert_eq!(jump(&term, false), Some(3));
|
||||
term.scroll_display(Scroll::Delta(-3));
|
||||
assert_eq!(
|
||||
jump(&term, false),
|
||||
Some(0),
|
||||
"a prompt on the screen means the bottom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_keeps_its_start_through_a_resize() {
|
||||
let bytes = b"\x1b]133;A\x07~/folder $ \x1b]133;B\x07ls\r\nout\r\n\x1b]133;A\x07$ ";
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(12, 4), VoidListener);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, &passed(&[bytes]));
|
||||
term.resize(TermSize::new(6, 4));
|
||||
let lines = term.topmost_line().0..=term.bottommost_line().0;
|
||||
let starts: Vec<i32> = lines.filter(|&line| starts(&term, Line(line))).collect();
|
||||
// The first prompt wraps over two rows and starts at its first; the last starts two below.
|
||||
let first = starts.first().copied().expect("a prompt");
|
||||
assert_eq!(starts.len(), 2, "{starts:?}");
|
||||
assert!(!starts.contains(&(first + 1)), "{starts:?}");
|
||||
}
|
||||
@@ -259,6 +259,8 @@ mod tests {
|
||||
folders: Vec<PathBuf>,
|
||||
marks: Vec<Mark>,
|
||||
text: String,
|
||||
/// The first line's cells that carry a prompt's link.
|
||||
prompt: Vec<usize>,
|
||||
success: bool,
|
||||
pid: u32,
|
||||
columns: usize,
|
||||
@@ -290,10 +292,16 @@ mod tests {
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||
let prompt = (0..24).filter(|&column| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
||||
});
|
||||
Ran {
|
||||
folders,
|
||||
marks,
|
||||
text: text.trim_end().to_string(),
|
||||
prompt: prompt.collect(),
|
||||
success,
|
||||
pid: pty.pid,
|
||||
columns: term.columns(),
|
||||
@@ -327,6 +335,7 @@ mod tests {
|
||||
let marks = [Mark::Prompt, 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-17
@@ -7,7 +7,7 @@ use alacritty_terminal::{
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use polling::{Event, PollMode, Poller};
|
||||
|
||||
use super::{marks, mouse::MouseMode, pty::PtyEvent};
|
||||
use super::{marks, mouse::MouseMode, prompts::Prompts, pty::PtyEvent};
|
||||
|
||||
/// The longest OSC body kept; longer ones are dropped whole.
|
||||
const MOST: usize = 4096;
|
||||
@@ -17,6 +17,7 @@ pub(crate) struct Tapped<P> {
|
||||
pub inner: P,
|
||||
scanner: Osc,
|
||||
mouse: MouseModes,
|
||||
prompts: Prompts,
|
||||
events: UnboundedSender<PtyEvent>,
|
||||
pid: u32,
|
||||
last: Option<PathBuf>,
|
||||
@@ -28,6 +29,7 @@ impl<P> Tapped<P> {
|
||||
inner,
|
||||
scanner: Osc::default(),
|
||||
mouse: MouseModes::default(),
|
||||
prompts: Prompts::default(),
|
||||
events,
|
||||
pid,
|
||||
last: None,
|
||||
@@ -37,9 +39,15 @@ impl<P> Tapped<P> {
|
||||
|
||||
impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let count = self.inner.reader().read(buf)?;
|
||||
let held = self.prompts.held();
|
||||
// A buffer with no room past the held bytes waits for the parser to empty it.
|
||||
if buf.len() <= held {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = self.inner.reader().read(&mut buf[held..])?;
|
||||
let read = &buf[held..held + count];
|
||||
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
|
||||
self.scanner.feed(&buf[..count], &mut |body| {
|
||||
self.scanner.feed(read, &mut |body| {
|
||||
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
|
||||
log::debug!("pty {pid}: {mark:?}");
|
||||
if events.unbounded_send(PtyEvent::Mark(mark)).is_err() {
|
||||
@@ -60,13 +68,13 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
log::debug!("pty {pid}: a folder report after its session closed");
|
||||
}
|
||||
});
|
||||
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
||||
if let Some(mode) = self.mouse.feed(read) {
|
||||
log::info!("pty {pid}: mouse {mode:?}");
|
||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
Ok(self.prompts.pass(buf, count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +126,9 @@ enum State {
|
||||
Ground,
|
||||
Escape,
|
||||
Osc,
|
||||
OscEscape,
|
||||
}
|
||||
|
||||
/// Finds each OSC body, `ESC ] body` ended by BEL or `ESC \`, across reads.
|
||||
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Osc {
|
||||
state: State,
|
||||
@@ -151,21 +158,19 @@ impl Osc {
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
(State::Escape, 0x1b) => State::Escape,
|
||||
// vte runs controls in an escape and stays there.
|
||||
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f) => State::Escape,
|
||||
(State::Escape, _) => State::Ground,
|
||||
(State::Osc, 0x07) | (State::OscEscape, b'\\') => {
|
||||
(State::Osc, 0x07 | 0x18 | 0x1a) => {
|
||||
self.finish(found);
|
||||
State::Ground
|
||||
}
|
||||
(State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground,
|
||||
(State::Osc, 0x1b) => State::OscEscape,
|
||||
(State::OscEscape, b']') => {
|
||||
self.body.clear();
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
// ESC ends the body, then reads as an escape: `\` makes ST.
|
||||
(State::Osc, 0x1b) => {
|
||||
self.finish(found);
|
||||
State::Escape
|
||||
}
|
||||
(State::OscEscape, 0x1b) => State::Escape,
|
||||
(State::OscEscape, _) => State::Ground,
|
||||
(State::Osc, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f) => State::Osc,
|
||||
(State::Osc, byte) => {
|
||||
if self.body.len() < MOST {
|
||||
self.body.push(byte);
|
||||
|
||||
@@ -12,7 +12,7 @@ fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_each_osc_body_ended_by_bel_or_st_across_reads() {
|
||||
fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]),
|
||||
@@ -22,13 +22,15 @@ fn finds_each_osc_body_ended_by_bel_or_st_across_reads() {
|
||||
scan(&[b"\x1b", b"]133;A\x07\x1b]0;t\x1b\\"]),
|
||||
["133;A", "0;t"]
|
||||
);
|
||||
assert_eq!(scan(&[b"\x1b]7;x\x18\x1b]7;y\x1a"]), ["7;x", "7;y"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;cut\x1b]133;D;1\x07"]), ["7;cut", "133;D;1"]);
|
||||
assert_eq!(scan(&[b"\x1b]0;t\x1b[31m"]), ["0;t"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_and_cancels_pass() {
|
||||
fn controls_vte_skips_stay_out_of_a_body() {
|
||||
assert_eq!(scan(&[b"\x1b\x00]1\x0033;\x1fA\x07"]), ["133;A"]);
|
||||
assert!(scan(&[b"\x1b[31m\x1bc"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]133;D;1\x07"]), ["133;D;1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-1
@@ -21,7 +21,7 @@ use super::{
|
||||
find::Finder,
|
||||
frame::frame,
|
||||
ink::Ink,
|
||||
input::{self, Find},
|
||||
input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
marks::Mark,
|
||||
mouse::MouseMode,
|
||||
paint::{self, Look},
|
||||
@@ -370,6 +370,10 @@ impl Render for TerminalView {
|
||||
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
|
||||
.on_action(cx.listener(|view, _: &SelectAll, _, cx| view.select_all(cx)))
|
||||
.on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx)))
|
||||
.on_action(
|
||||
cx.listener(|view, _: &PreviousPrompt, _, cx| view.scroll_to_prompt(true, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|view, _: &NextPrompt, _, cx| view.scroll_to_prompt(false, 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))
|
||||
|
||||
Reference in new issue
Block a user