use std::io::{self, Write};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crate::cancellation::requested;
use crate::model::Profile;
use crate::ui::{self, RawModeGuard};
const ITEM_COUNT: usize = 7;
const DEFAULT_CURSOR: usize = 1;
const RECOMMENDED_INDEX: usize = 1;
#[derive(Clone)]
pub(crate) enum LauncherChoice {
Install(Profile),
Status,
Doctor,
Help,
Exit,
}
pub(crate) fn choose(version: &str) -> Result<LauncherChoice, String> {
let mut cursor = DEFAULT_CURSOR;
loop {
render(cursor, version)?;
let (choice, next_cursor) = read_choice(cursor)?;
cursor = next_cursor;
if let Some(choice) = choice {
return Ok(choice);
}
}
}
fn read_choice(cursor: usize) -> Result<(Option<LauncherChoice>, usize), String> {
let _raw_mode = RawModeGuard::acquire()
.map_err(|error| format!("failed to enable launcher input: {error}"))?;
loop {
if requested() {
return Err("operation interrupted by Ctrl-C".to_string());
}
if !event::poll(std::time::Duration::from_millis(50))
.map_err(|error| format!("failed to poll launcher input: {error}"))?
{
continue;
}
let event =
event::read().map_err(|error| format!("failed to read launcher input: {error}"))?;
let Event::Key(key) = event else {
continue;
};
if !is_actionable_key_event(key.kind) {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Err("operation interrupted by Ctrl-C".to_string());
}
let choice = match key.code {
KeyCode::Char('1') => Some(LauncherChoice::Install(Profile::Minimal)),
KeyCode::Char('2') => Some(LauncherChoice::Install(Profile::Standard)),
KeyCode::Char('3') => Some(LauncherChoice::Install(Profile::Advanced)),
KeyCode::Char('4') => Some(LauncherChoice::Status),
KeyCode::Char('5') => Some(LauncherChoice::Doctor),
KeyCode::Char('6' | 'h' | 'H') => Some(LauncherChoice::Help),
KeyCode::Char('0' | 'q' | 'Q') | KeyCode::Esc => Some(LauncherChoice::Exit),
_ => None,
};
if let Some(choice) = choice {
return Ok((Some(choice), cursor));
}
let next = match key.code {
KeyCode::Up | KeyCode::Left | KeyCode::Char('k' | 'K') => move_cursor(cursor, -1),
KeyCode::Down | KeyCode::Right | KeyCode::Tab | KeyCode::Char('j' | 'J') => {
move_cursor(cursor, 1)
}
KeyCode::Home => 0,
KeyCode::End => ITEM_COUNT - 1,
KeyCode::Enter => return Ok((Some(choice_at(cursor)), cursor)),
_ => continue,
};
return Ok((None, next));
}
}
fn move_cursor(cursor: usize, delta: isize) -> usize {
(isize::try_from(cursor).unwrap_or_default() + delta)
.rem_euclid(isize::try_from(ITEM_COUNT).unwrap_or(1)) as usize
}
fn choice_at(index: usize) -> LauncherChoice {
match index {
0 => LauncherChoice::Install(Profile::Minimal),
1 => LauncherChoice::Install(Profile::Standard),
2 => LauncherChoice::Install(Profile::Advanced),
3 => LauncherChoice::Status,
4 => LauncherChoice::Doctor,
5 => LauncherChoice::Help,
_ => LauncherChoice::Exit,
}
}
fn items() -> [(&'static str, &'static str); ITEM_COUNT] {
[
("Install minimal", "Minimal Rust development environment"),
("Install standard", "Rust development and quality toolchain"),
("Install advanced", "Advanced analysis and automation tools"),
("Status", "Managed tools and transaction status"),
("Doctor", "System, network, and platform diagnostics"),
("Help", "Command reference and options"),
("Exit", "Close bot-forge"),
]
}
fn render(cursor: usize, version: &str) -> Result<(), String> {
let mut stdout = io::stdout();
let options = ui::RenderOptions::stdout();
crossterm::execute!(
stdout,
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
)
.map_err(|error| format!("failed to draw launcher: {error}"))?;
writeln!(stdout, "{}", ui::page_title("bot-forge", version, &options))
.map_err(|error| format!("failed to write launcher: {error}"))?;
writeln!(stdout).map_err(|error| format!("failed to write launcher: {error}"))?;
writeln!(stdout, "{}", ui::section("Actions", &options))
.map_err(|error| format!("failed to write launcher: {error}"))?;
writeln!(stdout, " {}", ui::launcher_key_help(&options))
.map_err(|error| format!("failed to write launcher: {error}"))?;
for (index, (label, description)) in items().into_iter().enumerate() {
if index == 3 {
writeln!(
stdout,
"{}",
ui::launcher_group_label("Utilities", &options)
)
.map_err(|error| format!("failed to write launcher: {error}"))?;
}
writeln!(
stdout,
"{}",
ui::choice_line_with_recommendation(
index == cursor,
index == cursor,
false,
label,
description,
index == RECOMMENDED_INDEX,
&options,
)
)
.map_err(|error| format!("failed to write launcher: {error}"))?;
}
stdout
.flush()
.map_err(|error| format!("failed to flush launcher: {error}"))
}
fn is_actionable_key_event(kind: KeyEventKind) -> bool {
kind == KeyEventKind::Press
}
#[cfg(test)]
mod tests {
use crate::cli::launcher::{
DEFAULT_CURSOR, LauncherChoice, choice_at, is_actionable_key_event, items, move_cursor,
};
use crate::model::Profile;
use crossterm::event::KeyEventKind;
#[test]
fn ignores_key_release_events() {
assert!(is_actionable_key_event(KeyEventKind::Press));
assert!(!is_actionable_key_event(KeyEventKind::Repeat));
assert!(!is_actionable_key_event(KeyEventKind::Release));
}
#[test]
fn navigation_wraps_like_the_suite_choice_list() {
assert_eq!(move_cursor(0, -1), 6);
assert_eq!(move_cursor(6, 1), 0);
assert!(matches!(
choice_at(0),
LauncherChoice::Install(Profile::Minimal)
));
assert!(matches!(choice_at(6), LauncherChoice::Exit));
}
#[test]
fn launcher_defaults_to_second_row_standard_and_remains_navigable() {
assert_eq!(items()[DEFAULT_CURSOR].0, "Install standard");
assert!(matches!(
choice_at(DEFAULT_CURSOR),
LauncherChoice::Install(Profile::Standard)
));
assert!(matches!(
choice_at(move_cursor(DEFAULT_CURSOR, -1)),
LauncherChoice::Install(Profile::Minimal)
));
assert!(matches!(
choice_at(move_cursor(DEFAULT_CURSOR, 1)),
LauncherChoice::Install(Profile::Advanced)
));
assert_eq!(items()[1].0, "Install standard");
}
}