1use std::{borrow::Cow, vec};
2
3use crate::prelude::*;
4
5use itertools::Itertools;
6use ratatui::{
7 prelude::*,
8 widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10
11#[derive(Debug, Default, Clone, PartialEq, Eq)]
20pub struct TextPrompt<'a> {
21 message: Cow<'a, str>,
23 block: Option<Block<'a>>,
25 render_style: TextRenderStyle,
26}
27
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum TextRenderStyle {
30 #[default]
31 Default,
32 Password,
33 Invisible,
34}
35
36impl TextRenderStyle {
37 #[must_use]
38 pub fn render(&self, state: &TextState) -> String {
39 match self {
40 Self::Default => state.value().to_string(),
41 Self::Password => "*".repeat(state.len()),
42 Self::Invisible => String::new(),
43 }
44 }
45}
46
47impl<'a> TextPrompt<'a> {
48 #[must_use]
49 pub const fn new(message: Cow<'a, str>) -> Self {
50 Self {
51 message,
52 block: None,
53 render_style: TextRenderStyle::Default,
54 }
55 }
56
57 #[must_use]
58 pub fn with_block(mut self, block: Block<'a>) -> Self {
59 self.block = Some(block);
60 self
61 }
62
63 #[must_use]
64 pub const fn with_render_style(mut self, render_style: TextRenderStyle) -> Self {
65 self.render_style = render_style;
66 self
67 }
68}
69
70impl Prompt for TextPrompt<'_> {
71 fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) {
76 frame.render_stateful_widget(self, area, state);
77 if state.is_focused() {
78 frame.set_cursor_position(state.cursor());
79 }
80 }
81}
82
83impl<'a> StatefulWidget for TextPrompt<'a> {
84 type State = TextState<'a>;
85
86 fn render(mut self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) {
87 self.render_block(&mut area, buf);
88
89 let width = area.width as usize;
90 let height = area.height as usize;
91 let value = self.render_style.render(state);
92 let value_length = value.chars().count();
93
94 let line = Line::from(vec![
95 state.status().symbol(),
96 " ".into(),
97 self.message.bold(),
98 " βΊ ".cyan().dim(),
99 Span::raw(value),
100 ]);
101 let prompt_length = line.width() - value_length;
102 let lines = wrap(line, width).take(height).collect_vec();
103
104 let position = (state.position() + prompt_length).min(area.area() as usize - 1);
106 let row = position / width;
107 let column = position % width;
108 *state.cursor_mut() = (area.x + column as u16, area.y + row as u16);
109 Paragraph::new(lines).render(area, buf);
110 }
111}
112
113fn wrap(line: Line, width: usize) -> impl Iterator<Item = Line> {
119 let mut line = line;
120 std::iter::from_fn(move || {
121 if line.width() > width {
122 let (first, second) = line_split_at(line.clone(), width);
123 line = second;
124 Some(first)
125 } else if line.width() > 0 {
126 let first = line.clone();
127 line = Line::default();
128 Some(first)
129 } else {
130 None
131 }
132 })
133}
134
135fn line_split_at(line: Line, mid: usize) -> (Line, Line) {
140 let mut first = Line::default();
141 let mut second = Line::default();
142 first.alignment = line.alignment;
143 second.alignment = line.alignment;
144 for span in line.spans {
145 let first_width = first.width();
146 let span_width = span.width();
147 if first_width + span_width <= mid {
148 first.spans.push(span);
149 } else if first_width < mid && first_width + span_width > mid {
150 let span_mid = mid - first_width;
151 let (span_first, span_second) = span_split_at(span, span_mid);
152 first.spans.push(span_first);
153 second.spans.push(span_second);
154 } else {
155 second.spans.push(span);
156 }
157 }
158 (first, second)
159}
160
161fn span_split_at(span: Span, mid: usize) -> (Span, Span) {
166 let (first, second) = span.content.split_at(mid);
167 let first = Span {
168 content: Cow::Owned(first.into()),
169 style: span.style,
170 };
171 let second = Span {
172 content: Cow::Owned(second.into()),
173 style: span.style,
174 };
175 (first, second)
176}
177
178impl<'a> TextPrompt<'a> {
179 fn render_block(&mut self, area: &mut Rect, buf: &mut Buffer) {
180 if let Some(block) = self.block.take() {
181 let inner = block.inner(*area);
182 block.render(*area, buf);
183 *area = inner;
184 };
185 }
186}
187
188impl<T> From<T> for TextPrompt<'static>
189where
190 T: Into<Cow<'static, str>>,
191{
192 fn from(message: T) -> Self {
193 Self::new(message.into())
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use crate::Status;
200 use ratatui_macros::line;
201 use rstest::{fixture, rstest};
202
203 use super::*;
204 use ratatui::{backend::TestBackend, widgets::Borders};
205
206 #[test]
207 fn new() {
208 const PROMPT: TextPrompt<'_> = TextPrompt::new(Cow::Borrowed("Enter your name"));
209 assert_eq!(PROMPT.message, "Enter your name");
210 assert_eq!(PROMPT.block, None);
211 assert_eq!(PROMPT.render_style, TextRenderStyle::Default);
212 }
213
214 #[test]
215 fn default() {
216 let prompt = TextPrompt::default();
217 assert_eq!(prompt.message, "");
218 assert_eq!(prompt.block, None);
219 assert_eq!(prompt.render_style, TextRenderStyle::Default);
220 }
221
222 #[test]
223 fn from() {
224 let prompt = TextPrompt::from("Enter your name");
225 assert_eq!(prompt.message, "Enter your name");
226 }
227
228 #[test]
229 fn render() {
230 let prompt = TextPrompt::from("prompt");
231 let mut state = TextState::new();
232 let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
233
234 prompt.render(buffer.area, &mut buffer, &mut state);
235
236 let line = line!["?".cyan(), " ", "prompt".bold(), " βΊ ".cyan().dim(), " ",];
237 assert_eq!(buffer, Buffer::with_lines([line]));
238 assert_eq!(state.cursor(), (11, 0));
239 }
240
241 #[test]
242 fn render_emoji() {
243 let prompt = TextPrompt::from("π");
244 let mut state = TextState::new();
245 let mut buffer = Buffer::empty(Rect::new(0, 0, 11, 1));
246
247 prompt.render(buffer.area, &mut buffer, &mut state);
248
249 let line = line!["?".cyan(), " ", "π".bold(), " βΊ ".cyan().dim(), " "];
250 assert_eq!(buffer, Buffer::with_lines([line]));
251 assert_eq!(state.cursor(), (7, 0));
252 }
253
254 #[test]
255 fn render_with_done() {
256 let prompt = TextPrompt::from("prompt");
257 let mut state = TextState::new().with_status(Status::Done);
258 let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
259
260 prompt.render(buffer.area, &mut buffer, &mut state);
261
262 let line = line![
263 "β".green(),
264 " ",
265 "prompt".bold(),
266 " βΊ ".cyan().dim(),
267 " "
268 ];
269 assert_eq!(buffer, Buffer::with_lines([line]));
270 }
271
272 #[test]
273 fn render_with_aborted() {
274 let prompt = TextPrompt::from("prompt");
275 let mut state = TextState::new().with_status(Status::Aborted);
276 let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
277
278 prompt.render(buffer.area, &mut buffer, &mut state);
279
280 let line = line!["β".red(), " ", "prompt".bold(), " βΊ ".cyan().dim(), " "];
281 assert_eq!(buffer, Buffer::with_lines([line]));
282 }
283
284 #[test]
285 fn render_with_value() {
286 let prompt = TextPrompt::from("prompt");
287 let mut state = TextState::new().with_value("value");
288 let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
289
290 prompt.render(buffer.area, &mut buffer, &mut state);
291
292 let line = line![
293 "?".cyan(),
294 " ",
295 "prompt".bold(),
296 " βΊ ".cyan().dim(),
297 "value ".to_string()
298 ];
299 assert_eq!(buffer, Buffer::with_lines([line]));
300 }
301
302 #[test]
303 fn render_with_block() {
304 let prompt = TextPrompt::from("prompt")
305 .with_block(Block::default().borders(Borders::ALL).title("Title"));
306 let mut state = TextState::new();
307 let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 3));
308
309 prompt.render(buffer.area, &mut buffer, &mut state);
310
311 let mut expected = Buffer::with_lines(vec![
312 "βTitleβββββββββ",
313 "β? prompt βΊ β",
314 "βββββββββββββββ",
315 ]);
316 expected.set_style(Rect::new(1, 1, 1, 1), Color::Cyan);
317 expected.set_style(Rect::new(3, 1, 6, 1), Modifier::BOLD);
318 expected.set_style(Rect::new(9, 1, 3, 1), (Color::Cyan, Modifier::DIM));
319 assert_eq!(buffer, expected);
320 }
321
322 #[test]
323 fn render_password() {
324 let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Password);
325 let mut state = TextState::new().with_value("value");
326 let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
327
328 prompt.render(buffer.area, &mut buffer, &mut state);
329
330 let line = line![
331 "?".cyan(),
332 " ",
333 "prompt".bold(),
334 " βΊ ".cyan().dim(),
335 "***** ".to_string()
336 ];
337 assert_eq!(buffer, Buffer::with_lines([line]));
338 }
339
340 #[test]
341 fn render_invisible() {
342 let prompt = TextPrompt::from("prompt").with_render_style(TextRenderStyle::Invisible);
343 let mut state = TextState::new().with_value("value");
344 let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
345
346 prompt.render(buffer.area, &mut buffer, &mut state);
347
348 let line = line![
349 "?".cyan(),
350 " ",
351 "prompt".bold(),
352 " βΊ ".cyan().dim(),
353 " ".to_string()
354 ];
355 assert_eq!(buffer, Buffer::with_lines([line]));
356 }
357
358 #[fixture]
359 fn terminal() -> Terminal<TestBackend> {
360 Terminal::new(TestBackend::new(17, 2)).unwrap()
361 }
362
363 type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
364
365 #[rstest]
366 fn draw_not_focused<'a>(mut terminal: Terminal<impl Backend>) -> Result<()> {
367 let prompt = TextPrompt::from("prompt");
368 let mut state = TextState::new().with_value("hello");
369 let _ = terminal.draw(|frame| prompt.draw(frame, frame.area(), &mut state))?;
371 assert_eq!(state.cursor(), (11, 0));
372 assert_eq!(
373 terminal.backend_mut().get_cursor_position().unwrap(),
374 Position::ORIGIN
375 );
376 Ok(())
377 }
378
379 #[rstest]
380 fn draw_focused<'a>(mut terminal: Terminal<impl Backend>) -> Result<()> {
381 let prompt = TextPrompt::from("prompt");
382 let mut state = TextState::new().with_value("hello");
383 state.focus();
385 let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
386 assert_eq!(state.cursor(), (11, 0));
387 assert_eq!(
388 terminal.backend_mut().get_cursor_position().unwrap(),
389 Position::new(11, 0)
390 );
391 Ok(())
392 }
393
394 #[rstest]
395 #[case::position_0(0, (11, 0))] #[case::position_3(2, (13, 0))] #[case::position_4(4, (15, 0))] #[case::position_5(5, (16, 0))] #[case::position_6(6, (0, 1))] #[case::position_7(7, (1, 1))] #[case::position_22(22, (16, 1))] #[case::position_99(99, (16, 1))] fn draw_unwrapped_position<'a>(
404 #[case] position: usize,
405 #[case] expected_cursor: (u16, u16),
406 mut terminal: Terminal<impl Backend>,
407 ) -> Result<()> {
408 let prompt = TextPrompt::from("prompt");
409 let mut state = TextState::new().with_value("hello");
410 state.focus();
416 *state.position_mut() = position;
417 let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
418 assert_eq!(state.cursor(), expected_cursor);
419 assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
420
421 Ok(())
422 }
423
424 #[rstest]
425 #[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(12, (6, 1))] #[case::position_13(13, (7, 1))] #[case::position_22(22, (16, 1))] #[case::position_99(99, (16, 1))] fn draw_wrapped_position<'a>(
436 #[case] position: usize,
437 #[case] expected_cursor: (u16, u16),
438 mut terminal: Terminal<impl Backend>,
439 ) -> Result<()> {
440 let prompt = TextPrompt::from("prompt");
441 let mut state = TextState::new().with_value("hello world");
442 state.focus();
450 *state.position_mut() = position;
451 let _ = terminal.draw(|frame| prompt.clone().draw(frame, frame.area(), &mut state))?;
452 assert_eq!(state.cursor(), expected_cursor);
453 assert_eq!(terminal.get_cursor_position()?, expected_cursor.into());
454
455 Ok(())
456 }
457}