T10: following the current match takes one pass: a forward walk maps matches to cells, line ends are found once; the same occurrence ranks by an identical line, then one grown or cut at its end, then by line and column; a cleared or trimmed history moves no line; the title replays on CursorBlinkingChange, which a reset sends in the parser's order, synchronized updates too, and the tap's reset count is gone
This commit is contained in:
8 files changed
+185
-73
No files matched your search
+4
-2
@@ -40,8 +40,10 @@ impl Workspace {
|
||||
) {
|
||||
if let TerminalEvent::Title(title) = event {
|
||||
let title = if title.is_empty() { TITLE } else { title };
|
||||
log::debug!("app: the window title is {title:?}");
|
||||
window.set_window_title(title);
|
||||
if window.window_title() != title {
|
||||
log::debug!("app: the window title is {title:?}");
|
||||
window.set_window_title(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-40
@@ -38,12 +38,13 @@ impl Finder {
|
||||
pub fn lit<T>(&self, term: &Term<T>, color: Hsla) -> Vec<(Match, Hsla)> {
|
||||
let offset = term.grid().display_offset() as i32;
|
||||
let (top, bottom) = (-offset, term.screen_lines() as i32 - 1 - offset);
|
||||
self.found
|
||||
.hits
|
||||
let hits = &self.found.hits;
|
||||
let first = hits.partition_point(|hit| hit.end().line.0 < top);
|
||||
hits[first..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, hit)| hit.end().line.0 >= top && hit.start().line.0 <= bottom)
|
||||
.map(|(at, hit)| {
|
||||
.take_while(|hit| hit.start().line.0 <= bottom)
|
||||
.zip(first..)
|
||||
.map(|(hit, at)| {
|
||||
let wash = if Some(at) == self.current { 0.55 } else { 0.25 };
|
||||
(hit.clone(), color.opacity(wash))
|
||||
})
|
||||
@@ -51,14 +52,17 @@ impl Finder {
|
||||
}
|
||||
}
|
||||
|
||||
/// One search of the grid: its shown text, and each match as cells and as bytes of that text.
|
||||
/// One search of the grid: its shown text and line ends, and each match as cells and as bytes.
|
||||
#[derive(Default)]
|
||||
struct Found {
|
||||
text: String,
|
||||
/// Where each newline sits in the text.
|
||||
breaks: Vec<usize>,
|
||||
hits: Vec<Match>,
|
||||
bytes: Vec<Range<usize>>,
|
||||
/// The grid's history then, to follow lines that scrolled since.
|
||||
/// The grid's history and size then, to follow lines that scrolled since.
|
||||
history: usize,
|
||||
size: (usize, usize),
|
||||
}
|
||||
|
||||
const SPACERS: Flags = Flags::WIDE_CHAR_SPACER.union(Flags::LEADING_WIDE_CHAR_SPACER);
|
||||
@@ -100,72 +104,124 @@ fn grid_text<T>(term: &Term<T>) -> (String, Vec<(usize, Line)>) {
|
||||
(text, rows)
|
||||
}
|
||||
|
||||
/// The cell showing byte `offset` of the grid's text; a line's end maps to the cell after its text.
|
||||
fn cell_at<T>(term: &Term<T>, rows: &[(usize, Line)], offset: usize) -> Point {
|
||||
let (mut end, line) = rows[rows.partition_point(|(start, _)| *start <= offset) - 1];
|
||||
let row = &term.grid()[line];
|
||||
for column in 0..term.columns() {
|
||||
let cell = &row[Column(column)];
|
||||
if !cell.flags.intersects(SPACERS) {
|
||||
end += shown(cell).map(char::len_utf8).sum::<usize>();
|
||||
/// Walks the grid's cells in text order, so rising byte offsets map to cells in one pass.
|
||||
struct Walk<'a, T> {
|
||||
term: &'a Term<T>,
|
||||
rows: &'a [(usize, Line)],
|
||||
row: usize,
|
||||
column: usize,
|
||||
/// The text's byte where `column` starts.
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<T> Walk<'_, T> {
|
||||
/// The cell showing byte `offset`, at or past the last asked; a line's end maps past its text.
|
||||
fn cell(&mut self, offset: usize) -> Point {
|
||||
while self
|
||||
.rows
|
||||
.get(self.row + 1)
|
||||
.is_some_and(|(start, _)| *start <= offset)
|
||||
{
|
||||
self.row += 1;
|
||||
self.column = 0;
|
||||
self.at = self.rows[self.row].0;
|
||||
}
|
||||
if offset < end {
|
||||
return Point::new(line, Column(column));
|
||||
let line = self.rows[self.row].1;
|
||||
let row = &self.term.grid()[line];
|
||||
while self.column < self.term.columns() {
|
||||
let cell = &row[Column(self.column)];
|
||||
let size = match cell.flags.intersects(SPACERS) {
|
||||
true => 0,
|
||||
false => shown(cell).map(char::len_utf8).sum(),
|
||||
};
|
||||
if offset < self.at + size {
|
||||
return Point::new(line, Column(self.column));
|
||||
}
|
||||
self.at += size;
|
||||
self.column += 1;
|
||||
}
|
||||
Point::new(line, self.term.last_column())
|
||||
}
|
||||
Point::new(line, term.last_column())
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// Every match of `query`, oldest first; a pattern that does not compile is an error.
|
||||
fn search<T>(term: &Term<T>, query: &str, options: FindOptions) -> Result<Self, String> {
|
||||
let history = term.grid().history_size();
|
||||
let size = (term.columns(), term.screen_lines());
|
||||
if query.is_empty() {
|
||||
return Ok(Self {
|
||||
history,
|
||||
size,
|
||||
..Self::default()
|
||||
});
|
||||
}
|
||||
let (text, rows) = grid_text(term);
|
||||
let bytes = find_all(&text, query, options)?;
|
||||
let mut walk = Walk {
|
||||
term,
|
||||
rows: &rows,
|
||||
row: 0,
|
||||
column: 0,
|
||||
at: 0,
|
||||
};
|
||||
let hits = bytes
|
||||
.iter()
|
||||
.map(|range| cell_at(term, &rows, range.start)..=cell_at(term, &rows, range.end - 1))
|
||||
.map(|range| {
|
||||
let start = walk.cell(range.start);
|
||||
start..=walk.cell(range.end - 1)
|
||||
})
|
||||
.collect();
|
||||
let breaks = text.match_indices('\n').map(|(at, _)| at).collect();
|
||||
Ok(Self {
|
||||
text,
|
||||
breaks,
|
||||
hits,
|
||||
bytes,
|
||||
history,
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes of the line holding byte `offset`.
|
||||
fn line(&self, offset: usize) -> Range<usize> {
|
||||
let start = self.text[..offset].rfind('\n').map_or(0, |at| at + 1);
|
||||
let end = self.text[offset..]
|
||||
.find('\n')
|
||||
.map_or(self.text.len(), |at| offset + at);
|
||||
start..end
|
||||
let ends = self.breaks.partition_point(|&at| at < offset);
|
||||
let start = ends
|
||||
.checked_sub(1)
|
||||
.map_or(0, |before| self.breaks[before] + 1);
|
||||
start..self.breaks.get(ends).copied().unwrap_or(self.text.len())
|
||||
}
|
||||
|
||||
/// The match that was `then`'s `at`: same line and place, nearest its scrolled spot; else the nearest.
|
||||
/// The match that was `then`'s `at`: at its place in its line, kept, grown or cut; else the nearest.
|
||||
fn follow(&self, then: &Found, at: usize) -> Option<usize> {
|
||||
let was = then.bytes[at].start;
|
||||
let line = then.line(was);
|
||||
let (text, offset) = (&then.text[line.clone()], was - line.start);
|
||||
let scrolled = self.history as i32 - then.history as i32;
|
||||
let expected = then.hits[at].start().line.0 - scrolled;
|
||||
let distance = |index: &usize| (self.hits[*index].start().line.0 - expected).abs();
|
||||
let all = 0..self.hits.len();
|
||||
all.clone()
|
||||
.filter(|&index| {
|
||||
let start = self.bytes[index].start;
|
||||
let line = self.line(start);
|
||||
start - line.start == offset && self.text[line] == *text
|
||||
})
|
||||
.min_by_key(distance)
|
||||
.or_else(|| all.min_by_key(distance))
|
||||
let (whole, offset) = (&then.text[line.clone()], was - line.start);
|
||||
let place = *then.hits[at].start();
|
||||
let grew = self.history as i32 - then.history as i32;
|
||||
// A cleared or trimmed history moves no line; a resize pulls lines back down.
|
||||
let scrolled = if self.size == then.size {
|
||||
grew.max(0)
|
||||
} else {
|
||||
grew
|
||||
};
|
||||
let mut best: Option<((u8, i32, usize), usize)> = None;
|
||||
for (index, bytes) in self.bytes.iter().enumerate() {
|
||||
let line = self.line(bytes.start);
|
||||
let now = &self.text[line.clone()];
|
||||
let rank = match bytes.start - line.start == offset {
|
||||
true if now == whole => 0,
|
||||
true if now.starts_with(whole) || whole.starts_with(now) => 1,
|
||||
_ => 2,
|
||||
};
|
||||
let start = self.hits[index].start();
|
||||
let away = (start.line.0 + scrolled - place.line.0).abs();
|
||||
let key = (rank, away, start.column.0.abs_diff(place.column.0));
|
||||
if best.is_none_or(|(least, _)| key < least) {
|
||||
best = Some((key, index));
|
||||
}
|
||||
}
|
||||
best.map(|(_, index)| index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +306,9 @@ impl TerminalView {
|
||||
);
|
||||
self.reveal(cx);
|
||||
} else {
|
||||
let count = finder.found.hits.len();
|
||||
let (count, current) = (finder.found.hits.len(), finder.current);
|
||||
log::trace!(
|
||||
"terminal {}: {count} matches as the grid changed",
|
||||
"terminal {}: {count} matches as the grid changed, current {current:?}",
|
||||
self.pty.pid
|
||||
);
|
||||
cx.notify();
|
||||
|
||||
@@ -131,6 +131,16 @@ fn the_current_match_follows_its_line() {
|
||||
"of like lines, the one that scrolled"
|
||||
);
|
||||
|
||||
let mut cleared = term("n\r\nn\r\nn\r\nn\r\nn", 10, 3, 10);
|
||||
let then = found(&cleared, "n", PLAIN);
|
||||
feed(&mut cleared, "\x1b[3J");
|
||||
let now = found(&cleared, "n", PLAIN);
|
||||
assert_eq!(
|
||||
now.follow(&then, 3),
|
||||
Some(1),
|
||||
"a cleared history moves no line"
|
||||
);
|
||||
|
||||
let mut gone = term("needle A\r\nneedle B\r\n\r\nneedle C", 20, 5, 10);
|
||||
let then = found(&gone, "needle", PLAIN);
|
||||
feed(&mut gone, "\x1b[2;1H\x1b[2K");
|
||||
@@ -146,6 +156,64 @@ fn the_current_match_follows_its_line() {
|
||||
assert_ne!(now.hits[0].start(), then.hits[0].start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_grows_or_changes_keeps_the_match_at_its_place() {
|
||||
let mut grown = term("needle A needle B", 30, 3, 10);
|
||||
let then = found(&grown, "needle", PLAIN);
|
||||
feed(&mut grown, "!");
|
||||
let now = found(&grown, "needle", PLAIN);
|
||||
assert_eq!(
|
||||
now.follow(&then, 1),
|
||||
Some(1),
|
||||
"the same text runs through it"
|
||||
);
|
||||
|
||||
let mut ring = term(
|
||||
"needle 1\r\nneedle 2\r\nneedle 3\r\nneedle 4\r\nneedle 5",
|
||||
10,
|
||||
3,
|
||||
2,
|
||||
);
|
||||
let then = found(&ring, "needle", PLAIN);
|
||||
feed(&mut ring, "!\r\nneedle 6");
|
||||
let now = found(&ring, "needle", PLAIN);
|
||||
assert_eq!(now.follow(&then, 4), Some(3), "needle 5!, a line up");
|
||||
|
||||
let mut both = term(
|
||||
"needle 1\r\nneedle 2\r\nneedle 5\r\nneedle 6\r\nneedle 60",
|
||||
10,
|
||||
3,
|
||||
2,
|
||||
);
|
||||
let then = found(&both, "needle", PLAIN);
|
||||
feed(&mut both, "\r\nx");
|
||||
let now = found(&both, "needle", PLAIN);
|
||||
assert_eq!(
|
||||
now.follow(&then, 3),
|
||||
Some(2),
|
||||
"needle 6 itself before needle 60 in its place"
|
||||
);
|
||||
|
||||
let mut changed = term("needle A needle B", 30, 3, 10);
|
||||
let then = found(&changed, "needle", PLAIN);
|
||||
feed(&mut changed, "\rneedle X needle Y");
|
||||
let now = found(&changed, "needle", PLAIN);
|
||||
assert_eq!(now.follow(&then, 1), Some(1), "else the nearest column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_a_long_line_takes_one_pass() {
|
||||
let mut long = term(&"a".repeat(1_000_000), 147, 48, 10_000);
|
||||
let then = found(&long, "a", PLAIN);
|
||||
feed(&mut long, "!");
|
||||
let now = found(&long, "a", PLAIN);
|
||||
let last = then.hits.len() - 1;
|
||||
let clock = std::time::Instant::now();
|
||||
assert_eq!(now.follow(&then, last), Some(last));
|
||||
let took = clock.elapsed();
|
||||
assert!(took.as_secs() < 5, "a quadratic follow took {took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_go_round_the_ends() {
|
||||
assert_eq!(stepped(Some(0), 3, false), Some(2));
|
||||
|
||||
+1
-3
@@ -20,8 +20,6 @@ pub enum PtyEvent {
|
||||
Term(Event),
|
||||
Folder(PathBuf),
|
||||
Mouse(MouseMode),
|
||||
/// A full reset, `ESC c`, which empties the title without a title event.
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||
@@ -251,7 +249,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::Mouse(_) | PtyEvent::Reset => {}
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, success)
|
||||
|
||||
+2
-10
@@ -53,12 +53,7 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
log::debug!("pty {pid}: a folder report after its session closed");
|
||||
}
|
||||
});
|
||||
let resets = self.mouse.resets;
|
||||
let changed = self.mouse.feed(&buf[..count]);
|
||||
if self.mouse.resets != resets && events.unbounded_send(PtyEvent::Reset).is_err() {
|
||||
log::debug!("pty {pid}: a reset after its session closed");
|
||||
}
|
||||
if let Some(mode) = changed {
|
||||
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
||||
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");
|
||||
@@ -199,7 +194,7 @@ enum Csi {
|
||||
Private,
|
||||
}
|
||||
|
||||
/// The mouse modes as xterm holds them, and resets, read as vte reads; alacritty drops 9.
|
||||
/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MouseModes {
|
||||
state: Csi,
|
||||
@@ -209,8 +204,6 @@ pub(crate) struct MouseModes {
|
||||
/// Past a `:`, whose subparameters the parser's mode sets skip.
|
||||
sub: bool,
|
||||
mode: MouseMode,
|
||||
/// Full resets, `ESC c`, seen so far.
|
||||
pub resets: usize,
|
||||
}
|
||||
|
||||
impl MouseModes {
|
||||
@@ -237,7 +230,6 @@ impl MouseModes {
|
||||
(Csi::Escape, b'[') => Csi::Bracket,
|
||||
(Csi::Escape, b'c') => {
|
||||
self.mode = MouseMode::default();
|
||||
self.resets += 1;
|
||||
Csi::Ground
|
||||
}
|
||||
(Csi::Bracket, b'?') => {
|
||||
|
||||
@@ -50,7 +50,6 @@ fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
modes.feed(b"\x1b[?1002h");
|
||||
let reset = modes.feed(b"\x1bc");
|
||||
assert_eq!(reset, Some(MouseMode::default()), "a reset ends it");
|
||||
assert_eq!(modes.resets, 1);
|
||||
modes.feed(b"\x1b[?09;\x1b[?1003h");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Any);
|
||||
}
|
||||
@@ -120,10 +119,10 @@ fn other_hosts_and_bad_uris_give_none() {
|
||||
assert_eq!(folder(b"file:///%FF"), None);
|
||||
}
|
||||
|
||||
/// vte's own reading of `bytes`, under the same mode rules: the mode, and the resets.
|
||||
fn parsed(bytes: &[u8]) -> (MouseMode, usize) {
|
||||
/// vte's own reading of `bytes`, under the same mode rules.
|
||||
fn parsed(bytes: &[u8]) -> MouseMode {
|
||||
use alacritty_terminal::vte::{Params, Parser, Perform};
|
||||
struct Modes(MouseMode, usize);
|
||||
struct Modes(MouseMode);
|
||||
impl Perform for Modes {
|
||||
fn csi_dispatch(&mut self, params: &Params, between: &[u8], ignore: bool, action: char) {
|
||||
if !ignore && between == b"?" && matches!(action, 'h' | 'l') {
|
||||
@@ -136,13 +135,12 @@ fn parsed(bytes: &[u8]) -> (MouseMode, usize) {
|
||||
fn esc_dispatch(&mut self, between: &[u8], _: bool, byte: u8) {
|
||||
if between.is_empty() && byte == b'c' {
|
||||
self.0 = MouseMode::default();
|
||||
self.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut modes = Modes(MouseMode::default(), 0);
|
||||
let mut modes = Modes(MouseMode::default());
|
||||
Parser::new().advance(&mut modes, bytes);
|
||||
(modes.0, modes.1)
|
||||
modes.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -176,8 +174,7 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
|
||||
let mut modes = MouseModes::default();
|
||||
for end in 1..=case.len() {
|
||||
modes.feed(&case[end - 1..end]);
|
||||
let seen = (modes.mode, modes.resets);
|
||||
assert_eq!(seen, parsed(&case[..end]), "{case:?} to byte {end}");
|
||||
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -204,12 +204,6 @@ impl TerminalView {
|
||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||
PtyEvent::Reset => {
|
||||
log::debug!("terminal {}: a reset; the title follows", self.pty.pid);
|
||||
// Setting the options sends the grid's title, empty after a reset.
|
||||
let settings = config(SettingsStore::get(cx));
|
||||
self.pty.term.lock().set_options(settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
||||
@@ -222,7 +216,12 @@ impl TerminalView {
|
||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
Event::Wakeup => self.shown = true,
|
||||
Event::MouseCursorDirty | Event::CursorBlinkingChange => {}
|
||||
Event::MouseCursorDirty => {}
|
||||
// A reset empties the title and sends only this; setting the options sends the title.
|
||||
Event::CursorBlinkingChange => {
|
||||
let settings = config(SettingsStore::get(cx));
|
||||
self.pty.term.lock().set_options(settings);
|
||||
}
|
||||
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
|
||||
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
|
||||
Event::Bell => {
|
||||
|
||||
Reference in new issue
Block a user