feat: config file, cloes #47

Enable use of a config file, with custom keymap and custom colours
This commit is contained in:
Jack Wills
2025-02-16 12:53:54 +00:00
parent 4539d8ad07
commit 8fd95b7fd1
37 changed files with 8725 additions and 3879 deletions
Generated
+67
View File
@@ -1067,8 +1067,12 @@ dependencies = [
"futures-util",
"parking_lot",
"ratatui",
"serde",
"serde_json",
"serde_jsonc",
"tokio",
"tokio-util",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
@@ -1295,6 +1299,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_jsonc"
version = "1.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a58154381df481a41b7536101c0daccdaf2426f244334074c4c77b89b6253a7"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.19"
@@ -1306,6 +1321,15 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1580,6 +1604,40 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee"
dependencies = [
"indexmap 2.7.1",
"serde",
"serde_spanned",
"toml_datetime",
"winnow",
]
[[package]]
name = "tower-service"
version = "0.3.3"
@@ -1930,6 +1988,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e376c75f4f43f44db463cf729e0d3acbf954d13e22c51e26e4c264b4ab545f"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
+4
View File
@@ -35,8 +35,12 @@ directories = "6.0"
futures-util = "0.3"
parking_lot = { version = "0.12" }
ratatui = "0.29"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_jsonc = "1.0"
tokio = { version = "1.43", features = ["full"] }
tokio-util = "0.7"
toml = { version = "0.8", default-features = false, features = ["parse"] }
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1.12", features = ["fast-rng", "v4"] }
+2 -2
View File
@@ -186,11 +186,11 @@ docker compose -f ./docker/docker-compose.yml up -d
or individually
```shell
docker run --name redis -d redis:alpine3.19
docker run --name redis -d redis:alpine3.21
```
```shell
docker run --name postgres -e POSTGRES_PASSWORD=never_use_this_password_in_production -d postgres:alpine3.19
docker run --name postgres -e POSTGRES_PASSWORD=never_use_this_password_in_production -d postgres:alpine3.21
```
```shell
+177
View File
@@ -0,0 +1,177 @@
{
"color_logs": false,
"docker_interval": 1000,
"gui": true,
"host": "/var/run/docker.sock",
"raw_logs": false,
"show_self": false,
"show_std_err": false,
"show_timestamp": true,
"use_cli": false,
"colors": {
"borders": {
"selected": "lightcyan",
"unselected": "grey"
},
"chart_cpu": {
"background": "reset",
"border": "white",
"max": "#FFB224",
"points": "magenta",
"title": "green",
"y_axis": "white"
},
"chart_memory": {
"background": "reset",
"border": "white",
"max": "#FFB224",
"points": "cyan",
"title": "green",
"y_axis": "white"
},
"chart_ports": {
"background": "reset",
"border": "white",
"headings": "yellow",
"text": "white",
"title": "green"
},
"commands": {
"background": "reset",
"delete": "gray",
"pause": "yellow",
"restart": "magenta",
"resume": "blue",
"start": "green",
"stop": "red"
},
"container_state": {
"dead": "red",
"exited": "red",
"paused": "yellow",
"removing": "lightred",
"restarting": "lightgreen",
"running_healthy": "green",
"running_unhealthy": "#FFB224",
"unknown": "red"
},
"containers": {
"background": "reset",
"icon": "white",
"text": "blue",
"text_rx": "#FFE9C1",
"text_tx": "#CD8C8C"
},
"headers_bar": {
"background": "magenta",
"loading_spinner": "white",
"text": "black",
"text_selected": "gray"
},
"popup_delete": {
"background": "white",
"text": "black",
"text_highlight": "red"
},
"popup_error": {
"background": "red",
"text": "white"
},
"popup_help": {
"background": "magenta",
"text": "black",
"text_highlight": "white"
},
"popup_info": {
"background": "blue",
"text": "white"
}
},
"keymap": {
"clear": [
"c",
"esc"
],
"delete_confirm": [
"y"
],
"delete_deny": [
"n"
],
"exec": [
"e"
],
"filter_mode": [
"/",
"F1"
],
"quit": [
"q"
],
"save_logs": [
"s"
],
"scroll_down_many": [
"pagedown"
],
"scroll_down_one": [
"down",
"j"
],
"scroll_end": [
"end"
],
"scroll_start": [
"home"
],
"scroll_up_many": [
"pageup"
],
"scroll_up_one": [
"up",
"k"
],
"select_next_panel": [
"tab"
],
"select_previous_panel": [
"backtab"
],
"sort_by_cpu": [
"4"
],
"sort_by_id": [
"6"
],
"sort_by_image": [
"7"
],
"sort_by_memory": [
"5"
],
"sort_by_name": [
"1"
],
"sort_by_rx": [
"8"
],
"sort_by_state": [
"2"
],
"sort_by_status": [
"3"
],
"sort_by_tx": [
"9"
],
"sort_reset": [
"0"
],
"toggle_help": [
"h"
],
"toggle_mouse_capture": [
"m"
]
}
}
+282
View File
@@ -0,0 +1,282 @@
{
// Example JSONC config file
// This needs to be renamed to "config.jsonc" ("config.json" will also work, even if the file is actually a jsonc) in order for oxker to automatically load
// oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
// Every key is optional, with defaults that oxker will choose if missing or invalid
// The `--config-file` cli argument can be used to load configuration files from any readable location
// Docker update interval in ms, minimum effectively 1000
"docker_interval": 1000,
// Attempt to colorize the logs, conflicts with "raw"
"color_logs": false,
// Show raw logs, default is to remove ansi formatting, conflicts with "color"
"raw_logs": false,
// Show self (the oxker container) when running as a docker container
"show_self": false,
// Show std_err in logs
"show_std_err": false,
// Show a timestamp for every log entry
"show_timestamp": true,
// Don't draw gui - for debugging - mostly pointless
"gui": true,
// Docker host location
"host": "/var/run/docker.sock",
// Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
// "save_dir": "$HOME",
// Force use of docker cli when execing into containers, honestly mostly pointless
"use_cli": false,
//////////////////
// Custom Keymap //
//////////////////
// Available keys are;
// 1) a-z and A-Z
// 2) 0-9
// 3) / \ , . # ' [ ] ; = -
// 3) F1-F12
// 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
// Each definition can have two keys associated with it
// If any key clashes are found, oxker will revert to it's default keymap
"keymap": {
// Clear any popup boxes, filter panel, or help panel
"clear": [
"c",
"esc"
],
// Cancel delete - clear also works here
"delete_deny": [
"n"
],
// Confirm Delete
"delete_confirm": [
"y"
],
// Exec into the selected container
"exec": [
"e"
],
// Enter filter mode
"filter_mode": [
"/",
"F1"
],
// Quit at anytime
"quit": [
"q"
],
// Save logs of selected container to file on disk
"save_logs": [
"s"
],
// Scroll down a list by many
"scroll_down_many": [
"pagedown"
],
// Scroll down a list by one item
"scroll_down_one": [
"down",
"j"
],
// Scroll down to the end of a list
"scroll_end": [
"end"
],
// Scroll up to the start of a list
"scroll_start": [
"home"
],
// Scroll up a list by many
"scroll_up_many": [
"pageup"
],
// Scroll up a list by one item
"scroll_up_one": [
"up",
"k"
],
// Select next panel
"select_next_panel": [
"tab"
],
// Select previous panel
"select_previous_panel": [
"backtab"
],
// Sort the containers based on specific column
"sort_by_name": [
"1"
],
"sort_by_state": [
"2"
],
"sort_by_status": [
"3"
],
"sort_by_cpu": [
"4"
],
"sort_by_memory": [
"5"
],
"sort_by_id": [
"6"
],
"sort_by_image": [
"7"
],
"sort_by_rx": [
"8"
],
"sort_by_tx": [
"9"
],
// Reset the sorted containers
"sort_reset": [
"0"
],
// Toggle the help panel
"toggle_help": [
"h"
],
// Toggle mouse capture
"toggle_mouse_capture": [
"m"
]
},
////////////////////
// Custom Colors //
////////////////////
// Colors be listed as either;
// 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
// 2) Hex values: '#FF0000', case insensitive
// 3) 'reset' for transparency
// Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
"colors": {
// The single line bar at the uppermost of the display. Contains name/state/status headings etc
"headers_bar": {
// Background color of the entire line
"background": "magenta",
// Animated loading icon at the start of the bar
"loading_spinner": "white",
// Text color
"text": "black",
// Text color of a selected header
"text_selected": "gray"
},
// The borders around the selectable panels - Containers, Commands, Logs
"borders": {
// Border when selected
"selected": "lightcyan",
// Border when not selected
"unselected": "grey"
},
// The containers sections, in the future more color customization options should be made available in this section
"containers": {
// The icon use to illustrate which container is currently selected
"icon": "white",
// Background color of panel
"background": "reset",
// At the moment, this will only change the color of the name, id, and image columns
"text": "blue",
// Text color of the RX column
"text_rx": "#FFE9C1",
// Text color of the TX column
"text_tx": "#CD8C8C"
},
// Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
"container_state": {
"dead": "red",
"exited": "red",
"paused": "yellow",
"removing": "lightred",
"restarting": "lightgreen",
"running_healthy": "green",
"running_unhealthy": "#FFB224",
"unknown": "red"
},
// The color the of Docker commands available for each container
"commands": {
// Background color of panel
"background": "reset",
"pause": "yellow",
"restart": "magenta",
"stop": "red",
"delete": "gray",
"resume": "blue",
"start": "green"
},
// The cpu chart
"chart_cpu": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Maximum CPU percentage
"max": "#FFB224",
// Points on the chart
"points": "magenta",
// The charts y-axis
"y_axis": "white"
},
// The memory chart
"chart_memory": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Maximum memory use
"max": "#FFB224",
// Points on the chart
"points": "cyan",
// The charts y-axis
"y_axis": "white"
},
// The ports chart
"chart_ports": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Private/Public/IP headings
"headings": "yellow",
// Ports & IP listing text
"text": "white"
},
// The help popup
"popup_help": {
// Background color
"background": "magenta",
// Text color
"text": "black",
// Highlighted text color
"text_highlight": "white"
},
// The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
"popup_info": {
// Background color
"background": "blue",
// Text color
"text": "white"
},
// The delete popup - used to display a confirmation warning when about to delete a container
"popup_delete": {
// Background color
"background": "white",
// Text color
"text": "black",
// Highlighted text color
"text_highlight": "red"
},
// The error popup - hopefully you'll never have to see this
"popup_error": {
// Background color
"background": "red",
// Text color
"text": "white"
}
}
}
+239
View File
@@ -0,0 +1,239 @@
# Example toml config file
# This needs to be renamed to "config.toml" in order for oxker to automatically load
# oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
# Every key is optional, with defaults that oxker will choose if missing or invalid
# The `--config-file` cli argument can be used to load configuration files from any readable location
# Docker update interval in ms, minimum effectively 1000
docker_interval = 1000
# Attempt to colorize the logs, conflicts with "raw"
color_logs = false
# Show raw logs, default is to remove ansi formatting, conflicts with "color"
raw_logs = false
# Show self (the oxker container) when running as a docker container
show_self = false
# Show std_err in logs
show_std_err = false
# Show a timestamp for every log entry
show_timestamp = true
# Don't draw gui - for debugging - mostly pointless
gui = true
# Docker host location
host = "/var/run/docker.sock"
# Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
# save_dir = "$HOME"
# Force use of docker cli when execing into containers, honestly mostly pointless
use_cli = false
#################
# Custom Keymap #
#################
# Available keys are;
# 1) a-z and A-Z
# 2) 0-9
# 3) / \ , . # ' [ ] ; = -
# 3) F1-F12
# 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
# Each definition can have two keys associated with it
# If any key clashes are found, oxker will revert to it's default keymap
[keymap]
# Clear any popup boxes, filter panel, or help panel
clear=["c", "esc"]
# Cancel delete - clear also works here
delete_deny=["n"]
# Confirm Delete
delete_confirm=["y"]
# Exec into the selected container
exec=["e"]
# Enter filter mode
filter_mode=["/", "F1"]
# Quit at anytime
quit = ["q"]
# Save logs of selected container to file on disk
save_logs=["s"]
# scroll down a list by many
scroll_down_many=["pagedown"]
# scroll down a list by one item
scroll_down_one=["down", "j"]
# scroll down to the end of a list
scroll_end=["end"]
# scroll up to the start of a list
scroll_start=["home"]
# scroll up a list by many
scroll_up_many=["pageup"]
# scroll up a list by one item
scroll_up_one=["up", "k"]
# Select next panel
select_next_panel=["tab"]
# Select previous panel
select_previous_panel=["backtab"]
# Sort the containers based on specific column
sort_by_name=["1"]
sort_by_state=["2"]
sort_by_status=["3"]
sort_by_cpu=["4"]
sort_by_memory=["5"]
sort_by_id=["6"]
sort_by_image=["7"]
sort_by_rx=["8"]
sort_by_tx=["9"]
# Reset the sorted containers
sort_reset=["0"]
# Toggle the help panel
toggle_help=["h"]
# Toggle mouse capture
toggle_mouse_capture=["m"]
#################
# Custom Colors #
#################
# Colors be listed as either;
# 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
# 2) Hex values: '#FF0000', case insensitive
# 3) 'reset' for transparency
# Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
# The single line bar at the uppermost of the display. Contains name/state/status headings etc
[colors.headers_bar]
# Background color of the entire line
background = "magenta"
# Animated loading icon at the start of the bar
loading_spinner="white"
# Text color
text = "black"
# Text color of a selected header
text_selected = "gray"
# The borders around the selectable panels - Containers, Commands, Logs
[colors.borders]
# Border when selected
selected="lightcyan"
# Border when not selected
unselected="grey"
# The containers sections, in the future more color customization options should be made available in this section
[colors.containers]
# The icon use to illustrate which container is currently selected - at the moment the TUI library, ratatui, doesn't seem allow changing the color of the highlight symbol
icon="white"
# Background color of panel
background = "reset"
# At the moment, this will only change the color of the name, id, and image columns
text="blue"
# Text color of the RX column
text_rx="#FFE9C1"
# Text color of the TX column
text_tx="#CD8C8C"
# Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
[colors.container_state]
dead="red"
exited="red"
paused = "yellow"
removing ="lightred"
restarting ="lightgreen"
running_healthy ="green"
running_unhealthy="#FFB224"
unknown="red"
# The color the of Docker commands available for each container
[colors.commands]
# Background color of panel
background = "reset"
pause="yellow"
restart="magenta"
stop = "red"
delete ="gray"
resume ="blue"
start ="green"
# The cpu chart
[colors.chart_cpu]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped colors not yet customizable - or could just use state color?
title="green"
# Maximum CPU percentage - again paused & stopped colors not yet customizable
max="#FFB224"
# Points on the chart - again paused & stopped colors not yet customizable
points="magenta"
# The charts y-axis
y_axis="white"
# The memory chart
[colors.chart_memory]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title="green"
# Maximum memory use - again paused & stopped will use colors.container_state
max="#FFB224"
# Points on the chart - again paused & stopped will use colors.container_state
points="cyan"
# The charts y-axis
y_axis="white"
# The ports chart
[colors.chart_ports]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title="green"
# Private/Public/IP headings
headings="yellow"
# Ports & IP listing text
text="white"
# The help popup
[colors.popup_help]
# Background color
background="magenta"
# Text color
text="black"
# Highlighted text color
text_highlight="white"
# The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
[colors.popup_info]
# Background color
background="blue"
# Text color
text="white"
# The delete popup - used to display a confirmation warning when about to delete a container
[colors.popup_delete]
# Background color
background="white"
# Text color
text="black"
# Highlighted text color
text_highlight="red"
# The error popup - hopefully you'll never have to see this
[colors.popup_error]
# Background color
background="red"
# Text color
text="white"
+29 -17
View File
@@ -11,7 +11,7 @@ use ratatui::{
widgets::{ListItem, ListState},
};
use crate::ui::ORANGE;
use crate::config::AppColors;
use super::Header;
@@ -265,16 +265,28 @@ impl State {
pub const fn is_alive(self) -> bool {
matches!(self, Self::Running(_))
}
/// Color of the state for the containers section
/// TODO allow usable editable colours
pub const fn get_color(self) -> Color {
/// Check if state is running & healthy
pub const fn is_healthy(self) -> bool {
match self {
Self::Paused => Color::Yellow,
Self::Removing => Color::LightRed,
Self::Restarting => Color::LightGreen,
Self::Running(RunningState::Healthy) => Color::Green,
Self::Running(RunningState::Unhealthy) => ORANGE,
_ => Color::Red,
Self::Running(x) => match x {
RunningState::Healthy => true,
RunningState::Unhealthy => false,
},
_ => false,
}
}
/// Color of the state for the containers section
pub const fn get_color(self, colors: AppColors) -> Color {
match self {
Self::Dead => colors.container_state.dead,
Self::Exited => colors.container_state.exited,
Self::Paused => colors.container_state.paused,
Self::Removing => colors.container_state.removing,
Self::Restarting => colors.container_state.restarting,
Self::Running(RunningState::Healthy) => colors.container_state.running_healthy,
Self::Running(RunningState::Unhealthy) => colors.container_state.running_unhealthy,
Self::Unknown => colors.container_state.unknown,
}
}
/// Dirty way to create order for the state, rather than impl Ord
@@ -348,14 +360,14 @@ pub enum DockerCommand {
}
impl DockerCommand {
pub const fn get_color(self) -> Color {
pub const fn get_color(self, colors: AppColors) -> Color {
match self {
Self::Pause => Color::Yellow,
Self::Restart => Color::Magenta,
Self::Start => Color::Green,
Self::Stop => Color::Red,
Self::Delete => Color::Gray,
Self::Resume => Color::Blue,
Self::Pause => colors.commands.pause,
Self::Restart => colors.commands.restart,
Self::Start => colors.commands.start,
Self::Stop => colors.commands.stop,
Self::Delete => colors.commands.delete,
Self::Resume => colors.commands.resume,
}
}
+11 -11
View File
@@ -12,7 +12,7 @@ mod container_state;
use crate::{
app_error::AppError,
parse_args::CliArgs,
config::Config,
ui::{log_sanitizer, GuiState, Status},
ENTRY_POINT,
};
@@ -123,13 +123,13 @@ pub struct AppData {
filter: Filter,
hidden_containers: Vec<ContainerItem>,
sorted_by: Option<(Header, SortedOrder)>,
pub args: CliArgs,
pub config: Config,
}
#[derive(Debug, Clone)]
#[cfg(test)]
pub struct AppData {
pub args: CliArgs,
pub config: Config,
pub containers: StatefulList<ContainerItem>,
pub error: Option<AppError>,
pub filter: Filter,
@@ -139,9 +139,9 @@ pub struct AppData {
impl AppData {
/// Generate a default app_state
pub fn default(args: CliArgs) -> Self {
pub fn default(config: Config) -> Self {
Self {
args,
config,
containers: StatefulList::new(vec![]),
error: None,
filter: Filter::new(),
@@ -657,8 +657,8 @@ impl AppData {
/// Error related methods
/// Get single app_state error
pub const fn get_error(&self) -> Option<AppError> {
self.error
pub fn get_error(&self) -> Option<AppError> {
self.error.clone()
}
/// Remove single app_state error
@@ -682,7 +682,7 @@ impl AppData {
/// Check if selected container is oxker and also that oxker is being run in a container
pub fn is_oxker_in_container(&self) -> bool {
self.get_selected_container()
.is_some_and(|i| i.is_oxker && self.args.in_container)
.is_some_and(|i| i.is_oxker && self.config.in_container)
}
/// Find the widths for the strings in the containers panel.
@@ -877,10 +877,10 @@ impl AppData {
/// Update logs of a given container, based on id
pub fn update_log_by_id(&mut self, logs: Vec<String>, id: &ContainerId) {
let color = self.args.color;
let raw = self.args.raw;
let color = self.config.color_logs;
let raw = self.config.raw_logs;
let timestamp = self.args.timestamp;
let timestamp = self.config.show_timestamp;
if let Some(container) = self.get_any_container_by_id(id) {
if !container.is_oxker {
+5 -1
View File
@@ -2,13 +2,15 @@ use crate::app_data::DockerCommand;
use std::fmt;
/// app errors to set in global state
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
pub enum AppError {
DockerCommand(DockerCommand),
DockerExec,
DockerLogs,
DockerConnect,
IO(String),
MouseCapture(bool),
Parse(String),
Terminal,
}
@@ -20,10 +22,12 @@ impl fmt::Display for AppError {
Self::DockerExec => write!(f, "Unable to exec into container"),
Self::DockerLogs => write!(f, "Unable to save logs"),
Self::DockerConnect => write!(f, "Unable to access docker daemon"),
Self::IO(msg) => write!(f, "IO error with: {msg}"),
Self::MouseCapture(x) => {
let reason = if *x { "en" } else { "dis" };
write!(f, "Unable to {reason}able mouse capture")
}
Self::Parse(msg) => write!(f, "Parsing error: {msg}"),
Self::Terminal => write!(f, "Unable to fully render to terminal"),
}
}
+433
View File
@@ -0,0 +1,433 @@
use ratatui::style::Color;
/// The macro accepts a list of struct names with key names
/// Returns a struct where every key name is an Option<String>, with the correct derived attributes
macro_rules! optional_config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
struct $struct_name {
$(
$key_name: Option<String>,
)*
}
)*
};
}
/// The macro accepts a list of struct names with key names
macro_rules! config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub struct $struct_name {
$(
pub $key_name: Color,
)*
}
)*
};
}
impl AppColors {
fn map_color(color_str: Option<&str>, setter: &mut Color) {
color_str.map(|i| i.parse::<Color>().map(|i| *setter = i));
}
}
impl From<Option<ConfigColors>> for AppColors {
#[allow(clippy::too_many_lines)]
fn from(value: Option<ConfigColors>) -> Self {
let mut app_colors = Self::new();
if let Some(config_colors) = value {
// Heading bar
if let Some(hb) = config_colors.headers_bar {
Self::map_color(
hb.background.as_deref(),
&mut app_colors.headers_bar.background,
);
Self::map_color(
hb.loading_spinner.as_deref(),
&mut app_colors.headers_bar.loading_spinner,
);
Self::map_color(hb.text.as_deref(), &mut app_colors.headers_bar.text);
Self::map_color(
hb.text_selected.as_deref(),
&mut app_colors.headers_bar.text_selected,
);
}
// Seletable panel borders
if let Some(b) = config_colors.borders {
Self::map_color(b.selected.as_deref(), &mut app_colors.borders.selected);
Self::map_color(b.unselected.as_deref(), &mut app_colors.borders.unselected);
}
// Error Popup
if let Some(ep) = config_colors.popup_error {
Self::map_color(
ep.background.as_deref(),
&mut app_colors.popup_error.background,
);
Self::map_color(ep.text.as_deref(), &mut app_colors.popup_error.text);
}
// Help Popup
if let Some(hp) = config_colors.popup_help {
Self::map_color(
hp.background.as_deref(),
&mut app_colors.popup_help.background,
);
Self::map_color(hp.text.as_deref(), &mut app_colors.popup_help.text);
Self::map_color(
hp.text_highlight.as_deref(),
&mut app_colors.popup_help.text_highlight,
);
}
// Info Popup
if let Some(ip) = config_colors.popup_info {
Self::map_color(
ip.background.as_deref(),
&mut app_colors.popup_info.background,
);
Self::map_color(ip.text.as_deref(), &mut app_colors.popup_info.text);
}
// Delete Popup
if let Some(dp) = config_colors.popup_delete {
Self::map_color(
dp.background.as_deref(),
&mut app_colors.popup_delete.background,
);
Self::map_color(dp.text.as_deref(), &mut app_colors.popup_delete.text);
Self::map_color(
dp.text_highlight.as_deref(),
&mut app_colors.popup_delete.text_highlight,
);
}
// Chart Cpu
if let Some(cc) = config_colors.chart_cpu {
Self::map_color(
cc.background.as_deref(),
&mut app_colors.chart_cpu.background,
);
Self::map_color(cc.border.as_deref(), &mut app_colors.chart_cpu.border);
Self::map_color(cc.max.as_deref(), &mut app_colors.chart_cpu.max);
Self::map_color(cc.points.as_deref(), &mut app_colors.chart_cpu.points);
Self::map_color(cc.title.as_deref(), &mut app_colors.chart_cpu.title);
Self::map_color(cc.y_axis.as_deref(), &mut app_colors.chart_cpu.y_axis);
}
// Chart Memory
if let Some(cm) = config_colors.chart_memory {
Self::map_color(
cm.background.as_deref(),
&mut app_colors.chart_memory.background,
);
Self::map_color(cm.border.as_deref(), &mut app_colors.chart_memory.border);
Self::map_color(cm.max.as_deref(), &mut app_colors.chart_memory.max);
Self::map_color(cm.points.as_deref(), &mut app_colors.chart_memory.points);
Self::map_color(cm.title.as_deref(), &mut app_colors.chart_memory.title);
Self::map_color(cm.y_axis.as_deref(), &mut app_colors.chart_memory.y_axis);
}
// Chart ports
if let Some(cp) = config_colors.chart_ports {
Self::map_color(
cp.background.as_deref(),
&mut app_colors.chart_ports.background,
);
Self::map_color(cp.border.as_deref(), &mut app_colors.chart_ports.border);
Self::map_color(cp.headings.as_deref(), &mut app_colors.chart_ports.headings);
Self::map_color(cp.text.as_deref(), &mut app_colors.chart_ports.text);
Self::map_color(cp.title.as_deref(), &mut app_colors.chart_ports.title);
}
// Containers
if let Some(c) = config_colors.containers {
Self::map_color(
c.background.as_deref(),
&mut app_colors.containers.background,
);
Self::map_color(c.icon.as_deref(), &mut app_colors.containers.icon);
Self::map_color(c.text.as_deref(), &mut app_colors.containers.text);
Self::map_color(c.text_rx.as_deref(), &mut app_colors.containers.text_rx);
Self::map_color(c.text_tx.as_deref(), &mut app_colors.containers.text_tx);
}
// Commands
if let Some(cc) = config_colors.commands {
Self::map_color(
cc.background.as_deref(),
&mut app_colors.commands.background,
);
Self::map_color(cc.pause.as_deref(), &mut app_colors.commands.pause);
Self::map_color(cc.restart.as_deref(), &mut app_colors.commands.restart);
Self::map_color(cc.stop.as_deref(), &mut app_colors.commands.stop);
Self::map_color(cc.delete.as_deref(), &mut app_colors.commands.start);
Self::map_color(cc.resume.as_deref(), &mut app_colors.commands.resume);
Self::map_color(cc.start.as_deref(), &mut app_colors.commands.start);
}
// Container State
if let Some(cs) = config_colors.container_state {
Self::map_color(cs.dead.as_deref(), &mut app_colors.container_state.dead);
Self::map_color(cs.exited.as_deref(), &mut app_colors.container_state.exited);
Self::map_color(cs.paused.as_deref(), &mut app_colors.container_state.paused);
Self::map_color(
cs.removing.as_deref(),
&mut app_colors.container_state.removing,
);
Self::map_color(
cs.restarting.as_deref(),
&mut app_colors.container_state.restarting,
);
Self::map_color(
cs.running_healthy.as_deref(),
&mut app_colors.container_state.running_healthy,
);
Self::map_color(
cs.running_unhealthy.as_deref(),
&mut app_colors.container_state.running_unhealthy,
);
Self::map_color(
cs.unknown.as_deref(),
&mut app_colors.container_state.unknown,
);
}
}
app_colors
}
}
const ORANGE: Color = Color::Rgb(255, 178, 36);
optional_config_struct!(
ConfigBackgroundText, background, text;
ConfigBackgroundTextHighlight, background, text, text_highlight;
ConfigBorders, selected, unselected;
ConfigChartCpu, background, border, order, title, max, points,y_axis;
ConfigChartMemory, background, border, title, max, points, y_axis;
ConfigChartPorts, background, border, title, headings, text;
ConfigCommands, background, pause, restart, stop, delete, resume, start;
ConfigContainers, background, icon, text, text_rx, text_tx;
ConfigContainerState, background, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown;
ConfigHeadersBar, background, loading_spinner, text, text_selected
);
config_struct!(
Borders, selected, unselected;
ChartCpu, background, border, title, max, points, y_axis;
ChartMemory, background, border, title, max, points, y_axis;
ChartPorts, background, border, title, headings, text;
Commands, background, pause, restart, stop, delete, resume, start;
Containers, background, icon, text, text_rx, text_tx;
ContainerState, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown;
HeadersBar, background, text_selected, loading_spinner, text;
PopupDelete, background, text, text_highlight;
PopupError, background, text;
PopupHelp, background, text, text_highlight;
PopupInfo, background, text
);
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct ConfigColors {
borders: Option<ConfigBorders>,
chart_cpu: Option<ConfigChartCpu>,
chart_memory: Option<ConfigChartMemory>,
chart_ports: Option<ConfigChartPorts>,
commands: Option<ConfigCommands>,
container_state: Option<ConfigContainerState>,
containers: Option<ConfigContainers>,
headers_bar: Option<ConfigHeadersBar>,
popup_delete: Option<ConfigBackgroundTextHighlight>,
popup_error: Option<ConfigBackgroundText>,
popup_help: Option<ConfigBackgroundTextHighlight>,
popup_info: Option<ConfigBackgroundText>,
}
/// Default colours for the header bar
impl HeadersBar {
const fn new() -> Self {
Self {
background: Color::Magenta,
loading_spinner: Color::White,
text: Color::Black,
text_selected: Color::Gray,
}
}
}
/// Default colours for the borders
impl Borders {
const fn new() -> Self {
Self {
selected: Color::LightCyan,
unselected: Color::Gray,
}
}
}
/// Default colours for the delete popup
impl Commands {
const fn new() -> Self {
Self {
background: Color::Reset,
pause: Color::Yellow,
restart: Color::Magenta,
stop: Color::Red,
delete: Color::Gray,
resume: Color::Blue,
start: Color::Green,
}
}
}
/// Default colours for the help popup
impl ChartCpu {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
max: ORANGE,
points: Color::Magenta,
y_axis: Color::White,
}
}
}
/// Default colours for the help popup
impl ChartMemory {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
max: ORANGE,
points: Color::Cyan,
y_axis: Color::White,
}
}
}
/// Default colours for the help popup
impl ChartPorts {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
headings: Color::Yellow,
text: Color::White,
}
}
}
/// Default colours for the help popup
impl Containers {
const fn new() -> Self {
Self {
background: Color::Reset,
icon: Color::White,
text: Color::Blue,
text_rx: Color::Rgb(255, 233, 193),
text_tx: Color::Rgb(205, 140, 140),
}
}
}
/// Default colours for the help popup
impl ContainerState {
const fn new() -> Self {
Self {
paused: Color::Yellow,
removing: Color::LightRed,
restarting: Color::LightGreen,
running_healthy: Color::Green,
running_unhealthy: ORANGE,
dead: Color::Red,
exited: Color::Red,
unknown: Color::Red,
}
}
}
/// Default colours for the Error popup
impl PopupError {
const fn new() -> Self {
Self {
background: Color::Red,
text: Color::White,
}
}
}
/// Default colours for the info popup
impl PopupInfo {
const fn new() -> Self {
Self {
background: Color::Blue,
text: Color::White,
}
}
}
/// Default colours for the help popup
impl PopupHelp {
const fn new() -> Self {
Self {
background: Color::Magenta,
text: Color::Black,
text_highlight: Color::White,
}
}
}
/// Default colours for the delete popup
impl PopupDelete {
const fn new() -> Self {
Self {
background: Color::White,
text: Color::Black,
text_highlight: Color::Red,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub struct AppColors {
pub borders: Borders,
pub chart_cpu: ChartCpu,
pub chart_memory: ChartMemory,
pub chart_ports: ChartPorts,
pub commands: Commands,
pub container_state: ContainerState,
pub containers: Containers,
pub headers_bar: HeadersBar,
pub popup_delete: PopupDelete,
pub popup_error: PopupError,
pub popup_help: PopupHelp,
pub popup_info: PopupInfo,
}
impl AppColors {
pub const fn new() -> Self {
Self {
borders: Borders::new(),
chart_cpu: ChartCpu::new(),
chart_memory: ChartMemory::new(),
chart_ports: ChartPorts::new(),
commands: Commands::new(),
container_state: ContainerState::new(),
containers: Containers::new(),
headers_bar: HeadersBar::new(),
popup_delete: PopupDelete::new(),
popup_error: PopupError::new(),
popup_help: PopupHelp::new(),
popup_info: PopupInfo::new(),
}
}
}
+239
View File
@@ -0,0 +1,239 @@
# Example toml config file
# This needs to be renamed to "config.toml" in order for oxker to automatically load
# oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
# Every key is optional, with defaults that oxker will choose if missing or invalid
# The `--config-file` cli argument can be used to load configuration files from any readable location
# Docker update interval in ms, minimum effectively 1000
docker_interval = 1000
# Attempt to colorize the logs, conflicts with "raw"
color_logs = false
# Show raw logs, default is to remove ansi formatting, conflicts with "color"
raw_logs = false
# Show self (the oxker container) when running as a docker container
show_self = false
# Show std_err in logs
show_std_err = false
# Show a timestamp for every log entry
show_timestamp = true
# Don't draw gui - for debugging - mostly pointless
gui = true
# Docker host location
host = "/var/run/docker.sock"
# Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
# save_dir = "$HOME"
# Force use of docker cli when execing into containers, honestly mostly pointless
use_cli = false
#################
# Custom Keymap #
#################
# Available keys are;
# 1) a-z and A-Z
# 2) 0-9
# 3) / \ , . # ' [ ] ; = -
# 3) F1-F12
# 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
# Each definition can have two keys associated with it
# If any key clashes are found, oxker will revert to it's default keymap
[keymap]
# Clear any popup boxes, filter panel, or help panel
clear=["c", "esc"]
# Cancel delete - clear also works here
delete_deny=["n"]
# Confirm Delete
delete_confirm=["y"]
# Exec into the selected container
exec=["e"]
# Enter filter mode
filter_mode=["/", "F1"]
# Quit at anytime
quit = ["q"]
# Save logs of selected container to file on disk
save_logs=["s"]
# scroll down a list by many
scroll_down_many=["pagedown"]
# scroll down a list by one item
scroll_down_one=["down", "j"]
# scroll down to the end of a list
scroll_end=["end"]
# scroll up to the start of a list
scroll_start=["home"]
# scroll up a list by many
scroll_up_many=["pageup"]
# scroll up a list by one item
scroll_up_one=["up", "k"]
# Select next panel
select_next_panel=["tab"]
# Select previous panel
select_previous_panel=["backtab"]
# Sort the containers based on specific column
sort_by_name=["1"]
sort_by_state=["2"]
sort_by_status=["3"]
sort_by_cpu=["4"]
sort_by_memory=["5"]
sort_by_id=["6"]
sort_by_image=["7"]
sort_by_rx=["8"]
sort_by_tx=["9"]
# Reset the sorted containers
sort_reset=["0"]
# Toggle the help panel
toggle_help=["h"]
# Toggle mouse capture
toggle_mouse_capture=["m"]
#################
# Custom Colors #
#################
# Colors be listed as either;
# 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
# 2) Hex values: '#FF0000', case insensitive
# 3) 'reset' for transparency
# Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
# The single line bar at the uppermost of the display. Contains name/state/status headings etc
[colors.headers_bar]
# Background color of the entire line
background = "magenta"
# Animated loading icon at the start of the bar
loading_spinner="white"
# Text color
text = "black"
# Text color of a selected header
text_selected = "gray"
# The borders around the selectable panels - Containers, Commands, Logs
[colors.borders]
# Border when selected
selected="lightcyan"
# Border when not selected
unselected="grey"
# The containers sections, in the future more color customization options should be made available in this section
[colors.containers]
# The icon use to illustrate which container is currently selected - at the moment the TUI library, ratatui, doesn't seem allow changing the color of the highlight symbol
icon="white"
# Background color of panel
background = "reset"
# At the moment, this will only change the color of the name, id, and image columns
text="blue"
# Text color of the RX column
text_rx="#FFE9C1"
# Text color of the TX column
text_tx="#CD8C8C"
# Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
[colors.container_state]
dead="red"
exited="red"
paused = "yellow"
removing ="lightred"
restarting ="lightgreen"
running_healthy ="green"
running_unhealthy="#FFB224"
unknown="red"
# The color the of Docker commands available for each container
[colors.commands]
# Background color of panel
background = "reset"
pause="yellow"
restart="magenta"
stop = "red"
delete ="gray"
resume ="blue"
start ="green"
# The cpu chart
[colors.chart_cpu]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped colors not yet customizable - or could just use state color?
title="green"
# Maximum CPU percentage - again paused & stopped colors not yet customizable
max="#FFB224"
# Points on the chart - again paused & stopped colors not yet customizable
points="magenta"
# The charts y-axis
y_axis="white"
# The memory chart
[colors.chart_memory]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title="green"
# Maximum memory use - again paused & stopped will use colors.container_state
max="#FFB224"
# Points on the chart - again paused & stopped will use colors.container_state
points="cyan"
# The charts y-axis
y_axis="white"
# The ports chart
[colors.chart_ports]
# Background color of panel
background = "reset"
# Border color
border="white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title="green"
# Private/Public/IP headings
headings="yellow"
# Ports & IP listing text
text="white"
# The help popup
[colors.popup_help]
# Background color
background="magenta"
# Text color
text="black"
# Highlighted text color
text_highlight="white"
# The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
[colors.popup_info]
# Background color
background="blue"
# Text color
text="white"
# The delete popup - used to display a confirmation warning when about to delete a container
[colors.popup_delete]
# Background color
background="white"
# Text color
text="black"
# Highlighted text color
text_highlight="red"
# The error popup - hopefully you'll never have to see this
[colors.popup_error]
# Background color
background="red"
# Text color
text="white"
+437
View File
@@ -0,0 +1,437 @@
use std::collections::HashSet;
use crossterm::event::KeyCode;
/// The macro accepts a list of struct names with key names
/// Returns a struct where every key name is an Option<String>, with the correct derived attributes
macro_rules! optional_config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct $struct_name {
$(
$key_name: Option<Vec<String>>,
)*
}
)*
};
}
/// The macro accepts a list of struct names with key names
/// Similar to the optional_config_struct macro as above, but returns struct where every key name is Color
macro_rules! config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct $struct_name {
$(
pub $key_name: (KeyCode, Option<KeyCode>),
)*
}
)*
};
}
optional_config_struct!(
ConfigKeymap,
clear,
delete_deny,
delete_confirm,
exec,
filter_mode,
quit,
save_logs,
scroll_down_many,
scroll_down_one,
scroll_end,
scroll_start,
scroll_up_many,
scroll_up_one,
select_next_panel,
select_previous_panel,
sort_by_name,
sort_by_state,
sort_by_status,
sort_by_cpu,
sort_by_memory,
sort_by_id,
sort_by_image,
sort_by_rx,
sort_by_tx,
sort_reset,
toggle_help,
toggle_mouse_capture
);
config_struct!(
Keymap,
clear,
delete_deny,
delete_confirm,
exec,
filter_mode,
quit,
save_logs,
scroll_down_many,
scroll_down_one,
scroll_end,
scroll_start,
scroll_up_many,
scroll_up_one,
select_next_panel,
select_previous_panel,
sort_by_name,
sort_by_state,
sort_by_status,
sort_by_cpu,
sort_by_memory,
sort_by_id,
sort_by_image,
sort_by_rx,
sort_by_tx,
sort_reset,
toggle_help,
toggle_mouse_capture
);
impl Keymap {
pub const fn new() -> Self {
Self {
clear: (KeyCode::Char('c'), Some(KeyCode::Esc)),
delete_deny: (KeyCode::Char('n'), None),
delete_confirm: (KeyCode::Char('y'), None),
exec: (KeyCode::Char('e'), None),
filter_mode: (KeyCode::Char('/'), Some(KeyCode::F(1))),
quit: (KeyCode::Char('q'), None),
save_logs: (KeyCode::Char('s'), None),
scroll_down_many: (KeyCode::PageDown, None),
scroll_down_one: (KeyCode::Down, Some(KeyCode::Char('j'))),
scroll_end: (KeyCode::End, None),
scroll_start: (KeyCode::Home, None),
scroll_up_many: (KeyCode::PageUp, None),
scroll_up_one: (KeyCode::Up, Some(KeyCode::Char('k'))),
select_next_panel: (KeyCode::Tab, None),
select_previous_panel: (KeyCode::BackTab, None),
sort_by_name: (KeyCode::Char('1'), None),
sort_by_state: (KeyCode::Char('2'), None),
sort_by_status: (KeyCode::Char('3'), None),
sort_by_cpu: (KeyCode::Char('4'), None),
sort_by_memory: (KeyCode::Char('5'), None),
sort_by_id: (KeyCode::Char('6'), None),
sort_by_image: (KeyCode::Char('7'), None),
sort_by_rx: (KeyCode::Char('8'), None),
sort_by_tx: (KeyCode::Char('9'), None),
sort_reset: (KeyCode::Char('0'), None),
toggle_help: (KeyCode::Char('h'), None),
toggle_mouse_capture: (KeyCode::Char('m'), None),
}
}
}
impl From<Option<ConfigKeymap>> for Keymap {
/// Probably a better way to do this, but for now it works
fn from(value: Option<ConfigKeymap>) -> Self {
let mut keymap = Self::new();
let mut clash = HashSet::new();
let mut counter = 0;
let mut update_keymap =
|vec_str: Option<Vec<String>>,
keymap_field: &mut (KeyCode, Option<KeyCode>),
keymap_clash: &mut HashSet<KeyCode>| {
if let Some(vec_str) = vec_str {
if let Some(vec_keycode) = Self::try_parse_keycode(&vec_str) {
if let Some(first) = vec_keycode.first() {
keymap_clash.insert(*first);
counter += 1;
keymap_field.0 = *first;
}
if let Some(second) = vec_keycode.get(1) {
keymap_clash.insert(*second);
counter += 1;
keymap_field.1 = Some(*second);
} else {
keymap_field.1 = None;
}
}
}
};
if let Some(ck) = value {
update_keymap(ck.clear, &mut keymap.clear, &mut clash);
update_keymap(ck.delete_deny, &mut keymap.delete_deny, &mut clash);
update_keymap(ck.delete_confirm, &mut keymap.delete_confirm, &mut clash);
update_keymap(ck.exec, &mut keymap.exec, &mut clash);
update_keymap(ck.filter_mode, &mut keymap.filter_mode, &mut clash);
update_keymap(ck.quit, &mut keymap.quit, &mut clash);
update_keymap(ck.save_logs, &mut keymap.save_logs, &mut clash);
update_keymap(
ck.scroll_down_many,
&mut keymap.scroll_down_many,
&mut clash,
);
update_keymap(ck.scroll_down_one, &mut keymap.scroll_down_one, &mut clash);
update_keymap(ck.scroll_end, &mut keymap.scroll_end, &mut clash);
update_keymap(ck.scroll_start, &mut keymap.scroll_start, &mut clash);
update_keymap(ck.scroll_up_many, &mut keymap.scroll_up_many, &mut clash);
update_keymap(ck.scroll_up_one, &mut keymap.scroll_up_one, &mut clash);
update_keymap(
ck.select_next_panel,
&mut keymap.select_next_panel,
&mut clash,
);
update_keymap(
ck.select_previous_panel,
&mut keymap.select_previous_panel,
&mut clash,
);
update_keymap(ck.sort_by_name, &mut keymap.sort_by_name, &mut clash);
update_keymap(ck.sort_by_state, &mut keymap.sort_by_state, &mut clash);
update_keymap(ck.sort_by_status, &mut keymap.sort_by_status, &mut clash);
update_keymap(ck.sort_by_cpu, &mut keymap.sort_by_cpu, &mut clash);
update_keymap(ck.sort_by_memory, &mut keymap.sort_by_memory, &mut clash);
update_keymap(ck.sort_by_id, &mut keymap.sort_by_id, &mut clash);
update_keymap(ck.sort_by_image, &mut keymap.sort_by_image, &mut clash);
update_keymap(ck.sort_by_rx, &mut keymap.sort_by_rx, &mut clash);
update_keymap(ck.sort_by_tx, &mut keymap.sort_by_tx, &mut clash);
update_keymap(ck.sort_reset, &mut keymap.sort_reset, &mut clash);
update_keymap(ck.toggle_help, &mut keymap.toggle_help, &mut clash);
update_keymap(
ck.toggle_mouse_capture,
&mut keymap.toggle_mouse_capture,
&mut clash,
);
}
// A very basic clash check, every key has been inserted into a hashset, and a counter has been increased
// if the counter and hashet length don't match, then there's a clash, and we just return the default keymap
if counter == clash.len() {
keymap
} else {
Self::new()
}
}
}
impl Keymap {
/// Try to parse a &[String] into a Vec of keycodes, at most the output will have 2 entries
fn try_parse_keycode(input: &[String]) -> Option<Vec<KeyCode>> {
let mut output = vec![];
for key in input.iter().take(2) {
if key.chars().count() == 1 {
if let Some(first_char) = key.chars().next() {
if let Some(first_char) = match first_char {
x if x.is_ascii_alphabetic() || x.is_ascii_digit() => Some(first_char),
'/' | '\\' | ',' | '.' | '#' | '\'' | '[' | ']' | ';' | '=' | '-' => {
Some(first_char)
}
_ => None,
} {
output.push(KeyCode::Char(first_char));
}
}
} else {
let keycode = match key.to_lowercase().as_str() {
"f1" => Some(KeyCode::F(1)),
"f2" => Some(KeyCode::F(2)),
"f3" => Some(KeyCode::F(3)),
"f4" => Some(KeyCode::F(4)),
"f5" => Some(KeyCode::F(5)),
"f6" => Some(KeyCode::F(6)),
"f7" => Some(KeyCode::F(7)),
"f8" => Some(KeyCode::F(8)),
"f9" => Some(KeyCode::F(9)),
"f10" => Some(KeyCode::F(10)),
"f11" => Some(KeyCode::F(11)),
"f12" => Some(KeyCode::F(12)),
"backspace" => Some(KeyCode::Backspace),
"backtab" => Some(KeyCode::BackTab),
"delete" => Some(KeyCode::Delete),
"down" => Some(KeyCode::Down),
"end" => Some(KeyCode::End),
"esc" => Some(KeyCode::Esc),
"home" => Some(KeyCode::Home),
"insert" => Some(KeyCode::Insert),
"left" => Some(KeyCode::Left),
"pagedown" => Some(KeyCode::PageDown),
"pageup" => Some(KeyCode::PageUp),
"right" => Some(KeyCode::Right),
"tab" => Some(KeyCode::Tab),
"up" => Some(KeyCode::Up),
_ => None,
};
if let Some(a) = keycode {
output.push(a);
}
}
}
if output.is_empty() {
None
} else {
// Remove any duplicates for a single deinition
if output.first() == output.get(1) {
output.pop();
}
Some(output)
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::KeyCode;
use crate::config::keymap_parser::ConfigKeymap;
use super::Keymap;
#[test]
/// Only allow two definitions to be parsed
fn test_return_max_two() {
let result = Keymap::try_parse_keycode(&["a".to_owned(), "b".to_owned(), "c".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('a'), KeyCode::Char('b')]));
let result = Keymap::try_parse_keycode(&["0".to_owned(), "1".to_owned(), "2".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('0'), KeyCode::Char('1')]));
let result =
Keymap::try_parse_keycode(&["esc".to_owned(), "tab".to_owned(), "backtab".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Esc, KeyCode::Tab]));
}
#[test]
/// If a single definition has two identical entries, just return a single entry
fn test_duplicate_definition() {
let result = Keymap::try_parse_keycode(&["c".to_owned(), "c".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('c')]));
let result = Keymap::try_parse_keycode(&["0".to_owned(), "0".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('0')]));
let result = Keymap::try_parse_keycode(&["esc".to_owned(), "esc".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Esc]));
}
#[test]
/// Return None is invalid key definition is provided
fn test_invalid_key() {
let result = Keymap::try_parse_keycode(&["(".to_owned(), "*".to_owned()]);
assert!(result.is_none());
let result = Keymap::try_parse_keycode(&["enter".to_owned(), "shift".to_owned()]);
assert!(result.is_none());
let result = Keymap::try_parse_keycode(&["ö".to_owned(), "ä".to_owned()]);
assert!(result.is_none());
}
#[test]
/// If any key definitions clash, just return the default keymap
fn test_clash_returns_default() {
let input = ConfigKeymap {
clear: Some(vec!["s".to_owned()]),
delete_deny: Some(vec!["s".to_owned()]),
delete_confirm: None,
exec: None,
filter_mode: None,
quit: None,
save_logs: None,
scroll_down_many: None,
scroll_down_one: None,
scroll_end: None,
scroll_start: None,
scroll_up_many: None,
scroll_up_one: None,
select_next_panel: None,
select_previous_panel: None,
sort_by_name: None,
sort_by_state: None,
sort_by_status: None,
sort_by_cpu: None,
sort_by_memory: None,
sort_by_id: None,
sort_by_image: None,
sort_by_rx: None,
sort_by_tx: None,
sort_reset: None,
toggle_help: None,
toggle_mouse_capture: None,
};
let result = Keymap::from(Some(input));
assert_eq!(result, Keymap::new());
}
#[test]
/// Custom keymap definition creation
fn test_valid_custom_keymap() {
let gen_v = |a: (&str, &str)| Some(vec![a.0.to_owned(), a.1.to_owned()]);
let input = ConfigKeymap {
clear: gen_v(("a", "b")),
delete_deny: gen_v(("c", "d")),
delete_confirm: gen_v(("e", "f")),
exec: gen_v(("g", "h")),
filter_mode: gen_v(("i", "j")),
quit: gen_v(("k", "l")),
save_logs: gen_v(("m", "n")),
scroll_down_many: gen_v(("o", "p")),
scroll_down_one: gen_v(("q", "r")),
scroll_end: gen_v(("s", "t")),
scroll_start: gen_v(("u", "v")),
scroll_up_many: gen_v(("w", "x")),
scroll_up_one: gen_v(("y", "z")),
select_next_panel: gen_v(("0", "1")),
select_previous_panel: gen_v(("2", "3")),
sort_by_name: gen_v(("4", "5")),
sort_by_state: gen_v(("6", "7")),
sort_by_status: gen_v(("8", "9")),
sort_by_cpu: gen_v(("F1", "F12")),
sort_by_memory: gen_v(("/", "\\")),
sort_by_id: gen_v(("[", "]")),
sort_by_image: gen_v(("A", "B")),
sort_by_rx: gen_v(("C", "D")),
sort_by_tx: gen_v(("backspace", "TAB")),
sort_reset: gen_v(("up", "down")),
toggle_help: gen_v(("home", "end")),
toggle_mouse_capture: gen_v(("pagedown", "PAGEUP")),
};
let result = Keymap::from(Some(input));
let expected = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_deny: (KeyCode::Char('c'), Some(KeyCode::Char('d'))),
delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), Some(KeyCode::Char('h'))),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
quit: (KeyCode::Char('k'), Some(KeyCode::Char('l'))),
save_logs: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
scroll_down_many: (KeyCode::Char('o'), Some(KeyCode::Char('p'))),
scroll_down_one: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
scroll_end: (KeyCode::Char('s'), Some(KeyCode::Char('t'))),
scroll_start: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
scroll_up_many: (KeyCode::Char('w'), Some(KeyCode::Char('x'))),
scroll_up_one: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
select_next_panel: (KeyCode::Char('0'), Some(KeyCode::Char('1'))),
select_previous_panel: (KeyCode::Char('2'), Some(KeyCode::Char('3'))),
sort_by_name: (KeyCode::Char('4'), Some(KeyCode::Char('5'))),
sort_by_state: (KeyCode::Char('6'), Some(KeyCode::Char('7'))),
sort_by_status: (KeyCode::Char('8'), Some(KeyCode::Char('9'))),
sort_by_cpu: (KeyCode::F(1), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Char('/'), Some(KeyCode::Char('\\'))),
sort_by_id: (KeyCode::Char('['), Some(KeyCode::Char(']'))),
sort_by_image: (KeyCode::Char('A'), Some(KeyCode::Char('B'))),
sort_by_rx: (KeyCode::Char('C'), Some(KeyCode::Char('D'))),
sort_by_tx: (KeyCode::Backspace, Some(KeyCode::Tab)),
sort_reset: (KeyCode::Up, Some(KeyCode::Down)),
toggle_help: (KeyCode::Home, Some(KeyCode::End)),
toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)),
};
assert_eq!(expected, result);
}
}
+116
View File
@@ -0,0 +1,116 @@
use std::path::PathBuf;
use clap::Parser;
use parse_args::Args;
use parse_config_file::ConfigFile;
mod color_parser;
mod keymap_parser;
use crate::{ENV_KEY, ENV_VALUE};
pub use {color_parser::AppColors, keymap_parser::Keymap};
mod parse_args;
mod parse_config_file;
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct Config {
pub app_colors: AppColors,
pub color_logs: bool,
pub docker_interval: u32,
pub gui: bool,
pub host: Option<String>,
pub in_container: bool,
pub keymap: Keymap,
pub raw_logs: bool,
pub save_dir: Option<PathBuf>,
pub show_self: bool,
pub show_std_err: bool,
pub show_timestamp: bool,
pub use_cli: bool,
}
impl From<Args> for Config {
fn from(args: Args) -> Self {
Self {
app_colors: AppColors::new(),
color_logs: args.color,
docker_interval: args.docker_interval,
gui: !args.gui,
host: args.host,
in_container: Self::check_if_in_container(),
keymap: Keymap::new(),
raw_logs: args.raw,
save_dir: Self::try_get_logs_dir(args.save_dir.as_ref()),
show_self: !args.show_self,
show_std_err: !args.no_std_err,
show_timestamp: !args.timestamp,
use_cli: args.use_cli,
}
}
}
impl From<ConfigFile> for Config {
fn from(config_file: ConfigFile) -> Self {
Self {
app_colors: AppColors::from(config_file.colors),
color_logs: config_file.color_logs.unwrap_or(false),
docker_interval: config_file.docker_interval.unwrap_or(1000),
gui: config_file.gui.unwrap_or(true),
host: config_file.host,
in_container: Self::check_if_in_container(),
keymap: Keymap::from(config_file.keymap),
raw_logs: config_file.raw_logs.unwrap_or(false),
save_dir: Self::try_get_logs_dir(config_file.save_dir.as_ref()),
show_self: config_file.show_self.unwrap_or(false),
show_std_err: config_file.show_std_err.unwrap_or(true),
show_timestamp: config_file.show_timestamp.unwrap_or(true),
use_cli: config_file.use_cli.unwrap_or(false),
}
}
}
impl Config {
/// Check if oxker is running inside of a container
fn check_if_in_container() -> bool {
std::env::var(ENV_KEY).is_ok_and(|i| i == ENV_VALUE)
}
/// If a cli_arg is provided, create a pathbuf from it, else try to get home_dir automatically
fn try_get_logs_dir(dir: Option<&String>) -> Option<PathBuf> {
dir.as_ref()
.map_or_else(Self::try_get_home_dir, |home_dir| {
Some(std::path::Path::new(&home_dir).to_owned())
})
}
/// Try to get the home dir of the current user
fn try_get_home_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned())
}
/// Generate a new config file
/// First check cli args,
/// then if a config file location is given check then
/// Else check the default location
/// else just return the default config + the cli args
pub fn new() -> Self {
let in_container = Self::check_if_in_container();
let args = Args::parse();
if let Some(config_file) = &args.config_file {
if let Some(config_file) =
parse_config_file::ConfigFile::try_parse_from_file(config_file)
{
return Self::from(config_file);
}
}
if let Some(config_file) = parse_config_file::ConfigFile::try_parse(in_container) {
return Self::from(config_file);
}
Self::from(args)
}
}
+51
View File
@@ -0,0 +1,51 @@
use clap::Parser;
use serde::Deserialize;
#[derive(Parser, Debug, Clone, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
#[command(version, about)]
pub struct Args {
/// Docker update interval in ms, minimum effectively 1000
#[clap(short = 'd', value_name = "ms", default_value_t = 1000)]
pub docker_interval: u32,
/// Remove timestamps from Docker logs
#[clap(short = 't')]
pub timestamp: bool,
/// Attempt to colorize the logs, conflicts with "-r"
#[clap(short = 'c', conflicts_with = "raw")]
pub color: bool,
/// Show raw logs, default is to remove ansi formatting, conflicts with "-c"
#[clap(short = 'r', conflicts_with = "color")]
pub raw: bool,
/// Show self when running as a docker container
#[clap(short = 's')]
pub show_self: bool,
/// Don't draw gui - for debugging - mostly pointless
#[clap(short = 'g')]
pub gui: bool,
/// Docker host, defaults to `/var/run/docker.sock`
#[clap(long, short = None)]
pub host: Option<String>,
/// Do not include stderr output in logs
#[clap(long = "no-stderr")]
pub no_std_err: bool,
/// Directory for saving exported logs, defaults to `$HOME`
#[clap(long="save-dir", short = None)]
pub save_dir: Option<String>,
/// Path to a config file, readable as TOML, JSONC, or JSON
#[clap(long="config-file", short = None)]
pub config_file: Option<String>,
/// Force use of docker cli when execing into containers
#[clap(long="use-cli", short = None)]
pub use_cli: bool,
}
+306
View File
@@ -0,0 +1,306 @@
use std::{
io::{Read, Write},
path::PathBuf,
};
use serde::Deserialize;
use crate::app_error::AppError;
use super::{color_parser::ConfigColors, keymap_parser::ConfigKeymap};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFileType {
Toml,
Jsonc,
Json,
JsoncAsJson,
}
impl TryFrom<&PathBuf> for ConfigFileType {
type Error = AppError;
fn try_from(value: &PathBuf) -> Result<Self, AppError> {
let err = || AppError::IO(format!("Can't parse give config file: {}", value.display()));
let Some(ext) = value.extension() else {
return Err(err());
};
let Some(ext) = ext.to_str() else {
return Err(err());
};
match ext {
"toml" => Ok(Self::Toml),
"json" => Ok(Self::Json),
"jsonc" => Ok(Self::Jsonc),
_ => Err(err()),
}
}
}
impl ConfigFileType {
/// Get the local config directory, to be used by default config parser
fn get_config_dir(in_container: bool) -> Option<PathBuf> {
if in_container {
Some(PathBuf::from("/"))
} else {
directories::BaseDirs::new()
.map(|base_dirs| base_dirs.config_local_dir().join(env!("CARGO_PKG_NAME")))
}
}
// should take in a pathbuf as well?
fn get_default_filename(self, in_container: bool) -> PathBuf {
let suffix = match self {
Self::Json | Self::JsoncAsJson => "config.json",
Self::Jsonc => "config.jsonc",
Self::Toml => "config.toml",
};
Self::get_config_dir(in_container).map_or_else(|| PathBuf::from(suffix), |i| i.join(suffix))
}
}
// impl ConfigFileType
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
pub struct ConfigFile {
pub color_logs: Option<bool>,
pub docker_interval: Option<u32>,
pub gui: Option<bool>,
pub host: Option<String>,
pub raw_logs: Option<bool>,
pub show_timestamp: Option<bool>,
pub save_dir: Option<String>,
pub show_self: Option<bool>,
pub show_std_err: Option<bool>,
pub use_cli: Option<bool>,
pub colors: Option<ConfigColors>,
pub keymap: Option<ConfigKeymap>,
}
impl ConfigFile {
/// Attempt to create an example.config.toml file, will attempt to recursively create the directories as well
fn create_example_file(in_container: bool) -> Result<(), AppError> {
if in_container {
return Ok(());
}
let config_dir = ConfigFileType::get_config_dir(in_container)
.ok_or_else(|| AppError::IO("config_dir".to_owned()))?;
let file_name = config_dir.join("example.config.toml");
if !std::fs::exists(&file_name).map_err(|i| AppError::IO(i.to_string()))? {
if !std::fs::exists(&config_dir).map_err(|i| AppError::IO(i.to_string()))? {
std::fs::DirBuilder::new()
.recursive(true)
.create(&config_dir)
.map_err(|i| AppError::IO(i.to_string()))?;
}
let mut file =
std::fs::File::create_new(&file_name).map_err(|i| AppError::IO(i.to_string()))?;
file.write_all(include_bytes!("./config.toml"))
.map_err(|i| AppError::IO(i.to_string()))?;
file.flush().map_err(|i| AppError::IO(i.to_string()))?;
}
Ok(())
}
/// parse a given &str (read from the configfile) into Self
fn parse(file_type: ConfigFileType, input: &str) -> Result<Self, AppError> {
match file_type {
ConfigFileType::Json => {
serde_json::from_str::<Self>(input).map_err(|i| AppError::Parse(i.to_string()))
}
ConfigFileType::Jsonc | ConfigFileType::JsoncAsJson => {
serde_jsonc::from_str::<Self>(input).map_err(|i| AppError::Parse(i.to_string()))
}
ConfigFileType::Toml => {
toml::from_str::<Self>(input).map_err(|i| AppError::Parse(i.message().to_owned()))
}
}
}
/// Read the config file path to string, then attempt to parse
fn parse_config_file(file_type: ConfigFileType, path: &PathBuf) -> Result<Self, AppError> {
let mut file = std::fs::File::open(path).map_err(|_| {
AppError::IO(
path.to_str()
.map_or_else(String::new, std::borrow::ToOwned::to_owned),
)
})?;
let mut input = String::new();
file.read_to_string(&mut input)
.map_err(|i| AppError::IO(i.to_string()))?;
Self::parse(file_type, &input)
}
/// Resolve conflict in the args, this is handled automatically by Clap, basically just by rejecting it
/// But here we can just change the options - although maybe should be also reject to follow the same behaviour as Clap?
fn resolve_conflict(&mut self) {
if let Some(color) = self.color_logs.as_ref() {
if *color {
self.raw_logs = Some(false);
}
}
if let Some(interval) = self.docker_interval.as_ref() {
if interval < &1000 {
self.docker_interval = Some(1000);
}
}
}
/// Try to parse the config file when the path is user supplied via cliargs
pub fn try_parse_from_file(path: &str) -> Option<Self> {
let path = PathBuf::from(path);
let Ok(file_type) = ConfigFileType::try_from(&path) else {
return None;
};
Self::parse_config_file(file_type, &path).map_or(None, |mut config_file| {
config_file.resolve_conflict();
Some(config_file)
})
}
/// Parse a config file using default config_file location
/// This is executed first, then the CLI args are read, and if they contain a "--config-file" entry, then Self::try_parse_from_file() is executed
pub fn try_parse(in_container: bool) -> Option<Self> {
let mut config = None;
for file_type in [
ConfigFileType::Toml,
ConfigFileType::Jsonc,
ConfigFileType::JsoncAsJson,
ConfigFileType::Json,
] {
if let Ok(mut config_file) =
Self::parse_config_file(file_type, &file_type.get_default_filename(in_container))
{
Self::resolve_conflict(&mut config_file);
config = Some(config_file);
break;
}
}
if config.is_none() {
Self::create_example_file(in_container).ok();
}
config
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::config::{AppColors, Keymap};
use super::ConfigFile;
#[test]
/// ./config.toml parses fine - as this is used to write a file on disk, it's vital that this is always valid
fn test_parse_config_toml_valid() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileType::Toml, example_toml);
assert!(result.is_ok());
}
#[test]
/// make sure config.toml matches the default keymap
fn test_parse_config_keymap_toml() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileType::Toml, example_toml).unwrap();
assert!(result.keymap.is_some());
assert_eq!(Keymap::from(result.keymap), Keymap::new());
}
#[test]
/// make sure example.config.jsonc matches the default keymap
fn test_parse_config_keymap_jsonc() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result = ConfigFile::parse(super::ConfigFileType::Jsonc, example_jsonc).unwrap();
assert!(result.keymap.is_some());
assert_eq!(Keymap::from(result.keymap), Keymap::new());
}
#[test]
/// make sure example.config.json matches the default keymap
fn test_parse_config_keymap_json() {
let example_json = include_str!("../../example_config/example.config.json");
let result = ConfigFile::parse(super::ConfigFileType::Json, example_json).unwrap();
assert!(result.keymap.is_some());
assert_eq!(Keymap::from(result.keymap), Keymap::new());
}
#[test]
/// All configs parsed and are equal
fn test_parse_config_keymap_all() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result_jsonc = ConfigFile::parse(super::ConfigFileType::Jsonc, example_jsonc).unwrap();
assert!(result_jsonc.keymap.is_some());
let result_jsonc = result_jsonc.keymap.unwrap();
let example_json = include_str!("../../example_config/example.config.json");
let result_json = ConfigFile::parse(super::ConfigFileType::Json, example_json).unwrap();
assert!(result_json.keymap.is_some());
let result_json = result_json.keymap.unwrap();
let example_toml = include_str!("./config.toml");
let result_toml = ConfigFile::parse(super::ConfigFileType::Toml, example_toml).unwrap();
assert!(result_toml.keymap.is_some());
let result_toml = result_toml.keymap.unwrap();
assert_eq!(Keymap::from(Some(result_toml.clone())), Keymap::new());
assert_eq!(result_toml, result_jsonc);
assert_eq!(result_jsonc, result_json);
}
#[test]
/// make sure config.toml matches the default app colors
fn test_parse_config_colors_toml() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileType::Toml, example_toml).unwrap();
assert!(result.colors.is_some());
assert_eq!(AppColors::from(result.colors), AppColors::new());
}
#[test]
/// make sure config.toml matches the default app colors
fn test_parse_config_colors_jsonc() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result = ConfigFile::parse(super::ConfigFileType::Jsonc, example_jsonc).unwrap();
assert!(result.colors.is_some());
assert_eq!(AppColors::from(result.colors), AppColors::new());
}
#[test]
/// make sure config.toml matches the default app colors
fn test_parse_config_colors_json() {
let example_json = include_str!("../../example_config/example.config.json");
let result = ConfigFile::parse(super::ConfigFileType::Json, example_json).unwrap();
assert!(result.colors.is_some());
assert_eq!(AppColors::from(result.colors), AppColors::new());
}
#[test]
/// All configs parsed and are equal
fn test_parse_config_colors_all() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result_jsonc = ConfigFile::parse(super::ConfigFileType::Jsonc, example_jsonc).unwrap();
assert!(result_jsonc.colors.is_some());
let result_jsonc = result_jsonc.colors.unwrap();
let example_json = include_str!("../../example_config/example.config.json");
let result_json = ConfigFile::parse(super::ConfigFileType::Json, example_json).unwrap();
assert!(result_json.colors.is_some());
let result_json = result_json.colors.unwrap();
let example_toml = include_str!("./config.toml");
let result_toml = ConfigFile::parse(super::ConfigFileType::Toml, example_toml).unwrap();
assert!(result_toml.colors.is_some());
let result_toml = result_toml.colors.unwrap();
assert_eq!(AppColors::from(Some(result_toml.clone())), AppColors::new());
assert_eq!(result_toml, result_jsonc);
assert_eq!(result_jsonc, result_json);
}
}
+11 -11
View File
@@ -21,7 +21,7 @@ use uuid::Uuid;
use crate::{
app_data::{AppData, ContainerId, DockerCommand, State},
app_error::AppError,
parse_args::CliArgs,
config::Config,
ui::{GuiState, Status},
ENTRY_POINT,
};
@@ -64,7 +64,7 @@ impl Binate {
pub struct DockerData {
app_data: Arc<Mutex<AppData>>,
args: CliArgs,
config: Config,
binate: Binate,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
@@ -214,11 +214,11 @@ impl DockerData {
.into_iter()
.filter_map(|f| match f.id {
Some(_) => {
if self.args.in_container
if self.config.in_container
&& f.command
.as_ref()
.is_some_and(|c| c.starts_with(ENTRY_POINT))
&& self.args.show_self
&& self.config.show_self
{
None
} else {
@@ -271,7 +271,7 @@ impl DockerData {
Arc::clone(&self.app_data);
let docker = Arc::clone(&self.docker);
let spawns = Arc::clone(&self.spawns);
let std_err = self.args.std_err;
let std_err = self.config.show_std_err;
let init = Arc::clone(&init);
self.spawns.lock().insert(
SpawnId::Log(id.clone()),
@@ -319,7 +319,7 @@ impl DockerData {
container.id.clone(),
last_updated,
Arc::clone(&self.spawns),
self.args.std_err,
self.config.show_std_err,
)));
}
};
@@ -399,8 +399,8 @@ impl DockerData {
}
/// Send an update message every x ms, where x is the args.docker_interval
fn heartbeat(args: &CliArgs, docker_tx: Sender<DockerMessage>) {
let update_duration = std::time::Duration::from_millis(u64::from(args.docker_interval));
fn heartbeat(config: &Config, docker_tx: Sender<DockerMessage>) {
let update_duration = std::time::Duration::from_millis(u64::from(config.docker_interval));
let mut now = std::time::Instant::now();
tokio::spawn(async move {
loop {
@@ -421,11 +421,11 @@ impl DockerData {
docker_tx: Sender<DockerMessage>,
gui_state: Arc<Mutex<GuiState>>,
) {
let args = app_data.lock().args.clone();
let args = app_data.lock().config.clone();
if app_data.lock().get_error().is_none() {
let mut inner = Self {
app_data,
args,
config: args,
binate: Binate::One,
docker: Arc::new(docker),
gui_state,
@@ -433,7 +433,7 @@ impl DockerData {
spawns: Arc::new(Mutex::new(HashMap::new())),
};
inner.initialise_container_data().await;
Self::heartbeat(&inner.args, docker_tx);
Self::heartbeat(&inner.config, docker_tx);
inner.message_handler().await;
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ impl ExecMode {
return None;
}
let use_cli = app_data.lock().args.use_cli;
let use_cli = app_data.lock().config.use_cli;
let container = app_data.lock().get_selected_container_id_state_name();
if let Some((id, state, _)) = container {
+202 -59
View File
@@ -21,6 +21,7 @@ mod message;
use crate::{
app_data::{AppData, DockerCommand, Header},
app_error::AppError,
config,
docker_data::DockerMessage,
exec::{tty_readable, ExecMode},
ui::{DeleteButton, GuiState, SelectablePanel, Status, Ui},
@@ -32,6 +33,7 @@ pub use message::InputMessages;
pub struct InputHandler {
app_data: Arc<Mutex<AppData>>,
docker_tx: Sender<DockerMessage>,
keymap: config::Keymap,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
mouse_capture: bool,
@@ -47,11 +49,13 @@ impl InputHandler {
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
) {
let keymap = app_data.lock().config.keymap.clone();
let mut inner = Self {
app_data,
docker_tx,
gui_state,
is_running,
keymap,
rec,
mouse_capture: true,
};
@@ -67,16 +71,15 @@ impl InputHandler {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
if !contains(Status::Error)
if contains(Status::DeleteConfirm) {
self.button_intersect(mouse_event).await;
} else if !contains(Status::Error)
| !contains(Status::Help)
| !contains(Status::DeleteConfirm)
| !contains(Status::Filter)
{
self.mouse_press(mouse_event);
}
if contains(Status::DeleteConfirm) {
self.button_intersect(mouse_event).await;
}
}
}
}
@@ -115,7 +118,7 @@ impl InputHandler {
}
/// Validate that one can exec into a Docker container
async fn e_key(&self) {
async fn exec_key(&self) {
let is_oxker = self.app_data.lock().is_oxker();
if !is_oxker && tty_readable() {
let uuid = Uuid::new_v4();
@@ -142,7 +145,7 @@ impl InputHandler {
}
/// Toggle the mouse capture (via input of the 'm' key)
fn m_key(&mut self) {
fn mouse_capture_key(&mut self) {
let err = || {
self.app_data.lock().set_error(
AppError::MouseCapture(!self.mouse_capture),
@@ -171,7 +174,7 @@ impl InputHandler {
/// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file
async fn save_logs(&self) -> Result<(), Box<dyn std::error::Error>> {
let args = self.app_data.lock().args.clone();
let args = self.app_data.lock().config.clone();
let container = self.app_data.lock().get_selected_container_id_state_name();
if let Some((id, _, name)) = container {
if let Some(log_path) = args.save_dir {
@@ -187,7 +190,7 @@ impl InputHandler {
let options = Some(LogsOptions::<String> {
stderr: true,
stdout: true,
timestamps: args.timestamp,
timestamps: args.show_timestamp,
since: 0,
..Default::default()
});
@@ -230,7 +233,7 @@ impl InputHandler {
}
/// Attempt to save the currently selected container logs to a file
async fn s_key(&self) {
async fn save_key(&self) {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
@@ -284,7 +287,7 @@ impl InputHandler {
/// Change the the "next" selectable panel
/// If no containers, and on Commands panel, skip to next panel, as Commands panel isn't visible in this state
fn tab_key(&self) {
fn next_panel_key(&self) {
self.gui_state.lock().next_panel();
if self.app_data.lock().get_container_len() == 0
&& self.gui_state.lock().get_selected_panel() == SelectablePanel::Commands
@@ -295,7 +298,7 @@ impl InputHandler {
/// Change to previously selected panel
/// Need to skip the commands planel if there no are current containers running
fn back_tab_key(&self) {
fn previous_panel_key(&self) {
self.gui_state.lock().previous_panel();
if self.app_data.lock().get_container_len() == 0
&& self.gui_state.lock().get_selected_panel() == SelectablePanel::Commands
@@ -304,7 +307,7 @@ impl InputHandler {
}
}
fn home_key(&self) {
fn scroll_start_key(&self) {
let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel {
SelectablePanel::Containers => self.app_data.lock().containers_start(),
@@ -314,7 +317,7 @@ impl InputHandler {
}
/// Go to end of the list of the currently selected panel
fn end_key(&self) {
fn scroll_end_key(&self) {
let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel {
SelectablePanel::Containers => self.app_data.lock().containers_end(),
@@ -325,32 +328,41 @@ impl InputHandler {
/// Actions to take when in Help status active
fn handle_help(&mut self, key_code: KeyCode) {
match key_code {
KeyCode::Esc | KeyCode::Char('h' | 'H') => {
if self.keymap.clear.0 == key_code
|| self.keymap.clear.1 == Some(key_code)
|| self.keymap.toggle_help.0 == key_code
|| self.keymap.toggle_help.1 == Some(key_code)
{
self.gui_state.lock().status_del(Status::Help);
}
KeyCode::Char('m' | 'M') => self.m_key(),
_ => (),
if self.keymap.toggle_mouse_capture.0 == key_code
|| self.keymap.toggle_mouse_capture.1 == Some(key_code)
{
self.mouse_capture_key();
}
}
/// Actions to take when Error status active
fn handle_error(&self, key_code: KeyCode) {
match key_code {
KeyCode::Esc | KeyCode::Char('c' | 'C') => {
if self.keymap.clear.0 == key_code || self.keymap.clear.1 == Some(key_code) {
self.app_data.lock().remove_error();
self.gui_state.lock().status_del(Status::Error);
}
_ => (),
}
}
/// Actions to take when Delete status active
async fn handle_delete(&self, key_code: KeyCode) {
match key_code {
KeyCode::Char('y' | 'Y') => self.confirm_delete().await,
KeyCode::Esc | KeyCode::Char('n' | 'N') => self.clear_delete(),
_ => (),
if self.keymap.delete_confirm.0 == key_code
|| self.keymap.delete_confirm.1 == Some(key_code)
{
self.confirm_delete().await;
} else if self.keymap.delete_deny.0 == key_code
|| self.keymap.delete_deny.1 == Some(key_code)
|| self.keymap.clear.0 == key_code
|| self.keymap.clear.1 == Some(key_code)
{
self.clear_delete();
}
}
@@ -361,7 +373,10 @@ impl InputHandler {
self.app_data.lock().filter_term_clear();
self.gui_state.lock().status_del(Status::Filter);
}
KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('/') => {
_ if KeyCode::Enter == key_code
|| self.keymap.filter_mode.0 == key_code
|| self.keymap.filter_mode.1 == Some(key_code) =>
{
self.gui_state.lock().status_del(Status::Filter);
}
KeyCode::Backspace => {
@@ -380,47 +395,159 @@ impl InputHandler {
}
}
/// Handle input that refers to the sorting of columns
fn handle_sort(&self, key_code: KeyCode) {
match key_code {
_ if self.keymap.sort_reset.0 == key_code
|| self.keymap.sort_reset.1 == Some(key_code) =>
{
self.app_data.lock().reset_sorted();
}
_ if self.keymap.sort_by_name.0 == key_code
|| self.keymap.sort_by_name.1 == Some(key_code) =>
{
self.sort(Header::Name);
}
_ if self.keymap.sort_by_state.0 == key_code
|| self.keymap.sort_by_state.1 == Some(key_code) =>
{
self.sort(Header::State);
}
_ if self.keymap.sort_by_status.0 == key_code
|| self.keymap.sort_by_status.1 == Some(key_code) =>
{
self.sort(Header::Status);
}
_ if self.keymap.sort_by_cpu.0 == key_code
|| self.keymap.sort_by_cpu.1 == Some(key_code) =>
{
self.sort(Header::Cpu);
}
_ if self.keymap.sort_by_memory.0 == key_code
|| self.keymap.sort_by_memory.1 == Some(key_code) =>
{
self.sort(Header::Memory);
}
_ if self.keymap.sort_by_id.0 == key_code
|| self.keymap.sort_by_id.1 == Some(key_code) =>
{
self.sort(Header::Id);
}
_ if self.keymap.sort_by_image.0 == key_code
|| self.keymap.sort_by_image.1 == Some(key_code) =>
{
self.sort(Header::Image);
}
_ if self.keymap.sort_by_rx.0 == key_code
|| self.keymap.sort_by_rx.1 == Some(key_code) =>
{
self.sort(Header::Rx);
}
_ if self.keymap.sort_by_tx.0 == key_code
|| self.keymap.sort_by_tx.1 == Some(key_code) =>
{
self.sort(Header::Tx);
}
_ => (),
}
}
/// Handle button presses in all other scenarios
async fn handle_others(&mut self, key_code: KeyCode) {
self.handle_sort(key_code);
match key_code {
KeyCode::Char('0') => self.app_data.lock().reset_sorted(),
KeyCode::Char('1') => self.sort(Header::Name),
KeyCode::Char('2') => self.sort(Header::State),
KeyCode::Char('3') => self.sort(Header::Status),
KeyCode::Char('4') => self.sort(Header::Cpu),
KeyCode::Char('5') => self.sort(Header::Memory),
KeyCode::Char('6') => self.sort(Header::Id),
KeyCode::Char('7') => self.sort(Header::Image),
KeyCode::Char('8') => self.sort(Header::Rx),
KeyCode::Char('9') => self.sort(Header::Tx),
KeyCode::Char('e' | 'E') => self.e_key().await,
KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help),
KeyCode::Char('m' | 'M') => self.m_key(),
KeyCode::Char('s' | 'S') => self.s_key().await,
KeyCode::Tab => self.tab_key(),
KeyCode::BackTab => self.back_tab_key(),
KeyCode::Home => self.home_key(),
KeyCode::End => self.end_key(),
KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(),
KeyCode::PageUp => {
_ if self.keymap.exec.0 == key_code || self.keymap.exec.1 == Some(key_code) => {
self.exec_key().await;
}
_ if self.keymap.toggle_help.0 == key_code
|| self.keymap.toggle_help.1 == Some(key_code) =>
{
self.gui_state.lock().status_push(Status::Help);
}
_ if self.keymap.toggle_mouse_capture.0 == key_code
|| self.keymap.toggle_mouse_capture.1 == Some(key_code) =>
{
self.mouse_capture_key();
}
_ if self.keymap.save_logs.0 == key_code
|| self.keymap.save_logs.1 == Some(key_code) =>
{
self.save_key().await;
}
_ if self.keymap.select_next_panel.0 == key_code
|| self.keymap.select_next_panel.1 == Some(key_code) =>
{
self.next_panel_key();
}
_ if self.keymap.select_previous_panel.0 == key_code
|| self.keymap.select_previous_panel.1 == Some(key_code) =>
{
self.previous_panel_key();
}
_ if self.keymap.scroll_start.0 == key_code
|| self.keymap.scroll_start.1 == Some(key_code) =>
{
self.scroll_start_key();
}
_ if self.keymap.scroll_end.0 == key_code
|| self.keymap.scroll_end.1 == Some(key_code) =>
{
self.scroll_end_key();
}
_ if self.keymap.scroll_up_one.0 == key_code
|| self.keymap.scroll_up_one.1 == Some(key_code) =>
{
self.previous();
}
_ if self.keymap.scroll_up_many.0 == key_code
|| self.keymap.scroll_up_many.1 == Some(key_code) =>
{
for _ in 0..=6 {
self.previous();
}
}
KeyCode::F(1) | KeyCode::Char('/') => {
self.gui_state.lock().status_push(Status::Filter);
self.docker_tx.send(DockerMessage::Update).await.ok();
_ if self.keymap.scroll_down_one.0 == key_code
|| self.keymap.scroll_down_one.1 == Some(key_code) =>
{
self.next();
}
KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(),
KeyCode::PageDown => {
_ if self.keymap.scroll_down_many.0 == key_code
|| self.keymap.scroll_down_many.1 == Some(key_code) =>
{
for _ in 0..=6 {
self.next();
}
}
_ if self.keymap.filter_mode.0 == key_code
|| self.keymap.filter_mode.1 == Some(key_code) =>
{
self.gui_state.lock().status_push(Status::Filter);
self.docker_tx.send(DockerMessage::Update).await.ok();
}
KeyCode::Enter => self.enter_key().await,
_ => (),
}
}
/// Handle keyboard button events
async fn button_press(&mut self, key_code: KeyCode, key_modifier: KeyModifiers) {
let status = self.gui_state.lock().get_status();
@@ -433,9 +560,10 @@ impl InputHandler {
let contains_delete = contains(Status::DeleteConfirm);
if !contains_exec {
let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C');
let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q');
if key_modifier == KeyModifiers::CONTROL && is_c() || is_q() && !contains_filter {
let is_q = || key_code == self.keymap.quit.0 || Some(key_code) == self.keymap.quit.1;
if key_modifier == KeyModifiers::CONTROL && key_code == KeyCode::Char('c')
|| is_q() && !contains_filter
{
// Always just quit on Ctrl + c/C or q/Q, unless in Filter status active
self.quit();
}
@@ -457,7 +585,7 @@ impl InputHandler {
/// Check if a button press interacts with either the yes or no buttons in the delete container confirm window
async fn button_intersect(&self, mouse_event: MouseEvent) {
if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
let intersect = self.gui_state.lock().button_intersect(Rect::new(
let intersect = self.gui_state.lock().get_intersect_button(Rect::new(
mouse_event.column,
mouse_event.row,
1,
@@ -466,8 +594,8 @@ impl InputHandler {
if let Some(button) = intersect {
match button {
DeleteButton::Yes => self.confirm_delete().await,
DeleteButton::No => self.clear_delete(),
DeleteButton::Confirm => self.confirm_delete().await,
DeleteButton::Cancel => self.clear_delete(),
}
}
}
@@ -475,21 +603,36 @@ impl InputHandler {
/// Handle mouse button events
fn mouse_press(&self, mouse_event: MouseEvent) {
// If in help panel, ignore?
let status = self.gui_state.lock().get_status();
if status.contains(&Status::Help) {
let mouse_point = Rect::new(mouse_event.column, mouse_event.row, 1, 1);
let help_intersect = self.gui_state.lock().get_intersect_help(mouse_point);
if help_intersect {
self.gui_state.lock().status_del(Status::Help);
}
} else {
match mouse_event.kind {
MouseEventKind::ScrollUp => self.previous(),
MouseEventKind::ScrollDown => self.next(),
MouseEventKind::Down(MouseButton::Left) => {
let mouse_point = Rect::new(mouse_event.column, mouse_event.row, 1, 1);
let header = self.gui_state.lock().header_intersect(mouse_point);
let header = self.gui_state.lock().get_intersect_header(mouse_point);
if let Some(header) = header {
self.sort(header);
}
let help_intersect = self.gui_state.lock().get_intersect_help(mouse_point);
if help_intersect {
self.gui_state.lock().status_push(Status::Help);
}
self.gui_state.lock().panel_intersect(mouse_point);
self.gui_state.lock().get_intersect_panel(mouse_point);
}
_ => (),
}
}
}
/// Change state to next, depending which panel is currently in focus
fn next(&self) {
+21 -18
View File
@@ -1,10 +1,10 @@
use app_data::AppData;
use app_error::AppError;
use bollard::{Docker, API_DEFAULT_VERSION};
use config::Config;
use docker_data::DockerData;
use input_handler::InputMessages;
use parking_lot::Mutex;
use parse_args::CliArgs;
use std::{
process,
sync::{
@@ -17,10 +17,10 @@ use tracing::{error, info, Level};
mod app_data;
mod app_error;
mod config;
mod docker_data;
mod exec;
mod input_handler;
mod parse_args;
mod ui;
use ui::{GuiState, Status, Ui};
@@ -40,8 +40,9 @@ fn setup_tracing() {
}
/// Read the optional docker_host path, the cli args take priority over the DOCKER_HOST env
fn read_docker_host(args: &CliArgs) -> Option<String> {
args.host
fn read_docker_host(config: &Config) -> Option<String> {
config
.host
.as_ref()
.map_or_else(|| std::env::var(DOCKER_HOST).ok(), |x| Some(x.to_string()))
}
@@ -53,7 +54,7 @@ async fn docker_init(
docker_tx: Sender<DockerMessage>,
gui_state: &Arc<Mutex<GuiState>>,
) {
let host = read_docker_host(&app_data.lock().args);
let host = read_docker_host(&app_data.lock().config);
let connection = host.map_or_else(Docker::connect_with_socket_defaults, |host| {
Docker::connect_with_socket(&host, 120, API_DEFAULT_VERSION)
@@ -96,17 +97,16 @@ fn handler_init(
#[tokio::main]
async fn main() {
setup_tracing();
let config = config::Config::new();
let args = CliArgs::new();
let app_data = Arc::new(Mutex::new(AppData::default(args.clone())));
let app_data = Arc::new(Mutex::new(AppData::default(config.clone())));
let gui_state = Arc::new(Mutex::new(GuiState::default()));
let is_running = Arc::new(AtomicBool::new(true));
let (docker_tx, docker_rx) = tokio::sync::mpsc::channel(32);
docker_init(&app_data, docker_rx, docker_tx.clone(), &gui_state).await;
if args.gui {
if config.gui {
let (input_tx, input_rx) = tokio::sync::mpsc::channel(32);
handler_init(&app_data, &docker_tx, &gui_state, input_rx, &is_running);
Ui::start(app_data, gui_state, input_tx, is_running).await;
@@ -120,7 +120,7 @@ async fn main() {
error!("{}", err);
process::exit(1);
}
if let Some(Ok(to_sleep)) = u128::from(args.docker_interval)
if let Some(Ok(to_sleep)) = u128::from(config.docker_interval)
.checked_sub(now.elapsed().as_millis())
.map(u64::try_from)
{
@@ -155,21 +155,24 @@ mod tests {
AppData, ContainerId, ContainerItem, ContainerPorts, ContainerStatus, Filter,
RunningState, State, StatefulList,
},
parse_args::CliArgs,
config::{AppColors, Config, Keymap},
};
pub const fn gen_args() -> CliArgs {
CliArgs {
color: false,
/// Default test config, has timestamps turned off
pub const fn gen_config() -> Config {
Config {
color_logs: false,
docker_interval: 1000,
gui: true,
host: None,
std_err: false,
show_std_err: false,
in_container: false,
save_dir: None,
raw: false,
raw_logs: false,
show_self: false,
timestamp: false,
app_colors: AppColors::new(),
keymap: Keymap::new(),
show_timestamp: false,
use_cli: false,
}
}
@@ -198,7 +201,7 @@ mod tests {
error: None,
sorted_by: None,
filter: Filter::new(),
args: gen_args(),
config: gen_config(),
}
}
-105
View File
@@ -1,105 +0,0 @@
use std::{path::PathBuf, process};
use clap::Parser;
use tracing::error;
use crate::{ENV_KEY, ENV_VALUE};
#[derive(Parser, Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
#[command(version, about)]
pub struct Args {
/// Docker update interval in ms, minimum effectively 1000
#[clap(short = 'd', value_name = "ms", default_value_t = 1000)]
pub docker_interval: u32,
/// Remove timestamps from Docker logs
#[clap(short = 't')]
pub timestamp: bool,
/// Attempt to colorize the logs, conflicts with "-r"
#[clap(short = 'c', conflicts_with = "raw")]
pub color: bool,
/// Show raw logs, default is to remove ansi formatting, conflicts with "-c"
#[clap(short = 'r', conflicts_with = "color")]
pub raw: bool,
/// Show self when running as a docker container
#[clap(short = 's')]
pub show_self: bool,
/// Don't draw gui - for debugging - mostly pointless
#[clap(short = 'g')]
pub gui: bool,
/// Docker host, defaults to `/var/run/docker.sock`
#[clap(long, short = None)]
pub host: Option<String>,
/// Do not include stderr output in logs
#[clap(long = "no-stderr")]
pub no_std_err: bool,
/// Directory for saving exported logs, defaults to `$HOME`
#[clap(long="save-dir", short = None)]
pub save_dir: Option<String>,
/// Force use of docker cli when execing into containers
#[clap(long="use-cli", short = None)]
pub use_cli: bool,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct CliArgs {
pub color: bool,
pub docker_interval: u32,
pub gui: bool,
pub host: Option<String>,
pub in_container: bool,
pub save_dir: Option<PathBuf>,
pub raw: bool,
pub show_self: bool,
pub timestamp: bool,
pub std_err: bool,
pub use_cli: bool,
}
impl CliArgs {
/// An ENV is set in the ./containerised/Dockerfile, if this is ENV found, then sleep for 250ms, else the container, for as yet unknown reasons, will close immediately
/// returns a bool, so that the `update_all_containers()` won't bother to check the entry point unless running via a container
fn check_if_in_container() -> bool {
std::env::var(ENV_KEY).is_ok_and(|i| i == ENV_VALUE)
}
/// Parse cli arguments
pub fn new() -> Self {
let args = Args::parse();
let logs_dir = args.save_dir.map_or_else(
|| directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned()),
|logs_dir| Some(std::path::Path::new(&logs_dir).to_owned()),
);
// Quit the program if the docker update argument is 0
// Should maybe change it to check if less than 100
if args.docker_interval == 0 {
error!("\"-d\" argument needs to be greater than 0");
process::exit(1)
}
Self {
color: args.color,
docker_interval: args.docker_interval,
use_cli: args.use_cli,
gui: !args.gui,
host: args.host,
in_container: Self::check_if_in_container(),
save_dir: logs_dir,
raw: args.raw,
std_err: !args.no_std_err,
show_self: !args.show_self,
timestamp: !args.timestamp,
}
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod log_sanitizer {
};
/// Attempt to colorize the given string to ratatui standards
/// TODO this is somewhat slow/cpu intensive
pub fn colorize_logs<'a>(input: &str) -> Vec<Line<'a>> {
vec![Line::from(
categorise_text(input)
File diff suppressed because it is too large Load Diff
+507
View File
@@ -0,0 +1,507 @@
use std::fmt::Display;
use ratatui::{
layout::{Alignment, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::Span,
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType},
Frame,
};
use super::{FrameData, CONSTRAINT_50_50};
use crate::{
app_data::{ByteStats, CpuStats, State, Stats},
config::AppColors,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChartType {
Cpu,
Memory,
}
impl ChartType {
const fn name(self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Memory => "memory",
}
}
const fn get_title_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.title,
Self::Memory => colors.chart_memory.title,
}
} else {
state.get_color(colors)
}
}
const fn get_bg_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.background,
Self::Memory => colors.chart_memory.background,
}
}
const fn get_border_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.border,
Self::Memory => colors.chart_memory.border,
}
}
const fn get_y_axis_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.y_axis,
Self::Memory => colors.chart_memory.y_axis,
}
}
const fn get_max_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.max,
Self::Memory => colors.chart_memory.max,
}
} else {
state.get_color(colors)
}
}
}
// mem_stats, mem_dataset, mem.1, "", cpu.2
// current, dataset, max, name, state
/// Create charts
fn make_chart<'a, T: Stats + Display>(
chart_type: ChartType,
colors: AppColors,
current: &'a T,
dataset: Vec<Dataset<'a>>,
max: &'a T,
state: State,
) -> Chart<'a> {
let max_color = chart_type.get_max_color(colors, state);
Chart::new(dataset)
.bg(chart_type.get_bg_color(colors))
.block(
Block::default()
.style(Style::default().bg(chart_type.get_bg_color(colors)))
.title_alignment(Alignment::Center)
.title(Span::styled(
format!(" {} {current} ", chart_type.name()),
Style::default()
.fg(chart_type.get_title_color(colors, state))
.add_modifier(Modifier::BOLD),
))
// .bg(chart_type.get_bg_color(colors))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(chart_type.get_border_color(colors))),
)
.x_axis(Axis::default().bounds([0.00, 60.0]))
.y_axis(
Axis::default()
.labels(vec![
Span::styled("", Style::default().fg(max_color)),
Span::styled(
format!("{max}"),
Style::default().add_modifier(Modifier::BOLD).fg(max_color),
),
])
.style(
Style::new()
// .bg(chart_type.get_bg_color(colors))
.fg(chart_type.get_y_axis_color(colors)),
)
// Add 0.01, so that max point is always visible?
.bounds([0.0, max.get_value() + 0.01]),
)
// .style(Style::new().bg(chart_type.get_bg_color(colors)))
}
/// Draw the cpu + mem charts
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some((cpu, mem)) = fd.chart_data.as_ref() {
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_50_50)
.split(area);
let cpu_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_cpu.points))
.graph_type(GraphType::Line)
.data(&cpu.0)];
let mem_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_memory.points))
.graph_type(GraphType::Line)
.data(&mem.0)];
let cpu_stats = CpuStats::new(cpu.0.last().map_or(0.00, |f| f.1));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let mem_stats = ByteStats::new(mem.0.last().map_or(0, |f| f.1 as u64));
let cpu_chart = make_chart(
ChartType::Cpu,
colors,
&cpu_stats,
cpu_dataset,
&cpu.1,
cpu.2,
);
let mem_chart = make_chart(
ChartType::Memory,
colors,
&mem_stats,
mem_dataset,
&mem.1,
mem.2,
);
f.render_widget(cpu_chart, area[0]);
f.render_widget(mem_chart, area[1]);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::{
app_data::State,
config::AppColors,
ui::{
draw_blocks::tests::{
expected_to_vec, get_result, insert_chart_data, test_setup, COLOR_ORANGE,
},
FrameData,
},
};
/// CPU and Memory charts used in multiple tests, based on data from above insert_chart_data()
const EXPECTED: [&str; 10] = [
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮",
"│10.00%│ • ││100.00 kB│ •• │",
"│ │ •• ││ │ •• │",
"│ │ ••• ││ │ • • │",
"│ │ • • ││ │ • • │",
"│ │ • •• ││ │•• •• │",
"│ │• • ││ │• • │",
"│ │• • ││ │• • │",
"│ │ ││ │ │",
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯",
];
// co-ordinates of the dots from the cpu chart
const CPU_XY: [(usize, usize); 15] = [
(1, 12),
(2, 11),
(2, 12),
(3, 10),
(3, 11),
(3, 12),
(4, 10),
(4, 12),
(5, 9),
(5, 13),
(5, 14),
(6, 8),
(6, 13),
(7, 8),
(7, 13),
];
// co-ordinates of the dots from the memory chart
const MEM_XY: [(usize, usize); 16] = [
(1, 54),
(1, 55),
(2, 54),
(2, 55),
(3, 53),
(3, 55),
(4, 52),
(4, 55),
(5, 51),
(5, 52),
(5, 55),
(5, 56),
(6, 51),
(6, 55),
(7, 51),
(7, 55),
];
#[test]
/// When status is Running, but not data, charts drawn without dots etc, colours correct
fn test_draw_blocks_charts_running_none() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭───────────── cpu 00.00% ─────────────╮╭─────────── memory 0.00 kB ───────────╮",
"│00.00%│ ││0.00 kB│ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 52..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=47) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(2..=8, 1..=6 | 8..=38 | 49..=78 | 41..=47) | (1, 8..=38 | 49..=78) => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When status is Running, charts correctly drawn
fn test_draw_blocks_charts_running_some() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Whens status paused, some text is now Yellow
fn test_draw_blocks_charts_paused() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When dead, text is red
fn test_draw_blocks_charts_dead() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Custom colos correctly applied to each part of the charts
fn test_custom_colors() {
let mut colors = AppColors::new();
colors.chart_cpu.background = Color::White;
colors.chart_cpu.border = Color::Red;
colors.chart_cpu.title = Color::Green;
colors.chart_cpu.max = Color::Magenta;
colors.chart_cpu.points = Color::Black;
colors.chart_cpu.y_axis = Color::Blue;
colors.chart_memory.background = Color::White;
colors.chart_memory.border = Color::Red;
colors.chart_memory.title = Color::Green;
colors.chart_memory.max = Color::Magenta;
colors.chart_memory.points = Color::Black;
colors.chart_memory.y_axis = Color::Blue;
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// border
(0, 0..=13 | 26..=50 | 68..=79) | (9, _) | (1..=8, 0 | 39 | 40 | 79) => {
assert_eq!(result_cell.fg, Color::Red);
}
// title
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
}
// max label
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// data points
xy if CPU_XY.contains(&xy) | MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Black);
}
// y axis
(1..=8, 7 | 50) => {
assert_eq!(result_cell.fg, Color::Blue);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
+415
View File
@@ -0,0 +1,415 @@
use std::sync::Arc;
use super::RIGHT_ARROW;
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel},
};
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
Frame,
};
use super::generate_block;
/// Draw the command panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Commands)
.bg(colors.commands.background);
let items = app_data.lock().get_control_items().map_or(vec![], |i| {
i.iter()
.map(|c| {
let lines = Line::from(vec![Span::styled(
c.to_string(),
Style::default().fg(c.get_color(colors)),
)]);
ListItem::new(lines)
})
.collect::<Vec<_>>()
});
if let Some(i) = app_data.lock().get_control_state() {
let items = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol(RIGHT_ARROW);
f.render_stateful_widget(items, area, i);
} else {
let paragraph = Paragraph::new("").block(block).alignment(Alignment::Center);
f.render_widget(paragraph, area);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::{
config::AppColors,
tests::gen_container_summary,
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup, BORDER_CHARS},
FrameData,
},
};
// cusomt border colors
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
fn test_draw_blocks_commands_none() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, false, false);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────╯",
];
for (row_index, row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (cell_index, cell) in row.iter().enumerate() {
assert_eq!(cell.symbol(), expected_row[cell_index]);
}
}
}
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
fn test_draw_blocks_commands_some() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// Borders & delete
(0 | 5, _) | (1..=4, 0 | 11) | (4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Change the controls state
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup.app_data.lock().docker_controls_next();
let expected = [
"╭──────────╮",
"│ resume │",
"│▶ stop │",
"│ delete │",
"│ │",
"╰──────────╯",
];
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// resume
(1, 3..=8) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
// delete
(0 | 5, _) | (1..=4, 0 | 11) | (3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// When control panel is selected, the border is blue, if not then white, selected text is highlighted
fn test_draw_blocks_commands_panel_selected_color() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
// Unselected, has a grey border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
// Control panel now selected, should have a blue border
setup.gui_state.lock().next_panel();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if row_index == 0
|| row_index == 5
|| result_cell_index == 0
|| result_cell_index == 11
{
assert_eq!(result_cell.fg, Color::LightCyan);
}
if row_index == 1 && result_cell_index > 0 && result_cell_index < 11 {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Custom colors are rendered correctlty
fn test_draw_blocks_commands_custom_colors() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.commands.background = Color::White;
colors.commands.pause = Color::Black;
colors.commands.restart = Color::Green;
colors.commands.stop = Color::Blue;
colors.commands.delete = Color::Magenta;
colors.commands.resume = Color::Yellow;
colors.commands.start = Color::Cyan;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Black);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Green);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
// Change the controls state
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup.app_data.lock().docker_controls_next();
let expected = [
"╭──────────╮",
"│ resume │",
"│▶ stop │",
"│ delete │",
"│ │",
"╰──────────╯",
];
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// resume
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Direction, Layout},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::{CONSTRAINT_BUTTONS, CONSTRAINT_POPUP};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::{
gui_state::{BoxLocation, Region},
DeleteButton, GuiState,
},
};
use super::popup;
/// Draw the delete confirm box in the centre of the screen
/// take in container id and container name here?
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
name: &ContainerName,
) {
let block = Block::default()
.title(" Confirm Delete ")
.border_type(BorderType::Rounded)
.style(
Style::default()
.bg(colors.popup_delete.background)
.fg(colors.popup_delete.text),
)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let confirm = Line::from(vec![
Span::from("Are you sure you want to delete container: "),
Span::styled(
name.get(),
Style::default()
.fg(colors.popup_delete.text_highlight)
.bg(colors.popup_delete.background)
.add_modifier(Modifier::BOLD),
),
]);
let yes_text = if keymap.delete_confirm == Keymap::new().delete_confirm {
"( y ) yes".to_owned()
} else if let Some(secondary) = keymap.delete_confirm.1 {
format!("( {} | {} ) yes", keymap.delete_confirm.0, secondary)
} else {
format!("( {} ) yes", keymap.delete_confirm.0)
};
let no_text = if keymap.delete_deny == Keymap::new().delete_deny {
"( n ) no".to_owned()
} else if let Some(secondary) = keymap.delete_deny.1 {
format!("( {} | {} ) no", keymap.delete_deny.0, secondary)
} else {
format!("( {} ) no", keymap.delete_deny.0)
};
// Find the maximum line width & height, and add some padding
let max_line_width = u16::try_from(confirm.width()).unwrap_or(64) + 12;
let lines = 8;
let confirm_para = Paragraph::new(confirm).alignment(Alignment::Center);
let button_block = || {
Block::default()
.border_type(BorderType::Rounded)
.borders(Borders::ALL)
.style(Style::default().bg(colors.popup_delete.background))
};
let yes_para = Paragraph::new(yes_text)
.alignment(Alignment::Center)
.block(button_block());
let no_para = Paragraph::new(no_text)
.alignment(Alignment::Center)
.block(button_block());
let area = popup::draw(
lines,
max_line_width.into(),
f.area(),
BoxLocation::MiddleCentre,
);
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints(CONSTRAINT_POPUP)
.split(area);
let split_buttons = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_BUTTONS)
.split(split_popup[3]);
let no_area = split_buttons[1];
let yes_area = split_buttons[3];
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(confirm_para, split_popup[1]);
f.render_widget(no_para, no_area);
f.render_widget(yes_para, yes_area);
// Insert button areas into region map, so can interact with them on click
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Cancel), no_area);
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Confirm), yes_area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::KeyCode;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete_long_name() {
let (w, h) = (106, 10);
let mut setup = test_setup(w, h, true, true);
let name = ContainerName::from("container_1_container_1_container_1");
setup.app_data.lock().containers.items[0].name = name.clone();
let expected = [
" ",
" ╭──────────────────────────────────── Confirm Delete ────────────────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1_container_1_container_1 │ ",
" │ │ ",
" │ ╭──────────────────────────────╮ ╭─────────────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰──────────────────────────────╯ ╰─────────────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(colors, f, &setup.gui_state, keymap, &name);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 98..=106) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=91) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom colors applied correctly to delete popup
fn test_draw_blocks_delete_custom_colors() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut colors = AppColors::new();
colors.popup_delete.background = Color::Black;
colors.popup_delete.text = Color::Yellow;
colors.popup_delete.text_highlight = Color::Green;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&Keymap::new(),
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
}
}
}
}
}
#[test]
/// Custom keymap, with multiple definitions for each button, applied correctly to delete popup
#[allow(clippy::too_many_lines)]
fn test_draw_blocks_delete_custom_keymap() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End ) no │ │ ( F10 ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, None);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End | Up ) no │ │ ( F10 | L ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), Some(KeyCode::Char('L')));
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End | Up ) no │ │ ( F10 ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+358
View File
@@ -0,0 +1,358 @@
use ratatui::{
layout::Alignment,
style::Style,
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::{max_line_width, NAME, VERSION};
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::gui_state::BoxLocation,
};
use super::popup;
/// Draw an error popup over whole screen
pub fn draw(
f: &mut Frame,
error: &AppError,
keymap: &Keymap,
seconds: Option<u8>,
colors: AppColors,
) {
let block = Block::default()
.title(" Error ")
.border_type(BorderType::Rounded)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let to_push = if matches!(error, AppError::DockerConnect) {
format!(
"\n\n {}::v{} closing in {:02} seconds",
NAME,
VERSION,
seconds.unwrap_or(5)
)
} else {
let clear_suffix = "clear error";
let clear_text = if keymap.clear == Keymap::new().clear {
format!("( {} ) {clear_suffix}", keymap.clear.0)
} else if let Some(secondary) = keymap.clear.1 {
format!(" ( {} | {secondary} ) {clear_suffix}", keymap.clear.0)
} else {
format!(" ( {} ) {clear_suffix}", keymap.clear.0)
};
let quit_suffix = "quit oxker";
let quit_text = if keymap.quit == Keymap::new().quit {
format!("( {} ) {quit_suffix}", keymap.quit.0)
} else if let Some(secondary) = keymap.quit.1 {
format!(" ( {} | {secondary} ) {quit_suffix}", keymap.quit.0)
} else {
format!(" ( {} ) {quit_suffix}", keymap.quit.0)
};
format!("\n\n{clear_text}\n\n{quit_text}")
};
let mut text = format!("\n{error}");
text.push_str(to_push.as_str());
// Find the maximum line width & height
let padded_width = max_line_width(&text) + 8;
let line_count = text.lines().count();
let padded_height = if line_count % 2 == 0 {
line_count + 3
} else {
line_count + 2
};
let paragraph = Paragraph::new(text)
.style(
Style::default()
.bg(colors.popup_error.background)
.fg(colors.popup_error.text),
)
.block(block)
.alignment(Alignment::Center);
let area = popup::draw(
padded_height,
padded_width,
f.area(),
BoxLocation::MiddleCentre,
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::VERSION;
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
use crossterm::event::KeyCode;
use ratatui::style::Color;
#[test]
/// Test that the error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_docker_connect_error() {
let (w, h) = (46, 9);
let mut setup = test_setup(w, h, true, true);
let app_colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerConnect, keymap, Some(4), app_colors);
})
.unwrap();
let version_row = format!(" │ oxker::v{VERSION} closing in 04 seconds │ ");
let expected = [
" ",
" ╭───────────────── Error ──────────────────╮ ",
" │ │ ",
" │ Unable to access docker daemon │ ",
" │ │ ",
version_row.as_str(),
" │ │ ",
" ╰──────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 8, _) | (1..=7, 0 | 45) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Test that the clearable error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_clearable_error() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let app_colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, keymap, Some(4), app_colors);
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( q ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Custom colors applied to the error popup correctly
fn test_draw_blocks_clearable_error_custom_colors() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let keymap = &setup.app_data.lock().config.keymap;
let mut colors = AppColors::new();
colors.popup_error.background = Color::Yellow;
colors.popup_error.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, keymap, Some(4), colors);
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( q ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Yellow);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom keymap applied correct with both 1 and 2 definitions
fn test_draw_blocks_clearable_error_custom_keymap() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, None);
keymap.quit = (KeyCode::F(4), None);
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( Back Tab ) clear error │ ",
" │ │ ",
" │ ( F4 ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, Some(KeyCode::Char('m')));
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( Back Tab | m ) clear error │ ",
" │ │ ",
" │ ( F4 | End ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let mut keymap = Keymap::new();
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( F4 | End ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
Frame,
};
use crate::{app_data::FilterBy, ui::FrameData};
/// Create the filter_by by spans, coloured dependant on which one is selected
fn filter_by_spans(fd: &FrameData) -> [Span; 4] {
let selected = Style::default().bg(Color::Gray).fg(Color::Black);
let not_selected = Style::default().bg(Color::Reset).fg(Color::Reset);
let name = [" Name ", " Image ", " Status ", " All "];
let mut filter_spans = [
Span::styled(name[0], not_selected),
Span::styled(name[1], not_selected),
Span::styled(name[2], not_selected),
Span::styled(name[3], not_selected),
];
match fd.filter_by {
FilterBy::Name => filter_spans[0] = Span::styled(name[0], selected),
FilterBy::Image => filter_spans[1] = Span::styled(name[1], selected),
FilterBy::Status => filter_spans[2] = Span::styled(name[2], selected),
FilterBy::All => filter_spans[3] = Span::styled(name[3], selected),
}
filter_spans
}
/// Draw the filter bar
pub fn draw(area: Rect, frame: &mut Frame, fd: &FrameData) {
let style_but = Style::default().fg(Color::Black).bg(Color::Magenta);
let style_desc = Style::default().fg(Color::Gray).bg(Color::Reset);
let mut line = vec![
Span::styled(" Esc ", style_but),
Span::styled(" clear ", style_desc),
Span::styled(" ← by → ", style_but),
Span::from(" "),
];
line.extend_from_slice(&filter_by_spans(fd));
line.extend_from_slice(&[
Span::styled(
" term: ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(
fd.filter_term
.as_ref()
.map_or(String::new(), std::clone::Clone::clone),
Style::default().fg(Color::Gray),
),
]);
frame.render_widget(Line::from(line), area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData,
};
#[test]
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
/// Filter row is drawn correctly & colors are correct
/// Colours change when filter_by option is changed
fn test_draw_blocks_filter_row() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &setup.fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Test when char added to search term
setup.app_data.lock().filter_term_push('c');
setup.app_data.lock().filter_term_push('d');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: cd "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 54..=55 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Test when filter_by chances
setup.app_data.lock().filter_by_next();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: cd "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 54..=55 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
27..=33 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
+557
View File
@@ -0,0 +1,557 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Paragraph},
Frame,
};
use super::{CONSTRAINT_100, MARGIN};
use crate::{
app_data::{Header, SortedOrder},
config::{AppColors, Keymap},
ui::{gui_state::Region, FrameData, GuiState, Status},
};
// Draw heading bar at top of program, always visible
/// TODO Should separate into loading icon/headers/help functions
#[allow(clippy::too_many_lines)]
pub fn draw(
area: Rect,
colors: AppColors,
frame: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
) {
let gen_style = |bg: Option<Color>, fg: Color| {
bg.map_or_else(
|| Style::default().fg(fg),
|bg| Style::default().bg(bg).fg(fg),
)
};
frame.render_widget(
Block::default().style(gen_style(Some(colors.headers_bar.background), Color::Reset)),
area,
);
// Generate a block for the header, if the header is currently being used to sort a column, then highlight it white
let header_block = |x: &Header, colors: AppColors| {
let mut color = colors.headers_bar.text;
let mut suffix = "";
if let Some((a, b)) = &fd.sorted_by {
if x == a {
match b {
SortedOrder::Asc => suffix = "",
SortedOrder::Desc => suffix = "",
}
color = colors.headers_bar.text_selected;
};
};
(color, suffix)
};
// Generate block for the headers, state and status has a specific layout, others all equal
// width is dependant on it that column is selected to sort - or not
// TODO - yes this is a mess, needs documenting correctly
let gen_header = |header: &Header, width: usize, colors: AppColors| {
let block = header_block(header, colors);
let text = format!(
"{x:<width$}{MARGIN}",
x = format!("{header}{ic}", ic = block.1),
);
let count = u16::try_from(text.chars().count()).unwrap_or_default();
let status = Paragraph::new(text)
.style(gen_style(None, block.0))
.alignment(Alignment::Left);
(status, count)
};
// Meta data to iterate over to create blocks with correct widths
let header_meta = [
(Header::Name, fd.columns.name.1),
(Header::State, fd.columns.state.1),
(Header::Status, fd.columns.status.1),
(Header::Cpu, fd.columns.cpu.1),
(Header::Memory, fd.columns.mem.1 + fd.columns.mem.2 + 3),
(Header::Id, fd.columns.id.1),
(Header::Image, fd.columns.image.1),
(Header::Rx, fd.columns.net_rx.1),
(Header::Tx, fd.columns.net_tx.1),
];
let suffix = if fd.status.contains(&Status::Help) {
"exit"
} else {
"show"
};
let info_text = if keymap.toggle_help == Keymap::new().toggle_help {
format!("( h ) {suffix} help{MARGIN}")
} else if let Some(secondary) = keymap.toggle_help.1 {
format!(
" ( {} | {secondary} ) {suffix} help{MARGIN}",
keymap.toggle_help.0
)
} else {
format!(" ( {} ) {suffix} help{MARGIN}", keymap.toggle_help.0)
};
let info_width = info_text.chars().count();
let column_width = usize::from(area.width).saturating_sub(info_width);
let column_width = if column_width > 0 { column_width } else { 1 };
let splits = if fd.has_containers {
vec![
Constraint::Max(4),
Constraint::Max(column_width.try_into().unwrap_or_default()),
Constraint::Max(info_width.try_into().unwrap_or_default()),
]
} else {
CONSTRAINT_100.to_vec()
};
let split_bar = Layout::default()
.direction(Direction::Horizontal)
.constraints(splits)
.split(area);
// Draw loading icon, or not, and a prefix with a single space
let loading_paragraph = Paragraph::new(format!("{:>2}", fd.loading_icon))
.style(gen_style(None, colors.headers_bar.loading_spinner))
.alignment(Alignment::Left);
frame.render_widget(loading_paragraph, split_bar[0]);
if fd.has_containers {
let header_section_width = split_bar[1].width;
let mut counter = 0;
// Only show a header if the header cumulative header width is less than the header section width
let header_data = header_meta
.iter()
.filter_map(|i| {
let header_block = gen_header(&i.0, i.1.into(), colors);
counter += header_block.1;
if counter <= header_section_width {
Some((header_block.0, i.0, Constraint::Max(header_block.1)))
} else {
None
}
})
.collect::<Vec<_>>();
let container_splits = header_data.iter().map(|i| i.2).collect::<Vec<_>>();
let headers_section = Layout::default()
.direction(Direction::Horizontal)
.constraints(container_splits)
.split(split_bar[1]);
for (index, (paragraph, header, _)) in header_data.into_iter().enumerate() {
let rect = headers_section[index];
gui_state
.lock()
.update_region_map(Region::Header(header), rect);
frame.render_widget(paragraph, rect);
}
}
// show/hide help
let help_text_color = if fd.status.contains(&Status::Help) {
colors.headers_bar.text
} else {
colors.headers_bar.text_selected
};
let help_paragraph = Paragraph::new(info_text)
.style(gen_style(None, help_text_color))
.alignment(Alignment::Right);
// If no containers, don't display the headers, could maybe do this first?
let help_index = if fd.has_containers { 2 } else { 0 };
gui_state
.lock()
.update_region_map(Region::HelpPanel, split_bar[help_index]);
frame.render_widget(help_paragraph, split_bar[help_index]);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::ops::RangeInclusive;
use crossterm::event::KeyCode;
use ratatui::style::Color;
use uuid::Uuid;
use crate::{
app_data::{Header, SortedOrder, StatefulList},
config::{AppColors, Keymap},
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData, Status,
},
};
#[test]
/// Heading back only has show/exit help when no containers, correctly coloured
fn test_draw_blocks_headers_no_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers = StatefulList::new(vec![]);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.fg, Color::Gray,);
}
}
fd.status.insert(Status::Help);
let expected = [" ( h ) exit help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
#[test]
/// Show all headings when containers present, colors valid
fn test_draw_blocks_headers_some_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
112..=121 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Only show the headings that fit the reduced-in-size header section
fn test_draw_blocks_headers_some_containers_reduced_width() {
let (w, h) = (80, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected =
[" name state status cpu ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=50 => Color::Black,
51..=61 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Test all combination of headers & sort by
fn test_draw_blocks_headers_sort_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
// Actual test, used for each header and sorted type
let mut test =
|expected: &[&str], range: RangeInclusive<usize>, x: (Header, SortedOrder)| {
fd.sorted_by = Some(x);
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
122..=139 => Color::Gray,
// given range | help section
x if range.contains(&x) => Color::Gray,
112..=121 => Color::Reset,
_ => Color::Black,
}
);
}
}
};
// Name
test(&[" name ▲ state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 1..=17, (Header::Name, SortedOrder::Asc));
test(&[" name ▼ state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 1..=17, (Header::Name, SortedOrder::Desc));
// state
test(&[" name state ▲ status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "],18..=29, (Header::State, SortedOrder::Asc));
test(&[" name state ▼ status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 18..=29, (Header::State, SortedOrder::Desc));
// status
test(&[" name state status ▲ cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 30..=41, (Header::Status, SortedOrder::Asc));
test(&[" name state status ▼ cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 30..=41, (Header::Status, SortedOrder::Desc));
// cpu
test(&[" name state status cpu ▲ memory/limit id image ↓ rx ↑ tx ( h ) show help "],42..=50, (Header::Cpu, SortedOrder::Asc));
test(&[" name state status cpu ▼ memory/limit id image ↓ rx ↑ tx ( h ) show help "],42..=50, (Header::Cpu, SortedOrder::Desc));
// memory
test(&[" name state status cpu memory/limit ▲ id image ↓ rx ↑ tx ( h ) show help "], 51..=70, (Header::Memory, SortedOrder::Asc));
test(&[" name state status cpu memory/limit ▼ id image ↓ rx ↑ tx ( h ) show help "], 51..=70, (Header::Memory, SortedOrder::Desc));
//id
test(&[" name state status cpu memory/limit id ▲ image ↓ rx ↑ tx ( h ) show help "], 71..=81, (Header::Id, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id ▼ image ↓ rx ↑ tx ( h ) show help "], 71..=81, (Header::Id, SortedOrder::Desc));
// image
test(&[" name state status cpu memory/limit id image ▲ ↓ rx ↑ tx ( h ) show help "], 82..=91, (Header::Image, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ▼ ↓ rx ↑ tx ( h ) show help "], 82..=91, (Header::Image, SortedOrder::Desc));
// rx
test(&[" name state status cpu memory/limit id image ↓ rx ▲ ↑ tx ( h ) show help "], 92..=101, (Header::Rx, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ↓ rx ▼ ↑ tx ( h ) show help "], 92..=101, (Header::Rx, SortedOrder::Desc));
// tx
test(&[" name state status cpu memory/limit id image ↓ rx ↑ tx ▲ ( h ) show help "], 102..=111, (Header::Tx, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ↓ rx ↑ tx ▼ ( h ) show help "], 102..=111, (Header::Tx, SortedOrder::Desc));
}
#[test]
/// Show animation
fn test_draw_blocks_headers_animation() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" ⠙ name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
122..=140 => Color::Gray,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom colors are applied correctly
fn test_draw_blocks_headers_cusomt_colors() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let keymap = &setup.app_data.lock().config.keymap;
let mut colors = AppColors::new();
colors.headers_bar.background = Color::Black;
colors.headers_bar.loading_spinner = Color::Green;
colors.headers_bar.text = Color::Blue;
colors.headers_bar.text_selected = Color::Yellow;
let expected = [" ⠙ name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd, &setup.gui_state, keymap);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::Green,
4..=111 => Color::Blue,
122..=140 => Color::Yellow,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom keymap for help panel is correctly display, with one and two definitions
fn test_draw_blocks_headers_custom_keymap() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let mut keymap = Keymap::new();
keymap.toggle_help = (KeyCode::Char('T'), None);
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( T ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
keymap.toggle_help = (KeyCode::Char('T'), Some(KeyCode::Tab));
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( T | Tab ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+886
View File
@@ -0,0 +1,886 @@
use crossterm::event::KeyCode;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use crate::{
config::{AppColors, Keymap},
ui::gui_state::BoxLocation,
};
use super::{popup, DESCRIPTION, NAME_TEXT, REPO, VERSION};
/// Help popup box needs these three pieces of information
struct HelpInfo {
lines: Vec<Line<'static>>,
width: usize,
height: usize,
}
impl HelpInfo {
/// Find the max width of a Span in &[Line]
fn calc_width(lines: &[Line]) -> usize {
lines
.iter()
.map(ratatui::prelude::Line::width)
.max()
.unwrap_or(1)
}
/// Just an empty span, i.e. a new line
fn empty_span<'a>() -> Line<'a> {
Line::from(String::new())
}
/// generate a span, of given &str and given color
fn span<'a>(input: &str, color: Color) -> Span<'a> {
Span::styled(input.to_owned(), Style::default().fg(color))
}
/// &str to black text span
fn text_span<'a>(input: &str, color: AppColors) -> Span<'a> {
Self::span(input, color.popup_help.text)
}
/// &str to white text span
fn highlighted_text_span<'a>(input: &str, color: AppColors) -> Span<'a> {
Self::span(input, color.popup_help.text_highlight)
}
/// Generate the `oxker` name span + metadata
fn gen_name(colors: AppColors) -> Self {
let mut lines = NAME_TEXT
.lines()
.map(|i| Line::from(Self::highlighted_text_span(i, colors)))
.collect::<Vec<_>>();
lines.insert(0, Self::empty_span());
let width = Self::calc_width(&lines);
let height = lines.len();
Self {
lines,
width,
height,
}
}
/// Generate the description span + metadata
fn gen_description(colors: AppColors) -> Self {
let lines = [
Self::empty_span(),
Line::from(Self::highlighted_text_span(DESCRIPTION, colors)),
Self::empty_span(),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the button information span + metadata
fn gen_keymap_info(colors: AppColors) -> Self {
let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors);
let button_desc = |x: &str| Self::text_span(x, colors);
let or = || button_desc("or");
let space = || button_desc(" ");
let lines = [
Line::from(vec![
space(),
button_item("tab"),
or(),
button_item("shift+tab"),
button_desc("change panels"),
]),
Line::from(vec![
space(),
button_item("↑ ↓"),
or(),
button_item("j k"),
or(),
button_item("PgUp PgDown"),
or(),
button_item("Home End"),
button_desc("change selected line"),
]),
Line::from(vec![
space(),
button_item("enter"),
button_desc("send docker container command"),
]),
Line::from(vec![
space(),
button_item("e"),
button_desc("exec into a container"),
#[cfg(target_os = "windows")]
button_desc(" - not available on Windows"),
]),
Line::from(vec![
space(),
button_item("h"),
button_desc("toggle this help information - or click heading"),
]),
Line::from(vec![
space(),
button_item("s"),
button_desc("save logs to file"),
]),
Line::from(vec![
space(),
button_item("m"),
button_desc(
"toggle mouse capture - if disabled, text on screen can be selected & copied",
),
]),
Line::from(vec![
space(),
button_item("F1"),
or(),
button_item("/"),
button_desc("enter filter mode"),
]),
Line::from(vec![space(), button_item("0"), button_desc("stop sort")]),
Line::from(vec![
space(),
button_item("1 - 9"),
button_desc("sort by header - or click header"),
]),
Line::from(vec![
space(),
button_item("esc"),
button_desc("close dialog"),
]),
Line::from(vec![
space(),
button_item("q"),
button_desc("quit at any time"),
]),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the final lines, GitHub link etc, + metadata
fn gen_final(colors: AppColors) -> Self {
let lines = [
Self::empty_span(),
Line::from(vec![Self::text_span(
"currently an early work in progress, all and any input appreciated",
colors,
)]),
Line::from(vec![Span::styled(
REPO,
Style::default()
.fg(colors.popup_help.text_highlight)
.add_modifier(Modifier::UNDERLINED),
)]),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the display information when a custom keymap is being used
fn gen_custom_keymap_info(colors: AppColors, km: &Keymap) -> Self {
let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors);
let button_desc = |x: &str| Self::text_span(x, colors);
let or = || button_desc("or");
let space = || button_desc(" ");
let or_secondary = |a: (KeyCode, Option<KeyCode>), desc: &str| {
a.1.map_or_else(
|| {
Line::from(vec![
space(),
button_item(&a.0.to_string()),
button_desc(desc),
])
},
|secondary| {
Line::from(vec![
space(),
button_item(&a.0.to_string()),
or(),
button_item(&secondary.to_string()),
button_desc(desc),
])
},
)
};
let lines = [
Line::from(vec![Span::from("Custom keymap config in use\n")])
.alignment(Alignment::Center)
.style(Style::default().fg(colors.popup_help.text_highlight)),
or_secondary(km.select_next_panel, "select next panel"),
or_secondary(km.select_previous_panel, "select previous panel"),
or_secondary(km.scroll_down_one, "scroll list down by one"),
or_secondary(km.scroll_up_one, "scroll list up by one"),
or_secondary(km.scroll_down_many, "scroll list down by many"),
or_secondary(km.scroll_up_many, "scroll list by up many"),
or_secondary(km.scroll_end, "scroll list to end"),
or_secondary(km.scroll_start, "scroll list to start"),
Line::from(vec![
space(),
button_item("enter"),
button_desc("send docker container command"),
]),
#[cfg(not(target_os = "windows"))]
or_secondary(km.exec, "exec into a container"),
#[cfg(target_os = "windows")]
or_secondary(km.exec, "exec into a container - not available on Windows"),
or_secondary(
km.toggle_help,
"toggle this help information - or click heading",
),
or_secondary(km.toggle_help, "save logs to file"),
or_secondary(
km.toggle_mouse_capture,
"toggle mouse capture - if disabled, text on screen can be selected & copied",
),
or_secondary(km.filter_mode, "enter filter mode"),
or_secondary(km.sort_reset, "reset container sorting"),
or_secondary(km.sort_by_name, "sort containers by name"),
or_secondary(km.sort_by_state, "sort containers by state"),
or_secondary(km.sort_by_status, "sort containers by status"),
or_secondary(km.sort_by_cpu, "sort containers by cpu"),
or_secondary(km.sort_by_memory, "sort containers by memory"),
or_secondary(km.sort_by_id, "sort containers by id"),
or_secondary(km.sort_by_image, "sort containers by image"),
or_secondary(km.sort_by_rx, "sort containers by rx"),
or_secondary(km.sort_by_tx, "sort containers by tx"),
or_secondary(km.clear, "close dialog"),
or_secondary(km.quit, "quit at any time"),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
}
/// Draw the help box in the centre of the screen
pub fn draw(f: &mut Frame, colors: AppColors, keymap: &Keymap) {
let title = format!(" {VERSION} ");
let name_info = HelpInfo::gen_name(colors);
let description_info = HelpInfo::gen_description(colors);
let final_info = HelpInfo::gen_final(colors);
let button_info = if keymap == &Keymap::new() {
HelpInfo::gen_keymap_info(colors)
} else {
HelpInfo::gen_custom_keymap_info(colors, keymap)
};
let max_line_width = [
name_info.width,
description_info.width,
button_info.width,
final_info.width,
]
.into_iter()
.max()
.unwrap_or_default()
+ 2;
let max_height =
name_info.height + description_info.height + button_info.height + final_info.height + 2;
let area = popup::draw(
max_height,
max_line_width,
f.area(),
BoxLocation::MiddleCentre,
);
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Max(name_info.height.try_into().unwrap_or_default()),
Constraint::Max(description_info.height.try_into().unwrap_or_default()),
Constraint::Max(button_info.height.try_into().unwrap_or_default()),
Constraint::Min(final_info.height.try_into().unwrap_or_default()),
])
.split(area);
let name_paragraph = Paragraph::new(name_info.lines)
.style(
Style::default()
.bg(colors.popup_help.background)
.fg(colors.popup_help.text_highlight),
)
.alignment(Alignment::Center);
let style = || {
Style::default()
.bg(colors.popup_help.background)
.fg(colors.popup_help.text)
};
let description_paragraph = Paragraph::new(description_info.lines)
.style(style())
.alignment(Alignment::Center);
let help_paragraph = Paragraph::new(button_info.lines)
.style(style())
.alignment(Alignment::Left);
let final_paragraph = Paragraph::new(final_info.lines)
.style(style())
.alignment(Alignment::Center);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::default()
.fg(colors.popup_help.text)
.bg(colors.popup_help.background),
);
// 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(help_paragraph, split_popup[2]);
f.render_widget(final_paragraph, split_popup[3]);
f.render_widget(block, area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::{
config::{AppColors, Keymap},
ui::draw_blocks::VERSION,
};
use crossterm::event::KeyCode;
use ratatui::style::{Color, Modifier};
use crate::ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup};
#[test]
/// This will cause issues once the version has more than the current 5 chars (0.5.0)
fn test_draw_blocks_help() {
let (w, h) = (87, 33);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(f, colors, &setup.app_data.lock().config.keymap);
})
.unwrap();
let version_row = format!("{VERSION} ────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ ( tab ) or ( shift+tab ) change panels │ ",
" │ ( ↑ ↓ ) or ( j k ) or ( PgUp PgDown ) or ( Home End ) change selected line │ ",
" │ ( enter ) send docker container command │ ",
" │ ( e ) exec into a container │ ",
" │ ( h ) toggle this help information - or click heading │ ",
" │ ( s ) save logs to file │ ",
" │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( F1 ) or ( / ) enter filter mode │ ",
" │ ( 0 ) stop sort │ ",
" │ ( 1 - 9 ) sort by header - or click header │ ",
" │ ( esc ) close dialog │ ",
" │ ( q ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰───────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
// first & last row, and first & last char on each row, is reset/reset, making sure that the help info is centered in the given area
(0 | 32, _) | (0..=33, 0 | 86) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
// border is black on magenta
(1 | 31, _) | (1..=31, 1 | 85) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
// oxker logo && description
(2..=10, 2..=85) | (12, 19..=66)
// button in the brackets
| (14, 2..=10 | 13..=27)
| (15, 2..=10 | 13..=21 | 24..=40 | 43..=56)
| (16 | 23, 2..=12)
| (17..=20 | 22 | 25, 2..=8)
| (21, 2..=9 | 12..=18)
| (24, 2..=10) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::White);
}
// The URL is white and underlined
(28, 25..=60) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::White);
assert_eq!(result_cell.modifier, Modifier::UNDERLINED);
}
// The rest is black on magenta
_ => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Test that the help panel gets drawn with custom colors
fn test_draw_blocks_help_custom_colors() {
let (w, h) = (87, 33);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.popup_help.background = Color::Black;
colors.popup_help.text = Color::Red;
colors.popup_help.text_highlight = Color::Yellow;
setup
.terminal
.draw(|f| {
super::draw(f, colors, &setup.app_data.lock().config.keymap);
})
.unwrap();
let version_row = format!("{VERSION} ────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ ( tab ) or ( shift+tab ) change panels │ ",
" │ ( ↑ ↓ ) or ( j k ) or ( PgUp PgDown ) or ( Home End ) change selected line │ ",
" │ ( enter ) send docker container command │ ",
" │ ( e ) exec into a container │ ",
" │ ( h ) toggle this help information - or click heading │ ",
" │ ( s ) save logs to file │ ",
" │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( F1 ) or ( / ) enter filter mode │ ",
" │ ( 0 ) stop sort │ ",
" │ ( 1 - 9 ) sort by header - or click header │ ",
" │ ( esc ) close dialog │ ",
" │ ( q ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰───────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
// first & last row, and first & last char on each row, is reset/reset, making sure that the help info is centered in the given area
(0 | 32, _) | (0..=33, 0 | 86) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
// border is black on magenta
(1 | 31, _) | (1..=31, 1 | 85) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Red);
}
// oxker logo && description
(2..=10, 2..=85) | (12, 19..=66)
// button in the brackets
| (14, 2..=10 | 13..=27)
| (15, 2..=10 | 13..=21 | 24..=40 | 43..=56)
| (16 | 23, 2..=12)
| (17..=20 | 22 | 25, 2..=8)
| (21, 2..=9 | 12..=18)
| (24, 2..=10) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
}
// The URL is yellow and underlined
(28, 25..=60) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::UNDERLINED);
}
// The rest is red on black
_ => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Red);
}
}
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with one definition for each entry
fn test_draw_blocks_custom_keymap_one_definition() {
let (w, h) = (98, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), None),
delete_deny: (KeyCode::Char('c'), None),
delete_confirm: (KeyCode::Char('e'), None),
exec: (KeyCode::Char('g'), None),
filter_mode: (KeyCode::Char('i'), None),
quit: (KeyCode::Char('k'), None),
save_logs: (KeyCode::Char('m'), None),
scroll_down_many: (KeyCode::Char('o'), None),
scroll_down_one: (KeyCode::Char('q'), None),
scroll_end: (KeyCode::Char('s'), None),
scroll_start: (KeyCode::Char('u'), None),
scroll_up_many: (KeyCode::Char('w'), None),
scroll_up_one: (KeyCode::Char('y'), None),
select_next_panel: (KeyCode::Char('0'), None),
select_previous_panel: (KeyCode::Char('2'), None),
sort_by_name: (KeyCode::Char('4'), None),
sort_by_state: (KeyCode::Char('6'), None),
sort_by_status: (KeyCode::Char('8'), None),
sort_by_cpu: (KeyCode::F(1), None),
sort_by_memory: (KeyCode::Char('#'), None),
sort_by_id: (KeyCode::Char('/'), None),
sort_by_image: (KeyCode::Char(','), None),
sort_by_rx: (KeyCode::Char('.'), None),
sort_by_tx: (KeyCode::Backspace, None),
sort_reset: (KeyCode::Up, None),
toggle_help: (KeyCode::Home, None),
toggle_mouse_capture: (KeyCode::PageDown, None),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ─────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) select next panel │ ",
" │ ( 2 ) select previous panel │ ",
" │ ( q ) scroll list down by one │ ",
" │ ( y ) scroll list up by one │ ",
" │ ( o ) scroll list down by many │ ",
" │ ( w ) scroll list by up many │ ",
" │ ( s ) scroll list to end │ ",
" │ ( u ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) exec into a container │ ",
" │ ( Home ) toggle this help information - or click heading │ ",
" │ ( Home ) save logs to file │ ",
" │ ( Page Down ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) enter filter mode │ ",
" │ ( Up ) reset container sorting │ ",
" │ ( 4 ) sort containers by name │ ",
" │ ( 6 ) sort containers by state │ ",
" │ ( 8 ) sort containers by status │ ",
" │ ( F1 ) sort containers by cpu │ ",
" │ ( # ) sort containers by memory │ ",
" │ ( / ) sort containers by id │ ",
" │ ( , ) sort containers by image │ ",
" │ ( . ) sort containers by rx │ ",
" │ ( Backspace ) sort containers by tx │ ",
" │ ( a ) close dialog │ ",
" │ ( k ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if row_index == 14 && (36..=62).contains(&result_cell_index) {
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with two definition for each entry
fn test_draw_blocks_custom_keymap_two_definitions() {
let (w, h) = (110, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_deny: (KeyCode::Char('c'), Some(KeyCode::Char('d'))),
delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), Some(KeyCode::Char('h'))),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
quit: (KeyCode::Char('k'), Some(KeyCode::Char('l'))),
save_logs: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
scroll_down_many: (KeyCode::Char('o'), Some(KeyCode::Char('p'))),
scroll_down_one: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
scroll_end: (KeyCode::Char('s'), Some(KeyCode::Char('t'))),
scroll_start: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
scroll_up_many: (KeyCode::Char('w'), Some(KeyCode::Char('x'))),
scroll_up_one: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
select_next_panel: (KeyCode::Char('0'), Some(KeyCode::Char('1'))),
select_previous_panel: (KeyCode::Char('2'), Some(KeyCode::Char('3'))),
sort_by_name: (KeyCode::Char('4'), Some(KeyCode::Char('5'))),
sort_by_state: (KeyCode::Char('6'), Some(KeyCode::Char('7'))),
sort_by_status: (KeyCode::Char('8'), Some(KeyCode::Char('9'))),
sort_by_cpu: (KeyCode::F(1), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Char('#'), Some(KeyCode::Char('-'))),
sort_by_id: (KeyCode::Char('/'), Some(KeyCode::Char('='))),
sort_by_image: (KeyCode::Char(','), Some(KeyCode::Char('\\'))),
sort_by_rx: (KeyCode::Char('.'), Some(KeyCode::Char(']'))),
sort_by_tx: (KeyCode::Backspace, Some(KeyCode::BackTab)),
sort_reset: (KeyCode::Up, Some(KeyCode::Down)),
toggle_help: (KeyCode::Home, Some(KeyCode::Delete)),
toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ───────────────────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) or ( 1 ) select next panel │ ",
" │ ( 2 ) or ( 3 ) select previous panel │ ",
" │ ( q ) or ( r ) scroll list down by one │ ",
" │ ( y ) or ( z ) scroll list up by one │ ",
" │ ( o ) or ( p ) scroll list down by many │ ",
" │ ( w ) or ( x ) scroll list by up many │ ",
" │ ( s ) or ( t ) scroll list to end │ ",
" │ ( u ) or ( v ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) or ( h ) exec into a container │ ",
" │ ( Home ) or ( Del ) toggle this help information - or click heading │ ",
" │ ( Home ) or ( Del ) save logs to file │ ",
" │ ( Page Down ) or ( Page Up ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) or ( j ) enter filter mode │ ",
" │ ( Up ) or ( Down ) reset container sorting │ ",
" │ ( 4 ) or ( 5 ) sort containers by name │ ",
" │ ( 6 ) or ( 7 ) sort containers by state │ ",
" │ ( 8 ) or ( 9 ) sort containers by status │ ",
" │ ( F1 ) or ( F12 ) sort containers by cpu │ ",
" │ ( # ) or ( - ) sort containers by memory │ ",
" │ ( / ) or ( = ) sort containers by id │ ",
r" │ ( , ) or ( \ ) sort containers by image │ ",
" │ ( . ) or ( ] ) sort containers by rx │ ",
" │ ( Backspace ) or ( Back Tab ) sort containers by tx │ ",
" │ ( a ) or ( b ) close dialog │ ",
" │ ( k ) or ( l ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with either one or two definition for each entry
fn test_draw_blocks_custom_keymap_one_and_two_definitions() {
let (w, h) = (110, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_deny: (KeyCode::Char('c'), None),
delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), None),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
quit: (KeyCode::Char('k'), None),
save_logs: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
scroll_down_many: (KeyCode::Char('o'), None),
scroll_down_one: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
scroll_end: (KeyCode::Char('s'), None),
scroll_start: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
scroll_up_many: (KeyCode::Char('w'), None),
scroll_up_one: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
select_next_panel: (KeyCode::Char('0'), None),
select_previous_panel: (KeyCode::Char('2'), Some(KeyCode::Char('3'))),
sort_by_name: (KeyCode::Char('4'), None),
sort_by_state: (KeyCode::Char('6'), Some(KeyCode::Char('7'))),
sort_by_status: (KeyCode::Char('8'), None),
sort_by_cpu: (KeyCode::F(1), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Char('#'), None),
sort_by_id: (KeyCode::Char('/'), Some(KeyCode::Char('='))),
sort_by_image: (KeyCode::Char(','), None),
sort_by_rx: (KeyCode::Char('.'), Some(KeyCode::Char(']'))),
sort_by_tx: (KeyCode::Backspace, None),
sort_reset: (KeyCode::Up, Some(KeyCode::Down)),
toggle_help: (KeyCode::Home, None),
toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ───────────────────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) select next panel │ ",
" │ ( 2 ) or ( 3 ) select previous panel │ ",
" │ ( q ) or ( r ) scroll list down by one │ ",
" │ ( y ) or ( z ) scroll list up by one │ ",
" │ ( o ) scroll list down by many │ ",
" │ ( w ) scroll list by up many │ ",
" │ ( s ) scroll list to end │ ",
" │ ( u ) or ( v ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) exec into a container │ ",
" │ ( Home ) toggle this help information - or click heading │ ",
" │ ( Home ) save logs to file │ ",
" │ ( Page Down ) or ( Page Up ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) or ( j ) enter filter mode │ ",
" │ ( Up ) or ( Down ) reset container sorting │ ",
" │ ( 4 ) sort containers by name │ ",
" │ ( 6 ) or ( 7 ) sort containers by state │ ",
" │ ( 8 ) sort containers by status │ ",
" │ ( F1 ) or ( F12 ) sort containers by cpu │ ",
" │ ( # ) sort containers by memory │ ",
" │ ( / ) or ( = ) sort containers by id │ ",
" │ ( , ) sort containers by image │ ",
" │ ( . ) or ( ] ) sort containers by rx │ ",
" │ ( Backspace ) sort containers by tx │ ",
" │ ( a ) or ( b ) close dialog │ ",
" │ ( k ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
use std::{sync::Arc, time::Instant};
use parking_lot::Mutex;
use ratatui::{
layout::Alignment,
style::Style,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::{
config::AppColors,
ui::{gui_state::BoxLocation, GuiState},
};
use super::{max_line_width, popup};
/// Draw info box in one of the 9 BoxLocations
// TODO is this broken - I don't think so
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
instant: &Instant,
msg: String,
) {
let block = Block::default()
.title("")
.title_alignment(Alignment::Center)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.borders(Borders::NONE);
let max_line_width = max_line_width(&msg) + 8;
let lines = msg.lines().count() + 2;
let paragraph = Paragraph::new(msg)
.block(block)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.alignment(Alignment::Center);
let area = popup::draw(lines, max_line_width, f.area(), BoxLocation::BottomRight);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
if instant.elapsed().as_millis() > 4000 {
gui_state.lock().reset_info_box();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::Color;
use crate::{
config::AppColors,
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
#[test]
/// Info box drawn in bottom right
fn test_draw_blocks_info() {
let (w, h) = (45, 9);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" test ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Blue, Color::White),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
#[test]
/// Info box drawn in bottom right with custom colors applied
fn test_draw_blocks_info_custom_color() {
let (w, h) = (45, 9);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.popup_info.background = Color::Red;
colors.popup_info.text = Color::Black;
let expected = [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" test ",
" ",
];
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Red, Color::Black),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
}
+386
View File
@@ -0,0 +1,386 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{List, Paragraph},
Frame,
};
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel, Status},
};
use super::{generate_block, RIGHT_ARROW};
/// Draw the logs panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Logs);
if fd.status.contains(&Status::Init) {
let paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon))
.style(Style::default())
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area);
} else {
let logs = app_data.lock().get_logs();
if logs.is_empty() {
let paragraph = Paragraph::new("no logs found")
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area);
} else {
let items = List::new(logs)
.block(block)
.highlight_symbol(RIGHT_ARROW)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
// This should always return Some, as logs is not empty
if let Some(log_state) = app_data.lock().get_log_state() {
f.render_stateful_widget(items, area, log_state);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use uuid::Uuid;
use crate::{
app_data::{ContainerImage, ContainerName},
ui::{
draw_blocks::tests::{
expected_to_vec, get_result, insert_logs, test_setup, BORDER_CHARS,
},
FrameData, Status,
},
};
#[test]
/// No logs, panel unselected, then selected, border color changes correctly
fn test_draw_blocks_logs_none() {
let (w, h) = (35, 6);
let mut setup = test_setup(w, h, true, true);
let expected = [
"╭ Logs - container_1 - image_1 ───╮",
"│ no logs found │",
"│ │",
"│ │",
"│ │",
"╰─────────────────────────────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 5, 0..=34) | (1..=4, 0) | (1..=5, 34) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
setup.gui_state.lock().next_panel();
setup.gui_state.lock().next_panel();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
// When selected, has a blue border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::LightCyan);
}
}
}
}
#[test]
/// Parsing logs, spinner visible, and then animates by one frame
fn test_draw_blocks_logs_parsing() {
let (w, h) = (32, 6);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let expected = [
"╭ Logs - container_1 - image_1 ╮",
"│ parsing logs ⠙ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
// animation moved by one frame
setup.gui_state.lock().next_loading(uuid);
let expected = [
"╭ Logs - container_1 - image_1 ╮",
"│ parsing logs ⠹ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Logs correct displayed, changing log state also draws correctly
fn test_draw_blocks_logs_some() {
let (w, h) = (36, 6);
let mut setup = test_setup(w, h, true, true);
insert_logs(&setup);
let colors = setup.app_data.lock().config.app_colors;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭ Logs 3/3 - container_1 - image_1 ╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"╰──────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 3 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
// Change selected log line
setup.app_data.lock().log_previous();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭ Logs 2/3 - container_1 - image_1 ╮",
"│ line 1 │",
"│▶ line 2 │",
"│ line 3 │",
"│ │",
"╰──────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 2 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Full (long) name displayed in logs border
fn test_draw_blocks_logs_long_name() {
let (w, h) = (80, 6);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
insert_logs(&setup);
let expected = [
"╭ Logs 3/3 - a_long_container_name_for_the_purposes_of_this_test - a_long_image╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+525
View File
@@ -0,0 +1,525 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Constraint, Rect},
style::Style,
widgets::{Block, BorderType, Borders},
};
use crate::config::AppColors;
use super::{gui_state::Region, FrameData, GuiState, SelectablePanel, Status};
pub mod charts;
pub mod commands;
pub mod containers;
pub mod delete_confirm;
pub mod error;
pub mod filter;
pub mod headers;
pub mod help;
pub mod info;
pub mod logs;
pub mod popup;
pub mod ports;
pub const NAME_TEXT: &str = r#"
88
88
88
,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba,
a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8
8b d8 )888( 8888[ 8PP""""""" 88
"8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88
`"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 "#;
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REPO: &str = env!("CARGO_PKG_REPOSITORY");
pub const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
pub const MARGIN: &str = " ";
pub const RIGHT_ARROW: &str = "";
pub const CIRCLE: &str = "";
pub const CONSTRAINT_50_50: [Constraint; 2] =
[Constraint::Percentage(50), Constraint::Percentage(50)];
pub const CONSTRAINT_100: [Constraint; 1] = [Constraint::Percentage(100)];
pub const CONSTRAINT_POPUP: [Constraint; 5] = [
Constraint::Min(2),
Constraint::Max(1),
Constraint::Max(1),
Constraint::Max(3),
Constraint::Min(1),
];
pub const CONSTRAINT_BUTTONS: [Constraint; 5] = [
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
];
/// From a given &str, return the maximum number of chars on a single line
pub fn max_line_width(text: &str) -> usize {
text.lines()
.map(|i| i.chars().count())
.max()
.unwrap_or_default()
}
/// Generate block, add a border if is the selected panel,
/// add custom title based on state of each panel
fn generate_block<'a>(
area: Rect,
colors: AppColors,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
panel: SelectablePanel,
) -> Block<'a> {
gui_state
.lock()
.update_region_map(Region::Panel(panel), area);
let mut title = match panel {
SelectablePanel::Containers => {
format!("{}{}", panel.title(), fd.container_title)
}
SelectablePanel::Logs => {
format!("{}{}", panel.title(), fd.log_title)
}
SelectablePanel::Commands => String::new(),
};
if !title.is_empty() {
title = format!(" {title} ");
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(title);
if !fd.status.contains(&Status::Filter) {
if fd.selected_panel == panel {
block = block.border_style(Style::default().fg(colors.borders.selected));
} else {
block = block.border_style(Style::default().fg(colors.borders.unselected));
}
}
block
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub mod tests {
use std::{
net::{IpAddr, Ipv4Addr},
sync::Arc,
};
use parking_lot::Mutex;
use ratatui::{backend::TestBackend, layout::Rect, style::Color, Terminal};
use crate::{
app_data::{AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts},
tests::{gen_appdata, gen_containers},
ui::{draw_frame, GuiState},
};
use super::FrameData;
pub struct TuiTestSetup {
pub app_data: Arc<Mutex<AppData>>,
pub gui_state: Arc<Mutex<GuiState>>,
pub fd: FrameData,
pub area: Rect,
pub terminal: Terminal<TestBackend>,
pub ids: Vec<ContainerId>,
}
pub const BORDER_CHARS: [&str; 6] = ["", "", "", "", "", ""];
pub const COLOR_RX: Color = Color::Rgb(255, 233, 193);
pub const COLOR_TX: Color = Color::Rgb(205, 140, 140);
pub const COLOR_ORANGE: Color = Color::Rgb(255, 178, 36);
impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData {
fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> Self {
let (app_data, gui_data) = (data.0.lock(), data.1.lock());
// set max height for container section, needs +5 to deal with docker commands list and borders
let height = app_data.get_container_len();
let height = if height < 12 {
u16::try_from(height + 5).unwrap_or_default()
} else {
12
};
let (filter_by, filter_term) = app_data.get_filter();
Self {
chart_data: app_data.get_chart_data(),
columns: app_data.get_width(),
container_title: app_data.get_container_title(),
delete_confirm: gui_data.get_delete_container(),
filter_by,
filter_term: filter_term.cloned(),
has_containers: app_data.get_container_len() > 0,
has_error: app_data.get_error(),
height,
ports: app_data.get_selected_ports(),
port_max_lens: app_data.get_longest_port(),
info_text: gui_data.info_box_text.clone(),
is_loading: gui_data.is_loading(),
loading_icon: gui_data.get_loading().to_string(),
log_title: app_data.get_log_title(),
selected_panel: gui_data.get_selected_panel(),
sorted_by: app_data.get_sorted(),
status: gui_data.get_status(),
}
}
}
/// Generate state to be used in *most* gui tests
pub fn test_setup(w: u16, h: u16, control_start: bool, container_start: bool) -> TuiTestSetup {
let backend = TestBackend::new(w, h);
let terminal = Terminal::new(backend).unwrap();
let (ids, containers) = gen_containers();
let mut app_data = gen_appdata(&containers);
if control_start {
app_data.docker_controls_start();
}
if container_start {
app_data.containers_start();
}
let gui_state = GuiState::default();
let app_data = Arc::new(Mutex::new(app_data));
let gui_state = Arc::new(Mutex::new(gui_state));
let fd = FrameData::from((&app_data, &gui_state));
let area = Rect::new(0, 0, w, h);
TuiTestSetup {
app_data,
gui_state,
fd,
area,
terminal,
ids,
}
}
/// Get a single row of String's from the expected data
pub fn expected_to_vec(expected: &[&str], row_index: usize) -> Vec<String> {
expected[row_index]
.chars()
.map(|i| i.to_string())
.collect::<Vec<_>>()
}
pub fn get_result(
setup: &TuiTestSetup,
w: u16,
) -> std::iter::Enumerate<std::slice::Chunks<ratatui::buffer::Cell>> {
setup
.terminal
.backend()
.buffer()
.content
.chunks(usize::from(w))
.enumerate()
}
/// Insert some logs into the first container
pub fn insert_logs(setup: &TuiTestSetup) {
let logs = (1..=3).map(|i| format!("{i} line {i}")).collect::<Vec<_>>();
setup.app_data.lock().update_log_by_id(logs, &setup.ids[0]);
}
#[allow(clippy::cast_precision_loss)]
// Add fixed data to the cpu & mem vecdeques
pub fn insert_chart_data(setup: &TuiTestSetup) {
for i in 1..=10 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
for i in 1..=3 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
}
// *************** //
// The whole layout //
// **************** //
#[test]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout() {
let (w, h) = (160, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│",
"│ │ ••• • ││ │ ••• • ││ 8001 │",
"│ │•• ••• ││ │•• ••• ││127.0.0.1 8003 8003│",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
#[allow(clippy::too_many_lines)]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout_with_filter() {
let (w, h) = (160, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[1]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│",
"│ │ ••• • ││ │ ••• • ││ 8001 │",
"│ │•• ••• ││ │•• ••• ││ │",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup.app_data.lock().filter_term_push('r');
setup.app_data.lock().filter_term_push('_');
setup.app_data.lock().filter_term_push('1');
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/1 - filtered ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ ││ restart │",
"│ ││ stop │",
"│ ││ delete │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ ••• ││100.00 kB│ •• ││ ip private public│",
"│ │ •• • ││ │ •• • ││ 8001 │",
"│ │ ••• • • ││ │ ••• • • ││ │",
"│ │• •• ││ │• •• ││ │",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
" Esc clear ← by → Name Image Status All term: r_1 ",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
/// Check that the whole layout is drawn correctly when have long container name and long image name
fn test_draw_blocks_whole_layout_long_name() {
let (w, h) = (190, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭─────────────────╮",
"│⚪ a_long_container_name_for_the… ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 a_long_image_name_for_the_pur… 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰─────────────────╯",
"╭ Logs 3/3 - a_long_container_name_for_the_purposes_of_this_test - a_long_image_name_for_the_purposes_of_this_test ──────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────────────── cpu 03.00% ─────────────────────────────────╮╭────────────────────────────── memory 30.00 kB ───────────────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••••• ││ ip private public│",
"│ │ •••• • ││ │ ••• • ││ 8001 │",
"│ │••• •••• ││ │••• ••• ││127.0.0.1 8003 8003│",
"│ │ ││ │ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use ratatui::layout::{Direction, Layout, Rect};
use crate::ui::gui_state::BoxLocation;
/// draw a box in the one of the BoxLocations, based on max line width + number of lines
pub fn draw(text_lines: usize, text_width: usize, r: Rect, box_location: BoxLocation) -> Rect {
// Make sure blank_space can't be an negative, as will crash
let calc = |x: u16, y: usize| usize::from(x).saturating_sub(y).saturating_div(2);
let blank_vertical = calc(r.height, text_lines);
let blank_horizontal = calc(r.width, text_width);
let (h_constraints, v_constraints) = box_location.get_constraints(
blank_horizontal.try_into().unwrap_or_default(),
blank_vertical.try_into().unwrap_or_default(),
text_lines.try_into().unwrap_or_default(),
text_width.try_into().unwrap_or_default(),
);
let indexes = box_location.get_indexes();
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(v_constraints)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(h_constraints)
.split(popup_layout[indexes.0])[indexes.1]
}
+320
View File
@@ -0,0 +1,320 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::{app_data::State, config::AppColors, ui::FrameData};
/// Get the port title color, at the moment the color is only customizable if the container is alive
const fn get_port_title_color(colors: AppColors, state: State) -> Color {
if state.is_alive() {
colors.chart_ports.title
} else {
state.get_color(colors)
}
}
/// Display the ports in a formatted list
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some(ports) = fd.ports.as_ref() {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::new().fg(colors.chart_ports.border))
// .bg(colors.chart_ports.border))
.title_alignment(Alignment::Center)
.title(Span::styled(
" ports ",
Style::default()
.fg(get_port_title_color(colors, ports.1))
.add_modifier(Modifier::BOLD),
));
let (ip, private, public) = fd.port_max_lens;
if ports.0.is_empty() {
let text = match ports.1 {
State::Running(_) | State::Paused | State::Restarting => "no ports",
_ => "",
};
let paragraph = Paragraph::new(Span::from(text).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(block);
f.render_widget(paragraph, area);
} else {
let mut output = vec![Line::from(
Span::from(format!(
"{:>ip$}{:>private$}{:>public$}",
"ip", "private", "public"
))
.fg(colors.chart_ports.headings),
)];
for item in &ports.0 {
let strings = item.get_all();
let line = vec![
Span::from(format!("{:>ip$}", strings.0)).fg(colors.chart_ports.text),
Span::from(format!("{:>private$}", strings.1)).fg(colors.chart_ports.text),
Span::from(format!("{:>public$}", strings.2)).fg(colors.chart_ports.text),
];
output.push(Line::from(line));
}
let paragraph = Paragraph::new(output).block(block);
f.render_widget(paragraph, area);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use ratatui::style::{Color, Modifier};
use crate::{
app_data::{ContainerPorts, State},
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData,
},
};
#[test]
/// Port section when container has no ports
fn test_draw_blocks_ports_no_ports() {
let (w, h) = (30, 8);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0].ports = vec![];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭────────── ports ───────────╮",
"│ no ports │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 11..=17) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 11..=18) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
// When state is "State::Running | State::Paused | State::Restarting, won't show "no ports"
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭────────── ports ───────────╮",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 11..=17) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Port section when container has multiple ports
fn test_draw_blocks_ports_multiple_ports() {
let (w, h) = (32, 8);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: None,
private: 8002,
public: None,
});
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭─────────── ports ────────────╮",
"│ ip private public │",
"│ 8001 │",
"│ 8002 │",
"│127.0.0.1 8003 8003 │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
(0, 12..=18) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=28) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert!(result_cell.modifier.is_empty());
}
(2..=4, 1..=28) | (0 | 2..=9, 0..=31) | (1, 0 | 29..=31) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Port section title color correct dependant on state
fn test_draw_blocks_ports_container_state() {
let (w, h) = (32, 8);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭─────────── ports ────────────╮",
"│ ip private public │",
"│ 8001 │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Exited;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
}
}
+38 -23
View File
@@ -49,13 +49,14 @@ impl SelectablePanel {
pub enum Region {
Panel(SelectablePanel),
Header(Header),
HelpPanel,
Delete(DeleteButton),
}
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
pub enum DeleteButton {
Yes,
No,
Confirm,
Cancel,
}
#[allow(unused)]
@@ -173,21 +174,22 @@ pub enum Status {
#[derive(Debug, Default)]
pub struct GuiState {
delete_container: Option<ContainerId>,
delete_map: HashMap<DeleteButton, Rect>,
heading_map: HashMap<Header, Rect>,
exec_mode: Option<ExecMode>,
loading_handle: Option<JoinHandle<()>>,
loading_index: u8,
loading_set: HashSet<Uuid>,
panel_map: HashMap<SelectablePanel, Rect>,
intersect_delete: HashMap<DeleteButton, Rect>,
intersect_heading: HashMap<Header, Rect>,
intersect_help: Option<Rect>,
intersect_panel: HashMap<SelectablePanel, Rect>,
selected_panel: SelectablePanel,
status: HashSet<Status>,
exec_mode: Option<ExecMode>,
pub info_box_text: Option<(String, Instant)>,
}
impl GuiState {
/// Clear panels hash map, so on resize can fix the sizes for mouse clicks
pub fn clear_area_map(&mut self) {
self.panel_map.clear();
self.intersect_panel.clear();
}
/// Get the currently selected panel
@@ -196,9 +198,9 @@ impl GuiState {
}
/// Check if a given Rect (a clicked area of 1x1), interacts with any known panels
pub fn panel_intersect(&mut self, rect: Rect) {
pub fn get_intersect_panel(&mut self, rect: Rect) {
if let Some(data) = self
.panel_map
.intersect_panel
.iter()
.filter(|i| i.1.intersects(rect))
.collect::<Vec<_>>()
@@ -209,8 +211,8 @@ impl GuiState {
}
/// Check if a given Rect (a clicked area of 1x1), interacts with any known delete button
pub fn button_intersect(&self, rect: Rect) -> Option<DeleteButton> {
self.delete_map
pub fn get_intersect_button(&self, rect: Rect) -> Option<DeleteButton> {
self.intersect_delete
.iter()
.filter(|i| i.1.intersects(rect))
.collect::<Vec<_>>()
@@ -219,8 +221,8 @@ impl GuiState {
}
/// Check if a given Rect (a clicked area of 1x1), interacts with any known panels
pub fn header_intersect(&self, rect: Rect) -> Option<Header> {
self.heading_map
pub fn get_intersect_header(&self, rect: Rect) -> Option<Header> {
self.intersect_heading
.iter()
.filter(|i| i.1.intersects(rect))
.collect::<Vec<_>>()
@@ -228,24 +230,37 @@ impl GuiState {
.map(|data| *data.0)
}
/// Check if a the "show/hide help" section has been clicked
pub fn get_intersect_help(&self, rect: Rect) -> bool {
self.intersect_help
.as_ref()
.is_some_and(|i| i.intersects(rect))
}
/// Insert, or updates header area panel into heading_map
pub fn update_region_map(&mut self, region: Region, area: Rect) {
match region {
Region::Header(header) => self
.heading_map
Region::Header(header) => {
self.intersect_heading
.entry(header)
.and_modify(|w| *w = area)
.or_insert(area),
Region::Panel(panel) => self
.panel_map
.or_insert(area);
}
Region::Panel(panel) => {
self.intersect_panel
.entry(panel)
.and_modify(|w| *w = area)
.or_insert(area),
Region::Delete(button) => self
.delete_map
.or_insert(area);
}
Region::Delete(button) => {
self.intersect_delete
.entry(button)
.and_modify(|w| *w = area)
.or_insert(area),
.or_insert(area);
}
Region::HelpPanel => {
self.intersect_help = Some(area);
}
};
}
@@ -260,7 +275,7 @@ impl GuiState {
if id.is_some() {
self.status.insert(Status::DeleteConfirm);
} else {
self.delete_map.clear();
self.intersect_delete.clear();
self.status.remove(&Status::DeleteConfirm);
}
self.delete_container = id;
+36 -17
View File
@@ -32,11 +32,11 @@ use crate::{
SortedOrder, State,
},
app_error::AppError,
config::{AppColors, Keymap},
exec::TerminalSize,
input_handler::InputMessages,
};
pub const ORANGE: ratatui::style::Color = ratatui::style::Color::Rgb(255, 178, 36);
const POLL_RATE: Duration = std::time::Duration::from_millis(100);
pub struct Ui {
@@ -124,6 +124,8 @@ impl Ui {
/// Draw the the error message ui, for 5 seconds, with a countdown
fn err_loop(&mut self) -> Result<(), AppError> {
let mut seconds = 5;
let colors = self.app_data.lock().config.app_colors;
let keymap = self.app_data.lock().config.keymap.clone();
loop {
if self.now.elapsed() >= std::time::Duration::from_secs(1) {
seconds -= 1;
@@ -135,7 +137,15 @@ impl Ui {
if self
.terminal
.draw(|f| draw_blocks::error(f, AppError::DockerConnect, Some(seconds)))
.draw(|f| {
draw_blocks::error::draw(
f,
&AppError::DockerConnect,
&keymap,
Some(seconds),
colors,
);
})
.is_err()
{
return Err(AppError::Terminal);
@@ -165,6 +175,8 @@ impl Ui {
/// The loop for drawing the main UI to the terminal
async fn gui_loop(&mut self) -> Result<(), AppError> {
let colors = self.app_data.lock().config.app_colors;
let keymap = self.app_data.lock().config.keymap.clone();
while self.is_running.load(Ordering::SeqCst) {
let fd = FrameData::from(&*self);
let exec = fd.status.contains(&Status::Exec);
@@ -174,7 +186,9 @@ impl Ui {
if self
.terminal
.draw(|frame| draw_frame(frame, &self.app_data, &self.gui_state, &fd))
.draw(|frame| {
draw_frame(&self.app_data, colors, &keymap, frame, &fd, &self.gui_state);
})
.is_err()
{
return Err(AppError::Terminal);
@@ -223,6 +237,7 @@ impl Ui {
/// Frequent data required by multiple frame drawing functions, can reduce mutex reads by placing it all in here
#[derive(Debug, Clone)]
pub struct FrameData {
// app_colors: AppColors,
chart_data: Option<(CpuTuple, MemTuple)>,
columns: Columns,
container_title: String,
@@ -257,6 +272,7 @@ impl From<&Ui> for FrameData {
let (filter_by, filter_term) = app_data.get_filter();
Self {
// app_colors: app_data.config.app_colors,
chart_data: app_data.get_chart_data(),
columns: app_data.get_width(),
container_title: app_data.get_container_title(),
@@ -281,10 +297,13 @@ impl From<&Ui> for FrameData {
/// Draw the main ui to a frame of the terminal
fn draw_frame(
f: &mut Frame,
app_data: &Arc<Mutex<AppData>>,
gui_state: &Arc<Mutex<GuiState>>,
colors: AppColors,
keymap: &Keymap,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
// should pass in the colors here, then I only need to get it once from app+data
) {
let whole_constraints = if fd.status.contains(&Status::Filter) {
vec![Constraint::Max(1), Constraint::Min(1), Constraint::Max(1)]
@@ -326,15 +345,15 @@ fn draw_frame(
.constraints(lower_split)
.split(upper_main[1]);
draw_blocks::containers(app_data, top_panel[0], f, fd, gui_state);
draw_blocks::containers::draw(app_data, top_panel[0], colors, f, fd, gui_state);
draw_blocks::logs(app_data, lower_main[0], f, fd, gui_state);
draw_blocks::logs::draw(app_data, lower_main[0], colors, f, fd, gui_state);
draw_blocks::heading_bar(whole_layout[0], f, fd, gui_state);
draw_blocks::headers::draw(whole_layout[0], colors, f, fd, gui_state, keymap);
// Draw filter bar
if let Some(rect) = whole_layout.get(2) {
draw_blocks::filter_bar(*rect, f, fd);
draw_blocks::filter::draw(*rect, f, fd);
}
if let Some(id) = fd.delete_confirm.as_ref() {
@@ -345,14 +364,14 @@ fn draw_frame(
gui_state.lock().set_delete_container(None);
},
|name| {
draw_blocks::delete_confirm(f, gui_state, name);
draw_blocks::delete_confirm::draw(colors, f, gui_state, keymap, name);
},
);
}
// only draw commands + charts if there are containers
if let Some(rect) = top_panel.get(1) {
draw_blocks::commands(app_data, *rect, f, fd, gui_state);
draw_blocks::commands::draw(app_data, *rect, colors, f, fd, gui_state);
// Can calculate the max string length here, and then use that to keep the ports section as small as possible (+4 for some padding + border)
let ports_len =
@@ -364,20 +383,20 @@ fn draw_frame(
.constraints([Constraint::Min(1), Constraint::Max(ports_len)])
.split(lower_main[1]);
draw_blocks::chart(f, lower[0], fd);
draw_blocks::ports(f, lower[1], fd);
draw_blocks::charts::draw(lower[0], colors, f, fd);
draw_blocks::ports::draw(lower[1], colors, f, fd);
}
if let Some((text, instant)) = fd.info_text.as_ref() {
draw_blocks::info(f, text.to_owned(), instant, gui_state);
draw_blocks::info::draw(colors, f, gui_state, instant, text.to_owned());
}
// Check if error, and show popup if so
if fd.status.contains(&Status::Help) {
draw_blocks::help_box(f);
draw_blocks::help::draw(f, colors, keymap);
}
if let Some(error) = fd.has_error {
draw_blocks::error(f, error, None);
if let Some(error) = fd.has_error.as_ref() {
draw_blocks::error::draw(f, error, keymap, None, colors);
}
}