From 8fd95b7fd1eae7dc6094708ac77753f9cb420606 Mon Sep 17 00:00:00 2001 From: Jack Wills <32690432+mrjackwills@users.noreply.github.com> Date: Sun, 16 Feb 2025 12:53:54 +0000 Subject: [PATCH] feat: config file, cloes #47 Enable use of a config file, with custom keymap and custom colours --- Cargo.lock | 67 + Cargo.toml | 4 + README.md | 4 +- example_config/example.config.json | 177 ++ example_config/example.config.jsonc | 282 ++ example_config/example.config.toml | 239 ++ src/app_data/container_state.rs | 46 +- src/app_data/mod.rs | 22 +- src/app_error.rs | 6 +- src/config/color_parser.rs | 433 ++++ src/config/config.toml | 239 ++ src/config/keymap_parser.rs | 437 ++++ src/config/mod.rs | 116 + src/config/parse_args.rs | 51 + src/config/parse_config_file.rs | 306 +++ src/docker_data/mod.rs | 22 +- src/exec.rs | 2 +- src/input_handler/mod.rs | 287 +- src/main.rs | 39 +- src/parse_args.rs | 105 - src/ui/color_match.rs | 1 + src/ui/draw_blocks.rs | 3595 -------------------------- src/ui/draw_blocks/charts.rs | 507 ++++ src/ui/draw_blocks/commands.rs | 415 +++ src/ui/draw_blocks/containers.rs | 1212 +++++++++ src/ui/draw_blocks/delete_confirm.rs | 421 +++ src/ui/draw_blocks/error.rs | 358 +++ src/ui/draw_blocks/filter.rs | 218 ++ src/ui/draw_blocks/headers.rs | 557 ++++ src/ui/draw_blocks/help.rs | 886 +++++++ src/ui/draw_blocks/info.rs | 162 ++ src/ui/draw_blocks/logs.rs | 386 +++ src/ui/draw_blocks/mod.rs | 525 ++++ src/ui/draw_blocks/popup.rs | 31 + src/ui/draw_blocks/ports.rs | 320 +++ src/ui/gui_state.rs | 73 +- src/ui/mod.rs | 53 +- 37 files changed, 8725 insertions(+), 3879 deletions(-) create mode 100644 example_config/example.config.json create mode 100644 example_config/example.config.jsonc create mode 100644 example_config/example.config.toml create mode 100644 src/config/color_parser.rs create mode 100644 src/config/config.toml create mode 100644 src/config/keymap_parser.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/parse_args.rs create mode 100644 src/config/parse_config_file.rs delete mode 100644 src/parse_args.rs delete mode 100644 src/ui/draw_blocks.rs create mode 100644 src/ui/draw_blocks/charts.rs create mode 100644 src/ui/draw_blocks/commands.rs create mode 100644 src/ui/draw_blocks/containers.rs create mode 100644 src/ui/draw_blocks/delete_confirm.rs create mode 100644 src/ui/draw_blocks/error.rs create mode 100644 src/ui/draw_blocks/filter.rs create mode 100644 src/ui/draw_blocks/headers.rs create mode 100644 src/ui/draw_blocks/help.rs create mode 100644 src/ui/draw_blocks/info.rs create mode 100644 src/ui/draw_blocks/logs.rs create mode 100644 src/ui/draw_blocks/mod.rs create mode 100644 src/ui/draw_blocks/popup.rs create mode 100644 src/ui/draw_blocks/ports.rs diff --git a/Cargo.lock b/Cargo.lock index 6c93221..06e18b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3037961..ac217d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index b239e57..1b1fba9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/example_config/example.config.json b/example_config/example.config.json new file mode 100644 index 0000000..37ef0bf --- /dev/null +++ b/example_config/example.config.json @@ -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" + ] + } +} \ No newline at end of file diff --git a/example_config/example.config.jsonc b/example_config/example.config.jsonc new file mode 100644 index 0000000..db7e7e5 --- /dev/null +++ b/example_config/example.config.jsonc @@ -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" + } + } +} \ No newline at end of file diff --git a/example_config/example.config.toml b/example_config/example.config.toml new file mode 100644 index 0000000..feb3da1 --- /dev/null +++ b/example_config/example.config.toml @@ -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" \ No newline at end of file diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index 69b5c5e..f888ec1 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -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, } } diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 3df0b3c..1822cb8 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -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, 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, pub error: Option, 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 { - self.error + pub fn get_error(&self) -> Option { + 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, 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 { diff --git a/src/app_error.rs b/src/app_error.rs index e192ad8..74d7d3c 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -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"), } } diff --git a/src/config/color_parser.rs b/src/config/color_parser.rs new file mode 100644 index 0000000..bdf7f82 --- /dev/null +++ b/src/config/color_parser.rs @@ -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, 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, + )* + } + )* + }; +} + +/// 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::().map(|i| *setter = i)); + } +} + +impl From> for AppColors { + #[allow(clippy::too_many_lines)] + fn from(value: Option) -> 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, + chart_cpu: Option, + chart_memory: Option, + chart_ports: Option, + commands: Option, + container_state: Option, + containers: Option, + headers_bar: Option, + popup_delete: Option, + popup_error: Option, + popup_help: Option, + popup_info: Option, +} + +/// 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(), + } + } +} diff --git a/src/config/config.toml b/src/config/config.toml new file mode 100644 index 0000000..feb3da1 --- /dev/null +++ b/src/config/config.toml @@ -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" \ No newline at end of file diff --git a/src/config/keymap_parser.rs b/src/config/keymap_parser.rs new file mode 100644 index 0000000..c187af9 --- /dev/null +++ b/src/config/keymap_parser.rs @@ -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, 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>, + )* + } + )* + }; +} + +/// 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), + )* + } + )* + }; +} + +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> for Keymap { + /// Probably a better way to do this, but for now it works + fn from(value: Option) -> Self { + let mut keymap = Self::new(); + + let mut clash = HashSet::new(); + let mut counter = 0; + + let mut update_keymap = + |vec_str: Option>, + keymap_field: &mut (KeyCode, Option), + keymap_clash: &mut HashSet| { + 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> { + 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); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..12a6130 --- /dev/null +++ b/src/config/mod.rs @@ -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, + pub in_container: bool, + pub keymap: Keymap, + pub raw_logs: bool, + pub save_dir: Option, + pub show_self: bool, + pub show_std_err: bool, + pub show_timestamp: bool, + pub use_cli: bool, +} + +impl From 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 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 { + 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 { + 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) + } +} diff --git a/src/config/parse_args.rs b/src/config/parse_args.rs new file mode 100644 index 0000000..da24118 --- /dev/null +++ b/src/config/parse_args.rs @@ -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, + + /// 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, + + /// Path to a config file, readable as TOML, JSONC, or JSON + #[clap(long="config-file", short = None)] + pub config_file: Option, + + /// Force use of docker cli when execing into containers + #[clap(long="use-cli", short = None)] + pub use_cli: bool, +} diff --git a/src/config/parse_config_file.rs b/src/config/parse_config_file.rs new file mode 100644 index 0000000..b1452ce --- /dev/null +++ b/src/config/parse_config_file.rs @@ -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 { + 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 { + 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, + pub docker_interval: Option, + pub gui: Option, + pub host: Option, + pub raw_logs: Option, + pub show_timestamp: Option, + pub save_dir: Option, + pub show_self: Option, + pub show_std_err: Option, + pub use_cli: Option, + pub colors: Option, + pub keymap: Option, +} + +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 { + match file_type { + ConfigFileType::Json => { + serde_json::from_str::(input).map_err(|i| AppError::Parse(i.to_string())) + } + ConfigFileType::Jsonc | ConfigFileType::JsoncAsJson => { + serde_jsonc::from_str::(input).map_err(|i| AppError::Parse(i.to_string())) + } + ConfigFileType::Toml => { + toml::from_str::(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 { + 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 { + 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 { + 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); + } +} diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 173b6e6..f48a8b5 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -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>, - args: CliArgs, + config: Config, binate: Binate, docker: Arc, gui_state: Arc>, @@ -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) { - let update_duration = std::time::Duration::from_millis(u64::from(args.docker_interval)); + fn heartbeat(config: &Config, docker_tx: Sender) { + 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, gui_state: Arc>, ) { - 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; } } diff --git a/src/exec.rs b/src/exec.rs index ba77737..a7c3b8b 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -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 { diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index 1670410..a5f91fe 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -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>, docker_tx: Sender, + keymap: config::Keymap, gui_state: Arc>, is_running: Arc, mouse_capture: bool, @@ -47,11 +49,13 @@ impl InputHandler { gui_state: Arc>, is_running: Arc, ) { + 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> { - 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:: { 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') => { - self.gui_state.lock().status_del(Status::Help); - } - KeyCode::Char('m' | 'M') => self.m_key(), - _ => (), + 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); + } + + 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') => { - self.app_data.lock().remove_error(); - self.gui_state.lock().status_del(Status::Error); - } - _ => (), + 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,19 +603,34 @@ impl InputHandler { /// Handle mouse button events fn mouse_press(&self, mouse_event: MouseEvent) { - 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); - if let Some(header) = header { - self.sort(header); - } + // If in help panel, ignore? - self.gui_state.lock().panel_intersect(mouse_point); + 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().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().get_intersect_panel(mouse_point); + } + _ => (), } - _ => (), } } diff --git a/src/main.rs b/src/main.rs index 3ad0af4..44cb4fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { - args.host +fn read_docker_host(config: &Config) -> Option { + 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, gui_state: &Arc>, ) { - 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(), } } diff --git a/src/parse_args.rs b/src/parse_args.rs deleted file mode 100644 index a8067a9..0000000 --- a/src/parse_args.rs +++ /dev/null @@ -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, - - /// 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, - - /// 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, - pub in_container: bool, - pub save_dir: Option, - 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, - } - } -} diff --git a/src/ui/color_match.rs b/src/ui/color_match.rs index 9643b43..5c6c75b 100644 --- a/src/ui/color_match.rs +++ b/src/ui/color_match.rs @@ -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> { vec![Line::from( categorise_text(input) diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs deleted file mode 100644 index d939255..0000000 --- a/src/ui/draw_blocks.rs +++ /dev/null @@ -1,3595 +0,0 @@ -use parking_lot::Mutex; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - symbols, - text::{Line, Span}, - widgets::{ - Axis, Block, BorderType, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, - Paragraph, - }, - Frame, -}; -use std::{default::Default, time::Instant}; -use std::{fmt::Display, sync::Arc}; - -use crate::app_data::{ContainerItem, ContainerName, FilterBy, Header, SortedOrder}; -use crate::{ - app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats}, - app_error::AppError, -}; - -use super::{ - gui_state::{BoxLocation, DeleteButton, Region}, - FrameData, Status, ORANGE, -}; -use super::{GuiState, SelectablePanel}; - -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 "#; - -const NAME: &str = env!("CARGO_PKG_NAME"); -const VERSION: &str = env!("CARGO_PKG_VERSION"); -const REPO: &str = env!("CARGO_PKG_REPOSITORY"); -const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); -const MARGIN: &str = " "; -const RIGHT_ARROW: &str = "▶ "; -const CIRCLE: &str = "⚪ "; - -const COLOR_RX: Color = Color::Rgb(255, 233, 193); -const COLOR_TX: Color = Color::Rgb(205, 140, 140); - -const CONSTRAINT_50_50: [Constraint; 2] = [Constraint::Percentage(50), Constraint::Percentage(50)]; -const CONSTRAINT_100: [Constraint; 1] = [Constraint::Percentage(100)]; -const CONSTRAINT_POPUP: [Constraint; 5] = [ - Constraint::Min(2), - Constraint::Max(1), - Constraint::Max(1), - Constraint::Max(3), - Constraint::Min(1), -]; - -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 -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, - fd: &FrameData, - gui_state: &Arc>, - 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.selected_panel == panel && !fd.status.contains(&Status::Filter) { - block = block.border_style(Style::default().fg(Color::LightCyan)); - } - block -} - -/// Draw the command panel -pub fn commands( - app_data: &Arc>, - area: Rect, - f: &mut Frame, - fd: &FrameData, - gui_state: &Arc>, -) { - let block = generate_block(area, fd, gui_state, SelectablePanel::Commands); - 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()), - )]); - ListItem::new(lines) - }) - .collect::>() - }); - - 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); - } -} - -/// Format the container data to display nicely on the screen -fn format_containers<'a>(i: &ContainerItem, widths: &Columns) -> Line<'a> { - let state_style = Style::default().fg(i.state.get_color()); - let blue = Style::default().fg(Color::Blue); - - Line::from(vec![ - Span::styled( - format!( - "{:width$}{MARGIN}", - i.cpu_stats.back().map_or_else(CpuStats::default, |f| *f), - width = &widths.cpu.1.into() - ), - state_style, - ), - Span::styled( - format!( - "{:>width_current$} / {:>width_limit$}{MARGIN}", - i.mem_stats.back().map_or_else(ByteStats::default, |f| *f), - i.mem_limit, - width_current = &widths.mem.1.into(), - width_limit = &widths.mem.2.into() - ), - state_style, - ), - Span::styled( - format!( - "{:>width$}{MARGIN}", - i.id.get_short(), - width = &widths.id.1.into() - ), - blue, - ), - Span::styled( - format!( - "{:width$}{MARGIN}", i.rx, width = widths.net_rx.1.into()), - Style::default().fg(COLOR_RX), - ), - Span::styled( - format!("{:>width$}{MARGIN}", i.tx, width = widths.net_tx.1.into()), - Style::default().fg(COLOR_TX), - ), - ]) -} - -/// Draw the containers panel -pub fn containers( - app_data: &Arc>, - area: Rect, - f: &mut Frame, - fd: &FrameData, - gui_state: &Arc>, -) { - let block = generate_block(area, fd, gui_state, SelectablePanel::Containers); - - let items = app_data - .lock() - .get_container_items() - .iter() - .map(|i| ListItem::new(format_containers(i, &fd.columns))) - .collect::>(); - - if items.is_empty() { - let text = if fd.filter_term.is_some() { - "no containers match filter" - } else if fd.is_loading { - &format!("loading {}", fd.loading_icon) - } else { - "no containers running" - }; - - let paragraph = Paragraph::new(text) - .block(block) - .alignment(Alignment::Center); - f.render_widget(paragraph, area); - } else { - let items = List::new(items) - .block(block) - .highlight_style(Style::default().add_modifier(Modifier::BOLD)) - .highlight_symbol(CIRCLE); - f.render_stateful_widget(items, area, app_data.lock().get_container_state()); - } -} - -/// Draw the logs panel -pub fn logs( - app_data: &Arc>, - area: Rect, - f: &mut Frame, - fd: &FrameData, - gui_state: &Arc>, -) { - let block = generate_block(area, 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); - } - } - } -} - -/// Display the ports in a formatted list -pub fn ports(f: &mut Frame, area: Rect, fd: &FrameData) { - if let Some(ports) = fd.ports.as_ref() { - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title_alignment(Alignment::Center) - .title(Span::styled( - " ports ", - Style::default() - .fg(ports.1.get_color()) - .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(Color::Yellow), - )]; - for item in &ports.0 { - let fg = Color::White; - let strings = item.get_all(); - - let line = vec![ - Span::from(format!("{:>ip$}", strings.0)).fg(fg), - Span::from(format!("{:>private$}", strings.1)).fg(fg), - Span::from(format!("{:>public$}", strings.2)).fg(fg), - ]; - output.push(Line::from(line)); - } - let paragraph = Paragraph::new(output).block(block); - f.render_widget(paragraph, area); - } - } -} - -/// Draw the cpu + mem charts -pub fn chart(f: &mut Frame, area: Rect, 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(Color::Magenta)) - .graph_type(GraphType::Line) - .data(&cpu.0)]; - let mem_dataset = vec![Dataset::default() - .marker(symbols::Marker::Dot) - .style(Style::default().fg(Color::Cyan)) - .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(cpu.2, "cpu", cpu_dataset, &cpu_stats, &cpu.1); - let mem_chart = make_chart(mem.2, "memory", mem_dataset, &mem_stats, &mem.1); - - f.render_widget(cpu_chart, area[0]); - f.render_widget(mem_chart, area[1]); - } -} - -/// Create charts -fn make_chart<'a, T: Stats + Display>( - state: State, - name: &'a str, - dataset: Vec>, - current: &'a T, - max: &'a T, -) -> Chart<'a> { - let title_color = state.get_color(); - let label_color = match state { - State::Running(_) => ORANGE, - _ => state.get_color(), - }; - Chart::new(dataset) - .block( - Block::default() - .title_alignment(Alignment::Center) - .title(Span::styled( - format!(" {name} {current} "), - Style::default() - .fg(title_color) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_type(BorderType::Rounded), - ) - .x_axis( - Axis::default() - .style(Style::default().fg(title_color)) - .bounds([0.00, 60.0]), - ) - .y_axis( - Axis::default() - .labels(vec![ - Span::styled("", Style::default().fg(label_color)), - Span::styled( - format!("{max}"), - Style::default() - .add_modifier(Modifier::BOLD) - .fg(label_color), - ), - ]) - // Add 0.01, so that max point is always visible? - .bounds([0.0, max.get_value() + 0.01]), - ) -} - -/// 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 filter_bar(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); -} - -/// 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 heading_bar( - area: Rect, - frame: &mut Frame, - fd: &FrameData, - gui_state: &Arc>, -) { - let block = |fg: Color| Block::default().style(Style::default().bg(Color::Magenta).fg(fg)); - - frame.render_widget(block(Color::Black), 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| { - let mut color = Color::Black; - let mut suffix = ""; - if let Some((a, b)) = &fd.sorted_by { - if x == a { - match b { - SortedOrder::Asc => suffix = " ▲", - SortedOrder::Desc => suffix = " ▼", - } - color = Color::Gray; - }; - }; - - (Block::default().style(Style::default().fg(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 - let gen_header = |header: &Header, width: usize| { - let block = header_block(header); - - // TODO - // Yes this is a mess, needs documenting correctly - - let text = format!( - "{x: 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)) - .block(block(Color::White)) - .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()); - counter += header_block.1; - if counter <= header_section_width { - Some((header_block.0, i.0, Constraint::Max(header_block.1))) - } else { - None - } - }) - .collect::>(); - - let container_splits = header_data.iter().map(|i| i.2).collect::>(); - 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 color = if fd.status.contains(&Status::Help) { - Color::Black - } else { - Color::White - }; - let help_paragraph = Paragraph::new(info_text) - .block(block(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 }; - frame.render_widget(help_paragraph, split_bar[help_index]); -} - -/// Help popup box needs these three pieces of information -struct HelpInfo { - lines: Vec>, - width: usize, - height: usize, -} - -impl HelpInfo { - /// Find the max width of a Span in &[Line], although it isn't calculating it correctly - fn calc_width(lines: &[Line]) -> usize { - lines - .iter() - .flat_map(|x| x.spans.iter()) - .map(ratatui::text::Span::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 black_span<'a>(input: &str) -> Span<'a> { - Self::span(input, Color::Black) - } - - /// &str to white text span - fn white_span<'a>(input: &str) -> Span<'a> { - Self::span(input, Color::White) - } - - /// Generate the `oxker` name span + metadata - fn gen_name() -> Self { - let mut lines = NAME_TEXT - .lines() - .map(|i| Line::from(Self::white_span(i))) - .collect::>(); - 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() -> Self { - let lines = [ - Self::empty_span(), - Line::from(Self::white_span(DESCRIPTION)), - Self::empty_span(), - ]; - let width = Self::calc_width(&lines); - let height = lines.len(); - Self { - lines: lines.to_vec(), - width, - height, - } - } - - /// Generate the button information span + metadata - fn gen_button() -> Self { - let button_item = |x: &str| Self::white_span(&format!(" ( {x} ) ")); - let button_desc = |x: &str| Self::black_span(x); - 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"), - ]), - 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"), - ]), - ]; - - let height = lines.len(); - let width = Self::calc_width(&lines); - Self { - lines: lines.to_vec(), - width, - height, - } - } - - /// Generate the final lines, GitHub link etc, + metadata - fn gen_final() -> Self { - let lines = [ - Self::empty_span(), - Line::from(vec![Self::black_span( - "currently an early work in progress, all and any input appreciated", - )]), - Line::from(vec![Span::styled( - REPO.to_owned(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::UNDERLINED), - )]), - ]; - let height = lines.len(); - let width = Self::calc_width(&lines); - Self { - lines: lines.to_vec(), - width, - height, - } - } -} - -/// Draw the help box in the centre of the screen -pub fn help_box(f: &mut Frame) { - let title = format!(" {VERSION} "); - - let name_info = HelpInfo::gen_name(); - let description_info = HelpInfo::gen_description(); - let button_info = HelpInfo::gen_button(); - let final_info = HelpInfo::gen_final(); - - // have to add 10, but shouldn't need to, is an error somewhere - let max_line_width = [ - name_info.width, - description_info.width, - button_info.width, - final_info.width, - ] - .into_iter() - .max() - .unwrap_or_default() - + 10; - let max_height = - name_info.height + description_info.height + button_info.height + final_info.height + 2; - - let area = popup( - 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(Color::Magenta).fg(Color::White)) - .alignment(Alignment::Center); - - let style = || Style::default().bg(Color::Magenta).fg(Color::Black); - 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(Color::Black).bg(Color::Magenta)); - - // 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); -} - -/// Draw the delete confirm box in the centre of the screen -/// take in container id and container name here? -pub fn delete_confirm(f: &mut Frame, gui_state: &Arc>, name: &ContainerName) { - let block = Block::default() - .title(" Confirm Delete ") - .border_type(BorderType::Rounded) - .style(Style::default().bg(Color::White).fg(Color::Black)) - .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(Color::Red) - .bg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ]); - - let yes_text = " (Y)es "; - let no_text = " (N)o "; - - // 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(Color::White)) - }; - - 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( - 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::No), no_area); - - gui_state - .lock() - .update_region_map(Region::Delete(DeleteButton::Yes), yes_area); -} - -/// Draw an error popup over whole screen -pub fn error(f: &mut Frame, error: AppError, seconds: Option) { - let block = Block::default() - .title(" Error ") - .border_type(BorderType::Rounded) - .title_alignment(Alignment::Center) - .borders(Borders::ALL); - - let to_push = match error { - AppError::DockerConnect => { - format!( - "\n\n {}::v{} closing in {:02} seconds", - NAME, - VERSION, - seconds.unwrap_or(5) - ) - } - _ => String::from("\n\n ( c ) clear error\n ( q ) quit oxker "), - }; - - let mut text = format!("\n{error}"); - - text.push_str(to_push.as_str()); - - // Find the maximum line width & height - let mut max_line_width = max_line_width(&text); - let mut lines = text.lines().count(); - - // Add some horizontal & vertical margins - max_line_width += 8; - lines += 3; - - let paragraph = Paragraph::new(text) - .style(Style::default().bg(Color::Red).fg(Color::White)) - .block(block) - .alignment(Alignment::Center); - - let area = popup(lines, max_line_width, f.area(), BoxLocation::MiddleCentre); - - f.render_widget(Clear, area); - f.render_widget(paragraph, area); -} - -/// Draw info box in one of the 9 BoxLocations -// TODO is this broken - I don't think so -pub fn info(f: &mut Frame, text: String, instant: &Instant, gui_state: &Arc>) { - let block = Block::default() - .title("") - .title_alignment(Alignment::Center) - .borders(Borders::NONE); - - let mut max_line_width = max_line_width(&text); - let mut lines = text.lines().count(); - - // Add some horizontal & vertical margins - max_line_width += 8; - lines += 2; - - let paragraph = Paragraph::new(text) - .style(Style::default().bg(Color::Blue).fg(Color::White)) - .block(block) - .alignment(Alignment::Center); - - let area = popup(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(); - } -} - -/// draw a box in the one of the BoxLocations, based on max line width + number of lines -fn popup(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] -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - - use std::{ - net::{IpAddr, Ipv4Addr}, - ops::RangeInclusive, - sync::Arc, - }; - - use parking_lot::Mutex; - use ratatui::{ - backend::TestBackend, - layout::Rect, - style::{Color, Modifier}, - Terminal, - }; - use uuid::Uuid; - - use crate::{ - app_data::{ - AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts, ContainerStatus, - Header, SortedOrder, State, StatefulList, - }, - app_error::AppError, - tests::{gen_appdata, gen_container_summary, gen_containers}, - ui::{ - draw_blocks::{COLOR_RX, COLOR_TX}, - draw_frame, GuiState, Status, - }, - }; - - use super::{FrameData, ORANGE, VERSION}; - - struct TuiTestSetup { - app_data: Arc>, - gui_state: Arc>, - fd: FrameData, - area: Rect, - terminal: Terminal, - ids: Vec, - } - - const BORDER_CHARS: [&str; 6] = ["╭", "╮", "─", "│", "╰", "╯"]; - - impl From<(&Arc>, &Arc>)> for FrameData { - fn from(data: (&Arc>, &Arc>)) -> 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 - 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, - } - } - - /// Insert some logs into the first container - fn insert_logs(setup: &TuiTestSetup) { - let logs = (1..=3).map(|i| format!("{i} line {i}")).collect::>(); - setup.app_data.lock().update_log_by_id(logs, &setup.ids[0]); - } - - /// Get a single row of String's from the expected data - fn expected_to_vec(expected: &[&str], row_index: usize) -> Vec { - expected[row_index] - .chars() - .map(|i| i.to_string()) - .collect::>() - } - - fn get_result( - setup: &TuiTestSetup, - w: u16, - ) -> std::iter::Enumerate> { - setup - .terminal - .backend() - .buffer() - .content - .chunks(usize::from(w)) - .enumerate() - } - - // ******************** // - // DockerControls panel // - // ******************** // - - #[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); - - setup - .terminal - .draw(|f| { - super::commands(&setup.app_data, setup.area, 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); - - setup - .terminal - .draw(|f| { - super::commands(&setup.app_data, setup.area, 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) { - // 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); - } - // delete - (4, 3..=8) => { - assert_eq!(result_cell.fg, Color::Gray); - } - _ => { - 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::commands(&setup.app_data, setup.area, 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 - (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 │", - "╰──────────╯", - ]; - - // Unselected, has a grey border - setup - .terminal - .draw(|f| { - super::commands(&setup.app_data, setup.area, 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::Reset); - } - } - } - - // 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::commands(&setup.app_data, setup.area, 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()); - } - } - } - } - - // *********************** // - // Container summary panel // - // *********************** // - - #[test] - /// No containers, panel unselected, then selected, border color changes correctly - fn test_draw_blocks_containers_none() { - let (w, h) = (25, 6); - let mut setup = test_setup(w, h, true, true); - setup.app_data.lock().containers = StatefulList::new(vec![]); - - let expected = [ - "╭ Containers ───────────╮", - "│ no containers running │", - "│ │", - "│ │", - "│ │", - "╰───────────────────────╯", - ]; - - setup.gui_state.lock().next_panel(); - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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]); - assert_eq!(result_cell.fg, Color::Reset); - } - } - - setup.gui_state.lock().previous_panel(); - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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] - /// Containers panel drawn, selected line is bold, border is blue - fn test_draw_blocks_containers_selected_bold() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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::LightCyan); - } - - let not_bold = || assert!(result_cell.modifier.is_empty()); - if row_index == 1 { - match result_cell_index { - 0 | 2 | 129 => { - not_bold(); - } - _ => { - assert_eq!(result_cell.modifier, Modifier::BOLD); - } - } - } else { - not_bold(); - } - } - } - - // Change selected panel, border is now no longer blue - setup.gui_state.lock().next_panel(); - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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::Reset); - } - } - } - } - - #[test] - /// Columns on all rows are coloured correctly - fn test_draw_blocks_containers_colors() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" - ]; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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) { - //border - (0 | 5, _) | (1..=4, 0 | 129) => { - assert_eq!(result_cell.fg, Color::LightCyan); - } - // name, id, image column - (1..=3, 4..=17 | 71..=91) => { - assert_eq!(result_cell.fg, Color::Blue); - } - // state, status, cpu, memory column - (1..=3, 18..=70) => { - assert_eq!(result_cell.fg, Color::Green); - } - // rx column - (1..=3, 92..=101) => { - assert_eq!(result_cell.fg, COLOR_RX); - } - // tx column - (1..=3, 102..=111) => { - assert_eq!(result_cell.fg, COLOR_TX); - } - _ => assert_eq!(result_cell.fg, Color::Reset), - } - } - } - } - - #[test] - /// Long container + image name is truncated correctly - fn test_draw_blocks_containers_long_name_image() { - let (w, h) = (170, 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"); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ a_long_container_name_for_the… ॥ paused Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 a_long_image_name_for_the_pur… 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - setup.app_data.lock().containers.items[0].state = State::Paused; - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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]); - } - } - } - - // Check that the correct colour is applied to the state/status/cpu/memory section - fn check_expected(expected: [&str; 6], w: u16, _h: u16, setup: &TuiTestSetup, color: Color) { - 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) { - // border - (0 | 5, _) | (1..=4, 0 | 129) => { - assert_eq!(result_cell.fg, Color::LightCyan); - } - // name, id, image column - (1..=3, 4..=17 | 71..=91) => { - assert_eq!(result_cell.fg, Color::Blue); - } - // state, status, cpu, memory column of the first row - (1, 18..=70) => { - assert_eq!(result_cell.fg, color); - } - // state, status, cpu, memory column - (2..=3, 4..=77) => { - assert_eq!(result_cell.fg, Color::Green); - } - // rx column - (1..=3, 92..=101) => { - assert_eq!(result_cell.fg, COLOR_RX); - } - // tx column - (1..=3, 102..=111) => { - assert_eq!(result_cell.fg, COLOR_TX); - } - _ => assert_eq!(result_cell.fg, Color::Reset), - } - } - } - } - - #[test] - /// When container is paused, correct colors displayed - fn test_draw_blocks_containers_paused() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ॥ paused Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - setup.app_data.lock().containers.items[0].state = State::Paused; - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); - }) - .unwrap(); - - check_expected(expected, w, h, &setup, Color::Yellow); - } - - #[test] - /// When container is dead, correct colors displayed - fn test_draw_blocks_containers_dead() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ✖ dead Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - 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::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); - }) - .unwrap(); - - check_expected(expected, w, h, &setup, Color::Red); - } - - #[test] - /// When container is exited, correct colors displayed - fn test_draw_blocks_containers_exited() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ✖ exited Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - 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::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); - }) - .unwrap(); - - check_expected(expected, w, h, &setup, Color::Red); - } - #[test] - /// When container is paused, correct colors displayed - fn test_draw_blocks_containers_removing() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 removing Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - setup.app_data.lock().containers.items[0].state = State::Removing; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); - }) - .unwrap(); - - check_expected(expected, w, h, &setup, Color::LightRed); - } - - #[test] - /// When container state is restarting, correct colors displayed - fn test_draw_blocks_containers_restarting() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ↻ restarting Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - setup.app_data.lock().containers.items[0].state = State::Restarting; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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) { - // border - (0 | 5, _) | (1..=4, 0 | 129) => { - assert_eq!(result_cell.fg, Color::LightCyan); - } - // name, id, image column - (1..=3, 4..=17 | 74..=94) => { - assert_eq!(result_cell.fg, Color::Blue); - } - // state, status, cpu, memory column of the first row - (1, 18..=73) => { - assert_eq!(result_cell.fg, Color::LightGreen); - } - // state, status, cpu, memory column - (2..=3, 18..=73) => { - assert_eq!(result_cell.fg, Color::Green); - } - // rx column - (1..=3, 95..=104) => { - assert_eq!(result_cell.fg, COLOR_RX); - } - // tx column - (1..=3, 105..=114) => { - assert_eq!(result_cell.fg, COLOR_TX); - } - _ => { - assert_eq!(result_cell.fg, Color::Reset); - } - } - } - } - } - - #[test] - /// When container state is unknown, correct colors displayed - fn test_draw_blocks_containers_unhealthy() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let status = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); - setup.app_data.lock().containers.items[0].state = State::from(("running", &status)); - setup.app_data.lock().containers.items[0].status = status; - - let expected= [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ! running Up 1 hour (unhealthy) 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" - ]; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, 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) { - // border - (0 | 5, _) | (1..=4, 0 | 129) => { - assert_eq!(result_cell.fg, Color::LightCyan); - } - // name, id, image column - (1..=3, 4..=17 | 83..=103) => { - assert_eq!(result_cell.fg, Color::Blue); - } - // state, status, cpu, memory column of the first row - (1, 18..=82) => { - assert_eq!(result_cell.fg, ORANGE); - } - // state, status, cpu, memory column - (2..=3, 18..=82) => { - assert_eq!(result_cell.fg, Color::Green); - } - // rx column - (1..=3, 104..=113) => { - assert_eq!(result_cell.fg, COLOR_RX); - } - // tx column - (1..=3, 114..=123) => { - assert_eq!(result_cell.fg, COLOR_TX); - } - _ => assert_eq!(result_cell.fg, Color::Reset), - } - } - } - } - - #[test] - /// When container state is unknown, correct colors displayed - fn test_draw_blocks_containers_unknown() { - let (w, h) = (130, 6); - let mut setup = test_setup(w, h, true, true); - - let expected = [ - "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│⚪ container_1 ? unknown Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", - "│ │", - "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - ]; - setup.app_data.lock().containers.items[0].state = State::Unknown; - let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - - setup - .terminal - .draw(|f| { - super::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); - }) - .unwrap(); - - check_expected(expected, w, h, &setup, Color::Red); - } - - // ********** // - // Logs panel // - // ********** // - - #[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 │", - "│ │", - "│ │", - "│ │", - "╰─────────────────────────────────╯", - ]; - - setup - .terminal - .draw(|f| { - super::logs(&setup.app_data, setup.area, 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.fg, 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::logs(&setup.app_data, setup.area, 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); - - setup - .terminal - .draw(|f| { - super::logs(&setup.app_data, setup.area, 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]); - assert_eq!(result_cell.fg, 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::logs(&setup.app_data, setup.area, 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]); - assert_eq!(result_cell.fg, 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 fd = FrameData::from((&setup.app_data, &setup.gui_state)); - setup - .terminal - .draw(|f| { - super::logs(&setup.app_data, setup.area, 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.fg, Color::Reset); - - 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::logs(&setup.app_data, setup.area, 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.fg, Color::Reset); - - 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)); - setup - .terminal - .draw(|f| { - super::logs(&setup.app_data, setup.area, 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]); - } - } - } - - // ************ // - // Charts panel // - // ************ // - - #[allow(clippy::cast_precision_loss)] - // Add fixed data to the cpu & mem vecdeques - 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, - ); - } - } - - /// 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::chart(f, setup.area, &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, ORANGE); - assert_eq!(result_cell.modifier, Modifier::BOLD); - } - _ => { - assert_eq!(result_cell.fg, Color::Reset); - 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::chart(f, setup.area, &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, 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()); - } - _ => { - 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::chart(f, setup.area, &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()); - } - _ => { - assert_eq!(result_cell.fg, Color::Reset); - assert!(result_cell.modifier.is_empty()); - } - } - } - } - } - - #[test] - /// When dead, text is read - 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::chart(f, setup.area, &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()); - } - _ => { - assert_eq!(result_cell.fg, Color::Reset); - assert!(result_cell.modifier.is_empty()); - } - } - } - } - } - - // ******* // - // Headers // - // ******* // - - #[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::heading_bar(setup.area, 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]); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!(result_cell.fg, Color::White); - } - } - - fd.status.insert(Status::Help); - let expected = [" ( h ) exit help "]; - setup - .terminal - .draw(|f| { - super::heading_bar(setup.area, 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]); - 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::heading_bar(setup.area, 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]); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!( - result_cell.fg, - match result_cell_index { - (4..=121) => Color::Black, - _ => Color::White, - } - ); - } - } - } - - #[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::heading_bar(setup.area, 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]); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!( - result_cell.fg, - match result_cell_index { - (4..=61) => Color::Black, - _ => Color::White, - } - ); - } - } - } - - #[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, x: (Header, SortedOrder)| { - fd.sorted_by = Some(x); - - setup - .terminal - .draw(|f| { - super::heading_bar(setup.area, 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]); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!( - result_cell.fg, - match result_cell_index { - 0..=3 | 122..=139 => Color::White, - // given range | help section - x if range.contains(&x) => Color::Gray, - _ => 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::heading_bar(setup.area, 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]); - assert_eq!(result_cell.bg, Color::Magenta); - assert_eq!( - result_cell.fg, - match result_cell_index { - (4..=121) => Color::Black, - _ => Color::White, - } - ); - } - } - } - - // ********** // - // Help popup // - // ********** // - #[test] - /// This will cause issues once the version has more than the current 5 chars (0.5.0) - // Help popup is drawn correctly - fn test_draw_blocks_help() { - let (w, h) = (87, 33); - let mut setup = test_setup(w, h, true, true); - - setup - .terminal - .draw(|f| { - super::help_box(f); - }) - .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 │ ", - " │ ( 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); - } - } - } - } - } - - // ************ // - // Delete popup // - // ************ // - - #[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)o │ │ (Y)es │ │ ", - " │ ╰─────────────────────╯ ╰─────────────────────╯ │ ", - " ╰────────────────────────────────────────────────────────────────╯ ", - " ", - ]; - - setup - .terminal - .draw(|f| { - super::delete_confirm(f, &setup.gui_state, &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)o │ │ (Y)es │ │ ", - " │ ╰──────────────────────────────╯ ╰─────────────────────────────╯ │ ", - " ╰────────────────────────────────────────────────────────────────────────────────────────╯ ", - " ", - ]; - - setup - .terminal - .draw(|f| { - super::delete_confirm(f, &setup.gui_state, &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); - } - } - } - } - } - - // ***** // - // popup // - // ***** // - - #[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 ", - " ", - ]; - - setup - .terminal - .draw(|f| { - super::info( - f, - "test".to_owned(), - &std::time::Instant::now(), - &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]); - - 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); - } - } - } - - // ********** // - // Filter Row // - // ********** // - - #[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::filter_bar(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::filter_bar(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::filter_bar(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); - } - } - } - } - } - - // *********** // - // Error popup // - // *********** // - - #[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); - - setup - .terminal - .draw(|f| { - super::error(f, AppError::DockerConnect, Some(4)); - }) - .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, 10); - let mut setup = test_setup(w, h, true, true); - - setup - .terminal - .draw(|f| { - super::error(f, AppError::DockerExec, Some(4)); - }) - .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 | 9, _) | (1..=8, 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] - /// 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::ports(f, setup.area, &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::Reset); - assert_eq!(result_cell.modifier, Modifier::BOLD); - } - _ => { - assert_eq!(result_cell.bg, Color::Reset); - assert_eq!(result_cell.fg, Color::Reset); - 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::ports(f, setup.area, &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::Reset); - 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::ports(f, setup.area, &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) => { - 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::ports(f, setup.area, &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::ports(f, setup.area, &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::ports(f, setup.area, &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); - } - } - } - } - - // *************** // - // 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 fd = FrameData::from((&setup.app_data, &setup.gui_state)); - setup - .terminal - .draw(|f| { - draw_frame(f, &setup.app_data, &setup.gui_state, &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]); - } - } - } - - #[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)); - setup - .terminal - .draw(|f| { - draw_frame(f, &setup.app_data, &setup.gui_state, &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]); - } - } - - 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(f, &setup.app_data, &setup.gui_state, &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]); - } - } - } - - #[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)); - setup - .terminal - .draw(|f| { - draw_frame(f, &setup.app_data, &setup.gui_state, &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]); - } - } - } -} diff --git a/src/ui/draw_blocks/charts.rs b/src/ui/draw_blocks/charts.rs new file mode 100644 index 0000000..a09defe --- /dev/null +++ b/src/ui/draw_blocks/charts.rs @@ -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>, + 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); + } + } + } + } + } +} diff --git a/src/ui/draw_blocks/commands.rs b/src/ui/draw_blocks/commands.rs new file mode 100644 index 0000000..100ffc0 --- /dev/null +++ b/src/ui/draw_blocks/commands.rs @@ -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>, + area: Rect, + colors: AppColors, + f: &mut Frame, + fd: &FrameData, + gui_state: &Arc>, +) { + 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::>() + }); + + 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); + } + _ => (), + } + } + } + } +} diff --git a/src/ui/draw_blocks/containers.rs b/src/ui/draw_blocks/containers.rs new file mode 100644 index 0000000..0082a3b --- /dev/null +++ b/src/ui/draw_blocks/containers.rs @@ -0,0 +1,1212 @@ +use std::sync::Arc; + +use super::MARGIN; +use parking_lot::Mutex; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{List, ListItem, Paragraph}, + Frame, +}; + +use crate::{ + app_data::{AppData, ByteStats, Columns, ContainerItem, CpuStats}, + config::AppColors, + ui::{FrameData, GuiState, SelectablePanel}, +}; + +use super::{generate_block, CIRCLE}; + +/// Format the container data to display nicely on the screen +fn format_containers<'a>(colors: AppColors, i: &ContainerItem, widths: &Columns) -> Line<'a> { + let state_style = Style::default().fg(i.state.get_color(colors)); + + Line::from(vec![ + Span::styled( + format!( + "{:width$}{MARGIN}", + i.cpu_stats.back().map_or_else(CpuStats::default, |f| *f), + width = &widths.cpu.1.into() + ), + state_style, + ), + Span::styled( + format!( + "{:>width_current$} / {:>width_limit$}{MARGIN}", + i.mem_stats.back().map_or_else(ByteStats::default, |f| *f), + i.mem_limit, + width_current = &widths.mem.1.into(), + width_limit = &widths.mem.2.into() + ), + state_style, + ), + Span::styled( + format!( + "{:>width$}{MARGIN}", + i.id.get_short(), + width = &widths.id.1.into() + ), + colors.containers.text, + ), + Span::styled( + format!( + "{:width$}{MARGIN}", i.rx, width = widths.net_rx.1.into()), + Style::default().fg(colors.containers.text_rx), + ), + Span::styled( + format!("{:>width$}{MARGIN}", i.tx, width = widths.net_tx.1.into()), + Style::default().fg(colors.containers.text_tx), + ), + ]) +} + +/// Draw the containers panel +pub fn draw( + app_data: &Arc>, + area: Rect, + colors: AppColors, + f: &mut Frame, + fd: &FrameData, + gui_state: &Arc>, +) { + let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Containers) + .bg(colors.containers.background); + + let items = app_data + .lock() + .get_container_items() + .iter() + .map(|i| ListItem::new(format_containers(colors, i, &fd.columns))) + .collect::>(); + + if items.is_empty() { + let text = if fd.filter_term.is_some() { + "no containers match filter" + } else if fd.is_loading { + &format!("loading {}", fd.loading_icon) + } else { + "no containers running" + }; + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + f.render_widget(paragraph, area); + } else { + let items = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(CIRCLE); + f.render_stateful_widget(items, area, app_data.lock().get_container_state()); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use ratatui::style::{Color, Modifier}; + + use crate::{ + app_data::{ContainerImage, ContainerName, ContainerStatus, State, StatefulList}, + config::AppColors, + ui::{ + draw_blocks::tests::{ + expected_to_vec, get_result, test_setup, TuiTestSetup, BORDER_CHARS, COLOR_ORANGE, + COLOR_RX, COLOR_TX, + }, + FrameData, + }, + }; + + #[test] + /// No containers, panel unselected, then selected, border color changes correctly + fn test_draw_blocks_containers_none() { + let (w, h) = (25, 6); + let mut setup = test_setup(w, h, true, true); + setup.app_data.lock().containers = StatefulList::new(vec![]); + + let expected = [ + "╭ Containers ───────────╮", + "│ no containers running │", + "│ │", + "│ │", + "│ │", + "╰───────────────────────╯", + ]; + + setup.gui_state.lock().next_panel(); + 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]); + if BORDER_CHARS.contains(&result_cell.symbol()) { + assert_eq!(result_cell.fg, Color::Gray); + } + } + } + + setup.gui_state.lock().previous_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 BORDER_CHARS.contains(&result_cell.symbol()) { + assert_eq!(result_cell.fg, Color::LightCyan); + } + } + } + } + + #[test] + /// Containers panel drawn, selected line is bold, border is blue + fn test_draw_blocks_containers_selected_bold() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + 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]); + + if BORDER_CHARS.contains(&result_cell.symbol()) { + assert_eq!(result_cell.fg, Color::LightCyan); + } + + let not_bold = || assert!(result_cell.modifier.is_empty()); + if row_index == 1 { + match result_cell_index { + 0 | 2 | 129 => { + not_bold(); + } + _ => { + assert_eq!(result_cell.modifier, Modifier::BOLD); + } + } + } else { + not_bold(); + } + } + } + + // Change selected panel, border is now no longer blue + 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 BORDER_CHARS.contains(&result_cell.symbol()) { + assert_eq!(result_cell.fg, Color::Gray); + } + } + } + } + + #[test] + /// Columns on all rows are coloured correctly + fn test_draw_blocks_containers_colors() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + 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]); + + match (row_index, result_cell_index) { + //border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::LightCyan); + } + // name, id, image column + (1..=3, 4..=17 | 71..=91) => { + assert_eq!(result_cell.fg, Color::Blue); + } + // state, status, cpu, memory column + (1..=3, 18..=70) => { + assert_eq!(result_cell.fg, Color::Green); + } + // rx column + (1..=3, 92..=101) => { + assert_eq!(result_cell.fg, COLOR_RX); + } + // tx column + (1..=3, 102..=111) => { + assert_eq!(result_cell.fg, COLOR_TX); + } + _ => assert_eq!(result_cell.fg, Color::Reset), + } + } + } + } + + #[test] + /// Long container + image name is truncated correctly + fn test_draw_blocks_containers_long_name_image() { + let (w, h) = (170, 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"); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ a_long_container_name_for_the… ॥ paused Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 a_long_image_name_for_the_pur… 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + let colors = setup.app_data.lock().config.app_colors; + setup.app_data.lock().containers.items[0].state = State::Paused; + + 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]); + } + } + } + + // Check that the correct colour is applied to the state/status/cpu/memory section + fn check_expected(expected: [&str; 6], w: u16, _h: u16, setup: &TuiTestSetup, color: Color) { + 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) { + // border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::LightCyan); + } + // name, id, image column + (1..=3, 4..=17 | 71..=91) => { + assert_eq!(result_cell.fg, Color::Blue); + } + // state, status, cpu, memory column of the first row + (1, 18..=70) => { + assert_eq!(result_cell.fg, color); + } + // state, status, cpu, memory column + (2..=3, 4..=77) => { + assert_eq!(result_cell.fg, Color::Green); + } + // rx column + (1..=3, 92..=101) => { + assert_eq!(result_cell.fg, COLOR_RX); + } + // tx column + (1..=3, 102..=111) => { + assert_eq!(result_cell.fg, COLOR_TX); + } + _ => assert_eq!(result_cell.fg, Color::Reset), + } + } + } + } + + #[test] + /// When container is paused, correct colors displayed + fn test_draw_blocks_containers_paused() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ॥ paused Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + let colors = setup.app_data.lock().config.app_colors; + setup.app_data.lock().containers.items[0].state = State::Paused; + + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + f, + &fd, + &setup.gui_state, + ); + }) + .unwrap(); + + check_expected(expected, w, h, &setup, Color::Yellow); + } + + #[test] + /// When container is dead, correct colors displayed + fn test_draw_blocks_containers_dead() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✖ dead Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + setup.app_data.lock().containers.items[0].state = State::Dead; + 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(); + + check_expected(expected, w, h, &setup, Color::Red); + } + + #[test] + /// When container is exited, correct colors displayed + fn test_draw_blocks_containers_exited() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✖ exited Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + setup.app_data.lock().containers.items[0].state = State::Exited; + 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(); + + check_expected(expected, w, h, &setup, Color::Red); + } + #[test] + /// When container is paused, correct colors displayed + fn test_draw_blocks_containers_removing() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 removing Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + setup.app_data.lock().containers.items[0].state = State::Removing; + 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(); + + check_expected(expected, w, h, &setup, Color::LightRed); + } + + #[test] + /// When container state is restarting, correct colors displayed + fn test_draw_blocks_containers_restarting() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ↻ restarting Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + setup.app_data.lock().containers.items[0].state = State::Restarting; + 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]); + + match (row_index, result_cell_index) { + // border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::LightCyan); + } + // name, id, image column + (1..=3, 4..=17 | 74..=94) => { + assert_eq!(result_cell.fg, Color::Blue); + } + // state, status, cpu, memory column of the first row + (1, 18..=73) => { + assert_eq!(result_cell.fg, Color::LightGreen); + } + // state, status, cpu, memory column + (2..=3, 18..=73) => { + assert_eq!(result_cell.fg, Color::Green); + } + // rx column + (1..=3, 95..=104) => { + assert_eq!(result_cell.fg, COLOR_RX); + } + // tx column + (1..=3, 105..=114) => { + assert_eq!(result_cell.fg, COLOR_TX); + } + _ => { + assert_eq!(result_cell.fg, Color::Reset); + } + } + } + } + } + + #[test] + /// When container state is unhealthy, correct colors displayed + fn test_draw_blocks_containers_unhealthy() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let status = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); + setup.app_data.lock().containers.items[0].state = State::from(("running", &status)); + setup.app_data.lock().containers.items[0].status = status; + + let expected= [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ! running Up 1 hour (unhealthy) 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + 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]); + match (row_index, result_cell_index) { + // border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::LightCyan); + } + // name, id, image column + (1..=3, 4..=17 | 83..=103) => { + assert_eq!(result_cell.fg, Color::Blue); + } + // state, status, cpu, memory column of the first row + (1, 18..=82) => { + assert_eq!(result_cell.fg, COLOR_ORANGE); + } + // state, status, cpu, memory column + (2..=3, 18..=82) => { + assert_eq!(result_cell.fg, Color::Green); + } + // rx column + (1..=3, 104..=113) => { + assert_eq!(result_cell.fg, COLOR_RX); + } + // tx column + (1..=3, 114..=123) => { + assert_eq!(result_cell.fg, COLOR_TX); + } + _ => assert_eq!(result_cell.fg, Color::Reset), + } + } + } + } + + #[test] + /// When container state is unknown, correct colors displayed + fn test_draw_blocks_containers_unknown() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ? unknown Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + ]; + setup.app_data.lock().containers.items[0].state = State::Unknown; + 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(); + + check_expected(expected, w, h, &setup, Color::Red); + } + + #[test] + /// Custom colors applied correctly + fn test_draw_blocks_containers_custom_colors() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + let mut colors = AppColors::new(); + colors.borders.selected = Color::Green; + colors.containers.background = Color::Black; + colors.containers.text = Color::Yellow; + colors.containers.text_rx = Color::Red; + colors.containers.text_tx = Color::Blue; + + colors.container_state.running_healthy = Color::Magenta; + + 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]); + // The highlight symbol can't correctly be colored + if (row_index, result_cell_index) != (1, 2) { + assert_eq!(result_cell.bg, Color::Black); + } + match (row_index, result_cell_index) { + //border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::Green); + } + // name, id, image column + (1..=3, 4..=17 | 71..=91) => { + assert_eq!(result_cell.fg, Color::Yellow); + } + // state, status, cpu, memory column + (1..=3, 18..=70) => { + assert_eq!(result_cell.fg, Color::Magenta); + } + // rx column + (1..=3, 92..=101) => { + assert_eq!(result_cell.fg, Color::Red); + } + // tx column + (1..=3, 102..=111) => { + assert_eq!(result_cell.fg, Color::Blue); + } + _ => assert_eq!(result_cell.fg, Color::Reset), + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_healthy() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + // Healthy + let mut colors = AppColors::new(); + colors.container_state.running_healthy = Color::Magenta; + + 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 let (1..=3, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Magenta); + } + } + } + } + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_unhealthy() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + // Unhealthy + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ! running Up 1 hour (unhealthy) 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.running_unhealthy = Color::Red; + let status = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); + setup.app_data.lock().containers.items[0].state = State::from(("running", &status)); + setup.app_data.lock().containers.items[0].status = status; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Red); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_dead() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✖ dead Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.dead = Color::Magenta; + setup.app_data.lock().containers.items[0].state = State::Dead; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Magenta); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_exited() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ✖ exited Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.exited = Color::Gray; + setup.app_data.lock().containers.items[0].state = State::Exited; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Gray); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_paused() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ॥ paused Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.paused = Color::Cyan; + setup.app_data.lock().containers.items[0].state = State::Paused; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Cyan); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_removing() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 removing Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.removing = Color::White; + setup.app_data.lock().containers.items[0].state = State::Removing; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::White); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_restarting() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ↻ restarting Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.restarting = Color::LightYellow; + setup.app_data.lock().containers.items[0].state = State::Restarting; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::LightYellow); + } + } + } + } + + #[test] + /// Make sure that the state has the correctly color applied to it + fn test_draw_blocks_containers_custom_colors_state_unknown() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + let expected = [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ? unknown Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + + let mut colors = AppColors::new(); + colors.container_state.unknown = COLOR_ORANGE; + setup.app_data.lock().containers.items[0].state = State::Unknown; + + 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 let (1, 18..=70) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, COLOR_ORANGE); + } + } + } + } +} diff --git a/src/ui/draw_blocks/delete_confirm.rs b/src/ui/draw_blocks/delete_confirm.rs new file mode 100644 index 0000000..e7659f3 --- /dev/null +++ b/src/ui/draw_blocks/delete_confirm.rs @@ -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>, + 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/error.rs b/src/ui/draw_blocks/error.rs new file mode 100644 index 0000000..bf64ad7 --- /dev/null +++ b/src/ui/draw_blocks/error.rs @@ -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, + 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/filter.rs b/src/ui/draw_blocks/filter.rs new file mode 100644 index 0000000..3cce1a5 --- /dev/null +++ b/src/ui/draw_blocks/filter.rs @@ -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); + } + } + } + } + } +} diff --git a/src/ui/draw_blocks/headers.rs b/src/ui/draw_blocks/headers.rs new file mode 100644 index 0000000..1f8c40f --- /dev/null +++ b/src/ui/draw_blocks/headers.rs @@ -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>, + keymap: &Keymap, +) { + let gen_style = |bg: Option, 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: 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::>(); + + let container_splits = header_data.iter().map(|i| i.2).collect::>(); + 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, 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/help.rs b/src/ui/draw_blocks/help.rs new file mode 100644 index 0000000..a7777b6 --- /dev/null +++ b/src/ui/draw_blocks/help.rs @@ -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>, + 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::>(); + 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), 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/info.rs b/src/ui/draw_blocks/info.rs new file mode 100644 index 0000000..7f65310 --- /dev/null +++ b/src/ui/draw_blocks/info.rs @@ -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>, + 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); + } + } + } +} diff --git a/src/ui/draw_blocks/logs.rs b/src/ui/draw_blocks/logs.rs new file mode 100644 index 0000000..4ecff2a --- /dev/null +++ b/src/ui/draw_blocks/logs.rs @@ -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>, + area: Rect, + colors: AppColors, + f: &mut Frame, + fd: &FrameData, + gui_state: &Arc>, +) { + 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/mod.rs b/src/ui/draw_blocks/mod.rs new file mode 100644 index 0000000..39d1de7 --- /dev/null +++ b/src/ui/draw_blocks/mod.rs @@ -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>, + 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>, + pub gui_state: Arc>, + pub fd: FrameData, + pub area: Rect, + pub terminal: Terminal, + pub ids: Vec, + } + + 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>, &Arc>)> for FrameData { + fn from(data: (&Arc>, &Arc>)) -> 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 { + expected[row_index] + .chars() + .map(|i| i.to_string()) + .collect::>() + } + + pub fn get_result( + setup: &TuiTestSetup, + w: u16, + ) -> std::iter::Enumerate> { + 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::>(); + 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]); + } + } + } +} diff --git a/src/ui/draw_blocks/popup.rs b/src/ui/draw_blocks/popup.rs new file mode 100644 index 0000000..1b9329e --- /dev/null +++ b/src/ui/draw_blocks/popup.rs @@ -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] +} diff --git a/src/ui/draw_blocks/ports.rs b/src/ui/draw_blocks/ports.rs new file mode 100644 index 0000000..41cfb08 --- /dev/null +++ b/src/ui/draw_blocks/ports.rs @@ -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); + } + } + } + } +} diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index b7b1850..14ac715 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -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, - delete_map: HashMap, - heading_map: HashMap, + exec_mode: Option, loading_handle: Option>, loading_index: u8, loading_set: HashSet, - panel_map: HashMap, + intersect_delete: HashMap, + intersect_heading: HashMap, + intersect_help: Option, + intersect_panel: HashMap, selected_panel: SelectablePanel, status: HashSet, - exec_mode: Option, 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::>() @@ -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 { - self.delete_map + pub fn get_intersect_button(&self, rect: Rect) -> Option { + self.intersect_delete .iter() .filter(|i| i.1.intersects(rect)) .collect::>() @@ -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
{ - self.heading_map + pub fn get_intersect_header(&self, rect: Rect) -> Option
{ + self.intersect_heading .iter() .filter(|i| i.1.intersects(rect)) .collect::>() @@ -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 - .entry(header) - .and_modify(|w| *w = area) - .or_insert(area), - Region::Panel(panel) => self - .panel_map - .entry(panel) - .and_modify(|w| *w = area) - .or_insert(area), - Region::Delete(button) => self - .delete_map - .entry(button) - .and_modify(|w| *w = area) - .or_insert(area), + Region::Header(header) => { + self.intersect_heading + .entry(header) + .and_modify(|w| *w = area) + .or_insert(area); + } + Region::Panel(panel) => { + self.intersect_panel + .entry(panel) + .and_modify(|w| *w = area) + .or_insert(area); + } + Region::Delete(button) => { + self.intersect_delete + .entry(button) + .and_modify(|w| *w = 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; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fc3244c..c3dd246 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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>, - gui_state: &Arc>, + colors: AppColors, + keymap: &Keymap, + f: &mut Frame, fd: &FrameData, + gui_state: &Arc>, + // 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); } }