use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use crate::services::build_platforms::BUILD_PLATFORMS;
use super::theme;
use super::tui_layout::{render_footer_hints, standard_tui_layout};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WizardStep {
ProjectName,
RobotType,
RobotPlatform,
Confirmation,
}
impl WizardStep {
fn next(&self) -> Option<WizardStep> {
match self {
WizardStep::ProjectName => Some(WizardStep::RobotType),
WizardStep::RobotType => Some(WizardStep::RobotPlatform),
WizardStep::RobotPlatform => Some(WizardStep::Confirmation),
WizardStep::Confirmation => None,
}
}
fn prev(&self) -> Option<WizardStep> {
match self {
WizardStep::ProjectName => None,
WizardStep::RobotType => Some(WizardStep::ProjectName),
WizardStep::RobotPlatform => Some(WizardStep::RobotType),
WizardStep::Confirmation => Some(WizardStep::RobotPlatform),
}
}
}
#[derive(Debug, Clone)]
pub struct TemplateOption {
pub id: String,
pub name: String,
pub description: String,
pub nodes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct NodeOption {
pub id: String,
pub name: String,
#[allow(dead_code)]
pub description: String,
}
pub struct InitWizardState {
pub project_name: String,
pub selected_template: Option<usize>,
pub selected_nodes: Vec<bool>,
pub templates: Vec<TemplateOption>,
pub nodes: Vec<NodeOption>,
pub selected_platform: usize,
pub selected_remote_platforms: Vec<bool>,
}
impl InitWizardState {
pub fn new(templates: Vec<TemplateOption>, nodes: Vec<NodeOption>, defaults: Vec<bool>) -> Self {
let selected_remote_platforms = BUILD_PLATFORMS
.iter()
.map(|p| p.triple == "x86_64-unknown-linux-gnu")
.collect();
Self {
project_name: String::new(),
selected_template: None,
selected_nodes: defaults,
templates,
nodes,
selected_platform: 0,
selected_remote_platforms,
}
}
pub fn get_template(&self) -> Option<&TemplateOption> {
self.selected_template.and_then(|idx| self.templates.get(idx))
}
pub fn get_platform_label(&self) -> &'static str {
BUILD_PLATFORMS[self.selected_platform].label
}
pub fn get_remote_platform_labels(&self) -> Vec<&'static str> {
self.selected_remote_platforms
.iter()
.enumerate()
.filter_map(|(idx, &selected)| {
if selected {
Some(BUILD_PLATFORMS[idx].label)
} else {
None
}
})
.collect()
}
pub fn get_selected_nodes(&self) -> Vec<&NodeOption> {
self.selected_nodes
.iter()
.enumerate()
.filter_map(|(idx, &selected)| if selected { self.nodes.get(idx) } else { None })
.collect()
}
pub fn selected_node_count(&self) -> usize {
count_selected(&self.selected_nodes)
}
pub fn sync_node_defaults_to_selected_template(&mut self) {
let Some(template) = self.get_template() else {
return;
};
let template_nodes = template.nodes.clone();
self.selected_nodes = self
.nodes
.iter()
.map(|node| template_nodes.iter().any(|n| n == &node.id))
.collect();
}
}
fn count_selected(flags: &[bool]) -> usize {
flags.iter().filter(|&&s| s).count()
}
pub struct InitWizardTui {
current_step: WizardStep,
state: InitWizardState,
selected_index: usize,
should_quit: bool,
confirmed: bool,
}
impl InitWizardTui {
pub fn new(state: InitWizardState) -> Self {
Self {
current_step: WizardStep::ProjectName,
state,
selected_index: 0,
should_quit: false,
confirmed: false,
}
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn is_confirmed(&self) -> bool {
self.confirmed
}
pub fn into_state(self) -> InitWizardState {
self.state
}
pub fn draw(&mut self, f: &mut Frame) {
let (content_area, footer_area) = standard_tui_layout(f.area());
if self.current_step == WizardStep::Confirmation {
self.draw_current_step(f, content_area);
} else {
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(content_area);
self.draw_current_step(f, horizontal_chunks[0]);
self.draw_selections(f, horizontal_chunks[1]);
}
self.draw_footer(f, footer_area);
}
fn draw_selections(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let mut text = vec![
Line::from(vec![Span::styled(
"📋 YOUR SELECTIONS",
Style::default().fg(theme::HEADING).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
let name_text = if self.state.project_name.is_empty() {
Span::styled("<not set>", Style::default().fg(theme::MUTED))
} else {
Span::styled(&self.state.project_name, Style::default().fg(theme::SELECTED))
};
text.push(Line::from(vec![
Span::styled("Project: ", Style::default().add_modifier(Modifier::BOLD)),
name_text,
]));
let template_text = if let Some(template) = self.state.get_template() {
Span::styled(&template.name, Style::default().fg(theme::SELECTED))
} else {
Span::styled("<not selected>", Style::default().fg(theme::MUTED))
};
text.push(Line::from(vec![
Span::styled("Robot Type: ", Style::default().add_modifier(Modifier::BOLD)),
template_text,
]));
if matches!(self.current_step, WizardStep::RobotPlatform | WizardStep::Confirmation) {
let node_count = self.state.selected_node_count();
text.push(Line::from(vec![
Span::styled("Nodes: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} selected", node_count),
if node_count > 0 {
Style::default().fg(theme::SELECTED)
} else {
Style::default().fg(theme::MUTED)
},
),
]));
}
if matches!(self.current_step, WizardStep::RobotPlatform | WizardStep::Confirmation) {
text.push(Line::from(vec![
Span::styled("Platform: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(self.state.get_platform_label(), Style::default().fg(theme::SELECTED)),
]));
}
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Your Selections ")
.border_style(Style::default().fg(theme::BORDER_INFO)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_current_step(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
match self.current_step {
WizardStep::ProjectName => self.draw_project_name_step(f, area),
WizardStep::RobotType => self.draw_robot_type_step(f, area),
WizardStep::RobotPlatform => self.draw_robot_platform_step(f, area),
WizardStep::Confirmation => self.draw_confirmation_step(f, area),
}
}
fn draw_project_name_step(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let text = vec![
Line::from(vec![Span::styled(
"📝 PROJECT NAME",
Style::default().fg(theme::HEADING).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from("Enter a name for your robot project."),
Line::from(""),
Line::from(vec![
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(&self.state.project_name, Style::default().fg(theme::INPUT)),
Span::styled("_", Style::default().fg(theme::INPUT)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Tips:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" • Use lowercase letters, numbers, hyphens"),
Line::from(" • Example: my-robot, rover-bot"),
Line::from(""),
Line::from(vec![Span::styled(
"Press Enter to continue →",
Style::default().fg(theme::SUCCESS),
)]),
];
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Step 1/4: Project Name ")
.border_style(Style::default().fg(theme::BORDER_ACTIVE)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_robot_type_step(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = self
.state
.templates
.iter()
.enumerate()
.map(|(idx, template)| {
let is_selected = Some(idx) == self.state.selected_template;
let is_highlighted = idx == self.selected_index;
let (prefix, style) = if is_selected {
("◉ ", Style::default().fg(theme::SELECTED).add_modifier(Modifier::BOLD))
} else if is_highlighted {
("○ ", Style::default().fg(theme::HIGHLIGHT).add_modifier(Modifier::BOLD))
} else {
("○ ", Style::default().fg(theme::TEXT))
};
let content = vec![
Line::from(vec![Span::raw(prefix), Span::styled(&template.name, style)]),
Line::from(vec![
Span::raw(" "),
Span::styled(&template.description, Style::default().fg(theme::MUTED)),
]),
];
ListItem::new(content)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" Step 2/4: Robot Type ")
.border_style(Style::default().fg(theme::BORDER_ACTIVE)),
);
let mut state = ListState::default();
state.select(Some(self.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_robot_platform_step(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = BUILD_PLATFORMS
.iter()
.enumerate()
.map(|(idx, platform)| {
let is_selected = idx == self.state.selected_platform;
let is_highlighted = idx == self.selected_index;
let (prefix, style) = if is_selected {
("◉ ", Style::default().fg(theme::SELECTED).add_modifier(Modifier::BOLD))
} else if is_highlighted {
("○ ", Style::default().fg(theme::HIGHLIGHT).add_modifier(Modifier::BOLD))
} else {
("○ ", Style::default().fg(theme::TEXT))
};
let content = vec![Line::from(vec![Span::raw(prefix), Span::styled(platform.label, style)])];
ListItem::new(content)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" Step 3/4: Robot Platform ")
.border_style(Style::default().fg(theme::BORDER_ACTIVE)),
);
let mut state = ListState::default();
state.select(Some(self.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_confirmation_step(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let template_name = self.state.get_template().map(|t| t.name.as_str()).unwrap_or("<none>");
let mut text = vec![
Line::from(vec![Span::styled(
"✅ CONFIRMATION",
Style::default().fg(theme::SUCCESS).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from("Review your selections:"),
Line::from(""),
Line::from(vec![
Span::styled("Project: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(&self.state.project_name, Style::default().fg(theme::HEADING)),
]),
Line::from(vec![
Span::styled("Type: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(template_name, Style::default().fg(theme::HEADING)),
]),
Line::from(vec![
Span::styled("Nodes: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} selected", self.state.selected_node_count()),
Style::default().fg(theme::HEADING),
),
]),
Line::from(vec![
Span::styled("Platform: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(self.state.get_platform_label(), Style::default().fg(theme::HEADING)),
]),
Line::from(vec![
Span::styled("Remote Platforms: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
self.state.get_remote_platform_labels().join(", "),
Style::default().fg(theme::HEADING),
),
]),
Line::from(""),
];
if self.state.selected_node_count() > 0 {
for node in self.state.get_selected_nodes() {
text.push(Line::from(vec![
Span::raw(" ✓ "),
Span::styled(&node.name, Style::default().fg(theme::INPUT)),
]));
}
text.push(Line::from(""));
}
text.extend(vec![
Line::from(""),
Line::from(vec![Span::styled(
"Press Enter to create project",
Style::default().fg(theme::SUCCESS).add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::styled(
"Press Backspace to go back",
Style::default().fg(theme::WARNING),
)]),
]);
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Step 4/4: Confirmation ")
.border_style(Style::default().fg(theme::BORDER_ACTIVE)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
render_footer_hints(
f,
area,
&[
("↑/↓", "Navigate", theme::HIGHLIGHT),
("Space", "Select", theme::WARNING),
("Enter", "Next", theme::SUCCESS),
("Backspace", "Back", theme::WARNING),
("Esc", "Cancel", theme::DANGER),
],
);
}
pub fn handle_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.should_quit = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
_ => match self.current_step {
WizardStep::ProjectName => self.handle_project_name_key(key),
WizardStep::RobotType => self.handle_robot_type_key(key),
WizardStep::RobotPlatform => self.handle_robot_platform_key(key),
WizardStep::Confirmation => self.handle_confirmation_key(key),
},
}
}
fn handle_project_name_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char(c) => {
self.state.project_name.push(c);
}
KeyCode::Backspace => {
self.state.project_name.pop();
}
KeyCode::Enter if !self.state.project_name.is_empty() => {
if let Some(next_step) = self.current_step.next() {
self.current_step = next_step;
self.selected_index = 0;
}
}
_ => {}
}
}
fn handle_robot_type_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Down => {
if self.selected_index < self.state.templates.len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Enter => {
self.state.selected_template = Some(self.selected_index);
self.state.sync_node_defaults_to_selected_template();
if let Some(next_step) = self.current_step.next() {
self.current_step = next_step;
self.selected_index = self.state.selected_platform;
}
}
KeyCode::Backspace => {
if let Some(prev_step) = self.current_step.prev() {
self.current_step = prev_step;
}
}
_ => {}
}
}
fn handle_robot_platform_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Down => {
if self.selected_index < BUILD_PLATFORMS.len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Enter => {
self.state.selected_platform = self.selected_index;
if let Some(next_step) = self.current_step.next() {
self.current_step = next_step;
self.selected_index = 0;
}
}
KeyCode::Backspace => {
if let Some(prev_step) = self.current_step.prev() {
self.current_step = prev_step;
}
}
_ => {}
}
}
fn handle_confirmation_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
self.confirmed = true;
}
KeyCode::Backspace => {
if let Some(prev_step) = self.current_step.prev() {
self.current_step = prev_step;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod count_selected_tests {
use super::count_selected;
#[test]
fn counts_only_the_true_flags() {
assert_eq!(count_selected(&[true, false, true, true]), 3);
}
#[test]
fn is_zero_for_an_empty_or_all_false_slice() {
assert_eq!(count_selected(&[]), 0);
assert_eq!(count_selected(&[false, false]), 0);
}
}