use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use crate::app::App;
use crate::config::Config;
use crate::vm::DiscoveredVm;
#[derive(Debug, Clone)]
pub struct MenuItem {
pub name: &'static str,
pub description: &'static str,
pub action: MenuAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuAction {
StopVm,
BootOptions,
Snapshots,
UsbPassthrough,
PciPassthrough,
SharedFolders,
NetworkSettings,
MultiGpuPassthrough,
SingleGpuPassthrough,
ChangeDisplay,
EditNotes,
RenameVm,
ResetVm,
DeleteVm,
EditRawConfig,
}
pub fn get_menu_items(vm: &DiscoveredVm, config: &Config) -> Vec<MenuItem> {
let mut items = vec![
MenuItem {
name: "Boot Options",
description: "Normal, install, or custom ISO boot",
action: MenuAction::BootOptions,
},
MenuItem {
name: "Snapshots",
description: "Create, restore, or delete snapshots",
action: MenuAction::Snapshots,
},
MenuItem {
name: "USB Passthrough",
description: "Pass USB devices to the VM",
action: MenuAction::UsbPassthrough,
},
MenuItem {
name: "PCI Passthrough",
description: "Pass PCI devices to the VM",
action: MenuAction::PciPassthrough,
},
MenuItem {
name: "Shared Folders",
description: "Share host directories with the VM (9p)",
action: MenuAction::SharedFolders,
},
MenuItem {
name: "Network Settings",
description: "Configure networking backend and port forwarding",
action: MenuAction::NetworkSettings,
},
];
if config.enable_multi_gpu_passthrough {
items.push(MenuItem {
name: "Multi-GPU Passthrough",
description: "Pass a secondary GPU to the VM with Looking Glass",
action: MenuAction::MultiGpuPassthrough,
});
}
if config.single_gpu_enabled {
items.push(MenuItem {
name: "Single GPU Passthrough",
description: "Configure passthrough for your primary GPU",
action: MenuAction::SingleGpuPassthrough,
});
}
items.extend([
MenuItem {
name: "Change Display",
description: "GTK, SDL, SPICE-app, or VNC output",
action: MenuAction::ChangeDisplay,
},
MenuItem {
name: "Edit Notes",
description: "Add or edit personal notes for this VM",
action: MenuAction::EditNotes,
},
MenuItem {
name: "Rename VM",
description: "Change the VM's display name",
action: MenuAction::RenameVm,
},
]);
items.push(MenuItem {
name: "Stop VM",
description: "Shut down the running VM (ACPI poweroff)",
action: MenuAction::StopVm,
});
items.extend([
MenuItem {
name: "Reset VM (recreate disk)",
description: "Restore VM to fresh state",
action: MenuAction::ResetVm,
},
MenuItem {
name: "Delete VM",
description: "Permanently remove this VM",
action: MenuAction::DeleteVm,
},
MenuItem {
name: "Edit Raw Configuration",
description: "Edit the launch.sh script directly",
action: MenuAction::EditRawConfig,
},
]);
let _has_gpu_script = vm.path.join("launch-with-gpu-passthrough.sh").exists();
items
}
pub fn menu_item_count(app: &App) -> usize {
if let Some(vm) = app.selected_vm() {
get_menu_items(vm, &app.config).len()
} else {
6 }
}
const DISPLAY_OPTIONS: &[(&str, &str)] = &[
("gtk", "GTK - Default windowed display"),
("sdl", "SDL - Better for 3D acceleration"),
("spice-app", "SPICE - Remote desktop (needs virt-viewer)"),
("vnc", "VNC - Network accessible display"),
("none", "None - Headless, no graphical output"),
];
pub fn get_display_options(app: &App) -> Vec<(String, String)> {
let emulator = app.selected_vm()
.map(|vm| vm.config.emulator.command())
.unwrap_or("qemu-system-x86_64");
let detected = app.get_display_options_for_emulator(emulator);
detected.iter().map(|backend| {
let desc = DISPLAY_OPTIONS.iter()
.find(|(name, _)| *name == backend.as_str())
.map(|(_, desc)| desc.to_string())
.unwrap_or_else(|| format!("{} display", backend));
(backend.clone(), desc)
}).collect()
}
pub fn render(app: &App, frame: &mut Frame) {
let area = frame.area();
let menu_items = if let Some(vm) = app.selected_vm() {
get_menu_items(vm, &app.config)
} else {
Vec::new()
};
let dialog_width = 50.min(area.width.saturating_sub(4));
let item_count = menu_items.len();
let dialog_height = (6 + item_count * 2).min(area.height.saturating_sub(4) as usize) as u16;
let dialog_area = centered_rect(dialog_width, dialog_height, area);
frame.render_widget(Clear, dialog_area);
let vm_name = app.selected_vm()
.map(|vm| vm.display_name())
.unwrap_or_else(|| "Unknown".to_string());
let block = Block::default()
.title(format!(" {} - Management ", vm_name))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(4), Constraint::Length(2), ])
.split(h_chunks[1]);
let items: Vec<ListItem> = menu_items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == app.selected_menu_item {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let content = vec![
Line::styled(format!("[{}] {}", i + 1, item.name), style),
Line::styled(
format!(" {}", item.description),
Style::default().fg(Color::DarkGray),
),
];
ListItem::new(content)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_menu_item));
let list = List::new(items)
.highlight_symbol("> ");
frame.render_stateful_widget(list, chunks[1], &mut state);
let help = Paragraph::new("[Enter] Select [Esc] Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
pub fn render_boot_options(app: &App, frame: &mut Frame) {
let area = frame.area();
let dialog_width = 45.min(area.width.saturating_sub(4));
let dialog_height = 14.min(area.height.saturating_sub(4));
let dialog_area = centered_rect(dialog_width, dialog_height, area);
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Boot Options ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), ])
.split(h_chunks[1]);
let boot_items = [
("Normal boot", "Start the VM normally"),
("Install mode", "Boot from installation media"),
("Boot with custom ISO", "Select an ISO file to boot"),
];
let items: Vec<ListItem> = boot_items
.iter()
.enumerate()
.map(|(i, (name, desc))| {
let style = if i == app.selected_menu_item {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(vec![
Line::styled(format!("[{}] {}", i + 1, name), style),
Line::styled(format!(" {}", desc), Style::default().fg(Color::DarkGray)),
])
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_menu_item));
let list = List::new(items);
frame.render_stateful_widget(list, v_chunks[1], &mut state);
}
pub fn render_display_options(app: &App, frame: &mut Frame) {
let area = frame.area();
let dialog_width = 50.min(area.width.saturating_sub(4));
let dialog_height = 16.min(area.height.saturating_sub(4));
let dialog_area = centered_rect(dialog_width, dialog_height, area);
frame.render_widget(Clear, dialog_area);
let current_display = app.selected_vm()
.map(|vm| extract_display_from_script(&vm.config.raw_script))
.unwrap_or_else(|| "gtk".to_string());
let block = Block::default()
.title(format!(" Display Options (current: {}) ", current_display))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(2), ])
.split(h_chunks[1]);
let display_options = get_display_options(app);
let items: Vec<ListItem> = display_options
.iter()
.enumerate()
.map(|(i, (name, desc))| {
let is_current = *name == current_display;
let style = if i == app.selected_menu_item {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let marker = if is_current { " *" } else { "" };
ListItem::new(vec![
Line::styled(format!("[{}] {}{}", i + 1, name, marker), style),
Line::styled(format!(" {}", desc), Style::default().fg(Color::DarkGray)),
])
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_menu_item));
let list = List::new(items);
frame.render_stateful_widget(list, v_chunks[1], &mut state);
let help = Paragraph::new("[Enter] Select [Esc] Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, v_chunks[2]);
}
fn extract_display_from_script(script: &str) -> String {
if let Some(pos) = script.find("-display ") {
let rest = &script[pos + 9..];
let end = rest.find(|c: char| c.is_whitespace() || c == ',' || c == '\\')
.unwrap_or(rest.len());
let display = rest[..end].trim();
if let Some(comma_pos) = display.find(',') {
return display[..comma_pos].to_string();
}
return display.to_string();
}
"gtk".to_string() }
pub fn render_snapshots(app: &App, frame: &mut Frame) {
use ratatui::widgets::Wrap;
let area = frame.area();
let dialog_width = 55.min(area.width.saturating_sub(4));
let dialog_height = 18.min(area.height.saturating_sub(4));
let dialog_area = centered_rect(dialog_width, dialog_height, area);
frame.render_widget(Clear, dialog_area);
let supports_snapshots = app.selected_vm()
.map(|vm| vm.config.supports_snapshots())
.unwrap_or(false);
let title = if supports_snapshots {
format!(" Snapshots ({}) ", app.snapshots.len())
} else {
" Snapshots (not supported) ".to_string()
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), ])
.split(h_chunks[1]);
let content_area = v_chunks[1];
if !supports_snapshots {
let msg = Paragraph::new("This VM uses a raw disk image which doesn't support snapshots.\n\nOnly qcow2 format disks support snapshots.")
.style(Style::default().fg(Color::Yellow))
.wrap(Wrap { trim: false });
frame.render_widget(msg, content_area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(4), Constraint::Length(2)])
.split(content_area);
let actions = Paragraph::new(vec![
Line::from(vec![
Span::styled("[c]", Style::default().fg(Color::Yellow)),
Span::raw(" Create new snapshot"),
]),
]);
frame.render_widget(actions, chunks[0]);
if app.snapshots.is_empty() {
let msg = Paragraph::new("No snapshots yet.")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(msg, chunks[1]);
} else {
let items: Vec<ListItem> = app.snapshots
.iter()
.enumerate()
.map(|(i, snap)| {
let style = if i == app.selected_snapshot {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(vec![
Line::styled(format!(" {}", snap.name), style),
Line::styled(
format!(" {} - {}", snap.date, snap.size),
Style::default().fg(Color::DarkGray),
),
])
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected_snapshot));
let list = List::new(items)
.highlight_symbol("> ");
frame.render_stateful_widget(list, chunks[1], &mut state);
}
let help = Paragraph::new("[r] Restore [d] Delete [Esc] Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}