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::layout::{Position, Size, bottom_third, centered_block_origin, centered_column, size};
11
12const BOX_CHROME: u16 = 4; // "│ " + " │"
13const MIN_INNER_WIDTH: u16 = 20;
14const MAX_INNER_WIDTH: u16 = 60;
15const BOX_HEIGHT: u16 = 6; // question + top border + content + bottom border + hint + blank
16
17#[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  // ── visible_window ──────────────────────────────────────
593
594  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  // ── compute_window ────────────────────────────────────────
671
672  #[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  // ── clamp_selection ───────────────────────────────────────
694
695  #[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}