Skip to main content

cli_ui/prompt/
select_key.rs

1//! Single-keypress option select — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, DIM, WHITE};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8
9#[derive(Clone)]
10struct KeyOption {
11    key: char,
12    label: String,
13    hint: Option<String>,
14}
15
16/// Returned by [`select_key`]: the character pressed and the option's label.
17#[derive(Debug, Clone)]
18pub struct KeySelected {
19    /// The character the user pressed (matches one of the option keys).
20    pub key: char,
21    /// Human-readable label of the chosen option.
22    pub label: String,
23}
24
25/// Builder returned by [`select_key()`].
26pub struct SelectKeyPrompt {
27    label: String,
28    options: Vec<KeyOption>,
29    case_sensitive: bool,
30}
31
32/// Pick one option by a single keypress — no Enter required.
33///
34/// ```no_run
35/// use cli_ui::prompt::select_key;
36///
37/// let answer = select_key("Apply?")
38///     .option('y', "Yes")
39///     .option('n', "No")
40///     .run()?;
41/// # Ok::<(), cli_ui::prompt::PromptError>(())
42/// ```
43pub fn select_key(label: impl Into<String>) -> SelectKeyPrompt {
44    SelectKeyPrompt {
45        label: label.into(),
46        options: Vec::new(),
47        case_sensitive: false,
48    }
49}
50
51impl SelectKeyPrompt {
52    /// Append an option bound to `key`.
53    pub fn option(mut self, key: char, label: impl Into<String>) -> Self {
54        self.options.push(KeyOption {
55            key,
56            label: label.into(),
57            hint: None,
58        });
59        self
60    }
61    /// Attach a hint to the most recently added option.
62    pub fn hint(mut self, text: impl Into<String>) -> Self {
63        if let Some(last) = self.options.last_mut() {
64            last.hint = Some(text.into());
65        }
66        self
67    }
68    /// Require exact case match (default: `false` — `Y` accepts `y`).
69    pub fn case_sensitive(mut self, v: bool) -> Self {
70        self.case_sensitive = v;
71        self
72    }
73
74    /// Run the prompt to completion. Panics if no options were added.
75    pub fn run(self) -> Result<KeySelected> {
76        assert!(!self.options.is_empty(), "select_key: no options added");
77        super::core::run(self)
78    }
79}
80
81impl Prompt for SelectKeyPrompt {
82    type Output = KeySelected;
83
84    fn handle(&mut self, key: Key) -> Step<KeySelected> {
85        match key {
86            Key::Char(c) => {
87                let hit = self.options.iter().find(|o| {
88                    if self.case_sensitive {
89                        o.key == c
90                    } else {
91                        o.key.eq_ignore_ascii_case(&c)
92                    }
93                });
94                match hit {
95                    Some(o) => Step::Submit(KeySelected {
96                        key: o.key,
97                        label: o.label.clone(),
98                    }),
99                    None => Step::Continue,
100                }
101            }
102            Key::Escape | Key::Interrupt => Step::Cancel,
103            _ => Step::Continue,
104        }
105    }
106
107    fn render(&self, _ctx: RenderCtx) -> Frame {
108        let c = super::settings::colors();
109        let mut f: Frame = Vec::with_capacity(self.options.len() + 4);
110        f.push(theme::label(&self.label));
111        f.push(theme::hint(""));
112        // Use the active-frame colour for the in-prompt bars so the whole
113        // option block reads as one highlighted column — matches the rest
114        // of the prompt family (select, multiselect, …).
115        let _ = DIM;
116        for o in &self.options {
117            let badge = paint(c.accent, &format!(" {} ", o.key));
118            let label = paint(WHITE, &o.label);
119            let hint = theme::hint_inline(o.hint.as_deref());
120            f.push(format!(
121                "{}  {} {}{}",
122                paint(c.active, "│"),
123                badge,
124                label,
125                hint
126            ));
127        }
128        f.push(theme::hint("press the highlighted key"));
129        f.push(theme::frame_bot(None));
130        f
131    }
132
133    fn render_answered(&self, value: &KeySelected) -> Frame {
134        theme::answered(&self.label, &value.label)
135            .split("\r\n")
136            .map(String::from)
137            .collect()
138    }
139
140    fn run_fallback(self) -> Result<KeySelected> {
141        use std::io::Write;
142        let mut out = std::io::stderr();
143        writeln!(out, "  {}", self.label).map_err(PromptError::Io)?;
144        for o in &self.options {
145            writeln!(out, "  [{}] {}", o.key, o.label).map_err(PromptError::Io)?;
146        }
147        write!(out, "  Press a key (and Enter): ").map_err(PromptError::Io)?;
148        out.flush().map_err(PromptError::Io)?;
149        let line = super::engine::fallback::read_line_raw()?;
150        let ch = line.chars().next().ok_or(PromptError::Interrupted)?;
151        let hit = self
152            .options
153            .iter()
154            .find(|o| {
155                if self.case_sensitive {
156                    o.key == ch
157                } else {
158                    o.key.eq_ignore_ascii_case(&ch)
159                }
160            })
161            .ok_or(PromptError::Interrupted)?;
162        Ok(KeySelected {
163            key: hit.key,
164            label: hit.label.clone(),
165        })
166    }
167}