Skip to main content

improv_tui/
fnhelp.rs

1//! Function help overlay state (`?` / F1): a search line over the built-in
2//! functions, the ranked results, and the selection in them. Pure state,
3//! terminal-free; `ui` draws it and `main` feeds it keys.
4
5use improv_core_model::functions::{self, FnDef};
6
7/// The open help overlay.
8pub struct FnHelp {
9    /// The search line, filtered through [`functions::search`].
10    pub query: String,
11    /// Functions matching `query`, best first.
12    pub results: Vec<&'static FnDef>,
13    /// Index of the selected result.
14    pub selected: usize,
15    /// First visible result row; the renderer scrolls it to the selection.
16    pub top: usize,
17    /// Rows the result list showed last render; PgUp/PgDn move by this.
18    pub page: usize,
19}
20
21impl FnHelp {
22    /// An overlay listing every function, the first one selected.
23    pub fn new() -> FnHelp {
24        let mut h = FnHelp {
25            query: String::new(),
26            results: Vec::new(),
27            selected: 0,
28            top: 0,
29            page: 10,
30        };
31        h.refilter();
32        h
33    }
34
35    /// Type a character into the search line.
36    pub fn push(&mut self, c: char) {
37        self.query.push(c);
38        self.refilter();
39    }
40
41    /// Delete the last search character.
42    pub fn pop(&mut self) {
43        self.query.pop();
44        self.refilter();
45    }
46
47    fn refilter(&mut self) {
48        self.results = functions::search(&self.query);
49        self.selected = 0;
50        self.top = 0;
51    }
52
53    /// Move the selection by `delta` rows, clamped to the results.
54    pub fn step(&mut self, delta: isize) {
55        let max = self.results.len().saturating_sub(1) as isize;
56        self.selected = (self.selected as isize + delta).clamp(0, max) as usize;
57    }
58
59    /// Move the selection a page up (`-1`) or down (`1`).
60    pub fn page_by(&mut self, dir: isize) {
61        self.step(dir * self.page.max(1) as isize);
62    }
63
64    /// The selected function, if any result matched.
65    pub fn current(&self) -> Option<&'static FnDef> {
66        self.results.get(self.selected).copied()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn search_filters_and_ranks() {
76        let mut h = FnHelp::new();
77        assert!(h.results.len() > 100, "empty query lists everything");
78        h.page = 5;
79        h.page_by(1);
80        assert_eq!(h.selected, 5);
81        for c in "pmt".chars() {
82            h.push(c);
83        }
84        assert_eq!(h.selected, 0, "typing resets the selection");
85        assert_eq!(h.current().unwrap().name, "PMT", "exact name first");
86        h.pop();
87        assert_eq!(h.query, "pm");
88        h.query.clear();
89        h.push('z');
90        h.push('z');
91        h.push('z');
92        h.push('q');
93        assert!(h.results.is_empty());
94        assert!(h.current().is_none());
95        h.step(1);
96        assert_eq!(h.selected, 0, "no results: nothing to move to");
97    }
98
99    #[test]
100    fn selection_clamps_to_the_list() {
101        let mut h = FnHelp::new();
102        h.step(-3);
103        assert_eq!(h.selected, 0);
104        h.page = 0; // a zero-row render still pages by one
105        h.page_by(1);
106        assert_eq!(h.selected, 1);
107        h.step(isize::MAX / 2);
108        assert_eq!(h.selected, h.results.len() - 1);
109    }
110}