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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum ListNavigate {
33 Up,
34 Down,
35}
36
37#[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 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 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}