brewery
notashelf /
fb590c1645035cc2537f826f025ac7ea12f5e514

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git
src/grid/search.rsRust148 lines4.8 KB
1
use super::*;
2
3
/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.
4
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
5
struct Match {
6
    row: usize,
7
    col: usize,
8
    len: usize,
9
}
10
11
/// Incremental scrollback search: the query, every hit, and the focused one.
12
#[derive(Clone, Debug, Default)]
13
pub(super) struct SearchState {
14
    query: String,
15
    matches: Vec<Match>,
16
    current: usize,
17
}
18
19
impl Grid {
20
    // --- scrollback search ---
21
22
    /// Set the search query and recompute matches over scrollback + the live
23
    /// screen, focusing the most recent hit and scrolling it into view. An
24
    /// empty query keeps search mode active but clears the hit list.
25
    pub fn set_search(&mut self, query: &str) {
26
        let matches = if query.is_empty() {
27
            Vec::new()
28
        } else {
29
            self.compute_matches(query)
30
        };
31
        let current = matches.len().saturating_sub(1);
32
        self.search = Some(SearchState {
33
            query: query.to_string(),
34
            matches,
35
            current,
36
        });
37
        self.jump_to_current();
38
    }
39
40
    /// Move the focused match `forward` (toward newer) or back (toward older),
41
    /// wrapping, and scroll it into view.
42
    pub fn search_step(&mut self, forward: bool) {
43
        if let Some(s) = self.search.as_mut()
44
            && !s.matches.is_empty()
45
        {
46
            let n = s.matches.len();
47
            s.current = if forward {
48
                (s.current + 1) % n
49
            } else {
50
                (s.current + n - 1) % n
51
            };
52
        }
53
        self.jump_to_current();
54
    }
55
56
    pub fn clear_search(&mut self) {
57
        self.search = None;
58
    }
59
60
    /// The current query, while search mode is active.
61
    pub fn search_query(&self) -> Option<&str> {
62
        self.search.as_ref().map(|s| s.query.as_str())
63
    }
64
65
    /// `(focused match index 1-based, total matches)` for the search prompt.
66
    pub fn search_count(&self) -> (usize, usize) {
67
        self.search.as_ref().map_or((0, 0), |s| {
68
            let total = s.matches.len();
69
            (if total == 0 { 0 } else { s.current + 1 }, total)
70
        })
71
    }
72
73
    /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.
74
    pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {
75
        let Some(s) = self.search.as_ref() else {
76
            return Vec::new();
77
        };
78
        s.matches
79
            .iter()
80
            .enumerate()
81
            .filter(|(_, m)| m.row == row && m.len > 0)
82
            .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))
83
            .collect()
84
    }
85
86
    fn compute_matches(&self, query: &str) -> Vec<Match> {
87
        // Smart case: an uppercase letter in the query forces case sensitivity.
88
        let sensitive = query.chars().any(|c| c.is_ascii_uppercase());
89
        let fold = |c: char| {
90
            if sensitive { c } else { c.to_ascii_lowercase() }
91
        };
92
        let needle: Vec<char> = query.chars().map(fold).collect();
93
        if needle.is_empty() {
94
            return Vec::new();
95
        }
96
        let total = self.scrollback.len() + self.rows;
97
        let mut matches = Vec::new();
98
        for row in 0..total {
99
            let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();
100
            let mut i = 0;
101
            while i + needle.len() <= hay.len() {
102
                if hay[i..i + needle.len()] == needle[..] {
103
                    matches.push(Match {
104
                        row,
105
                        col: i,
106
                        len: needle.len(),
107
                    });
108
                    i += needle.len();
109
                } else {
110
                    i += 1;
111
                }
112
            }
113
        }
114
        matches
115
    }
116
117
    /// Scroll the viewport so the focused match is on screen, centering it only
118
    /// when it would otherwise be off the visible range.
119
    fn jump_to_current(&mut self) {
120
        let Some(abs) = self
121
            .search
122
            .as_ref()
123
            .and_then(|s| s.matches.get(s.current))
124
            .map(|m| m.row)
125
        else {
126
            return;
127
        };
128
        let sb = self.scrollback.len();
129
        let top = sb - self.view_offset;
130
        let bottom = top + self.rows - 1;
131
        if abs < top || abs > bottom {
132
            let target = sb as isize + self.rows as isize / 2 - abs as isize;
133
            self.view_offset = target.clamp(0, sb as isize) as usize;
134
        }
135
    }
136
137
    /// Slide search hits up by `n` rows after scrollback eviction, dropping any
138
    /// that scrolled off the top.
139
    pub(super) fn shift_search(&mut self, n: usize) {
140
        if let Some(s) = self.search.as_mut() {
141
            s.matches.retain(|m| m.row >= n);
142
            for m in &mut s.matches {
143
                m.row -= n;
144
            }
145
            s.current = s.current.min(s.matches.len().saturating_sub(1));
146
        }
147
    }
148
}