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, Default, Clone, PartialEq, Eq)]
26pub struct TextPrompt<'a> {
27 message: Cow<'a, str>,
29 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 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 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
120fn 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
142fn 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
168fn 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 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 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))] #[case::position_3(2, (13, 0))] #[case::position_4(4, (15, 0))] #[case::position_5(5, (16, 0))] 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 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))] #[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>(
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 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}