use crate::mouse_input::MouseInput;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::{
prelude::*,
style::{Color, Style},
widgets::*,
};
use crate::app::Action;
use crate::menus::{centered_popup, wrap_index, Menu, PopupSize};
pub struct NodeSelectMenu {
open: bool, pub index: i32,
pub state: ListState,
pub nodes: Vec<String>,
pub job_id: String,
pub rect: Rect,
}
impl Default for NodeSelectMenu {
fn default() -> Self {
Self::new()
}
}
impl NodeSelectMenu {
pub fn new() -> Self {
Self {
open: false,
index: 0,
state: ListState::default(),
nodes: Vec::new(),
job_id: String::new(),
rect: Rect::default(),
}
}
}
impl NodeSelectMenu {
pub fn set_index(&mut self, index: i32) {
self.index = wrap_index(index as isize, 0, self.nodes.len()) as i32;
self.state.select(Some(self.index as usize));
}
fn next(&mut self) {
self.set_index(self.index + 1);
}
fn previous(&mut self) {
self.set_index(self.index - 1);
}
fn perform_action(&mut self, action: &mut Action) {
if let Some(node) = self.nodes.get(self.index as usize) {
*action = Action::SshToNode(node.clone());
}
self.deactivate();
}
pub fn activate(&mut self, job_id: &str, nodes: Vec<String>) {
self.job_id = job_id.to_string();
self.nodes = nodes;
self.open = true;
self.set_index(0);
}
pub fn deactivate(&mut self) {
self.open = false;
}
}
impl Menu for NodeSelectMenu {
fn is_open(&self) -> bool {
self.open
}
fn render(&mut self, f: &mut Frame, _area: &Rect) {
let rect = centered_popup(
f.area(),
PopupSize::Fraction(0.8),
PopupSize::Fixed(self.nodes.len() as u16 + 2),
);
self.rect = rect;
f.render_widget(Clear, rect);
let title = format!("Select node (job {})", self.job_id);
let list = List::new(self.nodes.clone())
.block(
Block::default()
.borders(Borders::ALL)
.title_top(Line::from(title).alignment(Alignment::Center))
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::Blue)),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Blue)
.fg(Color::Black),
);
f.render_stateful_widget(list, rect, &mut self.state);
}
fn input(&mut self, action: &mut Action, key_event: KeyEvent) -> bool {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
self.deactivate();
}
KeyCode::Down | KeyCode::Char('j') => {
self.next();
}
KeyCode::Up | KeyCode::Char('k') => {
self.previous();
}
KeyCode::Enter | KeyCode::Char('l') => {
self.perform_action(action);
}
_ => {}
}
true
}
fn mouse_input(&mut self, action: &mut Action, mouse_input: &mut MouseInput) {
if let Some(mouse_event_kind) = mouse_input.kind() {
match mouse_event_kind {
MouseEventKind::Down(MouseButton::Left) => {
if !self.rect.contains(mouse_input.get_position()) {
self.deactivate();
mouse_input.click();
} else {
let y = mouse_input.get_position().y - self.rect.y;
let y_min = 1;
let y_max = self.nodes.len() as u16 + 1;
if y >= y_min && y < y_max {
self.set_index(y as i32 - 1);
}
if mouse_input.is_double_click() {
self.perform_action(action);
}
mouse_input.click();
}
}
MouseEventKind::ScrollUp => {
self.previous();
}
MouseEventKind::ScrollDown => {
self.next();
}
_ => {}
}
mouse_input.handled = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn open_menu() -> NodeSelectMenu {
let mut menu = NodeSelectMenu::new();
menu.activate(
"4242",
vec!["gpu1".to_string(), "gpu3".to_string(), "mem1".to_string()],
);
menu
}
#[test]
fn test_activate_opens_at_the_first_node() {
let menu = open_menu();
assert!(menu.is_open());
assert_eq!(menu.index, 0);
assert_eq!(menu.job_id, "4242");
}
#[test]
fn test_navigation_wraps_forwards_and_backwards() {
let mut menu = open_menu();
let mut action = Action::None;
menu.input(&mut action, key(KeyCode::Char('k')));
assert_eq!(menu.index, 2);
menu.input(&mut action, key(KeyCode::Char('j')));
assert_eq!(menu.index, 0);
menu.input(&mut action, key(KeyCode::Down));
assert_eq!(menu.index, 1);
menu.input(&mut action, key(KeyCode::Up));
assert_eq!(menu.index, 0);
}
#[test]
fn test_enter_emits_ssh_action_with_the_selected_node() {
let mut menu = open_menu();
let mut action = Action::None;
menu.input(&mut action, key(KeyCode::Char('j')));
menu.input(&mut action, key(KeyCode::Enter));
match action {
Action::SshToNode(node) => assert_eq!(node, "gpu3"),
other => panic!("expected Action::SshToNode, got {:?}", other),
}
assert!(!menu.is_open());
}
#[test]
fn test_escape_closes_without_emitting_an_action() {
let mut menu = open_menu();
let mut action = Action::None;
menu.input(&mut action, key(KeyCode::Esc));
assert!(!menu.is_open());
assert!(matches!(action, Action::None));
}
}