Skip to main content

cli_ui/prompt/
multiline.rs

1//! Multi-line text input — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, DIM};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8use super::validate::Validator;
9
10/// Builder returned by [`multiline()`].
11pub struct MultilinePrompt {
12    label: String,
13    placeholder: Option<String>,
14    show_submit: bool,
15    validate: Option<Validator>,
16    buf: Vec<String>,
17    focus_submit: bool,
18}
19
20/// Ask the user for multiple lines of text. Enter inserts a newline; submit
21/// by pressing Enter on an empty line, or by Tab-focusing the on-screen
22/// `[ submit ]` button when [`MultilinePrompt::show_submit`] is `true`.
23///
24/// ```no_run
25/// use cli_ui::prompt::multiline;
26///
27/// let bio = multiline("Tell us about yourself")
28///     .placeholder("…")
29///     .show_submit(true)
30///     .run()?;
31/// # Ok::<(), cli_ui::prompt::PromptError>(())
32/// ```
33pub fn multiline(label: impl Into<String>) -> MultilinePrompt {
34    MultilinePrompt {
35        label: label.into(),
36        placeholder: None,
37        show_submit: false,
38        validate: None,
39        buf: vec![String::new()],
40        focus_submit: false,
41    }
42}
43
44impl MultilinePrompt {
45    /// Dim text shown in the input area before the user types.
46    pub fn placeholder(mut self, v: impl Into<String>) -> Self {
47        self.placeholder = Some(v.into());
48        self
49    }
50    /// Show a `[ submit ]` button focused with Tab (default: `false`,
51    /// which means double-Enter submits).
52    pub fn show_submit(mut self, v: bool) -> Self {
53        self.show_submit = v;
54        self
55    }
56    /// Validate with an ad-hoc closure.
57    pub fn validate<F>(mut self, f: F) -> Self
58    where
59        F: Fn(&str) -> std::result::Result<(), String> + Send + Sync + 'static,
60    {
61        self.validate = Some(Validator::new(f));
62        self
63    }
64    /// Apply a composed [`Validator`] from the rules library.
65    pub fn rule(mut self, v: Validator) -> Self {
66        self.validate = Some(v);
67        self
68    }
69
70    /// Deprecated alias for [`rule`](Self::rule).
71    #[deprecated(since = "0.1.0", note = "use `.rule(...)` instead")]
72    pub fn validate_with(self, v: Validator) -> Self {
73        self.rule(v)
74    }
75
76    /// Run the prompt to completion.
77    pub fn run(self) -> Result<String> {
78        super::core::run(self)
79    }
80
81    fn try_submit(&self) -> Option<String> {
82        let trailing_empty = self.buf.last().map(|s| s.is_empty()).unwrap_or(false)
83            && self.buf.len() >= 2
84            && self.buf[self.buf.len() - 2].is_empty();
85        if !(self.focus_submit || trailing_empty) {
86            return None;
87        }
88        let mut joined: Vec<&str> = self.buf.iter().map(String::as_str).collect();
89        if !self.focus_submit {
90            joined.truncate(joined.len() - 2);
91        }
92        Some(joined.join("\n"))
93    }
94}
95
96impl Prompt for MultilinePrompt {
97    type Output = String;
98
99    fn handle(&mut self, key: Key) -> Step<String> {
100        match key {
101            Key::Char('\t') if self.show_submit => {
102                self.focus_submit = !self.focus_submit;
103                Step::Continue
104            }
105            Key::Char(c) => {
106                self.focus_submit = false;
107                self.buf.last_mut().unwrap().push(c);
108                Step::Continue
109            }
110            Key::Backspace => {
111                if let Some(last) = self.buf.last_mut() {
112                    if last.pop().is_none() && self.buf.len() > 1 {
113                        self.buf.pop();
114                    }
115                }
116                Step::Continue
117            }
118            Key::Enter => {
119                if let Some(value) = self.try_submit() {
120                    if let Some(ref v) = self.validate {
121                        if let Err(msg) = v.check(&value) {
122                            return Step::Reject(msg);
123                        }
124                    }
125                    return Step::Submit(value);
126                }
127                self.buf.push(String::new());
128                Step::Continue
129            }
130            Key::Escape | Key::Interrupt => Step::Cancel,
131            _ => Step::Continue,
132        }
133    }
134
135    fn render(&self, _ctx: RenderCtx) -> Frame {
136        let mut f: Frame = Vec::with_capacity(self.buf.len() + 4);
137        f.push(theme::label(&self.label));
138        let placeholder = self.placeholder.as_deref().unwrap_or("");
139        let is_empty = self.buf.len() == 1 && self.buf[0].is_empty();
140        if is_empty && !placeholder.is_empty() {
141            f.push(format!("{}  {}", paint(DIM, "│"), paint(DIM, placeholder)));
142        } else {
143            for line in &self.buf {
144                f.push(format!(
145                    "{}  {}",
146                    paint(DIM, "│"),
147                    paint(super::settings::colors().input, line)
148                ));
149            }
150        }
151        if self.show_submit {
152            let c = super::settings::colors();
153            let style = if self.focus_submit { c.accent } else { c.dim };
154            f.push(format!(
155                "{}  {}",
156                paint(DIM, "│"),
157                paint(style, "[ submit ]")
158            ));
159        }
160        f.push(theme::hint(if self.show_submit {
161            "Tab to focus submit · Enter to insert newline"
162        } else {
163            "Enter for newline · Enter on empty line to submit"
164        }));
165        f.push(theme::frame_bot(None));
166        f
167    }
168
169    fn render_answered(&self, value: &String) -> Frame {
170        let snippet: String = value
171            .lines()
172            .next()
173            .unwrap_or("")
174            .chars()
175            .take(40)
176            .collect();
177        let suffix = if value.lines().count() > 1 {
178            " …"
179        } else {
180            ""
181        };
182        theme::answered(&self.label, &format!("{snippet}{suffix}"))
183            .split("\r\n")
184            .map(String::from)
185            .collect()
186    }
187
188    fn run_fallback(self) -> Result<String> {
189        use std::io::{BufRead, Write};
190        let mut err = std::io::stderr();
191        writeln!(err, "  {} (end with empty line)", self.label).map_err(PromptError::Io)?;
192        err.flush().map_err(PromptError::Io)?;
193        let stdin = std::io::stdin();
194        let mut acc = String::new();
195        for line in stdin.lock().lines() {
196            let l = line.map_err(PromptError::Io)?;
197            if l.is_empty() {
198                break;
199            }
200            if !acc.is_empty() {
201                acc.push('\n');
202            }
203            acc.push_str(&l);
204        }
205        Ok(acc)
206    }
207}