diff --git a/Cargo.toml b/Cargo.toml index 59fce02..a26aed7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ parking_lot = {version= "0.12.0"} tokio = {version = "1.17.0", features=["full"]} tracing = "0.1.32" tracing-subscriber = "0.3.9" -tui = "0.17" +tui = "0.18" [dev-dependencies] diff --git a/README.md b/README.md index ae524e6..4610130 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,21 @@

-## Download +## Download & install See releases +install +```bash +INSTALL_DIR="${HOME}/.local/bin" +tar xzvf oxker_linux_x86_64.tar.gz oxker +install -Dm 755 oxker -t "$INSTALL_DIR" +rm oxker_linux_x86_64.tar.gz oxker +``` ## Run -```./oxker``` +```oxker``` available command line arguments | argument|result| @@ -56,7 +63,7 @@ requires docker & Self { Self { - // 7 to allow for 100.00% - cpu: (String::from("cpu"), 7), - image: (String::from("image"), 5), - name: (String::from("name"), 4), state: (String::from("state"), 11), status: (String::from("status"), 16), + // 7 to allow for "100.00%" + cpu: (String::from("cpu"), 7), mem: (String::from("memory/limit"), 12), + id: (String::from("id"), 8), + name: (String::from("name"), 4), + image: (String::from("image"), 5), net_rx: (String::from("↓ rx"), 5), net_tx: (String::from("↑ tx"), 5), } diff --git a/src/app_error.rs b/src/app_error.rs index ed28d6f..d6111cd 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -1,7 +1,5 @@ -use core::fmt; -use tracing::error; - use crate::app_data::DockerControls; +use core::fmt; /// app errors to set in global state #[allow(unused)] @@ -11,25 +9,10 @@ pub enum AppError { DockerInterval, InputPoll, DockerCommand(DockerControls), + MouseCapture(bool), Terminal, } -impl AppError { - /// for handling errors from terminal - pub fn disp(&self) { - match self { - Self::DockerConnect => error!("Unable to access docker daemon"), - Self::DockerInterval => error!("Docker update interval needs to be greater than 0"), - Self::InputPoll => error!("Unable to poll user input"), - Self::Terminal => error!("Unable to draw to terminal"), - Self::DockerCommand(s) => { - let error = format!("Unable to {} container", s); - error!(%error); - } - } - } -} - /// Convert errors into strings to display impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -39,6 +22,10 @@ impl fmt::Display for AppError { Self::InputPoll => "Unable to poll user input".to_owned(), Self::Terminal => "Unable to draw to terminal".to_owned(), Self::DockerCommand(s) => format!("Unable to {} container", s), + Self::MouseCapture(x) => { + let reason = if *x { "en" } else { "dis" }; + format!("Unable to {}able mouse capture", reason) + } }; write!(f, "{}", disp) } diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 0ab2a94..e6364c3 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -220,7 +220,7 @@ impl DockerData { docker, gui_state, initialised: false, - sleep_duration: Duration::from_millis(args.docker as u64), + sleep_duration: Duration::from_millis(args.docker_interval as u64), timestamps: args.timestamp, }; inner.initialise_container_data().await; diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index 3e14fb5..9baadc7 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -4,9 +4,14 @@ use std::sync::{ }; use bollard::{container::StartContainerOptions, Docker}; -use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, KeyCode, MouseButton, MouseEvent, MouseEventKind, + }, + execute, +}; use parking_lot::Mutex; -use tokio::sync::broadcast::Receiver; +use tokio::{sync::broadcast::Receiver, task::JoinHandle}; use tui::layout::Rect; mod message; @@ -25,6 +30,8 @@ pub struct InputHandler { gui_state: Arc>, is_running: Arc, rec: Receiver, + mouse_capture: bool, + info_sleep: Option>, } impl InputHandler { @@ -42,6 +49,8 @@ impl InputHandler { gui_state, is_running, rec, + mouse_capture: true, + info_sleep: None, }; inner.start().await; } @@ -65,10 +74,46 @@ impl InputHandler { } } + fn m_button(&mut self) { + if self.mouse_capture { + match execute!(std::io::stdout(), DisableMouseCapture) { + Ok(_) => self + .gui_state + .lock() + .set_info_box("✖ mouse capture disabled".to_owned()), + Err(_) => self + .app_data + .lock() + .set_error(AppError::MouseCapture(false)), + } + } else { + match execute!(std::io::stdout(), EnableMouseCapture) { + Ok(_) => self + .gui_state + .lock() + .set_info_box("✓ mouse capture enabled".to_owned()), + Err(_) => self.app_data.lock().set_error(AppError::MouseCapture(true)), + } + }; + + let gui_state = Arc::clone(&self.gui_state); + + if self.info_sleep.is_some() { + self.info_sleep.as_ref().unwrap().abort() + } + self.info_sleep = Some(tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(4000)).await; + gui_state.lock().reset_info_box() + })); + + self.mouse_capture = !self.mouse_capture; + } + /// Handle any keyboard button events async fn button_press(&mut self, key_code: KeyCode) { let show_error = self.app_data.lock().show_error; let show_info = self.gui_state.lock().show_help; + if show_error { match key_code { KeyCode::Char('q') => { @@ -82,22 +127,16 @@ impl InputHandler { } } else if show_info { match key_code { - KeyCode::Char('q') => { - self.is_running.store(false, Ordering::SeqCst); - } - KeyCode::Char('h') => { - self.gui_state.lock().show_help = false; - } + KeyCode::Char('q') => self.is_running.store(false, Ordering::SeqCst), + KeyCode::Char('h') => self.gui_state.lock().show_help = false, + KeyCode::Char('m') => self.m_button(), _ => (), } } else { match key_code { - KeyCode::Char('q') => { - self.is_running.store(false, Ordering::SeqCst); - } - KeyCode::Char('h') => { - self.gui_state.lock().show_help = true; - } + KeyCode::Char('q') => self.is_running.store(false, Ordering::SeqCst), + KeyCode::Char('h') => self.gui_state.lock().show_help = true, + KeyCode::Char('m') => self.m_button(), KeyCode::Tab => self.gui_state.lock().next_panel(), KeyCode::BackTab => self.gui_state.lock().previous_panel(), KeyCode::Home => { diff --git a/src/main.rs b/src/main.rs index 97a3e50..112118a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![allow(unused)] + use app_data::AppData; use app_error::AppError; use bollard::Docker; diff --git a/src/parse_args/mod.rs b/src/parse_args/mod.rs index 351917a..524da85 100644 --- a/src/parse_args/mod.rs +++ b/src/parse_args/mod.rs @@ -8,13 +8,16 @@ use tracing::error; pub struct CliArgs { /// Docker update interval in ms, minimum 1, reccomended 500+ - #[clap(short = 'd', default_value_t = 1000)] - pub docker: u32, + #[clap(short = 'd', value_name = "ms", default_value_t = 1000)] + pub docker_interval: u32, /// Don't draw gui - for debugging - mostly pointless #[clap(short = 'g')] pub gui: bool, + // /// Install to ./local/bin + // #[clap(short = 'i')] + // pub install: bool, /// Remove timestamps from Docker logs #[clap(short = 't')] pub timestamp: bool, @@ -35,15 +38,16 @@ impl CliArgs { // Quit the program if the docker update argument is 0 // Should maybe change it to check if less than 100 - if args.docker == 0 { + if args.docker_interval == 0 { error!("docker args needs to be greater than 0"); process::exit(1) } Self { color: args.color, - docker: args.docker, + docker_interval: args.docker_interval, gui: !args.gui, raw: args.raw, + // install: args.install, timestamp: !args.timestamp, } } diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index f3ac6c9..4d71af0 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -19,6 +19,7 @@ use crate::{ app_error::AppError, }; +use super::gui_state::BoxLocation; use super::{GuiState, SelectablePanel}; const NAME_TEXT: &str = r#" @@ -34,17 +35,20 @@ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 const NAME: &str = env!("CARGO_PKG_NAME"); const VERSION: &str = env!("CARGO_PKG_VERSION"); const REPO: &str = env!("CARGO_PKG_REPOSITORY"); +const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); const ORANGE: Color = Color::Rgb(255, 178, 36); const MARGIN: &str = " "; -/// Generate block, add a bored if is the selected panel, +/// Generate block, add a border if is the selected panel, /// add custom title based on state of each panel fn generate_block<'a>( selectable_panel: Option, app_data: &Arc>, selected_panel: &SelectablePanel, ) -> Block<'a> { - let mut block = Block::default().borders(Borders::ALL); + let mut block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded); if let Some(panel) = selectable_panel { let title = match panel { @@ -62,11 +66,7 @@ fn generate_block<'a>( }; block = block.title(title); if selected_panel == &panel { - let selected_style = Style::default().fg(Color::LightCyan); - let selected_border = BorderType::Plain; - block = block - .border_style(selected_style) - .border_type(selected_border); + block = block.border_style(Style::default().fg(Color::LightCyan)); } } block @@ -170,6 +170,15 @@ pub fn draw_containers( format!("{}{:>width$}", MARGIN, mems, width = widths.mem.1), state_style, ), + Span::styled( + format!( + "{}{:>width$}", + MARGIN, + i.id.chars().take(8).collect::(), + width = widths.id.1 + ), + blue, + ), Span::styled( format!("{}{:>width$}", MARGIN, i.name, width = widths.name.1), blue, @@ -330,7 +339,7 @@ fn make_chart( .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_type(BorderType::Plain), + .border_type(BorderType::Rounded), ) .x_axis( Axis::default() @@ -348,13 +357,12 @@ fn make_chart( .fg(label_color), ), ]) - // add 0.01, for cases when the value is 0 .bounds([0.0, max.get_value() + 0.01]), ) } /// Show error popup over whole screen -pub fn draw_info_bar( +pub fn draw_heading_bar( area: Rect, columns: &Columns, f: &mut Frame<'_, B>, @@ -379,6 +387,8 @@ pub fn draw_info_bar( .push_str(format!("{}{:>width$}", MARGIN, columns.cpu.0, width = columns.cpu.1).as_str()); column_headings .push_str(format!("{}{:>width$}", MARGIN, columns.mem.0, width = columns.mem.1).as_str()); + column_headings + .push_str(format!("{}{:>width$}", MARGIN, columns.id.0, width = columns.id.1).as_str()); column_headings.push_str( format!( "{}{:>width$}", @@ -455,18 +465,21 @@ pub fn draw_info_bar( pub fn draw_help_box(f: &mut Frame<'_, B>) { let title = format!(" {} ", VERSION); - let mut description_text = - String::from("\n A basic docker container information viewer and controller."); - description_text.push_str("\n Tab or Alt+Tab to change panels, arrows to change lines, enter to send docker container commands."); - description_text.push_str("\n Mouse input also available."); - description_text.push_str("\n ( q ) to quit at any time."); - description_text - .push_str("\n\n currenty an early work in progress, all and any input appreciated"); - description_text.push_str(format!("\n {}", REPO.trim()).as_str()); + let description_text = format!("\n{}", DESCRIPTION); + + let mut help_text = String::from("\n ( tab ) or ( alt+tab ) to change panels"); + help_text.push_str("\n ( ↑ ↓ ← → ) to change selected line"); + help_text.push_str("\n ( enter ) to send docker container commands"); + help_text.push_str("\n ( h ) to toggle this help information"); + help_text.push_str("\n ( m ) to toggle mouse capture - if disabled, text on screen can be selected & copied"); + help_text.push_str("\n ( q ) to quit at any time"); + help_text.push_str("\n mouse scrolling & clicking also available"); + help_text.push_str("\n\n currenty an early work in progress, all and any input appreciated"); + help_text.push_str(format!("\n {}", REPO.trim()).as_str()); let mut max_line_width = 0; - let all_text = format!("{}{}", NAME_TEXT, description_text); + let all_text = format!("{}{}{}", NAME_TEXT, description_text, help_text); all_text.lines().into_iter().for_each(|line| { let width = line.chars().count(); @@ -486,7 +499,12 @@ pub fn draw_help_box(f: &mut Frame<'_, B>) { .block(Block::default()) .alignment(Alignment::Center); - let description_paragraph = Paragraph::new(description_text.as_str()) + let description_paragrpah = Paragraph::new(description_text.as_str()) + .style(Style::default().bg(Color::Magenta).fg(Color::Black)) + .block(Block::default()) + .alignment(Alignment::Center); + + let help_paragraph = Paragraph::new(help_text.as_str()) .style(Style::default().bg(Color::Magenta).fg(Color::Black)) .block(Block::default()) .alignment(Alignment::Left); @@ -497,7 +515,12 @@ pub fn draw_help_box(f: &mut Frame<'_, B>) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Black)); - let area = centered_info(lines as u16, max_line_width as u16, f.size()); + let area = draw_popup( + lines as u16, + max_line_width as u16, + f.size(), + BoxLocation::MiddleCentre, + ); let split_popup = Layout::default() .direction(Direction::Vertical) @@ -505,6 +528,7 @@ pub fn draw_help_box(f: &mut Frame<'_, B>) { [ Constraint::Max(NAME_TEXT.lines().count() as u16), Constraint::Max(description_text.lines().count() as u16), + Constraint::Max(help_text.lines().count() as u16), ] .as_ref(), ) @@ -513,7 +537,8 @@ pub fn draw_help_box(f: &mut Frame<'_, B>) { // Order is important here f.render_widget(Clear, area); f.render_widget(name_paragraph, split_popup[0]); - f.render_widget(description_paragraph, split_popup[1]); + f.render_widget(description_paragrpah, split_popup[1]); + f.render_widget(help_paragraph, split_popup[2]); f.render_widget(block, area); } @@ -560,38 +585,80 @@ pub fn draw_error(f: &mut Frame<'_, B>, error: AppError, seconds: Op .block(block) .alignment(Alignment::Center); - let area = centered_info(lines as u16, max_line_width as u16, f.size()); + let area = draw_popup( + lines as u16, + max_line_width as u16, + f.size(), + BoxLocation::MiddleCentre, + ); + f.render_widget(Clear, area); + f.render_widget(paragraph, area); +} + +/// Show info box in bottom right corner +pub fn draw_info(f: &mut Frame<'_, B>, text: String) { + let block = Block::default() + .title("") + .title_alignment(Alignment::Center) + .borders(Borders::NONE); + + + let mut max_line_width = 0; + text.lines().into_iter().for_each(|line| { + let width = line.chars().count(); + if width > max_line_width { + max_line_width = width; + } + }); + + let mut lines = text.lines().count(); + + // Add some horizontal & vertical margins + max_line_width += 8; + lines += 2; + + let paragraph = Paragraph::new(text) + .style(Style::default().bg(Color::Blue).fg(Color::White)) + .block(block) + .alignment(Alignment::Center); + + let area = draw_popup( + lines as u16, + max_line_width as u16, + f.size(), + BoxLocation::BottomRight, + ); f.render_widget(Clear, area); f.render_widget(paragraph, area); } /// draw a box in the center of the screen, based on max line width + number of lines -fn centered_info(number_lines: u16, max_line_width: u16, r: Rect) -> Rect { - // This can panic if number_lines or max_line_width is larger than r.height or r.width - let blank_vertical = (r.height - number_lines) / 2; - let blank_horizontal = (r.width - max_line_width) / 2; +fn draw_popup(text_lines: u16, text_width: u16, r: Rect, box_location: BoxLocation) -> Rect { + // Make sure blank_space can't be an negative, as will crash + let blank_vertical = if r.height > text_lines { + (r.height - text_lines) / 2 + } else { + 1 + }; + let blank_horizontal = if r.width > text_width { + (r.width - text_width) / 2 + } else { + 1 + }; + + let vertical_constraints = box_location.get_vertical_constraints(blank_vertical, text_lines); + let horizontal_constraints = + box_location.get_horizontal_constraints(blank_horizontal, text_width); + + let indexes = box_location.get_indexes(); let popup_layout = Layout::default() .direction(Direction::Vertical) - .constraints( - [ - Constraint::Max(blank_vertical), - Constraint::Max(number_lines), - Constraint::Max(blank_vertical), - ] - .as_ref(), - ) + .constraints(vertical_constraints) .split(r); Layout::default() .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Max(blank_horizontal), - Constraint::Max(max_line_width), - Constraint::Max(blank_horizontal), - ] - .as_ref(), - ) - .split(popup_layout[1])[1] + .constraints(horizontal_constraints) + .split(popup_layout[indexes.0])[indexes.1] } diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index eb53924..72ef238 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -1,5 +1,5 @@ use std::{collections::HashMap, fmt}; -use tui::layout::Rect; +use tui::layout::{Constraint, Rect}; #[derive(Debug, PartialEq, std::hash::Hash, std::cmp::Eq, Clone, Copy)] pub enum SelectablePanel { @@ -7,7 +7,85 @@ pub enum SelectablePanel { Commands, Logs, } -#[derive(Debug)] + +#[derive(Debug, Clone, Copy)] +pub enum BoxLocation { + TopLeft, + TopCentre, + TopRight, + MiddleLeft, + MiddleCentre, + MiddleRight, + BottomLeft, + BottomCentre, + BottomRight, +} + +impl BoxLocation { + pub fn get_indexes(&self) -> (usize, usize) { + match self { + Self::TopLeft => (0, 0), + Self::TopCentre => (0, 1), + Self::TopRight => (0, 2), + Self::MiddleLeft => (1, 0), + Self::MiddleCentre => (1, 1), + Self::MiddleRight => (1, 2), + Self::BottomLeft => (2, 0), + Self::BottomCentre => (2, 1), + Self::BottomRight => (2, 2), + } + } + + // Should combine and just return a tupple? + pub fn get_horizontal_constraints( + &self, + blank_vertical: u16, + text_width: u16, + ) -> [Constraint; 3] { + match self { + Self::TopLeft | Self::MiddleLeft | Self::BottomLeft => [ + Constraint::Max(text_width), + Constraint::Max(blank_vertical), + Constraint::Max(blank_vertical), + ], + Self::TopCentre | Self::MiddleCentre | Self::BottomCentre => [ + Constraint::Max(blank_vertical), + Constraint::Max(text_width), + Constraint::Max(blank_vertical), + ], + Self::TopRight | Self::MiddleRight | Self::BottomRight => [ + Constraint::Max(blank_vertical), + Constraint::Max(blank_vertical), + Constraint::Max(text_width), + ], + } + } + pub fn get_vertical_constraints( + &self, + blank_vertical: u16, + number_lines: u16, + ) -> [Constraint; 3] { + match self { + Self::TopLeft | Self::TopCentre | Self::TopRight => [ + Constraint::Max(number_lines), + Constraint::Max(blank_vertical), + Constraint::Max(blank_vertical), + ], + Self::MiddleLeft | Self::MiddleCentre | Self::MiddleRight => [ + Constraint::Max(blank_vertical), + Constraint::Max(number_lines), + Constraint::Max(blank_vertical), + ], + Self::BottomLeft | Self::BottomCentre | Self::BottomRight => [ + Constraint::Max(blank_vertical), + Constraint::Max(blank_vertical), + Constraint::Max(number_lines), + ], + } + } +} + +#[derive(Debug, Clone)] pub enum Loading { One, Two, @@ -34,20 +112,9 @@ impl Loading { Self::Eight => Self::Nine, Self::Nine => Self::Ten, Self::Ten => Self::One, - // Self::Five => Self::One } } } -// "⠋", -// "⠙", -// "⠹", -// "⠸", -// "⠼", -// "⠴", -// "⠦", -// "⠧", -// "⠇", -// "⠏" impl fmt::Display for Loading { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -92,7 +159,7 @@ impl SelectablePanel { } /// Global gui_state, stored in an Arc -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GuiState { // Think this should be a BMapTree, so can define order when iterating over potential intersects // Is an issue if two panels are in the same space, sush as a smaller panel embedded, yet infront of, a larger panel @@ -101,6 +168,8 @@ pub struct GuiState { loading: Loading, pub selected_panel: SelectablePanel, pub show_help: bool, + // show_info_panel: bool, + pub info_box_text: Option, } impl GuiState { @@ -111,6 +180,8 @@ impl GuiState { loading: Loading::One, selected_panel: SelectablePanel::Containers, show_help: false, + // show_info_panel: false, + info_box_text: None, } } @@ -158,4 +229,18 @@ impl GuiState { pub fn reset_loading(&mut self) { self.loading = Loading::One; } + + pub fn set_info_box(&mut self, text: String) { + self.info_box_text = Some(text); + // self.show_info_panel = true; + + // Should spawn and after 10 seconds close? + // Need to copy whatever we're doing with parsing logs icon + } + + pub fn reset_info_box(&mut self) { + // self.loading = Loading::One; + self.info_box_text = None; + // self.show_info_panel = false; + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 002175a..7027a5a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,6 +11,7 @@ use std::{ sync::{atomic::Ordering, Arc}, }; use tokio::sync::broadcast::Sender; +use tracing::error; use tui::{ backend::{Backend, CrosstermBackend}, layout::{Constraint, Direction, Layout}, @@ -50,7 +51,7 @@ pub async fn create_ui( terminal.show_cursor().unwrap(); if let Err(err) = res { - err.disp() + error!(%err); } Ok(()) } @@ -126,6 +127,7 @@ fn ui( let log_index = app_data.lock().get_selected_log_index(); let selected_panel = gui_state.lock().selected_panel; let show_help = gui_state.lock().show_help; + let info_text = gui_state.lock().info_box_text.clone(); let whole_layout = Layout::default() .direction(Direction::Vertical) @@ -190,7 +192,7 @@ fn ui( &selected_panel, ); - draw_info_bar( + draw_heading_bar( whole_layout[0], &column_widths, f, @@ -203,6 +205,10 @@ fn ui( draw_chart(f, lower_main[1], app_data, log_index); } + if let Some(info) = info_text { + draw_info(f, info); + } + // Check if error, and show popup if so if show_help { draw_help_box(f);