diff --git a/coman/src/components/context_menu.rs b/coman/src/components/context_menu.rs index 579c4c9..490dc73 100644 --- a/coman/src/components/context_menu.rs +++ b/coman/src/components/context_menu.rs @@ -1,9 +1,10 @@ use tui_realm_stdlib::List; use tuirealm::{ - AttrValue, Attribute, Component, Event, MockComponent, State, StateValue, + AttrValue, Attribute, Component, Event, Frame, MockComponent, State, StateValue, command::{Cmd, CmdResult, Direction, Position}, - event::{Key, KeyEvent}, + event::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind}, props::{Alignment, BorderType, Borders, Color, Table, TableBuilder, TextSpan}, + ratatui::layout::{Position as RectPosition, Rect}, }; use crate::app::{ @@ -11,10 +12,10 @@ use crate::app::{ user_events::{FileEvent, JobEvent, UserEvent}, }; -#[derive(MockComponent)] pub struct ContextMenu { component: List, current_view: View, + current_rect: Rect, } impl ContextMenu { @@ -27,7 +28,6 @@ impl ContextMenu { .add_col(TextSpan::from("Cancel Job").fg(Color::Cyan)) .add_row() .add_col(TextSpan::from("Quit").fg(Color::Cyan)) - .add_row() .build() } fn workload_actions(index: usize) -> Option { @@ -50,7 +50,6 @@ impl ContextMenu { .add_col(TextSpan::from("Delete").fg(Color::Cyan)) .add_row() .add_col(TextSpan::from("Quit").fg(Color::Cyan)) - .add_row() .build() } fn fileview_actions(index: usize) -> Option { @@ -82,10 +81,30 @@ impl ContextMenu { }) .selected_line(0), current_view: view, + current_rect: Rect::ZERO, } } } +impl MockComponent for ContextMenu { + fn view(&mut self, frame: &mut Frame, area: Rect) { + self.current_rect = area; + self.component.view(frame, area); + } + fn query(&self, attr: Attribute) -> Option { + self.component.query(attr) + } + fn attr(&mut self, query: Attribute, attr: AttrValue) { + self.component.attr(query, attr) + } + fn state(&self) -> State { + self.component.state() + } + fn perform(&mut self, cmd: Cmd) -> CmdResult { + self.component.perform(cmd) + } +} + impl Component for ContextMenu { fn on(&mut self, ev: tuirealm::Event) -> Option { let _ = match ev { @@ -116,6 +135,35 @@ impl Component for ContextMenu { }; return msg; } + Event::Mouse(MouseEvent { + kind, column: col, row, .. + }) => { + if !self.current_rect.contains(RectPosition { x: col, y: row }) { + CmdResult::None + } else { + let mut list_index = (row - self.current_rect.y) as usize; + list_index = list_index.saturating_sub(1); + if list_index >= self.component.states.list_len { + list_index = self.component.states.list_len; + } + + match kind { + MouseEventKind::Moved => { + self.component.states.list_index = list_index; + CmdResult::Changed(self.component.state()) + } + MouseEventKind::Down(MouseButton::Left) => { + return match self.current_view { + View::Workloads => ContextMenu::workload_actions(list_index), + View::Files => ContextMenu::fileview_actions(list_index), + }; + } + MouseEventKind::ScrollUp => self.perform(Cmd::Move(Direction::Up)), + MouseEventKind::ScrollDown => self.perform(Cmd::Move(Direction::Down)), + _ => CmdResult::None, + } + } + } Event::User(UserEvent::SwitchedToView(view)) => { match view { View::Workloads => self.attr(Attribute::Content, AttrValue::Table(ContextMenu::workload_options())), diff --git a/coman/src/components/file_tree.rs b/coman/src/components/file_tree.rs index e578e4d..f612728 100644 --- a/coman/src/components/file_tree.rs +++ b/coman/src/components/file_tree.rs @@ -1,17 +1,18 @@ -use std::{iter, path::PathBuf}; +use std::{collections::VecDeque, iter, path::PathBuf}; use tokio::sync::mpsc; use tui_realm_treeview::{Node, NodeValue, TREE_CMD_CLOSE, TREE_CMD_OPEN, TREE_INITIAL_NODE, Tree, TreeView}; use tuirealm::{ - AttrValue, Attribute, Component, Event, MockComponent, State, StateValue, + AttrValue, Attribute, Component, Event, Frame, MockComponent, State, StateValue, command::{Cmd, CmdResult, Direction, Position}, - event::{Key, KeyEvent, KeyModifiers}, + event::{Key, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, props::{Alignment, BorderType, Borders, Color, Style}, + ratatui::layout::{Position as RectPosition, Rect}, }; use crate::{ app::{ - messages::{DownloadPopupMsg, Msg}, + messages::{DownloadPopupMsg, MenuMsg, Msg}, user_events::{FileEvent, UserEvent}, }, cscs::{api_client::types::PathType, ports::BackgroundTask}, @@ -45,10 +46,10 @@ impl NodeValue for FileNode { } } -#[derive(MockComponent)] pub struct FileTree { component: TreeView, file_tree_tx: mpsc::Sender, + current_rect: Rect, } impl FileTree { pub fn new(file_tree_tx: mpsc::Sender) -> Self { @@ -81,29 +82,127 @@ impl FileTree { .with_tree(tree) .initial_node(root_node.id()), file_tree_tx, + current_rect: Rect::ZERO, + } + } + fn node_list(&self) -> Vec<&String> { + let root = self.component.tree().root(); + let mut ids = vec![]; + let mut stack = VecDeque::new(); + stack.push_back(root.id()); + + while let Some(current_id) = stack.pop_front() { + ids.push(current_id); + let node = root.query(current_id).unwrap(); + if !node.is_leaf() && self.component.tree_state().is_open(node) { + for child in node.children().iter().rev() { + stack.push_front(child.id()); + } + } + } + + ids + } + + fn open_current_node(&mut self) -> CmdResult { + let current_id = self.state().unwrap_one().unwrap_string(); + let node = self.component.tree().root().query(¤t_id).unwrap(); + match node.value().path_type { + PathType::Directory => { + if node.children().is_empty() { + // try loading children if there are none + let tree_tx = self.file_tree_tx.clone(); + tokio::spawn(async move { + tree_tx + .send(BackgroundTask::ListPaths(PathBuf::from(current_id))) + .await + .unwrap(); + }); + CmdResult::None + } else { + self.perform(Cmd::Custom(TREE_CMD_OPEN)) + } + } + PathType::File => CmdResult::None, + PathType::Link => CmdResult::None, + } + } + + fn close_current_node(&mut self) -> CmdResult { + let current_id = self.state().unwrap_one().unwrap_string(); + let node = self.component.tree().root().query(¤t_id).unwrap(); + if self.component.tree_state().is_closed(node) { + // current node is already closed, so we select and close the parent + if let Some(parent) = self.component.tree().root().parent(node.id()) { + self.attr( + Attribute::Custom(TREE_INITIAL_NODE), + AttrValue::String(parent.id().clone()), + ); + } + } + self.perform(Cmd::Custom(TREE_CMD_CLOSE)) + } + + fn mouse_select_row(&mut self, row: u16) -> CmdResult { + let mut list_index = (row - self.current_rect.y) as usize; + list_index = list_index.saturating_sub(1); + let render_area_h = self.current_rect.height as usize - 2; + // adjust for border + if list_index >= render_area_h { + list_index = render_area_h - 1; + } + + // the tree view auto-scrolls when selecting a node, we need to compensate for that in our + // selection. See `calc_rows_to_skip` in `TreeWidget` for where this comes from. + let nodes = self.node_list(); + let offset_max = nodes.len().saturating_sub(render_area_h); + let num_lines_to_show_at_top = render_area_h / 2; + let root = self.component.tree().root().clone(); + let prev = self.component.tree_state().selected().unwrap(); + let prev_index = nodes.iter().position(|n| n == &&prev.to_string()).unwrap() + 1; + let current_offset = prev_index.saturating_sub(num_lines_to_show_at_top).min(offset_max); + list_index += current_offset; + // current offset is how far the view is currently scrolled + + let selected = root.query(nodes[list_index]).unwrap(); + if prev != selected.id() { + self.attr( + Attribute::Custom(TREE_INITIAL_NODE), + AttrValue::String(selected.id().to_string()), + ); + } + if self.component.tree_state().is_open(selected) { + self.perform(Cmd::Custom(TREE_CMD_CLOSE)) + } else { + self.open_current_node() } } } +impl MockComponent for FileTree { + fn view(&mut self, frame: &mut Frame, area: Rect) { + self.current_rect = area; + self.component.view(frame, area); + } + fn query(&self, attr: Attribute) -> Option { + self.component.query(attr) + } + fn attr(&mut self, query: Attribute, attr: AttrValue) { + self.component.attr(query, attr) + } + fn state(&self) -> State { + self.component.state() + } + fn perform(&mut self, cmd: Cmd) -> CmdResult { + self.component.perform(cmd) + } +} impl Component for FileTree { fn on(&mut self, ev: Event) -> Option { match ev { Event::Keyboard(KeyEvent { code: Key::Left, modifiers: KeyModifiers::NONE, - }) => { - let current_id = self.state().unwrap_one().unwrap_string(); - let node = self.component.tree().root().query(¤t_id).unwrap(); - if self.component.tree_state().is_closed(node) { - // current node is already closed, so we select and close the parent - if let Some(parent) = self.component.tree().root().parent(node.id()) { - self.attr( - Attribute::Custom(TREE_INITIAL_NODE), - AttrValue::String(parent.id().clone()), - ); - } - } - self.perform(Cmd::Custom(TREE_CMD_CLOSE)) - } + }) => self.close_current_node(), Event::Keyboard(KeyEvent { code: Key::Right, modifiers: KeyModifiers::NONE, @@ -154,6 +253,25 @@ impl Component for FileTree { code: Key::End, modifiers: KeyModifiers::NONE, }) => self.perform(Cmd::GoTo(Position::End)), + + Event::Mouse(MouseEvent { + kind, column: col, row, .. + }) => { + if !self.current_rect.contains(RectPosition { x: col, y: row }) { + CmdResult::None + } else { + match kind { + MouseEventKind::Down(MouseButton::Left) => self.mouse_select_row(row), + MouseEventKind::Down(MouseButton::Right) => { + self.mouse_select_row(row); + return Some(Msg::Menu(MenuMsg::Opened)); + } + MouseEventKind::ScrollDown => self.perform(Cmd::Scroll(Direction::Down)), + MouseEventKind::ScrollUp => self.perform(Cmd::Scroll(Direction::Up)), + _ => CmdResult::None, + } + } + } Event::User(UserEvent::File(FileEvent::List(id, subpaths))) => { let tree = self.component.tree_mut(); let parent = tree.root_mut().query_mut(&id).unwrap(); diff --git a/coman/src/components/system_select_popup.rs b/coman/src/components/system_select_popup.rs index e80e606..0f6fbc7 100644 --- a/coman/src/components/system_select_popup.rs +++ b/coman/src/components/system_select_popup.rs @@ -1,9 +1,10 @@ use tui_realm_stdlib::List; use tuirealm::{ - Component, Event, MockComponent, State, StateValue, + AttrValue, Attribute, Component, Event, Frame, MockComponent, State, StateValue, command::{Cmd, CmdResult, Direction, Position}, - event::{Key, KeyEvent}, + event::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind}, props::{Alignment, BorderType, Borders, Color, TableBuilder, TextSpan}, + ratatui::layout::{Position as RectPosition, Rect}, }; use crate::{ @@ -14,10 +15,29 @@ use crate::{ cscs::api_client::types::System, }; -#[derive(MockComponent)] pub struct SystemSelectPopup { component: List, systems: Vec, + current_rect: Rect, +} + +impl MockComponent for SystemSelectPopup { + fn view(&mut self, frame: &mut Frame, area: Rect) { + self.current_rect = area; + self.component.view(frame, area); + } + fn query(&self, attr: Attribute) -> Option { + self.component.query(attr) + } + fn attr(&mut self, query: Attribute, attr: AttrValue) { + self.component.attr(query, attr) + } + fn state(&self) -> State { + self.component.state() + } + fn perform(&mut self, cmd: Cmd) -> CmdResult { + self.component.perform(cmd) + } } impl SystemSelectPopup { @@ -37,6 +57,18 @@ impl SystemSelectPopup { .step(4) .rows(rows.build()), systems, + current_rect: Rect::ZERO, + } + } + + fn select_entry(&mut self) -> Option { + if let State::One(StateValue::Usize(index)) = self.state() { + let selected_system = self.systems[index].clone(); + Some(Msg::SystemSelectPopup(SystemSelectMsg::SystemSelected( + selected_system.name, + ))) + } else { + Some(Msg::SystemSelectPopup(SystemSelectMsg::Closed)) } } } @@ -61,15 +93,33 @@ impl Component for SystemSelectPopup { return Some(Msg::SystemSelectPopup(SystemSelectMsg::Closed)); } Event::Keyboard(KeyEvent { code: Key::Enter, .. }) => { - let msg = if let State::One(StateValue::Usize(index)) = self.state() { - let selected_system = self.systems[index].clone(); - Some(Msg::SystemSelectPopup(SystemSelectMsg::SystemSelected( - selected_system.name, - ))) + return self.select_entry(); + } + Event::Mouse(MouseEvent { + kind, column: col, row, .. + }) => { + if !self.current_rect.contains(RectPosition { x: col, y: row }) { + CmdResult::None } else { - Some(Msg::SystemSelectPopup(SystemSelectMsg::Closed)) - }; - return msg; + let mut list_index = (row - self.current_rect.y) as usize; + list_index = list_index.saturating_sub(1); + if list_index >= self.component.states.list_len { + list_index = self.component.states.list_len; + } + + match kind { + MouseEventKind::Moved => { + self.component.states.list_index = list_index; + CmdResult::Changed(self.component.state()) + } + MouseEventKind::Down(MouseButton::Left) => { + return self.select_entry(); + } + MouseEventKind::ScrollUp => self.perform(Cmd::Move(Direction::Up)), + MouseEventKind::ScrollDown => self.perform(Cmd::Move(Direction::Down)), + _ => CmdResult::None, + } + } } _ => CmdResult::None, diff --git a/coman/src/components/workload_list.rs b/coman/src/components/workload_list.rs index 1e609ac..2ef3850 100644 --- a/coman/src/components/workload_list.rs +++ b/coman/src/components/workload_list.rs @@ -2,10 +2,11 @@ use std::cmp::Reverse; use tui_realm_stdlib::Table; use tuirealm::{ - AttrValue, Attribute, Component, Event, MockComponent, State, StateValue, + AttrValue, Attribute, Component, Event, Frame, MockComponent, State, StateValue, command::{Cmd, CmdResult, Direction, Position}, - event::{Key, KeyEvent, KeyModifiers}, + event::{Key, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, props::{Alignment, BorderType, Borders, Color, TableBuilder, TextSpan}, + ratatui::layout::{Position as RectPosition, Rect}, }; use crate::{ @@ -16,10 +17,10 @@ use crate::{ cscs::api_client::types::Job, }; -#[derive(MockComponent)] pub(crate) struct WorkloadList { component: Table, jobs: Vec, + current_rect: Rect, } impl Default for WorkloadList { @@ -35,9 +36,28 @@ impl Default for WorkloadList { .step(4) .headers(["Name", "Status", "Id", "Start", "End"]), jobs: vec![], + current_rect: Rect::ZERO, } } } +impl MockComponent for WorkloadList { + fn view(&mut self, frame: &mut Frame, area: Rect) { + self.current_rect = area; + self.component.view(frame, area); + } + fn query(&self, attr: Attribute) -> Option { + self.component.query(attr) + } + fn attr(&mut self, query: Attribute, attr: AttrValue) { + self.component.attr(query, attr) + } + fn state(&self) -> State { + self.component.state() + } + fn perform(&mut self, cmd: Cmd) -> CmdResult { + self.component.perform(cmd) + } +} impl Component for WorkloadList { fn on(&mut self, ev: tuirealm::Event) -> Option { @@ -111,6 +131,43 @@ impl Component for WorkloadList { } CmdResult::None } + Event::Mouse(MouseEvent { + kind, column: col, row, .. + }) => { + if !self.current_rect.contains(RectPosition { x: col, y: row }) { + CmdResult::None + } else { + let mut list_index = (row - self.current_rect.y) as usize; + list_index = list_index.saturating_sub(1); + if list_index >= self.component.states.list_len { + list_index = self.component.states.list_len; + } + + match kind { + MouseEventKind::Moved => { + self.component.states.list_index = list_index; + CmdResult::Changed(self.component.state()) + } + MouseEventKind::Down(MouseButton::Left) => { + if !self.jobs.is_empty() { + let job = self.jobs[list_index].clone(); + return Some(Msg::Job(JobMsg::Log(job.id))); + } + CmdResult::None + } + MouseEventKind::Down(MouseButton::Right) => { + if !self.jobs.is_empty() { + let job = self.jobs[list_index].clone(); + return Some(Msg::Job(JobMsg::GetDetails(job.id))); + } + CmdResult::None + } + MouseEventKind::ScrollUp => self.perform(Cmd::Move(Direction::Up)), + MouseEventKind::ScrollDown => self.perform(Cmd::Move(Direction::Down)), + _ => CmdResult::None, + } + } + } _ => CmdResult::None, }; Some(Msg::None) diff --git a/coman/src/main.rs b/coman/src/main.rs index 39f8b0f..0f5674a 100644 --- a/coman/src/main.rs +++ b/coman/src/main.rs @@ -8,7 +8,7 @@ use tokio::{runtime::Handle, sync::mpsc}; use tuirealm::{ Application, EventListenerCfg, PollStrategy, Sub, SubClause, SubEventClause, Update, event::{Key, KeyEvent, KeyModifiers}, - terminal::{CrosstermTerminalAdapter, TerminalBridge}, + terminal::{CrosstermTerminalAdapter, TerminalAdapter, TerminalBridge}, }; use crate::{ @@ -171,8 +171,10 @@ async fn main() -> Result<()> { fn run_tui(tick_rate: f64) -> Result<()> { crate::errors::init()?; //we initialize the terminal early so the panic handler that restores the terminal is correctly set up - let adapter = CrosstermTerminalAdapter::new()?; - let bridge = TerminalBridge::init(adapter).expect("Cannot initialize terminal"); + let mut adapter = CrosstermTerminalAdapter::new()?; + adapter.enable_mouse_capture()?; + let mut bridge = TerminalBridge::init(adapter).expect("Cannot initialize terminal"); + bridge.enable_mouse_capture()?; let handle = Handle::current(); let (select_system_tx, select_system_rx) = mpsc::channel(100);