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:
1 parent
59d1069692
commit
8e9be42da8
14 files changed
+6784
No files matched your search
@@ -12,6 +12,7 @@ pub mod ref_counted_set;
|
|||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod screen;
|
pub mod screen;
|
||||||
pub mod screen_set;
|
pub mod screen_set;
|
||||||
|
pub mod search;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod size;
|
pub mod size;
|
||||||
pub mod stream_readonly;
|
pub mod stream_readonly;
|
||||||
@@ -19,3 +20,4 @@ pub mod string_map;
|
|||||||
pub mod style;
|
pub mod style;
|
||||||
pub mod tabstops;
|
pub mod tabstops;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
pub mod tmux;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
//! tmux layout string parsing (port of `tmux/layout.zig`).
|
||||||
|
|
||||||
|
/// A tmux layout.
|
||||||
|
///
|
||||||
|
/// A tree structure; children are owned by their parent.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Layout {
|
||||||
|
/// Width of the node.
|
||||||
|
pub width: usize,
|
||||||
|
/// Height of the node.
|
||||||
|
pub height: usize,
|
||||||
|
/// X offset from the top-left corner of the window.
|
||||||
|
pub x: usize,
|
||||||
|
/// Y offset from the top-left corner of the window.
|
||||||
|
pub y: usize,
|
||||||
|
/// The content of this node, either a pane (leaf) or more nodes
|
||||||
|
/// (split) horizontally or vertically.
|
||||||
|
pub content: Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of a layout node.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Content {
|
||||||
|
/// Leaf pane with its ID.
|
||||||
|
Pane(usize),
|
||||||
|
/// Left-right split.
|
||||||
|
Horizontal(Vec<Layout>),
|
||||||
|
/// Top-bottom split.
|
||||||
|
Vertical(Vec<Layout>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from layout parsing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
/// The layout string is malformed.
|
||||||
|
#[error("syntax error")]
|
||||||
|
SyntaxError,
|
||||||
|
/// The checksum prefix did not match.
|
||||||
|
#[error("checksum mismatch")]
|
||||||
|
ChecksumMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_usize(s: &[u8]) -> Result<usize, ParseError> {
|
||||||
|
// std.fmt.parseInt(usize, s, 10): optional '+', digits only.
|
||||||
|
let s = s.strip_prefix(b"+").unwrap_or(s);
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err(ParseError::SyntaxError);
|
||||||
|
}
|
||||||
|
let mut v: usize = 0;
|
||||||
|
for &c in s {
|
||||||
|
if !c.is_ascii_digit() {
|
||||||
|
return Err(ParseError::SyntaxError);
|
||||||
|
}
|
||||||
|
v = v
|
||||||
|
.checked_mul(10)
|
||||||
|
.and_then(|v| v.checked_add(usize::from(c - b'0')))
|
||||||
|
.ok_or(ParseError::SyntaxError)?;
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_of_any(s: &[u8], set: &[u8]) -> Option<usize> {
|
||||||
|
s.iter().position(|c| set.contains(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
/// Parse a layout string that includes a 4-character checksum prefix
|
||||||
|
/// (`XXXX,layout_string`).
|
||||||
|
pub fn parse_with_checksum(str: &[u8]) -> Result<Layout, ParseError> {
|
||||||
|
if str.len() < 5 || str[4] != b',' {
|
||||||
|
return Err(ParseError::SyntaxError);
|
||||||
|
}
|
||||||
|
let checksum = Checksum::calculate(&str[5..]);
|
||||||
|
if !str.starts_with(&checksum.as_string()) {
|
||||||
|
return Err(ParseError::ChecksumMismatch);
|
||||||
|
}
|
||||||
|
Self::parse(&str[5..])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a layout string into a Layout structure.
|
||||||
|
///
|
||||||
|
/// - `WxH,X,Y,ID` leaf pane
|
||||||
|
/// - `WxH,X,Y{...}` horizontal split
|
||||||
|
/// - `WxH,X,Y[...]` vertical split
|
||||||
|
pub fn parse(str: &[u8]) -> Result<Layout, ParseError> {
|
||||||
|
let mut offset = 0;
|
||||||
|
let root = Self::parse_next(str, &mut offset)?;
|
||||||
|
if offset != str.len() {
|
||||||
|
return Err(ParseError::SyntaxError);
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_next(str: &[u8], offset: &mut usize) -> Result<Layout, ParseError> {
|
||||||
|
let err = ParseError::SyntaxError;
|
||||||
|
let idx = str[*offset..].iter().position(|&c| c == b'x').ok_or(err)?;
|
||||||
|
let width = parse_usize(&str[*offset..*offset + idx]);
|
||||||
|
*offset += idx + 1;
|
||||||
|
let width = width?;
|
||||||
|
|
||||||
|
let idx = str[*offset..].iter().position(|&c| c == b',').ok_or(err)?;
|
||||||
|
let height = parse_usize(&str[*offset..*offset + idx]);
|
||||||
|
*offset += idx + 1;
|
||||||
|
let height = height?;
|
||||||
|
|
||||||
|
let idx = str[*offset..].iter().position(|&c| c == b',').ok_or(err)?;
|
||||||
|
let x = parse_usize(&str[*offset..*offset + idx]);
|
||||||
|
*offset += idx + 1;
|
||||||
|
let x = x?;
|
||||||
|
|
||||||
|
let idx = index_of_any(&str[*offset..], b",{[").ok_or(err)?;
|
||||||
|
let y = parse_usize(&str[*offset..*offset + idx]);
|
||||||
|
*offset += idx;
|
||||||
|
let y = y?;
|
||||||
|
|
||||||
|
let content = match str[*offset] {
|
||||||
|
b',' => {
|
||||||
|
*offset += 1;
|
||||||
|
let idx = index_of_any(&str[*offset..], b",}]").unwrap_or(str.len() - *offset);
|
||||||
|
let pane_id = parse_usize(&str[*offset..*offset + idx]);
|
||||||
|
*offset += idx;
|
||||||
|
Content::Pane(pane_id?)
|
||||||
|
}
|
||||||
|
opening @ (b'{' | b'[') => {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
*offset += 1;
|
||||||
|
loop {
|
||||||
|
nodes.push(Self::parse_next(str, offset)?);
|
||||||
|
if *offset >= str.len() {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
if str[*offset] == b',' {
|
||||||
|
*offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let close = if opening == b'{' { b'}' } else { b']' };
|
||||||
|
if str[*offset] != close {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
*offset += 1;
|
||||||
|
break if opening == b'{' {
|
||||||
|
Content::Horizontal(nodes)
|
||||||
|
} else {
|
||||||
|
Content::Vertical(nodes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("index_of_any guarantees one of the delimiters"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Layout {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checksum of a tmux layout string.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Checksum(pub u16);
|
||||||
|
|
||||||
|
impl Checksum {
|
||||||
|
/// Calculate the checksum of a tmux layout string: rotate right by 1
|
||||||
|
/// bit (with wraparound) and add each byte.
|
||||||
|
pub fn calculate(str: &[u8]) -> Checksum {
|
||||||
|
let mut result: u16 = 0;
|
||||||
|
for &c in str {
|
||||||
|
result = result.rotate_right(1);
|
||||||
|
result = result.wrapping_add(u16::from(c));
|
||||||
|
}
|
||||||
|
Checksum(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4-character zero-padded lowercase hex string.
|
||||||
|
pub fn as_string(self) -> [u8; 4] {
|
||||||
|
let charset = b"0123456789abcdef";
|
||||||
|
let v = self.0;
|
||||||
|
[
|
||||||
|
charset[usize::from((v >> 12) & 0xf)],
|
||||||
|
charset[usize::from((v >> 8) & 0xf)],
|
||||||
|
charset[usize::from((v >> 4) & 0xf)],
|
||||||
|
charset[usize::from(v & 0xf)],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn p(s: &str) -> Result<Layout, ParseError> {
|
||||||
|
Layout::parse(s.as_bytes())
|
||||||
|
}
|
||||||
|
fn pane(l: &Layout) -> usize {
|
||||||
|
match l.content {
|
||||||
|
Content::Pane(p) => p,
|
||||||
|
_ => panic!("not a pane"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn horiz(l: &Layout) -> &[Layout] {
|
||||||
|
match &l.content {
|
||||||
|
Content::Horizontal(c) => c,
|
||||||
|
_ => panic!("not horizontal"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn vert(l: &Layout) -> &[Layout] {
|
||||||
|
match &l.content {
|
||||||
|
Content::Vertical(c) => c,
|
||||||
|
_ => panic!("not vertical"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn dims(l: &Layout) -> (usize, usize, usize, usize) {
|
||||||
|
(l.width, l.height, l.x, l.y)
|
||||||
|
}
|
||||||
|
fn cs(s: &str) -> String {
|
||||||
|
String::from_utf8(Checksum::calculate(s.as_bytes()).as_string().to_vec()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_single_pane() {
|
||||||
|
let l = p("80x24,0,0,42").unwrap();
|
||||||
|
assert_eq!(dims(&l), (80, 24, 0, 0));
|
||||||
|
assert_eq!(pane(&l), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_pane_with_offset() {
|
||||||
|
let l = p("40x12,10,5,7").unwrap();
|
||||||
|
assert_eq!(dims(&l), (40, 12, 10, 5));
|
||||||
|
assert_eq!(pane(&l), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_pane_large_values() {
|
||||||
|
let l = p("1920x1080,100,200,999").unwrap();
|
||||||
|
assert_eq!(dims(&l), (1920, 1080, 100, 200));
|
||||||
|
assert_eq!(pane(&l), 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_split_two_panes() {
|
||||||
|
let l = p("80x24,0,0{40x24,0,0,1,40x24,40,0,2}").unwrap();
|
||||||
|
assert_eq!(dims(&l), (80, 24, 0, 0));
|
||||||
|
let c = horiz(&l);
|
||||||
|
assert_eq!(c.len(), 2);
|
||||||
|
assert_eq!(dims(&c[0]), (40, 24, 0, 0));
|
||||||
|
assert_eq!(pane(&c[0]), 1);
|
||||||
|
assert_eq!(dims(&c[1]), (40, 24, 40, 0));
|
||||||
|
assert_eq!(pane(&c[1]), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertical_split_two_panes() {
|
||||||
|
let l = p("80x24,0,0[80x12,0,0,1,80x12,0,12,2]").unwrap();
|
||||||
|
assert_eq!(dims(&l), (80, 24, 0, 0));
|
||||||
|
let c = vert(&l);
|
||||||
|
assert_eq!(c.len(), 2);
|
||||||
|
assert_eq!(dims(&c[0]), (80, 12, 0, 0));
|
||||||
|
assert_eq!(pane(&c[0]), 1);
|
||||||
|
assert_eq!(dims(&c[1]), (80, 12, 0, 12));
|
||||||
|
assert_eq!(pane(&c[1]), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_split_three_panes() {
|
||||||
|
let l = p("120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}").unwrap();
|
||||||
|
assert_eq!((l.width, l.height), (120, 24));
|
||||||
|
let c = horiz(&l);
|
||||||
|
assert_eq!(c.len(), 3);
|
||||||
|
assert_eq!(pane(&c[0]), 1);
|
||||||
|
assert_eq!(pane(&c[1]), 2);
|
||||||
|
assert_eq!(pane(&c[2]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_horizontal_in_vertical() {
|
||||||
|
let l = p("80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]").unwrap();
|
||||||
|
let v = vert(&l);
|
||||||
|
assert_eq!(v.len(), 2);
|
||||||
|
assert_eq!(pane(&v[0]), 1);
|
||||||
|
let h = horiz(&v[1]);
|
||||||
|
assert_eq!(h.len(), 2);
|
||||||
|
assert_eq!(pane(&h[0]), 2);
|
||||||
|
assert_eq!(pane(&h[1]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_vertical_in_horizontal() {
|
||||||
|
let l = p("80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}").unwrap();
|
||||||
|
let h = horiz(&l);
|
||||||
|
assert_eq!(h.len(), 2);
|
||||||
|
assert_eq!(pane(&h[0]), 1);
|
||||||
|
let v = vert(&h[1]);
|
||||||
|
assert_eq!(v.len(), 2);
|
||||||
|
assert_eq!(pane(&v[0]), 2);
|
||||||
|
assert_eq!(pane(&v[1]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deeply_nested_layout() {
|
||||||
|
let l = p("80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}").unwrap();
|
||||||
|
let h = horiz(&l);
|
||||||
|
assert_eq!(h.len(), 2);
|
||||||
|
let v = vert(&h[0]);
|
||||||
|
assert_eq!(v.len(), 2);
|
||||||
|
assert_eq!(pane(&v[0]), 1);
|
||||||
|
assert_eq!(pane(&v[1]), 2);
|
||||||
|
assert_eq!(pane(&h[1]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syntax(s: &str) {
|
||||||
|
assert_eq!(p(s), Err(ParseError::SyntaxError), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_empty_string() {
|
||||||
|
syntax("");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_missing_width() {
|
||||||
|
syntax("x24,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_missing_height() {
|
||||||
|
syntax("80x,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_missing_x() {
|
||||||
|
syntax("80x24,,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_missing_y() {
|
||||||
|
syntax("80x24,0,,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_missing_pane_id() {
|
||||||
|
syntax("80x24,0,0,");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_non_numeric_width() {
|
||||||
|
syntax("abcx24,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_non_numeric_pane_id() {
|
||||||
|
syntax("80x24,0,0,abc");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_unclosed_horizontal_bracket() {
|
||||||
|
syntax("80x24,0,0{40x24,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_unclosed_vertical_bracket() {
|
||||||
|
syntax("80x24,0,0[40x24,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_mismatched_brackets() {
|
||||||
|
syntax("80x24,0,0{40x24,0,0,1]");
|
||||||
|
syntax("80x24,0,0[40x24,0,0,1}");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_trailing_data() {
|
||||||
|
syntax("80x24,0,0,1extra");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_no_x_separator() {
|
||||||
|
syntax("8024,0,0,1");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_no_content_delimiter() {
|
||||||
|
syntax("80x24,0,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_with_checksum_valid() {
|
||||||
|
let l = Layout::parse_with_checksum(b"f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}").unwrap();
|
||||||
|
assert_eq!((l.width, l.height), (80, 24));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parse_with_checksum_mismatch() {
|
||||||
|
assert_eq!(
|
||||||
|
Layout::parse_with_checksum(b"0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"),
|
||||||
|
Err(ParseError::ChecksumMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parse_with_checksum_too_short() {
|
||||||
|
assert_eq!(
|
||||||
|
Layout::parse_with_checksum(b"bb62"),
|
||||||
|
Err(ParseError::SyntaxError)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Layout::parse_with_checksum(b""),
|
||||||
|
Err(ParseError::SyntaxError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parse_with_checksum_missing_comma() {
|
||||||
|
assert_eq!(
|
||||||
|
Layout::parse_with_checksum(b"bb62x159x48,0,0"),
|
||||||
|
Err(ParseError::SyntaxError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checksum_empty_string() {
|
||||||
|
assert_eq!(Checksum::calculate(b"").0, 0);
|
||||||
|
assert_eq!(cs(""), "0000");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_single_character() {
|
||||||
|
assert_eq!(Checksum::calculate(b"A").0, 65);
|
||||||
|
assert_eq!(cs("A"), "0041");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_two_characters() {
|
||||||
|
assert_eq!(Checksum::calculate(b"AB").0, 32866);
|
||||||
|
assert_eq!(cs("AB"), "8062");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_simple_layout() {
|
||||||
|
assert_eq!(cs("80x24,0,0,42"), "d962");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_horizontal_split_layout() {
|
||||||
|
assert_eq!(cs("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"), "f8f9");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_as_string_zero_padding() {
|
||||||
|
assert_eq!(&Checksum(0x000f).as_string(), b"000f");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_as_string_all_digits() {
|
||||||
|
assert_eq!(&Checksum(0x1234).as_string(), b"1234");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_as_string_with_letters() {
|
||||||
|
assert_eq!(&Checksum(0xabcd).as_string(), b"abcd");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_as_string_max_value() {
|
||||||
|
assert_eq!(&Checksum(0xffff).as_string(), b"ffff");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_wraparound() {
|
||||||
|
let c = Checksum::calculate(&[0xff; 8]);
|
||||||
|
assert_eq!(&c.as_string(), b"03fc");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_deterministic() {
|
||||||
|
let s = b"159x48,0,0{79x48,0,0,79x48,80,0}";
|
||||||
|
assert_eq!(Checksum::calculate(s), Checksum::calculate(s));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_different_inputs_different_outputs() {
|
||||||
|
assert_ne!(
|
||||||
|
Checksum::calculate(b"80x24,0,0,1"),
|
||||||
|
Checksum::calculate(b"80x24,0,0,2")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checksum_known_tmux_layout_bb62() {
|
||||||
|
assert_eq!(cs("159x48,0,0{79x48,0,0,79x48,80,0}"), "bb62");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Types and functions related to tmux protocols.
|
||||||
|
|
||||||
|
pub mod layout;
|
||||||
|
pub mod output;
|
||||||
|
pub mod viewer;
|
||||||
|
|
||||||
|
pub use ghostty_vt::tmux::control;
|
||||||
|
pub use ghostty_vt::tmux::control::{Notification as ControlNotification, Parser as ControlParser};
|
||||||
|
pub use layout::Layout;
|
||||||
|
pub use viewer::Viewer;
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
//! tmux format-string variables and output parsing (port of `tmux/output.zig`).
|
||||||
|
//!
|
||||||
|
//! The Zig version builds a struct type at comptime from a variable list. In
|
||||||
|
//! Rust a [`FormatValues`] holds the parsed values in the same order, with
|
||||||
|
//! typed accessors.
|
||||||
|
#![allow(
|
||||||
|
clippy::assert_is_empty,
|
||||||
|
clippy::assigning_clones,
|
||||||
|
clippy::collapsible_if,
|
||||||
|
clippy::enum_glob_use,
|
||||||
|
clippy::manual_assert_eq,
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::single_match_else,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::unnecessary_wraps
|
||||||
|
)]
|
||||||
|
|
||||||
|
/// Errors from [`parse_format_struct`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
/// Fewer entries than variables.
|
||||||
|
#[error("missing entry")]
|
||||||
|
MissingEntry,
|
||||||
|
/// More entries than variables.
|
||||||
|
#[error("extra entry")]
|
||||||
|
ExtraEntry,
|
||||||
|
/// A value failed to parse.
|
||||||
|
#[error("format error")]
|
||||||
|
FormatError,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from [`Variable::parse`] (mirrors the Zig `parseInt` error set plus
|
||||||
|
/// `FormatError`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum VariableError {
|
||||||
|
/// Missing/incorrect id prefix.
|
||||||
|
#[error("format error")]
|
||||||
|
FormatError,
|
||||||
|
/// Non-digit character (or empty).
|
||||||
|
#[error("invalid character")]
|
||||||
|
InvalidCharacter,
|
||||||
|
/// Value doesn't fit in `usize` (or is negative).
|
||||||
|
#[error("overflow")]
|
||||||
|
Overflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed variable value.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Value {
|
||||||
|
/// Boolean flag (`"1"` is true).
|
||||||
|
Bool(bool),
|
||||||
|
/// Unsigned integer / id.
|
||||||
|
Usize(usize),
|
||||||
|
/// Raw string.
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equivalent of `std.fmt.parseInt(usize, s, 10)`.
|
||||||
|
fn parse_int(s: &str) -> Result<usize, VariableError> {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let (neg, digits) = match b.first() {
|
||||||
|
Some(b'-') => (true, &b[1..]),
|
||||||
|
Some(b'+') => (false, &b[1..]),
|
||||||
|
_ => (false, b),
|
||||||
|
};
|
||||||
|
if digits.is_empty() {
|
||||||
|
return Err(VariableError::InvalidCharacter);
|
||||||
|
}
|
||||||
|
let mut v: usize = 0;
|
||||||
|
let mut overflow = false;
|
||||||
|
for &c in digits {
|
||||||
|
if !c.is_ascii_digit() {
|
||||||
|
return Err(VariableError::InvalidCharacter);
|
||||||
|
}
|
||||||
|
match v
|
||||||
|
.checked_mul(10)
|
||||||
|
.and_then(|v| v.checked_add(usize::from(c - b'0')))
|
||||||
|
{
|
||||||
|
Some(n) => v = n,
|
||||||
|
None => overflow = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Negative values (other than zero) overflow an unsigned type.
|
||||||
|
if overflow || (neg && v != 0) {
|
||||||
|
return Err(VariableError::Overflow);
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_prefixed(prefix: u8, value: &str) -> Result<usize, VariableError> {
|
||||||
|
let b = value.as_bytes();
|
||||||
|
if b.len() >= 2 && b[0] == prefix {
|
||||||
|
parse_int(&value[1..])
|
||||||
|
} else {
|
||||||
|
Err(VariableError::FormatError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Possible variables in a tmux format string that we support.
|
||||||
|
///
|
||||||
|
/// Tmux supports a large number of variables, but we only implement
|
||||||
|
/// a subset of them here that are relevant to the use case of implementing
|
||||||
|
/// control mode for terminal emulators.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Variable {
|
||||||
|
/// 1 if pane is in alternate screen.
|
||||||
|
AlternateOn,
|
||||||
|
/// Saved cursor X in alternate screen.
|
||||||
|
AlternateSavedX,
|
||||||
|
/// Saved cursor Y in alternate screen.
|
||||||
|
AlternateSavedY,
|
||||||
|
/// 1 if bracketed paste mode is enabled.
|
||||||
|
BracketedPaste,
|
||||||
|
/// 1 if the cursor is blinking.
|
||||||
|
CursorBlinking,
|
||||||
|
/// Cursor colour in pane. Possible formats:
|
||||||
|
/// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`,
|
||||||
|
/// `cyan`, `white`, `default`, `terminal`, or bright variants.
|
||||||
|
/// - 256 colors: `colour<N>` where N is 0-255 (e.g., `colour100`).
|
||||||
|
/// - RGB hex: `#RRGGBB` (e.g., `#ff0000`).
|
||||||
|
/// - Empty string if unset.
|
||||||
|
CursorColour,
|
||||||
|
/// Pane cursor flag.
|
||||||
|
CursorFlag,
|
||||||
|
/// Cursor shape in pane. Possible values: `block`, `underline`, `bar`,
|
||||||
|
/// or `default`.
|
||||||
|
CursorShape,
|
||||||
|
/// Cursor X position in pane.
|
||||||
|
CursorX,
|
||||||
|
/// Cursor Y position in pane.
|
||||||
|
CursorY,
|
||||||
|
/// 1 if focus reporting is enabled.
|
||||||
|
FocusFlag,
|
||||||
|
/// Pane insert flag.
|
||||||
|
InsertFlag,
|
||||||
|
/// Pane keypad cursor flag.
|
||||||
|
KeypadCursorFlag,
|
||||||
|
/// Pane keypad flag.
|
||||||
|
KeypadFlag,
|
||||||
|
/// Pane mouse all flag.
|
||||||
|
MouseAllFlag,
|
||||||
|
/// Pane mouse any flag.
|
||||||
|
MouseAnyFlag,
|
||||||
|
/// Pane mouse button flag.
|
||||||
|
MouseButtonFlag,
|
||||||
|
/// Pane mouse SGR flag.
|
||||||
|
MouseSgrFlag,
|
||||||
|
/// Pane mouse standard flag.
|
||||||
|
MouseStandardFlag,
|
||||||
|
/// Pane mouse UTF-8 flag.
|
||||||
|
MouseUtf8Flag,
|
||||||
|
/// Pane origin flag.
|
||||||
|
OriginFlag,
|
||||||
|
/// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`).
|
||||||
|
PaneId,
|
||||||
|
/// Pane tab positions as a comma-separated list of 0-indexed column
|
||||||
|
/// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set.
|
||||||
|
PaneTabs,
|
||||||
|
/// Bottom of scroll region in pane.
|
||||||
|
ScrollRegionLower,
|
||||||
|
/// Top of scroll region in pane.
|
||||||
|
ScrollRegionUpper,
|
||||||
|
/// Unique session ID prefixed with `$` (e.g., `$0`, `$42`).
|
||||||
|
SessionId,
|
||||||
|
/// Server version (e.g., `3.5a`).
|
||||||
|
Version,
|
||||||
|
/// Unique window ID prefixed with `@` (e.g., `@0`, `@42`).
|
||||||
|
WindowId,
|
||||||
|
/// Width of window.
|
||||||
|
WindowWidth,
|
||||||
|
/// Height of window.
|
||||||
|
WindowHeight,
|
||||||
|
/// Window layout description, ignoring zoomed window panes. Format is
|
||||||
|
/// `<checksum>,<layout>` where checksum is a 4-digit hex CRC16 and layout
|
||||||
|
/// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal
|
||||||
|
/// splits and `[...]` for vertical splits.
|
||||||
|
WindowLayout,
|
||||||
|
/// Pane wrap flag.
|
||||||
|
WrapFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Variable {
|
||||||
|
/// The tmux variable name (the Zig tag name).
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Variable::AlternateOn => "alternate_on",
|
||||||
|
Variable::AlternateSavedX => "alternate_saved_x",
|
||||||
|
Variable::AlternateSavedY => "alternate_saved_y",
|
||||||
|
Variable::BracketedPaste => "bracketed_paste",
|
||||||
|
Variable::CursorBlinking => "cursor_blinking",
|
||||||
|
Variable::CursorColour => "cursor_colour",
|
||||||
|
Variable::CursorFlag => "cursor_flag",
|
||||||
|
Variable::CursorShape => "cursor_shape",
|
||||||
|
Variable::CursorX => "cursor_x",
|
||||||
|
Variable::CursorY => "cursor_y",
|
||||||
|
Variable::FocusFlag => "focus_flag",
|
||||||
|
Variable::InsertFlag => "insert_flag",
|
||||||
|
Variable::KeypadCursorFlag => "keypad_cursor_flag",
|
||||||
|
Variable::KeypadFlag => "keypad_flag",
|
||||||
|
Variable::MouseAllFlag => "mouse_all_flag",
|
||||||
|
Variable::MouseAnyFlag => "mouse_any_flag",
|
||||||
|
Variable::MouseButtonFlag => "mouse_button_flag",
|
||||||
|
Variable::MouseSgrFlag => "mouse_sgr_flag",
|
||||||
|
Variable::MouseStandardFlag => "mouse_standard_flag",
|
||||||
|
Variable::MouseUtf8Flag => "mouse_utf8_flag",
|
||||||
|
Variable::OriginFlag => "origin_flag",
|
||||||
|
Variable::PaneId => "pane_id",
|
||||||
|
Variable::PaneTabs => "pane_tabs",
|
||||||
|
Variable::ScrollRegionLower => "scroll_region_lower",
|
||||||
|
Variable::ScrollRegionUpper => "scroll_region_upper",
|
||||||
|
Variable::SessionId => "session_id",
|
||||||
|
Variable::Version => "version",
|
||||||
|
Variable::WindowId => "window_id",
|
||||||
|
Variable::WindowWidth => "window_width",
|
||||||
|
Variable::WindowHeight => "window_height",
|
||||||
|
Variable::WindowLayout => "window_layout",
|
||||||
|
Variable::WrapFlag => "wrap_flag",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the given string value into the appropriate value for this
|
||||||
|
/// variable.
|
||||||
|
pub fn parse(self, value: &str) -> Result<Value, VariableError> {
|
||||||
|
use Variable::*;
|
||||||
|
Ok(match self {
|
||||||
|
AlternateOn | BracketedPaste | CursorBlinking | CursorFlag | FocusFlag | InsertFlag
|
||||||
|
| KeypadCursorFlag | KeypadFlag | MouseAllFlag | MouseAnyFlag | MouseButtonFlag
|
||||||
|
| MouseSgrFlag | MouseStandardFlag | MouseUtf8Flag | OriginFlag | WrapFlag => {
|
||||||
|
Value::Bool(value == "1")
|
||||||
|
}
|
||||||
|
AlternateSavedX | AlternateSavedY | CursorX | CursorY | ScrollRegionLower
|
||||||
|
| ScrollRegionUpper | WindowWidth | WindowHeight => Value::Usize(parse_int(value)?),
|
||||||
|
SessionId => Value::Usize(parse_prefixed(b'$', value)?),
|
||||||
|
WindowId => Value::Usize(parse_prefixed(b'@', value)?),
|
||||||
|
PaneId => Value::Usize(parse_prefixed(b'%', value)?),
|
||||||
|
CursorColour | CursorShape | PaneTabs | Version | WindowLayout => {
|
||||||
|
Value::Str(value.to_owned())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed values for a list of variables, in order (the runtime analogue of
|
||||||
|
/// Zig's `FormatStruct`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FormatValues {
|
||||||
|
entries: Vec<(Variable, Value)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatValues {
|
||||||
|
/// Look up a value.
|
||||||
|
pub fn get(&self, v: Variable) -> Option<&Value> {
|
||||||
|
self.entries.iter().find(|(k, _)| *k == v).map(|(_, v)| v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bool accessor; panics if `v` is absent or not a flag.
|
||||||
|
pub fn bool(&self, v: Variable) -> bool {
|
||||||
|
match self.get(v) {
|
||||||
|
Some(Value::Bool(b)) => *b,
|
||||||
|
_ => panic!("variable {} is not a bool", v.name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integer accessor; panics if `v` is absent or not an integer.
|
||||||
|
pub fn usize(&self, v: Variable) -> usize {
|
||||||
|
match self.get(v) {
|
||||||
|
Some(Value::Usize(n)) => *n,
|
||||||
|
_ => panic!("variable {} is not an integer", v.name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String accessor; panics if `v` is absent or not a string.
|
||||||
|
pub fn str(&self, v: Variable) -> &str {
|
||||||
|
match self.get(v) {
|
||||||
|
Some(Value::Str(s)) => s,
|
||||||
|
_ => panic!("variable {} is not a string", v.name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the output from a command with the given variables (in the order
|
||||||
|
/// of the format string). Each variable is expected to be a plain variable
|
||||||
|
/// separated by a single `delimiter`.
|
||||||
|
pub fn parse_format_struct(
|
||||||
|
vars: &[Variable],
|
||||||
|
str: &str,
|
||||||
|
delimiter: char,
|
||||||
|
) -> Result<FormatValues, ParseError> {
|
||||||
|
let mut it = str.split(delimiter);
|
||||||
|
let mut entries = Vec::with_capacity(vars.len());
|
||||||
|
for &var in vars {
|
||||||
|
let part = it.next().ok_or(ParseError::MissingEntry)?;
|
||||||
|
let value = var.parse(part).map_err(|_| ParseError::FormatError)?;
|
||||||
|
entries.push((var, value));
|
||||||
|
}
|
||||||
|
if it.next().is_some() {
|
||||||
|
return Err(ParseError::ExtraEntry);
|
||||||
|
}
|
||||||
|
Ok(FormatValues { entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a set of variables into the proper format string for tmux
|
||||||
|
/// that we can handle with [`parse_format_struct`].
|
||||||
|
pub fn format(vars: &[Variable], delimiter: char) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, v) in vars.iter().enumerate() {
|
||||||
|
if i != 0 {
|
||||||
|
out.push(delimiter);
|
||||||
|
}
|
||||||
|
out.push_str("#{");
|
||||||
|
out.push_str(v.name());
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use Variable::*;
|
||||||
|
|
||||||
|
fn flag(v: Variable) {
|
||||||
|
assert_eq!(v.parse("1"), Ok(Value::Bool(true)));
|
||||||
|
assert_eq!(v.parse("0"), Ok(Value::Bool(false)));
|
||||||
|
assert_eq!(v.parse(""), Ok(Value::Bool(false)));
|
||||||
|
assert_eq!(v.parse("true"), Ok(Value::Bool(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uint(v: Variable, ok: &[(&str, usize)]) {
|
||||||
|
for (s, n) in ok {
|
||||||
|
assert_eq!(v.parse(s), Ok(Value::Usize(*n)));
|
||||||
|
}
|
||||||
|
assert_eq!(v.parse("abc"), Err(VariableError::InvalidCharacter));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s(x: &str) -> Result<Value, VariableError> {
|
||||||
|
Ok(Value::Str(x.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_alternate_on() {
|
||||||
|
flag(AlternateOn);
|
||||||
|
assert_eq!(AlternateOn.parse("yes"), Ok(Value::Bool(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_alternate_saved_x() {
|
||||||
|
uint(AlternateSavedX, &[("0", 0), ("42", 42)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_alternate_saved_y() {
|
||||||
|
uint(AlternateSavedY, &[("0", 0), ("42", 42)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_x() {
|
||||||
|
uint(CursorX, &[("0", 0), ("79", 79)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_y() {
|
||||||
|
uint(CursorY, &[("0", 0), ("23", 23)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scroll_region_upper() {
|
||||||
|
uint(ScrollRegionUpper, &[("0", 0), ("5", 5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scroll_region_lower() {
|
||||||
|
uint(ScrollRegionLower, &[("0", 0), ("23", 23)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_session_id() {
|
||||||
|
assert_eq!(SessionId.parse("$42"), Ok(Value::Usize(42)));
|
||||||
|
assert_eq!(SessionId.parse("$0"), Ok(Value::Usize(0)));
|
||||||
|
assert_eq!(SessionId.parse("0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(SessionId.parse("@0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(SessionId.parse("$"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(SessionId.parse(""), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(
|
||||||
|
SessionId.parse("$abc"),
|
||||||
|
Err(VariableError::InvalidCharacter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_window_id() {
|
||||||
|
assert_eq!(WindowId.parse("@42"), Ok(Value::Usize(42)));
|
||||||
|
assert_eq!(WindowId.parse("@0"), Ok(Value::Usize(0)));
|
||||||
|
assert_eq!(WindowId.parse("0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(WindowId.parse("$0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(WindowId.parse("@"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(WindowId.parse(""), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(WindowId.parse("@abc"), Err(VariableError::InvalidCharacter));
|
||||||
|
assert_eq!(WindowId.parse("@12345"), Ok(Value::Usize(12345)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pane_id() {
|
||||||
|
assert_eq!(PaneId.parse("%42"), Ok(Value::Usize(42)));
|
||||||
|
assert_eq!(PaneId.parse("%0"), Ok(Value::Usize(0)));
|
||||||
|
assert_eq!(PaneId.parse("0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(PaneId.parse("@0"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(PaneId.parse("%"), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(PaneId.parse(""), Err(VariableError::FormatError));
|
||||||
|
assert_eq!(PaneId.parse("%abc"), Err(VariableError::InvalidCharacter));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_window_width() {
|
||||||
|
uint(WindowWidth, &[("80", 80), ("0", 0), ("12345", 12345)]);
|
||||||
|
assert_eq!(
|
||||||
|
WindowWidth.parse("80px"),
|
||||||
|
Err(VariableError::InvalidCharacter)
|
||||||
|
);
|
||||||
|
assert_eq!(WindowWidth.parse("-1"), Err(VariableError::Overflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_window_height() {
|
||||||
|
uint(WindowHeight, &[("80", 80), ("0", 0), ("12345", 12345)]);
|
||||||
|
assert_eq!(
|
||||||
|
WindowHeight.parse("80px"),
|
||||||
|
Err(VariableError::InvalidCharacter)
|
||||||
|
);
|
||||||
|
assert_eq!(WindowHeight.parse("-1"), Err(VariableError::Overflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_window_layout() {
|
||||||
|
assert_eq!(WindowLayout.parse("abc123"), s("abc123"));
|
||||||
|
assert_eq!(WindowLayout.parse(""), s(""));
|
||||||
|
assert_eq!(WindowLayout.parse("a]b,c{d}e(f)"), s("a]b,c{d}e(f)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_flag() {
|
||||||
|
flag(CursorFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_insert_flag() {
|
||||||
|
flag(InsertFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_keypad_cursor_flag() {
|
||||||
|
flag(KeypadCursorFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_keypad_flag() {
|
||||||
|
flag(KeypadFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_any_flag() {
|
||||||
|
flag(MouseAnyFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_button_flag() {
|
||||||
|
flag(MouseButtonFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_sgr_flag() {
|
||||||
|
flag(MouseSgrFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_standard_flag() {
|
||||||
|
flag(MouseStandardFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_utf8_flag() {
|
||||||
|
flag(MouseUtf8Flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_wrap_flag() {
|
||||||
|
flag(WrapFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_bracketed_paste() {
|
||||||
|
flag(BracketedPaste);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_blinking() {
|
||||||
|
flag(CursorBlinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_focus_flag() {
|
||||||
|
flag(FocusFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mouse_all_flag() {
|
||||||
|
flag(MouseAllFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_origin_flag() {
|
||||||
|
flag(OriginFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_colour() {
|
||||||
|
assert_eq!(CursorColour.parse("red"), s("red"));
|
||||||
|
assert_eq!(CursorColour.parse("#ff0000"), s("#ff0000"));
|
||||||
|
assert_eq!(CursorColour.parse(""), s(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cursor_shape() {
|
||||||
|
assert_eq!(CursorShape.parse("block"), s("block"));
|
||||||
|
assert_eq!(CursorShape.parse("underline"), s("underline"));
|
||||||
|
assert_eq!(CursorShape.parse("bar"), s("bar"));
|
||||||
|
assert_eq!(CursorShape.parse(""), s(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pane_tabs() {
|
||||||
|
assert_eq!(PaneTabs.parse("0,8,16,24"), s("0,8,16,24"));
|
||||||
|
assert_eq!(PaneTabs.parse(""), s(""));
|
||||||
|
assert_eq!(PaneTabs.parse("0"), s("0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_version() {
|
||||||
|
assert_eq!(Version.parse("3.5a"), s("3.5a"));
|
||||||
|
assert_eq!(Version.parse("3.5"), s("3.5"));
|
||||||
|
assert_eq!(Version.parse("next-3.5"), s("next-3.5"));
|
||||||
|
assert_eq!(Version.parse(""), s(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_single_field() {
|
||||||
|
let r = parse_format_struct(&[SessionId], "$42", ' ').unwrap();
|
||||||
|
assert_eq!(r.usize(SessionId), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_multiple_fields() {
|
||||||
|
let r = parse_format_struct(
|
||||||
|
&[SessionId, WindowId, WindowWidth, WindowHeight],
|
||||||
|
"$1 @2 80 24",
|
||||||
|
' ',
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.usize(SessionId), 1);
|
||||||
|
assert_eq!(r.usize(WindowId), 2);
|
||||||
|
assert_eq!(r.usize(WindowWidth), 80);
|
||||||
|
assert_eq!(r.usize(WindowHeight), 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_with_string_field() {
|
||||||
|
let r = parse_format_struct(&[WindowId, WindowLayout], "@5,abc123", ',').unwrap();
|
||||||
|
assert_eq!(r.usize(WindowId), 5);
|
||||||
|
assert_eq!(r.str(WindowLayout), "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_different_delimiter() {
|
||||||
|
let r = parse_format_struct(&[WindowWidth, WindowHeight], "120\t40", '\t').unwrap();
|
||||||
|
assert_eq!(r.usize(WindowWidth), 120);
|
||||||
|
assert_eq!(r.usize(WindowHeight), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_missing_entry() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_format_struct(&[SessionId, WindowId], "$1", ' '),
|
||||||
|
Err(ParseError::MissingEntry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_extra_entry() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_format_struct(&[SessionId], "$1 @2", ' '),
|
||||||
|
Err(ParseError::ExtraEntry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_format_error() {
|
||||||
|
for x in ["42", "@42", "$abc"] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_format_struct(&[SessionId], x, ' '),
|
||||||
|
Err(ParseError::FormatError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_empty_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_format_struct(&[SessionId], "", ' '),
|
||||||
|
Err(ParseError::FormatError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_format_struct_with_empty_layout_field() {
|
||||||
|
let r = parse_format_struct(&[SessionId, WindowLayout], "$1,", ',').unwrap();
|
||||||
|
assert_eq!(r.usize(SessionId), 1);
|
||||||
|
assert_eq!(r.str(WindowLayout), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_single_variable() {
|
||||||
|
assert_eq!(format(&[SessionId], ' '), "#{session_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_multiple_variables() {
|
||||||
|
assert_eq!(
|
||||||
|
format(&[SessionId, WindowId, WindowWidth, WindowHeight], ' '),
|
||||||
|
"#{session_id} #{window_id} #{window_width} #{window_height}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_with_comma_delimiter() {
|
||||||
|
assert_eq!(
|
||||||
|
format(&[WindowId, WindowLayout], ','),
|
||||||
|
"#{window_id},#{window_layout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_with_tab_delimiter() {
|
||||||
|
assert_eq!(
|
||||||
|
format(&[WindowWidth, WindowHeight], '\t'),
|
||||||
|
"#{window_width}\t#{window_height}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_empty_variables() {
|
||||||
|
assert_eq!(format(&[], ' '), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_all_variables() {
|
||||||
|
assert_eq!(
|
||||||
|
format(
|
||||||
|
&[SessionId, WindowId, WindowWidth, WindowHeight, WindowLayout],
|
||||||
|
' '
|
||||||
|
),
|
||||||
|
"#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,1477 @@
|
|||||||
|
//! tmux control mode viewer (port of `tmux/viewer.zig`).
|
||||||
|
//!
|
||||||
|
//! A viewer is a tmux control mode client that attempts to create a remote
|
||||||
|
//! view of a tmux session, including providing the ability to send new
|
||||||
|
//! input to the session.
|
||||||
|
//!
|
||||||
|
//! This struct helps move through a state machine of connecting to a tmux
|
||||||
|
//! session, negotiating capabilities, listing window state, etc.
|
||||||
|
//!
|
||||||
|
//! ## Viewer Lifecycle
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! DCS 1000p received by host
|
||||||
|
//! -> startup_block wait for the initial %begin/%end block (response to
|
||||||
|
//! the initial command, e.g. "attach -t 0")
|
||||||
|
//! -> startup_session wait for %session-changed to get the session ID
|
||||||
|
//! -> command_queue main operating state: process commands sequentially
|
||||||
|
//! and handle notifications
|
||||||
|
//! tmux_version query tmux version
|
||||||
|
//! list_windows get all windows in the session
|
||||||
|
//! sync layouts parse layouts; for each new pane queue
|
||||||
|
//! pane_history/pane_visible (primary, alternate)
|
||||||
|
//! pane_state cursor position/style, modes, tabs, scroll region
|
||||||
|
//! -> ready: %output, %layout-change, %session-changed etc. are handled live.
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Error Handling
|
||||||
|
//!
|
||||||
|
//! At any point, if an unrecoverable error occurs or tmux sends `%exit`,
|
||||||
|
//! the viewer transitions to the `defunct` state and emits an `Exit` action.
|
||||||
|
//!
|
||||||
|
//! ## Session Changes
|
||||||
|
//!
|
||||||
|
//! When `%session-changed` is received during `command_queue` state, the
|
||||||
|
//! viewer resets itself completely: clears all windows/panes, emits an
|
||||||
|
//! empty windows action, and restarts the `list_windows` flow for the new
|
||||||
|
//! session.
|
||||||
|
#![allow(
|
||||||
|
clippy::assert_is_empty,
|
||||||
|
clippy::assigning_clones,
|
||||||
|
clippy::collapsible_if,
|
||||||
|
clippy::enum_glob_use,
|
||||||
|
clippy::manual_assert_eq,
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::single_match_else,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::unnecessary_wraps
|
||||||
|
)]
|
||||||
|
|
||||||
|
// TODO(zig): A list of TODOs from the original:
|
||||||
|
// - We need to make startup more robust so session and block can happen
|
||||||
|
// out of order.
|
||||||
|
// - We need to ignore `output` for panes that aren't yet initialized
|
||||||
|
// (until capture-panes are complete).
|
||||||
|
// - We should note what the active window pane is on the tmux side;
|
||||||
|
// we can use this at least for initial focus.
|
||||||
|
|
||||||
|
// NOTE: There is some fragility here that can possibly break if tmux
|
||||||
|
// changes their implementation. In particular, the order of notifications
|
||||||
|
// and assurances about what is sent when are based on reading the tmux
|
||||||
|
// source code as of Dec, 2025. These aren't documented as fixed.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use ghostty_vt::modes::Mode;
|
||||||
|
use ghostty_vt::tmux::control::Notification;
|
||||||
|
|
||||||
|
use super::layout::{self, Content, Layout};
|
||||||
|
use super::output::{self, Variable};
|
||||||
|
use crate::cursor::Style as CursorStyle;
|
||||||
|
use crate::page;
|
||||||
|
use crate::point::{Coordinate, Point};
|
||||||
|
use crate::screen_set::Key as ScreenKey;
|
||||||
|
use crate::size::CellCountInt;
|
||||||
|
use crate::terminal::{Options as TerminalOptions, Terminal};
|
||||||
|
|
||||||
|
/// The initial capacity of the command queue.
|
||||||
|
const COMMAND_QUEUE_INITIAL: usize = 8;
|
||||||
|
|
||||||
|
/// Errors that can occur while processing tmux input. These never escape
|
||||||
|
/// [`Viewer::next`]; they make the viewer go defunct (or are logged).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Format output parse failure.
|
||||||
|
#[error("format parse: {0}")]
|
||||||
|
Output(#[from] output::ParseError),
|
||||||
|
/// Layout parse failure.
|
||||||
|
#[error("layout parse: {0}")]
|
||||||
|
Layout(#[from] layout::ParseError),
|
||||||
|
/// Terminal/page failure.
|
||||||
|
#[error("terminal: {0:?}")]
|
||||||
|
Terminal(page::Error),
|
||||||
|
/// VT stream failure.
|
||||||
|
#[error("stream: {0}")]
|
||||||
|
Stream(String),
|
||||||
|
/// Window/pane dimension doesn't fit a cell count.
|
||||||
|
#[error("dimension overflow")]
|
||||||
|
Overflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<page::Error> for Error {
|
||||||
|
fn from(e: page::Error) -> Self {
|
||||||
|
Error::Terminal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action the caller must perform as the result of an input.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Tmux has closed the control mode connection, we should end
|
||||||
|
/// our viewer session in some way.
|
||||||
|
Exit,
|
||||||
|
|
||||||
|
/// Send a command to tmux, e.g. `list-windows`. The caller should not
|
||||||
|
/// worry about parsing this or reading what command it is; just send it
|
||||||
|
/// to tmux as-is. This includes the trailing newline.
|
||||||
|
Command(Vec<u8>),
|
||||||
|
|
||||||
|
/// Windows changed. This may add, remove or change windows. The caller
|
||||||
|
/// is responsible for diffing the new window list against the prior one.
|
||||||
|
/// For a given Viewer, window IDs are stable (and tmux never reuses
|
||||||
|
/// window IDs within a server process lifetime as of Dec 2025).
|
||||||
|
Windows(Vec<Window>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
#[cfg(test)]
|
||||||
|
fn tag(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Action::Exit => "exit",
|
||||||
|
Action::Command(_) => "command",
|
||||||
|
Action::Windows(_) => "windows",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input to the viewer.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Input {
|
||||||
|
/// Data from tmux was received that needs to be processed.
|
||||||
|
Tmux(Notification),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tmux window.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Window {
|
||||||
|
/// Window ID.
|
||||||
|
pub id: usize,
|
||||||
|
/// Width in cells.
|
||||||
|
pub width: usize,
|
||||||
|
/// Height in cells.
|
||||||
|
pub height: usize,
|
||||||
|
/// Parsed layout.
|
||||||
|
pub layout: Layout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tmux pane mirrored in a local terminal.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Pane {
|
||||||
|
/// The terminal holding the pane contents.
|
||||||
|
pub terminal: Terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insertion-ordered map of pane ID to pane (Zig `AutoArrayHashMapUnmanaged`).
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PanesMap {
|
||||||
|
entries: Vec<(usize, Pane)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanesMap {
|
||||||
|
/// Number of panes.
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
/// True if the pane ID exists.
|
||||||
|
pub fn contains(&self, id: usize) -> bool {
|
||||||
|
self.entries.iter().any(|(k, _)| *k == id)
|
||||||
|
}
|
||||||
|
/// Get a pane.
|
||||||
|
pub fn get(&self, id: usize) -> Option<&Pane> {
|
||||||
|
self.entries.iter().find(|(k, _)| *k == id).map(|(_, v)| v)
|
||||||
|
}
|
||||||
|
/// Get a pane mutably.
|
||||||
|
pub fn get_mut(&mut self, id: usize) -> Option<&mut Pane> {
|
||||||
|
self.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(k, _)| *k == id)
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
}
|
||||||
|
/// Pane IDs in insertion order.
|
||||||
|
pub fn keys(&self) -> impl Iterator<Item = usize> + '_ {
|
||||||
|
self.entries.iter().map(|(k, _)| *k)
|
||||||
|
}
|
||||||
|
fn take(&mut self, id: usize) -> Option<Pane> {
|
||||||
|
let i = self.entries.iter().position(|(k, _)| *k == id)?;
|
||||||
|
Some(self.entries.remove(i).1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum State {
|
||||||
|
/// We start in this state just after receiving the initial DCS 1000p
|
||||||
|
/// opening sequence. We wait for an initial begin/end block that is
|
||||||
|
/// guaranteed to be sent by tmux for the initial control mode command.
|
||||||
|
StartupBlock,
|
||||||
|
/// After receiving the initial block, we wait for a session-changed
|
||||||
|
/// notification to record the initial session ID.
|
||||||
|
StartupSession,
|
||||||
|
/// Tmux has closed the control mode connection.
|
||||||
|
Defunct,
|
||||||
|
/// We're sitting on the command queue waiting for command output in the
|
||||||
|
/// order provided in `command_queue`.
|
||||||
|
///
|
||||||
|
/// Precondition: if the queue is non-empty, the first command has
|
||||||
|
/// already been sent to tmux (via a `Command` action). The next output
|
||||||
|
/// is assumed to be the result of this command. Any transition INTO this
|
||||||
|
/// state must send a command action for the first queued command.
|
||||||
|
CommandQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct CapturePane {
|
||||||
|
id: usize,
|
||||||
|
screen_key: ScreenKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum Command {
|
||||||
|
/// List all windows so we can sync our window state.
|
||||||
|
ListWindows,
|
||||||
|
/// Capture history for the given pane ID.
|
||||||
|
PaneHistory(CapturePane),
|
||||||
|
/// Capture visible area for the given pane ID.
|
||||||
|
PaneVisible(CapturePane),
|
||||||
|
/// Capture the pane terminal state as best we can.
|
||||||
|
PaneState,
|
||||||
|
/// Get the tmux server version.
|
||||||
|
TmuxVersion,
|
||||||
|
/// User command provided by the user (opaque).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
User(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format strings used for commands in our viewer.
|
||||||
|
struct Format {
|
||||||
|
/// The variables included in this format, in order.
|
||||||
|
vars: &'static [Variable],
|
||||||
|
/// The delimiter between variables. Must be a character guaranteed to
|
||||||
|
/// not appear in any variable output.
|
||||||
|
delim: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Format {
|
||||||
|
const LIST_PANES: Format = Format {
|
||||||
|
delim: ';',
|
||||||
|
vars: &[
|
||||||
|
Variable::PaneId,
|
||||||
|
// Cursor position & appearance
|
||||||
|
Variable::CursorX,
|
||||||
|
Variable::CursorY,
|
||||||
|
Variable::CursorFlag,
|
||||||
|
Variable::CursorShape,
|
||||||
|
Variable::CursorColour,
|
||||||
|
Variable::CursorBlinking,
|
||||||
|
// Alternate screen
|
||||||
|
Variable::AlternateOn,
|
||||||
|
Variable::AlternateSavedX,
|
||||||
|
Variable::AlternateSavedY,
|
||||||
|
// Terminal modes
|
||||||
|
Variable::InsertFlag,
|
||||||
|
Variable::WrapFlag,
|
||||||
|
Variable::KeypadFlag,
|
||||||
|
Variable::KeypadCursorFlag,
|
||||||
|
Variable::OriginFlag,
|
||||||
|
// Mouse modes
|
||||||
|
Variable::MouseAllFlag,
|
||||||
|
Variable::MouseAnyFlag,
|
||||||
|
Variable::MouseButtonFlag,
|
||||||
|
Variable::MouseStandardFlag,
|
||||||
|
Variable::MouseUtf8Flag,
|
||||||
|
Variable::MouseSgrFlag,
|
||||||
|
// Focus & special features
|
||||||
|
Variable::FocusFlag,
|
||||||
|
Variable::BracketedPaste,
|
||||||
|
// Scroll region
|
||||||
|
Variable::ScrollRegionUpper,
|
||||||
|
Variable::ScrollRegionLower,
|
||||||
|
// Tab stops
|
||||||
|
Variable::PaneTabs,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_WINDOWS: Format = Format {
|
||||||
|
delim: ' ',
|
||||||
|
vars: &[
|
||||||
|
Variable::SessionId,
|
||||||
|
Variable::WindowId,
|
||||||
|
Variable::WindowWidth,
|
||||||
|
Variable::WindowHeight,
|
||||||
|
Variable::WindowLayout,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TMUX_VERSION: Format = Format {
|
||||||
|
delim: ' ',
|
||||||
|
vars: &[Variable::Version],
|
||||||
|
};
|
||||||
|
|
||||||
|
fn format_string(&self) -> String {
|
||||||
|
output::format(self.vars, self.delim)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(&self, s: &str) -> Result<output::FormatValues, output::ParseError> {
|
||||||
|
output::parse_format_struct(self.vars, s, self.delim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
/// Format the command into the command that should be executed by
|
||||||
|
/// tmux. Trailing newlines are appended so this can be sent as-is.
|
||||||
|
fn format_command(&self) -> Vec<u8> {
|
||||||
|
let alt = |k: ScreenKey| if k == ScreenKey::Alternate { "-a " } else { "" };
|
||||||
|
match self {
|
||||||
|
Command::ListWindows => format!(
|
||||||
|
"list-windows -F '{}'\n",
|
||||||
|
Format::LIST_WINDOWS.format_string()
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
// -p = output to stdout instead of buffer
|
||||||
|
// -e = output escape sequences for SGR
|
||||||
|
// -a = capture alternate screen (only valid for alternate)
|
||||||
|
// -q = quiet, don't error if alternate screen doesn't exist
|
||||||
|
// -S - = start at the top of history ("-")
|
||||||
|
// -E -1 = end at the last line of history (1 before the
|
||||||
|
// visible area is -1).
|
||||||
|
// -t %{d} = target a specific pane ID
|
||||||
|
Command::PaneHistory(cap) => format!(
|
||||||
|
"capture-pane -p -e -q {}-S - -E -1 -t %{}\n",
|
||||||
|
alt(cap.screen_key),
|
||||||
|
cap.id
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
// (no -S/-E = capture visible area only)
|
||||||
|
Command::PaneVisible(cap) => format!(
|
||||||
|
"capture-pane -p -e -q {}-t %{}\n",
|
||||||
|
alt(cap.screen_key),
|
||||||
|
cap.id
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
Command::PaneState => {
|
||||||
|
format!("list-panes -F '{}'\n", Format::LIST_PANES.format_string()).into_bytes()
|
||||||
|
}
|
||||||
|
Command::TmuxVersion => format!(
|
||||||
|
"display-message -p '{}'\n",
|
||||||
|
Format::TMUX_VERSION.format_string()
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
Command::User(v) => v.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_bytes<'a>(s: &'a [u8], set: &[u8]) -> &'a [u8] {
|
||||||
|
let mut s = s;
|
||||||
|
while let [first, rest @ ..] = s {
|
||||||
|
if set.contains(first) {
|
||||||
|
s = rest;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let [rest @ .., last] = s {
|
||||||
|
if set.contains(last) {
|
||||||
|
s = rest;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lossy(s: &[u8]) -> String {
|
||||||
|
String::from_utf8_lossy(s).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cell(v: usize) -> Option<CellCountInt> {
|
||||||
|
CellCountInt::try_from(v).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tmux control mode viewer. See the module docs.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Viewer {
|
||||||
|
state: State,
|
||||||
|
|
||||||
|
/// The current session ID we're attached to.
|
||||||
|
pub session_id: usize,
|
||||||
|
|
||||||
|
/// The tmux server version string (e.g., "3.5a"). We capture this on
|
||||||
|
/// startup because it will allow us to change behavior between versions
|
||||||
|
/// as necessary.
|
||||||
|
pub tmux_version: String,
|
||||||
|
|
||||||
|
/// The list of commands we've sent that we want to send and wait for a
|
||||||
|
/// response for. We only send one command at a time just to avoid any
|
||||||
|
/// possible confusion around ordering.
|
||||||
|
command_queue: VecDeque<Command>,
|
||||||
|
|
||||||
|
/// The windows in the current session.
|
||||||
|
pub windows: Vec<Window>,
|
||||||
|
|
||||||
|
/// The panes in the current session, mapped by pane ID.
|
||||||
|
pub panes: PanesMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Viewer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewer {
|
||||||
|
/// Initialize a new viewer.
|
||||||
|
pub fn new() -> Viewer {
|
||||||
|
Viewer {
|
||||||
|
state: State::StartupBlock,
|
||||||
|
// The default value here is meaningless. We don't get started
|
||||||
|
// until we receive a session-changed notification which will
|
||||||
|
// set this to a real value.
|
||||||
|
session_id: 0,
|
||||||
|
tmux_version: String::new(),
|
||||||
|
command_queue: VecDeque::with_capacity(COMMAND_QUEUE_INITIAL),
|
||||||
|
windows: Vec::new(),
|
||||||
|
panes: PanesMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the command queue has no pending commands.
|
||||||
|
pub fn command_queue_is_empty(&self) -> bool {
|
||||||
|
self.command_queue.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of queued commands (including the in-flight one).
|
||||||
|
pub fn command_queue_len(&self) -> usize {
|
||||||
|
self.command_queue.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once tmux has exited or an unrecoverable error occurred.
|
||||||
|
pub fn is_defunct(&self) -> bool {
|
||||||
|
self.state == State::Defunct
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send in an input event (such as a tmux protocol notification) and
|
||||||
|
/// process it. The returned list is a set of actions to take as a result
|
||||||
|
/// of the input prior to the next input. This list may be empty.
|
||||||
|
///
|
||||||
|
/// This never fails; on error the viewer goes defunct.
|
||||||
|
pub fn next(&mut self, input: Input) -> Vec<Action> {
|
||||||
|
match input {
|
||||||
|
Input::Tmux(n) => self.next_tmux(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_tmux(&mut self, n: Notification) -> Vec<Action> {
|
||||||
|
match self.state {
|
||||||
|
State::Defunct => {
|
||||||
|
log::info!("received notification in defunct state, ignoring");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
State::StartupBlock => self.next_startup_block(n),
|
||||||
|
State::StartupSession => self.next_startup_session(n),
|
||||||
|
State::CommandQueue => self.next_command(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_startup_block(&mut self, n: Notification) -> Vec<Action> {
|
||||||
|
debug_assert!(self.state == State::StartupBlock);
|
||||||
|
match n {
|
||||||
|
// Only sent by the DCS parser when we first get DCS 1000p, it
|
||||||
|
// should never reach us here.
|
||||||
|
Notification::Enter => unreachable!("enter is handled before the viewer"),
|
||||||
|
|
||||||
|
// Semantically fine to handle without issue.
|
||||||
|
Notification::Exit => self.defunct(),
|
||||||
|
|
||||||
|
// Any begin and end (even error) is fine! Now we wait for
|
||||||
|
// session-changed to get the initial session ID.
|
||||||
|
Notification::BlockEnd(_) | Notification::BlockErr(_) => {
|
||||||
|
self.state = State::StartupSession;
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_startup_session(&mut self, n: Notification) -> Vec<Action> {
|
||||||
|
debug_assert!(self.state == State::StartupSession);
|
||||||
|
match n {
|
||||||
|
Notification::Enter => unreachable!("enter is handled before the viewer"),
|
||||||
|
Notification::Exit => self.defunct(),
|
||||||
|
Notification::SessionChanged { id, .. } => {
|
||||||
|
self.session_id = id;
|
||||||
|
self.enter_command_queue(vec![Command::TmuxVersion, Command::ListWindows])
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_command(&mut self, n: Notification) -> Vec<Action> {
|
||||||
|
// We have to be in a command queue, but the command queue MAY be
|
||||||
|
// empty. If it is empty, then received_command_output will handle it
|
||||||
|
// by ignoring any command output. That's okay!
|
||||||
|
debug_assert!(self.state == State::CommandQueue);
|
||||||
|
|
||||||
|
let mut actions: Vec<Action> = Vec::new();
|
||||||
|
|
||||||
|
// Track whether the in-flight command slot is available. Starts true
|
||||||
|
// if queue is empty (no command in flight). Set to true when a
|
||||||
|
// command completes (block_end/block_err) or the queue is reset
|
||||||
|
// (session_changed).
|
||||||
|
let mut command_consumed = self.command_queue.is_empty();
|
||||||
|
|
||||||
|
match n {
|
||||||
|
Notification::Enter => unreachable!("enter is handled before the viewer"),
|
||||||
|
Notification::Exit => return self.defunct(),
|
||||||
|
|
||||||
|
Notification::BlockEnd(content) | Notification::BlockErr(content) => {
|
||||||
|
// (is_err is only used for logging in Zig)
|
||||||
|
if self
|
||||||
|
.received_command_output(&mut actions, &content)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
log::warn!("failed to process command output, becoming defunct");
|
||||||
|
return self.defunct();
|
||||||
|
}
|
||||||
|
// Command is consumed since a block end/err is the output
|
||||||
|
// from a command.
|
||||||
|
command_consumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::Output { pane_id, data } => {
|
||||||
|
if let Err(err) = self.received_output(pane_id, &data) {
|
||||||
|
log::warn!("failed to process output for pane id={pane_id}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session changed means we switched to a different tmux session.
|
||||||
|
// We need to reset our state and start fresh with list-windows.
|
||||||
|
Notification::SessionChanged { id, .. } => {
|
||||||
|
self.session_changed(&mut actions, id);
|
||||||
|
// Command is consumed because session_changed resets our
|
||||||
|
// entire viewer.
|
||||||
|
command_consumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout changed of a single window.
|
||||||
|
Notification::LayoutChange {
|
||||||
|
window_id, layout, ..
|
||||||
|
} => {
|
||||||
|
if self
|
||||||
|
.layout_changed(&mut actions, window_id, &layout)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Note: in the future, we can probably handle a failure
|
||||||
|
// here with a fallback to remove this one window, list
|
||||||
|
// windows again, and try again.
|
||||||
|
log::warn!("failed to handle layout change, becoming defunct");
|
||||||
|
return self.defunct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window was added to this session.
|
||||||
|
Notification::WindowAdd { id } => self.window_add(id),
|
||||||
|
|
||||||
|
// The active pane changed. We handle our own focus.
|
||||||
|
Notification::WindowPaneChanged { .. } => {}
|
||||||
|
|
||||||
|
// A session was created or destroyed. If it was our own session
|
||||||
|
// we will get an exit notification very soon.
|
||||||
|
Notification::SessionsChanged => {}
|
||||||
|
|
||||||
|
// We don't use window names for anything, currently.
|
||||||
|
Notification::WindowRenamed { .. } => {}
|
||||||
|
|
||||||
|
// For other clients. For us, we'll get `exit` or
|
||||||
|
// `session_changed`, respectively.
|
||||||
|
Notification::ClientDetached { .. } | Notification::ClientSessionChanged { .. } => {}
|
||||||
|
|
||||||
|
// TODO(integration): the Rust control parser may expose more
|
||||||
|
// notification kinds than the Zig switch handled.
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After processing commands, we add our next command to execute if
|
||||||
|
// we have one. We do this last because command processing may itself
|
||||||
|
// queue more commands. We only emit a command if a prior command was
|
||||||
|
// consumed (or never existed).
|
||||||
|
if self.state == State::CommandQueue && command_consumed {
|
||||||
|
if let Some(next_command) = self.command_queue.front() {
|
||||||
|
debug_assert!(!actions.iter().any(|a| matches!(a, Action::Command(_))));
|
||||||
|
actions.push(Action::Command(next_command.format_command()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the layout changes for a single window, a pane may be added or
|
||||||
|
/// removed that we've never seen, in addition to the layout itself
|
||||||
|
/// physically changing.
|
||||||
|
///
|
||||||
|
/// Similar to list-windows except we expect the window to already
|
||||||
|
/// exist. We update the layout, init any new panes, queue commands to
|
||||||
|
/// capture them, and prune removed panes.
|
||||||
|
fn layout_changed(
|
||||||
|
&mut self,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
window_id: usize,
|
||||||
|
layout_str: &[u8],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// Find the window this layout change is for.
|
||||||
|
let Some(window) = self.windows.iter_mut().find(|w| w.id == window_id) else {
|
||||||
|
log::info!("layout change for unknown window id={window_id}");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.layout = match Layout::parse_with_checksum(layout_str) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
log::info!(
|
||||||
|
"failed to parse window layout id={} layout={}",
|
||||||
|
window_id,
|
||||||
|
lossy(layout_str)
|
||||||
|
);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Our initial action is to definitely let the caller know that some
|
||||||
|
// windows changed.
|
||||||
|
actions.push(Action::Windows(self.windows.clone()));
|
||||||
|
|
||||||
|
// Sync up our panes
|
||||||
|
self.sync_layouts(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a window is added to the session, we refresh our window list
|
||||||
|
/// to get the new window's information.
|
||||||
|
fn window_add(&mut self, _window_id: usize) {
|
||||||
|
self.queue_commands([Command::ListWindows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync panes with the layouts of `new_windows` (or of `self.windows`
|
||||||
|
/// if `None`), then replace our window list if `new_windows` is given.
|
||||||
|
fn sync_layouts(&mut self, new_windows: Option<Vec<Window>>) -> Result<(), Error> {
|
||||||
|
// Collect the pane IDs (and sizes) in layout order.
|
||||||
|
fn collect(layout: &Layout, out: &mut Vec<(usize, usize, usize)>) {
|
||||||
|
match &layout.content {
|
||||||
|
Content::Horizontal(c) | Content::Vertical(c) => {
|
||||||
|
for l in c {
|
||||||
|
collect(l, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Content::Pane(id) => {
|
||||||
|
if !out.iter().any(|(i, _, _)| i == id) {
|
||||||
|
out.push((*id, layout.width, layout.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut wanted: Vec<(usize, usize, usize)> = Vec::new();
|
||||||
|
for w in new_windows.as_deref().unwrap_or(&self.windows) {
|
||||||
|
collect(&w.layout, &mut wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new terminals first so failure leaves us untouched.
|
||||||
|
// TODO(zig): We need to gracefully handle overflow of our max
|
||||||
|
// cols/width here. In practice we shouldn't hit this.
|
||||||
|
let mut created: Vec<(usize, Pane)> = Vec::new();
|
||||||
|
for &(id, w, h) in &wanted {
|
||||||
|
if self.panes.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cols = to_cell(w).ok_or(Error::Overflow)?;
|
||||||
|
let rows = to_cell(h).ok_or(Error::Overflow)?;
|
||||||
|
let terminal = Terminal::new(TerminalOptions::new(cols, rows))?;
|
||||||
|
created.push((id, Pane { terminal }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get our list of added panes and setup our command queue to
|
||||||
|
// populate them.
|
||||||
|
let mut added = false;
|
||||||
|
for (id, _) in &created {
|
||||||
|
added = true;
|
||||||
|
let id = *id;
|
||||||
|
self.queue_commands([
|
||||||
|
Command::PaneHistory(CapturePane {
|
||||||
|
id,
|
||||||
|
screen_key: ScreenKey::Primary,
|
||||||
|
}),
|
||||||
|
Command::PaneVisible(CapturePane {
|
||||||
|
id,
|
||||||
|
screen_key: ScreenKey::Primary,
|
||||||
|
}),
|
||||||
|
Command::PaneHistory(CapturePane {
|
||||||
|
id,
|
||||||
|
screen_key: ScreenKey::Alternate,
|
||||||
|
}),
|
||||||
|
Command::PaneVisible(CapturePane {
|
||||||
|
id,
|
||||||
|
screen_key: ScreenKey::Alternate,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// If we added any panes, then we also want to resync the pane state
|
||||||
|
// (terminal modes and cursor positions and so on).
|
||||||
|
if added {
|
||||||
|
self.queue_commands([Command::PaneState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more errors after this point.
|
||||||
|
if let Some(w) = new_windows {
|
||||||
|
self.windows = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace our panes (old panes not in the layout are dropped).
|
||||||
|
let mut old = std::mem::take(&mut self.panes);
|
||||||
|
let mut entries = Vec::with_capacity(wanted.len());
|
||||||
|
for (id, _, _) in wanted {
|
||||||
|
let pane = match old.take(id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
let i = created.iter().position(|(k, _)| *k == id);
|
||||||
|
created.remove(i.expect("created pane")).1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
entries.push((id, pane));
|
||||||
|
}
|
||||||
|
self.panes = PanesMap { entries };
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a session changes, we have to basically reset our whole state.
|
||||||
|
/// To do this, we emit an empty windows event (so callers can clear all
|
||||||
|
/// windows), reset ourself, and start all over.
|
||||||
|
fn session_changed(&mut self, actions: &mut Vec<Action>, session_id: usize) {
|
||||||
|
debug_assert!(actions.is_empty());
|
||||||
|
|
||||||
|
// Build up a new viewer. It's the easiest way to reset ourselves.
|
||||||
|
let mut replacement = Viewer::new();
|
||||||
|
actions.push(Action::Windows(Vec::new()));
|
||||||
|
|
||||||
|
// Setup our command queue and put ourselves in the command queue
|
||||||
|
// state.
|
||||||
|
replacement.queue_commands([Command::ListWindows]);
|
||||||
|
replacement.state = State::CommandQueue;
|
||||||
|
|
||||||
|
// Transfer preserved version to replacement
|
||||||
|
replacement.tmux_version = self.tmux_version.clone();
|
||||||
|
|
||||||
|
*self = replacement;
|
||||||
|
|
||||||
|
// Set our session ID and jump directly to the list
|
||||||
|
self.session_id = session_id;
|
||||||
|
debug_assert!(self.state == State::CommandQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_command_output(
|
||||||
|
&mut self,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
content: &[u8],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// Get the command we're expecting output for.
|
||||||
|
let Some(command) = self.command_queue.pop_front() else {
|
||||||
|
// If we have no pending commands, this is unexpected.
|
||||||
|
log::info!("unexpected block output");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Command::User(_) => Ok(()),
|
||||||
|
Command::PaneState => self.received_pane_state(content),
|
||||||
|
Command::ListWindows => self.received_list_windows(actions, content),
|
||||||
|
Command::PaneHistory(cap) => {
|
||||||
|
self.received_pane_history(cap.screen_key, cap.id, content)
|
||||||
|
}
|
||||||
|
Command::PaneVisible(cap) => {
|
||||||
|
self.received_pane_visible(cap.screen_key, cap.id, content)
|
||||||
|
}
|
||||||
|
Command::TmuxVersion => self.received_tmux_version(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_tmux_version(&mut self, content: &[u8]) -> Result<(), Error> {
|
||||||
|
let line = trim_bytes(content, b" \t\r\n");
|
||||||
|
if line.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let line_str = String::from_utf8_lossy(line);
|
||||||
|
let data = Format::TMUX_VERSION.parse(&line_str).inspect_err(|_| {
|
||||||
|
log::info!("failed to parse tmux version: {line_str}");
|
||||||
|
})?;
|
||||||
|
self.tmux_version = data.str(Variable::Version).to_owned();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_list_windows(
|
||||||
|
&mut self,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
content: &[u8],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// This stores our new window state from this list-windows output.
|
||||||
|
let mut windows: Vec<Window> = Vec::new();
|
||||||
|
|
||||||
|
for line_raw in content.split(|&c| c == b'\n') {
|
||||||
|
let line = trim_bytes(line_raw, b" \t\r");
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = String::from_utf8_lossy(line);
|
||||||
|
let data = Format::LIST_WINDOWS.parse(&line).inspect_err(|_| {
|
||||||
|
log::info!("failed to parse list-windows line: {line}");
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Parse the layout
|
||||||
|
let layout_str = data.str(Variable::WindowLayout);
|
||||||
|
let layout = Layout::parse_with_checksum(layout_str.as_bytes()).inspect_err(|_| {
|
||||||
|
log::info!(
|
||||||
|
"failed to parse window layout id={} layout={}",
|
||||||
|
data.usize(Variable::WindowId),
|
||||||
|
layout_str
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
windows.push(Window {
|
||||||
|
id: data.usize(Variable::WindowId),
|
||||||
|
width: data.usize(Variable::WindowWidth),
|
||||||
|
height: data.usize(Variable::WindowHeight),
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup our windows action so the caller can process GUI window
|
||||||
|
// changes.
|
||||||
|
actions.push(Action::Windows(windows.clone()));
|
||||||
|
|
||||||
|
// Sync up our layouts. This will populate unknown panes, prune, etc.
|
||||||
|
// On failure, drop the action we just added (Zig shrinks the list).
|
||||||
|
if let Err(e) = self.sync_layouts(Some(windows)) {
|
||||||
|
actions.pop();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_pane_state(&mut self, content: &[u8]) -> Result<(), Error> {
|
||||||
|
for line_raw in content.split(|&c| c == b'\n') {
|
||||||
|
let line = trim_bytes(line_raw, b" \t\r");
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = String::from_utf8_lossy(line);
|
||||||
|
let data = Format::LIST_PANES.parse(&line).inspect_err(|_| {
|
||||||
|
log::info!("failed to parse list-panes line: {line}");
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get the pane for this ID
|
||||||
|
let pane_id = data.usize(Variable::PaneId);
|
||||||
|
let Some(pane) = self.panes.get_mut(pane_id) else {
|
||||||
|
log::info!("received pane state for untracked pane id={pane_id}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let t = &mut pane.terminal;
|
||||||
|
|
||||||
|
// Determine which screen to use based on alternate_on
|
||||||
|
let screen_key = if data.bool(Variable::AlternateOn) {
|
||||||
|
ScreenKey::Alternate
|
||||||
|
} else {
|
||||||
|
ScreenKey::Primary
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set cursor position on the appropriate screen (tmux uses 0-based)
|
||||||
|
if let Some(screen) = t.screens.get_mut(screen_key) {
|
||||||
|
'cursor: {
|
||||||
|
let Some(cursor_x) = to_cell(data.usize(Variable::CursorX)) else {
|
||||||
|
break 'cursor;
|
||||||
|
};
|
||||||
|
let Some(cursor_y) = to_cell(data.usize(Variable::CursorY)) else {
|
||||||
|
break 'cursor;
|
||||||
|
};
|
||||||
|
if cursor_x >= screen.pages.cols || cursor_y >= screen.pages.rows {
|
||||||
|
break 'cursor;
|
||||||
|
}
|
||||||
|
screen.cursor_absolute(cursor_x, cursor_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cursor shape on this screen
|
||||||
|
match data.str(Variable::CursorShape) {
|
||||||
|
"block" => screen.cursor.cursor_style = CursorStyle::Block,
|
||||||
|
"underline" => screen.cursor.cursor_style = CursorStyle::Underline,
|
||||||
|
"bar" => screen.cursor.cursor_style = CursorStyle::Bar,
|
||||||
|
// "default" or unknown: leave as-is
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set alternate screen saved cursor position
|
||||||
|
if let Some(alt_screen) = t.screens.get_mut(ScreenKey::Alternate) {
|
||||||
|
'cursor: {
|
||||||
|
let Some(alt_x) = to_cell(data.usize(Variable::AlternateSavedX)) else {
|
||||||
|
break 'cursor;
|
||||||
|
};
|
||||||
|
let Some(alt_y) = to_cell(data.usize(Variable::AlternateSavedY)) else {
|
||||||
|
break 'cursor;
|
||||||
|
};
|
||||||
|
// If our coordinates are outside our screen we ignore it.
|
||||||
|
// tmux actually sends MAX_INT for when there isn't a set
|
||||||
|
// cursor position, so this isn't theoretical.
|
||||||
|
if alt_x >= alt_screen.pages.cols || alt_y >= alt_screen.pages.rows {
|
||||||
|
break 'cursor;
|
||||||
|
}
|
||||||
|
alt_screen.cursor_absolute(alt_x, alt_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor visibility / blinking
|
||||||
|
t.modes
|
||||||
|
.set(Mode::CursorVisible, data.bool(Variable::CursorFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::CursorBlinking, data.bool(Variable::CursorBlinking));
|
||||||
|
|
||||||
|
// Terminal modes
|
||||||
|
t.modes.set(Mode::Insert, data.bool(Variable::InsertFlag));
|
||||||
|
t.modes.set(Mode::Wraparound, data.bool(Variable::WrapFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::KeypadKeys, data.bool(Variable::KeypadFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::CursorKeys, data.bool(Variable::KeypadCursorFlag));
|
||||||
|
t.modes.set(Mode::Origin, data.bool(Variable::OriginFlag));
|
||||||
|
|
||||||
|
// Mouse modes
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseEventAny, data.bool(Variable::MouseAllFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseEventButton, data.bool(Variable::MouseAnyFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseEventNormal, data.bool(Variable::MouseButtonFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseEventX10, data.bool(Variable::MouseStandardFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseFormatUtf8, data.bool(Variable::MouseUtf8Flag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::MouseFormatSgr, data.bool(Variable::MouseSgrFlag));
|
||||||
|
|
||||||
|
// Focus and bracketed paste
|
||||||
|
t.modes
|
||||||
|
.set(Mode::FocusEvent, data.bool(Variable::FocusFlag));
|
||||||
|
t.modes
|
||||||
|
.set(Mode::BracketedPaste, data.bool(Variable::BracketedPaste));
|
||||||
|
|
||||||
|
// Scroll region (tmux uses 0-based values)
|
||||||
|
if let (Some(top), Some(bottom)) = (
|
||||||
|
to_cell(data.usize(Variable::ScrollRegionUpper)),
|
||||||
|
to_cell(data.usize(Variable::ScrollRegionLower)),
|
||||||
|
) {
|
||||||
|
t.scrolling_region.top = top;
|
||||||
|
t.scrolling_region.bottom = bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab stops - parse comma-separated list and set
|
||||||
|
t.tabstops.reset(0); // Clear all tabstops first
|
||||||
|
let tabs = data.str(Variable::PaneTabs);
|
||||||
|
if !tabs.is_empty() {
|
||||||
|
for tab_str in tabs.split(',') {
|
||||||
|
let Ok(col) = tab_str.parse::<usize>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(col_cell) = to_cell(col) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if col_cell >= t.cols {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
t.tabstops.set(usize::from(col_cell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_pane_history(
|
||||||
|
&mut self,
|
||||||
|
screen_key: ScreenKey,
|
||||||
|
id: usize,
|
||||||
|
content: &[u8],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let Some(pane) = self.panes.get_mut(id) else {
|
||||||
|
log::info!("received pane history for untracked pane id={id}");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let t = &mut pane.terminal;
|
||||||
|
t.switch_screen(screen_key)?;
|
||||||
|
|
||||||
|
// Send the data as-is into a VT stream. This will populate the
|
||||||
|
// active area too so it won't be exactly correct but we'll get the
|
||||||
|
// active contents soon.
|
||||||
|
{
|
||||||
|
let mut stream = t.vt_stream();
|
||||||
|
stream.next_slice(content).map_err(|e| {
|
||||||
|
log::info!("failed to process pane history for pane id={id}: {e:?}");
|
||||||
|
Error::Stream(format!("{e:?}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the active area to be empty since this is only history.
|
||||||
|
// We'll fill it with blanks and move the cursor to the top-left.
|
||||||
|
t.carriage_return();
|
||||||
|
for _ in 0..t.rows {
|
||||||
|
t.index()?;
|
||||||
|
}
|
||||||
|
t.set_cursor_pos(1, 1);
|
||||||
|
|
||||||
|
// Our active area should be empty
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
let s = t.screens.active();
|
||||||
|
let dump = s.dump_string_alloc(Point::Active(Coordinate::default()))?;
|
||||||
|
debug_assert!(dump.is_empty());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_pane_visible(
|
||||||
|
&mut self,
|
||||||
|
screen_key: ScreenKey,
|
||||||
|
id: usize,
|
||||||
|
content: &[u8],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let Some(pane) = self.panes.get_mut(id) else {
|
||||||
|
log::info!("received pane visible for untracked pane id={id}");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let t = &mut pane.terminal;
|
||||||
|
t.switch_screen(screen_key)?;
|
||||||
|
|
||||||
|
// Erase the active area and reset the cursor to the top-left before
|
||||||
|
// writing the visible content.
|
||||||
|
t.erase_display(ghostty_vt::csi::EraseDisplay::Complete, false);
|
||||||
|
t.set_cursor_pos(1, 1);
|
||||||
|
|
||||||
|
let mut stream = t.vt_stream();
|
||||||
|
stream.next_slice(content).map_err(|e| {
|
||||||
|
log::info!("failed to process pane visible for pane id={id}: {e:?}");
|
||||||
|
Error::Stream(format!("{e:?}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received_output(&mut self, id: usize, data: &[u8]) -> Result<(), Error> {
|
||||||
|
let Some(pane) = self.panes.get_mut(id) else {
|
||||||
|
log::info!("received output for untracked pane id={id}");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut stream = pane.terminal.vt_stream();
|
||||||
|
stream.next_slice(data).map_err(|e| {
|
||||||
|
log::info!("failed to process output for pane id={id}: {e:?}");
|
||||||
|
Error::Stream(format!("{e:?}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enters the command queue state from any other state, queueing the
|
||||||
|
/// commands and returning an action to execute the first command.
|
||||||
|
fn enter_command_queue(&mut self, commands: Vec<Command>) -> Vec<Action> {
|
||||||
|
debug_assert!(self.state != State::CommandQueue);
|
||||||
|
debug_assert!(!commands.is_empty());
|
||||||
|
|
||||||
|
let action = Action::Command(commands[0].format_command());
|
||||||
|
self.command_queue.extend(commands);
|
||||||
|
self.state = State::CommandQueue;
|
||||||
|
vec![action]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue multiple commands to execute. This doesn't return actions
|
||||||
|
/// because the command_queue state will automatically send the next
|
||||||
|
/// command when it receives output.
|
||||||
|
fn queue_commands(&mut self, commands: impl IntoIterator<Item = Command>) {
|
||||||
|
self.command_queue.extend(commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defunct(&mut self) -> Vec<Action> {
|
||||||
|
self.state = State::Defunct;
|
||||||
|
vec![Action::Exit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn n(n: Notification) -> Input {
|
||||||
|
Input::Tmux(n)
|
||||||
|
}
|
||||||
|
fn block_end(s: &str) -> Input {
|
||||||
|
n(Notification::BlockEnd(s.as_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
fn session_changed(id: usize, name: &str) -> Input {
|
||||||
|
n(Notification::SessionChanged {
|
||||||
|
id,
|
||||||
|
name: name.as_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn layout_change_in(window_id: usize, layout: &str) -> Input {
|
||||||
|
n(Notification::LayoutChange {
|
||||||
|
window_id,
|
||||||
|
layout: layout.as_bytes().to_vec(),
|
||||||
|
visible_layout: layout.as_bytes().to_vec(),
|
||||||
|
raw_flags: b"*".to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a step, check commands end in newline (common mistake).
|
||||||
|
fn run(v: &mut Viewer, input: Input) -> Vec<Action> {
|
||||||
|
let actions = v.next(input);
|
||||||
|
for a in &actions {
|
||||||
|
if let Action::Command(c) = a {
|
||||||
|
assert!(c.ends_with(b"\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_tag(actions: &[Action], tag: &str) -> bool {
|
||||||
|
actions.iter().any(|a| a.tag() == tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(actions: &[Action]) -> Vec<String> {
|
||||||
|
actions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| match a {
|
||||||
|
Action::Command(c) => Some(String::from_utf8_lossy(c).into_owned()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_command(actions: &[Action], prefix: &str) {
|
||||||
|
assert!(
|
||||||
|
commands(actions).iter().any(|c| c.starts_with(prefix)),
|
||||||
|
"no command starting with {prefix:?} in {actions:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts exactly one command that targets `-t %id` and `-a` or not.
|
||||||
|
fn check_capture(actions: &[Action], id: usize, alt: bool) {
|
||||||
|
contains_command(actions, "capture-pane");
|
||||||
|
let cmds = commands(actions);
|
||||||
|
assert!(!cmds.is_empty());
|
||||||
|
for c in cmds {
|
||||||
|
assert!(c.contains(&format!("-t %{id}")), "{c}");
|
||||||
|
assert_eq!(c.contains("-a"), alt, "{c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump(v: &Viewer, pane: usize, pt: Point) -> String {
|
||||||
|
v.panes
|
||||||
|
.get(pane)
|
||||||
|
.unwrap()
|
||||||
|
.terminal
|
||||||
|
.screens
|
||||||
|
.active()
|
||||||
|
.dump_string_alloc(pt)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn history() -> Point {
|
||||||
|
Point::History(Coordinate::default())
|
||||||
|
}
|
||||||
|
fn active() -> Point {
|
||||||
|
Point::Active(Coordinate::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common startup through the first list-windows output.
|
||||||
|
fn startup(v: &mut Viewer, session: usize, name: &str, window_line: &str) -> Vec<Action> {
|
||||||
|
run(v, block_end(""));
|
||||||
|
let a = run(v, session_changed(session, name));
|
||||||
|
contains_command(&a, "display-message");
|
||||||
|
let a = run(v, block_end("3.5a"));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
run(v, block_end(window_line))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn immediate_exit() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert_eq!(a.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_changed_resets_state() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
let a = run(&mut v, session_changed(1, "first"));
|
||||||
|
contains_command(&a, "display-message");
|
||||||
|
let a = run(&mut v, block_end("3.5a"));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
block_end("$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert_eq!(v.session_id, 1);
|
||||||
|
assert_eq!(v.windows.len(), 1);
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert_eq!(v.tmux_version, "3.5a");
|
||||||
|
|
||||||
|
// Now session changes - should reset everything but keep version
|
||||||
|
let a = run(&mut v, session_changed(2, "second"));
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
assert_eq!(v.session_id, 2);
|
||||||
|
assert!(
|
||||||
|
a.iter()
|
||||||
|
.any(|x| matches!(x, Action::Windows(w) if w.is_empty()))
|
||||||
|
);
|
||||||
|
assert_eq!(v.windows.len(), 0);
|
||||||
|
assert_eq!(v.panes.count(), 0);
|
||||||
|
assert_eq!(v.tmux_version, "3.5a");
|
||||||
|
|
||||||
|
// Same pane IDs 0,1 - they should be re-created since old panes were cleared
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
block_end("$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert_eq!(v.session_id, 2);
|
||||||
|
assert_eq!(v.windows.len(), 1);
|
||||||
|
assert_eq!(v.windows[0].id, 1);
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert_eq!(v.tmux_version, "3.5a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_flow() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
let a = run(&mut v, session_changed(1, "test"));
|
||||||
|
contains_command(&a, "display-message");
|
||||||
|
let a = run(&mut v, block_end("3.5a"));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
block_end("$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
check_capture(&a, 0, false);
|
||||||
|
|
||||||
|
let a = run(&mut v, block_end("Hello, world!"));
|
||||||
|
check_capture(&a, 0, false);
|
||||||
|
assert_eq!(dump(&v, 0, history()), "Hello, world!");
|
||||||
|
assert_eq!(dump(&v, 0, active()), "");
|
||||||
|
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 0, true);
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 0, true);
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 1, false);
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 1, false);
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 1, true);
|
||||||
|
let a = run(&mut v, block_end(""));
|
||||||
|
check_capture(&a, 1, true);
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
n(Notification::Output {
|
||||||
|
pane_id: 0,
|
||||||
|
data: b"new output".to_vec(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(a.len(), 0);
|
||||||
|
assert!(dump(&v, 0, active()).contains("new output"));
|
||||||
|
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
n(Notification::Output {
|
||||||
|
pane_id: 999,
|
||||||
|
data: b"ignored".to_vec(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(a.len(), 0);
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_change() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = startup(&mut v, 1, "test", "$0 @0 83 44 b7dd,83x44,0,0,0");
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert_eq!(v.windows.len(), 1);
|
||||||
|
assert_eq!(v.panes.count(), 1);
|
||||||
|
assert!(v.panes.contains(0));
|
||||||
|
|
||||||
|
// Complete all capture-pane commands for pane 0 plus pane_state
|
||||||
|
for _ in 0..5 {
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now send a layout_change that splits into two panes
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
layout_change_in(0, "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows"));
|
||||||
|
assert_eq!(v.windows.len(), 1);
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert!(v.panes.contains(0));
|
||||||
|
assert!(v.panes.contains(2));
|
||||||
|
// Commands queued for the new pane (4 capture-pane + 1 pane_state)
|
||||||
|
assert_eq!(v.command_queue_len(), 5);
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_change_does_not_return_command_when_queue_not_empty() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = startup(&mut v, 1, "test", "$0 @0 83 44 b7dd,83x44,0,0,0");
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert!(!v.command_queue_is_empty());
|
||||||
|
|
||||||
|
// Do NOT complete capture-pane commands - queue still has commands.
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
layout_change_in(0, "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows"));
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert!(!has_tag(&a, "command"));
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_change_returns_command_when_queue_was_empty() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = startup(&mut v, 1, "test", "$0 @0 83 44 b7dd,83x44,0,0,0");
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
|
||||||
|
// Complete all capture-pane commands for pane 0
|
||||||
|
for _ in 0..4 {
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
}
|
||||||
|
// Queue should now be empty
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
assert!(v.command_queue_is_empty());
|
||||||
|
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
layout_change_in(0, "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert!(!v.command_queue_is_empty());
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_add_queues_list_windows_when_queue_empty() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = startup(&mut v, 1, "test", "$0 @0 83 44 b7dd,83x44,0,0,0");
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
for _ in 0..4 {
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
}
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
assert!(v.command_queue_is_empty());
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::WindowAdd { id: 1 }));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
assert!(!v.command_queue_is_empty());
|
||||||
|
assert_eq!(v.command_queue_len(), 1);
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_add_queues_list_windows_when_queue_not_empty() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
let a = startup(&mut v, 1, "test", "$0 @0 83 44 b7dd,83x44,0,0,0");
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert!(!v.command_queue_is_empty());
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::WindowAdd { id: 1 }));
|
||||||
|
assert!(!has_tag(&a, "command"));
|
||||||
|
assert!(!v.command_queue_is_empty());
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_pane_flow_with_pane_state() {
|
||||||
|
let mut v = Viewer::new();
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
let a = run(&mut v, session_changed(0, "0"));
|
||||||
|
contains_command(&a, "display-message");
|
||||||
|
assert_eq!(v.session_id, 0);
|
||||||
|
let a = run(&mut v, block_end("3.5a"));
|
||||||
|
contains_command(&a, "list-windows");
|
||||||
|
|
||||||
|
let a = run(
|
||||||
|
&mut v,
|
||||||
|
block_end("$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4]"),
|
||||||
|
);
|
||||||
|
assert!(has_tag(&a, "windows") && has_tag(&a, "command"));
|
||||||
|
assert_eq!(v.windows.len(), 1);
|
||||||
|
let w = &v.windows[0];
|
||||||
|
assert_eq!((w.id, w.width, w.height), (0, 165, 79));
|
||||||
|
assert_eq!(v.panes.count(), 2);
|
||||||
|
assert!(v.panes.contains(0));
|
||||||
|
assert!(v.panes.contains(4));
|
||||||
|
|
||||||
|
// pane 0 primary history / visible
|
||||||
|
run(&mut v, block_end("prompt %\nprompt %"));
|
||||||
|
run(&mut v, block_end("prompt %"));
|
||||||
|
let h = dump(&v, 0, history());
|
||||||
|
assert!(h.matches("prompt %").count() >= 2, "{h:?}");
|
||||||
|
assert_eq!(dump(&v, 0, active()), "prompt %");
|
||||||
|
|
||||||
|
// pane 0 alternate history/visible (empty)
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
// pane 4 primary history / visible
|
||||||
|
run(&mut v, block_end("prompt %"));
|
||||||
|
run(&mut v, block_end("prompt %"));
|
||||||
|
assert_eq!(dump(&v, 4, history()), "prompt %");
|
||||||
|
assert!(dump(&v, 4, active()).starts_with("prompt %"));
|
||||||
|
// pane 4 alternate (empty)
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
run(&mut v, block_end(""));
|
||||||
|
|
||||||
|
// list-panes output with terminal state
|
||||||
|
let tabs = "8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160";
|
||||||
|
let state = format!(
|
||||||
|
"%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;{tabs}\n\
|
||||||
|
%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;{tabs}"
|
||||||
|
);
|
||||||
|
run(&mut v, block_end(&state));
|
||||||
|
|
||||||
|
for (id, x, y) in [(0usize, 42u16, 0u16), (4, 10, 5)] {
|
||||||
|
let t = &v.panes.get(id).unwrap().terminal;
|
||||||
|
let screen = t.screens.get(ScreenKey::Primary).unwrap();
|
||||||
|
assert_eq!(screen.cursor.x, x);
|
||||||
|
assert_eq!(screen.cursor.y, y);
|
||||||
|
assert!(t.modes.get(Mode::CursorVisible));
|
||||||
|
assert!(t.modes.get(Mode::Wraparound));
|
||||||
|
assert!(!t.modes.get(Mode::Insert));
|
||||||
|
assert!(!t.modes.get(Mode::Origin));
|
||||||
|
assert!(!t.modes.get(Mode::KeypadKeys));
|
||||||
|
assert!(!t.modes.get(Mode::CursorKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = run(&mut v, n(Notification::Exit));
|
||||||
|
assert!(has_tag(&a, "exit"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ghostty-terminal: search/* and tmux/* (agent T5)
|
||||||
|
|
||||||
|
## Ported
|
||||||
|
| Zig | Rust | tests (ported/Zig) |
|
||||||
|
|---|---|---|
|
||||||
|
| search.zig | `search/mod.rs` | - |
|
||||||
|
| search/sliding_window.zig | `search/sliding_window.rs` | 25/25 |
|
||||||
|
| search/active.zig | `search/active.rs` | 2/2 |
|
||||||
|
| search/pagelist.zig | `search/pagelist.rs` | 7/7 |
|
||||||
|
| search/viewport.zig | `search/viewport.rs` | 4/4 |
|
||||||
|
| search/screen.zig | `search/screen.rs` | 11/13 |
|
||||||
|
| search/Thread.zig | `search/thread.rs` | 1/1 (unnamed `test {}` -> `search_thread_basic`) + 1 extra (`search_thread_select_and_state`) |
|
||||||
|
| tmux.zig | `tmux/mod.rs` (re-exports `ghostty_vt::tmux::control`) | - |
|
||||||
|
| tmux/layout.zig | `tmux/layout.rs` | 43 (24 layout + 19 checksum) |
|
||||||
|
| tmux/output.zig | `tmux/output.rs` | 37 |
|
||||||
|
| tmux/viewer.zig | `tmux/viewer.rs` | 8/8 |
|
||||||
|
|
||||||
|
Skipped (Zig-only): the two `reloadActive partial history cleanup on ... error` tests in search/screen.zig
|
||||||
|
(tripwire allocator-failure injection). output.zig comptime-format variants are covered by the runtime `format()` tests.
|
||||||
|
|
||||||
|
## Search design / deviations
|
||||||
|
- Uses `crate::highlight::{Flattened, Untracked, Tracked, Chunk}` and `crate::formatter::{PageFormatter, Options}`
|
||||||
|
(plain + `unwrap` with a point map) from T4. `NodeId` carries the node, `Chunk.serial` is the node serial.
|
||||||
|
- Nothing stores `*Screen` / `*PageList`. Everything touching tracked pins takes the screen explicitly:
|
||||||
|
`ScreenSearch::new(&mut Screen, needle)`, `.deinit(&mut Screen)` (must be called to untrack pins),
|
||||||
|
`.feed(&mut Screen)`, `.reload_active(&mut Screen)`, `.select(&mut Screen, Select)`, `.search_all(&mut Screen)`.
|
||||||
|
`tick()`, `matches()`, `selected_match()`, `matches_len()` need no screen. `PageListSearch::{new,feed,deinit}` take `&mut PageList`.
|
||||||
|
`ActiveSearch::update(&PageList) -> Option<NodeId>`, `ViewportSearch::update(&PageList) -> bool` (infallible).
|
||||||
|
`SlidingWindow::append(&PageList, NodeId) -> usize`.
|
||||||
|
- `SlidingWindow` uses `VecDeque` (made contiguous for searching) instead of `CircBuf` + overlap buffer; results are identical.
|
||||||
|
The Zig assertions about circular-buffer slice layout in the "across circular buffer boundary" tests are dropped (behaviour kept).
|
||||||
|
Row counts are cached per page at `append` time. Needle must be non-empty (Zig has the same underflow).
|
||||||
|
`next()` returns an owned `Flattened`, so Zig's `clone()` dance is unnecessary.
|
||||||
|
- `ScreenSearch` caches `HistorySearch.start_node` (Zig reads `start_pin.node` lock-free in `tick`); refreshed in `feed`/`reload_active`.
|
||||||
|
Screen identity (`screen_id`, address) replaces Zig's `*Screen` pointer comparison for "screen was reinitialized" in the thread.
|
||||||
|
- `Thread`: libxev replaced by crossbeam-channel: bounded(64) mailbox + control channel (wakeup/stop) + 24 ms deadline refresh timer.
|
||||||
|
API: `Thread::new(Options)`, `.handle() -> Handle {send,try_send,wakeup,stop}`, `.thread_main()` or `Thread::spawn(opts) -> (Handle, JoinHandle)`.
|
||||||
|
`Options { terminal: Arc<Mutex<Terminal>>, event_cb: Option<Arc<dyn Fn(Event)+Send+Sync>> }`;
|
||||||
|
`Message::{ChangeNeedle(Vec<u8>), Select(Select)}` (Zig `MessageData(u8,255)` -> `Vec<u8>`); `Event` carries owned data
|
||||||
|
(`ViewportMatches(Vec<Flattened>)`). The search is torn down under the lock (pins untracked) when the thread exits or the needle changes.
|
||||||
|
Dependency added: `crossbeam-channel`.
|
||||||
|
- Terminal hook already present: `Terminal.flags.search_viewport_dirty` (the thread reads/clears it).
|
||||||
|
- `cargo fmt -p ghostty-terminal` was run for the whole crate (not just my files).
|
||||||
|
|
||||||
|
## tmux design / deviations
|
||||||
|
- `ghostty_vt::tmux::control` is reused (`tmux::control`, plus `ControlParser`/`ControlNotification` aliases).
|
||||||
|
- output.rs: comptime `FormatStruct` -> `Variable` enum + `FormatValues` (`.bool()/.usize()/.str()`); `Variable::parse` keeps
|
||||||
|
Zig's `InvalidCharacter`/`Overflow`/`FormatError` distinction; `parse_format_struct` folds into `ParseError::FormatError`.
|
||||||
|
- layout.rs: `Layout` owns children (`Vec`), `ParseError::{SyntaxError, ChecksumMismatch}`.
|
||||||
|
- viewer.rs: `Viewer::new()`, `next(Input) -> Vec<Action>` (owned; no arena); `Action::{Command(Vec<u8>), Windows(Vec<..> clone), Exit}`;
|
||||||
|
public `session_id`, `tmux_version`, `windows`, `panes` (insertion-ordered `PanesMap`). `sync_layouts` creates terminals before
|
||||||
|
committing; any command-processing failure goes defunct like Zig. `Notification::Enter` must NOT be fed to the viewer (unreachable, as Zig).
|
||||||
|
Unknown extra `Notification` variants are ignored.
|
||||||
|
|
||||||
|
## TODO(integration) - tmux viewer wiring (nothing in terminal.rs is required)
|
||||||
|
1. In the DCS state kept by `ghostty_vt::dcs`/stream, on DCS `1000p` (`Notification::Enter`) create `Viewer::new()` (do not pass Enter to it).
|
||||||
|
2. Feed every later `ghostty_vt::tmux::control::Parser` notification via `viewer.next(Input::Tmux(n))`.
|
||||||
|
3. Execute returned actions in termio: `Command(bytes)` -> write to pty; `Windows(w)` -> diff in apprt; `Exit` -> drop viewer, leave control mode.
|
||||||
|
4. Pane contents are in `viewer.panes.get(id).terminal`; renderer/apprt render from there.
|
||||||
|
|
||||||
|
## TODO(integration) - search wiring
|
||||||
|
- Core owns the search thread: keep `Handle`; on search-start/changed text send `Message::ChangeNeedle`, navigation sends `Message::Select`.
|
||||||
|
Events arrive on the search thread via `event_cb` (forward to the apprt main loop). The renderer must set
|
||||||
|
`terminal.flags.search_viewport_dirty = true` when it finds viewport/active dirty (as Zig), so the thread re-searches.
|
||||||
Reference in new issue
Block a user