tui_prompts/
text_prompt.rs

1use std::{borrow::Cow, vec};
2
3use crate::prelude::*;
4
5use itertools::Itertools;
6use ratatui::{
7    prelude::*,
8    widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10
11// TODO style the widget
12// TODO style each element of the widget.
13// TODO handle multi-line input.
14// TODO handle scrolling.
15// TODO handle vertical movement.
16// TODO handle bracketed paste.
17
18/// A prompt widget that displays a message and a text input.
19#[derive(Debug, Default, Clone, PartialEq, Eq)]
20pub struct TextPrompt<'a> {
21    /// The message to display to the user before the input.
22    message: Cow<'a, str>,
23    /// The block to wrap the prompt in.
24    block: Option<Block<'a>>,
25    render_style: TextRenderStyle,
26}
27
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum TextRenderStyle {
30    #[default]
31    Default,
32    Password,
33    Invisible,
34}
35
36impl TextRenderStyle {
37    #[must_use]
38    pub fn render(&self, state: &TextState) -> String {
39        match self {
40            Self::Default => state.value().to_string(),
41            Self::Password => "*".repeat(state.len()),
42            Self::Invisible => String::new(),
43        }
44    }
45}
46
47impl<'a> TextPrompt<'a> {
48    #[must_use]
49    pub const fn new(message: Cow<'a, str>) -> Self {
50        Self {
51            message,
52            block: None,
53            render_style: TextRenderStyle::Default,
54        }
55    }
56
57    #[must_use]
58    pub fn with_block(mut self, block: Block<'a>) -> Self {
59        self.block = Some(block);
60        self
61    }
62
63    #[must_use]
64    pub const fn with_render_style(mut self, render_style: TextRenderStyle) -> Self {
65        self.render_style = render_style;
66        self
67    }
68}
69
70impl Prompt for TextPrompt<'_> {
71    /// Draws the prompt widget.
72    ///
73    /// This is in addition to the `Widget` trait implementation as we need the `Frame` to set the
74    /// cursor position.
75    fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) {
76        frame.render_stateful_widget(self, area, state);
77        if state.is_focused() {
78            frame.set_cursor_position(state.cursor());
79        }
80    }
81}
82
83impl<'a> StatefulWidget for TextPrompt<'a> {
84    type State = TextState<'a>;
85
86    fn render(mut self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) {
87        self.render_block(&mut area, buf);
88
89        let width = area.width as usize;
90        let height = area.height as usize;
91        let value = self.render_style.render(state);
92        let value_length = value.chars().count();
93
94        let line = Line::from(vec![
95            state.status().symbol(),
96            " ".into(),
97            self.message.bold(),
98            " β€Ί ".cyan().dim(),
99            Span::raw(value),
100        ]);
101        let prompt_length = line.width() - value_length;
102        let lines = wrap(line, width).take(height).collect_vec();
103
104        // constrain the position to the area
105        let position = (state.position() + prompt_length).min(area.area() as usize - 1);
106        let row = position / width;
107        let column = position % width;
108        *state.cursor_mut() = (area.x + column as u16, area.y + row as u16);
109        Paragraph::new(lines).render(area, buf);
110    }
111}
112
113/// wraps a line into multiple lines of the given width.
114///
115/// This is a character based wrap, not a word based wrap.
116///
117/// TODO: move this into the `Line` type.
118fn wrap(line: Line, width: usize) -> impl Iterator<Item = Line> {
119    let mut line = line;
120    std::iter::from_fn(move || {
121        if line.width() > width {
122            let (first, second) = line_split_at(line.clone(), width);
123            line = second;
124            Some(first)
125        } else if line.width() > 0 {
126            let first = line.clone();
127            line = Line::default();
128            Some(first)
129        } else {
130            None
131        }
132    })
133}
134
135/// splits a line into two lines at the given position.
136///
137/// TODO: move this into the `Line` type.
138/// TODO: fix this so that it operates on multi-width characters.
139fn line_split_at(line: Line, mid: usize) -> (Line, Line) {
140    let mut first = Line::default();
141    let mut second = Line::default();
142    first.alignment = line.alignment;
143    second.alignment = line.alignment;
144    for span in line.spans {
145        let first_width = first.width();
146        let span_width = span.width();
147        if first_width + span_width <= mid {
148            first.spans.push(span);
149        } else if first_width < mid && first_width + span_width > mid {
150            let span_mid = mid - first_width;
151            let (span_first, span_second) = span_split_at(span, span_mid);
152            first.spans.push(span_first);
153            second.spans.push(span_second);
154        } else {
155            second.spans.push(span);
156        }
157    }
158    (first, second)
159}
160
161/// splits a span into two spans at the given position.
162///
163/// TODO: move this into the `Span` type.
164/// TODO: fix this so that it operates on multi-width characters.
165fn span_split_at(span: Span, mid: usize) -> (Span, Span) {
166    let (first, second) = span.content.split_at(mid);
167    let first = Span {
168        content: Cow::Owned(first.into()),
169        style: span.style,
170    };
171    let second = Span {
172        content: Cow::Owned(second.into()),
173        style: span.style,
174    };
175    (first, second)
176}
177
178impl<'a> TextPrompt<'a> {
179    fn render_block(&mut self, area: &mut Rect, buf: &mut Buffer) {
180        if let Some(block) = self.block.take() {
181            let inner = block.inner(*area);
182            block.render(*area, buf);
183            *area = inner;
184        };
185    }
186}
187
188impl<T> From<T> for TextPrompt<'static>
189where
190    T: Into<Cow<'static, str>>,
191{
192    fn from(message: T) -> Self {
193        Self::new(message.into())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use crate::Status;
200    use ratatui_macros::line;
201    use rstest::{fixture, rstest};
202
203    use super::*;
204    use ratatui::{backend::TestBackend, widgets::Borders};
205
206    #[test]
207    fn new() {
208        const PROMPT: TextPrompt<'_> = TextPrompt::new(Cow::Borrowed("Enter your name"));
209        assert_eq!(PROMPT.message, "Enter your name");
210        assert_eq!(PROMPT.block, None);
211        assert_eq!(PROMPT.render_style, TextRenderStyle::Default);
212    }
213
214    #[test]
215    fn default() {
216        let prompt = TextPrompt::default();
217        assert_eq!(prompt.message, "");
218        assert_eq!(prompt.block, None);
219        assert_eq!(prompt.render_style, TextRenderStyle::Default);
220    }
221
222    #[test]
223    fn from() {
224        let prompt = TextPrompt::from("Enter your name");
225        assert_eq!(prompt.message, "Enter your name");
226    }
227
228    #[test]
229    fn render() {
230        let prompt = TextPrompt::from("prompt");
231        let mut state = TextState::new();
232        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
233
234        prompt.render(buffer.area, &mut buffer, &mut state);
235
236        let line = line!["?".cyan(), " ", "prompt".bold(), " β€Ί ".cyan().dim(), "    ",];
237        assert_eq!(buffer, Buffer::with_lines([line]));
238        assert_eq!(state.cursor(), (11, 0));
239    }
240
241    #[test]
242    fn render_emoji() {
243        let prompt = TextPrompt::from("πŸ”");
244        let mut state = TextState::new();
245        let mut buffer = Buffer::empty(Rect::new(0, 0, 11, 1));
246
247        prompt.render(buffer.area, &mut buffer, &mut state);
248
249        let line = line!["?".cyan(), " ", "πŸ”".bold(), " β€Ί ".cyan().dim(), "    "];
250        assert_eq!(buffer, Buffer::with_lines([line]));
251        assert_eq!(state.cursor(), (7, 0));
252    }
253
254    #[test]
255    fn render_with_done() {
256        let prompt = TextPrompt::from("prompt");
257        let mut state = TextState::new().with_status(Status::Done);
258        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
259
260        prompt.render(buffer.area, &mut buffer, &mut state);
261
262        let line = line![
263            "βœ”".green(),
264            " ",
265            "prompt".bold(),
266            " β€Ί ".cyan().dim(),
267            "    "
268        ];
269        assert_eq!(buffer, Buffer::with_lines([line]));
270    }
271
272    #[test]
273    fn render_with_aborted() {
274        let prompt = TextPrompt::from("prompt");
275        let mut state = TextState::new().with_status(Status::Aborted);
276        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
277
278        prompt.render(buffer.area, &mut buffer, &mut state);
279
280        let line = line!["✘".red(), " ", "prompt".bold(), " β€Ί ".cyan().dim(), "    "];
281        assert_eq!(buffer, Buffer::with_lines([line]));
282    }
283
284    #[test]
285    fn render_with_value() {
286        let prompt = TextPrompt::from("prompt");
287        let mut state = TextState::new().with_value("value");
288        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
289
290        prompt.render(buffer.area, &mut buffer, &mut state);
291
292        let line = line![
293            "?".cyan(),
294            " ",
295            "prompt".bold(),
296            " β€Ί ".cyan().dim(),
297            "value              ".to_string()
298        ];
299        assert_eq!(buffer, Buffer::with_lines([line]));
300    }
301
302    #[test]
303    fn render_with_block() {
304        let prompt = TextPrompt::from("prompt")
305            .with_block(Block::default().borders(Borders::ALL).title("Title"));
306        let mut state = TextState::new();
307        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 3));
308
309        prompt.render(buffer.area, &mut buffer, &mut state);
310
311        let mut expected = Buffer::with_lines(vec![
312            "β”ŒTitle────────┐",
313            "β”‚? prompt β€Ί   β”‚",
314            "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
315        ]);
316        expected.set_style(Rect::new(1, 1, 1, 1), Color::Cyan);
317        expected.set_style(Rect::new(3, 1, 6, 1), Modifier::BOLD);
318        expected.set_style(Rect::new(9, 1, 3, 1), (Color::Cyan, Modifier::DIM));
319        assert_eq!(buffer, expected);
320    }
321
322    #[test]
323    fn render_password() {
324        let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Password);
325        let mut state = TextState::new().with_value("value");
326        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
327
328        prompt.render(buffer.area, &mut buffer, &mut state);
329
330        let line = line![
331            "?".cyan(),
332            " ",
333            "prompt".bold(),
334            " β€Ί ".cyan().dim(),
335            "*****              ".to_string()
336        ];
337        assert_eq!(buffer, Buffer::with_lines([line]));
338    }
339
340    #[test]
341    fn render_invisible() {
342        let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Invisible);
343        let mut state = TextState::new().with_value("value");
344        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
345
346        prompt.render(buffer.area, &mut buffer, &mut state);
347
348        let line = line![
349            "?".cyan(),
350            " ",
351            "prompt".bold(),
352            " β€Ί ".cyan().dim(),
353            "                   ".to_string()
354        ];
355        assert_eq!(buffer, Buffer::with_lines([line]));
356    }
357
358    #[fixture]
359    fn terminal() -> Terminal<TestBackend> {
360        Terminal::new(TestBackend::new(17, 2)).unwrap()
361    }
362
363    type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
364
365    #[rstest]
366    fn draw_not_focused<'a>(mut terminal: Terminal<impl Backend>) -> Result<()> {
367        let prompt = TextPrompt::from("prompt");
368        let mut state = TextState::new().with_value("hello");
369        // The cursor is not changed when the prompt is not focused.
370        let _ = terminal.draw(|frame| prompt.draw(frame, frame.area(), &mut state))?;
371        assert_eq!(state.cursor(), (11, 0));
372        assert_eq!(
373            terminal.backend_mut().get_cursor_position().unwrap(),
374            Position::ORIGIN
375        );
376        Ok(())
377    }
378
379    #[rstest]
380    fn draw_focused<'a>(mut terminal: Terminal<impl Backend>) -> Result<()> {
381        let prompt = TextPrompt::from("prompt");
382        let mut state = TextState::new().with_value("hello");
383        // The cursor is changed when the prompt is focused.
384        state.focus();
385        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
386        assert_eq!(state.cursor(), (11, 0));
387        assert_eq!(
388            terminal.backend_mut().get_cursor_position().unwrap(),
389            Position::new(11, 0)
390        );
391        Ok(())
392    }
393
394    #[rstest]
395    #[case::position_0(0, (11, 0))] // start of value
396    #[case::position_3(2, (13, 0))] // middle of value
397    #[case::position_4(4, (15, 0))] // last character of value
398    #[case::position_5(5, (16, 0))] // one character beyond the value
399    #[case::position_6(6, (0, 1))] // FIXME: should not go beyond the value
400    #[case::position_7(7, (1, 1))] // FIXME: should not go beyond the value
401    #[case::position_22(22, (16, 1))] // FIXME: should not go beyond the value
402    #[case::position_99(99, (16, 1))] // FIXME: should not go beyond the value
403    fn draw_unwrapped_position<'a>(
404        #[case] position: usize,
405        #[case] expected_cursor: (u16, u16),
406        mut terminal: Terminal<impl Backend>,
407    ) -> Result<()> {
408        let prompt = TextPrompt::from("prompt");
409        let mut state = TextState::new().with_value("hello");
410        // expected: "? prompt β€Ί hello "
411        //           "                 "
412        // position:             012345
413        // cursor:    01234567890123456
414        // The cursor is changed when the prompt is focused and the position is changed.
415        state.focus();
416        *state.position_mut() = position;
417        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
418        assert_eq!(state.cursor(), expected_cursor);
419        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
420
421        Ok(())
422    }
423
424    #[rstest]
425    #[case::position_0(0, (11, 0))] // start of value
426    #[case::position_1(3, (14, 0))] // middle of value
427    #[case::position_5(5, (16, 0))] // end of line
428    #[case::position_6(6, (0, 1))] // first character of the second line
429    #[case::position_7(7, (1, 1))] // second character of the second line
430    #[case::position_11(10, (4, 1))] // last character of the value
431    #[case::position_12(12, (6, 1))] // one character beyond the value
432    #[case::position_13(13, (7, 1))] // FIXME: should not go beyond the value
433    #[case::position_22(22, (16, 1))] // FIXME: should not go beyond the value
434    #[case::position_99(99, (16, 1))] // FIXME: should not go beyond the value
435    fn draw_wrapped_position<'a>(
436        #[case] position: usize,
437        #[case] expected_cursor: (u16, u16),
438        mut terminal: Terminal<impl Backend>,
439    ) -> Result<()> {
440        let prompt = TextPrompt::from("prompt");
441        let mut state = TextState::new().with_value("hello world");
442        // line 1:   "? prompt β€Ί hello "
443        // position:             012345
444        // cursor:    01234567890123456
445        // line 2:   "world            "
446        // position:  678901
447        // cursor:    01234567890123456
448        // The cursor is changed when the prompt is focused and the position is changed.
449        state.focus();
450        *state.position_mut() = position;
451        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
452        assert_eq!(state.cursor(), expected_cursor);
453        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
454
455        Ok(())
456    }
457}