Skip to main content

blizz_ui/components/
selector.rs

1use std::io::Write;
2
3use crossterm::{
4  cursor::MoveTo,
5  queue,
6  style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
7};
8use rand::Rng;
9
10use crate::decode;
11use crate::layout::centered_column;
12use crate::prompt::text_entry;
13use crate::select_option::SelectOption;
14
15const PREFIX_ACTIVE: &str = "\u{25b8} ";
16const PREFIX_INACTIVE: &str = "  ";
17const ARROW_UP: char = '\u{25b2}';
18const ARROW_DOWN: char = '\u{25bc}';
19const ELLIPSIS: &str = " \u{00b7}\u{00b7}\u{00b7} ";
20const BORDER_VERTICAL: &str = "\u{2502}";
21const BORDER_HORIZONTAL: &str = "\u{2500}";
22const CORNER_TOP_LEFT: &str = "\u{256d}";
23const CORNER_TOP_RIGHT: &str = "\u{256e}";
24const CORNER_BOTTOM_LEFT: &str = "\u{2570}";
25const CORNER_BOTTOM_RIGHT: &str = "\u{256f}";
26const BORDER_TOP_OFFSET: u16 = 1;
27
28const MAX_VISIBLE_OPTIONS: usize = 8;
29
30/// Vertical list navigation (↑/↓); map from the host wizard’s up/down actions.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum ListNavigate {
33  Up,
34  Down,
35}
36
37/// Select list with scroll, open/reveal animation progress, and bordered layout.
38#[derive(Debug, Clone)]
39pub struct SelectorComponent {
40  pub options: Vec<SelectOption>,
41  pub selected: usize,
42  pub scroll_offset: usize,
43  pub visible: bool,
44  pub open: f64,
45  pub reveal: f64,
46}
47
48impl SelectorComponent {
49  pub fn hidden() -> Self {
50    Self {
51      options: Vec::new(),
52      selected: 0,
53      scroll_offset: 0,
54      visible: false,
55      open: 0.0,
56      reveal: 0.0,
57    }
58  }
59
60  pub fn from_options(options: Vec<SelectOption>, default_index: usize) -> Self {
61    Self {
62      options,
63      selected: default_index,
64      scroll_offset: 0,
65      visible: true,
66      open: 0.0,
67      reveal: 0.0,
68    }
69  }
70
71  pub fn is_fully_open(&self) -> bool {
72    self.open >= 1.0
73  }
74
75  pub fn is_fully_closed(&self) -> bool {
76    self.open <= 0.0
77  }
78
79  pub fn is_fully_revealed(&self) -> bool {
80    self.reveal >= 1.0
81  }
82
83  pub fn is_fully_hidden(&self) -> bool {
84    self.reveal <= 0.0
85  }
86
87  /// How many option rows are visible for a list of this length (same cap as [`SelectorComponent::render`]).
88  pub fn viewport_row_count(option_count: usize) -> usize {
89    option_count.min(MAX_VISIBLE_OPTIONS)
90  }
91
92  pub fn navigate(&mut self, direction: ListNavigate) {
93    let len = self.options.len();
94    if len == 0 {
95      return;
96    }
97
98    match direction {
99      ListNavigate::Up => {
100        self.selected = if self.selected == 0 {
101          len - 1
102        } else {
103          self.selected - 1
104        };
105      }
106      ListNavigate::Down => {
107        self.selected = (self.selected + 1) % len;
108      }
109    }
110
111    let max_visible = Self::viewport_row_count(len);
112    if self.selected < self.scroll_offset {
113      self.scroll_offset = self.selected;
114    } else if self.selected >= self.scroll_offset + max_visible {
115      self.scroll_offset = self.selected + 1 - max_visible;
116    }
117
118    if self.selected == 0 {
119      self.scroll_offset = 0;
120    } else if self.selected == len - 1 {
121      self.scroll_offset = len.saturating_sub(max_visible);
122    }
123  }
124
125  /// Wizard decode phase: waits until the question’s reveal progress crosses 0.5 (same gate as text entry).
126  pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
127    if !self.visible || question_reveal < 0.5 {
128      return;
129    }
130    self.open = (self.open + box_open_speed).min(1.0);
131    if self.open >= 1.0 {
132      self.reveal = (self.reveal + reveal_speed).min(1.0);
133    }
134  }
135
136  pub fn tick_transition_encode(
137    &mut self,
138    next_has_selector: bool,
139    encode_speed: f64,
140    morph_speed: f64,
141  ) {
142    if !self.visible {
143      return;
144    }
145    self.reveal = (self.reveal - encode_speed).max(0.0);
146    if !next_has_selector && self.is_fully_hidden() {
147      self.open = (self.open - morph_speed).max(0.0);
148    }
149  }
150
151  pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
152    if !self.visible {
153      return;
154    }
155    if self.open < 1.0 {
156      self.open = (self.open + morph_speed).min(1.0);
157    } else {
158      self.reveal = (self.reveal + encode_speed).min(1.0);
159    }
160  }
161
162  pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
163    if !self.visible {
164      return;
165    }
166    self.reveal = (self.reveal - encode_speed).max(0.0);
167    if self.reveal <= 0.0 {
168      self.open = (self.open - box_close_speed).max(0.0);
169    }
170  }
171
172  #[cfg(not(tarpaulin_include))]
173  pub fn render<W: Write, R: Rng>(
174    &self,
175    writer: &mut W,
176    tw: u16,
177    start_row: u16,
178    rng: &mut R,
179  ) -> std::io::Result<usize> {
180    if self.options.is_empty() {
181      return Ok(0);
182    }
183
184    let layout = SelectorLayout::compute(self, tw);
185
186    if self.open < 1.0 {
187      return render_partial_border(writer, self.open, &layout, start_row);
188    }
189
190    let scroll_offset = self.scroll_offset;
191    let has_scroll_up = scroll_offset > 0;
192    let has_scroll_down = scroll_offset + layout.max_visible < self.options.len();
193
194    let mut row = start_row;
195
196    let top = selector_border(layout.inner_width, has_scroll_up, true);
197    queue!(
198      writer,
199      MoveTo(layout.col, row),
200      SetForegroundColor(Color::DarkGrey),
201      Print(&top),
202      ResetColor
203    )?;
204    row += 1;
205
206    render_selector_items(
207      writer,
208      self,
209      &layout,
210      row,
211      has_scroll_up,
212      has_scroll_down,
213      rng,
214    )?;
215    row += layout.max_visible as u16;
216
217    let bottom = selector_border(layout.inner_width, has_scroll_down, false);
218    queue!(
219      writer,
220      MoveTo(layout.col, row),
221      SetForegroundColor(Color::DarkGrey),
222      Print(&bottom),
223      ResetColor
224    )?;
225
226    Ok(layout.max_visible + 2)
227  }
228}
229
230struct SelectorLayout {
231  tw: u16,
232  inner_width: u16,
233  col: u16,
234  max_visible: usize,
235}
236
237impl SelectorLayout {
238  fn compute(sel: &SelectorComponent, tw: u16) -> Self {
239    let total = sel.options.len();
240    let max_visible = total.min(MAX_VISIBLE_OPTIONS);
241    let inner_width = selector_inner_width(sel, tw);
242    let outer_width = inner_width + 4;
243    let col = centered_column(tw, outer_width);
244    Self {
245      tw,
246      inner_width,
247      col,
248      max_visible,
249    }
250  }
251}
252
253fn selector_inner_width(sel: &SelectorComponent, tw: u16) -> u16 {
254  let max_label = sel
255    .options
256    .iter()
257    .map(|o| o.label.chars().count())
258    .max()
259    .unwrap_or(0);
260  let prefix_width = PREFIX_ACTIVE.chars().count();
261  let content_min = (prefix_width + max_label + 2) as u16;
262  let entry_width = text_entry("", "", tw).inner_width;
263  content_min.max(entry_width)
264}
265
266fn selector_border(inner_width: u16, has_overflow: bool, top: bool) -> String {
267  let fill_total = inner_width as usize + 2;
268  let (left_corner, right_corner) = if top {
269    (CORNER_TOP_LEFT, CORNER_TOP_RIGHT)
270  } else {
271    (CORNER_BOTTOM_LEFT, CORNER_BOTTOM_RIGHT)
272  };
273
274  if !has_overflow {
275    let fill: String = BORDER_HORIZONTAL.repeat(fill_total);
276    return format!("{left_corner}{fill}{right_corner}");
277  }
278
279  let text_col = PREFIX_ACTIVE.chars().count() + 1;
280  let leading_space = ELLIPSIS.chars().take_while(|c| *c == ' ').count();
281  let prefix_offset = text_col.saturating_sub(leading_space);
282  let ellipsis_len = ELLIPSIS.chars().count();
283  let right_fill = fill_total.saturating_sub(prefix_offset + ellipsis_len);
284  format!(
285    "{}{}{}{}{}",
286    left_corner,
287    BORDER_HORIZONTAL.repeat(prefix_offset),
288    ELLIPSIS,
289    BORDER_HORIZONTAL.repeat(right_fill),
290    right_corner,
291  )
292}
293
294#[cfg(not(tarpaulin_include))]
295fn render_partial_border<W: Write>(
296  writer: &mut W,
297  open_progress: f64,
298  layout: &SelectorLayout,
299  start_row: u16,
300) -> std::io::Result<usize> {
301  let raw = ((layout.inner_width as f64) * open_progress).round() as u16;
302  let even = (raw / 2) * 2;
303  let current_width = if even == 0 && raw > 0 {
304    2
305  } else {
306    even.min(layout.inner_width)
307  };
308
309  if current_width == 0 {
310    return Ok(0);
311  }
312
313  let col = centered_column(layout.tw, current_width + 4);
314
315  let top = selector_border(current_width, false, true);
316  let bottom = selector_border(current_width, false, false);
317  let empty_inner: String = " ".repeat(current_width as usize + 2);
318
319  queue!(
320    writer,
321    MoveTo(col, start_row),
322    SetForegroundColor(Color::DarkGrey),
323    Print(&top),
324    ResetColor
325  )?;
326
327  for i in 0..layout.max_visible {
328    let row = start_row + BORDER_TOP_OFFSET + i as u16;
329    queue!(
330      writer,
331      MoveTo(col, row),
332      SetForegroundColor(Color::DarkGrey),
333      Print(BORDER_VERTICAL),
334      Print(&empty_inner),
335      Print(BORDER_VERTICAL),
336      ResetColor
337    )?;
338  }
339
340  let bottom_row = start_row + BORDER_TOP_OFFSET + layout.max_visible as u16;
341  queue!(
342    writer,
343    MoveTo(col, bottom_row),
344    SetForegroundColor(Color::DarkGrey),
345    Print(&bottom),
346    ResetColor
347  )?;
348
349  Ok(layout.max_visible + 2)
350}
351
352struct SelectorRowPaint<'a> {
353  layout: &'a SelectorLayout,
354  row: u16,
355  opt_idx: usize,
356  max_label_width: usize,
357  pad_width: usize,
358  arrow_char: char,
359}
360
361#[cfg(not(tarpaulin_include))]
362fn render_selector_items<W: Write, R: Rng>(
363  writer: &mut W,
364  sel: &SelectorComponent,
365  layout: &SelectorLayout,
366  start_row: u16,
367  has_scroll_up: bool,
368  has_scroll_down: bool,
369  rng: &mut R,
370) -> std::io::Result<()> {
371  let max_label_width = sel
372    .options
373    .iter()
374    .map(|o| o.label.chars().count())
375    .max()
376    .unwrap_or(0);
377  let pad_width = (layout.inner_width as usize)
378    .saturating_sub(PREFIX_ACTIVE.chars().count() + max_label_width + 2);
379
380  for i in 0..layout.max_visible {
381    let opt_idx = sel.scroll_offset + i;
382    if opt_idx >= sel.options.len() {
383      break;
384    }
385    render_selector_item(
386      writer,
387      sel,
388      SelectorRowPaint {
389        layout,
390        row: start_row + i as u16,
391        opt_idx,
392        max_label_width,
393        pad_width,
394        arrow_char: scroll_arrow_for(i, layout.max_visible, has_scroll_up, has_scroll_down),
395      },
396      rng,
397    )?;
398  }
399
400  Ok(())
401}
402
403#[cfg(not(tarpaulin_include))]
404fn render_selector_item<W: Write, R: Rng>(
405  writer: &mut W,
406  sel: &SelectorComponent,
407  paint: SelectorRowPaint<'_>,
408  rng: &mut R,
409) -> std::io::Result<()> {
410  let SelectorRowPaint {
411    layout,
412    row,
413    opt_idx,
414    max_label_width,
415    pad_width,
416    arrow_char,
417  } = paint;
418
419  let opt = &sel.options[opt_idx];
420  let is_selected = opt_idx == sel.selected;
421
422  let label = if sel.reveal < 1.0 {
423    let revealed = (opt.label.chars().count() as f64 * sel.reveal).round() as usize;
424    decode::decode_frame(&opt.label, revealed, rng)
425  } else {
426    opt.label.clone()
427  };
428
429  let prefix = if is_selected {
430    PREFIX_ACTIVE
431  } else {
432    PREFIX_INACTIVE
433  };
434
435  let padded_label = format!("{:<width$}", label, width = max_label_width);
436
437  queue!(
438    writer,
439    MoveTo(layout.col, row),
440    SetForegroundColor(Color::DarkGrey),
441    Print(BORDER_VERTICAL),
442    Print(" "),
443  )?;
444
445  if is_selected {
446    queue!(
447      writer,
448      SetForegroundColor(Color::White),
449      SetAttribute(Attribute::Bold),
450      Print(prefix),
451      Print(&padded_label),
452      SetAttribute(Attribute::Reset),
453    )?;
454  } else {
455    queue!(
456      writer,
457      SetForegroundColor(Color::DarkGrey),
458      Print(prefix),
459      Print(&padded_label),
460    )?;
461  }
462
463  let gap: String = " ".repeat(pad_width + 1);
464  queue!(
465    writer,
466    SetForegroundColor(Color::DarkGrey),
467    Print(format!("{gap}{arrow_char}")),
468    Print(format!(" {BORDER_VERTICAL}")),
469    ResetColor
470  )
471}
472
473fn scroll_arrow_for(
474  i: usize,
475  max_visible: usize,
476  has_scroll_up: bool,
477  has_scroll_down: bool,
478) -> char {
479  if i == 0 && has_scroll_up {
480    ARROW_UP
481  } else if i == max_visible - 1 && has_scroll_down {
482    ARROW_DOWN
483  } else {
484    ' '
485  }
486}
487
488#[cfg(test)]
489mod tests {
490  use super::*;
491
492  fn option(label: &str, value: &str) -> SelectOption {
493    SelectOption {
494      label: label.to_string(),
495      value: value.to_string(),
496    }
497  }
498
499  #[test]
500  fn viewport_row_count_matches_render_cap() {
501    assert_eq!(SelectorComponent::viewport_row_count(0), 0);
502    assert_eq!(SelectorComponent::viewport_row_count(5), 5);
503    assert_eq!(SelectorComponent::viewport_row_count(8), 8);
504    assert_eq!(
505      SelectorComponent::viewport_row_count(200),
506      MAX_VISIBLE_OPTIONS
507    );
508  }
509
510  #[test]
511  fn is_fully_thresholds() {
512    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
513    s.open = 1.0;
514    assert!(s.is_fully_open());
515    s.open = 0.999;
516    assert!(!s.is_fully_open());
517    s.open = 0.0;
518    assert!(s.is_fully_closed());
519    s.open = -0.1;
520    assert!(s.is_fully_closed());
521
522    s.reveal = 1.0;
523    assert!(s.is_fully_revealed());
524    s.reveal = 0.999;
525    assert!(!s.is_fully_revealed());
526    s.reveal = 0.0;
527    assert!(s.is_fully_hidden());
528    s.reveal = 0.01;
529    assert!(!s.is_fully_hidden());
530  }
531
532  #[test]
533  fn scroll_arrow_for_branches() {
534    assert_eq!(scroll_arrow_for(0, 4, true, false), ARROW_UP);
535    assert_eq!(scroll_arrow_for(3, 4, false, true), ARROW_DOWN);
536    assert_eq!(scroll_arrow_for(1, 4, true, true), ' ');
537    assert_eq!(scroll_arrow_for(3, 4, true, false), ' ');
538  }
539
540  #[test]
541  fn selector_layout_compute_places_column() {
542    let sel = SelectorComponent::from_options(
543      vec![option("short", "s"), option("much longer label", "m")],
544      0,
545    );
546    let layout = SelectorLayout::compute(&sel, 120);
547    assert_eq!(layout.max_visible, 2);
548    assert_eq!(layout.tw, 120);
549    assert!(layout.inner_width > 0);
550    assert!(layout.col < 120);
551
552    let empty = SelectorComponent::hidden();
553    let layout_empty = SelectorLayout::compute(&empty, 80);
554    assert_eq!(layout_empty.max_visible, 0);
555  }
556
557  #[test]
558  fn selector_inner_width_tracks_longest_label() {
559    let sel = SelectorComponent::from_options(vec![option("ab", "a"), option("abcdef", "b")], 0);
560    let w = selector_inner_width(&sel, 200);
561    assert!(w >= 6);
562
563    let sel_empty = SelectorComponent::hidden();
564    let w0 = selector_inner_width(&sel_empty, 80);
565    assert!(w0 > 0);
566  }
567
568  #[test]
569  fn selector_border_plain_and_overflow() {
570    let plain_top = selector_border(10, false, true);
571    assert!(plain_top.starts_with(CORNER_TOP_LEFT));
572    assert!(plain_top.ends_with(CORNER_TOP_RIGHT));
573
574    let plain_bot = selector_border(10, false, false);
575    assert!(plain_bot.starts_with(CORNER_BOTTOM_LEFT));
576
577    let overflow_top = selector_border(20, true, true);
578    assert!(overflow_top.contains('\u{00b7}'));
579  }
580
581  #[test]
582  fn navigate_down_increments_selection() {
583    let mut sel = SelectorComponent::from_options(
584      vec![option("A", "a"), option("B", "b"), option("C", "c")],
585      0,
586    );
587    sel.navigate(ListNavigate::Down);
588    assert_eq!(sel.selected, 1);
589  }
590
591  #[test]
592  fn navigate_down_wraps_to_first() {
593    let mut sel = SelectorComponent::from_options(vec![option("A", "a"), option("B", "b")], 1);
594    sel.navigate(ListNavigate::Down);
595    assert_eq!(sel.selected, 0);
596  }
597
598  #[test]
599  fn navigate_up_decrements_selection() {
600    let mut sel = SelectorComponent::from_options(
601      vec![option("A", "a"), option("B", "b"), option("C", "c")],
602      2,
603    );
604    sel.navigate(ListNavigate::Up);
605    assert_eq!(sel.selected, 1);
606  }
607
608  #[test]
609  fn navigate_up_wraps_to_last() {
610    let mut sel = SelectorComponent::from_options(
611      vec![option("A", "a"), option("B", "b"), option("C", "c")],
612      0,
613    );
614    sel.navigate(ListNavigate::Up);
615    assert_eq!(sel.selected, 2);
616  }
617
618  #[test]
619  fn navigate_empty_options_is_noop() {
620    let mut sel = SelectorComponent::hidden();
621    sel.navigate(ListNavigate::Down);
622    assert_eq!(sel.selected, 0);
623  }
624}