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