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
This commit is contained in:
10 files changed
+335
-103
No files matched your search
@@ -24,7 +24,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget` over alacritty's regex search.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -57,6 +57,6 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
||||
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
|
||||
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
||||
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. New output searches again and keeps the current match.
|
||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound.
|
||||
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the one at the same place in the same line's text, nearest where scrolling moved it, else the nearest.
|
||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title without a title event; the tap counts resets, and the view replays the title through `set_options`, which sends it.
|
||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
Generated
-1
@@ -1766,7 +1766,6 @@ dependencies = [
|
||||
"objc",
|
||||
"polling",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"rustix-openpty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -20,7 +20,6 @@ gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
log = "0.4"
|
||||
polling = "3"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
+144
-86
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'?') => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user