scope_cli/cli/tui/
palette.rs1use super::exec::SESSION_COMMANDS;
8use crate::cli::Cli;
9use clap::CommandFactory;
10
11const HISTORY_ITEMS: usize = 50;
13
14#[derive(Debug, Clone)]
16pub struct PaletteItem {
17 pub label: String,
19 pub description: String,
21 pub fill: String,
23}
24
25pub 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
73pub fn score(query: &str, text: &str) -> Option<i32> {
76 if query.is_empty() {
77 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
102pub struct PaletteState {
104 pub query: String,
106 pub items: Vec<PaletteItem>,
108 pub selected: usize,
110}
111
112impl PaletteState {
113 pub fn new(items: Vec<PaletteItem>) -> Self {
115 Self {
116 query: String::new(),
117 items,
118 selected: 0,
119 }
120 }
121
122 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 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}