T10: find reads the grid as it shows, through Ely's find_all: combining marks, hidden cells blank, wrapped rows joined, Unicode words; new output, a resize and a settings change search again, and the current match follows its line text; a full reset replays the title through set_options
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 16:57:09 -04:00
1 parent 4d8788ff22
commit 1c21dfd197
10 files changed
+335 -103

No files matched your search

+144 -86
View File
@@ -1,13 +1,16 @@
use std::ops::Range;
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Direction, Point},
index::{Column, Line, Point},
term::{
Term,
search::{Match, RegexIter, RegexSearch},
cell::{Cell, Flags},
search::Match,
},
};
use ely_gpui_component::{
editor::{FindOptions, FindWidget},
editor::{FindOptions, FindWidget, find_all},
forms::{InputEvent, TextInput},
};
use gpui::{
@@ -24,7 +27,7 @@ use super::{
pub struct Finder {
query: Entity<TextInput>,
options: FindOptions,
hits: Vec<Match>,
found: Found,
current: Option<usize>,
error: Option<SharedString>,
_input: Subscription,
@@ -35,7 +38,8 @@ 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.hits
self.found
.hits
.iter()
.enumerate()
.filter(|(_, hit)| hit.end().line.0 >= top && hit.start().line.0 <= bottom)
@@ -47,28 +51,122 @@ impl Finder {
}
}
/// `query` under `options` as the grid's search reads it; word edges are ASCII, which its lazy DFA needs.
fn pattern(query: &str, options: FindOptions) -> String {
let body = if options.regex {
query.to_string()
} else {
regex::escape(query)
};
let body = if options.word {
format!(r"(?-u:\b)(?:{body})(?-u:\b)")
} else {
body
};
let case = if options.case { "(?-i)" } else { "(?i)" };
format!("{case}{body}")
/// One search of the grid: its shown text, and each match as cells and as bytes of that text.
#[derive(Default)]
struct Found {
text: String,
hits: Vec<Match>,
bytes: Vec<Range<usize>>,
/// The grid's history then, to follow lines that scrolled since.
history: usize,
}
/// Every match of `pattern` in the scrollback and the screen, oldest first.
fn search_all<T>(term: &Term<T>, pattern: &str) -> Result<Vec<Match>, String> {
let mut regex = RegexSearch::new(pattern).map_err(|error| error.to_string())?;
let start = Point::new(term.topmost_line(), Column(0));
let end = Point::new(term.bottommost_line(), term.last_column());
Ok(RegexIter::new(start, end, Direction::Right, term, &mut regex).collect())
const SPACERS: Flags = Flags::WIDE_CHAR_SPACER.union(Flags::LEADING_WIDE_CHAR_SPACER);
/// What a cell shows: its character and marks; a hidden cell shows a space.
fn shown(cell: &Cell) -> impl Iterator<Item = char> + '_ {
let hidden = cell.flags.contains(Flags::HIDDEN);
let marks = cell.zerowidth().filter(|_| !hidden);
std::iter::once(if hidden { ' ' } else { cell.c }).chain(marks.into_iter().flatten().copied())
}
/// The grid's shown text, oldest row first, lines ended by newlines; and each row's first byte.
fn grid_text<T>(term: &Term<T>) -> (String, Vec<(usize, Line)>) {
let (mut text, mut rows) = (String::new(), Vec::new());
for line in term.topmost_line().0..=term.bottommost_line().0 {
let row = &term.grid()[Line(line)];
let cells = (0..term.columns()).map(|column| &row[Column(column)]);
let wraps = row[term.last_column()].flags.contains(Flags::WRAPLINE);
let end = if wraps {
term.columns()
} else {
let blank = |cell: &Cell| shown(cell).eq([' ']);
cells
.clone()
.rposition(|cell| !blank(cell))
.map_or(0, |at| at + 1)
};
rows.push((text.len(), Line(line)));
for cell in cells
.take(end)
.filter(|cell| !cell.flags.intersects(SPACERS))
{
text.extend(shown(cell));
}
if !wraps {
text.push('\n');
}
}
(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>();
}
if offset < end {
return Point::new(line, Column(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();
if query.is_empty() {
return Ok(Self {
history,
..Self::default()
});
}
let (text, rows) = grid_text(term);
let bytes = find_all(&text, query, options)?;
let hits = bytes
.iter()
.map(|range| cell_at(term, &rows, range.start)..=cell_at(term, &rows, range.end - 1))
.collect();
Ok(Self {
text,
hits,
bytes,
history,
})
}
/// 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
}
/// The match that was `then`'s `at`: same line and place, nearest its scrolled spot; 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))
}
}
/// The match one newer than `current`, or one older, round the ends.
@@ -97,7 +195,7 @@ impl TerminalView {
self.finder = Some(Finder {
query: query.clone(),
options: FindOptions::default(),
hits: Vec::new(),
found: Found::default(),
current: None,
error: None,
_input: input,
@@ -121,40 +219,42 @@ impl TerminalView {
}
}
/// Finds the query anew; a `fresh` query starts at the newest match, new output keeps the current one.
/// Finds the query anew: fresh, from the newest match; else following the current one.
pub(super) fn search(&mut self, fresh: bool, cx: &mut Context<Self>) {
let Some(finder) = self.finder.as_mut() else {
return;
};
let query = finder.query.read(cx).text().to_string();
let found = if query.is_empty() {
Ok(Vec::new())
} else {
search_all(&self.pty.term.lock(), &pattern(&query, finder.options))
};
let (hits, error) = match found {
Ok(hits) => (hits, None),
let found = Found::search(&self.pty.term.lock(), &query, finder.options);
let (found, error) = match found {
Ok(found) => (found, None),
Err(error) => {
log::debug!(
"terminal {}: {query:?} is no pattern: {error}",
self.pty.pid
);
(Vec::new(), Some(SharedString::from(error)))
(Found::default(), Some(SharedString::from(error)))
}
};
let newest = hits.len().checked_sub(1);
let kept = finder.current.filter(|at| *at < hits.len());
finder.current = if fresh { newest } else { kept.or(newest) };
finder.current = match finder.current {
Some(at) if !fresh => found.follow(&finder.found, at),
_ => found.hits.len().checked_sub(1),
};
finder.error = error;
finder.hits = hits;
finder.found = found;
if fresh {
log::debug!(
"terminal {}: {} matches of {query:?}",
self.pty.pid,
finder.hits.len()
finder.found.hits.len()
);
self.reveal(cx);
} else {
let count = finder.found.hits.len();
log::trace!(
"terminal {}: {count} matches as the grid changed",
self.pty.pid
);
cx.notify();
}
}
@@ -164,7 +264,7 @@ impl TerminalView {
let Some(finder) = self.finder.as_mut() else {
return;
};
finder.current = stepped(finder.current, finder.hits.len(), newer);
finder.current = stepped(finder.current, finder.found.hits.len(), newer);
self.reveal(cx);
cx.notify();
}
@@ -172,7 +272,7 @@ impl TerminalView {
fn reveal(&mut self, cx: &mut Context<Self>) {
let start = self.finder.as_ref().and_then(|finder| {
let at = finder.current?;
Some(*finder.hits[at].start())
Some(*finder.found.hits[at].start())
});
if let Some(start) = start {
log::trace!("terminal {}: match at {start:?}", self.pty.pid);
@@ -192,7 +292,7 @@ impl TerminalView {
pub(super) fn find_box(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
let finder = self.finder.as_ref()?;
let (flip, step, close, back) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
let total = finder.hits.len();
let total = finder.found.hits.len();
let widget = FindWidget::new(
("find", cx.entity_id()),
&finder.query,
@@ -219,46 +319,4 @@ impl TerminalView {
}
#[cfg(test)]
mod tests {
use alacritty_terminal::term::test::mock_term;
use super::*;
#[test]
fn find_takes_case_words_and_patterns() {
let term = mock_term("one two One\r\nthree phone one");
let count = |query: &str, options: FindOptions| {
search_all(&term, &pattern(query, options)).map(|all| all.len())
};
assert_eq!(count("one", FindOptions::default()), Ok(4));
let case = FindOptions {
case: true,
..FindOptions::default()
};
assert_eq!(count("One", case), Ok(1));
let word = FindOptions {
word: true,
..FindOptions::default()
};
assert_eq!(count("one", word), Ok(3), "phone holds no whole word");
let regex = FindOptions {
regex: true,
..FindOptions::default()
};
assert_eq!(count("t[wh]", regex), Ok(2));
assert_eq!(
count("(", FindOptions::default()),
Ok(0),
"plain text is escaped"
);
assert!(count("(", regex).is_err());
}
#[test]
fn steps_go_round_the_ends() {
assert_eq!(stepped(Some(0), 3, false), Some(2));
assert_eq!(stepped(Some(2), 3, true), Some(0));
assert_eq!(stepped(Some(1), 3, false), Some(0));
assert_eq!(stepped(None, 0, true), None);
}
}
mod tests;
+155
View File
@@ -0,0 +1,155 @@
use alacritty_terminal::{
event::VoidListener,
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use super::*;
const PLAIN: FindOptions = FindOptions {
case: false,
word: false,
regex: false,
};
fn term(bytes: &str, columns: usize, lines: usize, history: usize) -> Term<VoidListener> {
let config = Config {
scrolling_history: history,
..Config::default()
};
let mut term = Term::new(config, &TermSize::new(columns, lines), VoidListener);
feed(&mut term, bytes);
term
}
fn feed(term: &mut Term<VoidListener>, bytes: &str) {
let mut parser: Processor = Processor::new();
parser.advance(term, bytes.as_bytes());
}
fn found(term: &Term<VoidListener>, query: &str, options: FindOptions) -> Found {
Found::search(term, query, options).expect("a pattern")
}
fn at(line: i32, column: usize) -> Point {
Point::new(Line(line), Column(column))
}
#[test]
fn find_takes_case_words_and_patterns() {
let term = term("one two One\r\nthree phone one", 20, 3, 10);
let count =
|query: &str, options| Found::search(&term, query, options).map(|found| found.hits.len());
assert_eq!(count("one", PLAIN), Ok(4));
assert_eq!(
count(
"One",
FindOptions {
case: true,
..PLAIN
}
),
Ok(1)
);
let word = FindOptions {
word: true,
..PLAIN
};
assert_eq!(count("one", word), Ok(3), "phone holds no whole word");
let regex = FindOptions {
regex: true,
..PLAIN
};
assert_eq!(count("t[wh]", regex), Ok(2));
assert_eq!(count("one$", regex), Ok(2), "a line ends after its text");
assert_eq!(count("(", PLAIN), Ok(0), "plain text is escaped");
assert!(count("(", regex).is_err());
}
#[test]
fn marks_wide_cells_and_unicode_words_are_found_where_they_show() {
let shown = "cafe\u{301} caf\u{e9} \u{4e2d}\u{6587} \x1b[8msecret\x1b[0m";
let line = term(shown, 30, 2, 10);
let hits = |query: &str, options| found(&line, query, options).hits;
assert_eq!(
hits("cafe\u{301}", PLAIN),
[at(0, 0)..=at(0, 3)],
"a mark rides its cell"
);
let word = FindOptions {
word: true,
..PLAIN
};
assert_eq!(hits("caf\u{e9}", word), [at(0, 5)..=at(0, 8)]);
assert_eq!(hits("\u{4e2d}\u{6587}", word), [at(0, 10)..=at(0, 12)]);
assert!(hits("secret", PLAIN).is_empty(), "hidden text stays hidden");
let after = term("\u{4e2d}x", 10, 2, 10);
let x = found(&after, "x", PLAIN).hits;
assert_eq!(
x,
[at(0, 2)..=at(0, 2)],
"a wide cell's spacer holds no text"
);
}
#[test]
fn a_wrapped_line_is_one_line_mapped_back_to_its_cells() {
let term = term("12345678needle", 10, 3, 10);
assert_eq!(found(&term, "needle", PLAIN).hits, [at(0, 8)..=at(1, 3)]);
let regex = FindOptions {
regex: true,
..PLAIN
};
assert_eq!(found(&term, "needle$", regex).hits.len(), 1);
}
#[test]
fn the_current_match_follows_its_line() {
let mut erased = term("needle OLD\r\nneedle KEEP\r\nneedle LAST", 20, 4, 10);
let then = found(&erased, "needle", PLAIN);
feed(&mut erased, "\x1b[1;1H\x1b[2K");
let now = found(&erased, "needle", PLAIN);
assert_eq!(now.follow(&then, 1), Some(0), "KEEP, though OLD is gone");
let mut ring = term("n 1\r\nn 2\r\nn 3\r\nn 4\r\nn 5", 10, 3, 2);
let then = found(&ring, "n", PLAIN);
feed(&mut ring, "\r\nn 6");
let now = found(&ring, "n", PLAIN);
assert_eq!(
now.follow(&then, 2),
Some(1),
"n 3, a line up, though n 1 left"
);
let mut same = term("n\r\nn\r\nn", 10, 3, 10);
let then = found(&same, "n", PLAIN);
feed(&mut same, "\r\nx\r\nx");
let now = found(&same, "n", PLAIN);
assert_eq!(
now.follow(&then, 1),
Some(1),
"of like lines, the one that scrolled"
);
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");
let now = found(&gone, "needle", PLAIN);
assert_eq!(now.follow(&then, 1), Some(0), "B is gone: A is nearest");
let mut wide = term(&format!("{}needle", "x".repeat(15)), 12, 4, 10);
let then = found(&wide, "needle", PLAIN);
wide.resize(TermSize::new(30, 4));
let now = found(&wide, "needle", PLAIN);
assert_eq!(now.follow(&then, 0), Some(0));
assert_eq!(now.hits[0].start().column, Column(15), "the reflowed cells");
assert_ne!(now.hits[0].start(), then.hits[0].start());
}
#[test]
fn steps_go_round_the_ends() {
assert_eq!(stepped(Some(0), 3, false), Some(2));
assert_eq!(stepped(Some(2), 3, true), Some(0));
assert_eq!(stepped(Some(1), 3, false), Some(0));
assert_eq!(stepped(None, 0, true), None);
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub fn grid(
origin + point(cell.width * column as f32, cell.height * row as f32);
view.caret = Bounds::new(corner, cell);
}
if view.fit(columns, lines) {
if view.fit(columns, lines, cx) {
cx.notify();
window.request_animation_frame();
}
+3 -1
View File
@@ -20,6 +20,8 @@ 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.
@@ -249,7 +251,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::Term(_) | PtyEvent::Mouse(_) | PtyEvent::Reset => {}
}
}
(folders, success)
+10 -2
View File
@@ -53,7 +53,12 @@ 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]) {
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 {
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");
@@ -194,7 +199,7 @@ enum Csi {
Private,
}
/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9.
/// The mouse modes as xterm holds them, and resets, read as vte reads; alacritty drops 9.
#[derive(Default)]
pub(crate) struct MouseModes {
state: Csi,
@@ -204,6 +209,8 @@ 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 {
@@ -230,6 +237,7 @@ impl MouseModes {
(Csi::Escape, b'[') => Csi::Bracket,
(Csi::Escape, b'c') => {
self.mode = MouseMode::default();
self.resets += 1;
Csi::Ground
}
(Csi::Bracket, b'?') => {
+9 -6
View File
@@ -50,6 +50,7 @@ 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);
}
@@ -119,10 +120,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.
fn parsed(bytes: &[u8]) -> MouseMode {
/// vte's own reading of `bytes`, under the same mode rules: the mode, and the resets.
fn parsed(bytes: &[u8]) -> (MouseMode, usize) {
use alacritty_terminal::vte::{Params, Parser, Perform};
struct Modes(MouseMode);
struct Modes(MouseMode, usize);
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') {
@@ -135,12 +136,13 @@ fn parsed(bytes: &[u8]) -> MouseMode {
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());
let mut modes = Modes(MouseMode::default(), 0);
Parser::new().advance(&mut modes, bytes);
modes.0
(modes.0, modes.1)
}
#[test]
@@ -174,7 +176,8 @@ 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]);
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
let seen = (modes.mode, modes.resets);
assert_eq!(seen, parsed(&case[..end]), "{case:?} to byte {end}");
}
}
}
+10 -2
View File
@@ -117,6 +117,7 @@ impl TerminalView {
.term
.lock()
.set_options(config(SettingsStore::get(cx)));
view.search(false, cx);
cx.notify();
}),
}
@@ -158,7 +159,7 @@ impl TerminalView {
}
/// Fits the grid to `columns` by `lines`; true when the size changed.
pub(super) fn fit(&mut self, columns: usize, lines: usize) -> bool {
pub(super) fn fit(&mut self, columns: usize, lines: usize, cx: &mut Context<Self>) -> bool {
let fitted = fit(columns, lines, self.cell);
let size = (usize::from(fitted.num_cols), usize::from(fitted.num_lines));
if size == self.size {
@@ -167,6 +168,7 @@ impl TerminalView {
self.size = size;
self.pty.resize(size, self.cell);
log::debug!("terminal {}: {} by {}", self.pty.pid, size.0, size.1);
self.search(false, cx);
true
}
@@ -202,9 +204,15 @@ 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 new lines alone if scrollbacks lag.
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
if drawn && self.finder.is_some() {
self.search(false, cx);
}