requestty_ui/
prompt.rs

1use std::{convert::TryFrom, io};
2
3use crate::{
4    backend::Backend,
5    events,
6    layout::Layout,
7    style::{Color, Stylize},
8    Widget,
9};
10
11/// The different delimiters that can be used with hints in [`Prompt`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Delimiter {
14    /// `(` and `)`
15    Parentheses,
16    /// `{` and `}`
17    Braces,
18    /// `[` and `]`
19    SquareBracket,
20    /// `<` and `>`
21    AngleBracket,
22    /// Any other delimiter
23    Other(char, char),
24    /// No delimiter.
25    None,
26}
27
28impl From<Delimiter> for Option<(char, char)> {
29    fn from(delim: Delimiter) -> Self {
30        match delim {
31            Delimiter::Parentheses => Some(('(', ')')),
32            Delimiter::Braces => Some(('{', '}')),
33            Delimiter::SquareBracket => Some(('[', ']')),
34            Delimiter::AngleBracket => Some(('<', '>')),
35            Delimiter::Other(start, end) => Some((start, end)),
36            Delimiter::None => None,
37        }
38    }
39}
40
41/// A generic prompt that renders a message and an optional hint.
42#[derive(Debug, Clone)]
43pub struct Prompt<M, H = &'static str> {
44    message: M,
45    hint: Option<H>,
46    delim: Delimiter,
47    message_len: u16,
48    hint_len: u16,
49}
50
51impl<M: AsRef<str>, H: AsRef<str>> Prompt<M, H> {
52    /// Creates a new `Prompt`
53    pub fn new(message: M) -> Self {
54        Self {
55            message_len: u16::try_from(textwrap::core::display_width(message.as_ref()))
56                .expect("message must fit within a u16"),
57            message,
58            hint: None,
59            delim: Delimiter::Parentheses,
60            hint_len: 0,
61        }
62    }
63
64    /// Sets the hint
65    pub fn with_hint(mut self, hint: H) -> Self {
66        self.hint_len = u16::try_from(textwrap::core::display_width(hint.as_ref()))
67            .expect("hint must fit within a u16");
68        self.hint = Some(hint);
69        self
70    }
71
72    /// Sets the hint
73    pub fn with_optional_hint(self, hint: Option<H>) -> Self {
74        match hint {
75            Some(hint) => self.with_hint(hint),
76            None => self,
77        }
78    }
79
80    /// Sets the hint delimiter
81    pub fn with_delim(mut self, delim: Delimiter) -> Self {
82        self.delim = delim;
83        self
84    }
85
86    /// Get the message
87    pub fn message(&self) -> &M {
88        &self.message
89    }
90
91    /// Get the hint
92    pub fn hint(&self) -> Option<&H> {
93        self.hint.as_ref()
94    }
95
96    /// Get the delimiter
97    pub fn delim(&self) -> Delimiter {
98        self.delim
99    }
100
101    /// Consume self returning the owned message
102    pub fn into_message(self) -> M {
103        self.message
104    }
105
106    /// Consume self returning the owned hint
107    pub fn into_hint(self) -> Option<H> {
108        self.hint
109    }
110
111    /// Consume self returning the owned message and hint
112    pub fn into_message_and_hint(self) -> (M, Option<H>) {
113        (self.message, self.hint)
114    }
115
116    /// The character length of the message
117    pub fn message_len(&self) -> u16 {
118        self.message_len
119    }
120
121    /// The character length of the hint. It is 0 if the hint is absent
122    pub fn hint_len(&self) -> u16 {
123        if self.hint.is_some() {
124            match self.delim {
125                Delimiter::None => self.hint_len,
126                _ => self.hint_len + 2,
127            }
128        } else {
129            0
130        }
131    }
132
133    /// The character length of the fully rendered prompt
134    pub fn width(&self) -> u16 {
135        if self.hint.is_some() {
136            // `? <message> <hint> `
137            2 + self.message_len + 1 + self.hint_len() + 1
138        } else {
139            // `? <message> › `
140            2 + self.message_len + 3
141        }
142    }
143
144    fn cursor_pos_impl(&self, layout: Layout) -> (u16, u16) {
145        let mut width = self.width();
146        let relative_pos = if width > layout.line_width() {
147            width -= layout.line_width();
148
149            (width % layout.width, 1 + width / layout.width)
150        } else {
151            (layout.line_offset + width, 0)
152        };
153
154        layout.offset_cursor(relative_pos)
155    }
156}
157
158impl<M: AsRef<str>> Prompt<M, &'static str> {
159    /// The end prompt to be printed once the question is answered.
160    pub fn write_finished_message<B: Backend>(
161        message: &M,
162        skipped: bool,
163        backend: &mut B,
164    ) -> io::Result<()> {
165        let symbol_set = crate::symbols::current();
166        if skipped {
167            backend.write_styled(&symbol_set.cross.yellow())?;
168        } else {
169            backend.write_styled(&symbol_set.completed.light_green())?;
170        }
171        backend.write_all(b" ")?;
172        backend.write_styled(&message.as_ref().bold())?;
173        backend.write_all(b" ")?;
174        backend.write_styled(&symbol_set.middle_dot.dark_grey())?;
175        backend.write_all(b" ")
176    }
177}
178
179impl<M: AsRef<str>, H: AsRef<str>> Widget for Prompt<M, H> {
180    fn render<B: Backend>(&mut self, layout: &mut Layout, b: &mut B) -> io::Result<()> {
181        b.write_styled(&"? ".light_green())?;
182        b.write_styled(&self.message.as_ref().bold())?;
183        b.write_all(b" ")?;
184
185        b.set_fg(Color::DarkGrey)?;
186
187        match (&self.hint, self.delim.into()) {
188            (Some(hint), Some((start, end))) => write!(b, "{}{}{}", start, hint.as_ref(), end)?,
189            (Some(hint), None) => write!(b, "{}", hint.as_ref())?,
190            (None, _) => {
191                write!(b, "{}", crate::symbols::current().arrow)?;
192            }
193        }
194
195        b.set_fg(Color::Reset)?;
196        b.write_all(b" ")?;
197
198        *layout = layout.with_cursor_pos(self.cursor_pos_impl(*layout));
199
200        Ok(())
201    }
202
203    fn height(&mut self, layout: &mut Layout) -> u16 {
204        // preserve the old offset since `cursor_pos` is absolute.
205        let offset_y = layout.offset_y;
206
207        let cursor_pos = self.cursor_pos_impl(*layout);
208        *layout = layout.with_cursor_pos(cursor_pos);
209
210        cursor_pos.1 + 1 - offset_y
211    }
212
213    fn cursor_pos(&mut self, layout: Layout) -> (u16, u16) {
214        self.cursor_pos_impl(layout)
215    }
216
217    fn handle_key(&mut self, _: events::KeyEvent) -> bool {
218        false
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::{backend::TestBackend, test_consts::*};
225
226    use super::*;
227
228    type Prompt = super::Prompt<&'static str, &'static str>;
229
230    #[test]
231    fn test_width() {
232        assert_eq!(Prompt::new("Hello").width(), 10);
233        assert_eq!(Prompt::new("Hello").with_hint("world").width(), 16);
234        assert_eq!(
235            Prompt::new("Hello")
236                .with_hint("world")
237                .with_delim(Delimiter::None)
238                .width(),
239            14
240        );
241        assert_eq!(Prompt::new(LOREM).with_hint(UNICODE).width(), 946);
242    }
243
244    #[test]
245    fn test_render() {
246        fn test(
247            message: &'static str,
248            hint: Option<&'static str>,
249            delim: Delimiter,
250            expected_layout: Layout,
251        ) {
252            let size = (100, 20).into();
253            let mut layout = Layout::new(5, size);
254            let mut prompt = Prompt::new(message)
255                .with_optional_hint(hint)
256                .with_delim(delim);
257            let mut backend = TestBackend::new_with_layout(size, layout);
258
259            prompt.render(&mut layout, &mut backend).unwrap();
260
261            crate::assert_backend_snapshot!(backend);
262            assert_eq!(
263                layout,
264                expected_layout,
265                "\ncursor pos = {:?}, width = {:?}",
266                prompt.cursor_pos(Layout::new(5, size)),
267                prompt.width(),
268            );
269        }
270
271        let layout = Layout::new(5, (100, 20).into());
272
273        test("Hello", None, Delimiter::None, layout.with_line_offset(15));
274
275        test(
276            "Hello",
277            Some("world"),
278            Delimiter::Parentheses,
279            layout.with_line_offset(21),
280        );
281
282        test(
283            "Hello",
284            Some("world"),
285            Delimiter::Braces,
286            layout.with_line_offset(21),
287        );
288
289        test(
290            "Hello",
291            Some("world"),
292            Delimiter::SquareBracket,
293            layout.with_line_offset(21),
294        );
295
296        test(
297            "Hello",
298            Some("world"),
299            Delimiter::AngleBracket,
300            layout.with_line_offset(21),
301        );
302
303        test(
304            "Hello",
305            Some("world"),
306            Delimiter::Other('-', '|'),
307            layout.with_line_offset(21),
308        );
309
310        test(
311            LOREM,
312            Some(UNICODE),
313            Delimiter::None,
314            layout.with_line_offset(49).with_offset(0, 9),
315        );
316    }
317
318    #[test]
319    fn test_height() {
320        let mut layout = Layout::new(5, (100, 20).into());
321
322        assert_eq!(Prompt::new("Hello").height(&mut layout.clone()), 1);
323        assert_eq!(
324            Prompt::new("Hello")
325                .with_hint("world")
326                .height(&mut layout.clone()),
327            1
328        );
329        assert_eq!(
330            Prompt::new(LOREM).with_hint(UNICODE).height(&mut layout),
331            10
332        );
333    }
334
335    #[test]
336    fn test_cursor_pos() {
337        let layout = Layout::new(5, (100, 20).into());
338
339        assert_eq!(Prompt::new("Hello").cursor_pos_impl(layout), (15, 0));
340        assert_eq!(
341            Prompt::new("Hello")
342                .with_hint("world")
343                .cursor_pos_impl(layout),
344            (21, 0)
345        );
346        assert_eq!(
347            Prompt::new("Hello")
348                .with_hint("world")
349                .with_delim(Delimiter::None)
350                .cursor_pos_impl(layout),
351            (19, 0)
352        );
353        assert_eq!(
354            Prompt::new(LOREM)
355                .with_hint(UNICODE)
356                .cursor_pos_impl(layout),
357            (51, 9)
358        );
359
360        assert_eq!(
361            Prompt::new(LOREM)
362                .with_hint(UNICODE)
363                .cursor_pos_impl(layout.with_offset(0, 3)),
364            (51, 12)
365        );
366    }
367}