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