use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WizardStep {
ProjectName,
RobotType,
NodeSelection,
Confirmation,
}
impl WizardStep {
fn next(&self) -> Option<WizardStep> {
match self {
WizardStep::ProjectName => Some(WizardStep::RobotType),
WizardStep::RobotType => Some(WizardStep::NodeSelection),
WizardStep::NodeSelection => Some(WizardStep::Confirmation),
WizardStep::Confirmation => None,
}
}
fn prev(&self) -> Option<WizardStep> {
match self {
WizardStep::ProjectName => None,
WizardStep::RobotType => Some(WizardStep::ProjectName),
WizardStep::NodeSelection => Some(WizardStep::RobotType),
WizardStep::Confirmation => Some(WizardStep::NodeSelection),
}
}
}
#[derive(Debug, Clone)]
pub struct TemplateOption {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct NodeOption {
pub id: String,
pub name: String,
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>,
}
impl InitWizardState {
pub fn new(templates: Vec<TemplateOption>, nodes: Vec<NodeOption>, defaults: Vec<bool>) -> Self {
Self {
project_name: String::new(),
selected_template: None,
selected_nodes: defaults,
templates,
nodes,
}
}
pub fn get_template(&self) -> Option<&TemplateOption> {
self.selected_template.and_then(|idx| self.templates.get(idx))
}
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 {
self.selected_nodes.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 vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10), Constraint::Length(3), ])
.split(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), Constraint::Percentage(50), ])
.split(vertical_chunks[0]);
let right_vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40), Constraint::Percentage(60), ])
.split(horizontal_chunks[1]);
self.draw_current_step(f, horizontal_chunks[0]);
self.draw_selections(f, right_vertical_chunks[0]);
self.draw_robot_visualization(f, right_vertical_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
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(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
let name_text = if self.state.project_name.is_empty() {
Span::styled("<not set>", Style::default().fg(Color::DarkGray))
} else {
Span::styled(&self.state.project_name, Style::default().fg(Color::Green))
};
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(Color::Green))
} else {
Span::styled("<not selected>", Style::default().fg(Color::DarkGray))
};
text.push(Line::from(vec![
Span::styled("Robot Type: ", Style::default().add_modifier(Modifier::BOLD)),
template_text,
]));
if matches!(self.current_step, WizardStep::NodeSelection | 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(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
},
),
]));
}
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Your Selections ")
.border_style(Style::default().fg(Color::Cyan)),
)
.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::NodeSelection => self.draw_node_selection_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(Color::Cyan).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(Color::Yellow)),
Span::styled("_", Style::default().fg(Color::Yellow)),
]),
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(Color::Green),
)]),
];
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Step 1/4: Project Name ")
.border_style(Style::default().fg(Color::Yellow)),
)
.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(Color::Green).add_modifier(Modifier::BOLD))
} else if is_highlighted {
("○ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
} else {
("○ ", Style::default().fg(Color::White))
};
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(Color::DarkGray)),
]),
];
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(Color::Yellow)),
);
let mut state = ListState::default();
state.select(Some(self.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_node_selection_step(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = self
.state
.nodes
.iter()
.enumerate()
.map(|(idx, node)| {
let is_selected = self.state.selected_nodes.get(idx).copied().unwrap_or(false);
let is_highlighted = idx == self.selected_index;
let checkbox = if is_selected { "[✓]" } else { "[ ]" };
let style = if is_highlighted {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let content = vec![
Line::from(vec![
Span::styled(checkbox, style),
Span::raw(" "),
Span::styled(&node.name, style),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(&node.description, Style::default().fg(Color::DarkGray)),
]),
];
ListItem::new(content)
})
.collect();
let selected_count = self.state.selected_node_count();
let title = format!(" Step 3/4: Nodes ({} selected) ", selected_count);
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
);
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(Color::Green).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(Color::Cyan)),
]),
Line::from(vec![
Span::styled("Type: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(template_name, Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled("Nodes: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} selected", self.state.selected_node_count()),
Style::default().fg(Color::Cyan),
),
]),
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(Color::Yellow)),
]));
}
text.push(Line::from(""));
}
text.extend(vec![
Line::from(""),
Line::from(vec![Span::styled(
"Press Enter to create project",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::styled(
"Press Backspace to go back",
Style::default().fg(Color::Yellow),
)]),
]);
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Step 4/4: Confirmation ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_robot_visualization(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let rover_art = vec![
Line::from(""),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled("_______________", Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("/|", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("/ |", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("/_______________/", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("/", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("|_______________|", Style::default().fg(Color::Cyan)),
Span::styled("/", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("/|", Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Yellow)),
Span::styled("/", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("/ |", Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled("(_)", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("(_)", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Yellow)),
Span::styled("/", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("|", Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled("|_______________|", Style::default().fg(Color::Yellow)),
Span::styled("/", Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("●", Style::default().fg(Color::DarkGray)),
Span::styled("/", Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::raw("\\"),
Span::styled("●", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled("(_)", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("(_)", Style::default().fg(Color::DarkGray)),
]),
Line::from(""),
];
let paragraph = Paragraph::new(rover_art)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Robot Preview ")
.border_style(Style::default().fg(Color::Green)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let footer_text = vec![Line::from(vec![
Span::raw(" "),
Span::styled("↑/↓:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(" Navigate "),
Span::styled(
"Space:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
Span::raw(" Select "),
Span::styled("Enter:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" Next "),
Span::styled(
"Backspace:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
Span::raw(" Back "),
Span::styled("Esc:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" Cancel"),
])];
let footer = Paragraph::new(footer_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(footer, area);
}
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::NodeSelection => self.handle_node_selection_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);
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_node_selection_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.nodes.len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Char(' ') => {
if let Some(selected) = self.state.selected_nodes.get_mut(self.selected_index) {
*selected = !*selected;
}
}
KeyCode::Enter => {
if let Some(next_step) = self.current_step.next() {
self.current_step = next_step;
}
}
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;
}
}
_ => {}
}
}
}