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)]
30pub struct TextPrompt<'a> {
31 message: Cow<'a, str>,
33 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 #[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 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 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
148fn 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
170fn 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
196fn 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 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 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))] #[case::position_3(2, (13, 0))] #[case::position_4(4, (15, 0))] #[case::position_5(5, (16, 0))] 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 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))] #[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>(
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 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}