Skip to main content

blizz_ui/components/selector/
component.rs

1use std::io::Write;
2
3use crossterm::{
4  cursor::MoveTo,
5  queue,
6  style::{Color, Print, ResetColor, SetForegroundColor},
7};
8use rand::Rng;
9
10use crate::box_chrome;
11use crate::select_option::SelectOption;
12use crate::{Component, Renderer};
13
14use super::layout::{
15  MAX_VISIBLE_OPTIONS, SelectorLayout, render_partial_border, render_selector_items,
16  selector_border,
17};
18
19/// Don't start chrome animation until the question is at least this far revealed.
20const QUESTION_REVEAL_GATE: f64 = 0.5;
21
22/// Vertical list navigation (↑/↓); map from the host wizard's up/down actions.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum ListNavigate {
25  Up,
26  Down,
27}
28
29/// Select list with scroll, open/reveal animation progress, and bordered layout.
30#[derive(Debug, Clone)]
31pub struct SelectorComponent {
32  pub options: Vec<SelectOption>,
33  pub selected: usize,
34  pub scroll_offset: usize,
35  pub visible: bool,
36  pub open: f64,
37  pub reveal: f64,
38}
39
40impl SelectorComponent {
41  pub fn hidden() -> Self {
42    Self {
43      options: Vec::new(),
44      selected: 0,
45      scroll_offset: 0,
46      visible: false,
47      open: 0.0,
48      reveal: 0.0,
49    }
50  }
51
52  pub fn from_options(options: Vec<SelectOption>, default_index: usize) -> Self {
53    Self {
54      options,
55      selected: default_index,
56      scroll_offset: 0,
57      visible: true,
58      open: 0.0,
59      reveal: 0.0,
60    }
61  }
62
63  pub fn is_fully_open(&self) -> bool {
64    self.open >= 1.0
65  }
66
67  pub fn is_fully_closed(&self) -> bool {
68    self.open <= 0.0
69  }
70
71  pub fn is_fully_revealed(&self) -> bool {
72    self.reveal >= 1.0
73  }
74
75  pub fn is_fully_hidden(&self) -> bool {
76    self.reveal <= 0.0
77  }
78
79  /// How many option rows are visible for a list of this length.
80  pub fn viewport_row_count(option_count: usize) -> usize {
81    option_count.min(MAX_VISIBLE_OPTIONS)
82  }
83
84  pub fn navigate(&mut self, direction: ListNavigate) {
85    let len = self.options.len();
86    if len == 0 {
87      return;
88    }
89
90    match direction {
91      ListNavigate::Up => {
92        self.selected = if self.selected == 0 {
93          len - 1
94        } else {
95          self.selected - 1
96        };
97      }
98      ListNavigate::Down => {
99        self.selected = (self.selected + 1) % len;
100      }
101    }
102
103    let max_visible = Self::viewport_row_count(len);
104    if self.selected < self.scroll_offset {
105      self.scroll_offset = self.selected;
106    } else if self.selected >= self.scroll_offset + max_visible {
107      self.scroll_offset = self.selected + 1 - max_visible;
108    }
109
110    if self.selected == 0 {
111      self.scroll_offset = 0;
112    } else if self.selected == len - 1 {
113      self.scroll_offset = len.saturating_sub(max_visible);
114    }
115  }
116
117  pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
118    if !self.visible || question_reveal < QUESTION_REVEAL_GATE {
119      return;
120    }
121    self.open = (self.open + box_open_speed).min(1.0);
122    if self.open >= 1.0 {
123      self.reveal = (self.reveal + reveal_speed).min(1.0);
124    }
125  }
126
127  pub fn tick_transition_encode(
128    &mut self,
129    next_has_selector: bool,
130    encode_speed: f64,
131    morph_speed: f64,
132  ) {
133    if !self.visible {
134      return;
135    }
136    self.reveal = (self.reveal - encode_speed).max(0.0);
137    if !next_has_selector && self.is_fully_hidden() {
138      self.open = (self.open - morph_speed).max(0.0);
139    }
140  }
141
142  pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
143    if !self.visible {
144      return;
145    }
146    if self.open < 1.0 {
147      self.open = (self.open + morph_speed).min(1.0);
148    } else {
149      self.reveal = (self.reveal + encode_speed).min(1.0);
150    }
151  }
152
153  pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
154    if !self.visible {
155      return;
156    }
157    self.reveal = (self.reveal - encode_speed).max(0.0);
158    if self.reveal <= 0.0 {
159      self.open = (self.open - box_close_speed).max(0.0);
160    }
161  }
162}
163
164#[cfg(not(tarpaulin_include))]
165impl SelectorComponent {
166  fn render_fully_open<W: Write, R: Rng>(
167    &self,
168    writer: &mut W,
169    layout: &SelectorLayout,
170    start_row: u16,
171    rng: &mut R,
172  ) -> std::io::Result<u16> {
173    let has_scroll_up = self.scroll_offset > 0;
174    let has_scroll_down = self.scroll_offset + layout.max_visible < self.options.len();
175    let mut row = start_row;
176
177    let top = selector_border(layout.inner_width, has_scroll_up, true);
178    queue!(
179      writer,
180      MoveTo(layout.col, row),
181      SetForegroundColor(Color::DarkGrey),
182      Print(&top),
183      ResetColor
184    )?;
185    row += 1;
186
187    render_selector_items(
188      writer,
189      self,
190      layout,
191      row,
192      has_scroll_up,
193      has_scroll_down,
194      rng,
195    )?;
196    row += layout.max_visible as u16;
197
198    let bottom = selector_border(layout.inner_width, has_scroll_down, false);
199    queue!(
200      writer,
201      MoveTo(layout.col, row),
202      SetForegroundColor(Color::DarkGrey),
203      Print(&bottom),
204      ResetColor
205    )?;
206
207    Ok((layout.max_visible as u16).saturating_add(box_chrome::BORDER_ROWS))
208  }
209}
210
211#[cfg(not(tarpaulin_include))]
212impl Component for SelectorComponent {
213  fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
214    if self.options.is_empty() {
215      return Ok(0);
216    }
217    renderer.with_panel(|writer, panel, rng| {
218      let layout = SelectorLayout::compute(self, panel.width);
219
220      if self.open < 1.0 {
221        return render_partial_border(writer, self.open, &layout, panel.row).map(|n| n as u16);
222      }
223
224      self.render_fully_open(writer, &layout, panel.row, rng)
225    })
226  }
227}
228
229#[cfg(test)]
230mod tests {
231  use super::*;
232  use crate::test_helpers::select_option as option;
233
234  #[test]
235  fn hidden_is_invisible_with_empty_options() {
236    let s = SelectorComponent::hidden();
237    assert!(!s.visible);
238    assert!(s.options.is_empty());
239    assert_eq!(s.open, 0.0);
240    assert_eq!(s.reveal, 0.0);
241  }
242
243  #[test]
244  fn tick_decode_noop_when_invisible() {
245    let mut s = SelectorComponent::hidden();
246    s.tick_decode(1.0, 0.1, 0.1);
247    assert_eq!(s.open, 0.0);
248  }
249
250  #[test]
251  fn tick_decode_noop_below_question_gate() {
252    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
253    s.tick_decode(0.49, 0.1, 0.1);
254    assert_eq!(s.open, 0.0);
255  }
256
257  #[test]
258  fn tick_decode_opens_then_reveals() {
259    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
260
261    s.tick_decode(0.6, 0.5, 0.3);
262    assert!(s.open > 0.0);
263    assert_eq!(s.reveal, 0.0);
264
265    s.open = 1.0;
266    s.tick_decode(0.6, 0.5, 0.3);
267    assert!(s.reveal > 0.0);
268  }
269
270  #[test]
271  fn tick_decode_clamps_at_one() {
272    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
273    s.open = 1.0;
274    s.tick_decode(1.0, 1.0, 2.0);
275    assert_eq!(s.open, 1.0);
276    assert_eq!(s.reveal, 1.0);
277  }
278
279  #[test]
280  fn tick_transition_encode_noop_when_invisible() {
281    let mut s = SelectorComponent::hidden();
282    s.tick_transition_encode(true, 0.1, 0.1);
283    assert_eq!(s.reveal, 0.0);
284  }
285
286  #[test]
287  fn tick_transition_encode_fades_reveal_then_closes() {
288    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
289    s.open = 1.0;
290    s.reveal = 1.0;
291
292    s.tick_transition_encode(false, 0.5, 0.0);
293    assert!(s.reveal < 1.0);
294
295    s.reveal = 0.0;
296    s.tick_transition_encode(false, 0.5, 0.4);
297    assert!(s.open < 1.0);
298  }
299
300  #[test]
301  fn tick_transition_encode_keeps_box_when_next_has_selector() {
302    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
303    s.open = 1.0;
304    s.reveal = 0.0;
305
306    s.tick_transition_encode(true, 0.5, 0.5);
307    assert_eq!(s.open, 1.0);
308  }
309
310  #[test]
311  fn tick_transition_decode_noop_when_invisible() {
312    let mut s = SelectorComponent::hidden();
313    s.tick_transition_decode(0.1, 0.1);
314    assert_eq!(s.open, 0.0);
315  }
316
317  #[test]
318  fn tick_transition_decode_opens_then_reveals() {
319    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
320    s.open = 0.0;
321
322    s.tick_transition_decode(0.3, 0.5);
323    assert!(s.open > 0.0);
324    assert_eq!(s.reveal, 0.0);
325
326    s.open = 1.0;
327    s.tick_transition_decode(0.3, 0.5);
328    assert!(s.reveal > 0.0);
329  }
330
331  #[test]
332  fn tick_exit_close_noop_when_invisible() {
333    let mut s = SelectorComponent::hidden();
334    s.tick_exit_close(0.1, 0.1);
335    assert_eq!(s.open, 0.0);
336  }
337
338  #[test]
339  fn tick_exit_close_fades_reveal_then_closes_box() {
340    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
341    s.open = 1.0;
342    s.reveal = 1.0;
343
344    s.tick_exit_close(0.5, 0.5);
345    assert!(s.reveal < 1.0);
346    assert_eq!(s.open, 1.0);
347
348    s.reveal = 0.0;
349    s.tick_exit_close(0.5, 0.5);
350    assert!(s.open < 1.0);
351  }
352
353  #[test]
354  fn viewport_row_count_matches_render_cap() {
355    assert_eq!(SelectorComponent::viewport_row_count(0), 0);
356    assert_eq!(SelectorComponent::viewport_row_count(5), 5);
357    assert_eq!(SelectorComponent::viewport_row_count(8), 8);
358    assert_eq!(
359      SelectorComponent::viewport_row_count(200),
360      MAX_VISIBLE_OPTIONS
361    );
362  }
363
364  #[test]
365  fn is_fully_thresholds() {
366    let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
367    s.open = 1.0;
368    assert!(s.is_fully_open());
369    s.open = 0.999;
370    assert!(!s.is_fully_open());
371    s.open = 0.0;
372    assert!(s.is_fully_closed());
373    s.open = -0.1;
374    assert!(s.is_fully_closed());
375
376    s.reveal = 1.0;
377    assert!(s.is_fully_revealed());
378    s.reveal = 0.999;
379    assert!(!s.is_fully_revealed());
380    s.reveal = 0.0;
381    assert!(s.is_fully_hidden());
382    s.reveal = 0.01;
383    assert!(!s.is_fully_hidden());
384  }
385
386  #[test]
387  fn navigate_down_increments_selection() {
388    let mut sel = SelectorComponent::from_options(
389      vec![option("A", "a"), option("B", "b"), option("C", "c")],
390      0,
391    );
392    sel.navigate(ListNavigate::Down);
393    assert_eq!(sel.selected, 1);
394  }
395
396  #[test]
397  fn navigate_down_wraps_to_first() {
398    let mut sel = SelectorComponent::from_options(vec![option("A", "a"), option("B", "b")], 1);
399    sel.navigate(ListNavigate::Down);
400    assert_eq!(sel.selected, 0);
401  }
402
403  #[test]
404  fn navigate_up_decrements_selection() {
405    let mut sel = SelectorComponent::from_options(
406      vec![option("A", "a"), option("B", "b"), option("C", "c")],
407      2,
408    );
409    sel.navigate(ListNavigate::Up);
410    assert_eq!(sel.selected, 1);
411  }
412
413  #[test]
414  fn navigate_up_wraps_to_last() {
415    let mut sel = SelectorComponent::from_options(
416      vec![option("A", "a"), option("B", "b"), option("C", "c")],
417      0,
418    );
419    sel.navigate(ListNavigate::Up);
420    assert_eq!(sel.selected, 2);
421  }
422
423  #[test]
424  fn navigate_empty_options_is_noop() {
425    let mut sel = SelectorComponent::hidden();
426    sel.navigate(ListNavigate::Down);
427    assert_eq!(sel.selected, 0);
428  }
429}