diff --git a/.github/release-body.md b/.github/release-body.md index 4d77784..b689008 100644 --- a/.github/release-body.md +++ b/.github/release-body.md @@ -1,5 +1,15 @@ -### 2022-04-25 +### 2022-04-29 + +### Features ++ allow toggling of mouse caputre, to select & copy text with mouse, closes [#2], [aec184ea22b289e91942a4c3e6a415685884bc47] ++ show id column, [b10f927481c9e38a48c1d4b94e744ec48e8b6ba6] ++ draw_popup, using enum to draw in one of 9 areas, closes [#6], [1017850a6cc91328abc1127bdb117495f5e909d8] ++ use a message rx/sx for all docker commands, remove update loop, wait for update message from gui instead, [9b70fdfad7b38361ebee301bdc2545d3f0dfcf9e] + +### Fixes ++ readme.md typo, [589501f9a4a0bfabdb0654e68cc0c752c529d97a] ++ column heading mem > memory, [5e8e6b590b06f01a542fdd10bae8f14d303ab08a] ++ cargo fmt added to create_release.sh, [bb29c0ebfafd6a9a036eb317a240954d1405966e] -+ init commit see CHANGELOG.md for more details diff --git a/.github/screenshot_01.jpg b/.github/screenshot_01.jpg index fd08e93..44ece7b 100644 Binary files a/.github/screenshot_01.jpg and b/.github/screenshot_01.jpg differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a3ffc..9e484f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ +# v0.0.2 +### 2022-04-29 + +### Features ++ allow toggling of mouse caputre, to select & copy text with mouse, closes [#2], [aec184ea22b289e91942a4c3e6a415685884bc47](https://github.com/mrjackwills/oxker/commit/aec184ea22b289e91942a4c3e6a415685884bc47), ++ show id column, [b10f927481c9e38a48c1d4b94e744ec48e8b6ba6](https://github.com/mrjackwills/oxker/commit/b10f927481c9e38a48c1d4b94e744ec48e8b6ba6), ++ draw_popup, using enum to draw in one of 9 areas, closes [#6], [1017850a6cc91328abc1127bdb117495f5e909d8](https://github.com/mrjackwills/oxker/commit/1017850a6cc91328abc1127bdb117495f5e909d8), ++ use a message rx/sx for all docker commands, remove update loop, wait for update message from gui instead, [9b70fdfad7b38361ebee301bdc2545d3f0dfcf9e](https://github.com/mrjackwills/oxker/commit/9b70fdfad7b38361ebee301bdc2545d3f0dfcf9e), + ### Fixes -+ readme.md typo, [589501f9a4a0bfabdb0654e68cc0c752c529d97a] -+ column heading mem > memory, [5e8e6b590b06f01a542fdd10bae8f14d303ab08a] -+ cargo fmt added to create_release.sh, [bb29c0ebfafd6a9a036eb317a240954d1405966e] ++ readme.md typo, [589501f9a4a0bfabdb0654e68cc0c752c529d97a](https://github.com/mrjackwills/oxker/commit/589501f9a4a0bfabdb0654e68cc0c752c529d97a), ++ column heading mem > memory, [5e8e6b590b06f01a542fdd10bae8f14d303ab08a](https://github.com/mrjackwills/oxker/commit/5e8e6b590b06f01a542fdd10bae8f14d303ab08a), ++ cargo fmt added to create_release.sh, [bb29c0ebfafd6a9a036eb317a240954d1405966e](https://github.com/mrjackwills/oxker/commit/bb29c0ebfafd6a9a036eb317a240954d1405966e), # v0.0.1 ### 2022-04-25 diff --git a/Cargo.toml b/Cargo.toml index 59fce02..e69da58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxker" -version = "0.0.1" +version = "0.0.2" edition = "2021" authors = ["Jack Wills "] description = "a simple tui to view & control docker containers" @@ -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..8035554 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,20 @@

-## Download +## Download & install See releases +install +```bash +tar xzvf oxker_linux_x86_64.tar.gz oxker +install -Dm 755 oxker -t "${HOME}/.local/bin" +rm oxker_linux_x86_64.tar.gz oxker +``` ## Run -```./oxker``` +```oxker``` available command line arguments | argument|result| @@ -56,7 +62,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_data/mod.rs b/src/app_data/mod.rs index e6ede5e..06dc806 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -304,6 +304,7 @@ impl AppData { if self.containers.state.selected().is_some() { self.containers.previous(); } + // docker rm -f $(docker ps -aq) will cause this to crash self.containers.items.remove(index); } } 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/message.rs b/src/docker_data/message.rs new file mode 100644 index 0000000..3d008f2 --- /dev/null +++ b/src/docker_data/message.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone)] +pub enum DockerMessage { + Update, + Start(String), + Restart(String), + Pause(String), + Unpause(String), + Stop(String), +} diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 0ab2a94..71375ec 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -1,22 +1,27 @@ use bollard::{ - container::{ListContainersOptions, LogsOptions, Stats, StatsOptions}, + container::{ListContainersOptions, LogsOptions, StartContainerOptions, Stats, StatsOptions}, Docker, }; use futures_util::{future::join_all, StreamExt}; use parking_lot::Mutex; -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; +use std::sync::Arc; +use tokio::{sync::mpsc::Receiver, task::JoinHandle}; -use crate::{app_data::AppData, parse_args::CliArgs, ui::GuiState}; +use crate::{ + app_data::{AppData, DockerControls}, + app_error::AppError, + parse_args::CliArgs, + ui::GuiState, +}; +mod message; +pub use message::DockerMessage; pub struct DockerData { app_data: Arc>, docker: Arc, gui_state: Arc>, initialised: bool, - sleep_duration: Duration, + receiver: Receiver, timestamps: bool, } @@ -178,8 +183,7 @@ impl DockerData { } /// Update all logs, spawn each container into own tokio::spawn thread - // rename init all logs, as only gets run once - async fn update_all_logs(&mut self, all_ids: &[(bool, String)]) { + async fn init_all_logs(&mut self, all_ids: &[(bool, String)]) { let mut handles = vec![]; for (_, id) in all_ids.iter() { @@ -207,43 +211,32 @@ impl DockerData { self.update_all_container_stats(&all_ids).await; } - /// Initialise self, and start the updated loop - pub async fn init( - args: CliArgs, - app_data: Arc>, - docker: Arc, - gui_state: Arc>, - ) { - if app_data.lock().get_error().is_none() { - let mut inner = Self { - app_data, - docker, - gui_state, - initialised: false, - sleep_duration: Duration::from_millis(args.docker as u64), - timestamps: args.timestamp, - }; - inner.initialise_container_data().await; - inner.update_loop().await; - } - } - - async fn initialise_container_data(&mut self) { + /// Animate the loading icon + async fn loading_spin(&mut self) -> JoinHandle<()> { let gui_state = Arc::clone(&self.gui_state); - // could also just loop while init is false, would need to move an arc mutex into here - // so instead just abort at end of function - let loading_spin = tokio::spawn(async move { + tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_millis(100)).await; gui_state.lock().next_loading(); } - }); + }) + } + + /// Stop the loading_spin fn, and reset gui loading status + fn stop_loading_spin(&mut self, handle: JoinHandle<()>) { + handle.abort(); + self.gui_state.lock().reset_loading(); + } + + // Initialize docker container data, before any messages are received + async fn initialise_container_data(&mut self) { + let loading_spin = self.loading_spin().await; let all_ids = self.update_all_containers().await; self.update_all_container_stats(&all_ids).await; // Maybe only do a single one at first? - self.update_all_logs(&all_ids).await; + self.init_all_logs(&all_ids).await; if all_ids.is_empty() { self.initialised = true; @@ -255,23 +248,94 @@ impl DockerData { self.initialised = self.app_data.lock().initialised(&all_ids); } self.app_data.lock().init = true; - loading_spin.abort(); - self.gui_state.lock().reset_loading(); + self.stop_loading_spin(loading_spin); } - /// Update all items, wait until all complete - /// sleep for CliArgs.docker ms before updating next - async fn update_loop(&mut self) { - loop { - let start = Instant::now(); - self.update_everything().await; - - let elapsed = start.elapsed(); - if elapsed < self.sleep_duration { - tokio::time::sleep(self.sleep_duration - elapsed).await; + /// Handle incoming messages, container controls & all container information update + async fn message_handler(&mut self) { + while let Some(message) = self.receiver.recv().await { + let docker = Arc::clone(&self.docker); + let app_data = Arc::clone(&self.app_data); + match message { + DockerMessage::Pause(id) => { + let loading_spin = self.loading_spin().await; + docker.pause_container(&id).await.unwrap_or_else(|_| { + app_data + .lock() + .set_error(AppError::DockerCommand(DockerControls::Pause)) + }); + self.stop_loading_spin(loading_spin); + } + DockerMessage::Restart(id) => { + let loading_spin = self.loading_spin().await; + docker + .restart_container(&id, None) + .await + .unwrap_or_else(|_| { + app_data + .lock() + .set_error(AppError::DockerCommand(DockerControls::Restart)) + }); + self.stop_loading_spin(loading_spin); + } + DockerMessage::Start(id) => { + let loading_spin = self.loading_spin().await; + docker + .start_container(&id, None::>) + .await + .unwrap_or_else(|_| { + app_data + .lock() + .set_error(AppError::DockerCommand(DockerControls::Start)) + }); + self.stop_loading_spin(loading_spin); + } + DockerMessage::Stop(id) => { + let loading_spin = self.loading_spin().await; + docker.stop_container(&id, None).await.unwrap_or_else(|_| { + app_data + .lock() + .set_error(AppError::DockerCommand(DockerControls::Stop)) + }); + self.stop_loading_spin(loading_spin); + } + DockerMessage::Unpause(id) => { + let loading_spin = self.loading_spin().await; + docker.unpause_container(&id).await.unwrap_or_else(|_| { + app_data + .lock() + .set_error(AppError::DockerCommand(DockerControls::Unpause)) + }); + self.stop_loading_spin(loading_spin); + self.update_everything().await + } + DockerMessage::Update => self.update_everything().await, } } } + + /// Initialise self, and start the message receiving loop + pub async fn init( + args: CliArgs, + app_data: Arc>, + docker: Arc, + gui_state: Arc>, + receiver: Receiver, + ) { + if app_data.lock().get_error().is_none() { + let mut inner = Self { + app_data, + docker, + gui_state, + initialised: false, + receiver, + timestamps: args.timestamp, + }; + inner.initialise_container_data().await; + + inner.message_handler().await; + } + } } // tests, use redis-test container, check logs exists, and selector of logs, and that it increases, and matches end, when you run restart on the docker containers diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index 3e14fb5..0dfead7 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -3,16 +3,24 @@ use std::sync::{ Arc, }; -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::mpsc::{Receiver, Sender}, + task::JoinHandle, +}; use tui::layout::Rect; mod message; use crate::{ app_data::{AppData, DockerControls}, app_error::AppError, + docker_data::DockerMessage, ui::{GuiState, SelectablePanel}, }; pub use message::InputMessages; @@ -21,9 +29,11 @@ pub use message::InputMessages; #[derive(Debug)] pub struct InputHandler { app_data: Arc>, - docker: Arc, + docker_sender: Sender, gui_state: Arc>, + info_sleep: Option>, is_running: Arc, + mouse_capture: bool, rec: Receiver, } @@ -32,23 +42,25 @@ impl InputHandler { pub async fn init( app_data: Arc>, rec: Receiver, - docker: Arc, + docker_sender: Sender, gui_state: Arc>, is_running: Arc, ) { let mut inner = Self { app_data, - docker, + docker_sender, gui_state, is_running, rec, + mouse_capture: true, + info_sleep: None, }; inner.start().await; } /// check for incoming messages async fn start(&mut self) { - while let Ok(message) = self.rec.recv().await { + while let Some(message) = self.rec.recv().await { match message { InputMessages::ButtonPress(key_code) => self.button_press(key_code).await, InputMessages::MouseEvent(mouse_event) => { @@ -65,10 +77,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 +130,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 => { @@ -129,90 +171,44 @@ impl InputHandler { } } KeyCode::Enter => { - // Does is matter though? // This isn't great, just means you can't send docker commands before full initialization of the program // could change to to if loading = true, although at the moment don't have a loading bool + // Does is matter though? let panel = self.gui_state.lock().selected_panel; if panel == SelectablePanel::Commands { let command = self.app_data.lock().get_docker_command(); if command.is_some() { let id = self.app_data.lock().get_selected_container_id(); - let app_data = Arc::clone(&self.app_data); - let docker = Arc::clone(&self.docker); if id.is_some() { let id = id.unwrap(); match command.unwrap() { - DockerControls::Pause => { - tokio::spawn(async move { - docker.pause_container(&id).await.unwrap_or_else( - |_| { - app_data.lock().set_error( - AppError::DockerCommand( - DockerControls::Pause, - ), - ) - }, - ); - }); - } - DockerControls::Unpause => { - tokio::spawn(async move { - docker.unpause_container(&id).await.unwrap_or_else( - |_| { - app_data.lock().set_error( - AppError::DockerCommand( - DockerControls::Unpause, - ), - ) - }, - ); - }); - } - DockerControls::Start => { - tokio::spawn(async move { - docker - .start_container( - &id, - None::>, - ) - .await - .unwrap_or_else(|_| { - app_data.lock().set_error( - AppError::DockerCommand( - DockerControls::Start, - ), - ) - }); - }); - } - DockerControls::Stop => { - tokio::spawn(async move { - docker.stop_container(&id, None).await.unwrap_or_else( - |_| { - app_data.lock().set_error( - AppError::DockerCommand( - DockerControls::Stop, - ), - ) - }, - ); - }); - } - DockerControls::Restart => { - tokio::spawn(async move { - docker - .restart_container(&id, None) - .await - .unwrap_or_else(|_| { - app_data.lock().set_error( - AppError::DockerCommand( - DockerControls::Restart, - ), - ) - }); - }); - } + // TODO handle theses errors? + DockerControls::Pause => self + .docker_sender + .send(DockerMessage::Pause(id)) + .await + .unwrap(), + DockerControls::Unpause => self + .docker_sender + .send(DockerMessage::Unpause(id)) + .await + .unwrap(), + DockerControls::Start => self + .docker_sender + .send(DockerMessage::Start(id)) + .await + .unwrap(), + DockerControls::Stop => self + .docker_sender + .send(DockerMessage::Stop(id)) + .await + .unwrap(), + DockerControls::Restart => self + .docker_sender + .send(DockerMessage::Restart(id)) + .await + .unwrap(), } } } diff --git a/src/main.rs b/src/main.rs index 97a3e50..2b38183 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,13 +31,21 @@ async fn main() { let docker_app_data = Arc::clone(&app_data); let docker_gui_state = Arc::clone(&gui_state); + let (docker_sx, docker_rx) = tokio::sync::mpsc::channel(16); // Create docker daemon handler, and only spawn up the docker data handler if ping returns non-error let docker = Arc::new(Docker::connect_with_socket_defaults().unwrap()); match docker.ping().await { Ok(_) => { let docker = Arc::clone(&docker); tokio::spawn(async move { - DockerData::init(docker_args, docker_app_data, docker, docker_gui_state).await; + DockerData::init( + docker_args, + docker_app_data, + docker, + docker_gui_state, + docker_rx, + ) + .await; }); } Err(_) => app_data.lock().set_error(AppError::DockerConnect), @@ -45,19 +53,20 @@ async fn main() { let input_app_data = Arc::clone(&app_data); - let (s, r) = tokio::sync::broadcast::channel(16); + let (input_sx, input_rx) = tokio::sync::mpsc::channel(16); - let input_docker = Arc::clone(&docker); + // let input_docker = Arc::clone(&docker); let is_running = Arc::new(AtomicBool::new(true)); let input_is_running = Arc::clone(&is_running); let input_gui_state = Arc::clone(&gui_state); + let input_docker_sender = docker_sx.clone(); // Spawn input handling into own tokio thread tokio::spawn(async { input_handler::InputHandler::init( input_app_data, - r, - input_docker, + input_rx, + input_docker_sender, input_gui_state, input_is_running, ) @@ -71,6 +80,16 @@ async fn main() { tokio::time::sleep(std::time::Duration::from_millis(5000)).await; } } else { - create_ui(app_data, s, is_running, gui_state).await.unwrap(); + let update_duration = std::time::Duration::from_millis(args.docker_interval as u64); + create_ui( + app_data, + input_sx, + is_running, + gui_state, + docker_sx, + update_duration, + ) + .await + .unwrap(); } } 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..182462c 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, @@ -214,6 +223,7 @@ pub fn draw_logs( f: &mut Frame<'_, B>, gui_state: &Arc>, index: Option, + loading_icon: String, selected_panel: &SelectablePanel, ) { let panel = SelectablePanel::Logs; @@ -224,8 +234,8 @@ pub fn draw_logs( let init = app_data.lock().init; if !init { - let icon = gui_state.lock().get_loading(); - let parsing_logs = format!("parsing logs {}", icon); + // let icon = gui_state.lock().get_loading(); + let parsing_logs = format!("parsing logs {}", loading_icon); let paragraph = Paragraph::new(parsing_logs) .style(Style::default()) .block(block) @@ -330,7 +340,7 @@ fn make_chart( .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_type(BorderType::Plain), + .border_type(BorderType::Rounded), ) .x_axis( Axis::default() @@ -348,27 +358,32 @@ 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>, has_containers: bool, + loading_icon: String, info_visible: bool, ) { let block = || Block::default().style(Style::default().bg(Color::Magenta).fg(Color::Black)); f.render_widget(block(), area); - let mut column_headings = format!(" {:>width$}", columns.state.0, width = columns.state.1); + let mut column_headings = format!( + " {}{:>width$}", + loading_icon, + columns.state.0, + width = columns.state.1 + ); column_headings.push_str( format!( - "{} {:>width$}", + "{} {:>width$}", MARGIN, columns.status.0, width = columns.status.1 @@ -379,6 +394,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 +472,23 @@ 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 +508,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 +524,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 +537,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 +546,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 +594,79 @@ 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..97983da 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,86 @@ pub enum SelectablePanel { Commands, Logs, } -#[derive(Debug)] + +#[allow(unused)] +#[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 +113,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,15 +160,19 @@ 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 // If a BMapTree think it would mean have to implement ordering for SelectablePanel area_map: HashMap, - loading: Loading, + loading_icon: Loading, + // Should be a vec, each time loading add a new to the vec, and reset remove from vec + // for for if is_loading just check if vec is empty or not + is_loading: bool, pub selected_panel: SelectablePanel, pub show_help: bool, + pub info_box_text: Option, } impl GuiState { @@ -108,9 +180,11 @@ impl GuiState { pub fn default() -> Self { Self { area_map: HashMap::new(), - loading: Loading::One, + loading_icon: Loading::One, selected_panel: SelectablePanel::Containers, show_help: false, + is_loading: false, + info_box_text: None, } } @@ -147,15 +221,33 @@ impl GuiState { self.selected_panel = self.selected_panel.prev(); } + /// Advance loading animation pub fn next_loading(&mut self) { - self.loading = self.loading.next() + self.loading_icon = self.loading_icon.next(); + self.is_loading = true; } + /// if is_loading, return loading animation frame, else single space pub fn get_loading(&mut self) -> String { - self.loading.to_string() + if self.is_loading { + self.loading_icon.to_string() + } else { + String::from(" ") + } } + /// set is_loading to false, but keep animation frame at same state pub fn reset_loading(&mut self) { - self.loading = Loading::One; + self.is_loading = false; + } + + /// Set info box content + pub fn set_info_box(&mut self, text: String) { + self.info_box_text = Some(text); + } + + /// Remove info box content + pub fn reset_info_box(&mut self) { + self.info_box_text = None; } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 002175a..15d04d2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,12 +5,15 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use parking_lot::Mutex; -use std::sync::atomic::AtomicBool; use std::{ io, sync::{atomic::Ordering, Arc}, }; -use tokio::sync::broadcast::Sender; +use std::{ + sync::atomic::AtomicBool, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc::Sender; use tui::{ backend::{Backend, CrosstermBackend}, layout::{Constraint, Direction, Layout}, @@ -23,7 +26,10 @@ mod gui_state; pub use self::color_match::*; pub use self::gui_state::{GuiState, SelectablePanel}; -use crate::{app_data::AppData, app_error::AppError, input_handler::InputMessages}; +use crate::{ + app_data::AppData, app_error::AppError, docker_data::DockerMessage, + input_handler::InputMessages, +}; use draw_blocks::*; /// Take control of the terminal in order to draw gui @@ -32,6 +38,8 @@ pub async fn create_ui( sender: Sender, is_running: Arc, gui_state: Arc>, + docker_sx: Sender, + update_duration: Duration, ) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); @@ -39,7 +47,16 @@ pub async fn create_ui( let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let res = run_app(&mut terminal, app_data, sender, is_running, gui_state).await; + let res = run_app( + &mut terminal, + app_data, + sender, + is_running, + gui_state, + docker_sx, + update_duration, + ) + .await; disable_raw_mode().unwrap(); execute!( @@ -50,7 +67,7 @@ pub async fn create_ui( terminal.show_cursor().unwrap(); if let Err(err) = res { - err.disp() + println!("{}", err); } Ok(()) } @@ -62,6 +79,8 @@ async fn run_app( sender: Sender, is_running: Arc, gui_state: Arc>, + docker_sx: Sender, + update_duration: Duration, ) -> Result<(), AppError> { let input_poll_rate = std::time::Duration::from_millis(75); @@ -83,6 +102,7 @@ async fn run_app( } } } else { + let mut now = Instant::now(); loop { terminal.draw(|f| ui(f, &app_data, &gui_state)).unwrap(); if crossterm::event::poll(input_poll_rate).unwrap() { @@ -90,15 +110,24 @@ async fn run_app( if let Event::Key(key) = event { sender .send(InputMessages::ButtonPress(key.code)) - .unwrap_or(0); + .await + .unwrap_or(()); } else if let Event::Mouse(m) = event { - sender.send(InputMessages::MouseEvent(m)).unwrap_or(0); + sender + .send(InputMessages::MouseEvent(m)) + .await + .unwrap_or(()); } else if let Event::Resize(_, _) = event { gui_state.lock().clear_area_map(); terminal.autoresize().unwrap_or(()); } } + if now.elapsed() >= update_duration { + docker_sx.send(DockerMessage::Update).await.unwrap(); + now = Instant::now(); + } + if !is_running.load(Ordering::SeqCst) { break; } @@ -126,6 +155,8 @@ 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 loading_icon = gui_state.lock().get_loading(); let whole_layout = Layout::default() .direction(Direction::Vertical) @@ -187,14 +218,16 @@ fn ui( f, gui_state, log_index, + loading_icon.to_owned(), &selected_panel, ); - draw_info_bar( + draw_heading_bar( whole_layout[0], &column_widths, f, has_containers, + loading_icon, show_help, ); @@ -203,6 +236,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);