feat(terminal): port terminal search and the tmux control-mode viewer

search/: sliding-window matcher over pages, active-area, page list,
viewport and screen searches, and the background search thread (std
thread + crossbeam-channel mailbox, 24 ms refresh, terminal behind
Arc<Mutex<_>>). Searches take &mut Screen explicitly; ScreenSearch::deinit
must be called to untrack pins. The sliding window uses a VecDeque
instead of CircBuf (same results; two layout-only assertions dropped).

tmux/: layout parsing, output formatting and the control-mode viewer
state machine, reusing ghostty_vt::tmux::control for parsing. The viewer
is not wired into termio yet; steps are in the notes.

The renderer must set terminal.flags.search_viewport_dirty for the
search thread to re-search the viewport.

Test Plan:
- cargo test -p ghostty-terminal --lib search (54; 51/53 Zig tests,
  2 allocator-failure tests skipped)
- cargo test -p ghostty-terminal --lib tmux (96; all Zig tests)
This commit is contained in:
ddidderr committed 2026-10-11 22:51:36 +02:00
1 parent 3742a131b8
commit db1d4fe41d
14 files changed
+6784

No files matched your search

@@ -0,0 +1,154 @@
//! Searches for a substring within the active area of a PageList.
//!
//! The distinction for "active area" is important because it is the
//! only part of a PageList that is mutable. Therefore, its the only part
//! of the terminal that needs to be repeatedly searched as the contents
//! change.
//!
//! This struct specializes in searching only within that active area,
//! and handling the active area moving as new lines are added to the bottom.
use super::sliding_window::{Direction, SlidingWindow};
use crate::highlight::Flattened;
use crate::page_list::{NodeId, PageList};
#[derive(Debug)]
pub struct ActiveSearch {
pub window: SlidingWindow,
}
impl ActiveSearch {
pub fn new(needle: &[u8]) -> Self {
// We just do a forward search since the active area is usually
// pretty small so search results are instant anyways. This avoids
// a small amount of work to reverse things.
Self {
window: SlidingWindow::new(Direction::Forward, needle),
}
}
/// Update the active area to reflect the current state of the PageList.
///
/// This doesn't do the search, it only copies the necessary data
/// to perform the search later. This lets the caller hold the lock
/// on the PageList for a minimal amount of time.
///
/// This returns the first page (in reverse order) covered by this
/// search. This allows the history search to overlap and search history.
/// There CAN BE duplicates, and this page CAN BE mutable, so the history
/// search results should prune anything that's in the active area.
///
/// If the return value is `None` it means the active area covers the entire
/// PageList, currently.
pub fn update(&mut self, list: &PageList) -> Option<NodeId> {
// Clear our previous sliding window
self.window.clear_and_retain_capacity();
// First up, add enough pages to cover the active area.
let mut rem: usize = usize::from(list.rows);
let mut node_ = list.last;
let mut last_node: Option<NodeId> = None;
while let Some(id) = node_ {
let Some(node) = list.node(id) else { break };
self.window.append(list, id);
last_node = Some(id);
// If we reached our target amount, then this is the last
// page that contains the active area. We go to the previous
// page once more since its the first page of our required
// overlap.
if rem <= usize::from(node.data.size.rows) {
node_ = node.prev;
break;
}
rem -= usize::from(node.data.size.rows);
node_ = node.prev;
}
// Next, add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap.
while let Some(id) = node_ {
let Some(node) = list.node(id) else { break };
// If the last row of this node isn't wrapped we can't overlap.
let row = node
.data
.get_row(usize::from(node.data.size.rows) - 1)
.expect("last row");
if !row.wrap() {
break;
}
// We could be more accurate here and count bytes since the
// last wrap but its complicated and unlikely multiple pages
// wrap so this should be fine.
let added = self.window.append(list, id);
if added >= self.window.needle().len() - 1 {
break;
}
node_ = node.prev;
}
// Return the last node we added to our window.
last_node
}
/// Find the next match for the needle in the active area. This returns
/// `None` when there are no more matches.
pub fn next(&mut self) -> Option<Flattened> {
self.window.next()
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::point::Tag;
fn check(list: &PageList, h: &Flattened, s: (u16, u32), e: (u16, u32)) {
let sel = h.untracked();
assert_eq!(
list.point_from_pin(Tag::Active, sel.start),
Some(active(s.0, s.1))
);
assert_eq!(
list.point_from_pin(Tag::Active, sel.end),
Some(active(e.0, e.1))
);
}
#[test]
fn simple_search() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ActiveSearch::new(b"Fizz");
search.update(&t.screen().pages);
let h = search.next().unwrap();
check(&t.screen().pages, &h, (0, 0), (3, 0));
let h = search.next().unwrap();
check(&t.screen().pages, &h, (0, 2), (3, 2));
assert!(search.next().is_none());
}
#[test]
fn clear_screen_and_search() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ActiveSearch::new(b"Fizz");
search.update(&t.screen().pages);
feed(&mut t, b"\x1b[2J"); // Clear screen
feed(&mut t, b"\x1b[H"); // Move cursor home
feed(&mut t, b"Buzz\r\nFizz\r\nBuzz");
search.update(&t.screen().pages);
let h = search.next().unwrap();
check(&t.screen().pages, &h, (0, 1), (3, 1));
assert!(search.next().is_none());
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Search functionality for the terminal.
#![allow(clippy::should_implement_trait)]
pub mod active;
pub mod pagelist;
pub mod screen;
pub mod sliding_window;
#[cfg(test)]
pub(crate) mod test_util;
pub mod thread;
pub mod viewport;
@@ -0,0 +1,412 @@
//! Searches for a term in a PageList structure.
//!
//! This searches in reverse order starting from the given node.
//!
//! This assumes that nodes do not change contents. For nodes that change
//! contents, look at `ActiveSearch`, which is designed to re-search the active
//! area since it assumed to change. When integrating `ActiveSearch` with
//! `PageListSearch`, the caller should start the `PageListSearch` from the
//! returned node from `ActiveSearch::update()`.
//!
//! Concurrent access to a PageList or nodes in a PageList are not allowed,
//! so the caller should ensure that necessary locks are held. Each function
//! documents whether it accesses the PageList or not. For example, you can
//! safely call `next()` without holding a lock, but you must hold a lock
//! while calling `feed()`.
//!
//! Deviation: Zig stores a `*PageList`; here every method that touches the
//! list takes it as a parameter.
use super::sliding_window::{Direction, SlidingWindow};
use crate::highlight::Flattened;
use crate::page::Error;
use crate::page_list::{NodeId, PageList, Pin, TrackedPin};
#[derive(Debug)]
pub struct PageListSearch {
/// The sliding window of page contents and nodes to search.
pub window: SlidingWindow,
/// The tracked pin for our current position in the pagelist. This
/// will always point to the CURRENT node we're searching from so that
/// we can track if we move.
pub pin: TrackedPin,
}
impl PageListSearch {
/// Initialize the page list search. The needle is copied so it can
/// be freed immediately.
///
/// Accesses the PageList/Node so the caller must ensure it is safe
/// to do so if there is any concurrent access.
pub fn new(needle: &[u8], list: &mut PageList, start: NodeId) -> Result<Self, Error> {
// We put a tracked pin into the node that we're starting from.
// By using a tracked pin, we can keep our pagelist references safe
// because if the pagelist prunes pages, the tracked pin will
// be moved somewhere safe.
let (rows, cols) = {
let n = list.node(start).ok_or(Error::InvalidId)?;
(n.data.size.rows, n.data.size.cols)
};
let pin = list.track_pin(Pin {
node: start,
y: rows - 1,
x: cols - 1,
garbage: false,
})?;
// Create our sliding window we'll use for searching.
let mut window = SlidingWindow::new(Direction::Reverse, needle);
// We always feed our initial page data into the window, because
// we have the lock anyways and this lets our `pin` point to our
// current node and feed to work properly.
window.append(list, start);
Ok(Self { window, pin })
}
/// Modifies the PageList (to untrack a pin) so the caller must ensure
/// that it is safe to do so.
pub fn deinit(&mut self, list: &mut PageList) {
list.untrack_pin(self.pin);
}
/// Return the next match in the loaded page nodes. If this returns
/// `None` then the PageList search needs to be fed the next node(s).
/// Call `feed` to do this.
///
/// Beware that the selection returned may point to a node that
/// is freed if the caller does not hold necessary locks on the
/// PageList while searching. The pins should be validated prior to
/// final use.
///
/// This does NOT access the PageList, so it can be called without
/// a lock held.
pub fn next(&mut self) -> Option<Flattened> {
self.window.next()
}
/// Feed more data to the sliding window from the pagelist. This will
/// feed enough data to cover at least one match (needle length) if it
/// exists; this doesn't perform the search, it only feeds data.
///
/// This accesses nodes in the PageList, so the caller must ensure
/// it is safe to do so (i.e. hold necessary locks).
///
/// This returns false if there is no more data to feed. This essentially
/// means we've searched the entire pagelist.
pub fn feed(&mut self, list: &mut PageList) -> Result<bool, Error> {
// If our pin becomes garbage it means wherever we were next
// was reused and we can't make sense of our progress anymore.
// It is effectively equivalent to reaching the end of the PageList.
let Some(pin) = list.tracked_pin(self.pin) else {
return Ok(false);
};
if pin.garbage {
return Ok(false);
}
// Add at least enough data to find a single match.
let mut rem = self.window.needle().len();
// Start at our previous node and then continue adding until we
// get our desired amount of data.
let mut node_ = list.node(pin.node).and_then(|n| n.prev);
while let Some(id) = node_ {
rem = rem.saturating_sub(self.window.append(list, id));
// Move our tracked pin to the new node.
let (rows, cols, prev) = {
let n = list.node(id).ok_or(Error::InvalidId)?;
(n.data.size.rows, n.data.size.cols, n.prev)
};
list.set_tracked_pin(
self.pin,
Pin {
node: id,
y: rows - 1,
x: cols - 1,
garbage: false,
},
)?;
if rem == 0 {
break;
}
node_ = prev;
}
// True if we fed any data.
Ok(rem < self.window.needle().len())
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::point::Tag;
use crate::screen::SelectionString;
use crate::selection::Selection;
fn check(list: &PageList, h: &Flattened, s: (u16, u32), e: (u16, u32)) {
let sel = h.untracked();
assert_eq!(
list.point_from_pin(Tag::Active, sel.start),
Some(active(s.0, s.1))
);
assert_eq!(
list.point_from_pin(Tag::Active, sel.end),
Some(active(e.0, e.1))
);
}
fn first_page_rows(t: &crate::terminal::Terminal) -> u16 {
let l = &t.screen().pages;
l.node(first(l)).unwrap().data.capacity.rows
}
#[test]
fn simple_search() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Fizz", &mut t.screen_mut().pages, last_id).unwrap();
let h = search.next().unwrap();
check(&t.screen().pages, &h, (0, 2), (3, 2));
let h = search.next().unwrap();
check(&t.screen().pages, &h, (0, 0), (3, 0));
assert!(search.next().is_none());
// We should not be able to feed since we have one page
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_multiple_pages_with_matches() {
let mut t = terminal(10, 10);
let rows = first_page_rows(&t);
for _ in 0..rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"Fizz");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Create second page
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"Buzz\r\nFizz");
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Fizz", &mut t.screen_mut().pages, last_id).unwrap();
// First match on the last page
assert!(search.next().is_some());
assert!(search.next().is_none());
// Feed should succeed and load the first page
assert!(search.feed(&mut t.screen_mut().pages).unwrap());
// Now we should find the match on the first page
assert!(search.next().is_some());
assert!(search.next().is_none());
// No more pages to feed
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_multiple_pages_no_matches() {
let mut t = terminal(10, 10);
let rows = first_page_rows(&t);
for _ in 0..rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"Hello");
// Create second page
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"World");
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Nope", &mut t.screen_mut().pages, last_id).unwrap();
// No matches on last page
assert!(search.next().is_none());
// Feed first page
assert!(search.feed(&mut t.screen_mut().pages).unwrap());
// Still no matches
assert!(search.next().is_none());
// No more pages
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_iteratively_through_multiple_matches() {
let mut t = terminal(80, 24);
let rows = first_page_rows(&t);
// Fill first page with a match at the end
for _ in 0..rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"Page1Test");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Create second page with a match
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"Page2Test");
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Test", &mut t.screen_mut().pages, last_id).unwrap();
// Match on page 2
assert!(search.next().is_some());
assert!(search.next().is_none());
// Feed page 1
assert!(search.feed(&mut t.screen_mut().pages).unwrap());
assert!(search.next().is_some());
assert!(search.next().is_none());
// No more pages
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_with_match_spanning_page_boundary() {
let mut t = terminal(80, 24);
let rows = first_page_rows(&t);
// Fill first page ending with "Te"
for _ in 0..rows - 1 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.cols - 2 {
feed(&mut t, b"x");
}
feed(&mut t, b"Te");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Second page starts with "st"
feed(&mut t, b"st");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Test", &mut t.screen_mut().pages, last_id).unwrap();
// No complete match on last page alone (only has "st")
assert!(search.next().is_none());
// Feed first page - this should give us enough data to find "Test"
assert!(search.feed(&mut t.screen_mut().pages).unwrap());
// Should find the spanning match
let h = search.next().unwrap();
let sel = h.untracked();
assert_ne!(sel.start.node, sel.end.node);
{
let str = t
.screen()
.selection_string(SelectionString::new(Selection::new(
sel.start, sel.end, false,
)));
assert_eq!(str, "Test");
}
// No more matches
assert!(search.next().is_none());
// No more pages
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_with_match_spanning_page_boundary_with_newline() {
let mut t = terminal(80, 24);
let rows = first_page_rows(&t);
for _ in 0..rows - 1 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.cols - 2 {
feed(&mut t, b"x");
}
feed(&mut t, b"Te");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Second page starts with "st"
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"st");
let last_id = last(&t.screen().pages);
let mut search = PageListSearch::new(b"Test", &mut t.screen_mut().pages, last_id).unwrap();
// Should not find any matches since we broke with an explicit newline.
assert!(search.next().is_none());
assert!(search.feed(&mut t.screen_mut().pages).unwrap());
assert!(search.next().is_none());
assert!(!search.feed(&mut t.screen_mut().pages).unwrap());
search.deinit(&mut t.screen_mut().pages);
}
#[test]
fn feed_with_pruned_page() {
// Zero here forces minimum max size to effectively two pages.
let mut p = PageList::init(80, 24, Some(0)).unwrap();
// Grow to capacity
let page1_node = p.last.unwrap();
let (cap1, size1) = {
let d = &p.node(page1_node).unwrap().data;
(d.capacity.rows, d.size.rows)
};
for _ in 0..cap1 - size1 {
assert!(p.grow().unwrap().is_none());
}
// Grow and allocate one more page. Then fill that page up.
let page2_node = p.grow().unwrap().unwrap();
let (cap2, size2) = {
let d = &p.node(page2_node).unwrap().data;
(d.capacity.rows, d.size.rows)
};
for _ in 0..cap2 - size2 {
assert!(p.grow().unwrap().is_none());
}
// Setup search and feed until we can't
let last_id = p.last.unwrap();
let mut search = PageListSearch::new(b"Test", &mut p, last_id).unwrap();
assert!(search.feed(&mut p).unwrap());
assert!(!search.feed(&mut p).unwrap());
// Next should create a new page, but it should reuse our first
// page since we're at max size.
let new = p.grow().unwrap().unwrap();
assert_eq!(p.last.unwrap(), new);
// Our first should now be page2. (Zig also asserts the last node
// pointer equals the old first; node ids carry a generation here so
// the reused node has a new id.)
assert_eq!(p.first.unwrap(), page2_node);
// Feed should still do nothing
assert!(!search.feed(&mut p).unwrap());
search.deinit(&mut p);
}
}
+1261
View File
@@ -0,0 +1,1261 @@
//! Searches for a needle within a Screen, handling active area updates,
//! pages being pruned from the screen (e.g. scrollback limits), and more.
//!
//! Unlike our lower-level searchers (like `ActiveSearch` and
//! `PageListSearch`), this will cache and store all search results so the
//! caller can re-access them as needed. This structure does this because it
//! is intended to help the caller handle the case where the Screen is
//! changing while the user is searching.
//!
//! An inactive screen can continue to be searched in the background, and when
//! screen state changes, the renderer/caller can access the existing search
//! results without needing to re-search everything. This prevents a particularly
//! nasty UX where going to alt screen (e.g. neovim) and then back would
//! restart the full scrollback search.
//!
//! Deviation: Zig stores `*Screen`. Here the screen is passed to every method
//! that touches it (`new`, `deinit`, `feed`, `reload_active`, `select`,
//! `search_all`); `tick`, `matches`, `selected_match` etc. do not need it.
use super::active::ActiveSearch;
use super::pagelist::PageListSearch;
use super::sliding_window::{Direction, SlidingWindow};
use crate::highlight::{Flattened, Tracked};
use crate::page::Error;
use crate::page_list::{NodeId, PageList, Pin, TrackedPin};
use crate::screen::Screen;
use crate::size::CellCountInt;
#[derive(Debug)]
pub struct ScreenSearch {
/// Identity of the screen being searched (its address). Only used to
/// detect that a screen was replaced; never dereferenced.
screen_id: usize,
/// The active area search state
pub active: ActiveSearch,
/// The history (scrollback) search state. May be `None` if there is
/// no history yet.
history: Option<HistorySearch>,
/// Current state of the search, a state machine.
pub state: State,
/// The currently selected match, if any. As the screen contents
/// change or get pruned, the screen search will do its best to keep
/// this accurate.
pub selected: Option<SelectedMatch>,
/// The results found so far. These are stored separately because history
/// is mostly immutable once found, while active area results may
/// change. This lets us easily reset the active area results for a
/// re-search scenario.
pub history_results: Vec<Flattened>,
pub active_results: Vec<Flattened>,
/// The dimensions of the screen. When this changes we need to
/// restart the whole search, currently.
rows: CellCountInt,
cols: CellCountInt,
}
#[derive(Debug)]
pub struct SelectedMatch {
/// Index from the end of the match list (0 = most recent match)
pub idx: usize,
/// Tracked highlight so we can detect movement.
pub highlight: Tracked,
}
impl SelectedMatch {
pub fn deinit(self, screen: &mut Screen) {
self.highlight.deinit(screen);
}
}
/// History search state.
#[derive(Debug)]
struct HistorySearch {
/// The actual searcher state.
searcher: PageListSearch,
/// The pin for the first node that this searcher is searching from.
/// We use this when the active area changes to find the diff between
/// the top of the new active area and the previous start point
/// to determine if we need to search more history.
start_pin: TrackedPin,
/// Cached node of `start_pin`. Zig reads `start_pin.node` during `tick`
/// without a lock; here the tracked pin lives in the PageList so the
/// node is cached and refreshed whenever the screen is locked
/// (`feed`, `reload_active`).
start_node: NodeId,
}
impl HistorySearch {
fn deinit(mut self, screen: &mut Screen) {
self.searcher.deinit(&mut screen.pages);
screen.pages.untrack_pin(self.start_pin);
}
}
/// Search state machine
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
/// Currently searching the active area
Active,
/// Currently searching the history area
History,
/// History search is waiting for more data to be fed before
/// it can progress.
HistoryFeed,
/// Search is complete given the current terminal state.
Complete,
}
impl State {
pub fn is_complete(self) -> bool {
matches!(self, State::Complete)
}
pub fn needs_feed(self) -> bool {
match self {
State::HistoryFeed => true,
// Not obvious but complete search states will prune
// stale history results on feed.
State::Complete => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Select {
/// Next selection, in reverse order (newest to oldest),
/// non-wrapping.
Next,
/// Prev selection, in forward order (oldest to newest),
/// non-wrapping.
Prev,
}
/// Errors from `tick`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TickError {
#[error("feed required")]
FeedRequired,
#[error("search complete")]
SearchComplete,
#[error("page error: {0}")]
Page(Error),
}
fn screen_id(screen: &Screen) -> usize {
std::ptr::from_ref::<Screen>(screen) as usize
}
fn pin_garbage(list: &PageList, h: TrackedPin) -> bool {
// A pin that can't be resolved is as good as garbage.
list.tracked_pin(h).is_none_or(|p| p.garbage)
}
impl ScreenSearch {
/// Initialize a screen search for the given screen and needle.
pub fn new(screen: &mut Screen, needle_unowned: &[u8]) -> Result<Self, Error> {
let mut result = ScreenSearch {
screen_id: screen_id(screen),
rows: screen.pages.rows,
cols: screen.pages.cols,
active: ActiveSearch::new(needle_unowned),
history: None,
state: State::Active,
selected: None,
active_results: Vec::new(),
history_results: Vec::new(),
};
// Update our initial active area state
if let Err(e) = result.reload_active(screen) {
result.deinit(screen);
return Err(e);
}
Ok(result)
}
/// Release tracked pins held in the screen.
pub fn deinit(&mut self, screen: &mut Screen) {
if let Some(h) = self.history.take() {
h.deinit(screen);
}
if let Some(m) = self.selected.take() {
m.deinit(screen);
}
}
/// The address identity of the screen this search was created for.
pub fn screen_id(&self) -> usize {
self.screen_id
}
/// True if this search was created for the given screen (by identity).
pub fn is_for_screen(&self, screen: &Screen) -> bool {
self.screen_id == screen_id(screen)
}
/// The needle that this search is using.
pub fn needle(&self) -> &[u8] {
assert_eq!(self.active.window.direction(), Direction::Forward);
self.active.window.needle()
}
/// Returns the total number of matches found so far.
pub fn matches_len(&self) -> usize {
self.active_results.len() + self.history_results.len()
}
/// Returns all matches. The matches are ordered from most recent to
/// oldest (e.g. bottom of the screen to top of the screen).
pub fn matches(&self) -> Vec<Flattened> {
// Active does a forward search, so we add the active results then
// reverse them. There are usually not many active results so this
// is fast enough compared to adding them in reverse order.
assert_eq!(self.active.window.direction(), Direction::Forward);
let mut results: Vec<Flattened> = self.active_results.clone();
results.reverse();
// History does a backward search, so we can just append them after.
results.extend(self.history_results.iter().cloned());
results
}
/// Search the full screen state. This will block until the search
/// is complete. For performance, it is recommended to use `tick` and
/// `feed` to incrementally make progress on the search instead.
pub fn search_all(&mut self, screen: &mut Screen) -> Result<(), Error> {
loop {
match self.tick() {
Ok(()) => {}
Err(TickError::Page(e)) => return Err(e),
Err(TickError::FeedRequired) => self.feed(screen)?,
Err(TickError::SearchComplete) => return Ok(()),
}
}
}
/// Make incremental progress on the search without accessing any
/// screen state (so no lock is required).
///
/// This will return `FeedRequired` if the search cannot make progress
/// without being fed more data. In this case, the caller should call
/// the `feed` function to provide more data to the searcher.
///
/// This will return `SearchComplete` if the search is fully complete.
/// This is to signal to the caller that it can move to a more efficient
/// sleep/wait state until there is more work to do (e.g. new data to feed).
pub fn tick(&mut self) -> Result<(), TickError> {
match self.state {
State::Active => self.tick_active(),
State::History => self.tick_history(),
State::HistoryFeed => return Err(TickError::FeedRequired),
State::Complete => return Err(TickError::SearchComplete),
}
Ok(())
}
/// Feed more data to the searcher so it can continue searching. This
/// accesses the screen state, so the caller must hold the necessary locks.
///
/// Feed on a complete screen search will perform some cleanup of
/// potentially stale history results (pruned) and reclaim some memory.
pub fn feed(&mut self, screen: &mut Screen) -> Result<(), Error> {
// If the screen resizes, we have to reset our entire search. That
// isn't ideal but we don't have a better way right now to handle
// reflowing the search results beyond putting a tracked pin for
// every single result.
if screen.pages.rows != self.rows || screen.pages.cols != self.cols {
// Reinit
let needle = self.needle().to_vec();
let new = ScreenSearch::new(screen, &needle)?;
// Deinit/reinit
self.deinit(screen);
*self = new;
// New result should have matching dimensions
assert_eq!(screen.pages.rows, self.rows);
assert_eq!(screen.pages.cols, self.cols);
}
let Some(h) = self.history.as_mut() else {
// No history to feed, search is complete.
self.state = State::Complete;
return Ok(());
};
// Future: we may want to feed multiple pages at once here to
// lower the frequency of lock acquisitions.
if !h.searcher.feed(&mut screen.pages)? {
// No more data to feed, search is complete.
self.state = State::Complete;
// We use this opportunity to also clean up older history
// results that may be gone due to scrollback pruning, though.
self.prune_history(screen);
return Ok(());
}
// Depending on our state handle where feed goes
match self.state {
// If we're searching active or history, then feeding doesn't
// change the state.
State::Active | State::History => {}
// Feed goes back to searching history.
State::HistoryFeed => self.state = State::History,
// If we're complete then the feed call above should always
// return false and we can't reach this.
State::Complete => unreachable!(),
}
Ok(())
}
fn prune_history(&mut self, screen: &Screen) {
// Go through our history results in order (newest to oldest) to find
// any result that contains an invalid serial. Prune up to that
// point.
for i in 0..self.history_results.len() {
let hl = &self.history_results[i];
let lowest = hl.chunks[0].serial;
if lowest < screen.pages.page_serial_min {
// Everything from here forward we assume is invalid because
// our history results only get older.
self.history_results.truncate(i);
self.history_results.shrink_to_fit();
return;
}
}
}
fn tick_active(&mut self) {
// For the active area, we consume the entire search in one go
// because the active area is generally small.
while let Some(hl) = self.active.next() {
self.active_results.push(hl);
}
// We've consumed the entire active area, move to history.
self.state = State::History;
}
fn tick_history(&mut self) {
let Some(history) = self.history.as_mut() else {
// No history to search, we're done.
self.state = State::Complete;
return;
};
// Try to consume all the loaded matches in one go, because
// the search is generally fast for loaded data.
while let Some(hl) = history.searcher.next() {
// Ignore selections that are found within the starting
// node since those are covered by the active area search.
if hl.chunks[0].node == history.start_node {
continue;
}
self.history_results.push(hl);
// Since history only appends to our results in reverse order,
// we don't need to update any selected match state. The index
// and prior results are unaffected.
}
// We need to be fed more data.
self.state = State::HistoryFeed;
}
/// Reload the active area because it has changed.
///
/// Since it is very fast, this will also do the full active area
/// search again, too. This avoids any complexity around the search
/// state machine.
///
/// The caller must hold the necessary locks to access the screen state.
pub fn reload_active(&mut self, screen: &mut Screen) -> Result<(), Error> {
// If our selection pin became garbage it means we scrolled off
// the end. Clear our selection and on exit of this function,
// try to select the last match.
let select_prev: bool = 'select_prev: {
let Some(m) = &self.selected else {
break 'select_prev false;
};
if !pin_garbage(&screen.pages, m.highlight.start)
&& !pin_garbage(&screen.pages, m.highlight.end)
{
break 'select_prev false;
}
if let Some(m) = self.selected.take() {
m.deinit(screen);
}
true
};
let result = self.reload_active_inner(screen);
if select_prev && let Err(e) = self.select(screen, Select::Prev) {
log::info!("reload failed to reset search selection err={e:?}");
}
result
}
/// Drop all history state and results.
fn reset_history(&mut self, screen: &mut Screen) {
if let Some(h) = self.history.take() {
h.deinit(screen);
}
self.history_results.clear();
}
fn reload_active_inner(&mut self, screen: &mut Screen) -> Result<(), Error> {
if let Some(history_node) = self.active.update(&screen.pages) {
self.reload_history(screen, history_node)?;
} else {
// No history node means we have no history
if self.history.is_some() {
self.reset_history(screen);
}
// If we have a selection in the history area, we need to
// move it to the end of the active area.
if let Some(m) = &self.selected {
let active_len = self.active_results.len();
if m.idx >= active_len {
if let Some(m) = self.selected.take() {
m.deinit(screen);
}
if let Err(e) = self.select(screen, Select::Prev) {
log::info!("reload failed to reset search selection err={e:?}");
}
}
}
}
// Figure out if we need to fixup our selection later because
// it was in the active area.
let old_active_len = self.active_results.len();
let old_selection_idx: Option<usize> = self.selected.as_ref().map(|m| m.idx);
if let Err(e) = self.reload_active_results(screen, old_active_len, old_selection_idx) {
// This is the error scenario. If something fails below,
// our active area is probably gone, so we just go back
// to the first result because our selection can't be trusted.
if old_selection_idx.is_some_and(|i| i < old_active_len)
&& let Some(m) = self.selected.take()
{
m.deinit(screen);
if let Err(e) = self.select(screen, Select::Next) {
log::info!("reload failed to reset search selection err={e:?}");
}
}
return Err(e);
}
Ok(())
}
/// The `history:` block of Zig's `reloadActive`: account for any active
/// area growth that would cause new pages to move into our history.
fn reload_history(&mut self, screen: &mut Screen, history_node: NodeId) -> Result<(), Error> {
// If our screen has no scrollback then we have no history.
if screen.no_scrollback {
assert!(self.history.is_none());
return Ok(());
}
// If our start pin became garbage, it means we pruned all
// the way up through it, so we have no history anymore.
// Reset our history state.
if let Some(h) = &self.history
&& pin_garbage(&screen.pages, h.start_pin)
{
self.reset_history(screen);
}
let Some(history) = self.history.as_mut() else {
// No history search yet, but we now have history. So let's
// initialize.
let needle = self.needle().to_vec();
let mut search = PageListSearch::new(&needle, &mut screen.pages, history_node)?;
let pin = match screen.pages.track_pin(Pin {
node: history_node,
y: 0,
x: 0,
garbage: false,
}) {
Ok(p) => p,
Err(e) => {
search.deinit(&mut screen.pages);
return Err(e);
}
};
self.history = Some(HistorySearch {
searcher: search,
start_pin: pin,
start_node: history_node,
});
// We don't need to update any history since we had no history
// before, so we can break out of the whole conditional.
return Ok(());
};
let mut start = screen
.pages
.tracked_pin(history.start_pin)
.ok_or(Error::InvalidId)?;
history.start_node = start.node;
if start.node == history_node {
// No change in the starting node, we're done.
return Ok(());
}
// Do a forward search from our prior node to this one. We
// collect all the results into a new list. We ASSUME that
// reloadActive is being called frequently enough that there isn't
// a massive amount of history to search here.
let needle = self.active.window.needle().to_vec();
let mut window = SlidingWindow::new(Direction::Forward, &needle);
loop {
window.append(&screen.pages, start.node);
if start.node == history_node {
break;
}
let Some(next) = screen.pages.node(start.node).and_then(|n| n.next) else {
break;
};
start = Pin {
node: next,
y: 0,
x: 0,
garbage: false,
};
screen.pages.set_tracked_pin(history.start_pin, start)?;
}
history.start_node = start.node;
assert_eq!(start.node, history_node);
let mut results: Vec<Flattened> = Vec::with_capacity(self.history_results.len());
while let Some(hl) = window.next() {
if hl.chunks[0].node == history_node {
continue;
}
results.push(hl);
}
// If we have no matches then there is nothing to change
// in our history (fast path)
if results.is_empty() {
return Ok(());
}
// The number added to our history. Needed for updating
// our selection if we have one.
let added_len = results.len();
// Matches! Reverse our list then append all the remaining
// history items that didn't start on our original node.
results.reverse();
results.append(&mut self.history_results);
self.history_results = results;
// If our prior selection was in the history area, update
// the offset.
if let Some(m) = &mut self.selected {
let active_len = self.active_results.len();
if m.idx >= active_len {
m.idx += added_len;
// Moving the idx should not change our targeted result
// since the history is immutable.
if cfg!(debug_assertions) {
let hl = &self.history_results[m.idx - active_len];
let sp = screen.pages.tracked_pin(m.highlight.start);
debug_assert!(sp.is_some_and(|p| p.eql(hl.start_pin())));
}
}
}
Ok(())
}
/// The tail of Zig's `reloadActive`: reset our active search results,
/// search again and fix up the selection.
fn reload_active_results(
&mut self,
screen: &mut Screen,
old_active_len: usize,
old_selection_idx: Option<usize>,
) -> Result<(), Error> {
// Reset our active search results and search again.
self.active_results.clear();
match self.state {
// If we're in the active state we run a normal tick so
// we can move into a better state.
State::Active => self.tick_active(),
// Otherwise, just tick it and move back to whatever state
// we were in.
old_state => {
self.tick_active();
self.state = old_state;
}
}
// If we have no scrollback, we need to prune any active results
// that aren't in the actual active area. We only do this for the
// no scrollback scenario because with scrollback we actually
// rely on our active search searching by page to find history
// items as well. This is all related to the fact that PageList
// scrollback limits are discrete by page size except we special
// case zero.
if screen.no_scrollback && !self.active_results.is_empty() {
let tl = screen
.pages
.get_top_left(crate::point::Tag::Active)
.ok_or(Error::InvalidCoordinate)?;
let mut keep_from: Option<usize> = None;
for (i, hl) in self.active_results.iter().enumerate() {
if !tl.before(&screen.pages, hl.end_pin()) {
// Pruned no matter what at some point for not being in
// the active area.
continue;
}
// In the active area! Since our results are sorted
// that means everything after this is also in the active
// area, so we prune up to this i.
keep_from = Some(i);
break;
}
match keep_from {
Some(i) => {
self.active_results.drain(..i);
}
// None are in the active area...
None => self.active_results.clear(),
}
}
// Now we have to fixup our selection if we had one.
'fixup: {
let Some(old_idx) = old_selection_idx else {
break 'fixup;
};
let Some(m) = self.selected.as_mut() else {
break 'fixup;
};
// If our old selection wasn't in the active area, then we
// need to fix up our offsets.
if old_idx >= old_active_len {
m.idx -= old_active_len;
m.idx += self.active_results.len();
break 'fixup;
}
// We search for the matching highlight in the new active results.
let ms = screen.pages.tracked_pin(m.highlight.start);
let me = screen.pages.tracked_pin(m.highlight.end);
if let (Some(ms), Some(me)) = (ms, me) {
for (i, hl) in self.active_results.iter().enumerate() {
let untracked = hl.untracked();
if ms.eql(untracked.start) && me.eql(untracked.end) {
// Found it! Update our index.
m.idx = self.active_results.len() - 1 - i;
break 'fixup;
}
}
}
// No match, just go back to the first match.
if let Some(m) = self.selected.take() {
m.deinit(screen);
}
if let Err(e) = self.select(screen, Select::Next) {
log::info!("reload failed to reset search selection err={e:?}");
}
}
Ok(())
}
/// Return the selected match.
///
/// This does not require read/write access to the underlying screen.
pub fn selected_match(&self) -> Option<Flattened> {
let sel = self.selected.as_ref()?;
let active_len = self.active_results.len();
if sel.idx < active_len {
return Some(self.active_results[active_len - 1 - sel.idx].clone());
}
let history_len = self.history_results.len();
if sel.idx < active_len + history_len {
return Some(self.history_results[sel.idx - active_len].clone());
}
None
}
/// Select the next or previous search result. This requires read/write
/// access to the underlying screen, since we utilize tracked pins to
/// ensure our selection sticks with contents changing.
pub fn select(&mut self, screen: &mut Screen, to: Select) -> Result<bool, Error> {
// All selection requires valid pins so we prune history and
// reload our active area immediately. This ensures all search
// results point to valid nodes.
self.reload_active(screen)?;
self.prune_history(screen);
match to {
Select::Next => self.select_next(screen),
Select::Prev => self.select_prev(screen),
}
}
fn select_next(&mut self, screen: &mut Screen) -> Result<bool, Error> {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the first.
let Some(prev_idx) = self.selected.as_ref().map(|m| m.idx) else {
// Get our highlight
let hl: &Flattened = if let Some(hl) = self.active_results.last() {
// Active is in forward order
hl
} else if let Some(hl) = self.history_results.first() {
// History is in reverse order
hl
} else {
// No matches at all. Can't select anything.
return Ok(false);
};
// Pin it so we can track any movement
let tracked = hl.untracked().track(screen)?;
// Our selection is index zero since we just started and
// we store our selection.
self.selected = Some(SelectedMatch {
idx: 0,
highlight: tracked,
});
return Ok(true);
};
let next_idx = prev_idx + 1;
let active_len = self.active_results.len();
let history_len = self.history_results.len();
if next_idx >= active_len + history_len {
// No more matches. We don't wrap or reset the match currently.
return Ok(false);
}
let hl: &Flattened = if next_idx < active_len {
&self.active_results[active_len - 1 - next_idx]
} else {
&self.history_results[next_idx - active_len]
};
// Pin it so we can track any movement
let tracked = hl.untracked().track(screen)?;
// Free our previous match and setup our new selection
if let Some(prev) = self.selected.take() {
prev.deinit(screen);
}
self.selected = Some(SelectedMatch {
idx: next_idx,
highlight: tracked,
});
Ok(true)
}
fn select_prev(&mut self, screen: &mut Screen) -> Result<bool, Error> {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the last.
let Some(prev_idx) = self.selected.as_ref().map(|m| m.idx) else {
// Get our highlight (oldest match)
let hl: &Flattened = if let Some(hl) = self.history_results.last() {
// History is in reverse order, so last item is oldest
hl
} else if let Some(hl) = self.active_results.first() {
// Active is in forward order, so first item is oldest
hl
} else {
// No matches at all. Can't select anything.
return Ok(false);
};
// Pin it so we can track any movement
let tracked = hl.untracked().track(screen)?;
// Our selection is the last index since we just started
// and we store our selection.
let active_len = self.active_results.len();
let history_len = self.history_results.len();
self.selected = Some(SelectedMatch {
idx: active_len + history_len - 1,
highlight: tracked,
});
return Ok(true);
};
// Can't go below zero
if prev_idx == 0 {
// No more matches. We don't wrap or reset the match currently.
return Ok(false);
}
let next_idx = prev_idx - 1;
let active_len = self.active_results.len();
let hl: &Flattened = if next_idx < active_len {
&self.active_results[active_len - 1 - next_idx]
} else {
&self.history_results[next_idx - active_len]
};
// Pin it so we can track any movement
let tracked = hl.untracked().track(screen)?;
// Free our previous match and setup our new selection
if let Some(prev) = self.selected.take() {
prev.deinit(screen);
}
self.selected = Some(SelectedMatch {
idx: next_idx,
highlight: tracked,
});
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::point::{Point, Tag};
use crate::terminal::Terminal;
fn pts(t: &Terminal, h: &Flattened, tag: Tag) -> (Option<Point>, Option<Point>) {
let sel = h.untracked();
(
t.screen().pages.point_from_pin(tag, sel.start),
t.screen().pages.point_from_pin(tag, sel.end),
)
}
fn expect(t: &Terminal, h: &Flattened, tag: Tag, s: (u16, u32), e: (u16, u32)) {
assert_eq!(
pts(t, h, tag),
(Some(pt(tag, s.0, s.1)), Some(pt(tag, e.0, e.1)))
);
}
fn expect_start(t: &Terminal, h: &Flattened, tag: Tag, s: (u16, u32)) {
assert_eq!(pts(t, h, tag).0, Some(pt(tag, s.0, s.1)));
}
fn total_pages(t: &Terminal) -> usize {
t.screen().pages.total_pages()
}
#[test]
fn simple_search() {
let mut t = terminal(10, 2);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(2, search.active_results.len());
// We don't test history results since there is overlap
let matches = search.matches();
assert_eq!(2, matches.len());
expect(&t, &matches[0], Tag::Screen, (0, 2), (3, 2));
expect(&t, &matches[1], Tag::Screen, (0, 0), (3, 0));
search.deinit(t.screen_mut());
}
#[test]
fn simple_search_with_history() {
let mut t = terminal_scrollback(10, 2, usize::MAX);
feed(&mut t, b"Fizz\r\n");
while total_pages(&t) < 3 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello.");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(0, search.active_results.len());
let matches = search.matches();
assert_eq!(1, matches.len());
expect(&t, &matches[0], Tag::Screen, (0, 0), (3, 0));
search.deinit(t.screen_mut());
}
#[test]
fn reload_active_with_history_change() {
let mut t = terminal_scrollback(10, 2, usize::MAX);
feed(&mut t, b"Fizz\r\n");
// Start up our search which will populate our initial active area.
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(1, search.matches().len());
// Grow into two pages so our history pin will move.
while total_pages(&t) < 2 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"2Fizz");
// Active area changed so reload
search.reload_active(t.screen_mut()).unwrap();
search.search_all(t.screen_mut()).unwrap();
{
let matches = search.matches();
assert_eq!(2, matches.len());
expect(&t, &matches[1], Tag::Screen, (0, 0), (3, 0));
expect(&t, &matches[0], Tag::Active, (1, 1), (4, 1));
}
// Reset the screen which will make our pin garbage.
t.full_reset();
feed(&mut t, b"WeFizzing");
search.reload_active(t.screen_mut()).unwrap();
search.search_all(t.screen_mut()).unwrap();
{
let matches = search.matches();
assert_eq!(1, matches.len());
expect(&t, &matches[0], Tag::Active, (2, 0), (5, 0));
}
search.deinit(t.screen_mut());
}
#[test]
fn active_change_contents() {
let mut t = terminal(10, 5);
feed(&mut t, b"Fuzz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(1, search.active_results.len());
// Erase the screen, move our cursor to the top, and change contents.
feed(&mut t, b"\x1b[2J\x1b[H"); // Clear screen and move home
feed(&mut t, b"Bang\r\nFizz\r\nHello!");
search.reload_active(t.screen_mut()).unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(1, search.active_results.len());
let matches = search.matches();
assert_eq!(1, matches.len());
expect(&t, &matches[0], Tag::Screen, (0, 1), (3, 1));
search.deinit(t.screen_mut());
}
#[test]
fn select_next() {
let mut t = terminal(10, 2);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
// Initially no selection
assert!(search.selected_match().is_none());
// Select our next match (first)
search.search_all(t.screen_mut()).unwrap();
search.select(t.screen_mut(), Select::Next).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 2),
(3, 2),
);
// Next match
search.select(t.screen_mut(), Select::Next).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Next match (no wrap)
search.select(t.screen_mut(), Select::Next).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
search.deinit(t.screen_mut());
}
#[test]
fn select_in_active_changes_contents_completely() {
let mut t = terminal(10, 5);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
search.select(t.screen_mut(), Select::Next).unwrap();
search.select(t.screen_mut(), Select::Next).unwrap();
// Initial selection is the first fizz
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Erase the screen, move our cursor to the top, and change contents.
feed(&mut t, b"\x1b[2J\x1b[H"); // Clear screen and move home
feed(&mut t, b"Fuzz\r\nFizz\r\nHello!");
search.reload_active(t.screen_mut()).unwrap();
// Our selection should move to the first
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 1),
(3, 1),
);
// Erase the screen, redraw with same contents.
feed(&mut t, b"\x1b[2J\x1b[H"); // Clear screen and move home
feed(&mut t, b"Fuzz\r\nFizz\r\nFizz");
search.reload_active(t.screen_mut()).unwrap();
// Our selection should not move to the first
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 1),
(3, 1),
);
search.deinit(t.screen_mut());
}
#[test]
fn select_into_history() {
let mut t = terminal_scrollback(10, 2, usize::MAX);
feed(&mut t, b"Fizz\r\n");
while total_pages(&t) < 3 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello.");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
// Get all matches
search.select(t.screen_mut(), Select::Next).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Erase the screen, redraw with same contents.
feed(&mut t, b"\x1b[2J\x1b[H"); // Clear screen and move home
feed(&mut t, b"yo yo");
search.reload_active(t.screen_mut()).unwrap();
// Our selection should not move since the history is still active.
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Create some new history by adding more lines.
feed(&mut t, b"\r\nfizz\r\nfizz\r\nfizz");
search.reload_active(t.screen_mut()).unwrap();
// Our selection should not move since the history is still not
// pruned.
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
search.deinit(t.screen_mut());
}
#[test]
fn select_prev() {
let mut t = terminal(10, 2);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
// Initially no selection
assert!(search.selected_match().is_none());
// Select prev (oldest first)
search.search_all(t.screen_mut()).unwrap();
search.select(t.screen_mut(), Select::Prev).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Prev match (towards newest)
search.select(t.screen_mut(), Select::Prev).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 2),
(3, 2),
);
// Prev match (no wrap, stays at newest)
search.select(t.screen_mut(), Select::Prev).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 2),
(3, 2),
);
search.deinit(t.screen_mut());
}
#[test]
fn select_prev_then_next() {
let mut t = terminal(10, 2);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
// Select next (newest first)
search.select(t.screen_mut(), Select::Next).unwrap();
expect_start(&t, &search.selected_match().unwrap(), Tag::Screen, (0, 2));
// Select next (older)
search.select(t.screen_mut(), Select::Next).unwrap();
expect_start(&t, &search.selected_match().unwrap(), Tag::Screen, (0, 0));
// Select prev (back to newer)
search.select(t.screen_mut(), Select::Prev).unwrap();
expect_start(&t, &search.selected_match().unwrap(), Tag::Screen, (0, 2));
search.deinit(t.screen_mut());
}
#[test]
fn select_prev_with_history() {
let mut t = terminal_scrollback(10, 2, usize::MAX);
feed(&mut t, b"Fizz\r\n");
while total_pages(&t) < 3 {
feed(&mut t, b"\r\n");
}
for _ in 0..t.screen().pages.rows {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"Fizz.");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
// Select prev (oldest first, should be in history)
search.select(t.screen_mut(), Select::Prev).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Screen,
(0, 0),
(3, 0),
);
// Select prev (towards newer, should move to active area)
search.select(t.screen_mut(), Select::Prev).unwrap();
expect(
&t,
&search.selected_match().unwrap(),
Tag::Active,
(0, 1),
(3, 1),
);
search.deinit(t.screen_mut());
}
#[test]
fn screen_search_no_scrollback_has_no_history() {
let mut t = terminal_scrollback(10, 2, 0);
// Alt screen has no scrollback
t.switch_screen(crate::screen_set::Key::Alternate).unwrap();
// This will probably stop working at some point and we'll have
// no way to test it using public APIs, but at the time of writing
// this test, CSI 22 J (scroll complete) pushes into scrollback
// with alt screen.
feed(&mut t, b"Fizz\r\n");
feed(&mut t, b"\x1b[22J");
feed(&mut t, b"hello.");
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
search.search_all(t.screen_mut()).unwrap();
assert_eq!(0, search.active_results.len());
assert_eq!(0, search.matches().len());
search.deinit(t.screen_mut());
}
}
@@ -0,0 +1,1008 @@
//! Searches page nodes via a sliding window. The sliding window maintains
//! the invariant that data isn't pruned until (1) we've searched it and
//! (2) we've accounted for overlaps across pages to fit the needle.
//!
//! The sliding window is first initialized empty. Pages are then appended
//! in the order to search them. The sliding window supports both a forward
//! and reverse order specified via `new`. The pages should be appended
//! in the correct order matching the search direction.
//!
//! All appends grow the window. The window is only pruned when a search
//! is done (positive or negative match) via `next()`.
//!
//! To avoid unnecessary memory growth, the recommended usage is to
//! call `next()` until it returns `None` and then `append` the next page
//! and repeat the process. This will always maintain the minimum
//! required memory to search for the needle.
//!
//! The caller is responsible for providing the pages and ensuring they're
//! in the proper order. The SlidingWindow itself doesn't own the pages, but
//! it will contain node ids to them in order to return selections. If any
//! pages become invalid, the caller should clear the sliding window and
//! start over.
use std::collections::VecDeque;
use crate::formatter::{Options, PageFormatter};
use crate::highlight::{Chunk, Flattened};
use crate::page::Page;
use crate::page_list::{Node, NodeId, PageList};
use crate::point::Coordinate;
/// The search direction. If the direction is forward then pages should
/// be appended in forward linked list order from the PageList. If the
/// direction is reverse then pages should be appended in reverse order.
///
/// This is important because in most cases, a reverse search is going
/// to be more desirable to search from the end of the active area
/// backwards so more recent data is found first. Supporting both is
/// trivial though and will let us do more complex optimizations in the
/// future (e.g. starting from the viewport and doing a forward/reverse
/// concurrently from that point).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Forward,
Reverse,
}
#[derive(Debug)]
struct Meta {
node: NodeId,
serial: u64,
/// Row count of the node at the time it was appended.
rows: u16,
cell_map: Vec<Coordinate>,
}
/// Where a match start was found: (end search start meta index and chars
/// consumed so far, if the end is in a later meta), start meta index, data
/// consumed before the start meta.
type StartFound = (Option<(usize, usize)>, usize, usize);
/// See the module docs.
#[derive(Debug)]
pub struct SlidingWindow {
/// Circular buffer of the encoded page text we search for the needle.
///
/// Deviation: Zig uses a `CircBuf` and searches its two slices plus an
/// overlap buffer; here we make the deque contiguous which is
/// equivalent for match results.
data: VecDeque<u8>,
/// Metadata about the pages we're searching, oldest first.
meta: VecDeque<Meta>,
/// Offset into data for our current state. This handles the
/// situation where our search moved through meta[0] but didn't
/// do enough to prune it.
data_offset: usize,
/// The needle we're searching for (reversed for reverse searches).
needle: Vec<u8>,
direction: Direction,
}
/// Encode a page with `PageFormatter` (`.emit = .plain, .unwrap = true`),
/// returning the text and the page coordinate of every byte.
pub fn encode_page(page: &Page) -> (Vec<u8>, Vec<Coordinate>) {
let mut opts = Options::plain();
opts.unwrap = true;
let mut map: Vec<Coordinate> = Vec::new();
let mut out: Vec<u8> = Vec::new();
let mut f = PageFormatter::new(page, &opts);
f.point_map = Some(&mut map);
f.format(&mut out).expect("writing to a Vec cannot fail");
(out, map)
}
/// ASCII case-insensitive substring search (`std.ascii.indexOfIgnoreCase`).
fn index_of_ignore_case(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.len() > haystack.len() {
return None;
}
if needle.is_empty() {
return Some(0);
}
haystack
.windows(needle.len())
.position(|w| w.eq_ignore_ascii_case(needle))
}
impl SlidingWindow {
/// The needle must be non-empty.
pub fn new(direction: Direction, needle_unowned: &[u8]) -> Self {
let mut needle = needle_unowned.to_vec();
if direction == Direction::Reverse {
needle.reverse();
}
Self {
data: VecDeque::new(),
meta: VecDeque::new(),
data_offset: 0,
needle,
direction,
}
}
/// The needle (reversed if this is a reverse search).
pub fn needle(&self) -> &[u8] {
&self.needle
}
pub fn direction(&self) -> Direction {
self.direction
}
/// Number of data bytes in the window.
pub fn data_len(&self) -> usize {
self.data.len()
}
/// Number of page metadata entries in the window.
pub fn meta_len(&self) -> usize {
self.meta.len()
}
/// Clear all data but retain allocated capacity.
pub fn clear_and_retain_capacity(&mut self) {
self.meta.clear();
self.data.clear();
self.data_offset = 0;
}
/// Search the window for the next occurrence of the needle. As
/// the window moves, the window will prune itself while maintaining
/// the invariant that the window is always big enough to contain
/// the needle.
///
/// This returns a flattened highlight on a match. The flattened
/// highlight can be used to render without a terminal lock because it
/// doesn't need to traverse the page nodes.
pub fn next(&mut self) -> Option<Flattened> {
// If we have less data then the needle then we can't possibly match
let data_len = self.data.len();
if data_len < self.needle.len() {
return None;
}
let found = {
let offset = self.data_offset;
let hay = &self.data.make_contiguous()[offset..];
index_of_ignore_case(hay, &self.needle)
};
if let Some(idx) = found {
return Some(self.highlight(idx, self.needle.len()));
}
// Special case 1-lengthed needles to delete the entire buffer.
if self.needle.len() == 1 {
self.clear_and_retain_capacity();
self.assert_integrity();
return None;
}
// No match. We keep `needle.len - 1` bytes available to
// handle the future overlap case.
'prune: {
let mut saved: usize = 0;
let mut found_idx: Option<usize> = None;
for i in (0..self.meta.len()).rev() {
let len = self.meta[i].cell_map.len();
let needed = self.needle.len() - 1 - saved;
if len >= needed {
// We save up to this meta. We set our data offset
// to exactly where it needs to be to continue searching.
self.data_offset = len - needed;
found_idx = Some(i);
break;
}
saved += len;
}
let Some(prune_count) = found_idx else {
// Never got the amount we needed so there is nothing to prune.
debug_assert!(saved < self.needle.len() - 1);
break 'prune;
};
if prune_count == 0 {
// This can happen if we need to save up to the first
// meta value to retain our window.
break 'prune;
}
// Delete all the metas up to but NOT including the meta we found.
let mut prune_data_len = 0;
for m in self.meta.drain(..prune_count) {
prune_data_len += m.cell_map.len();
}
self.data.drain(..prune_data_len);
}
// Our data offset now moves to needle.len - 1 from the end so
// that we can handle the overlap case.
self.data_offset = self.data.len() - self.needle.len() + 1;
self.assert_integrity();
None
}
/// Return a flattened highlight for the given start and length.
///
/// The start index is assumed to be relative to the offset. i.e.
/// index zero is actually at `self.data[self.data_offset]`. The
/// selection will account for the offset.
fn highlight(&mut self, start_offset: usize, len: usize) -> Flattened {
let start = start_offset + self.data_offset;
let end = start + len - 1;
debug_assert!(start < self.data.len());
debug_assert!(start + len <= self.data.len());
let mut chunks: Vec<Chunk> = Vec::new();
// Row counts of each chunk's node (for the reverse fixup).
let mut node_rows: Vec<u16> = Vec::new();
let mut result = Flattened::default();
// Go through the meta nodes to find our start.
let mut meta_consumed = 0usize;
let mut found: Option<StartFound> = None;
for (mi, meta) in self.meta.iter().enumerate() {
// Always increment our consumed count so that our index
// is right for the end search if we do it.
let prior_meta_consumed = meta_consumed;
meta_consumed += meta.cell_map.len();
// meta_i is the index we expect to find the match in the
// cell map within this meta if it contains it.
let meta_i = start - prior_meta_consumed;
// This meta doesn't contain the match.
if meta_i >= meta.cell_map.len() {
continue;
}
// Now we look for the end. In MOST cases it is the same as
// our starting chunk.
let end_i = end - prior_meta_consumed;
if end_i < meta.cell_map.len() {
let start_map = meta.cell_map[meta_i];
let end_map = meta.cell_map[end_i];
result.top_x = start_map.x;
result.bot_x = end_map.x;
chunks.push(Chunk {
node: meta.node,
serial: meta.serial,
start: start_map.y as u16,
end: (end_map.y + 1) as u16,
});
node_rows.push(meta.rows);
found = Some((None, mi, prior_meta_consumed));
} else {
// We found the meta that contains the start of the match
// only. Consume this entire node from our start offset.
let map = meta.cell_map[meta_i];
result.top_x = map.x;
chunks.push(Chunk {
node: meta.node,
serial: meta.serial,
start: map.y as u16,
end: meta.rows,
});
node_rows.push(meta.rows);
found = Some((Some((mi + 1, meta_consumed)), mi, prior_meta_consumed));
}
break;
}
// Precondition that the start index is within the data buffer.
let (br, prune_meta, prune_data) = found.expect("start index within data buffer");
// Search for our end.
if let Some((from, mut consumed)) = br {
let mut done = false;
for meta in self.meta.iter().skip(from) {
let meta_i = end - consumed;
if meta_i >= meta.cell_map.len() {
// This meta doesn't contain the match. We still add it
// to our results because we want the full flattened list.
chunks.push(Chunk {
node: meta.node,
serial: meta.serial,
start: 0,
end: meta.rows,
});
node_rows.push(meta.rows);
consumed += meta.cell_map.len();
continue;
}
// We found it
let map = meta.cell_map[meta_i];
result.bot_x = map.x;
chunks.push(Chunk {
node: meta.node,
serial: meta.serial,
start: 0,
end: (map.y + 1) as u16,
});
node_rows.push(meta.rows);
done = true;
break;
}
// Precondition that the end index is within the data buffer.
assert!(done, "end index within data buffer");
}
// Our offset into the current meta block is the start index
// minus the amount of data fully consumed. We then add one
// to move one past the match so we don't repeat it.
self.data_offset = start - prune_data + 1;
// If we went beyond our initial meta node we can prune.
if prune_meta > 0 {
self.meta.drain(..prune_meta);
// Delete all the data up to our current index.
debug_assert!(prune_data > 0);
self.data.drain(..prune_data);
}
if self.direction == Direction::Reverse {
if chunks.len() > 1 {
// Reverse all our chunks. This should be pretty obvious why.
chunks.reverse();
node_rows.reverse();
// Normally with forward traversal with multiple pages, the
// suffix of the first page and the prefix of the last page
// are used. For a reverse traversal this is inverted, so we
// fix up start/end to match the forward logic. Middle pages
// always use the full page.
let n = chunks.len();
chunks[0].start = chunks[0].end - 1;
chunks[0].end = node_rows[0];
chunks[n - 1].end = chunks[n - 1].start + 1;
chunks[n - 1].start = 0;
} else {
// For a single chunk, the y values are in reverse order
// (start is the screen-end, end is the screen-start).
// Swap them to get proper top-to-bottom order.
let start_y = chunks[0].start;
chunks[0].start = chunks[0].end - 1;
chunks[0].end = start_y + 1;
}
// X values also need to be reversed since the top/bottom
// are swapped for the nodes.
std::mem::swap(&mut result.top_x, &mut result.bot_x);
}
result.chunks = chunks;
result
}
/// Add a new node to the sliding window. This will always grow
/// the sliding window; data isn't pruned until it is consumed
/// via a search (via `next()`).
///
/// Returns the number of bytes of content added to the sliding window.
/// The total bytes will be larger since this omits metadata, but it is
/// an accurate measure of the text content size added.
pub fn append(&mut self, list: &PageList, id: NodeId) -> usize {
match list.node(id) {
Some(node) => self.append_node(id, node),
None => 0,
}
}
fn append_node(&mut self, id: NodeId, node: &Node) -> usize {
let (mut written, mut cell_map) = encode_page(&node.data);
debug_assert_eq!(cell_map.len(), written.len());
// If the node we're adding isn't soft-wrapped, we add the
// trailing newline.
let rows = node.data.size.rows;
let last_wrap = rows > 0
&& node
.data
.get_row(usize::from(rows) - 1)
.is_some_and(|r| r.wrap());
if !last_wrap {
written.push(b'\n');
let last = cell_map
.last()
.copied()
.unwrap_or(Coordinate { x: 0, y: 0 });
cell_map.push(last);
}
// If our written data is empty, then there is nothing to
// add to our data set.
if written.is_empty() {
self.assert_integrity();
return 0;
}
// If we're doing a reverse search then we need to reverse all
// our encodings.
if self.direction == Direction::Reverse {
written.reverse();
cell_map.reverse();
}
let n = written.len();
self.data.extend(written);
self.meta.push_back(Meta {
node: id,
serial: node.serial,
rows,
cell_map,
});
self.assert_integrity();
n
}
/// Only for tests!
#[cfg(test)]
fn test_change_needle(&mut self, new: &[u8]) {
assert_eq!(new.len(), self.needle.len());
self.needle = new.to_vec();
}
fn assert_integrity(&self) {
if !cfg!(debug_assertions) {
return;
}
// Integrity check: verify our data matches our metadata exactly.
let data_len: usize = self.meta.iter().map(|m| m.cell_map.len()).sum();
assert_eq!(data_len, self.data.len());
// Integrity check: verify our data offset is within bounds.
assert!(self.data.is_empty() || self.data_offset < self.data.len());
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::highlight::Flattened;
use crate::point::{Point, Tag};
use crate::screen::Screen;
fn check(list: &PageList, h: &Flattened, tag: Tag, s: (u16, u32), e: (u16, u32)) {
let sel = h.untracked();
assert_eq!(list.point_from_pin(tag, sel.start), Some(pt(tag, s.0, s.1)));
assert_eq!(list.point_from_pin(tag, sel.end), Some(pt(tag, e.0, e.1)));
}
fn check_active(list: &PageList, h: &Flattened, s: (u16, u32), e: (u16, u32)) {
check(list, h, Tag::Active, s, e);
}
/// Fill the first page, ending with `tail` ("boo!" etc.) in the last
/// row of the first page (preceded by cols-4 x's).
fn fill_first_page(s: &mut Screen, tail: &str) -> u16 {
let first_page_rows = s.pages.node(first(&s.pages)).unwrap().data.capacity.rows;
for _ in 0..first_page_rows - 1 {
s.test_write_string("\n").unwrap();
}
for _ in 0..s.pages.cols - 4 {
s.test_write_string("x").unwrap();
}
s.test_write_string(tail).unwrap();
first_page_rows
}
#[test]
fn sliding_window_empty_on_init() {
let w = SlidingWindow::new(Direction::Forward, b"boo!");
assert_eq!(0, w.data_len());
assert_eq!(0, w.meta_len());
}
#[test]
fn sliding_window_single_append() {
let mut w = SlidingWindow::new(Direction::Forward, b"boo!");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 0), (10, 0));
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (22, 0));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_case_insensitive_ascii() {
let mut w = SlidingWindow::new(Direction::Forward, b"Boo!");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 0), (10, 0));
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (22, 0));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_single_char() {
let mut w = SlidingWindow::new(Direction::Forward, b"b");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 0), (7, 0));
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (19, 0));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_no_match() {
let mut w = SlidingWindow::new(Direction::Forward, b"nope!");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert!(w.next().is_none());
// Should still keep the page
assert_eq!(1, w.meta_len());
}
#[test]
fn sliding_window_two_pages() {
let mut w = SlidingWindow::new(Direction::Forward, b"boo!");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
let h = w.next().unwrap();
check_active(&s.pages, &h, (76, 22), (79, 22));
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 23), (10, 23));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_two_pages_single_char() {
let mut w = SlidingWindow::new(Direction::Forward, b"b");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
let h = w.next().unwrap();
check_active(&s.pages, &h, (76, 22), (76, 22));
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 23), (7, 23));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_two_pages_match_across_boundary() {
let mut w = SlidingWindow::new(Direction::Forward, b"hello, world");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "hell");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("o, world!").unwrap();
assert_ne!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
let h = w.next().unwrap();
check_active(&s.pages, &h, (76, 22), (7, 23));
assert!(w.next().is_none());
assert!(w.next().is_none());
// We shouldn't prune because we don't have enough space
assert_eq!(2, w.meta_len());
}
#[test]
fn sliding_window_two_pages_no_match_across_boundary_with_newline() {
let mut w = SlidingWindow::new(Direction::Forward, b"hello, world");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "hell");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\no, world!").unwrap();
assert_ne!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
assert!(w.next().is_none());
assert!(w.next().is_none());
// We shouldn't prune because we don't have enough space
assert_eq!(2, w.meta_len());
}
#[test]
fn sliding_window_two_pages_no_match_across_boundary_with_newline_reverse() {
let mut w = SlidingWindow::new(Direction::Reverse, b"hello, world");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "hell");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\no, world!").unwrap();
assert_ne!(s.pages.first, s.pages.last);
let node = first(&s.pages);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_two_pages_no_match_prunes_first_page() {
let mut w = SlidingWindow::new(Direction::Forward, b"nope!");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
assert!(w.next().is_none());
assert!(w.next().is_none());
// We should've pruned our page because the second page
// has enough text to contain our needle.
assert_eq!(1, w.meta_len());
}
#[test]
fn sliding_window_two_pages_no_match_keeps_both_pages() {
let mut s = screen(80, 24, 1000);
let first_page_rows = fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
// Imaginary needle for search. Doesn't match!
let needle = vec![b'x'; usize::from(first_page_rows) * usize::from(s.pages.cols)];
let mut w = SlidingWindow::new(Direction::Forward, &needle);
let node = first(&s.pages);
w.append(&s.pages, node);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
assert!(w.next().is_none());
assert!(w.next().is_none());
// No pruning because both pages are needed to fit needle.
assert_eq!(2, w.meta_len());
}
// The Zig test asserts the circular buffer wraps; the VecDeque-backed
// window has no equivalent slice layout so only the behaviour is checked.
#[test]
fn sliding_window_single_append_across_circular_buffer_boundary() {
let mut w = SlidingWindow::new(Direction::Forward, b"abc");
let mut s = screen(80, 24, 0);
s.test_write_string("XXXXXXXXXXXXXXXXXXXboo!XXXXX").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
w.append(&s.pages, node);
// Search non-match, prunes page
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
// Change the needle, just needs to be the same length (not a real API)
w.test_change_needle(b"boo");
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (21, 0));
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_match_on_boundary() {
let mut w = SlidingWindow::new(Direction::Forward, b"abcd");
let mut s = screen(80, 24, 0);
s.test_write_string("o!XXXXXXXXXXXXXXXXXXXbo").unwrap();
// We need to surgically modify the last row to be soft-wrapped
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
{
let n = s.pages.node_mut(node).unwrap();
let y = usize::from(n.data.size.rows) - 1;
n.data.get_row_mut(y).unwrap().set_wrap(true);
}
w.append(&s.pages, node);
w.append(&s.pages, node);
// Search non-match, prunes page
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
w.test_change_needle(b"boo!");
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (21, 0), (1, 0));
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"boo!");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (22, 0));
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 0), (10, 0));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_no_match_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"nope!");
let mut s = screen(80, 24, 0);
s.test_write_string("hello. boo! hello. boo!").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
}
#[test]
fn sliding_window_two_pages_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"boo!");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let node = first(&s.pages);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (7, 23), (10, 23));
let h = w.next().unwrap();
check_active(&s.pages, &h, (76, 22), (79, 22));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_two_pages_match_across_boundary_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"hello, world");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "hell");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("o, world!").unwrap();
assert_ne!(s.pages.first, s.pages.last);
let node = first(&s.pages);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (76, 22), (7, 23));
assert!(w.next().is_none());
assert!(w.next().is_none());
// In reverse mode, the last appended meta (first original page) is large
// enough to contain needle.len - 1 bytes, so pruning occurs
assert_eq!(1, w.meta_len());
}
#[test]
fn sliding_window_two_pages_no_match_prunes_first_page_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"nope!");
let mut s = screen(80, 24, 1000);
fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let node = first(&s.pages);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
}
#[test]
fn sliding_window_two_pages_no_match_keeps_both_pages_reversed() {
let mut s = screen(80, 24, 1000);
let first_page_rows = fill_first_page(&mut s, "boo!");
assert_eq!(s.pages.first, s.pages.last);
s.test_write_string("\n").unwrap();
assert_ne!(s.pages.first, s.pages.last);
s.test_write_string("hello. boo!").unwrap();
let needle = vec![b'x'; usize::from(first_page_rows) * usize::from(s.pages.cols)];
let mut w = SlidingWindow::new(Direction::Reverse, &needle);
let node = first(&s.pages);
let n2 = next(&s.pages, node);
w.append(&s.pages, n2);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert!(w.next().is_none());
// No pruning because both pages are needed to fit needle.
assert_eq!(2, w.meta_len());
}
#[test]
fn sliding_window_single_append_across_circular_buffer_boundary_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"abc");
let mut s = screen(80, 24, 0);
s.test_write_string("XXXXXXXXXXXXXXXXXXXboo!XXXXX").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
w.append(&s.pages, node);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
// test_change_needle doesn't reverse, so pass reversed needle
w.test_change_needle(b"oob");
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (19, 0), (21, 0));
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_match_on_boundary_reversed() {
let mut w = SlidingWindow::new(Direction::Reverse, b"abcd");
let mut s = screen(80, 24, 0);
s.test_write_string("o!XXXXXXXXXXXXXXXXXXXbo").unwrap();
assert_eq!(s.pages.first, s.pages.last);
let node = first(&s.pages);
{
let n = s.pages.node_mut(node).unwrap();
let y = usize::from(n.data.size.rows) - 1;
n.data.get_row_mut(y).unwrap().set_wrap(true);
}
w.append(&s.pages, node);
w.append(&s.pages, node);
assert!(w.next().is_none());
assert_eq!(1, w.meta_len());
w.test_change_needle(b"!oob");
w.append(&s.pages, node);
let h = w.next().unwrap();
check_active(&s.pages, &h, (21, 0), (1, 0));
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_soft_wrapped() {
let mut w = SlidingWindow::new(Direction::Forward, b"boo!");
let mut t = terminal(4, 5);
feed(&mut t, b"A\r\nxxboo!\r\nC");
let screen = t.screen();
assert_eq!(screen.pages.first, screen.pages.last);
let node = first(&screen.pages);
w.append(&screen.pages, node);
let h = w.next().unwrap();
check_active(&screen.pages, &h, (2, 1), (1, 2));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
#[test]
fn sliding_window_single_append_reversed_soft_wrapped() {
let mut w = SlidingWindow::new(Direction::Reverse, b"boo!");
let mut t = terminal(4, 5);
feed(&mut t, b"A\r\nxxboo!\r\nC");
let screen = t.screen();
assert_eq!(screen.pages.first, screen.pages.last);
let node = first(&screen.pages);
w.append(&screen.pages, node);
let h = w.next().unwrap();
check_active(&screen.pages, &h, (2, 1), (1, 2));
assert!(w.next().is_none());
assert!(w.next().is_none());
}
// This tests a real bug that occurred where a whitespace-only page
// that encodes to zero bytes would crash.
#[test]
fn sliding_window_append_whitespace_only_node() {
let mut w = SlidingWindow::new(Direction::Forward, b"x");
let mut s = screen(80, 24, 0);
// By setting the empty page to wrap we get a zero-byte page.
let node = first(&s.pages);
{
let n = s.pages.node_mut(node).unwrap();
let y = usize::from(n.data.size.rows) - 1;
n.data.get_row_mut(y).unwrap().set_wrap(true);
}
assert_eq!(s.pages.first, s.pages.last);
w.append(&s.pages, node);
// No matches expected
assert!(w.next().is_none());
}
#[allow(dead_code)]
fn _unused(_: Point) {}
}
@@ -0,0 +1,50 @@
//! Shared helpers for search tests.
use crate::page_list::{NodeId, PageList};
use crate::point::{Coordinate, Point, Tag};
use crate::screen::{Options, Screen};
use crate::terminal::{self, Terminal};
pub fn screen(cols: u16, rows: u16, max_scrollback: usize) -> Screen {
Screen::new(Options::new(cols, rows, max_scrollback)).unwrap()
}
pub fn terminal(cols: u16, rows: u16) -> Terminal {
Terminal::new(terminal::Options::new(cols, rows)).unwrap()
}
pub fn terminal_scrollback(cols: u16, rows: u16, max_scrollback: usize) -> Terminal {
let mut o = terminal::Options::new(cols, rows);
o.max_scrollback = max_scrollback;
Terminal::new(o).unwrap()
}
pub fn feed(t: &mut Terminal, bytes: &[u8]) {
let mut s = t.vt_stream();
s.next_slice(bytes).unwrap();
}
/// Point constructor with a tag.
pub fn pt(tag: Tag, x: u16, y: u32) -> Point {
Point::new(tag, Coordinate { x, y })
}
pub fn active(x: u16, y: u32) -> Point {
pt(Tag::Active, x, y)
}
pub fn first(list: &PageList) -> NodeId {
list.first.unwrap()
}
pub fn last(list: &PageList) -> NodeId {
list.last.unwrap()
}
pub fn next(list: &PageList, id: NodeId) -> NodeId {
list.node(id).unwrap().next.unwrap()
}
pub fn screen_pt(x: u16, y: u32) -> Point {
pt(Tag::Screen, x, y)
}
@@ -0,0 +1,867 @@
//! Search thread that handles searching a terminal for a string match.
//! This is expected to run on a dedicated thread to try to prevent too much
//! overhead to other terminal read/write operations.
//!
//! The current architecture of search does acquire global locks for accessing
//! terminal data, so there's still added contention, but we do our best to
//! minimize this by trading off memory usage (copying data to minimize lock
//! time).
//!
//! Deviation: Zig drives this from a libxev loop (async wakeup/stop handles,
//! a refresh timer). Here the loop is built from `crossbeam-channel`: a
//! bounded mailbox, a control channel for wakeup/stop, and a deadline
//! based refresh timer. The terminal and its mutex are combined into an
//! `Arc<Mutex<Terminal>>`.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use crossbeam_channel::{Receiver, Sender, TrySendError};
use super::screen::{ScreenSearch, Select, TickError};
use super::viewport::ViewportSearch;
use crate::highlight::{Flattened, Untracked};
use crate::page_list::{Chunk, Direction as ListDirection};
use crate::point::{Coordinate, Point};
use crate::screen_set::Key as ScreenKey;
use crate::terminal::Terminal;
// TODO: Some stuff that could be improved:
// - pause the refresh timer when the terminal isn't focused
// - we probably want to know our progress through the search
// for viewport matches so we can show n/total UI.
// - notifications should be coalesced to avoid spamming a massive
// amount of events if the terminal is changing rapidly.
/// The interval at which we refresh the terminal state to check if
/// there are any changes that require us to re-search. This should be
/// balanced to be fast enough to be responsive but not so fast that
/// we hold the terminal lock too often.
pub const REFRESH_INTERVAL: Duration = Duration::from_millis(24); // 40 FPS
/// Capacity of the mailbox (Zig: `BlockingQueue(Message, 64)`).
pub const MAILBOX_CAPACITY: usize = 64;
/// Callback for events from the search thread.
pub type EventCallback = Arc<dyn Fn(Event) + Send + Sync>;
/// The options used to initialize the thread.
#[derive(Clone)]
pub struct Options {
/// The terminal data to search. The mutex must be held while
/// reading/writing the terminal.
pub terminal: Arc<Mutex<Terminal>>,
/// The callback for events from the search thread. This can be `None`
/// if you don't want to receive events, which could be useful for a
/// one-time search (although, odd, you should use our search
/// structures directly then).
pub event_cb: Option<EventCallback>,
}
/// The messages that can be sent to the thread.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// Change the search term. If no prior search term is given this
/// will start a search. If an existing search term is given this will
/// stop the prior search and start a new one.
ChangeNeedle(Vec<u8>),
/// Select a search result.
Select(Select),
}
/// Events that can be emitted from the search thread. The caller
/// chooses to handle these as they see fit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
/// Search is quitting. The search thread is exiting.
Quit,
/// Search is complete for the given needle on all screens.
Complete,
/// Total matches on the current active screen have changed.
TotalMatches(usize),
/// Selected match changed.
SelectedMatch(Option<SelectedMatch>),
/// Matches in the viewport have changed.
ViewportMatches(Vec<Flattened>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedMatch {
pub idx: usize,
pub highlight: Flattened,
}
#[derive(Debug)]
enum Ctl {
Wakeup,
Stop,
}
/// A cloneable handle used to talk to the thread from other threads
/// (Zig: `thread.mailbox`, `thread.wakeup`, `thread.stop`).
#[derive(Clone)]
pub struct Handle {
mailbox: Sender<Message>,
ctl: Sender<Ctl>,
}
impl Handle {
/// Push a message to the mailbox, blocking while it is full (Zig:
/// `mailbox.push(msg, .forever)`), and wake the thread. Returns false
/// if the thread is gone.
pub fn send(&self, msg: Message) -> bool {
if self.mailbox.send(msg).is_err() {
return false;
}
self.wakeup();
true
}
/// Push a message without blocking. Returns false if the mailbox is
/// full or the thread is gone.
pub fn try_send(&self, msg: Message) -> bool {
match self.mailbox.try_send(msg) {
Ok(()) => {
self.wakeup();
true
}
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => false,
}
}
/// Wake up the thread so it drains its mailbox.
pub fn wakeup(&self) {
let _ = self.ctl.try_send(Ctl::Wakeup);
}
/// Stop the thread on the next loop iteration.
pub fn stop(&self) {
let _ = self.ctl.send(Ctl::Stop);
}
}
fn lock(m: &Mutex<Terminal>) -> MutexGuard<'_, Terminal> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The search thread state.
pub struct Thread {
opts: Options,
mailbox_tx: Sender<Message>,
mailbox_rx: Receiver<Message>,
ctl_tx: Sender<Ctl>,
ctl_rx: Receiver<Ctl>,
/// Search state. Starts as `None` and is populated when a search is
/// started (a needle is given).
search: Option<Search>,
stopped: bool,
next_refresh: Instant,
}
impl Thread {
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to run `thread_main` on a dedicated thread (or
/// use `spawn`).
pub fn new(opts: Options) -> Self {
let (mailbox_tx, mailbox_rx) = crossbeam_channel::bounded(MAILBOX_CAPACITY);
let (ctl_tx, ctl_rx) = crossbeam_channel::unbounded();
Self {
opts,
mailbox_tx,
mailbox_rx,
ctl_tx,
ctl_rx,
search: None,
stopped: false,
next_refresh: Instant::now() + REFRESH_INTERVAL,
}
}
/// A handle to message/stop this thread.
pub fn handle(&self) -> Handle {
Handle {
mailbox: self.mailbox_tx.clone(),
ctl: self.ctl_tx.clone(),
}
}
/// Create the thread state and run it on a new OS thread named
/// "search". Returns the handle and the join handle.
pub fn spawn(opts: Options) -> std::io::Result<(Handle, std::thread::JoinHandle<()>)> {
let thread = Thread::new(opts);
let handle = thread.handle();
let join = std::thread::Builder::new()
.name("search".into())
.spawn(move || thread.thread_main())?;
Ok((handle, join))
}
/// The main entrypoint for the thread.
pub fn thread_main(mut self) {
log::debug!("starting search thread");
// Drain the mailbox immediately (Zig sends an initial wakeup).
self.drain_mailbox();
self.next_refresh = Instant::now() + REFRESH_INTERVAL;
// Unlike some of our other threads, we interleave search work
// with our loop so that we can try to make forward search progress
// while also listening for messages.
loop {
// If our loop is canceled then we drain our messages and quit.
if self.stopped {
while let Ok(message) = self.mailbox_rx.try_recv() {
log::debug!("mailbox message ignored during shutdown={message:?}");
}
break;
}
let Some(s) = self.search.as_mut() else {
// If we're not actively searching, we can block the loop
// until it does some work.
self.run_once();
continue;
};
// If we have an active search, we always send any pending
// notifications. Even if the search is complete, there may be
// notifications to send.
if let Some(cb) = &self.opts.event_cb {
s.notify(&self.opts.terminal, cb);
}
if s.is_complete() {
// If our search is complete, there's no more work to do, we
// can block until we have an event.
self.run_once();
continue;
}
// Tick the search. This will trigger any event callbacks, lock
// for data loading, etc.
match s.tick() {
// We're complete now when we were not before. Notify!
Tick::Complete => {}
// Forward progress was made.
Tick::Progress => {}
// All searches are blocked. Let's grab the lock and feed data.
Tick::Blocked => {
let mut t = lock(&self.opts.terminal);
s.feed(&mut t);
}
}
// We have an active search, so we only want to process messages
// we have but otherwise return immediately so we can continue the
// search.
self.run_no_wait();
}
log::debug!("starting search thread shutdown");
if let Some(mut s) = self.search.take() {
let mut t = lock(&self.opts.terminal);
s.deinit(&mut t);
}
// Send the quit message
if let Some(cb) = &self.opts.event_cb {
cb(Event::Quit);
}
log::debug!("search thread exited");
}
/// Block until something happens (message, control event, or the
/// refresh timer firing) and process it.
fn run_once(&mut self) {
let timeout = self.next_refresh.saturating_duration_since(Instant::now());
crossbeam_channel::select! {
recv(self.mailbox_rx) -> msg => {
if let Ok(msg) = msg {
self.handle_message(msg);
}
}
recv(self.ctl_rx) -> ctl => {
match ctl {
Ok(Ctl::Stop) | Err(_) => self.stopped = true,
Ok(Ctl::Wakeup) => {}
}
}
default(timeout) => {}
}
self.run_no_wait();
}
/// Process anything already pending without blocking.
fn run_no_wait(&mut self) {
while let Ok(ctl) = self.ctl_rx.try_recv() {
match ctl {
Ctl::Stop => self.stopped = true,
Ctl::Wakeup => {}
}
}
if self.stopped {
return;
}
self.drain_mailbox();
if Instant::now() >= self.next_refresh {
self.refresh();
}
}
/// Drain the mailbox.
fn drain_mailbox(&mut self) {
while let Ok(message) = self.mailbox_rx.try_recv() {
self.handle_message(message);
}
}
fn handle_message(&mut self, message: Message) {
log::debug!("mailbox message={message:?}");
match message {
Message::ChangeNeedle(v) => self.change_needle(&v),
Message::Select(v) => self.select(v),
}
}
/// The refresh timer callback.
fn refresh(&mut self) {
// Run our feed if we have a search active.
if let Some(s) = self.search.as_mut() {
let mut t = lock(&self.opts.terminal);
s.feed(&mut t);
}
self.next_refresh = Instant::now() + REFRESH_INTERVAL;
}
fn select(&mut self, sel: Select) {
let Some(s) = self.search.as_mut() else {
return;
};
let key = s.last_screen.key;
let Some(screen_search) = s.screens[key_index(key)].as_mut() else {
return;
};
let mut t = lock(&self.opts.terminal);
let Some(screen) = t.screens.get_mut(key) else {
return;
};
// Make the selection. Ignore the result because we don't
// care if the selection didn't change.
if let Err(e) = screen_search.select(screen, sel) {
log::warn!("error selecting search match err={e:?}");
return;
}
// Grab our match if we have one. If we don't have a selection
// then we do nothing.
let Some(flattened) = screen_search.selected_match() else {
return;
};
// No matter what we reset our selected match cache. This will
// trigger a callback which will trigger the renderer to wake up
// so it can be notified the screen scrolled.
s.last_screen.selected = None;
// Grab the viewport. Viewports and selections are usually small
// so this check isn't very expensive, despite appearing O(N^2),
// both Ns are usually equal to 1.
let overlaps = {
let it = screen.pages.page_iterator(
ListDirection::RightDown,
Point::Viewport(Coordinate { x: 0, y: 0 }),
None,
);
let mut overlaps = false;
'outer: for chunk in it {
for hl_chunk in &flattened.chunks {
if chunk.overlaps(Chunk {
node: hl_chunk.node,
start: hl_chunk.start,
end: hl_chunk.end,
}) {
overlaps = true;
break 'outer;
}
}
}
overlaps
};
if overlaps {
return;
}
screen.scroll(crate::screen::Scroll::Pin(flattened.start_pin()));
}
/// Change the search term to the given value.
fn change_needle(&mut self, needle: &[u8]) {
log::debug!(
"changing search needle to '{}'",
String::from_utf8_lossy(needle)
);
// Stop the previous search
if let Some(s) = self.search.as_mut() {
// If our search is unchanged, do nothing.
if s.viewport.needle().eq_ignore_ascii_case(needle) {
return;
}
{
let mut t = lock(&self.opts.terminal);
s.deinit(&mut t);
}
self.search = None;
// When the search changes then we need to emit that it stopped.
if let Some(cb) = &self.opts.event_cb {
cb(Event::TotalMatches(0));
cb(Event::SelectedMatch(None));
cb(Event::ViewportMatches(Vec::new()));
}
}
// No needle means stop the search.
if needle.is_empty() {
return;
}
// Setup our search state.
let mut search = Search::new(needle);
// We need to grab the terminal lock and do an initial feed.
{
let mut t = lock(&self.opts.terminal);
search.feed(&mut t);
}
self.search = Some(search);
}
}
fn key_index(key: ScreenKey) -> usize {
match key {
ScreenKey::Primary => 0,
ScreenKey::Alternate => 1,
}
}
const KEYS: [ScreenKey; 2] = [ScreenKey::Primary, ScreenKey::Alternate];
/// Search state.
struct Search {
/// Active viewport search for the active screen.
viewport: ViewportSearch,
/// The searchers for all the screens (indexed by screen key order).
screens: [Option<ScreenSearch>; 2],
/// All state related to screen switches, collected so that when
/// we switch screens it makes everything related stale, too.
last_screen: ScreenState,
/// True if we sent the complete notification yet.
last_complete: bool,
/// The last viewport matches we found.
stale_viewport_matches: bool,
}
struct ScreenState {
/// Last active screen key
key: ScreenKey,
/// Last notified total matches count
total: Option<usize>,
/// Last notified selected match index
selected: Option<ScreenStateSelected>,
}
struct ScreenStateSelected {
idx: usize,
highlight: Untracked,
}
impl ScreenState {
fn new(key: ScreenKey) -> Self {
Self {
key,
total: None,
selected: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tick {
/// All searches are complete.
Complete,
/// Progress was made on at least one screen.
Progress,
/// All incomplete searches are blocked on feed.
Blocked,
}
impl Search {
fn new(needle: &[u8]) -> Self {
let mut vp = ViewportSearch::new(needle);
// We use dirty tracking for active area changes. Start with it
// dirty so the first change is re-searched.
vp.active_dirty = Some(true);
Self {
viewport: vp,
screens: [None, None],
last_screen: ScreenState::new(ScreenKey::Primary),
last_complete: false,
stale_viewport_matches: true,
}
}
/// Release tracked pins. Requires the terminal (and therefore its lock).
fn deinit(&mut self, t: &mut Terminal) {
for key in KEYS {
if let Some(mut ss) = self.screens[key_index(key)].take()
&& let Some(screen) = t.screens.get_mut(key)
&& ss.is_for_screen(screen)
{
ss.deinit(screen);
}
}
}
/// Returns true if all searches on all screens are complete.
fn is_complete(&self) -> bool {
self.screens.iter().flatten().all(|s| s.state.is_complete())
}
/// Tick the search forward as much as possible without acquiring
/// the big lock. Returns the overall tick progress.
fn tick(&mut self) -> Tick {
let mut result = Tick::Complete;
for (i, ss) in self.screens.iter_mut().enumerate() {
let Some(ss) = ss else { continue };
match ss.tick() {
Ok(()) => result = Tick::Progress,
// Ignore... nothing we can do.
Err(TickError::Page(e)) => {
log::warn!("error ticking screen search key={:?} err={e:?}", KEYS[i]);
}
// Ignore, good for us. State remains whatever it is.
Err(TickError::SearchComplete) => {}
// Ignore, too, progressed
Err(TickError::FeedRequired) => {
// If we think we're complete, we're not because we're
// blocked now (nothing made progress). If we made some
// progress, we remain in progress since blocked means
// no progress at all.
if result == Tick::Complete {
result = Tick::Blocked;
}
}
}
}
result
}
/// Update any state that requires the terminal lock, such as
/// feeding additional data to the searches or updating the active screen.
fn feed(&mut self, t: &mut Terminal) {
// Update our active screen
if t.screens.active_key != self.last_screen.key {
// The default values will force resets of a bunch of other
// state too to force recalculations and notifications.
self.last_screen = ScreenState::new(t.screens.active_key);
}
// Reconcile our screens with the terminal screens. Remove
// searchers for screens that no longer exist and add searchers
// for screens that do exist but we don't have yet.
for key in KEYS {
let i = key_index(key);
let remove = match (&self.screens[i], t.screens.get(key)) {
(None, _) => false,
// If the screen doesn't exist at all, remove it.
(Some(_), None) => true,
// If the screen pointer changed, remove it, the screen
// was totally reinitialized.
(Some(ss), Some(actual)) => !ss.is_for_screen(actual),
};
if remove {
// The old screen (and its tracked pins) is gone, so there
// is nothing to untrack.
self.screens[i] = None;
}
}
for key in KEYS {
let i = key_index(key);
if self.screens[i].is_some() {
continue;
}
let Some(screen) = t.screens.get_mut(key) else {
continue;
};
match ScreenSearch::new(screen, self.viewport.needle()) {
Ok(ss) => self.screens[i] = Some(ss),
Err(e) => {
// OOM is probably going to sink the entire ship but
// we can just ignore it and wait on the next
// reconciliation to try again.
log::warn!("error initializing screen search for key={key:?} err={e:?}");
}
}
}
// See the `search_viewport_dirty` flag on the terminal to know
// what exactly this is for. But, if this is set, we know the renderer
// found the viewport/active area dirty, so we should mark it as
// dirty in our viewport searcher so it forces a re-search.
if t.flags.search_viewport_dirty {
t.flags.search_viewport_dirty = false;
// Mark our viewport dirty so it researches the active
self.viewport.active_dirty = Some(true);
// Reload our active area for our active screen
let key = t.screens.active_key;
if let Some(ss) = self.screens[key_index(key)].as_mut()
&& let Err(e) = ss.reload_active(t.screens.active_mut())
{
log::warn!("error reloading active area for screen key={key:?} err={e:?}");
}
}
// Check our viewport for changes.
if self.viewport.update(&t.screens.active().pages) {
self.stale_viewport_matches = true;
}
// Feed data
for key in KEYS {
let i = key_index(key);
let Some(ss) = self.screens[i].as_mut() else {
continue;
};
if ss.state.needs_feed()
&& let Some(screen) = t.screens.get_mut(key)
&& let Err(e) = ss.feed(screen)
{
log::warn!("error feeding screen search key={key:?} err={e:?}");
}
}
}
/// Notify about any changes to the search state.
///
/// This doesn't require any locking as it only reads internal state
/// (the terminal mutex is only passed through for API symmetry and is
/// not locked).
fn notify(&mut self, _terminal: &Arc<Mutex<Terminal>>, cb: &EventCallback) {
let Some(screen_search) = self.screens[key_index(self.last_screen.key)].as_ref() else {
return;
};
// Check our total match data
let total = screen_search.matches_len();
if Some(total) != self.last_screen.total {
log::debug!("notifying total matches={total}");
self.last_screen.total = Some(total);
cb(Event::TotalMatches(total));
}
// Check our viewport matches. If they're stale, we do the
// viewport search now. We do this as part of notify and not
// tick because the viewport search is very fast and doesn't
// require ticked progress or feeds.
if self.stale_viewport_matches {
// We always make stale as false. Even if we fail below
// we require a re-feed to re-search the viewport. The feed
// process will make it stale again.
self.stale_viewport_matches = false;
let mut results: Vec<Flattened> = Vec::new();
while let Some(hl) = self.viewport.next() {
results.push(hl);
}
log::debug!("notifying viewport matches len={}", results.len());
cb(Event::ViewportMatches(results));
}
// Check our last selected match data.
let flattened = screen_search
.selected
.as_ref()
.and_then(|m| screen_search.selected_match().map(|f| (m.idx, f)));
if let Some((idx, flattened)) = flattened {
let untracked = flattened.untracked();
if let Some(prev) = &self.last_screen.selected
&& prev.idx == idx
&& prev.highlight.eql(untracked)
{
// Same selection, don't update it.
} else {
// New selection, notify!
self.last_screen.selected = Some(ScreenStateSelected {
idx,
highlight: untracked,
});
log::debug!("notifying selection updated idx={idx}");
cb(Event::SelectedMatch(Some(SelectedMatch {
idx,
highlight: flattened,
})));
}
} else if self.last_screen.selected.is_some() {
log::debug!("notifying selection cleared");
self.last_screen.selected = None;
cb(Event::SelectedMatch(None));
}
// Send our complete notification if we just completed.
if !self.last_complete && self.is_complete() {
log::debug!("notifying search complete");
self.last_complete = true;
cb(Event::Complete);
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::point::Tag;
use std::sync::mpsc;
#[derive(Default)]
struct TestUserData {
total: usize,
selected: Option<SelectedMatch>,
viewport: Vec<Flattened>,
}
#[test]
fn search_thread_basic() {
let mut t = terminal(20, 2);
feed(&mut t, b"Hello, world");
let terminal = Arc::new(Mutex::new(t));
let ud = Arc::new(Mutex::new(TestUserData::default()));
let (done_tx, done_rx) = mpsc::channel::<()>();
let done_tx = Mutex::new(done_tx);
let ud_cb = Arc::clone(&ud);
let cb: EventCallback = Arc::new(move |event| {
let mut ud = ud_cb.lock().unwrap();
match event {
Event::Quit => {}
Event::Complete => {
let _ = done_tx.lock().unwrap().send(());
}
Event::TotalMatches(v) => ud.total = v,
Event::SelectedMatch(v) => ud.selected = v,
Event::ViewportMatches(v) => ud.viewport = v,
}
});
let (handle, join) = Thread::spawn(Options {
terminal: Arc::clone(&terminal),
event_cb: Some(cb),
})
.unwrap();
// Start our search
assert!(handle.send(Message::ChangeNeedle(b"world".to_vec())));
// Wait for completion
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
// Stop the thread
handle.stop();
join.join().unwrap();
// 1 total matches
let ud = ud.lock().unwrap();
assert_eq!(1, ud.total);
assert_eq!(1, ud.viewport.len());
let sel = ud.viewport[0].untracked();
let t = terminal.lock().unwrap();
assert_eq!(
t.screen().pages.point_from_pin(Tag::Screen, sel.start),
Some(screen_pt(7, 0))
);
assert_eq!(
t.screen().pages.point_from_pin(Tag::Screen, sel.end),
Some(screen_pt(11, 0))
);
}
#[test]
fn search_thread_select_and_state() {
// Extra coverage: selection round trip through the mailbox.
let mut t = terminal(20, 2);
feed(&mut t, b"foo\r\nbar foo\r\nfoo");
let terminal = Arc::new(Mutex::new(t));
let ud = Arc::new(Mutex::new(TestUserData::default()));
let (done_tx, done_rx) = mpsc::channel::<()>();
let done_tx = Mutex::new(done_tx);
let ud_cb = Arc::clone(&ud);
let cb: EventCallback = Arc::new(move |event| {
let mut ud = ud_cb.lock().unwrap();
match event {
Event::Complete => {
let _ = done_tx.lock().unwrap().send(());
}
Event::TotalMatches(v) => ud.total = v,
Event::SelectedMatch(v) => {
ud.selected = v;
let _ = done_tx.lock().unwrap().send(());
}
_ => {}
}
});
let (handle, join) = Thread::spawn(Options {
terminal: Arc::clone(&terminal),
event_cb: Some(cb),
})
.unwrap();
handle.send(Message::ChangeNeedle(b"FOO".to_vec()));
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
handle.send(Message::Select(Select::Next));
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
// Clearing the needle stops the search and resets state.
handle.send(Message::ChangeNeedle(Vec::new()));
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
handle.stop();
join.join().unwrap();
let ud = ud.lock().unwrap();
assert_eq!(0, ud.total);
assert!(ud.selected.is_none());
}
}
@@ -0,0 +1,337 @@
//! Searches for a substring within the viewport of a PageList.
//!
//! This contains logic to efficiently detect when the viewport changes
//! and only re-search when necessary.
//!
//! The specialization for "viewport" is because the viewport is the
//! only part of the search where the user can actively see the results,
//! usually. In that case, it is more efficient to re-search only the
//! viewport rather than store all the results for the entire screen.
//!
//! Note that this searches all the pages that viewport covers, so
//! this can include extra matches outside the viewport if the data
//! lives in the same page.
use super::sliding_window::{Direction, SlidingWindow};
use crate::highlight::Flattened;
use crate::page_list::{Direction as ListDirection, NodeId, PageList};
use crate::point::{Coordinate, Point, Tag};
#[derive(Debug)]
pub struct ViewportSearch {
window: SlidingWindow,
fingerprint: Option<Fingerprint>,
/// If this is `None`, then active dirty tracking is disabled and if the
/// viewport overlaps the active area we always re-search. If this is
/// `Some`, then we only re-search if the active area is dirty. Dirty
/// marking is up to the caller.
pub active_dirty: Option<bool>,
}
/// Viewport fingerprint so we can detect when the viewport moves.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Fingerprint {
/// The nodes that make up the viewport. We flatten this to a single
/// list because all that is safe is comparing the node ids.
nodes: Vec<NodeId>,
}
impl Fingerprint {
fn new(pages: &PageList) -> Self {
let mut nodes = Vec::new();
// Get our viewport area. Bottom right of a viewport can never
// fail.
let it = pages.page_iterator(
ListDirection::RightDown,
Point::Viewport(Coordinate { x: 0, y: 0 }),
None,
);
for chunk in it {
nodes.push(chunk.node);
}
Self { nodes }
}
}
impl ViewportSearch {
pub fn new(needle_unowned: &[u8]) -> Self {
// We just do a forward search since the viewport is usually
// pretty small so search results are instant anyways. This avoids
// a small amount of work to reverse things.
Self {
window: SlidingWindow::new(Direction::Forward, needle_unowned),
fingerprint: None,
active_dirty: None,
}
}
/// Reset our fingerprint and results so that the next update will
/// always re-search.
pub fn reset(&mut self) {
self.fingerprint = None;
self.window.clear_and_retain_capacity();
}
/// The needle that this search is using.
pub fn needle(&self) -> &[u8] {
assert_eq!(self.window.direction(), Direction::Forward);
self.window.needle()
}
/// Update the sliding window to reflect the current viewport. This
/// will do nothing if the viewport hasn't changed since the last
/// search.
///
/// The PageList must be safe to read throughout the lifetime of this
/// function.
///
/// Returns true if the viewport changed and a re-search is needed.
/// Returns false if the viewport is unchanged.
pub fn update(&mut self, list: &PageList) -> bool {
// See if our viewport changed
let fingerprint = Fingerprint::new(list);
if let Some(old) = &self.fingerprint {
if *old == fingerprint {
// Determine if we need to check if we overlap the active
// area. If we have dirty tracking on we also set it to
// false here.
let check_active = match self.active_dirty {
None => true,
Some(false) => false,
Some(true) => {
self.active_dirty = Some(false);
true
}
};
let mut overlaps = false;
if check_active {
// If our fingerprint contains the active area, then we always
// re-search since the active area is mutable.
let active_tl = list.get_top_left(Tag::Active).expect("active tl");
let active_br = list.get_bottom_right(Tag::Active).expect("active br");
// If our viewport contains the start or end of the active area,
// we are in the active area. We purposely do this first
// because our viewport is always larger than the active area.
for node in &old.nodes {
if *node == active_tl.node || *node == active_br.node {
overlaps = true;
break;
}
}
}
if !overlaps {
// No change
return false;
}
}
self.fingerprint = None;
}
debug_assert!(self.fingerprint.is_none());
// If our active area was set as dirty, we always unset it here
// because we're re-searching now.
if let Some(v) = &mut self.active_dirty {
*v = false;
}
// Clear our previous sliding window
self.window.clear_and_retain_capacity();
// Add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
let first = fingerprint.nodes[0];
let mut node_ = list.node(first).and_then(|n| n.prev);
let mut added: usize = 0;
while let Some(id) = node_ {
let Some(node) = list.node(id) else { break };
// If the last row of this node isn't wrapped we can't overlap.
let row = node
.data
.get_row(usize::from(node.data.size.rows) - 1)
.expect("last row");
if !row.wrap() {
break;
}
// We could be more accurate here and count bytes since the
// last wrap but its complicated and unlikely multiple pages
// wrap so this should be fine.
added += self.window.append(list, id);
if added >= self.window.needle().len() - 1 {
break;
}
node_ = node.prev;
}
// We can use our fingerprint nodes to initialize our sliding
// window, since we already traversed the viewport once.
for node in &fingerprint.nodes {
self.window.append(list, *node);
}
// Add any trailing overlap as well.
'trailing: {
let end = fingerprint.nodes[fingerprint.nodes.len() - 1];
let Some(end_node) = list.node(end) else {
break 'trailing;
};
let wraps = end_node
.data
.get_row(usize::from(end_node.data.size.rows) - 1)
.is_some_and(|r| r.wrap());
if !wraps {
break 'trailing;
}
node_ = end_node.next;
added = 0;
while let Some(id) = node_ {
let Some(node) = list.node(id) else { break };
added += self.window.append(list, id);
if added >= self.window.needle().len() - 1 {
break;
}
// If this row doesn't wrap, then we can quit
let row = node
.data
.get_row(usize::from(node.data.size.rows) - 1)
.expect("last row");
if !row.wrap() {
break;
}
node_ = node.next;
}
}
self.fingerprint = Some(fingerprint);
true
}
/// Find the next match for the needle in the viewport. This returns
/// `None` when there are no more matches.
pub fn next(&mut self) -> Option<Flattened> {
self.window.next()
}
}
#[cfg(test)]
mod tests {
use super::super::test_util::*;
use super::*;
use crate::terminal::ScrollViewport;
fn check(list: &PageList, h: &Flattened, tag: Tag, s: (u16, u32), e: (u16, u32)) {
let sel = h.untracked();
assert_eq!(list.point_from_pin(tag, sel.start), Some(pt(tag, s.0, s.1)));
assert_eq!(list.point_from_pin(tag, sel.end), Some(pt(tag, e.0, e.1)));
}
#[test]
fn simple_search() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ViewportSearch::new(b"Fizz");
assert!(search.update(&t.screen().pages));
// Viewport contains active so update should always re-search.
assert!(search.update(&t.screen().pages));
let h = search.next().unwrap();
check(&t.screen().pages, &h, Tag::Active, (0, 0), (3, 0));
let h = search.next().unwrap();
check(&t.screen().pages, &h, Tag::Active, (0, 2), (3, 2));
assert!(search.next().is_none());
}
#[test]
fn clear_screen_and_search() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ViewportSearch::new(b"Fizz");
assert!(search.update(&t.screen().pages));
feed(&mut t, b"\x1b[2J"); // Clear screen
feed(&mut t, b"\x1b[H"); // Move cursor home
feed(&mut t, b"Buzz\r\nFizz\r\nBuzz");
assert!(search.update(&t.screen().pages));
let h = search.next().unwrap();
check(&t.screen().pages, &h, Tag::Active, (0, 1), (3, 1));
assert!(search.next().is_none());
}
#[test]
fn clear_screen_and_search_dirty_tracking() {
let mut t = terminal(10, 10);
feed(&mut t, b"Fizz\r\nBuzz\r\nFizz\r\nBang");
let mut search = ViewportSearch::new(b"Fizz");
// Turn on dirty tracking
search.active_dirty = Some(false);
// Should update since we've never searched before
assert!(search.update(&t.screen().pages));
// Should not update since nothing changed
assert!(!search.update(&t.screen().pages));
feed(&mut t, b"\x1b[2J"); // Clear screen
feed(&mut t, b"\x1b[H"); // Move cursor home
feed(&mut t, b"Buzz\r\nFizz\r\nBuzz");
// Should still not update since active area isn't dirty
assert!(!search.update(&t.screen().pages));
// Mark
search.active_dirty = Some(true);
assert!(search.update(&t.screen().pages));
let h = search.next().unwrap();
check(&t.screen().pages, &h, Tag::Active, (0, 1), (3, 1));
assert!(search.next().is_none());
}
#[test]
fn history_search_no_active_area() {
let mut t = terminal(10, 2);
// Fill up first page
let first_page_rows = {
let l = &t.screen().pages;
l.node(first(l)).unwrap().data.capacity.rows
};
feed(&mut t, b"Fizz\r\n");
for _ in 1..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Create second page
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"Buzz\r\nFizz");
t.scroll_viewport(ScrollViewport::Top);
let mut search = ViewportSearch::new(b"Fizz");
assert!(search.update(&t.screen().pages));
let h = search.next().unwrap();
check(&t.screen().pages, &h, Tag::Screen, (0, 0), (3, 0));
assert!(search.next().is_none());
// Viewport doesn't contain active
assert!(!search.update(&t.screen().pages));
assert!(search.next().is_none());
}
}