Skip to main content

tui_prompts/
text_prompt.rs

1use std::borrow::Cow;
2use std::vec;
3
4use ratatui_core::buffer::Buffer;
5use ratatui_core::layout::Rect;
6use ratatui_core::style::Stylize;
7use ratatui_core::terminal::Frame;
8use ratatui_core::text::{Line, Span};
9use ratatui_core::widgets::{StatefulWidget, Widget};
10use ratatui_widgets::block::Block;
11use ratatui_widgets::paragraph::Paragraph;
12use unicode_width::UnicodeWidthStr;
13
14use crate::prelude::*;
15
16// TODO style the widget
17// TODO style each element of the widget.
18// TODO handle multi-line input.
19// TODO handle scrolling.
20// TODO handle vertical movement.
21// TODO handle bracketed paste.
22
23/// A prompt widget that displays a message and a text input.
24///
25/// By default, the prompt reserves space for the [`TextState`] status symbol before the prompt
26/// message. Use [`TextPrompt::without_status_symbol`] when the surrounding UI owns completion or
27/// cancellation state and the prompt should start directly with the message.
28///
29/// `TextPrompt` can be rendered by value when it is built inline, or by reference when prompt
30/// configuration is stored and reused across frames. Rendering by reference keeps the prompt
31/// configuration available for later frames while still updating the external [`TextState`].
32///
33/// # Examples
34///
35/// ```rust
36/// use ratatui::Frame;
37/// use ratatui::layout::Rect;
38/// use tui_prompts::{Prompt, TextPrompt, TextState};
39///
40/// struct App<'a> {
41///     prompt: TextPrompt<'a>,
42///     state: TextState<'a>,
43/// }
44///
45/// impl App<'_> {
46///     fn draw(&mut self, frame: &mut Frame, area: Rect) {
47///         (&self.prompt).draw(frame, area, &mut self.state);
48///     }
49/// }
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct TextPrompt<'a> {
53    /// The message to display to the user before the input.
54    message: Cow<'a, str>,
55    /// The block to wrap the prompt in.
56    block: Option<Block<'a>>,
57    render_style: TextRenderStyle,
58    status_symbol_visible: bool,
59}
60
61#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum TextRenderStyle {
63    #[default]
64    Default,
65    Password,
66    Invisible,
67}
68
69impl TextRenderStyle {
70    #[must_use]
71    pub fn render(&self, state: &TextState) -> String {
72        match self {
73            Self::Default => state.value().to_string(),
74            Self::Password => "*".repeat(state.len()),
75            Self::Invisible => String::new(),
76        }
77    }
78}
79
80impl<'a> TextPrompt<'a> {
81    #[must_use]
82    pub const fn new(message: Cow<'a, str>) -> Self {
83        Self {
84            message,
85            block: None,
86            render_style: TextRenderStyle::Default,
87            status_symbol_visible: true,
88        }
89    }
90
91    #[must_use]
92    pub fn with_block(mut self, block: Block<'a>) -> Self {
93        self.block = Some(block);
94        self
95    }
96
97    #[must_use]
98    pub const fn with_render_style(mut self, render_style: TextRenderStyle) -> Self {
99        self.render_style = render_style;
100        self
101    }
102
103    /// Renders the prompt without the status symbol or its following space.
104    ///
105    /// The prompt state's [`Status`] is still updated and available through [`TextState`]. This
106    /// only changes the rendered prefix, so the prompt message, input value, wrapping width, and
107    /// cursor position all move left by the width of the hidden status symbol prefix.
108    #[must_use]
109    pub const fn without_status_symbol(mut self) -> Self {
110        self.status_symbol_visible = false;
111        self
112    }
113}
114
115impl Default for TextPrompt<'_> {
116    fn default() -> Self {
117        Self::new(Cow::Borrowed(""))
118    }
119}
120
121impl Prompt for TextPrompt<'_> {
122    /// Draws the prompt widget.
123    ///
124    /// This is in addition to the `Widget` trait implementation as we need the `Frame` to set the
125    /// cursor position.
126    fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) {
127        (&self).draw(frame, area, state);
128    }
129}
130
131impl Prompt for &TextPrompt<'_> {
132    /// Draws the prompt widget.
133    ///
134    /// This is in addition to the `Widget` trait implementation as we need the `Frame` to set the
135    /// cursor position.
136    fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) {
137        frame.render_stateful_widget(self, area, state);
138        if state.is_focused() {
139            frame.set_cursor_position(state.cursor());
140        }
141    }
142}
143
144impl<'a> StatefulWidget for TextPrompt<'a> {
145    type State = TextState<'a>;
146
147    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
148        (&self).render(area, buf, state);
149    }
150}
151
152impl<'a> StatefulWidget for &TextPrompt<'a> {
153    type State = TextState<'a>;
154
155    fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) {
156        self.render_block(&mut area, buf);
157
158        let width = area.width as usize;
159        let height = area.height as usize;
160        let value = Span::raw(self.render_style.render(state));
161        let value_width = value.width();
162
163        let line = {
164            let mut parts = vec![];
165            if self.status_symbol_visible {
166                parts.push(state.status().symbol());
167                parts.push(" ".into());
168            }
169            parts.push(self.message.as_ref().bold());
170            parts.push(" β€Ί ".cyan().dim());
171            parts.push(value);
172            Line::from(parts)
173        };
174
175        let prompt_width = line.width() - value_width;
176        let lines = wrap(line, width).take(height).collect::<Vec<_>>();
177
178        // constrain the position to the area
179        let position = state.width_to_pos(state.position()) + prompt_width;
180        let position = position.min(area.area() as usize - 1);
181        let row = position / width;
182        let column = position % width;
183        *state.cursor_mut() = (area.x + column as u16, area.y + row as u16);
184        Paragraph::new(lines).render(area, buf);
185    }
186}
187
188/// wraps a line into multiple lines of the given width.
189///
190/// This is a character based wrap, not a word based wrap.
191///
192/// TODO: move this into the `Line` type.
193fn wrap(line: Line, width: usize) -> impl Iterator<Item = Line> {
194    let mut line = line;
195    std::iter::from_fn(move || {
196        if line.width() > width {
197            let (first, second) = line_split_at(line.clone(), width);
198            line = second;
199            Some(first)
200        } else if line.width() > 0 {
201            let first = line.clone();
202            line = Line::default();
203            Some(first)
204        } else {
205            None
206        }
207    })
208}
209
210/// splits a line into two lines at the given position.
211///
212/// TODO: move this into the `Line` type.
213/// TODO: fix this so that it operates on multi-width characters.
214fn line_split_at(line: Line, mid: usize) -> (Line, Line) {
215    let mut first = Line::default();
216    let mut second = Line::default();
217    first.alignment = line.alignment;
218    second.alignment = line.alignment;
219    for span in line.spans {
220        let first_width = first.width();
221        let span_width = span.width();
222        if first_width + span_width <= mid {
223            first.spans.push(span);
224        } else if first_width < mid && first_width + span_width > mid {
225            let span_mid = mid - first_width;
226            let (span_first, span_second) = span_split_at(span, span_mid);
227            first.spans.push(span_first);
228            second.spans.push(span_second);
229        } else {
230            second.spans.push(span);
231        }
232    }
233    (first, second)
234}
235
236/// Splits a span into two spans at the given character width
237///
238/// TODO: move this into the `Span` type.
239fn span_split_at(span: Span, mid: usize) -> (Span, Span) {
240    let mut first = String::new();
241    let mut second = span.content.to_string();
242    while first.width() < mid {
243        first.push(second.remove(0));
244    }
245    (
246        Span::styled(first, span.style),
247        Span::styled(second, span.style),
248    )
249}
250
251impl TextPrompt<'_> {
252    fn render_block(&self, area: &mut Rect, buf: &mut Buffer) {
253        if let Some(block) = &self.block {
254            let inner = block.inner(*area);
255            block.render(*area, buf);
256            *area = inner;
257        };
258    }
259}
260
261impl<T> From<T> for TextPrompt<'static>
262where
263    T: Into<Cow<'static, str>>,
264{
265    fn from(message: T) -> Self {
266        Self::new(message.into())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use ratatui_core::backend::{Backend, TestBackend};
273    use ratatui_core::layout::Position;
274    use ratatui_core::style::{Color, Modifier};
275    use ratatui_core::terminal::Terminal;
276    use ratatui_macros::line;
277    use ratatui_widgets::borders::Borders;
278    use rstest::{fixture, rstest};
279
280    use super::*;
281    use crate::Status;
282
283    #[test]
284    fn new() {
285        const PROMPT: TextPrompt<'_> = TextPrompt::new(Cow::Borrowed("Enter your name"));
286        assert_eq!(PROMPT.message, "Enter your name");
287        assert_eq!(PROMPT.block, None);
288        assert_eq!(PROMPT.render_style, TextRenderStyle::Default);
289    }
290
291    #[test]
292    fn default() {
293        let prompt = TextPrompt::default();
294        assert_eq!(prompt.message, "");
295        assert_eq!(prompt.block, None);
296        assert_eq!(prompt.render_style, TextRenderStyle::Default);
297    }
298
299    #[test]
300    fn from() {
301        let prompt = TextPrompt::from("Enter your name");
302        assert_eq!(prompt.message, "Enter your name");
303    }
304
305    #[test]
306    fn render() {
307        let prompt = TextPrompt::from("prompt");
308        let mut state = TextState::new();
309        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
310
311        prompt.render(buffer.area, &mut buffer, &mut state);
312
313        let line = line!["?".cyan(), " ", "prompt".bold(), " β€Ί ".cyan().dim(), "    ",];
314        assert_eq!(buffer, Buffer::with_lines([line]));
315        assert_eq!(state.cursor(), (11, 0));
316    }
317
318    #[test]
319    fn render_by_reference_matches_owned_render() {
320        let prompt = TextPrompt::from("prompt");
321        let mut owned_state = TextState::new().with_value("value");
322        let mut borrowed_state = owned_state.clone();
323        let mut owned_buf = Buffer::empty(Rect::new(0, 0, 20, 1));
324        let mut borrowed_buf = Buffer::empty(Rect::new(0, 0, 20, 1));
325
326        prompt
327            .clone()
328            .render(owned_buf.area, &mut owned_buf, &mut owned_state);
329        (&prompt).render(borrowed_buf.area, &mut borrowed_buf, &mut borrowed_state);
330
331        assert_eq!(borrowed_buf, owned_buf);
332        assert_eq!(borrowed_state, owned_state);
333    }
334
335    #[test]
336    fn render_default_keeps_status_symbol() {
337        let prompt = TextPrompt::default();
338        let mut state = TextState::new().with_status(Status::Done);
339        let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 1));
340
341        prompt.render(buffer.area, &mut buffer, &mut state);
342
343        let line = line!["βœ”".green(), " ", " β€Ί ".cyan().dim(), "   "];
344        assert_eq!(buffer, Buffer::with_lines([line]));
345        assert_eq!(state.cursor(), (5, 0));
346    }
347
348    #[test]
349    fn render_emoji() {
350        let prompt = TextPrompt::from("πŸ”");
351        let mut state = TextState::new();
352        let mut buffer = Buffer::empty(Rect::new(0, 0, 11, 1));
353
354        prompt.render(buffer.area, &mut buffer, &mut state);
355
356        let line = line!["?".cyan(), " ", "πŸ”".bold(), " β€Ί ".cyan().dim(), "    "];
357        assert_eq!(buffer, Buffer::with_lines([line]));
358        assert_eq!(state.cursor(), (7, 0));
359    }
360
361    #[test]
362    fn render_with_done() {
363        let prompt = TextPrompt::from("prompt");
364        let mut state = TextState::new().with_status(Status::Done);
365        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
366
367        prompt.render(buffer.area, &mut buffer, &mut state);
368
369        let line = line![
370            "βœ”".green(),
371            " ",
372            "prompt".bold(),
373            " β€Ί ".cyan().dim(),
374            "    "
375        ];
376        assert_eq!(buffer, Buffer::with_lines([line]));
377    }
378
379    #[test]
380    fn render_with_aborted() {
381        let prompt = TextPrompt::from("prompt");
382        let mut state = TextState::new().with_status(Status::Aborted);
383        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
384
385        prompt.render(buffer.area, &mut buffer, &mut state);
386
387        let line = line!["✘".red(), " ", "prompt".bold(), " β€Ί ".cyan().dim(), "    "];
388        assert_eq!(buffer, Buffer::with_lines([line]));
389    }
390
391    #[test]
392    fn render_without_status_symbol() {
393        let prompt = TextPrompt::from("prompt").without_status_symbol();
394        let mut state = TextState::new().with_status(Status::Aborted);
395        let mut buffer = Buffer::empty(Rect::new(0, 0, 13, 1));
396
397        prompt.render(buffer.area, &mut buffer, &mut state);
398
399        let line = line!["prompt".bold(), " β€Ί ".cyan().dim(), "    "];
400        assert_eq!(buffer, Buffer::with_lines([line]));
401        assert_eq!(state.cursor(), (9, 0));
402    }
403
404    #[test]
405    fn render_with_value() {
406        let prompt = TextPrompt::from("prompt");
407        let mut state = TextState::new().with_value("value");
408        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
409
410        prompt.render(buffer.area, &mut buffer, &mut state);
411
412        let line = line![
413            "?".cyan(),
414            " ",
415            "prompt".bold(),
416            " β€Ί ".cyan().dim(),
417            "value              ".to_string()
418        ];
419        assert_eq!(buffer, Buffer::with_lines([line]));
420    }
421
422    #[test]
423    fn render_with_block() {
424        let prompt = TextPrompt::from("prompt")
425            .with_block(Block::default().borders(Borders::ALL).title("Title"));
426        let mut state = TextState::new();
427        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 3));
428
429        prompt.render(buffer.area, &mut buffer, &mut state);
430
431        let mut expected = Buffer::with_lines(vec![
432            "β”ŒTitle────────┐",
433            "β”‚? prompt β€Ί   β”‚",
434            "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜",
435        ]);
436        expected.set_style(Rect::new(1, 1, 1, 1), Color::Cyan);
437        expected.set_style(Rect::new(3, 1, 6, 1), Modifier::BOLD);
438        expected.set_style(Rect::new(9, 1, 3, 1), (Color::Cyan, Modifier::DIM));
439        assert_eq!(buffer, expected);
440    }
441
442    #[test]
443    fn render_password() {
444        let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Password);
445        let mut state = TextState::new().with_value("value");
446        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
447
448        prompt.render(buffer.area, &mut buffer, &mut state);
449
450        let line = line![
451            "?".cyan(),
452            " ",
453            "prompt".bold(),
454            " β€Ί ".cyan().dim(),
455            "*****              ".to_string()
456        ];
457        assert_eq!(buffer, Buffer::with_lines([line]));
458    }
459
460    #[test]
461    fn render_invisible() {
462        let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Invisible);
463        let mut state = TextState::new().with_value("value");
464        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
465
466        prompt.render(buffer.area, &mut buffer, &mut state);
467
468        let line = line![
469            "?".cyan(),
470            " ",
471            "prompt".bold(),
472            " β€Ί ".cyan().dim(),
473            "                   ".to_string()
474        ];
475        assert_eq!(buffer, Buffer::with_lines([line]));
476    }
477
478    #[fixture]
479    fn terminal() -> Terminal<TestBackend> {
480        Terminal::new(TestBackend::new(17, 2)).unwrap()
481    }
482
483    type Result<T> = std::result::Result<T, core::convert::Infallible>;
484
485    #[rstest]
486    fn draw_not_focused<'a>(mut terminal: Terminal<TestBackend>) -> Result<()> {
487        let prompt = TextPrompt::from("prompt");
488        let mut state = TextState::new().with_value("hello");
489        // The cursor is not changed when the prompt is not focused.
490        let _ = terminal.draw(|frame| prompt.draw(frame, frame.area(), &mut state))?;
491        assert_eq!(state.cursor(), (11, 0));
492        assert_eq!(
493            terminal.backend_mut().get_cursor_position().unwrap(),
494            Position::ORIGIN
495        );
496        Ok(())
497    }
498
499    #[rstest]
500    fn draw_focused<'a>(mut terminal: Terminal<TestBackend>) -> Result<()> {
501        let prompt = TextPrompt::from("prompt");
502        let mut state = TextState::new().with_value("hello");
503        // The cursor is changed when the prompt is focused.
504        state.focus();
505        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
506        assert_eq!(state.cursor(), (11, 0));
507        assert_eq!(
508            terminal.backend_mut().get_cursor_position().unwrap(),
509            Position::new(11, 0)
510        );
511        Ok(())
512    }
513
514    #[rstest]
515    #[case::position_0(0, (11, 0))] // start of value
516    #[case::position_3(2, (13, 0))] // middle of value
517    #[case::position_4(4, (15, 0))] // last character of value
518    #[case::position_5(5, (16, 0))] // one character beyond the value
519    fn draw_unwrapped_position<'a>(
520        #[case] position: usize,
521        #[case] expected_cursor: (u16, u16),
522        mut terminal: Terminal<TestBackend>,
523    ) -> Result<()> {
524        let prompt = TextPrompt::from("prompt");
525        let mut state = TextState::new().with_value("hello");
526        // expected: "? prompt β€Ί hello "
527        //           "                 "
528        // position:             012345
529        // cursor:    01234567890123456
530        // The cursor is changed when the prompt is focused and the position is changed.
531        state.focus();
532        *state.position_mut() = position;
533        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
534        assert_eq!(state.cursor(), expected_cursor);
535        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
536
537        Ok(())
538    }
539
540    #[rstest]
541    #[case::position_0(0, (11, 0))]
542    #[case::position_1(1, (13, 0))]
543    #[case::position_2(2, (15, 0))]
544    #[case::position_3(3, (0, 1))]
545    fn draw_wrapped_position_fullwidth<'a>(
546        #[case] position: usize,
547        #[case] expected_cursor: (u16, u16),
548        mut terminal: Terminal<TestBackend>,
549    ) -> Result<()> {
550        let prompt = TextPrompt::from("prompt");
551        let mut state = TextState::new().with_value("ほげほげほげ");
552        state.focus();
553        *state.position_mut() = position;
554        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
555        assert_eq!(state.cursor(), expected_cursor);
556        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
557
558        Ok(())
559    }
560
561    #[rstest]
562    #[case::position_0(0, (12, 0))]
563    #[case::position_1(1, (14, 0))]
564    #[ignore]
565    #[case::position_2(2, (0, 1))]
566    #[ignore]
567    #[case::position_3(3, (2, 1))]
568    fn draw_wrapped_position_fullwidth_shift_by_one<'a>(
569        #[case] position: usize,
570        #[case] expected_cursor: (u16, u16),
571        mut terminal: Terminal<TestBackend>,
572    ) -> Result<()> {
573        let prompt = TextPrompt::from("prompt2");
574        let mut state = TextState::new().with_value("ほげほげほげ");
575        state.focus();
576        *state.position_mut() = position;
577        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
578        assert_eq!(state.cursor(), expected_cursor);
579        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
580
581        Ok(())
582    }
583
584    #[rstest]
585    #[case::position_0(0, (11, 0))] // start of value
586    #[case::position_1(3, (14, 0))] // middle of value
587    #[case::position_5(5, (16, 0))] // end of line
588    #[case::position_6(6, (0, 1))] // first character of the second line
589    #[case::position_7(7, (1, 1))] // second character of the second line
590    #[case::position_11(10, (4, 1))] // last character of the value
591    #[case::position_12(11, (5, 1))] // one character beyond the value
592    fn draw_wrapped_position<'a>(
593        #[case] position: usize,
594        #[case] expected_cursor: (u16, u16),
595        mut terminal: Terminal<TestBackend>,
596    ) -> Result<()> {
597        let prompt = TextPrompt::from("prompt");
598        let mut state = TextState::new().with_value("hello world");
599        // line 1:   "? prompt β€Ί hello "
600        // position:             012345
601        // cursor:    01234567890123456
602        // line 2:   "world            "
603        // position:  678901
604        // cursor:    01234567890123456
605        // The cursor is changed when the prompt is focused and the position is changed.
606        state.focus();
607        *state.position_mut() = position;
608        let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
609        assert_eq!(state.cursor(), expected_cursor);
610        assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
611
612        Ok(())
613    }
614}