Skip to main content

cli_ui/prompt/
text.rs

1//! Free-text and masked password prompts — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, ACCENT, DIM, WHITE};
4use std::io::Write;
5
6use super::core::{Frame, Key, Prompt, RenderCtx, Step};
7use super::cursor::LineBuf;
8use super::error::{PromptError, Result};
9use super::theme;
10use super::validate::Validator;
11
12// ─────────────────────────────────────────────────────────────────────────────
13// TextPrompt
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Builder returned by [`text()`]. Configure with the chainable methods,
17/// then call [`run`](Self::run) to read a line from the user.
18pub struct TextPrompt {
19    label: String,
20    default: Option<String>,
21    placeholder: Option<String>,
22    hint: Option<String>,
23    validate: Option<Validator>,
24    buf: LineBuf,
25}
26
27/// Ask the user for one line of text.
28///
29/// ```no_run
30/// use cli_ui::prompt::text;
31///
32/// let name = text("Your name")
33///     .placeholder("Anya")
34///     .default("Alice")
35///     .run()?;
36/// # Ok::<(), cli_ui::prompt::PromptError>(())
37/// ```
38pub fn text(label: impl Into<String>) -> TextPrompt {
39    TextPrompt {
40        label: label.into(),
41        default: None,
42        placeholder: None,
43        hint: None,
44        validate: None,
45        buf: LineBuf::new(),
46    }
47}
48
49impl TextPrompt {
50    /// Value used when the user submits an empty buffer.
51    pub fn default(mut self, v: impl Into<String>) -> Self {
52        self.default = Some(v.into());
53        self
54    }
55    /// Dim text shown in the input area before the user types anything.
56    pub fn placeholder(mut self, v: impl Into<String>) -> Self {
57        self.placeholder = Some(v.into());
58        self
59    }
60    /// One-line hint rendered under the label. Only visible when
61    /// [`settings::Settings::show_hints`](super::settings::Settings::show_hints)
62    /// is `true`.
63    pub fn hint(mut self, v: impl Into<String>) -> Self {
64        self.hint = Some(v.into());
65        self
66    }
67    /// Validate with an ad-hoc closure. Return `Ok(())` to accept,
68    /// `Err(msg)` to reject and show `msg` as the error.
69    pub fn validate<F>(mut self, f: F) -> Self
70    where
71        F: Fn(&str) -> std::result::Result<(), String> + Send + Sync + 'static,
72    {
73        self.validate = Some(Validator::new(f));
74        self
75    }
76    /// Apply a composed [`Validator`] from the rules library — e.g.
77    /// `.rule(min_chars(8).and(has_upper()))`. Reads as "this prompt obeys
78    /// these rules."
79    pub fn rule(mut self, v: Validator) -> Self {
80        self.validate = Some(v);
81        self
82    }
83
84    /// Deprecated alias for [`rule`](Self::rule).
85    #[deprecated(since = "0.1.0", note = "use `.rule(...)` instead")]
86    pub fn validate_with(self, v: Validator) -> Self {
87        self.rule(v)
88    }
89
90    /// Run the prompt to completion.
91    pub fn run(self) -> Result<String> {
92        super::core::run(self)
93    }
94
95    fn submit_value(&self) -> String {
96        if self.buf.is_empty() {
97            self.default.clone().unwrap_or_default()
98        } else {
99            self.buf.value().to_string()
100        }
101    }
102}
103
104impl Prompt for TextPrompt {
105    type Output = String;
106
107    fn handle(&mut self, key: Key) -> Step<String> {
108        match key {
109            Key::Enter => {
110                let value = self.submit_value();
111                match self.validate.as_ref().map(|v| v.check(&value)) {
112                    Some(Err(msg)) => Step::Reject(msg),
113                    _ => Step::Submit(value),
114                }
115            }
116            Key::Char(c) => {
117                self.buf.insert(c);
118                Step::Continue
119            }
120            Key::Backspace => {
121                self.buf.backspace();
122                Step::Continue
123            }
124            Key::Delete => {
125                self.buf.delete();
126                Step::Continue
127            }
128            Key::DeleteWordBack => {
129                self.buf.delete_word_back();
130                Step::Continue
131            }
132            Key::Left => {
133                self.buf.left();
134                Step::Continue
135            }
136            Key::Right => {
137                self.buf.right();
138                Step::Continue
139            }
140            Key::Home => {
141                self.buf.home();
142                Step::Continue
143            }
144            Key::End => {
145                self.buf.end();
146                Step::Continue
147            }
148            Key::Escape | Key::Interrupt => Step::Cancel,
149            _ => Step::Continue,
150        }
151    }
152
153    fn render(&self, ctx: RenderCtx) -> Frame {
154        let mut f: Frame = Vec::with_capacity(4);
155        let err = ctx.error;
156        f.push(theme::label_state(&self.label, err.is_some()));
157        if super::settings::get().show_hints {
158            if let Some(ref h) = self.hint {
159                f.push(theme::hint(h));
160            }
161        }
162        let placeholder = self
163            .placeholder
164            .as_deref()
165            .or(self.default.as_deref())
166            .unwrap_or("");
167        f.push(format!(
168            "{}  {}",
169            theme::input_bar(err.is_some()),
170            self.buf.with_placeholder(placeholder)
171        ));
172        f.push(theme::frame_bot(err));
173        f
174    }
175
176    fn render_answered(&self, value: &String) -> Frame {
177        theme::answered(&self.label, value)
178            .split("\r\n")
179            .map(String::from)
180            .collect()
181    }
182
183    fn run_fallback(self) -> Result<String> {
184        let hint = self
185            .default
186            .as_deref()
187            .map(|d| format!(" (default: {d})"))
188            .unwrap_or_default();
189        eprint!(
190            "{}  {}{}: ",
191            paint(ACCENT, "◇"),
192            paint(WHITE, &self.label),
193            paint(DIM, &hint)
194        );
195        std::io::stderr().flush().map_err(PromptError::Io)?;
196        loop {
197            let line = super::engine::fallback::read_line_raw()?;
198            let value = if line.is_empty() {
199                self.default.clone().unwrap_or_default()
200            } else {
201                line
202            };
203            if let Some(ref v) = self.validate {
204                if let Err(msg) = v.check(&value) {
205                    eprint!("  ✘ {msg}\n  Try again: ");
206                    std::io::stderr().flush().map_err(PromptError::Io)?;
207                    continue;
208                }
209            }
210            return Ok(value);
211        }
212    }
213}
214
215// ─────────────────────────────────────────────────────────────────────────────
216// SecretPrompt
217// ─────────────────────────────────────────────────────────────────────────────
218
219/// Builder returned by [`secret()`].
220pub struct SecretPrompt {
221    label: String,
222    allow_empty: bool,
223    hint: Option<String>,
224    validate: Option<Validator>,
225    buf: String,
226}
227
228/// Ask the user for a masked line of text (passwords, API keys).
229///
230/// Each character is shown as `•`. The submitted value contains the real
231/// characters; only the on-screen rendering is masked.
232///
233/// ```no_run
234/// use cli_ui::prompt::secret;
235///
236/// let token = secret("API token").run()?;
237/// # Ok::<(), cli_ui::prompt::PromptError>(())
238/// ```
239pub fn secret(label: impl Into<String>) -> SecretPrompt {
240    SecretPrompt {
241        label: label.into(),
242        allow_empty: false,
243        hint: None,
244        validate: None,
245        buf: String::new(),
246    }
247}
248
249impl SecretPrompt {
250    /// Allow submitting an empty value (default: `false`).
251    pub fn allow_empty(mut self, v: bool) -> Self {
252        self.allow_empty = v;
253        self
254    }
255    /// One-line hint rendered under the label (gated by
256    /// [`settings::Settings::show_hints`](super::settings::Settings::show_hints)).
257    pub fn hint(mut self, v: impl Into<String>) -> Self {
258        self.hint = Some(v.into());
259        self
260    }
261    /// Validate with an ad-hoc closure.
262    pub fn validate<F>(mut self, f: F) -> Self
263    where
264        F: Fn(&str) -> std::result::Result<(), String> + Send + Sync + 'static,
265    {
266        self.validate = Some(Validator::new(f));
267        self
268    }
269    /// Apply a composed [`Validator`] from the rules library.
270    pub fn rule(mut self, v: Validator) -> Self {
271        self.validate = Some(v);
272        self
273    }
274
275    /// Deprecated alias for [`rule`](Self::rule).
276    #[deprecated(since = "0.1.0", note = "use `.rule(...)` instead")]
277    pub fn validate_with(self, v: Validator) -> Self {
278        self.rule(v)
279    }
280
281    /// Run the prompt to completion.
282    pub fn run(self) -> Result<String> {
283        super::core::run(self)
284    }
285}
286
287impl Prompt for SecretPrompt {
288    type Output = String;
289
290    fn handle(&mut self, key: Key) -> Step<String> {
291        match key {
292            Key::Enter => {
293                if self.buf.is_empty() && !self.allow_empty {
294                    return Step::Reject("Value is required".into());
295                }
296                match self.validate.as_ref().map(|v| v.check(&self.buf)) {
297                    Some(Err(msg)) => Step::Reject(msg),
298                    _ => Step::Submit(self.buf.clone()),
299                }
300            }
301            Key::Char(c) => {
302                self.buf.push(c);
303                Step::Continue
304            }
305            Key::Backspace => {
306                self.buf.pop();
307                Step::Continue
308            }
309            Key::Escape | Key::Interrupt => Step::Cancel,
310            _ => Step::Continue,
311        }
312    }
313
314    fn render(&self, ctx: RenderCtx) -> Frame {
315        let err = ctx.error;
316        let mut f: Frame = Vec::with_capacity(4);
317        f.push(theme::label_state(&self.label, err.is_some()));
318        if super::settings::get().show_hints {
319            if let Some(ref h) = self.hint {
320                f.push(theme::hint(h));
321            }
322        }
323        let dots = "•".repeat(self.buf.chars().count().min(32));
324        f.push(format!(
325            "{}  {}",
326            theme::input_bar(err.is_some()),
327            paint(super::settings::colors().input, &dots)
328        ));
329        f.push(theme::frame_bot(err));
330        f
331    }
332
333    fn render_answered(&self, _value: &String) -> Frame {
334        theme::answered(&self.label, "••••••••")
335            .split("\r\n")
336            .map(String::from)
337            .collect()
338    }
339
340    fn run_fallback(self) -> Result<String> {
341        eprint!(
342            "{}  {} {}: ",
343            paint(ACCENT, "◇"),
344            paint(WHITE, &self.label),
345            paint(DIM, "(input will be visible)"),
346        );
347        std::io::stderr().flush().map_err(PromptError::Io)?;
348        loop {
349            let line = super::engine::fallback::read_line_raw()?;
350            if line.is_empty() && !self.allow_empty {
351                eprint!("  Value cannot be empty. Try again: ");
352                std::io::stderr().flush().map_err(PromptError::Io)?;
353                continue;
354            }
355            if let Some(ref v) = self.validate {
356                if let Err(msg) = v.check(&line) {
357                    eprint!("  ✘ {msg}\n  Try again: ");
358                    std::io::stderr().flush().map_err(PromptError::Io)?;
359                    continue;
360                }
361            }
362            return Ok(line);
363        }
364    }
365}