Skip to main content

cli_ui/prompt/
select.rs

1//! Single-choice select — built on the [`Prompt`] trait.
2
3use super::core::{Frame, Key, Prompt, RenderCtx, Step};
4use super::error::{PromptError, Result};
5use super::limit_options::limit_options;
6use super::theme;
7
8#[derive(Clone)]
9struct SelectOption {
10    value: String,
11    label: String,
12    hint: Option<String>,
13}
14
15/// Returned by [`select`] — the chosen option's index, machine-readable
16/// `value`, and human-readable `label`.
17#[derive(Debug, Clone)]
18pub struct Selected {
19    /// Zero-based position of the chosen option in the order it was added.
20    pub index: usize,
21    /// The first argument to `.option(value, label)` — typically used
22    /// programmatically (e.g. as a config key).
23    pub value: String,
24    /// The second argument — the human label.
25    pub label: String,
26}
27
28impl Selected {
29    /// Position of the chosen option (zero-based).
30    pub fn index(&self) -> usize {
31        self.index
32    }
33    /// The chosen option's machine value.
34    pub fn value(&self) -> &str {
35        &self.value
36    }
37    /// The chosen option's human label.
38    pub fn label(&self) -> &str {
39        &self.label
40    }
41}
42
43/// Builder returned by [`select()`].
44pub struct SelectPrompt {
45    label: String,
46    options: Vec<SelectOption>,
47    prompt_hint: Option<String>,
48    max_items: usize,
49    pos: usize,
50}
51
52/// Ask the user to pick one option from a list.
53///
54/// ```no_run
55/// use cli_ui::prompt::select;
56///
57/// let stack = select("Pick a stack")
58///     .option("rust", "Rust")
59///     .option("ts",   "TypeScript")
60///     .run()?;
61/// # Ok::<(), cli_ui::prompt::PromptError>(())
62/// ```
63pub fn select(label: impl Into<String>) -> SelectPrompt {
64    SelectPrompt {
65        label: label.into(),
66        options: Vec::new(),
67        prompt_hint: None,
68        max_items: 10,
69        pos: 0,
70    }
71}
72
73impl SelectPrompt {
74    /// Append an option. `value` is the machine identifier, `label` is what
75    /// the user sees. Chain `.hint("…")` immediately after to attach a hint
76    /// to this option.
77    pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
78        self.options.push(SelectOption {
79            value: value.into(),
80            label: label.into(),
81            hint: None,
82        });
83        self
84    }
85    /// Attach a hint to the **most recently added** option. Renders inline
86    /// next to the option label, e.g. `Rust (systems programming)`.
87    pub fn hint(mut self, text: impl Into<String>) -> Self {
88        if let Some(last) = self.options.last_mut() {
89            last.hint = Some(text.into());
90        }
91        self
92    }
93    /// One-line hint shown under the prompt label (not per-option).
94    pub fn prompt_hint(mut self, text: impl Into<String>) -> Self {
95        self.prompt_hint = Some(text.into());
96        self
97    }
98    /// Index of the initially selected option.
99    pub fn default(mut self, index: usize) -> Self {
100        self.pos = index;
101        self
102    }
103    /// Number of option rows visible at once. Longer lists scroll with
104    /// `↑ N more` / `↓ N more` indicators.
105    pub fn max_items(mut self, n: usize) -> Self {
106        self.max_items = n.max(1);
107        self
108    }
109
110    /// Run the prompt to completion. Panics if no options were added.
111    pub fn run(mut self) -> Result<Selected> {
112        assert!(!self.options.is_empty(), "select: no options added");
113        self.pos = self.pos.min(self.options.len() - 1);
114        super::core::run(self)
115    }
116}
117
118impl Prompt for SelectPrompt {
119    type Output = Selected;
120
121    fn handle(&mut self, key: Key) -> Step<Selected> {
122        let vim = super::settings::get().vim_keys;
123        let last = self.options.len() - 1;
124        match key {
125            Key::Up | Key::Char('k') if vim => {
126                if self.pos > 0 {
127                    self.pos -= 1;
128                }
129                Step::Continue
130            }
131            Key::Down | Key::Char('j') if vim => {
132                if self.pos < last {
133                    self.pos += 1;
134                }
135                Step::Continue
136            }
137            Key::Up => {
138                if self.pos > 0 {
139                    self.pos -= 1;
140                }
141                Step::Continue
142            }
143            Key::Down => {
144                if self.pos < last {
145                    self.pos += 1;
146                }
147                Step::Continue
148            }
149            Key::Char('q') if vim => Step::Cancel,
150            Key::Enter => {
151                let opt = &self.options[self.pos];
152                Step::Submit(Selected {
153                    index: self.pos,
154                    value: opt.value.clone(),
155                    label: opt.label.clone(),
156                })
157            }
158            Key::Escape | Key::Interrupt => Step::Cancel,
159            _ => Step::Continue,
160        }
161    }
162
163    fn render(&self, _ctx: RenderCtx) -> Frame {
164        let mut f: Frame = Vec::with_capacity(self.max_items + 4);
165        f.push(theme::label(&self.label));
166        if let Some(ref h) = self.prompt_hint {
167            f.push(theme::hint(h));
168        }
169        f.push(theme::hint(""));
170        let vp = limit_options(self.options.len(), self.pos, self.max_items);
171        if vp.above > 0 {
172            f.push(theme::hint(&format!("↑ {} more", vp.above)));
173        }
174        for i in vp.start..vp.end {
175            let opt = &self.options[i];
176            f.push(theme::cursor(
177                i == self.pos,
178                &opt.label,
179                opt.hint.as_deref(),
180            ));
181        }
182        if vp.below > 0 {
183            f.push(theme::hint(&format!("↓ {} more", vp.below)));
184        }
185        f.push(theme::frame_bot(None));
186        f
187    }
188
189    fn render_answered(&self, value: &Selected) -> Frame {
190        theme::answered(&self.label, &value.label)
191            .split("\r\n")
192            .map(String::from)
193            .collect()
194    }
195
196    fn run_fallback(self) -> Result<Selected> {
197        use std::io::Write;
198        let mut out = std::io::stderr();
199        writeln!(out, "  {}", self.label).map_err(PromptError::Io)?;
200        for (i, opt) in self.options.iter().enumerate() {
201            match &opt.hint {
202                Some(h) => writeln!(out, "  {}. {} ({})", i + 1, opt.label, h),
203                None => writeln!(out, "  {}. {}", i + 1, opt.label),
204            }
205            .map_err(PromptError::Io)?;
206        }
207        loop {
208            write!(out, "  Enter number (1–{}): ", self.options.len()).map_err(PromptError::Io)?;
209            out.flush().map_err(PromptError::Io)?;
210            let line = super::engine::fallback::read_line_raw()?;
211            if let Ok(n) = line.parse::<usize>() {
212                if n >= 1 && n <= self.options.len() {
213                    let opt = &self.options[n - 1];
214                    return Ok(Selected {
215                        index: n - 1,
216                        value: opt.value.clone(),
217                        label: opt.label.clone(),
218                    });
219                }
220            }
221            writeln!(
222                out,
223                "  Please enter a number between 1 and {}.",
224                self.options.len()
225            )
226            .map_err(PromptError::Io)?;
227        }
228    }
229}