use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{
layout::{Flex, Layout, Rect},
Frame,
};
use crate::app::Action;
use crate::menus::{
confirmation::Confirmation,
help::{HelpContext, HelpMenu},
job_actions::JobActionsMenu,
job_overview::JobOverview,
log_viewer::LogViewer,
message::Message,
node_select::NodeSelectMenu,
user_options_menu::UserOptionsMenu,
};
use crate::mouse_input::MouseInput;
use crate::{joblist::JobList, user_options::UserOptions};
use self::salloc::salloc_menu::SallocMenu;
pub mod confirmation;
pub mod help;
pub mod job_actions;
pub mod job_overview;
pub mod log_viewer;
pub mod message;
pub mod node_select;
pub mod salloc;
pub mod user_options_menu;
#[derive(Debug, Clone)]
pub enum OpenMenu {
UserOptions,
Help(HelpContext),
Salloc,
JobActions,
LogView,
NodeSelect {
job_id: String,
nodes: Vec<String>,
},
Message(message::Message),
}
pub trait Menu {
fn is_open(&self) -> bool;
fn render(&mut self, f: &mut Frame, area: &Rect);
fn input(&mut self, action: &mut Action, key_event: KeyEvent) -> bool;
fn mouse_input(&mut self, action: &mut Action, mouse_input: &mut MouseInput);
}
#[derive(Debug, Clone, Copy)]
pub enum PopupSize {
Fraction(f32),
Fixed(u16),
}
impl PopupSize {
fn resolve(self, total: u16) -> u16 {
match self {
PopupSize::Fraction(fraction) => (fraction * total as f32) as u16,
PopupSize::Fixed(cells) => cells.min(total),
}
}
}
pub fn centered_popup(frame_area: Rect, width: PopupSize, height: PopupSize) -> Rect {
let width = width.resolve(frame_area.width);
let height = height.resolve(frame_area.height);
let vertical = Layout::vertical([height]).flex(Flex::Center);
let horizontal = Layout::horizontal([width]).flex(Flex::Center);
let [rect] = vertical.areas(frame_area);
let [rect] = horizontal.areas(rect);
rect
}
pub fn wrap_index(current: isize, delta: isize, len: usize) -> usize {
if len == 0 {
return 0;
}
let max = len as isize - 1;
let target = current + delta;
if target > max {
0
} else if target < 0 {
max as usize
} else {
target as usize
}
}
pub struct MenuContainer {
pub job_overview: JobOverview,
pub job_actions_menu: JobActionsMenu,
pub node_select_menu: NodeSelectMenu,
pub log_viewer: LogViewer,
pub salloc_menu: SallocMenu,
pub user_options_menu: UserOptionsMenu,
pub help_menu: HelpMenu,
pub message: Message,
pub confirmation: Confirmation,
}
impl MenuContainer {
pub fn new(user_options: &UserOptions, joblist: &JobList) -> Self {
Self {
job_overview: JobOverview::new(
user_options.refresh_rate,
&joblist.squeue_command,
user_options.job_columns.clone(),
),
job_actions_menu: JobActionsMenu::new(),
node_select_menu: NodeSelectMenu::new(),
log_viewer: LogViewer::new(),
salloc_menu: SallocMenu::new(),
help_menu: HelpMenu::new(),
message: Message::new_disabled(),
confirmation: Confirmation::new_disabled(),
user_options_menu: UserOptionsMenu::load(),
}
}
}
impl MenuContainer {
fn popups_front_to_back(&mut self) -> [&mut dyn Menu; 8] {
[
&mut self.confirmation,
&mut self.message,
&mut self.help_menu,
&mut self.log_viewer,
&mut self.user_options_menu,
&mut self.salloc_menu,
&mut self.node_select_menu,
&mut self.job_actions_menu,
]
}
pub fn activate_menu(&mut self, open_menu: OpenMenu, joblist: &JobList) {
match open_menu {
OpenMenu::JobActions => {
self.open_job_action(joblist);
}
OpenMenu::LogView => {
self.open_log_view(joblist);
}
OpenMenu::Salloc => {
self.salloc_menu.activate();
}
OpenMenu::UserOptions => {
self.user_options_menu.activate();
}
OpenMenu::NodeSelect { job_id, nodes } => {
self.node_select_menu.activate(&job_id, nodes);
}
OpenMenu::Message(message) => {
self.message = message;
}
OpenMenu::Help(context) => {
self.help_menu.open(context);
}
}
}
fn open_job_action(&mut self, joblist: &JobList) {
if let Some((base_id, task_count, job)) = joblist.selected_group() {
self.job_actions_menu
.activate_group(&base_id, task_count, job);
return;
}
match joblist.get_job() {
Some(job) => {
self.job_actions_menu.activate(job);
}
None => {
self.message = Message::new("No job selected");
self.message.kind = message::MessageKind::Error;
}
}
}
fn open_log_view(&mut self, joblist: &JobList) {
let job = match joblist.get_job() {
Some(job) => job,
None => {
self.error_message("No job selected");
return;
}
};
match job.get_stdout() {
Some(path) => self.log_viewer.activate(&path),
None => self.error_message("No log file found"),
}
}
fn error_message(&mut self, text: &str) {
self.message = Message::new(text);
self.message.kind = message::MessageKind::Error;
}
}
impl MenuContainer {
pub fn render(&mut self, f: &mut Frame, area: &Rect, joblist: &JobList) {
self.job_overview.render(f, area, joblist);
for menu in self.popups_front_to_back().into_iter().rev() {
if menu.is_open() {
menu.render(f, area);
}
}
}
}
impl MenuContainer {
pub fn input(&mut self, action: &mut Action, key_event: KeyEvent) {
for menu in self.popups_front_to_back() {
if menu.is_open() && menu.input(action, key_event) {
return;
}
}
self.job_overview.input(action, key_event);
}
pub fn mouse_input(
&mut self,
action: &mut Action,
mouse_input: &mut MouseInput,
mouse_event: MouseEvent,
) {
mouse_input.handled = false;
mouse_input.event = Some(mouse_event);
for menu in self.popups_front_to_back() {
if menu.is_open() {
menu.mouse_input(action, mouse_input);
}
}
self.job_overview.mouse_input(action, mouse_input);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
#[test]
fn test_wrap_index_steps_within_bounds() {
assert_eq!(wrap_index(0, 1, 5), 1);
assert_eq!(wrap_index(3, -1, 5), 2);
assert_eq!(wrap_index(2, 0, 5), 2);
}
#[test]
fn test_wrap_index_wraps_at_the_ends() {
assert_eq!(wrap_index(4, 1, 5), 0);
assert_eq!(wrap_index(0, -1, 5), 4);
}
#[test]
fn test_wrap_index_jump_beyond_the_ends_wraps() {
assert_eq!(wrap_index(17, 0, 5), 0);
assert_eq!(wrap_index(-3, 0, 5), 4);
}
#[test]
fn test_wrap_index_empty_list() {
assert_eq!(wrap_index(0, 1, 0), 0);
assert_eq!(wrap_index(0, -1, 0), 0);
}
#[test]
fn test_wrap_index_with_synthetic_trailing_row() {
assert_eq!(wrap_index(2, 1, 3 + 1), 3);
assert_eq!(wrap_index(3, 1, 3 + 1), 0);
assert_eq!(wrap_index(0, -1, 3 + 1), 3);
}
#[test]
fn test_centered_popup_is_centered() {
let frame = Rect::new(0, 0, 100, 50);
let rect = centered_popup(frame, PopupSize::Fixed(40), PopupSize::Fixed(10));
assert_eq!(rect, Rect::new(30, 20, 40, 10));
}
#[test]
fn test_centered_popup_fraction() {
let frame = Rect::new(0, 0, 100, 50);
let rect = centered_popup(frame, PopupSize::Fraction(0.8), PopupSize::Fraction(0.8));
assert_eq!(rect.width, 80);
assert_eq!(rect.height, 40);
}
#[test]
fn test_centered_popup_clips_to_narrow_frame() {
let frame = Rect::new(0, 0, 10, 5);
let rect = centered_popup(frame, PopupSize::Fixed(40), PopupSize::Fixed(9));
assert!(rect.width <= frame.width);
assert!(rect.height <= frame.height);
assert_eq!(rect.intersection(frame), rect);
}
fn container() -> MenuContainer {
MenuContainer::new(&UserOptions::default(), &JobList::new())
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn left_click(column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn test_key_input_goes_to_confirmation_before_message() {
let mut container = container();
container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);
container.message = Message::new("some message");
let mut action = Action::None;
container.input(&mut action, key(KeyCode::Esc));
assert!(!container.confirmation.is_open());
assert!(container.message.is_open());
}
#[test]
fn test_mouse_input_goes_to_confirmation_before_message() {
let mut container = container();
container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);
container.message = Message::new("some message");
let mut action = Action::None;
let mut mouse_input = MouseInput::new();
container.mouse_input(&mut action, &mut mouse_input, left_click(0, 0));
assert!(!container.confirmation.is_open());
assert!(container.message.is_open());
}
#[test]
fn test_open_node_select_consumes_key_input() {
let mut container = container();
container.activate_menu(
OpenMenu::NodeSelect {
job_id: "4242".to_string(),
nodes: vec!["gpu1".to_string(), "gpu3".to_string()],
},
&JobList::new(),
);
assert!(container.node_select_menu.is_open());
let mut action = Action::None;
container.input(&mut action, key(KeyCode::Char('j')));
assert_eq!(container.node_select_menu.index, 1);
assert!(matches!(action, Action::None));
container.input(&mut action, key(KeyCode::Enter));
match action {
Action::SshToNode(node) => assert_eq!(node, "gpu3"),
other => panic!("expected Action::SshToNode, got {:?}", other),
}
assert!(!container.node_select_menu.is_open());
}
#[test]
fn test_job_actions_on_group_row_target_the_array() {
use crate::job::{Job, JobStatus};
use crate::menus::job_actions::JobActions;
let mut container = container();
let mut joblist = JobList::new();
for id in ["100_1", "100_2"] {
joblist.jobs.push(Job::new(
id,
"array_job",
JobStatus::Running,
"00:00:00",
"main",
1,
"/work",
"cmd",
None,
));
}
assert!(joblist.selected_group().is_some());
container.activate_menu(OpenMenu::JobActions, &joblist);
assert!(container.job_actions_menu.is_open());
assert_eq!(
container.job_actions_menu.job_name,
"job array 100 (2 tasks)"
);
assert_eq!(
container.job_actions_menu.labels[0],
"1. Kill job array (2 tasks)"
);
match &container.job_actions_menu.actions[0] {
JobActions::KillArray {
base_id,
task_count,
} => {
assert_eq!(base_id, "100");
assert_eq!(*task_count, 2);
}
other => panic!("expected KillArray, got {:?}", other),
}
match &container.job_actions_menu.actions[1] {
JobActions::OpenLog(job) => assert_eq!(job.id, "100_1"),
other => panic!("expected OpenLog, got {:?}", other),
}
let mut joblist = JobList::new();
joblist.jobs.push(Job::new_default());
container.activate_menu(OpenMenu::JobActions, &joblist);
assert_eq!(container.job_actions_menu.labels[0], "1. Kill job");
assert_eq!(container.job_actions_menu.job_name, "jobname");
assert!(matches!(
container.job_actions_menu.actions[0],
JobActions::Kill(_)
));
}
#[test]
fn test_open_log_view_for_selected_job() {
use crate::job::{Job, JobStatus};
let mut container = container();
let mut joblist = JobList::new();
let mut job = Job::new_default();
job.id = "4242".to_string();
job.output = Some("/logs/run-%j.out".to_string());
joblist.jobs.push(job);
container.activate_menu(OpenMenu::LogView, &joblist);
assert!(container.log_viewer.is_open());
assert_eq!(container.log_viewer.path, "/logs/run-4242.out");
let request = container.log_viewer.follow_request().unwrap();
assert_eq!(request.path, "/logs/run-4242.out");
assert_eq!(request.offset, None);
let mut action = Action::None;
container.input(&mut action, key(KeyCode::Char('q')));
assert!(matches!(action, Action::None));
assert!(!container.log_viewer.is_open());
let mut sacct_job = Job::new_default();
sacct_job.status = JobStatus::Completed;
sacct_job.output = None;
let mut joblist = JobList::new();
joblist.jobs.push(sacct_job);
container.activate_menu(OpenMenu::LogView, &joblist);
assert!(!container.log_viewer.is_open());
assert!(container.message.is_open());
assert!(container.message.text.contains("No log file found"));
container.message = Message::new_disabled();
container.activate_menu(OpenMenu::LogView, &JobList::new());
assert!(!container.log_viewer.is_open());
assert!(container.message.text.contains("No job selected"));
}
#[test]
fn test_job_overview_key_l_emits_log_view_action() {
let mut container = container();
let mut action = Action::None;
container.input(&mut action, key(KeyCode::Char('L')));
assert!(matches!(action, Action::OpenMenu(OpenMenu::LogView)));
}
#[test]
fn test_confirmation_key_confirm_emits_action() {
let mut container = container();
container.confirmation = Confirmation::new("Quit?", Action::ConfirmedQuit);
let mut action = Action::None;
container.input(&mut action, key(KeyCode::Char('y')));
assert!(matches!(action, Action::ConfirmedQuit));
assert!(!container.confirmation.is_open());
}
}