1use std::io::Write;
2
3use crossterm::{
4 cursor::{MoveTo, SetCursorStyle, Show},
5 queue,
6 style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
7 terminal::{Clear, ClearType},
8};
9
10use crate::layout::{Position, Size, bottom_third, centered_block_origin, centered_column, size};
11
12const BOX_CHROME: u16 = 4; const MIN_INNER_WIDTH: u16 = 20;
14const MAX_INNER_WIDTH: u16 = 60;
15const BOX_HEIGHT: u16 = 6; #[derive(Debug, Clone)]
18pub struct TextEntry {
19 pub label: String,
20 pub hint: String,
21 pub inner_width: u16,
22}
23
24#[derive(Debug, Clone)]
25pub struct TextEntryLayout {
26 pub question_row: u16,
27 pub box_top_row: u16,
28 pub content_row: u16,
29 pub box_bottom_row: u16,
30 pub hint_row: u16,
31 pub box_column: u16,
32 pub inner_width: u16,
33}
34
35pub fn text_entry(label: &str, hint: &str, terminal_width: u16) -> TextEntry {
36 let proportion = terminal_width.saturating_mul(3) / 10;
37 let inner = proportion.clamp(MIN_INNER_WIDTH, MAX_INNER_WIDTH);
38
39 TextEntry {
40 label: label.to_string(),
41 hint: hint.to_string(),
42 inner_width: inner,
43 }
44}
45
46pub fn text_entry_layout(entry: &TextEntry, terminal_size: Size) -> TextEntryLayout {
47 let region = bottom_third(terminal_size);
48 let outer_width = entry.inner_width.saturating_add(BOX_CHROME);
49 let block_size = size(outer_width, BOX_HEIGHT);
50 let origin = centered_block_origin(region, block_size);
51
52 TextEntryLayout {
53 question_row: origin.row,
54 box_top_row: origin.row.saturating_add(1),
55 content_row: origin.row.saturating_add(2),
56 box_bottom_row: origin.row.saturating_add(3),
57 hint_row: origin.row.saturating_add(4),
58 box_column: origin.column,
59 inner_width: entry.inner_width,
60 }
61}
62
63pub fn visible_content(entry: &TextEntry, value: &str) -> String {
64 let width = entry.inner_width as usize;
65
66 if value.is_empty() {
67 return truncate_or_pad(&entry.hint, width);
68 }
69
70 if value.len() <= width {
71 return pad_right(value, width);
72 }
73
74 let visible = &value[value.len() - (width - 1)..];
75 format!("\u{2026}{}", pad_right(visible, width - 1))
76}
77
78pub struct VisibleWindow {
79 pub text: String,
80 pub cursor_col: usize,
81 pub selection: Option<(usize, usize)>,
82}
83
84pub fn visible_window(
85 entry: &TextEntry,
86 value: &str,
87 cursor: usize,
88 selection: Option<(usize, usize)>,
89) -> VisibleWindow {
90 let width = entry.inner_width as usize;
91 let chars: Vec<char> = value.chars().collect();
92 let len = chars.len();
93
94 if value.is_empty() {
95 return VisibleWindow {
96 text: truncate_or_pad(&entry.hint, width),
97 cursor_col: 0,
98 selection: None,
99 };
100 }
101
102 if len <= width {
103 return VisibleWindow {
104 text: pad_right(value, width),
105 cursor_col: cursor.min(len),
106 selection: clamp_selection(selection, 0, len),
107 };
108 }
109
110 let (start, end) = compute_window(cursor, len, width);
111 let window_text: String = chars[start..end].iter().collect();
112
113 let display = if start > 0 {
114 let trimmed: String = chars[start + 1..end].iter().collect();
115 format!("\u{2026}{}", pad_right(&trimmed, width - 1))
116 } else {
117 pad_right(&window_text, width)
118 };
119
120 let cursor_col = cursor.saturating_sub(start);
121 let sel = selection.map(|(s, e)| {
122 let ws = s.saturating_sub(start).min(width);
123 let we = e.saturating_sub(start).min(width);
124 (ws, we)
125 });
126
127 VisibleWindow {
128 text: display,
129 cursor_col,
130 selection: sel.filter(|(s, e)| s != e),
131 }
132}
133
134fn compute_window(cursor: usize, len: usize, width: usize) -> (usize, usize) {
135 if cursor <= width / 2 {
136 (0, width.min(len))
137 } else if cursor >= len.saturating_sub(width / 2) {
138 let start = len.saturating_sub(width);
139 (start, len)
140 } else {
141 let start = cursor.saturating_sub(width / 2);
142 (start, (start + width).min(len))
143 }
144}
145
146fn clamp_selection(sel: Option<(usize, usize)>, min: usize, max: usize) -> Option<(usize, usize)> {
147 sel
148 .map(|(s, e)| (s.max(min).min(max), e.max(min).min(max)))
149 .filter(|(s, e)| s != e)
150}
151
152pub fn top_border(entry: &TextEntry) -> String {
153 let label_display = format!(" {} ", entry.label);
154 let label_chars = label_display.chars().count();
155 let fill_count = (entry.inner_width as usize)
156 .saturating_add(2)
157 .saturating_sub(label_chars);
158
159 let fill: String = "─".repeat(fill_count);
160 format!("╭{label_display}{fill}╮")
161}
162
163pub fn bottom_border(inner_width: u16) -> String {
164 let fill: String = "─".repeat(inner_width as usize + 2);
165 format!("╰{fill}╯")
166}
167
168pub fn content_line(visible: &str) -> String {
169 format!("│ {visible} │")
170}
171
172fn truncate_or_pad(text: &str, width: usize) -> String {
173 if text.len() <= width {
174 return pad_right(text, width);
175 }
176
177 let visible = &text[..width];
178 visible.to_string()
179}
180
181fn pad_right(text: &str, width: usize) -> String {
182 format!("{:<width$}", text, width = width)
183}
184
185#[cfg(not(tarpaulin_include))]
186pub fn queue_question<W: Write>(writer: &mut W, text: &str, pos: Position) -> std::io::Result<()> {
187 queue!(
188 writer,
189 MoveTo(pos.column, pos.row),
190 Clear(ClearType::CurrentLine),
191 SetForegroundColor(Color::White),
192 SetAttribute(Attribute::Bold),
193 Print(text),
194 SetAttribute(Attribute::Reset),
195 ResetColor
196 )
197}
198
199#[cfg(not(tarpaulin_include))]
200pub fn queue_text_entry<W: Write>(
201 writer: &mut W,
202 layout: &TextEntryLayout,
203 entry: &TextEntry,
204 value: &str,
205 label_width: u16,
206) -> std::io::Result<()> {
207 let visible = visible_content(entry, value);
208 let is_hint = value.is_empty();
209 let bottom = bottom_border(layout.inner_width);
210
211 queue_box_border(
212 writer,
213 &entry.label,
214 layout.inner_width,
215 label_width,
216 layout.box_column,
217 layout.box_top_row,
218 )?;
219 queue_box_content(
220 writer,
221 &visible,
222 is_hint,
223 layout.box_column,
224 layout.content_row,
225 )?;
226 queue_box_border_plain(writer, &bottom, layout.box_column, layout.box_bottom_row)
227}
228
229#[cfg(not(tarpaulin_include))]
230pub fn queue_cursor<W: Write>(
231 writer: &mut W,
232 layout: &TextEntryLayout,
233 cursor_col: u16,
234) -> std::io::Result<()> {
235 let abs_col = layout.box_column + 2 + cursor_col;
236 queue!(
237 writer,
238 MoveTo(abs_col, layout.content_row),
239 Show,
240 SetCursorStyle::BlinkingBlock
241 )
242}
243
244#[cfg(not(tarpaulin_include))]
245#[allow(clippy::too_many_arguments)]
246pub fn queue_text_entry_with_cursor<W: Write>(
247 writer: &mut W,
248 layout: &TextEntryLayout,
249 entry: &TextEntry,
250 value: &str,
251 label_width: u16,
252 cursor: usize,
253 selection: Option<(usize, usize)>,
254) -> std::io::Result<u16> {
255 let window = visible_window(entry, value, cursor, selection);
256 let cursor_col = window.cursor_col as u16;
257 let is_hint = value.is_empty();
258 let bottom = bottom_border(layout.inner_width);
259
260 queue_box_border(
261 writer,
262 &entry.label,
263 layout.inner_width,
264 label_width,
265 layout.box_column,
266 layout.box_top_row,
267 )?;
268 queue_box_content_with_cursor(
269 writer,
270 &window,
271 is_hint,
272 layout.box_column,
273 layout.content_row,
274 )?;
275 queue_box_border_plain(writer, &bottom, layout.box_column, layout.box_bottom_row)?;
276 Ok(cursor_col)
277}
278
279#[cfg(not(tarpaulin_include))]
280fn queue_box_content_with_cursor<W: Write>(
281 writer: &mut W,
282 window: &VisibleWindow,
283 is_hint: bool,
284 column: u16,
285 row: u16,
286) -> std::io::Result<()> {
287 queue!(
288 writer,
289 MoveTo(column, row),
290 Clear(ClearType::CurrentLine),
291 SetForegroundColor(Color::DarkGrey),
292 Print("│ ")
293 )?;
294
295 if let Some((sel_start, sel_end)) = window.selection {
296 let chars: Vec<char> = window.text.chars().collect();
297 let before: String = chars[..sel_start].iter().collect();
298 let selected: String = chars[sel_start..sel_end].iter().collect();
299 let after: String = chars[sel_end..].iter().collect();
300
301 let content_color = if is_hint {
302 Color::DarkGrey
303 } else {
304 Color::White
305 };
306 queue!(
307 writer,
308 SetForegroundColor(content_color),
309 Print(&before),
310 SetAttribute(Attribute::Reverse),
311 Print(&selected),
312 SetAttribute(Attribute::Reset),
313 SetForegroundColor(content_color),
314 Print(&after)
315 )?;
316 } else {
317 let content_color = if is_hint {
318 Color::DarkGrey
319 } else {
320 Color::White
321 };
322 queue!(
323 writer,
324 SetForegroundColor(content_color),
325 Print(&window.text)
326 )?;
327 }
328
329 queue!(
330 writer,
331 SetForegroundColor(Color::DarkGrey),
332 Print(" │"),
333 ResetColor
334 )
335}
336
337#[cfg(not(tarpaulin_include))]
338fn queue_box_border<W: Write>(
339 writer: &mut W,
340 label: &str,
341 inner_width: u16,
342 label_width: u16,
343 column: u16,
344 row: u16,
345) -> std::io::Result<()> {
346 let total_fill = inner_width as usize + 2;
347
348 if label.is_empty() {
349 let fill: String = "─".repeat(total_fill);
350 return queue!(
351 writer,
352 MoveTo(column, row),
353 Clear(ClearType::CurrentLine),
354 SetForegroundColor(Color::DarkGrey),
355 Print("╭"),
356 Print(&fill),
357 Print("╮"),
358 ResetColor
359 );
360 }
361
362 let label_display = format!(" {label} ");
363 let label_display_chars = label_display.chars().count();
364 let left_fill_count = (label_width as usize + 2).saturating_sub(label_display_chars);
365 let left_fill: String = "─".repeat(left_fill_count);
366 let right_fill_count = total_fill.saturating_sub(left_fill_count + label_display_chars);
367 let right_fill: String = "─".repeat(right_fill_count);
368
369 queue!(
370 writer,
371 MoveTo(column, row),
372 Clear(ClearType::CurrentLine),
373 SetForegroundColor(Color::DarkGrey),
374 Print("╭"),
375 Print(&left_fill),
376 SetForegroundColor(Color::Cyan),
377 SetAttribute(Attribute::Bold),
378 Print(&label_display),
379 SetAttribute(Attribute::Reset),
380 SetForegroundColor(Color::DarkGrey),
381 Print(&right_fill),
382 Print("╮"),
383 ResetColor
384 )
385}
386
387#[cfg(not(tarpaulin_include))]
388fn queue_box_content<W: Write>(
389 writer: &mut W,
390 visible: &str,
391 is_hint: bool,
392 column: u16,
393 row: u16,
394) -> std::io::Result<()> {
395 let content_color = if is_hint {
396 Color::DarkGrey
397 } else {
398 Color::White
399 };
400
401 queue!(
402 writer,
403 MoveTo(column, row),
404 Clear(ClearType::CurrentLine),
405 SetForegroundColor(Color::DarkGrey),
406 Print("│ "),
407 SetForegroundColor(content_color),
408 Print(visible),
409 SetForegroundColor(Color::DarkGrey),
410 Print(" │"),
411 ResetColor
412 )
413}
414
415#[cfg(not(tarpaulin_include))]
416fn queue_box_border_plain<W: Write>(
417 writer: &mut W,
418 border: &str,
419 column: u16,
420 row: u16,
421) -> std::io::Result<()> {
422 queue!(
423 writer,
424 MoveTo(column, row),
425 Clear(ClearType::CurrentLine),
426 SetForegroundColor(Color::DarkGrey),
427 Print(border),
428 ResetColor
429 )
430}
431
432#[cfg(not(tarpaulin_include))]
433pub fn queue_hint_line<W: Write>(
434 writer: &mut W,
435 text: &str,
436 tw: u16,
437 row: u16,
438 color: Color,
439) -> std::io::Result<()> {
440 let col = centered_column(tw, text.len() as u16);
441 queue!(
442 writer,
443 MoveTo(col, row),
444 Clear(ClearType::CurrentLine),
445 SetForegroundColor(color),
446 Print(text),
447 ResetColor
448 )
449}
450
451#[cfg(not(tarpaulin_include))]
452pub fn queue_clear_prompt<W: Write>(
453 writer: &mut W,
454 layout: &TextEntryLayout,
455) -> std::io::Result<()> {
456 queue!(
457 writer,
458 MoveTo(0, layout.question_row),
459 Clear(ClearType::CurrentLine),
460 MoveTo(0, layout.box_top_row),
461 Clear(ClearType::CurrentLine),
462 MoveTo(0, layout.content_row),
463 Clear(ClearType::CurrentLine),
464 MoveTo(0, layout.box_bottom_row),
465 Clear(ClearType::CurrentLine),
466 MoveTo(0, layout.hint_row),
467 Clear(ClearType::CurrentLine)
468 )
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn text_entry_clamps_inner_width() {
477 let narrow = text_entry("X", "hint", 30);
478 assert!(narrow.inner_width >= MIN_INNER_WIDTH);
479
480 let wide = text_entry("X", "hint", 200);
481 assert!(wide.inner_width <= MAX_INNER_WIDTH);
482 }
483
484 #[test]
485 fn text_entry_layout_places_rows_sequentially() {
486 let ib = text_entry("VAR", "default", 80);
487 let layout = text_entry_layout(&ib, size(80, 30));
488
489 assert_eq!(layout.box_top_row, layout.question_row + 1);
490 assert_eq!(layout.content_row, layout.box_top_row + 1);
491 assert_eq!(layout.box_bottom_row, layout.content_row + 1);
492 assert_eq!(layout.hint_row, layout.box_bottom_row + 1);
493 }
494
495 #[test]
496 fn text_entry_layout_centers_horizontally() {
497 let ib = text_entry("VAR", "default", 120);
498 let layout = text_entry_layout(&ib, size(120, 30));
499
500 assert!(layout.box_column > 0);
501 }
502
503 #[test]
504 fn visible_content_shows_hint_when_empty() {
505 let ib = text_entry("X", "~/.config/blizz", 80);
506 let content = visible_content(&ib, "");
507
508 assert!(content.starts_with("~/.config/blizz"));
509 }
510
511 #[test]
512 fn visible_content_shows_value_when_present() {
513 let ib = text_entry("X", "~/.config/blizz", 80);
514 let content = visible_content(&ib, "/home");
515
516 assert!(content.starts_with("/home"));
517 }
518
519 #[test]
520 fn visible_content_truncates_long_input_with_ellipsis() {
521 let ib = TextEntry {
522 label: "X".to_string(),
523 hint: "hint".to_string(),
524 inner_width: 10,
525 };
526 let content = visible_content(&ib, "abcdefghijklmnop");
527
528 assert!(content.starts_with('\u{2026}'));
529 assert_eq!(content.chars().count(), 10);
530 assert!(content.contains("hijklmnop"));
531 }
532
533 #[test]
534 fn visible_content_pads_short_input() {
535 let ib = TextEntry {
536 label: "X".to_string(),
537 hint: "hint".to_string(),
538 inner_width: 20,
539 };
540 let content = visible_content(&ib, "hi");
541
542 assert_eq!(content.len(), 20);
543 assert!(content.starts_with("hi"));
544 }
545
546 #[test]
547 fn top_border_includes_label_and_corners() {
548 let ib = text_entry("BLIZZ_HOME", "hint", 80);
549 let border = top_border(&ib);
550
551 assert!(border.starts_with("╭"));
552 assert!(border.ends_with("╮"));
553 assert!(border.contains("BLIZZ_HOME"));
554 }
555
556 #[test]
557 fn bottom_border_has_correct_width() {
558 let border = bottom_border(20);
559
560 assert!(border.starts_with("╰"));
561 assert!(border.ends_with("╯"));
562 }
563
564 #[test]
565 fn content_line_wraps_with_box_chars() {
566 let line = content_line("hello world ");
567
568 assert!(line.starts_with("│ "));
569 assert!(line.ends_with(" │"));
570 }
571
572 #[test]
573 fn visible_content_uses_hint_as_placeholder() {
574 let ib = TextEntry {
575 label: "X".to_string(),
576 hint: "type here".to_string(),
577 inner_width: 20,
578 };
579 let content = visible_content(&ib, "");
580 assert!(content.contains("type here"));
581 assert_eq!(content.len(), 20);
582 }
583
584 #[test]
585 fn top_border_with_empty_label_still_has_corners() {
586 let ib = text_entry("", "hint", 80);
587 let border = top_border(&ib);
588 assert!(border.starts_with("╭"));
589 assert!(border.ends_with("╮"));
590 }
591
592 fn ib(width: u16) -> TextEntry {
595 TextEntry {
596 label: "X".into(),
597 hint: "hint".into(),
598 inner_width: width,
599 }
600 }
601
602 #[test]
603 fn visible_window_empty_input_shows_hint() {
604 let w = visible_window(&ib(20), "", 0, None);
605 assert!(w.text.contains("hint"));
606 assert_eq!(w.cursor_col, 0);
607 assert!(w.selection.is_none());
608 }
609
610 #[test]
611 fn visible_window_short_input_fits_fully() {
612 let w = visible_window(&ib(20), "hello", 5, None);
613 assert!(w.text.starts_with("hello"));
614 assert_eq!(w.text.chars().count(), 20);
615 assert_eq!(w.cursor_col, 5);
616 assert!(w.selection.is_none());
617 }
618
619 #[test]
620 fn visible_window_short_input_with_selection() {
621 let w = visible_window(&ib(20), "hello", 3, Some((1, 4)));
622 assert_eq!(w.selection, Some((1, 4)));
623 }
624
625 #[test]
626 fn visible_window_short_input_clamps_cursor() {
627 let w = visible_window(&ib(20), "hi", 99, None);
628 assert_eq!(w.cursor_col, 2);
629 }
630
631 #[test]
632 fn visible_window_long_input_cursor_near_start() {
633 let input = "abcdefghijklmnopqrstuvwxyz";
634 let w = visible_window(&ib(10), input, 2, None);
635 assert_eq!(w.text.chars().count(), 10);
636 assert_eq!(w.cursor_col, 2);
637 }
638
639 #[test]
640 fn visible_window_long_input_cursor_near_end() {
641 let input = "abcdefghijklmnopqrstuvwxyz";
642 let w = visible_window(&ib(10), input, 24, None);
643 assert_eq!(w.text.chars().count(), 10);
644 }
645
646 #[test]
647 fn visible_window_long_input_cursor_in_middle_shows_ellipsis() {
648 let input = "abcdefghijklmnopqrstuvwxyz";
649 let w = visible_window(&ib(10), input, 13, None);
650 assert_eq!(w.text.chars().count(), 10);
651 assert!(w.text.starts_with('\u{2026}'));
652 }
653
654 #[test]
655 fn visible_window_long_input_with_selection() {
656 let input = "abcdefghijklmnopqrstuvwxyz";
657 let w = visible_window(&ib(10), input, 13, Some((10, 16)));
658 assert!(w.selection.is_some());
659 let (s, e) = w.selection.unwrap();
660 assert!(s < e);
661 }
662
663 #[test]
664 fn visible_window_selection_collapsed_is_none() {
665 let input = "abcdefghijklmnopqrstuvwxyz";
666 let w = visible_window(&ib(10), input, 2, Some((5, 5)));
667 assert!(w.selection.is_none());
668 }
669
670 #[test]
673 fn compute_window_cursor_at_start() {
674 let (start, end) = compute_window(2, 30, 10);
675 assert_eq!(start, 0);
676 assert_eq!(end, 10);
677 }
678
679 #[test]
680 fn compute_window_cursor_at_end() {
681 let (start, end) = compute_window(28, 30, 10);
682 assert_eq!(start, 20);
683 assert_eq!(end, 30);
684 }
685
686 #[test]
687 fn compute_window_cursor_in_middle() {
688 let (start, end) = compute_window(15, 30, 10);
689 assert_eq!(start, 10);
690 assert_eq!(end, 20);
691 }
692
693 #[test]
696 fn clamp_selection_within_range() {
697 assert_eq!(clamp_selection(Some((2, 5)), 0, 10), Some((2, 5)));
698 }
699
700 #[test]
701 fn clamp_selection_clamps_to_bounds() {
702 assert_eq!(clamp_selection(Some((0, 15)), 2, 10), Some((2, 10)));
703 }
704
705 #[test]
706 fn clamp_selection_collapsed_is_none() {
707 assert_eq!(clamp_selection(Some((5, 5)), 0, 10), None);
708 }
709
710 #[test]
711 fn clamp_selection_none_input() {
712 assert_eq!(clamp_selection(None, 0, 10), None);
713 }
714
715 #[cfg(not(tarpaulin_include))]
716 #[test]
717 fn queue_question_writes_text() {
718 let mut buffer = Vec::new();
719 let pos = Position { column: 0, row: 0 };
720
721 queue_question(&mut buffer, "Where to install?", pos).unwrap();
722 let output = String::from_utf8(buffer).unwrap();
723
724 assert!(output.contains("Where to install?"));
725 }
726
727 #[cfg(not(tarpaulin_include))]
728 #[test]
729 fn queue_text_entry_writes_all_parts() {
730 let mut buffer = Vec::new();
731 let ib = text_entry("VAR", "default", 80);
732 let layout = text_entry_layout(&ib, size(80, 30));
733
734 queue_text_entry(
735 &mut buffer,
736 &layout,
737 &ib,
738 "",
739 ib.label.chars().count() as u16,
740 )
741 .unwrap();
742 let output = String::from_utf8(buffer).unwrap();
743
744 assert!(output.contains("VAR"));
745 }
746
747 #[cfg(not(tarpaulin_include))]
748 #[test]
749 fn queue_clear_prompt_clears_all_rows() {
750 let mut buffer = Vec::new();
751 let ib = text_entry("VAR", "default", 80);
752 let layout = text_entry_layout(&ib, size(80, 30));
753
754 queue_clear_prompt(&mut buffer, &layout).unwrap();
755
756 assert!(!buffer.is_empty());
757 }
758}