use std::io::Write;
use crossterm::{
cursor::MoveTo,
queue,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
};
use rand::Rng;
use crate::decode;
use crate::layout::centered_column;
use crate::prompt::text_entry;
use crate::select_option::SelectOption;
const PREFIX_ACTIVE: &str = "\u{25b8} ";
const PREFIX_INACTIVE: &str = " ";
const ARROW_UP: char = '\u{25b2}';
const ARROW_DOWN: char = '\u{25bc}';
const ELLIPSIS: &str = " \u{00b7}\u{00b7}\u{00b7} ";
const BORDER_VERTICAL: &str = "\u{2502}";
const BORDER_HORIZONTAL: &str = "\u{2500}";
const CORNER_TOP_LEFT: &str = "\u{256d}";
const CORNER_TOP_RIGHT: &str = "\u{256e}";
const CORNER_BOTTOM_LEFT: &str = "\u{2570}";
const CORNER_BOTTOM_RIGHT: &str = "\u{256f}";
const BORDER_TOP_OFFSET: u16 = 1;
const MAX_VISIBLE_OPTIONS: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ListNavigate {
Up,
Down,
}
#[derive(Debug, Clone)]
pub struct SelectorComponent {
pub options: Vec<SelectOption>,
pub selected: usize,
pub scroll_offset: usize,
pub visible: bool,
pub open: f64,
pub reveal: f64,
}
impl SelectorComponent {
pub fn hidden() -> Self {
Self {
options: Vec::new(),
selected: 0,
scroll_offset: 0,
visible: false,
open: 0.0,
reveal: 0.0,
}
}
pub fn from_options(options: Vec<SelectOption>, default_index: usize) -> Self {
Self {
options,
selected: default_index,
scroll_offset: 0,
visible: true,
open: 0.0,
reveal: 0.0,
}
}
pub fn is_fully_open(&self) -> bool {
self.open >= 1.0
}
pub fn is_fully_closed(&self) -> bool {
self.open <= 0.0
}
pub fn is_fully_revealed(&self) -> bool {
self.reveal >= 1.0
}
pub fn is_fully_hidden(&self) -> bool {
self.reveal <= 0.0
}
pub fn viewport_row_count(option_count: usize) -> usize {
option_count.min(MAX_VISIBLE_OPTIONS)
}
pub fn navigate(&mut self, direction: ListNavigate) {
let len = self.options.len();
if len == 0 {
return;
}
match direction {
ListNavigate::Up => {
self.selected = if self.selected == 0 {
len - 1
} else {
self.selected - 1
};
}
ListNavigate::Down => {
self.selected = (self.selected + 1) % len;
}
}
let max_visible = Self::viewport_row_count(len);
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + max_visible {
self.scroll_offset = self.selected + 1 - max_visible;
}
if self.selected == 0 {
self.scroll_offset = 0;
} else if self.selected == len - 1 {
self.scroll_offset = len.saturating_sub(max_visible);
}
}
pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
if !self.visible || question_reveal < 0.5 {
return;
}
self.open = (self.open + box_open_speed).min(1.0);
if self.open >= 1.0 {
self.reveal = (self.reveal + reveal_speed).min(1.0);
}
}
pub fn tick_transition_encode(
&mut self,
next_has_selector: bool,
encode_speed: f64,
morph_speed: f64,
) {
if !self.visible {
return;
}
self.reveal = (self.reveal - encode_speed).max(0.0);
if !next_has_selector && self.is_fully_hidden() {
self.open = (self.open - morph_speed).max(0.0);
}
}
pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
if !self.visible {
return;
}
if self.open < 1.0 {
self.open = (self.open + morph_speed).min(1.0);
} else {
self.reveal = (self.reveal + encode_speed).min(1.0);
}
}
pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
if !self.visible {
return;
}
self.reveal = (self.reveal - encode_speed).max(0.0);
if self.reveal <= 0.0 {
self.open = (self.open - box_close_speed).max(0.0);
}
}
#[cfg(not(tarpaulin_include))]
pub fn render<W: Write, R: Rng>(
&self,
writer: &mut W,
tw: u16,
start_row: u16,
rng: &mut R,
) -> std::io::Result<usize> {
if self.options.is_empty() {
return Ok(0);
}
let layout = SelectorLayout::compute(self, tw);
if self.open < 1.0 {
return render_partial_border(writer, self.open, &layout, start_row);
}
let scroll_offset = self.scroll_offset;
let has_scroll_up = scroll_offset > 0;
let has_scroll_down = scroll_offset + layout.max_visible < self.options.len();
let mut row = start_row;
let top = selector_border(layout.inner_width, has_scroll_up, true);
queue!(
writer,
MoveTo(layout.col, row),
SetForegroundColor(Color::DarkGrey),
Print(&top),
ResetColor
)?;
row += 1;
render_selector_items(
writer,
self,
&layout,
row,
has_scroll_up,
has_scroll_down,
rng,
)?;
row += layout.max_visible as u16;
let bottom = selector_border(layout.inner_width, has_scroll_down, false);
queue!(
writer,
MoveTo(layout.col, row),
SetForegroundColor(Color::DarkGrey),
Print(&bottom),
ResetColor
)?;
Ok(layout.max_visible + 2)
}
}
struct SelectorLayout {
tw: u16,
inner_width: u16,
col: u16,
max_visible: usize,
}
impl SelectorLayout {
fn compute(sel: &SelectorComponent, tw: u16) -> Self {
let total = sel.options.len();
let max_visible = total.min(MAX_VISIBLE_OPTIONS);
let inner_width = selector_inner_width(sel, tw);
let outer_width = inner_width + 4;
let col = centered_column(tw, outer_width);
Self {
tw,
inner_width,
col,
max_visible,
}
}
}
fn selector_inner_width(sel: &SelectorComponent, tw: u16) -> u16 {
let max_label = sel
.options
.iter()
.map(|o| o.label.chars().count())
.max()
.unwrap_or(0);
let prefix_width = PREFIX_ACTIVE.chars().count();
let content_min = (prefix_width + max_label + 2) as u16;
let entry_width = text_entry("", "", tw).inner_width;
content_min.max(entry_width)
}
fn selector_border(inner_width: u16, has_overflow: bool, top: bool) -> String {
let fill_total = inner_width as usize + 2;
let (left_corner, right_corner) = if top {
(CORNER_TOP_LEFT, CORNER_TOP_RIGHT)
} else {
(CORNER_BOTTOM_LEFT, CORNER_BOTTOM_RIGHT)
};
if !has_overflow {
let fill: String = BORDER_HORIZONTAL.repeat(fill_total);
return format!("{left_corner}{fill}{right_corner}");
}
let text_col = PREFIX_ACTIVE.chars().count() + 1;
let leading_space = ELLIPSIS.chars().take_while(|c| *c == ' ').count();
let prefix_offset = text_col.saturating_sub(leading_space);
let ellipsis_len = ELLIPSIS.chars().count();
let right_fill = fill_total.saturating_sub(prefix_offset + ellipsis_len);
format!(
"{}{}{}{}{}",
left_corner,
BORDER_HORIZONTAL.repeat(prefix_offset),
ELLIPSIS,
BORDER_HORIZONTAL.repeat(right_fill),
right_corner,
)
}
#[cfg(not(tarpaulin_include))]
fn render_partial_border<W: Write>(
writer: &mut W,
open_progress: f64,
layout: &SelectorLayout,
start_row: u16,
) -> std::io::Result<usize> {
let raw = ((layout.inner_width as f64) * open_progress).round() as u16;
let even = (raw / 2) * 2;
let current_width = if even == 0 && raw > 0 {
2
} else {
even.min(layout.inner_width)
};
if current_width == 0 {
return Ok(0);
}
let col = centered_column(layout.tw, current_width + 4);
let top = selector_border(current_width, false, true);
let bottom = selector_border(current_width, false, false);
let empty_inner: String = " ".repeat(current_width as usize + 2);
queue!(
writer,
MoveTo(col, start_row),
SetForegroundColor(Color::DarkGrey),
Print(&top),
ResetColor
)?;
for i in 0..layout.max_visible {
let row = start_row + BORDER_TOP_OFFSET + i as u16;
queue!(
writer,
MoveTo(col, row),
SetForegroundColor(Color::DarkGrey),
Print(BORDER_VERTICAL),
Print(&empty_inner),
Print(BORDER_VERTICAL),
ResetColor
)?;
}
let bottom_row = start_row + BORDER_TOP_OFFSET + layout.max_visible as u16;
queue!(
writer,
MoveTo(col, bottom_row),
SetForegroundColor(Color::DarkGrey),
Print(&bottom),
ResetColor
)?;
Ok(layout.max_visible + 2)
}
struct SelectorRowPaint<'a> {
layout: &'a SelectorLayout,
row: u16,
opt_idx: usize,
max_label_width: usize,
pad_width: usize,
arrow_char: char,
}
#[cfg(not(tarpaulin_include))]
fn render_selector_items<W: Write, R: Rng>(
writer: &mut W,
sel: &SelectorComponent,
layout: &SelectorLayout,
start_row: u16,
has_scroll_up: bool,
has_scroll_down: bool,
rng: &mut R,
) -> std::io::Result<()> {
let max_label_width = sel
.options
.iter()
.map(|o| o.label.chars().count())
.max()
.unwrap_or(0);
let pad_width = (layout.inner_width as usize)
.saturating_sub(PREFIX_ACTIVE.chars().count() + max_label_width + 2);
for i in 0..layout.max_visible {
let opt_idx = sel.scroll_offset + i;
if opt_idx >= sel.options.len() {
break;
}
render_selector_item(
writer,
sel,
SelectorRowPaint {
layout,
row: start_row + i as u16,
opt_idx,
max_label_width,
pad_width,
arrow_char: scroll_arrow_for(i, layout.max_visible, has_scroll_up, has_scroll_down),
},
rng,
)?;
}
Ok(())
}
#[cfg(not(tarpaulin_include))]
fn render_selector_item<W: Write, R: Rng>(
writer: &mut W,
sel: &SelectorComponent,
paint: SelectorRowPaint<'_>,
rng: &mut R,
) -> std::io::Result<()> {
let SelectorRowPaint {
layout,
row,
opt_idx,
max_label_width,
pad_width,
arrow_char,
} = paint;
let opt = &sel.options[opt_idx];
let is_selected = opt_idx == sel.selected;
let label = if sel.reveal < 1.0 {
let revealed = (opt.label.chars().count() as f64 * sel.reveal).round() as usize;
decode::decode_frame(&opt.label, revealed, rng)
} else {
opt.label.clone()
};
let prefix = if is_selected {
PREFIX_ACTIVE
} else {
PREFIX_INACTIVE
};
let padded_label = format!("{:<width$}", label, width = max_label_width);
queue!(
writer,
MoveTo(layout.col, row),
SetForegroundColor(Color::DarkGrey),
Print(BORDER_VERTICAL),
Print(" "),
)?;
if is_selected {
queue!(
writer,
SetForegroundColor(Color::White),
SetAttribute(Attribute::Bold),
Print(prefix),
Print(&padded_label),
SetAttribute(Attribute::Reset),
)?;
} else {
queue!(
writer,
SetForegroundColor(Color::DarkGrey),
Print(prefix),
Print(&padded_label),
)?;
}
let gap: String = " ".repeat(pad_width + 1);
queue!(
writer,
SetForegroundColor(Color::DarkGrey),
Print(format!("{gap}{arrow_char}")),
Print(format!(" {BORDER_VERTICAL}")),
ResetColor
)
}
fn scroll_arrow_for(
i: usize,
max_visible: usize,
has_scroll_up: bool,
has_scroll_down: bool,
) -> char {
if i == 0 && has_scroll_up {
ARROW_UP
} else if i == max_visible - 1 && has_scroll_down {
ARROW_DOWN
} else {
' '
}
}
#[cfg(test)]
mod tests {
use super::*;
fn option(label: &str, value: &str) -> SelectOption {
SelectOption {
label: label.to_string(),
value: value.to_string(),
}
}
#[test]
fn viewport_row_count_matches_render_cap() {
assert_eq!(SelectorComponent::viewport_row_count(0), 0);
assert_eq!(SelectorComponent::viewport_row_count(5), 5);
assert_eq!(SelectorComponent::viewport_row_count(8), 8);
assert_eq!(
SelectorComponent::viewport_row_count(200),
MAX_VISIBLE_OPTIONS
);
}
#[test]
fn is_fully_thresholds() {
let mut s = SelectorComponent::from_options(vec![option("a", "a")], 0);
s.open = 1.0;
assert!(s.is_fully_open());
s.open = 0.999;
assert!(!s.is_fully_open());
s.open = 0.0;
assert!(s.is_fully_closed());
s.open = -0.1;
assert!(s.is_fully_closed());
s.reveal = 1.0;
assert!(s.is_fully_revealed());
s.reveal = 0.999;
assert!(!s.is_fully_revealed());
s.reveal = 0.0;
assert!(s.is_fully_hidden());
s.reveal = 0.01;
assert!(!s.is_fully_hidden());
}
#[test]
fn scroll_arrow_for_branches() {
assert_eq!(scroll_arrow_for(0, 4, true, false), ARROW_UP);
assert_eq!(scroll_arrow_for(3, 4, false, true), ARROW_DOWN);
assert_eq!(scroll_arrow_for(1, 4, true, true), ' ');
assert_eq!(scroll_arrow_for(3, 4, true, false), ' ');
}
#[test]
fn selector_layout_compute_places_column() {
let sel = SelectorComponent::from_options(
vec![option("short", "s"), option("much longer label", "m")],
0,
);
let layout = SelectorLayout::compute(&sel, 120);
assert_eq!(layout.max_visible, 2);
assert_eq!(layout.tw, 120);
assert!(layout.inner_width > 0);
assert!(layout.col < 120);
let empty = SelectorComponent::hidden();
let layout_empty = SelectorLayout::compute(&empty, 80);
assert_eq!(layout_empty.max_visible, 0);
}
#[test]
fn selector_inner_width_tracks_longest_label() {
let sel = SelectorComponent::from_options(vec![option("ab", "a"), option("abcdef", "b")], 0);
let w = selector_inner_width(&sel, 200);
assert!(w >= 6);
let sel_empty = SelectorComponent::hidden();
let w0 = selector_inner_width(&sel_empty, 80);
assert!(w0 > 0);
}
#[test]
fn selector_border_plain_and_overflow() {
let plain_top = selector_border(10, false, true);
assert!(plain_top.starts_with(CORNER_TOP_LEFT));
assert!(plain_top.ends_with(CORNER_TOP_RIGHT));
let plain_bot = selector_border(10, false, false);
assert!(plain_bot.starts_with(CORNER_BOTTOM_LEFT));
let overflow_top = selector_border(20, true, true);
assert!(overflow_top.contains('\u{00b7}'));
}
#[test]
fn navigate_down_increments_selection() {
let mut sel = SelectorComponent::from_options(
vec![option("A", "a"), option("B", "b"), option("C", "c")],
0,
);
sel.navigate(ListNavigate::Down);
assert_eq!(sel.selected, 1);
}
#[test]
fn navigate_down_wraps_to_first() {
let mut sel = SelectorComponent::from_options(vec![option("A", "a"), option("B", "b")], 1);
sel.navigate(ListNavigate::Down);
assert_eq!(sel.selected, 0);
}
#[test]
fn navigate_up_decrements_selection() {
let mut sel = SelectorComponent::from_options(
vec![option("A", "a"), option("B", "b"), option("C", "c")],
2,
);
sel.navigate(ListNavigate::Up);
assert_eq!(sel.selected, 1);
}
#[test]
fn navigate_up_wraps_to_last() {
let mut sel = SelectorComponent::from_options(
vec![option("A", "a"), option("B", "b"), option("C", "c")],
0,
);
sel.navigate(ListNavigate::Up);
assert_eq!(sel.selected, 2);
}
#[test]
fn navigate_empty_options_is_noop() {
let mut sel = SelectorComponent::hidden();
sel.navigate(ListNavigate::Down);
assert_eq!(sel.selected, 0);
}
}