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
CI / platforms (windows-latest) (push) Failing after 9s
CI / macos (push) Failing after 8m27s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 17:30:21 -04:00
1 parent 1c21dfd197
commit 11b2ce6f34
8 files changed
+185 -73

No files matched your search

+4 -2
View File
@@ -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
View File
@@ -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();
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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'?') => {
+6 -9
View File
@@ -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
View File
@@ -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 => {