Skip to main content

blizz_ui/
prompt.rs

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;
14/// question row + top border + content + bottom border + hint + blank
15const BOX_HEIGHT: u16 = 6;
16/// Inner box width as a percentage of terminal width, clamped to [MIN, MAX].
17const 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  // ── visible_window ──────────────────────────────────────
604
605  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  // ── compute_window ────────────────────────────────────────
682
683  #[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  // ── clamp_selection ───────────────────────────────────────
705
706  #[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}