Skip to main content

tui_prompts/
text_prompt.rs

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