use anyhow::Result;
use anyhow::bail;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use crate::utils::terminal::get_terminal_size;
use crate::utils::terminal::read_terminal_key_press;
use super::Logger;
use super::LoggerRefreshItemKind;
use super::LoggerTextItem;
struct MultiSelectData<'a> {
prompt: &'a str,
item_hanging_indent: u16,
items: Vec<(bool, &'a String)>,
filter: String,
active_index: usize,
scroll_offset: usize,
}
pub fn show_multi_select(logger: &Logger, context_name: &str, prompt: &str, item_hanging_indent: u16, items: Vec<(bool, &String)>) -> Result<Vec<usize>> {
let mut data = MultiSelectData {
prompt,
items,
item_hanging_indent,
filter: String::new(),
active_index: 0,
scroll_offset: 0,
};
loop {
let visible = visible_indexes(&data);
if data.active_index >= visible.len() {
data.active_index = visible.len().saturating_sub(1);
}
let max_visible_rows = max_visible_rows(&data);
update_scroll_offset(&mut data, visible.len(), max_visible_rows);
let text_items = render_multi_select(&data, &visible, max_visible_rows);
logger.set_refresh_item(LoggerRefreshItemKind::Selection, text_items);
if let Event::Key(key_event) = read_terminal_key_press()? {
if key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c')) {
logger.remove_refresh_item(LoggerRefreshItemKind::Selection);
bail!("Selection cancelled.");
}
match &key_event.code {
KeyCode::Up => {
if !visible.is_empty() {
data.active_index = (data.active_index + visible.len() - 1) % visible.len();
}
}
KeyCode::Down => {
if !visible.is_empty() {
data.active_index = (data.active_index + 1) % visible.len();
}
}
KeyCode::Char(' ') => {
if let Some(&item_index) = visible.get(data.active_index) {
data.items[item_index].0 = !data.items[item_index].0;
}
}
KeyCode::Backspace => {
if data.filter.pop().is_some() {
data.active_index = 0;
data.scroll_offset = 0;
}
}
KeyCode::Char(c) => {
if !key_event.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
data.filter.push(*c);
data.active_index = 0;
data.scroll_offset = 0;
}
}
KeyCode::Enter => {
break;
}
KeyCode::Esc => {
logger.remove_refresh_item(LoggerRefreshItemKind::Selection);
bail!("Selection cancelled.");
}
_ => {}
}
} else {
}
}
logger.remove_refresh_item(LoggerRefreshItemKind::Selection);
logger.log_text_items(&render_complete(&data), context_name);
let mut result = Vec::new();
for (i, (is_selected, _)) in data.items.iter().enumerate() {
if *is_selected {
result.push(i);
}
}
Ok(result)
}
fn visible_indexes(data: &MultiSelectData) -> Vec<usize> {
if data.filter.is_empty() {
return (0..data.items.len()).collect();
}
let filter = data.filter.to_lowercase();
data
.items
.iter()
.enumerate()
.filter(|(_, (_, text))| text.to_lowercase().contains(&filter))
.map(|(i, _)| i)
.collect()
}
fn max_visible_rows(data: &MultiSelectData) -> usize {
match get_terminal_size() {
Some(size) => {
let reserved = 1 + usize::from(!data.filter.is_empty()) + 2 + 1;
(size.rows as usize).saturating_sub(reserved).max(1)
}
None => data.items.len().max(1),
}
}
fn update_scroll_offset(data: &mut MultiSelectData, visible_len: usize, max_visible_rows: usize) {
if data.active_index < data.scroll_offset {
data.scroll_offset = data.active_index;
} else if data.active_index >= data.scroll_offset + max_visible_rows {
data.scroll_offset = data.active_index + 1 - max_visible_rows;
}
let max_scroll = visible_len.saturating_sub(max_visible_rows);
if data.scroll_offset > max_scroll {
data.scroll_offset = max_scroll;
}
}
fn render_multi_select(data: &MultiSelectData, visible: &[usize], max_visible_rows: usize) -> Vec<LoggerTextItem> {
let mut result = vec![LoggerTextItem::Text(data.prompt.to_string())];
if !data.filter.is_empty() {
result.push(LoggerTextItem::Text(format!(" filter: {}", data.filter)));
}
if visible.is_empty() {
result.push(LoggerTextItem::Text(" (no matching plugins)".to_string()));
return result;
}
let end = (data.scroll_offset + max_visible_rows).min(visible.len());
if data.scroll_offset > 0 {
result.push(LoggerTextItem::Text(format!(" ...{} more above", data.scroll_offset)));
}
for (visible_pos, &item_index) in visible.iter().enumerate().take(end).skip(data.scroll_offset) {
let (is_selected, item_text) = &data.items[item_index];
let mut text = String::new();
text.push_str(if visible_pos == data.active_index { ">" } else { " " });
text.push_str(" [");
text.push_str(if *is_selected { "x" } else { " " });
text.push_str("] ");
text.push_str(item_text);
result.push(LoggerTextItem::HangingText {
text,
indent: 7 + data.item_hanging_indent,
});
}
if end < visible.len() {
result.push(LoggerTextItem::Text(format!(" ...{} more below", visible.len() - end)));
}
result
}
fn render_complete(data: &MultiSelectData) -> Vec<LoggerTextItem> {
let mut result = Vec::new();
if data.items.iter().any(|(is_selected, _)| *is_selected) {
result.push(LoggerTextItem::Text(data.prompt.to_string()));
for (is_selected, item_text) in data.items.iter() {
if *is_selected {
result.push(LoggerTextItem::HangingText {
text: format!(" * {}", item_text),
indent: 3 + data.item_hanging_indent,
});
}
}
}
result
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
fn build_data(items: &[(bool, String)]) -> MultiSelectData<'_> {
MultiSelectData {
prompt: "Select:",
item_hanging_indent: 0,
items: items.iter().map(|(selected, text)| (*selected, text)).collect(),
filter: String::new(),
active_index: 0,
scroll_offset: 0,
}
}
fn rendered_lines(items: &[LoggerTextItem]) -> Vec<String> {
items
.iter()
.map(|item| match item {
LoggerTextItem::Text(text) => text.clone(),
LoggerTextItem::HangingText { text, .. } => text.clone(),
})
.collect()
}
#[test]
fn visible_indexes_returns_all_when_no_filter() {
let items = vec![(false, "alpha".to_string()), (false, "beta".to_string())];
let data = build_data(&items);
assert_eq!(visible_indexes(&data), vec![0, 1]);
}
#[test]
fn visible_indexes_filters_case_insensitively() {
let items = vec![(false, "TypeScript".to_string()), (false, "JSON".to_string()), (false, "Markdown".to_string())];
let mut data = build_data(&items);
data.filter = "json".to_string();
assert_eq!(visible_indexes(&data), vec![1]);
data.filter = "s".to_string(); assert_eq!(visible_indexes(&data), vec![0, 1]);
data.filter = "nope".to_string();
assert_eq!(visible_indexes(&data), Vec::<usize>::new());
}
#[test]
fn update_scroll_offset_scrolls_to_keep_active_visible() {
let items = (0..10).map(|i| (false, format!("item{i}"))).collect::<Vec<_>>();
let mut data = build_data(&items);
data.active_index = 7;
update_scroll_offset(&mut data, 10, 3);
assert_eq!(data.scroll_offset, 5);
data.active_index = 2;
update_scroll_offset(&mut data, 10, 3);
assert_eq!(data.scroll_offset, 2);
data.active_index = 3;
update_scroll_offset(&mut data, 10, 3);
assert_eq!(data.scroll_offset, 2);
}
#[test]
fn update_scroll_offset_clamps_to_end() {
let items = (0..5).map(|i| (false, format!("item{i}"))).collect::<Vec<_>>();
let mut data = build_data(&items);
data.scroll_offset = 4;
data.active_index = 4;
update_scroll_offset(&mut data, 5, 3);
assert_eq!(data.scroll_offset, 2);
}
#[test]
fn render_marks_active_and_selected() {
let items = vec![(true, "alpha".to_string()), (false, "beta".to_string())];
let mut data = build_data(&items);
data.active_index = 1;
let visible = visible_indexes(&data);
assert_eq!(
rendered_lines(&render_multi_select(&data, &visible, 10)),
vec!["Select:", " [x] alpha", "> [ ] beta"]
);
}
#[test]
fn render_shows_filter_and_scroll_indicators() {
let items = (0..10).map(|i| (false, format!("item{i}"))).collect::<Vec<_>>();
let mut data = build_data(&items);
data.filter = "item".to_string();
data.active_index = 5;
data.scroll_offset = 4;
let visible = visible_indexes(&data);
assert_eq!(
rendered_lines(&render_multi_select(&data, &visible, 3)),
vec![
"Select:",
" filter: item",
" ...4 more above",
" [ ] item4",
"> [ ] item5",
" [ ] item6",
" ...3 more below",
]
);
}
#[test]
fn render_shows_message_when_no_matches() {
let items = vec![(false, "alpha".to_string())];
let mut data = build_data(&items);
data.filter = "nope".to_string();
let visible = visible_indexes(&data);
assert_eq!(
rendered_lines(&render_multi_select(&data, &visible, 10)),
vec!["Select:", " filter: nope", " (no matching plugins)"]
);
}
}