Skip to main content

scope_cli/cli/tui/
palette.rs

1//! # Command Palette
2//!
3//! `Ctrl-K` opens a fuzzy list of every command (from the clap definition),
4//! the session commands, and recent history. `Enter` puts the selection in
5//! the input line; it does not run it.
6
7use super::exec::SESSION_COMMANDS;
8use crate::cli::Cli;
9use clap::CommandFactory;
10
11/// The most history entries offered.
12const HISTORY_ITEMS: usize = 50;
13
14/// One palette entry.
15#[derive(Debug, Clone)]
16pub struct PaletteItem {
17    /// What the list shows and the query matches.
18    pub label: String,
19    /// The one-line description.
20    pub description: String,
21    /// What goes in the input line.
22    pub fill: String,
23}
24
25/// All palette entries: history (newest first), then CLI commands, then
26/// session commands.
27pub fn items(history: &[String]) -> Vec<PaletteItem> {
28    let mut out = Vec::new();
29    let mut seen = std::collections::HashSet::new();
30    for line in history.iter().rev() {
31        if seen.insert(line.clone()) && seen.len() <= HISTORY_ITEMS {
32            out.push(PaletteItem {
33                label: line.clone(),
34                description: "history".into(),
35                fill: line.clone(),
36            });
37        }
38    }
39    for sub in Cli::command()
40        .get_subcommands()
41        .filter(|s| !s.is_hide_set())
42    {
43        let leaves: Vec<(String, String)> = if sub.has_subcommands() {
44            sub.get_subcommands()
45                .filter(|s| !s.is_hide_set())
46                .map(|l| {
47                    let about = l.get_about().map(|a| a.to_string()).unwrap_or_default();
48                    (format!("{} {}", sub.get_name(), l.get_name()), about)
49                })
50                .collect()
51        } else {
52            let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
53            vec![(sub.get_name().to_string(), about)]
54        };
55        for (label, description) in leaves {
56            out.push(PaletteItem {
57                fill: format!("{} ", label),
58                label,
59                description,
60            });
61        }
62    }
63    for (word, usage, what) in SESSION_COMMANDS {
64        out.push(PaletteItem {
65            label: word.to_string(),
66            description: format!("{} — {}", usage, what),
67            fill: format!("{} ", word),
68        });
69    }
70    out
71}
72
73/// Fuzzy score of `query` against `text`, case-insensitive. `None` when the
74/// letters of `query` are not all in `text` in order. Higher is better.
75pub fn score(query: &str, text: &str) -> Option<i32> {
76    if query.is_empty() {
77        // Everything matches an empty query, equally.
78        return Some(0);
79    }
80    let text: Vec<char> = text.to_lowercase().chars().collect();
81    let mut total = 0;
82    let mut pos = 0;
83    let mut prev: Option<usize> = None;
84    for q in query.to_lowercase().chars() {
85        let found = (pos..text.len()).find(|&i| text[i] == q)?;
86        if found == 0 {
87            total += 20;
88        } else if matches!(text[found - 1], ' ' | '-') {
89            total += 5;
90        }
91        match prev {
92            Some(p) if p + 1 == found => total += 10,
93            Some(p) => total -= (found - p - 1) as i32,
94            None => total -= found as i32,
95        }
96        prev = Some(found);
97        pos = found + 1;
98    }
99    Some(total - (text.len() as i32 / 8))
100}
101
102/// The open palette.
103pub struct PaletteState {
104    /// The typed query.
105    pub query: String,
106    /// All entries.
107    pub items: Vec<PaletteItem>,
108    /// Index into `matches()`.
109    pub selected: usize,
110}
111
112impl PaletteState {
113    /// A palette over `items` with an empty query.
114    pub fn new(items: Vec<PaletteItem>) -> Self {
115        Self {
116            query: String::new(),
117            items,
118            selected: 0,
119        }
120    }
121
122    /// Indexes of the matching items, best first. An empty query keeps the
123    /// original order.
124    pub fn matches(&self) -> Vec<usize> {
125        let mut scored: Vec<(i32, usize)> = self
126            .items
127            .iter()
128            .enumerate()
129            .filter_map(|(i, it)| score(&self.query, &it.label).map(|s| (s, i)))
130            .collect();
131        if !self.query.is_empty() {
132            scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
133        }
134        scored.into_iter().map(|(_, i)| i).collect()
135    }
136}
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_every_visible_leaf_command_is_an_item() {
143        // The same guard as the parity test: a new command shows up here.
144        let labels: Vec<String> = items(&[]).into_iter().map(|i| i.label).collect();
145        for sub in Cli::command()
146            .get_subcommands()
147            .filter(|s| !s.is_hide_set())
148        {
149            if sub.has_subcommands() {
150                for leaf in sub.get_subcommands().filter(|s| !s.is_hide_set()) {
151                    let l = format!("{} {}", sub.get_name(), leaf.get_name());
152                    assert!(labels.contains(&l), "{}", l);
153                }
154            } else {
155                assert!(
156                    labels.contains(&sub.get_name().to_string()),
157                    "{}",
158                    sub.get_name()
159                );
160            }
161        }
162        assert!(labels.contains(&"chain".to_string()));
163    }
164
165    #[test]
166    fn test_history_comes_first_newest_first_and_unique() {
167        let h = vec![
168            "venues list".to_string(),
169            "tx 0x1".to_string(),
170            "venues list".to_string(),
171        ];
172        let it = items(&h);
173        assert_eq!(it[0].label, "venues list");
174        assert_eq!(it[1].label, "tx 0x1");
175        assert_eq!(
176            it.iter().filter(|i| i.label == "venues list").count(),
177            2,
178            "history + command"
179        );
180    }
181
182    #[test]
183    fn test_score_prefers_prefix_and_consecutive_letters() {
184        assert!(score("msum", "market summary").is_some());
185        assert!(score("xyz", "market summary").is_none());
186        assert!(score("mark", "market summary") > score("mark", "compliance risk mark"));
187        assert!(score("addr", "address") > score("addr", "address-book add"));
188        assert_eq!(score("", "anything"), Some(0));
189    }
190
191    #[test]
192    fn test_matches_filters_and_ranks() {
193        let mut p = PaletteState::new(items(&[]));
194        p.query = "msum".into();
195        let m = p.matches();
196        assert_eq!(p.items[m[0]].label, "market summary");
197    }
198}