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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct TextPrompt<'a> {
53 message: Cow<'a, str>,
55 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 #[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 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 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 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
188fn 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
210fn 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
236fn 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 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 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))] #[case::position_3(2, (13, 0))] #[case::position_4(4, (15, 0))] #[case::position_5(5, (16, 0))] 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 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))] #[case::position_1(3, (14, 0))] #[case::position_5(5, (16, 0))] #[case::position_6(6, (0, 1))] #[case::position_7(7, (1, 1))] #[case::position_11(10, (4, 1))] #[case::position_12(11, (5, 1))] 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 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}