use super::{ColorScheme, Colorizer, LayoutManager, Screen, Terminal};
use crate::error::{PromptError, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use std::io::{self, stderr};
use std::time::Duration;
pub struct ChoiceMenu {
terminal: Terminal,
choices: Vec<String>,
allow_multiple: bool,
min_choices: usize,
max_choices: usize,
selected_choices: Vec<bool>,
current_index: usize,
colorizer: Colorizer,
validation_error: Option<String>,
last_content_lines: u16, timeout: Duration,
has_defaults: bool, user_has_interacted: bool, }
impl ChoiceMenu {
pub fn new(
mut terminal: Terminal,
choices: Vec<String>,
allow_multiple: bool,
min_choices: usize,
max_choices: usize,
no_color: bool,
timeout: Duration,
default_selections: Vec<String>,
) -> Result<Self> {
if terminal.capabilities().cursor_control {
terminal.enter_raw_mode()?;
}
let color_scheme = if no_color {
ColorScheme::no_color()
} else {
ColorScheme::default()
};
let colorizer = Colorizer::new(color_scheme, no_color);
let mut selected_choices = vec![false; choices.len()];
let has_defaults = !default_selections.is_empty();
for default_choice in &default_selections {
if let Some(index) = choices.iter().position(|choice| choice == default_choice) {
selected_choices[index] = true;
}
}
Ok(Self {
terminal,
choices,
allow_multiple,
min_choices,
max_choices,
selected_choices,
current_index: 0,
colorizer,
validation_error: None,
last_content_lines: 0,
timeout,
has_defaults,
user_has_interacted: false,
})
}
pub fn show(&mut self, prompt_text: &str) -> Result<Vec<String>> {
use crossterm::{terminal::Clear, terminal::ClearType, ExecutableCommand};
use std::io::stderr;
stderr().execute(Clear(ClearType::CurrentLine))?;
let (width, height) = self.terminal.size()?;
let layout = LayoutManager::new(width, height);
let mut screen = Screen::new(stderr(), layout, self.colorizer.clone());
let reserved_lines = self.calculate_and_reserve_space(width, prompt_text)?;
self.move_to_prompt_position(reserved_lines)?;
self.validate_selections();
self.draw_menu(&mut screen, prompt_text)?;
self.update_content_line_count();
loop {
screen.flush()?;
if event::poll(self.timeout)? {
if let Event::Key(key_event) = event::read()? {
match self.handle_key_event(key_event)? {
MenuAction::Continue => {
self.validate_selections();
self.clear_and_redraw(&mut screen, prompt_text)?;
}
MenuAction::Submit => {
if self.can_submit() {
let selected = self.get_selected_choices();
return Ok(selected);
} else {
self.validate_selections();
self.clear_and_redraw(&mut screen, prompt_text)?;
}
}
MenuAction::Cancel => {
return Err(PromptError::Interrupted);
}
}
}
} else {
return Err(PromptError::Timeout);
}
}
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<MenuAction> {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
if self.current_index > 0 {
self.current_index -= 1;
self.user_has_interacted = true;
}
Ok(MenuAction::Continue)
}
KeyEvent {
code: KeyCode::Down,
..
} => {
if self.current_index < self.choices.len() - 1 {
self.current_index += 1;
self.user_has_interacted = true;
}
Ok(MenuAction::Continue)
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
if self.allow_multiple {
Ok(MenuAction::Submit)
} else {
if self.has_defaults && !self.user_has_interacted {
Ok(MenuAction::Submit)
} else {
self.selected_choices.fill(false);
self.selected_choices[self.current_index] = true;
Ok(MenuAction::Submit)
}
}
}
KeyEvent {
code: KeyCode::Char(' '),
..
} => {
if self.allow_multiple {
self.selected_choices[self.current_index] =
!self.selected_choices[self.current_index];
self.user_has_interacted = true;
}
Ok(MenuAction::Continue)
}
KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
..
} => Ok(MenuAction::Cancel),
KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
} => Ok(MenuAction::Cancel),
_ => Ok(MenuAction::Continue),
}
}
fn draw_menu(&self, screen: &mut Screen<io::Stderr>, prompt_text: &str) -> Result<()> {
use crossterm::{cursor::MoveToNextLine, ExecutableCommand};
let _prompt_width = screen.write_prompt(prompt_text)?;
stderr().execute(MoveToNextLine(1))?;
self.draw_menu_content()?;
Ok(())
}
fn clear_and_redraw(
&mut self,
screen: &mut Screen<io::Stderr>,
prompt_text: &str,
) -> Result<()> {
use crossterm::{
cursor::{MoveToColumn, MoveUp},
terminal::Clear,
terminal::ClearType,
ExecutableCommand,
};
if self.last_content_lines > 0 {
stderr().execute(MoveUp(self.last_content_lines + 1))?;
}
stderr().execute(MoveToColumn(0))?;
stderr().execute(Clear(ClearType::FromCursorDown))?;
self.draw_menu(screen, prompt_text)?;
self.update_content_line_count();
Ok(())
}
fn update_content_line_count(&mut self) {
let mut lines = 1; lines += self.choices.len() as u16; if self.validation_error.is_some() {
lines += 1; }
self.last_content_lines = lines;
}
fn draw_menu_content(&self) -> Result<()> {
use crossterm::{cursor::MoveToNextLine, ExecutableCommand};
let instruction = if self.allow_multiple {
if self.min_choices == self.max_choices {
format!("Select exactly {} choice(s). Use ↑↓ to navigate, SPACE to toggle, ENTER to submit:", self.min_choices)
} else {
format!(
"Select {}-{} choice(s). Use ↑↓ to navigate, SPACE to toggle, ENTER to submit:",
self.min_choices, self.max_choices
)
}
} else {
"Use ↑↓ to navigate, ENTER to select:".to_string()
};
let colored_instruction = self.colorizer.help_text(&instruction);
self.colorizer
.write_colored(&mut std::io::stderr(), &colored_instruction)?;
stderr().execute(MoveToNextLine(1))?;
for (i, choice) in self.choices.iter().enumerate() {
let is_current = i == self.current_index;
let is_selected = self.selected_choices[i];
let marker = if self.allow_multiple {
if is_selected {
"[✓]"
} else {
"[ ]"
}
} else if is_current {
">"
} else {
" "
};
let choice_text = format!("{} {}", marker, choice);
if is_current {
let colored_choice = self.colorizer.highlighted_text(&choice_text);
self.colorizer
.write_colored(&mut std::io::stderr(), &colored_choice)?;
} else {
let colored_choice = self.colorizer.valid_text(&choice_text);
self.colorizer
.write_colored(&mut std::io::stderr(), &colored_choice)?;
}
stderr().execute(MoveToNextLine(1))?;
}
if let Some(error_message) = &self.validation_error {
stderr().execute(MoveToNextLine(1))?;
let colored_error = self.colorizer.error_message(error_message);
self.colorizer
.write_colored(&mut std::io::stderr(), &colored_error)?;
}
Ok(())
}
fn get_selected_choices(&self) -> Vec<String> {
self.selected_choices
.iter()
.enumerate()
.filter_map(|(i, &selected)| {
if selected {
Some(self.choices[i].clone())
} else {
None
}
})
.collect()
}
fn validate_selections(&mut self) {
let selected_count = self.selected_choices.iter().filter(|&&s| s).count();
self.validation_error = if selected_count < self.min_choices {
Some(format!("At least {} choice(s) required", self.min_choices))
} else if selected_count > self.max_choices {
Some(format!("At most {} choice(s) allowed", self.max_choices))
} else {
None
};
}
fn can_submit(&self) -> bool {
let selected_count = self.selected_choices.iter().filter(|&&s| s).count();
selected_count >= self.min_choices && selected_count <= self.max_choices
}
fn calculate_and_reserve_space(&self, _width: u16, _prompt_text: &str) -> Result<u16> {
use std::io::{self, Write};
let mut total_lines = 0u16;
total_lines += 1;
total_lines += 1;
total_lines += self.choices.len() as u16;
total_lines += 1;
total_lines += 2;
let (_, terminal_height) = self.terminal.size()?;
let max_reservable = terminal_height.saturating_sub(2);
total_lines = total_lines.min(max_reservable);
let mut stderr = io::stderr();
for _ in 0..total_lines {
writeln!(stderr)?;
}
stderr.flush()?;
Ok(total_lines)
}
fn move_to_prompt_position(&mut self, reserved_lines: u16) -> Result<()> {
use crossterm::{cursor::MoveUp, ExecutableCommand};
use std::io::stderr;
stderr().execute(MoveUp(reserved_lines))?;
Ok(())
}
}
impl Drop for ChoiceMenu {
fn drop(&mut self) {
if self.terminal.capabilities().cursor_control {
let _ = self.terminal.leave_raw_mode();
}
}
}
#[derive(Debug)]
enum MenuAction {
Continue,
Submit,
Cancel,
}