use std::io::Write;
use crossterm::{
cursor::MoveTo,
queue,
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
};
use rand::Rng;
use crate::box_chrome;
use crate::decode;
use crate::layout::centered_column;
use crate::prompt::text_entry;
use super::SelectorComponent;
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_TOP_OFFSET: u16 = 1;
const MIN_PARTIAL_WIDTH: u16 = 2;
const PREFIX_WIDTH: usize = 2;
const ELLIPSIS_LEN: usize = 5;
pub(super) const MAX_VISIBLE_OPTIONS: usize = 8;
pub(super) struct SelectorLayout {
pub terminal_width: u16,
pub inner_width: u16,
pub col: u16,
pub max_visible: usize,
pub max_label_width: usize,
}
impl SelectorLayout {
pub fn compute(sel: &SelectorComponent, terminal_width: u16) -> Self {
let total = sel.options.len();
let max_visible = total.min(MAX_VISIBLE_OPTIONS);
let max_label_width = sel
.options
.iter()
.map(|o| o.label.chars().count())
.max()
.unwrap_or(0);
let inner_width = selector_inner_width(max_label_width, terminal_width);
let outer_width = inner_width + box_chrome::CHROME_WIDTH;
let col = centered_column(terminal_width, outer_width);
Self {
terminal_width,
inner_width,
col,
max_visible,
max_label_width,
}
}
}
fn selector_inner_width(max_label: usize, terminal_width: u16) -> u16 {
let content_min = (PREFIX_WIDTH + max_label + box_chrome::INNER_PADDING) as u16;
let entry_width = text_entry("", "", terminal_width).inner_width;
content_min.max(entry_width)
}
pub(super) fn selector_border(inner_width: u16, has_overflow: bool, top: bool) -> String {
if !has_overflow {
return if top {
box_chrome::top_border(inner_width)
} else {
box_chrome::bottom_border(inner_width)
};
}
let (left_corner, right_corner) = if top {
(box_chrome::CORNER_TOP_LEFT, box_chrome::CORNER_TOP_RIGHT)
} else {
(
box_chrome::CORNER_BOTTOM_LEFT,
box_chrome::CORNER_BOTTOM_RIGHT,
)
};
let fill_total = inner_width as usize + box_chrome::INNER_PADDING;
let text_col = PREFIX_WIDTH + 1;
let leading_space = ELLIPSIS.chars().take_while(|c| *c == ' ').count();
let prefix_offset = text_col.saturating_sub(leading_space);
let right_fill = fill_total.saturating_sub(prefix_offset + ELLIPSIS_LEN);
format!(
"{}{}{}{}{}",
left_corner,
box_chrome::HORIZONTAL.repeat(prefix_offset),
ELLIPSIS,
box_chrome::HORIZONTAL.repeat(right_fill),
right_corner,
)
}
#[cfg(not(tarpaulin_include))]
pub(super) 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 current_width = box_chrome::snap_even(raw, MIN_PARTIAL_WIDTH).min(layout.inner_width);
if current_width == 0 {
return Ok(0);
}
let col = centered_column(
layout.terminal_width,
current_width + box_chrome::CHROME_WIDTH,
);
queue_border_line(writer, col, start_row, current_width, true)?;
queue_empty_rows(writer, col, start_row, current_width, layout.max_visible)?;
queue_border_line(
writer,
col,
start_row + BORDER_TOP_OFFSET + layout.max_visible as u16,
current_width,
false,
)?;
Ok(layout.max_visible + box_chrome::BORDER_ROWS as usize)
}
#[cfg(not(tarpaulin_include))]
fn queue_border_line<W: Write>(
writer: &mut W,
col: u16,
row: u16,
inner_width: u16,
top: bool,
) -> std::io::Result<()> {
let border = selector_border(inner_width, false, top);
queue!(
writer,
MoveTo(col, row),
SetForegroundColor(Color::DarkGrey),
Print(&border),
ResetColor
)
}
#[cfg(not(tarpaulin_include))]
fn queue_empty_rows<W: Write>(
writer: &mut W,
col: u16,
start_row: u16,
inner_width: u16,
count: usize,
) -> std::io::Result<()> {
let empty_inner: String = " ".repeat(inner_width as usize + box_chrome::INNER_PADDING);
for i in 0..count {
let row = start_row + BORDER_TOP_OFFSET + i as u16;
queue!(
writer,
MoveTo(col, row),
SetForegroundColor(Color::DarkGrey),
Print(box_chrome::VERTICAL),
Print(&empty_inner),
Print(box_chrome::VERTICAL),
ResetColor
)?;
}
Ok(())
}
#[cfg(not(tarpaulin_include))]
pub(super) 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 pad_width = (layout.inner_width as usize)
.saturating_sub(PREFIX_WIDTH + layout.max_label_width + box_chrome::INNER_PADDING);
for i in 0..layout.max_visible {
let opt_idx = sel.scroll_offset + i;
if opt_idx >= sel.options.len() {
break;
}
let arrow = scroll_arrow_for(i, layout.max_visible, has_scroll_up, has_scroll_down);
render_option_row(
writer,
sel,
OptionRowCtx {
layout,
row: start_row + i as u16,
opt_idx,
pad_width,
arrow_char: arrow,
},
rng,
)?;
}
Ok(())
}
struct OptionRowCtx<'a> {
layout: &'a SelectorLayout,
row: u16,
opt_idx: usize,
pad_width: usize,
arrow_char: char,
}
#[cfg(not(tarpaulin_include))]
fn render_option_row<W: Write, R: Rng>(
writer: &mut W,
sel: &SelectorComponent,
ctx: OptionRowCtx<'_>,
rng: &mut R,
) -> std::io::Result<()> {
let opt = &sel.options[ctx.opt_idx];
let is_selected = ctx.opt_idx == sel.selected;
let label = decode_label(&opt.label, sel.reveal, rng);
queue_left_border(writer, ctx.layout.col, ctx.row)?;
queue_option_label(writer, &label, is_selected, ctx.layout.max_label_width)?;
queue_right_border(writer, ctx.pad_width, ctx.arrow_char)
}
fn decode_label<R: Rng>(label: &str, reveal: f64, rng: &mut R) -> String {
if reveal < 1.0 {
let revealed = (label.chars().count() as f64 * reveal).round() as usize;
decode::decode_frame(label, revealed, rng)
} else {
label.to_string()
}
}
#[cfg(not(tarpaulin_include))]
fn queue_left_border<W: Write>(writer: &mut W, col: u16, row: u16) -> std::io::Result<()> {
queue!(
writer,
MoveTo(col, row),
SetForegroundColor(Color::DarkGrey),
Print(box_chrome::VERTICAL),
Print(" "),
)
}
#[cfg(not(tarpaulin_include))]
fn queue_option_label<W: Write>(
writer: &mut W,
label: &str,
is_selected: bool,
max_label_width: usize,
) -> std::io::Result<()> {
let prefix = if is_selected {
PREFIX_ACTIVE
} else {
PREFIX_INACTIVE
};
let padded_label = format!("{:<width$}", label, width = max_label_width);
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),
)
}
}
#[cfg(not(tarpaulin_include))]
fn queue_right_border<W: Write>(
writer: &mut W,
pad_width: usize,
arrow_char: char,
) -> std::io::Result<()> {
let gap: String = " ".repeat(pad_width + 1);
queue!(
writer,
SetForegroundColor(Color::DarkGrey),
Print(format!("{gap}{arrow_char}")),
Print(format!(" {}", box_chrome::VERTICAL)),
ResetColor
)
}
pub(super) 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::*;
use crate::test_helpers::select_option as option;
#[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.terminal_width, 120);
assert!(layout.inner_width > 0);
assert!(layout.col < 120);
assert_eq!(layout.max_label_width, "much longer label".len());
let empty = SelectorComponent::hidden();
let layout_empty = SelectorLayout::compute(&empty, 80);
assert_eq!(layout_empty.max_visible, 0);
assert_eq!(layout_empty.max_label_width, 0);
}
#[test]
fn selector_inner_width_tracks_longest_label() {
let w = selector_inner_width(6, 200);
assert!(w >= 6);
let w0 = selector_inner_width(0, 80);
assert!(w0 > 0);
}
#[test]
fn selector_border_plain_and_overflow() {
let plain_top = selector_border(10, false, true);
assert!(plain_top.starts_with(box_chrome::CORNER_TOP_LEFT));
assert!(plain_top.ends_with(box_chrome::CORNER_TOP_RIGHT));
let plain_bot = selector_border(10, false, false);
assert!(plain_bot.starts_with(box_chrome::CORNER_BOTTOM_LEFT));
let overflow_top = selector_border(20, true, true);
assert!(overflow_top.contains('\u{00b7}'));
let overflow_bot = selector_border(20, true, false);
assert!(overflow_bot.starts_with(box_chrome::CORNER_BOTTOM_LEFT));
assert!(overflow_bot.ends_with(box_chrome::CORNER_BOTTOM_RIGHT));
assert!(overflow_bot.contains('\u{00b7}'));
}
#[test]
fn decode_label_fully_revealed() {
let mut rng = rand::rng();
let result = decode_label("Hello", 1.0, &mut rng);
assert_eq!(result, "Hello");
}
#[test]
fn decode_label_partially_revealed() {
let mut rng = rand::rng();
let result = decode_label("Hello", 0.5, &mut rng);
assert_eq!(result.chars().count(), 5);
assert!(result.starts_with("He") || result.starts_with("Hel"));
}
}