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/// Select list with scroll, open/reveal animation progress, and bordered layout.
31#[derive(Debug, Clone)]
32pub struct SelectorComponent {
33  pub options: Vec<SelectOption>,
34  pub selected: usize,
35  pub scroll_offset: usize,
36  pub visible: bool,
37  pub open: f64,
38  pub reveal: f64,
39}
40
41impl SelectorComponent {
42  pub fn hidden() -> Self {
43    Self {
44      options: Vec::new(),
45      selected: 0,
46      scroll_offset: 0,
47      visible: false,
48      open: 0.0,
49      reveal: 0.0,
50    }
51  }
52
53  pub fn from_options(options: Vec<SelectOption>, default_index: usize) -> Self {
54    Self {
55      options,
56      selected: default_index,
57      scroll_offset: 0,
58      visible: true,
59      open: 0.0,
60      reveal: 0.0,
61    }
62  }
63
64  pub fn is_fully_open(&self) -> bool {
65    self.open >= 1.0
66  }
67
68  pub fn is_fully_closed(&self) -> bool {
69    self.open <= 0.0
70  }
71
72  pub fn is_fully_revealed(&self) -> bool {
73    self.reveal >= 1.0
74  }
75
76  pub fn is_fully_hidden(&self) -> bool {
77    self.reveal <= 0.0
78  }
79
80  /// How many option rows are visible for a list of this length (same cap as [`SelectorComponent::render`]).
81  pub fn viewport_row_count(option_count: usize) -> usize {
82    option_count.min(MAX_VISIBLE_OPTIONS)
83  }
84
85  #[cfg(not(tarpaulin_include))]
86  pub fn render<W: Write, R: Rng>(
87    &self,
88    writer: &mut W,
89    tw: u16,
90    start_row: u16,
91    rng: &mut R,
92  ) -> std::io::Result<usize> {
93    if self.options.is_empty() {
94      return Ok(0);
95    }
96
97    let layout = SelectorLayout::compute(self, tw);
98
99    if self.open < 1.0 {
100      return render_partial_border(writer, self.open, &layout, start_row);
101    }
102
103    let scroll_offset = self.scroll_offset;
104    let has_scroll_up = scroll_offset > 0;
105    let has_scroll_down = scroll_offset + layout.max_visible < self.options.len();
106
107    let mut row = start_row;
108
109    let top = selector_border(layout.inner_width, has_scroll_up, true);
110    queue!(
111      writer,
112      MoveTo(layout.col, row),
113      SetForegroundColor(Color::DarkGrey),
114      Print(&top),
115      ResetColor
116    )?;
117    row += 1;
118
119    render_selector_items(
120      writer,
121      self,
122      &layout,
123      row,
124      has_scroll_up,
125      has_scroll_down,
126      rng,
127    )?;
128    row += layout.max_visible as u16;
129
130    let bottom = selector_border(layout.inner_width, has_scroll_down, false);
131    queue!(
132      writer,
133      MoveTo(layout.col, row),
134      SetForegroundColor(Color::DarkGrey),
135      Print(&bottom),
136      ResetColor
137    )?;
138
139    Ok(layout.max_visible + 2)
140  }
141}
142
143struct SelectorLayout {
144  tw: u16,
145  inner_width: u16,
146  col: u16,
147  max_visible: usize,
148}
149
150impl SelectorLayout {
151  fn compute(sel: &SelectorComponent, tw: u16) -> Self {
152    let total = sel.options.len();
153    let max_visible = total.min(MAX_VISIBLE_OPTIONS);
154    let inner_width = selector_inner_width(sel, tw);
155    let outer_width = inner_width + 4;
156    let col = centered_column(tw, outer_width);
157    Self {
158      tw,
159      inner_width,
160      col,
161      max_visible,
162    }
163  }
164}
165
166fn selector_inner_width(sel: &SelectorComponent, tw: u16) -> u16 {
167  let max_label = sel
168    .options
169    .iter()
170    .map(|o| o.label.chars().count())
171    .max()
172    .unwrap_or(0);
173  let prefix_width = PREFIX_ACTIVE.chars().count();
174  let content_min = (prefix_width + max_label + 2) as u16;
175  let entry_width = text_entry("", "", tw).inner_width;
176  content_min.max(entry_width)
177}
178
179fn selector_border(inner_width: u16, has_overflow: bool, top: bool) -> String {
180  let fill_total = inner_width as usize + 2;
181  let (left_corner, right_corner) = if top {
182    (CORNER_TOP_LEFT, CORNER_TOP_RIGHT)
183  } else {
184    (CORNER_BOTTOM_LEFT, CORNER_BOTTOM_RIGHT)
185  };
186
187  if !has_overflow {
188    let fill: String = BORDER_HORIZONTAL.repeat(fill_total);
189    return format!("{left_corner}{fill}{right_corner}");
190  }
191
192  let text_col = PREFIX_ACTIVE.chars().count() + 1;
193  let leading_space = ELLIPSIS.chars().take_while(|c| *c == ' ').count();
194  let prefix_offset = text_col.saturating_sub(leading_space);
195  let ellipsis_len = ELLIPSIS.chars().count();
196  let right_fill = fill_total.saturating_sub(prefix_offset + ellipsis_len);
197  format!(
198    "{}{}{}{}{}",
199    left_corner,
200    BORDER_HORIZONTAL.repeat(prefix_offset),
201    ELLIPSIS,
202    BORDER_HORIZONTAL.repeat(right_fill),
203    right_corner,
204  )
205}
206
207#[cfg(not(tarpaulin_include))]
208fn render_partial_border<W: Write>(
209  writer: &mut W,
210  open_progress: f64,
211  layout: &SelectorLayout,
212  start_row: u16,
213) -> std::io::Result<usize> {
214  let raw = ((layout.inner_width as f64) * open_progress).round() as u16;
215  let even = (raw / 2) * 2;
216  let current_width = if even == 0 && raw > 0 {
217    2
218  } else {
219    even.min(layout.inner_width)
220  };
221
222  if current_width == 0 {
223    return Ok(0);
224  }
225
226  let col = centered_column(layout.tw, current_width + 4);
227
228  let top = selector_border(current_width, false, true);
229  let bottom = selector_border(current_width, false, false);
230  let empty_inner: String = " ".repeat(current_width as usize + 2);
231
232  queue!(
233    writer,
234    MoveTo(col, start_row),
235    SetForegroundColor(Color::DarkGrey),
236    Print(&top),
237    ResetColor
238  )?;
239
240  for i in 0..layout.max_visible {
241    let row = start_row + BORDER_TOP_OFFSET + i as u16;
242    queue!(
243      writer,
244      MoveTo(col, row),
245      SetForegroundColor(Color::DarkGrey),
246      Print(BORDER_VERTICAL),
247      Print(&empty_inner),
248      Print(BORDER_VERTICAL),
249      ResetColor
250    )?;
251  }
252
253  let bottom_row = start_row + BORDER_TOP_OFFSET + layout.max_visible as u16;
254  queue!(
255    writer,
256    MoveTo(col, bottom_row),
257    SetForegroundColor(Color::DarkGrey),
258    Print(&bottom),
259    ResetColor
260  )?;
261
262  Ok(layout.max_visible + 2)
263}
264
265struct SelectorRowPaint<'a> {
266  layout: &'a SelectorLayout,
267  row: u16,
268  opt_idx: usize,
269  max_label_width: usize,
270  pad_width: usize,
271  arrow_char: char,
272}
273
274#[cfg(not(tarpaulin_include))]
275fn render_selector_items<W: Write, R: Rng>(
276  writer: &mut W,
277  sel: &SelectorComponent,
278  layout: &SelectorLayout,
279  start_row: u16,
280  has_scroll_up: bool,
281  has_scroll_down: bool,
282  rng: &mut R,
283) -> std::io::Result<()> {
284  let max_label_width = sel
285    .options
286    .iter()
287    .map(|o| o.label.chars().count())
288    .max()
289    .unwrap_or(0);
290  let pad_width = (layout.inner_width as usize)
291    .saturating_sub(PREFIX_ACTIVE.chars().count() + max_label_width + 2);
292
293  for i in 0..layout.max_visible {
294    let opt_idx = sel.scroll_offset + i;
295    if opt_idx >= sel.options.len() {
296      break;
297    }
298    render_selector_item(
299      writer,
300      sel,
301      SelectorRowPaint {
302        layout,
303        row: start_row + i as u16,
304        opt_idx,
305        max_label_width,
306        pad_width,
307        arrow_char: scroll_arrow_for(i, layout.max_visible, has_scroll_up, has_scroll_down),
308      },
309      rng,
310    )?;
311  }
312
313  Ok(())
314}
315
316#[cfg(not(tarpaulin_include))]
317fn render_selector_item<W: Write, R: Rng>(
318  writer: &mut W,
319  sel: &SelectorComponent,
320  paint: SelectorRowPaint<'_>,
321  rng: &mut R,
322) -> std::io::Result<()> {
323  let SelectorRowPaint {
324    layout,
325    row,
326    opt_idx,
327    max_label_width,
328    pad_width,
329    arrow_char,
330  } = paint;
331
332  let opt = &sel.options[opt_idx];
333  let is_selected = opt_idx == sel.selected;
334
335  let label = if sel.reveal < 1.0 {
336    let revealed = (opt.label.chars().count() as f64 * sel.reveal).round() as usize;
337    decode::decode_frame(&opt.label, revealed, rng)
338  } else {
339    opt.label.clone()
340  };
341
342  let prefix = if is_selected {
343    PREFIX_ACTIVE
344  } else {
345    PREFIX_INACTIVE
346  };
347
348  let padded_label = format!("{:<width$}", label, width = max_label_width);
349
350  queue!(
351    writer,
352    MoveTo(layout.col, row),
353    SetForegroundColor(Color::DarkGrey),
354    Print(BORDER_VERTICAL),
355    Print(" "),
356  )?;
357
358  if is_selected {
359    queue!(
360      writer,
361      SetForegroundColor(Color::White),
362      SetAttribute(Attribute::Bold),
363      Print(prefix),
364      Print(&padded_label),
365      SetAttribute(Attribute::Reset),
366    )?;
367  } else {
368    queue!(
369      writer,
370      SetForegroundColor(Color::DarkGrey),
371      Print(prefix),
372      Print(&padded_label),
373    )?;
374  }
375
376  let gap: String = " ".repeat(pad_width + 1);
377  queue!(
378    writer,
379    SetForegroundColor(Color::DarkGrey),
380    Print(format!("{gap}{arrow_char}")),
381    Print(format!(" {BORDER_VERTICAL}")),
382    ResetColor
383  )
384}
385
386fn scroll_arrow_for(
387  i: usize,
388  max_visible: usize,
389  has_scroll_up: bool,
390  has_scroll_down: bool,
391) -> char {
392  if i == 0 && has_scroll_up {
393    ARROW_UP
394  } else if i == max_visible - 1 && has_scroll_down {
395    ARROW_DOWN
396  } else {
397    ' '
398  }
399}
400
401#[cfg(test)]
402mod tests {
403  use super::*;
404
405  fn option(label: &str, value: &str) -> SelectOption {
406    SelectOption {
407      label: label.to_string(),
408      value: value.to_string(),
409    }
410  }
411
412  #[test]
413  fn viewport_row_count_matches_render_cap() {
414    assert_eq!(SelectorComponent::viewport_row_count(0), 0);
415    assert_eq!(SelectorComponent::viewport_row_count(5), 5);
416    assert_eq!(SelectorComponent::viewport_row_count(8), 8);
417    assert_eq!(
418      SelectorComponent::viewport_row_count(200),
419      MAX_VISIBLE_OPTIONS
420    );
421  }
422
423  #[test]
424  fn is_fully_thresholds() {
425    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
426    s.open = 1.0;
427    assert!(s.is_fully_open());
428    s.open = 0.999;
429    assert!(!s.is_fully_open());
430    s.open = 0.0;
431    assert!(s.is_fully_closed());
432    s.open = -0.1;
433    assert!(s.is_fully_closed());
434
435    s.reveal = 1.0;
436    assert!(s.is_fully_revealed());
437    s.reveal = 0.999;
438    assert!(!s.is_fully_revealed());
439    s.reveal = 0.0;
440    assert!(s.is_fully_hidden());
441    s.reveal = 0.01;
442    assert!(!s.is_fully_hidden());
443  }
444
445  #[test]
446  fn scroll_arrow_for_branches() {
447    assert_eq!(scroll_arrow_for(0, 4, true, false), ARROW_UP);
448    assert_eq!(scroll_arrow_for(3, 4, false, true), ARROW_DOWN);
449    assert_eq!(scroll_arrow_for(1, 4, true, true), ' ');
450    assert_eq!(scroll_arrow_for(3, 4, true, false), ' ');
451  }
452
453  #[test]
454  fn selector_layout_compute_places_column() {
455    let sel = SelectorComponent::from_options(
456      vec![option("short", "s"), option("much longer label", "m")],
457      0,
458    );
459    let layout = SelectorLayout::compute(&sel, 120);
460    assert_eq!(layout.max_visible, 2);
461    assert_eq!(layout.tw, 120);
462    assert!(layout.inner_width > 0);
463    assert!(layout.col < 120);
464
465    let empty = SelectorComponent::hidden();
466    let layout_empty = SelectorLayout::compute(&empty, 80);
467    assert_eq!(layout_empty.max_visible, 0);
468  }
469
470  #[test]
471  fn selector_inner_width_tracks_longest_label() {
472    let sel = SelectorComponent::from_options(vec![option("ab", "a"), option("abcdef", "b")], 0);
473    let w = selector_inner_width(&sel, 200);
474    assert!(w >= 6);
475
476    let sel_empty = SelectorComponent::hidden();
477    let w0 = selector_inner_width(&sel_empty, 80);
478    assert!(w0 > 0);
479  }
480
481  #[test]
482  fn selector_border_plain_and_overflow() {
483    let plain_top = selector_border(10, false, true);
484    assert!(plain_top.starts_with(CORNER_TOP_LEFT));
485    assert!(plain_top.ends_with(CORNER_TOP_RIGHT));
486
487    let plain_bot = selector_border(10, false, false);
488    assert!(plain_bot.starts_with(CORNER_BOTTOM_LEFT));
489
490    let overflow_top = selector_border(20, true, true);
491    assert!(overflow_top.contains('\u{00b7}'));
492  }
493}