Skip to main content

improv_gui/
function_help.rs

1//! The function help browser (Help ▸ Functions…, F1), window-free: the
2//! search, its results grouped by category, and the selection in them. The
3//! app draws the window from a [`Browser`].
4
5use improv_core_model::functions::{self, Category, FnDef};
6
7/// The reference's title for a category.
8pub fn category_title(c: Category) -> &'static str {
9    match c {
10        Category::Aggregation => "Aggregations",
11        Category::Logical => "Logical",
12        Category::Information => "Information and errors",
13        Category::Math => "Math and trigonometry",
14        Category::Rounding => "Rounding",
15        Category::Text => "Text",
16        Category::DateTime => "Date and time",
17        Category::Statistical => "Statistical",
18        Category::Financial => "Financial",
19        Category::Dimensional => "Dimensional",
20        Category::Engineering => "Engineering",
21    }
22}
23
24/// Functions in one category, in result order.
25pub type Group = (Category, Vec<&'static FnDef>);
26
27/// [`functions::search`]'s results for `query`, grouped by category. With
28/// a query the groups come in order of their best match; without one, in
29/// category order, each sorted by name.
30pub fn groups(query: &str) -> Vec<Group> {
31    let mut out: Vec<Group> = Vec::new();
32    for d in functions::search(query) {
33        match out.iter_mut().find(|(c, _)| *c == d.category) {
34            Some((_, fs)) => fs.push(d),
35            None => out.push((d.category, vec![d])),
36        }
37    }
38    if query.trim().is_empty() {
39        out.sort_by_key(|(c, _)| *c);
40    }
41    out
42}
43
44/// `also AVG, MEAN` for a function with aliases.
45pub fn aliases(d: &FnDef) -> Option<String> {
46    (!d.aliases.is_empty()).then(|| format!("also {}", d.aliases.join(", ")))
47}
48
49/// Insert a call of `name` into `text` at byte `caret`: `NAME(`, the caret
50/// after the `(`. A word typed before the caret that starts the name
51/// (`pm` for PMT) is replaced, and a `(` already after the caret is reused.
52pub fn insert_call(text: &str, caret: usize, name: &str) -> (String, usize) {
53    let caret = caret.min(text.len());
54    let caret = (0..=caret)
55        .rev()
56        .find(|&i| text.is_char_boundary(i))
57        .unwrap_or(0);
58    let word = text[..caret]
59        .char_indices()
60        .rev()
61        .take_while(|&(_, c)| c.is_alphanumeric() || c == '_' || c == '.')
62        .last()
63        .map_or(caret, |(i, _)| i);
64    let typed = &text[word..caret];
65    let start = if name.len() >= typed.len() && name[..typed.len()].eq_ignore_ascii_case(typed) {
66        word
67    } else {
68        caret
69    };
70    let after = &text[caret..];
71    let paren = if after.starts_with('(') { "" } else { "(" };
72    let out = format!("{}{name}{paren}{after}", &text[..start]);
73    (out, start + name.len() + 1)
74}
75
76/// The browser's state: the search line, its grouped results, and the
77/// selected function.
78#[derive(Default)]
79pub struct Browser {
80    pub open: bool,
81    pub query: String,
82    pub selected: Option<&'static FnDef>,
83    /// Give the search line focus next frame (just opened).
84    pub focus_search: bool,
85    /// The results, and the query they are for.
86    results: Option<(String, Vec<Group>)>,
87}
88
89impl Browser {
90    /// Open it, or close it if open.
91    pub fn toggle(&mut self) {
92        self.open = !self.open;
93        self.focus_search = self.open;
94    }
95
96    /// The results for the current query, recomputed when it changed; a
97    /// selection the new results lack moves to their first function.
98    pub fn groups(&mut self) -> &[Group] {
99        if self.results.as_ref().is_none_or(|(q, _)| *q != self.query) {
100            let g = groups(&self.query);
101            let kept = self
102                .selected
103                .filter(|s| g.iter().any(|(_, fs)| fs.iter().any(|d| d.id == s.id)));
104            self.selected = kept.or_else(|| g.first().map(|(_, fs)| fs[0]));
105            self.results = Some((self.query.clone(), g));
106        }
107        &self.results.as_ref().expect("just set").1
108    }
109
110    /// Move the selection `delta` functions along the list (clamped).
111    pub fn step(&mut self, delta: isize) {
112        let sel = self.selected.map(|s| s.id);
113        let all: Vec<&'static FnDef> = self
114            .groups()
115            .iter()
116            .flat_map(|(_, fs)| fs.iter().copied())
117            .collect();
118        let Some(last) = all.len().checked_sub(1) else {
119            return;
120        };
121        let at = all.iter().position(|d| Some(d.id) == sel).unwrap_or(0);
122        let to = at.saturating_add_signed(delta).min(last);
123        self.selected = Some(all[to]);
124    }
125}
126
127#[cfg(test)]
128#[cfg_attr(coverage_nightly, coverage(off))]
129mod tests {
130    use super::*;
131
132    fn names(g: &[Group]) -> Vec<(Category, Vec<&str>)> {
133        g.iter()
134            .map(|(c, fs)| (*c, fs.iter().map(|d| d.name).collect()))
135            .collect()
136    }
137
138    #[test]
139    fn results_are_grouped_by_category() {
140        let all = groups("");
141        let cats: Vec<Category> = all.iter().map(|(c, _)| *c).collect();
142        let mut sorted = cats.clone();
143        sorted.sort();
144        sorted.dedup();
145        assert_eq!(cats, sorted, "every category once, in order");
146        let n: usize = all.iter().map(|(_, fs)| fs.len()).sum();
147        assert_eq!(n, functions::search("").len(), "every function once");
148        for (c, fs) in &all {
149            assert!(fs.iter().all(|d| d.category == *c));
150            assert!(fs.windows(2).all(|w| w[0].name <= w[1].name), "{c:?}");
151        }
152    }
153
154    #[test]
155    fn a_query_filters_and_its_best_match_leads() {
156        let g = groups("pmt");
157        let (cat, fs) = &names(&g)[0];
158        assert_eq!(*cat, Category::Financial);
159        assert_eq!(fs[0], "PMT", "the exact name first");
160        assert!(fs.contains(&"IPMT") && fs.contains(&"PPMT"), "{fs:?}");
161        // An alias finds its function.
162        let g = groups("avg");
163        assert_eq!(g[0].1[0].name, "AVERAGE");
164        // The description is searched too.
165        assert!(groups("payment")
166            .iter()
167            .any(|(_, fs)| fs.iter().any(|d| d.name == "PMT")));
168        assert!(groups("no such thing at all").is_empty());
169    }
170
171    #[test]
172    fn the_browser_keeps_or_moves_its_selection() {
173        let mut b = Browser::default();
174        b.toggle();
175        assert!(b.open && b.focus_search);
176        b.query = "pmt".into();
177        assert_eq!(b.groups()[0].1[0].name, "PMT");
178        assert_eq!(b.selected.map(|d| d.name), Some("PMT"));
179        let second = b.groups()[0].1[1].name;
180        b.step(1);
181        assert_eq!(b.selected.map(|d| d.name), Some(second));
182        b.step(-5);
183        assert_eq!(b.selected.map(|d| d.name), Some("PMT"), "clamped");
184        b.step(1);
185        // Still a result: kept. Gone: the first result.
186        b.query = second.to_lowercase();
187        let _ = b.groups();
188        assert_eq!(b.selected.map(|d| d.name), Some(second));
189        b.query = "abs".into();
190        assert_eq!(b.groups()[0].1[0].name, "ABS");
191        assert_eq!(b.selected.map(|d| d.name), Some("ABS"));
192        b.query = "zzzzzzzz".into();
193        assert!(b.groups().is_empty());
194        assert!(b.selected.is_none());
195        b.step(1);
196        assert!(b.selected.is_none());
197        b.toggle();
198        assert!(!b.open && !b.focus_search);
199    }
200
201    #[test]
202    fn inserting_a_call_at_the_caret() {
203        assert_eq!(insert_call("", 0, "PMT"), ("PMT(".into(), 4));
204        assert_eq!(insert_call("1 + ", 4, "PMT"), ("1 + PMT(".into(), 8));
205        assert_eq!(
206            insert_call("1 +  * 2", 4, "ABS"),
207            ("1 + ABS( * 2".into(), 8)
208        );
209        // A typed start of the name is replaced; another word is kept.
210        assert_eq!(insert_call("1 + pm", 6, "PMT"), ("1 + PMT(".into(), 8));
211        assert_eq!(insert_call("Price", 5, "ABS"), ("PriceABS(".into(), 9));
212        // A `(` already there is reused; the caret goes past it.
213        assert_eq!(insert_call("PM(1)", 2, "PMT"), ("PMT(1)".into(), 4));
214        // A caret past the end or inside a char is pulled back.
215        assert_eq!(insert_call("é", 1, "PI"), ("PI(é".into(), 3));
216        assert_eq!(insert_call("x", 99, "PI"), ("xPI(".into(), 4));
217    }
218
219    #[test]
220    fn titles_and_aliases() {
221        assert_eq!(category_title(Category::DateTime), "Date and time");
222        let avg = functions::by_name("AVERAGE").find(|d| !d.aliases.is_empty());
223        assert!(aliases(avg.expect("AVERAGE has an alias")).is_some_and(|a| a.starts_with("also ")));
224        let pmt = functions::by_name("PMT").next().expect("PMT");
225        assert_eq!(aliases(pmt), None);
226    }
227}