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(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 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}