Skip to main content

cli_ui/prompt/
autocomplete.rs

1//! Autocomplete prompt — type to filter, arrows to navigate.
2
3use crate::styles::{paint, DIM, OK, WHITE};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8
9#[derive(Clone)]
10struct AcOption {
11    value: String,
12    label: String,
13    hint: Option<String>,
14}
15
16/// Returned by [`autocomplete`].
17#[derive(Debug, Clone)]
18pub struct AutocompleteSelected {
19    value: String,
20    label: String,
21}
22impl AutocompleteSelected {
23    /// Machine value of the chosen option.
24    pub fn value(&self) -> &str {
25        &self.value
26    }
27    /// Human label of the chosen option.
28    pub fn label(&self) -> &str {
29        &self.label
30    }
31}
32
33/// Builder returned by [`autocomplete()`].
34pub struct AutocompletePrompt {
35    label: String,
36    options: Vec<AcOption>,
37    placeholder: Option<String>,
38    max_items: usize,
39    query: String,
40    cur: usize,
41}
42
43/// Pick one option, filtered live as the user types.
44///
45/// ```no_run
46/// use cli_ui::prompt::autocomplete;
47///
48/// let pkg = autocomplete("Search for a package")
49///     .option("axum",     "axum")
50///     .option("rocket",   "rocket")
51///     .placeholder("type to filter…")
52///     .run()?;
53/// # Ok::<(), cli_ui::prompt::PromptError>(())
54/// ```
55pub fn autocomplete(label: impl Into<String>) -> AutocompletePrompt {
56    AutocompletePrompt {
57        label: label.into(),
58        options: Vec::new(),
59        placeholder: None,
60        max_items: 8,
61        query: String::new(),
62        cur: 0,
63    }
64}
65
66impl AutocompletePrompt {
67    /// Append an option.
68    pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
69        self.options.push(AcOption {
70            value: value.into(),
71            label: label.into(),
72            hint: None,
73        });
74        self
75    }
76    /// Attach a hint to the most recently added option.
77    pub fn hint(mut self, text: impl Into<String>) -> Self {
78        if let Some(last) = self.options.last_mut() {
79            last.hint = Some(text.into());
80        }
81        self
82    }
83    /// Placeholder shown in the search area before the user types.
84    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
85        self.placeholder = Some(p.into());
86        self
87    }
88    /// Maximum number of filtered rows shown at once.
89    pub fn max_items(mut self, n: usize) -> Self {
90        self.max_items = n.max(1);
91        self
92    }
93
94    /// Run the prompt. Panics if no options were added.
95    pub fn run(self) -> Result<AutocompleteSelected> {
96        assert!(!self.options.is_empty(), "autocomplete: no options added");
97        super::core::run(self)
98    }
99
100    fn filter(&self) -> Vec<AcOption> {
101        let q = self.query.to_lowercase();
102        self.options
103            .iter()
104            .filter(|o| {
105                q.is_empty()
106                    || o.label.to_lowercase().contains(&q)
107                    || o.value.to_lowercase().contains(&q)
108            })
109            .take(self.max_items)
110            .cloned()
111            .collect()
112    }
113}
114
115impl Prompt for AutocompletePrompt {
116    type Output = AutocompleteSelected;
117
118    fn handle(&mut self, key: Key) -> Step<AutocompleteSelected> {
119        match key {
120            Key::Char(c) => {
121                self.query.push(c);
122                self.cur = 0;
123                Step::Continue
124            }
125            Key::Backspace => {
126                self.query.pop();
127                self.cur = 0;
128                Step::Continue
129            }
130            Key::Up => {
131                if self.cur > 0 {
132                    self.cur -= 1;
133                }
134                Step::Continue
135            }
136            Key::Down => {
137                if self.cur + 1 < self.filter().len() {
138                    self.cur += 1;
139                }
140                Step::Continue
141            }
142            Key::Enter => {
143                let f = self.filter();
144                if f.is_empty() {
145                    Step::Continue
146                } else {
147                    let opt = &f[self.cur];
148                    Step::Submit(AutocompleteSelected {
149                        value: opt.value.clone(),
150                        label: opt.label.clone(),
151                    })
152                }
153            }
154            Key::Escape | Key::Interrupt => Step::Cancel,
155            _ => Step::Continue,
156        }
157    }
158
159    fn render(&self, _ctx: RenderCtx) -> Frame {
160        let mut f: Frame = Vec::with_capacity(self.max_items + 5);
161        f.push(theme::label(&self.label));
162        f.push(theme::hint(""));
163        let ph = self.placeholder.as_deref().unwrap_or("Type to search...");
164        let search = if self.query.is_empty() {
165            format!(
166                "{}  {} {}",
167                paint(DIM, "│"),
168                paint(DIM, "Search:"),
169                paint(DIM, ph)
170            )
171        } else {
172            format!(
173                "{}  {} {}",
174                paint(DIM, "│"),
175                paint(DIM, "Search:"),
176                paint(super::settings::colors().input, &self.query)
177            )
178        };
179        f.push(search);
180        let filtered = self.filter();
181        if filtered.is_empty() {
182            f.push(format!("{}  {}", paint(DIM, "│"), paint(DIM, "No matches")));
183        } else {
184            for (i, opt) in filtered.iter().enumerate() {
185                let active = i == self.cur;
186                let bullet = if active {
187                    paint(OK, "●")
188                } else {
189                    paint(DIM, "○")
190                };
191                let label = if active {
192                    paint(WHITE, &opt.label)
193                } else {
194                    paint(DIM, &opt.label)
195                };
196                let hint_sfx = theme::hint_inline(opt.hint.as_deref());
197                let hint_col = if active {
198                    hint_sfx
199                } else {
200                    paint(DIM, &hint_sfx)
201                };
202                f.push(format!(
203                    "{}  {} {}{}",
204                    paint(DIM, "│"),
205                    bullet,
206                    label,
207                    hint_col
208                ));
209            }
210        }
211        f.push(theme::hint(
212            "↑/↓ to navigate · Enter to confirm · type to search",
213        ));
214        f.push(theme::frame_bot(None));
215        f
216    }
217
218    fn render_answered(&self, value: &AutocompleteSelected) -> Frame {
219        theme::answered(&self.label, &value.label)
220            .split("\r\n")
221            .map(String::from)
222            .collect()
223    }
224
225    fn run_fallback(self) -> Result<AutocompleteSelected> {
226        use std::io::Write;
227        let mut out = std::io::stderr();
228        writeln!(out, "  {}", self.label).map_err(PromptError::Io)?;
229        for (i, opt) in self.options.iter().enumerate() {
230            match &opt.hint {
231                Some(h) => writeln!(out, "  {}. {} ({})", i + 1, opt.label, h),
232                None => writeln!(out, "  {}. {}", i + 1, opt.label),
233            }
234            .map_err(PromptError::Io)?;
235        }
236        loop {
237            write!(out, "  Enter number (1–{}): ", self.options.len()).map_err(PromptError::Io)?;
238            out.flush().map_err(PromptError::Io)?;
239            let line = super::engine::fallback::read_line_raw()?;
240            if let Ok(n) = line.parse::<usize>() {
241                if n >= 1 && n <= self.options.len() {
242                    let opt = &self.options[n - 1];
243                    return Ok(AutocompleteSelected {
244                        value: opt.value.clone(),
245                        label: opt.label.clone(),
246                    });
247                }
248            }
249            writeln!(
250                out,
251                "  Please enter a number between 1 and {}.",
252                self.options.len()
253            )
254            .map_err(PromptError::Io)?;
255        }
256    }
257}