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