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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct TextPrompt<'a> {
54 message: Cow<'a, str>,
56 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 #[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 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 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 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
189fn 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
211fn 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
237fn 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 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 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))] #[case::position_3(2, (13, 0))] #[case::position_4(4, (15, 0))] #[case::position_5(5, (16, 0))] 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 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))] #[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>(
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 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}