From 8e9be42da8cd565e5189dd968d2f1057b46ccfc3 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:51:36 +0200 Subject: [PATCH] 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>). 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) --- crates/ghostty-terminal/src/lib.rs | 2 + crates/ghostty-terminal/src/search/active.rs | 154 ++ crates/ghostty-terminal/src/search/mod.rs | 11 + .../ghostty-terminal/src/search/pagelist.rs | 412 +++++ crates/ghostty-terminal/src/search/screen.rs | 1261 ++++++++++++++ .../src/search/sliding_window.rs | 1008 +++++++++++ .../ghostty-terminal/src/search/test_util.rs | 50 + crates/ghostty-terminal/src/search/thread.rs | 867 ++++++++++ .../ghostty-terminal/src/search/viewport.rs | 337 ++++ crates/ghostty-terminal/src/tmux/layout.rs | 467 ++++++ crates/ghostty-terminal/src/tmux/mod.rs | 10 + crates/ghostty-terminal/src/tmux/output.rs | 664 ++++++++ crates/ghostty-terminal/src/tmux/viewer.rs | 1477 +++++++++++++++++ .../notes/ghostty-terminal-search-tmux.md | 64 + 14 files changed, 6784 insertions(+) create mode 100644 crates/ghostty-terminal/src/search/active.rs create mode 100644 crates/ghostty-terminal/src/search/mod.rs create mode 100644 crates/ghostty-terminal/src/search/pagelist.rs create mode 100644 crates/ghostty-terminal/src/search/screen.rs create mode 100644 crates/ghostty-terminal/src/search/sliding_window.rs create mode 100644 crates/ghostty-terminal/src/search/test_util.rs create mode 100644 crates/ghostty-terminal/src/search/thread.rs create mode 100644 crates/ghostty-terminal/src/search/viewport.rs create mode 100644 crates/ghostty-terminal/src/tmux/layout.rs create mode 100644 crates/ghostty-terminal/src/tmux/mod.rs create mode 100644 crates/ghostty-terminal/src/tmux/output.rs create mode 100644 crates/ghostty-terminal/src/tmux/viewer.rs create mode 100644 docs/rewrite/notes/ghostty-terminal-search-tmux.md diff --git a/crates/ghostty-terminal/src/lib.rs b/crates/ghostty-terminal/src/lib.rs index 63c5cae..458dece 100644 --- a/crates/ghostty-terminal/src/lib.rs +++ b/crates/ghostty-terminal/src/lib.rs @@ -12,6 +12,7 @@ pub mod ref_counted_set; pub mod render; pub mod screen; pub mod screen_set; +pub mod search; pub mod selection; pub mod size; pub mod stream_readonly; @@ -19,3 +20,4 @@ pub mod string_map; pub mod style; pub mod tabstops; pub mod terminal; +pub mod tmux; diff --git a/crates/ghostty-terminal/src/search/active.rs b/crates/ghostty-terminal/src/search/active.rs new file mode 100644 index 0000000..3eb7f63 --- /dev/null +++ b/crates/ghostty-terminal/src/search/active.rs @@ -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 { + // 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 = 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 { + 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()); + } +} diff --git a/crates/ghostty-terminal/src/search/mod.rs b/crates/ghostty-terminal/src/search/mod.rs new file mode 100644 index 0000000..67a2a72 --- /dev/null +++ b/crates/ghostty-terminal/src/search/mod.rs @@ -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; diff --git a/crates/ghostty-terminal/src/search/pagelist.rs b/crates/ghostty-terminal/src/search/pagelist.rs new file mode 100644 index 0000000..42b46bc --- /dev/null +++ b/crates/ghostty-terminal/src/search/pagelist.rs @@ -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 { + // 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 { + 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 { + // 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); + } +} diff --git a/crates/ghostty-terminal/src/search/screen.rs b/crates/ghostty-terminal/src/search/screen.rs new file mode 100644 index 0000000..6c03b98 --- /dev/null +++ b/crates/ghostty-terminal/src/search/screen.rs @@ -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, + + /// 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, + + /// 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, + pub active_results: Vec, + + /// 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) 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 { + 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 { + // 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 = 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 = 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 = 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, + ) -> 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 = 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 { + 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 { + // 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 { + // 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 { + // 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, Option) { + 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()); + } +} diff --git a/crates/ghostty-terminal/src/search/sliding_window.rs b/crates/ghostty-terminal/src/search/sliding_window.rs new file mode 100644 index 0000000..e082235 --- /dev/null +++ b/crates/ghostty-terminal/src/search/sliding_window.rs @@ -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, +} + +/// 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, + + /// Metadata about the pages we're searching, oldest first. + meta: VecDeque, + + /// 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, + + 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, Vec) { + let mut opts = Options::plain(); + opts.unwrap = true; + let mut map: Vec = Vec::new(); + let mut out: Vec = 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 { + 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 { + // 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 = 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 = Vec::new(); + // Row counts of each chunk's node (for the reverse fixup). + let mut node_rows: Vec = 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 = 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) {} +} diff --git a/crates/ghostty-terminal/src/search/test_util.rs b/crates/ghostty-terminal/src/search/test_util.rs new file mode 100644 index 0000000..c4dea85 --- /dev/null +++ b/crates/ghostty-terminal/src/search/test_util.rs @@ -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) +} diff --git a/crates/ghostty-terminal/src/search/thread.rs b/crates/ghostty-terminal/src/search/thread.rs new file mode 100644 index 0000000..b54506b --- /dev/null +++ b/crates/ghostty-terminal/src/search/thread.rs @@ -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>`. + +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; + +/// 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>, + + /// 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, +} + +/// 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), + + /// 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), + + /// Matches in the viewport have changed. + ViewportMatches(Vec), +} + +#[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, + ctl: Sender, +} + +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) -> MutexGuard<'_, Terminal> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The search thread state. +pub struct Thread { + opts: Options, + mailbox_tx: Sender, + mailbox_rx: Receiver, + ctl_tx: Sender, + ctl_rx: Receiver, + + /// Search state. Starts as `None` and is populated when a search is + /// started (a needle is given). + search: Option, + + 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; 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, + + /// Last notified selected match index + selected: Option, +} + +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>, 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 = 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, + viewport: Vec, + } + + #[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()); + } +} diff --git a/crates/ghostty-terminal/src/search/viewport.rs b/crates/ghostty-terminal/src/search/viewport.rs new file mode 100644 index 0000000..0ad8877 --- /dev/null +++ b/crates/ghostty-terminal/src/search/viewport.rs @@ -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, + + /// 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, +} + +/// 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, +} + +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 { + 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()); + } +} diff --git a/crates/ghostty-terminal/src/tmux/layout.rs b/crates/ghostty-terminal/src/tmux/layout.rs new file mode 100644 index 0000000..c87d6b4 --- /dev/null +++ b/crates/ghostty-terminal/src/tmux/layout.rs @@ -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), + /// Top-bottom split. + Vertical(Vec), +} + +/// 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 { + // 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 { + 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 { + 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 { + 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 { + 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::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"); + } +} diff --git a/crates/ghostty-terminal/src/tmux/mod.rs b/crates/ghostty-terminal/src/tmux/mod.rs new file mode 100644 index 0000000..08202cc --- /dev/null +++ b/crates/ghostty-terminal/src/tmux/mod.rs @@ -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; diff --git a/crates/ghostty-terminal/src/tmux/output.rs b/crates/ghostty-terminal/src/tmux/output.rs new file mode 100644 index 0000000..8f81fb3 --- /dev/null +++ b/crates/ghostty-terminal/src/tmux/output.rs @@ -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 { + 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 { + 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` 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 + /// `,` 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 { + 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 { + 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 { + 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}" + ); + } +} diff --git a/crates/ghostty-terminal/src/tmux/viewer.rs b/crates/ghostty-terminal/src/tmux/viewer.rs new file mode 100644 index 0000000..a9a7ef2 --- /dev/null +++ b/crates/ghostty-terminal/src/tmux/viewer.rs @@ -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 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), + + /// 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), +} + +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 + '_ { + self.entries.iter().map(|(k, _)| *k) + } + fn take(&mut self, id: usize) -> Option { + 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), +} + +/// 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::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 { + 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::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, + + /// The windows in the current session. + pub windows: Vec, + + /// 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 { + match input { + Input::Tmux(n) => self.next_tmux(n), + } + } + + fn next_tmux(&mut self, n: Notification) -> Vec { + 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 { + 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 { + 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 { + // 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 = 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, + 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>) -> 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, 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, + 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, + content: &[u8], + ) -> Result<(), Error> { + // This stores our new window state from this list-windows output. + let mut windows: Vec = 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::() 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) -> Vec { + 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) { + self.command_queue.extend(commands); + } + + fn defunct(&mut self) -> Vec { + 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 { + 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 { + 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 { + 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")); + } +} diff --git a/docs/rewrite/notes/ghostty-terminal-search-tmux.md b/docs/rewrite/notes/ghostty-terminal-search-tmux.md new file mode 100644 index 0000000..3d03c94 --- /dev/null +++ b/docs/rewrite/notes/ghostty-terminal-search-tmux.md @@ -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`, `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>, event_cb: Option> }`; + `Message::{ChangeNeedle(Vec), Select(Select)}` (Zig `MessageData(u8,255)` -> `Vec`); `Event` carries owned data + (`ViewportMatches(Vec)`). 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` (owned; no arena); `Action::{Command(Vec), 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.