feat: config file, closes #47

Enable use of a config file, with custom keymap and custom colours
This commit is contained in:
Jack Wills
2025-02-16 12:53:54 +00:00
parent 4539d8ad07
commit f4d54e1ba8
37 changed files with 8725 additions and 3879 deletions
+507
View File
@@ -0,0 +1,507 @@
use std::fmt::Display;
use ratatui::{
layout::{Alignment, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::Span,
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType},
Frame,
};
use super::{FrameData, CONSTRAINT_50_50};
use crate::{
app_data::{ByteStats, CpuStats, State, Stats},
config::AppColors,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChartType {
Cpu,
Memory,
}
impl ChartType {
const fn name(self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Memory => "memory",
}
}
const fn get_title_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.title,
Self::Memory => colors.chart_memory.title,
}
} else {
state.get_color(colors)
}
}
const fn get_bg_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.background,
Self::Memory => colors.chart_memory.background,
}
}
const fn get_border_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.border,
Self::Memory => colors.chart_memory.border,
}
}
const fn get_y_axis_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.y_axis,
Self::Memory => colors.chart_memory.y_axis,
}
}
const fn get_max_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.max,
Self::Memory => colors.chart_memory.max,
}
} else {
state.get_color(colors)
}
}
}
// mem_stats, mem_dataset, mem.1, "", cpu.2
// current, dataset, max, name, state
/// Create charts
fn make_chart<'a, T: Stats + Display>(
chart_type: ChartType,
colors: AppColors,
current: &'a T,
dataset: Vec<Dataset<'a>>,
max: &'a T,
state: State,
) -> Chart<'a> {
let max_color = chart_type.get_max_color(colors, state);
Chart::new(dataset)
.bg(chart_type.get_bg_color(colors))
.block(
Block::default()
.style(Style::default().bg(chart_type.get_bg_color(colors)))
.title_alignment(Alignment::Center)
.title(Span::styled(
format!(" {} {current} ", chart_type.name()),
Style::default()
.fg(chart_type.get_title_color(colors, state))
.add_modifier(Modifier::BOLD),
))
// .bg(chart_type.get_bg_color(colors))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(chart_type.get_border_color(colors))),
)
.x_axis(Axis::default().bounds([0.00, 60.0]))
.y_axis(
Axis::default()
.labels(vec![
Span::styled("", Style::default().fg(max_color)),
Span::styled(
format!("{max}"),
Style::default().add_modifier(Modifier::BOLD).fg(max_color),
),
])
.style(
Style::new()
// .bg(chart_type.get_bg_color(colors))
.fg(chart_type.get_y_axis_color(colors)),
)
// Add 0.01, so that max point is always visible?
.bounds([0.0, max.get_value() + 0.01]),
)
// .style(Style::new().bg(chart_type.get_bg_color(colors)))
}
/// Draw the cpu + mem charts
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some((cpu, mem)) = fd.chart_data.as_ref() {
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_50_50)
.split(area);
let cpu_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_cpu.points))
.graph_type(GraphType::Line)
.data(&cpu.0)];
let mem_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_memory.points))
.graph_type(GraphType::Line)
.data(&mem.0)];
let cpu_stats = CpuStats::new(cpu.0.last().map_or(0.00, |f| f.1));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let mem_stats = ByteStats::new(mem.0.last().map_or(0, |f| f.1 as u64));
let cpu_chart = make_chart(
ChartType::Cpu,
colors,
&cpu_stats,
cpu_dataset,
&cpu.1,
cpu.2,
);
let mem_chart = make_chart(
ChartType::Memory,
colors,
&mem_stats,
mem_dataset,
&mem.1,
mem.2,
);
f.render_widget(cpu_chart, area[0]);
f.render_widget(mem_chart, area[1]);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::{
app_data::State,
config::AppColors,
ui::{
draw_blocks::tests::{
expected_to_vec, get_result, insert_chart_data, test_setup, COLOR_ORANGE,
},
FrameData,
},
};
/// CPU and Memory charts used in multiple tests, based on data from above insert_chart_data()
const EXPECTED: [&str; 10] = [
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮",
"│10.00%│ • ││100.00 kB│ •• │",
"│ │ •• ││ │ •• │",
"│ │ ••• ││ │ • • │",
"│ │ • • ││ │ • • │",
"│ │ • •• ││ │•• •• │",
"│ │• • ││ │• • │",
"│ │• • ││ │• • │",
"│ │ ││ │ │",
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯",
];
// co-ordinates of the dots from the cpu chart
const CPU_XY: [(usize, usize); 15] = [
(1, 12),
(2, 11),
(2, 12),
(3, 10),
(3, 11),
(3, 12),
(4, 10),
(4, 12),
(5, 9),
(5, 13),
(5, 14),
(6, 8),
(6, 13),
(7, 8),
(7, 13),
];
// co-ordinates of the dots from the memory chart
const MEM_XY: [(usize, usize); 16] = [
(1, 54),
(1, 55),
(2, 54),
(2, 55),
(3, 53),
(3, 55),
(4, 52),
(4, 55),
(5, 51),
(5, 52),
(5, 55),
(5, 56),
(6, 51),
(6, 55),
(7, 51),
(7, 55),
];
#[test]
/// When status is Running, but not data, charts drawn without dots etc, colours correct
fn test_draw_blocks_charts_running_none() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭───────────── cpu 00.00% ─────────────╮╭─────────── memory 0.00 kB ───────────╮",
"│00.00%│ ││0.00 kB│ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"│ │ ││ │ │",
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 52..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=47) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(2..=8, 1..=6 | 8..=38 | 49..=78 | 41..=47) | (1, 8..=38 | 49..=78) => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When status is Running, charts correctly drawn
fn test_draw_blocks_charts_running_some() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Whens status paused, some text is now Yellow
fn test_draw_blocks_charts_paused() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When dead, text is red
fn test_draw_blocks_charts_dead() {
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Custom colos correctly applied to each part of the charts
fn test_custom_colors() {
let mut colors = AppColors::new();
colors.chart_cpu.background = Color::White;
colors.chart_cpu.border = Color::Red;
colors.chart_cpu.title = Color::Green;
colors.chart_cpu.max = Color::Magenta;
colors.chart_cpu.points = Color::Black;
colors.chart_cpu.y_axis = Color::Blue;
colors.chart_memory.background = Color::White;
colors.chart_memory.border = Color::Red;
colors.chart_memory.title = Color::Green;
colors.chart_memory.max = Color::Magenta;
colors.chart_memory.points = Color::Black;
colors.chart_memory.y_axis = Color::Blue;
let (w, h) = (80, 10);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&EXPECTED, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// border
(0, 0..=13 | 26..=50 | 68..=79) | (9, _) | (1..=8, 0 | 39 | 40 | 79) => {
assert_eq!(result_cell.fg, Color::Red);
}
// title
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
}
// max label
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// data points
xy if CPU_XY.contains(&xy) | MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Black);
}
// y axis
(1..=8, 7 | 50) => {
assert_eq!(result_cell.fg, Color::Blue);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
+415
View File
@@ -0,0 +1,415 @@
use std::sync::Arc;
use super::RIGHT_ARROW;
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel},
};
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
Frame,
};
use super::generate_block;
/// Draw the command panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Commands)
.bg(colors.commands.background);
let items = app_data.lock().get_control_items().map_or(vec![], |i| {
i.iter()
.map(|c| {
let lines = Line::from(vec![Span::styled(
c.to_string(),
Style::default().fg(c.get_color(colors)),
)]);
ListItem::new(lines)
})
.collect::<Vec<_>>()
});
if let Some(i) = app_data.lock().get_control_state() {
let items = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol(RIGHT_ARROW);
f.render_stateful_widget(items, area, i);
} else {
let paragraph = Paragraph::new("").block(block).alignment(Alignment::Center);
f.render_widget(paragraph, area);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::{
config::AppColors,
tests::gen_container_summary,
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup, BORDER_CHARS},
FrameData,
},
};
// cusomt border colors
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
fn test_draw_blocks_commands_none() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, false, false);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────╯",
];
for (row_index, row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (cell_index, cell) in row.iter().enumerate() {
assert_eq!(cell.symbol(), expected_row[cell_index]);
}
}
}
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
fn test_draw_blocks_commands_some() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// Borders & delete
(0 | 5, _) | (1..=4, 0 | 11) | (4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Change the controls state
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup.app_data.lock().docker_controls_next();
let expected = [
"╭──────────╮",
"│ resume │",
"│▶ stop │",
"│ delete │",
"│ │",
"╰──────────╯",
];
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// resume
(1, 3..=8) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
// delete
(0 | 5, _) | (1..=4, 0 | 11) | (3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// When control panel is selected, the border is blue, if not then white, selected text is highlighted
fn test_draw_blocks_commands_panel_selected_color() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
// Unselected, has a grey border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
// Control panel now selected, should have a blue border
setup.gui_state.lock().next_panel();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if row_index == 0
|| row_index == 5
|| result_cell_index == 0
|| result_cell_index == 11
{
assert_eq!(result_cell.fg, Color::LightCyan);
}
if row_index == 1 && result_cell_index > 0 && result_cell_index < 11 {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Custom colors are rendered correctlty
fn test_draw_blocks_commands_custom_colors() {
let (w, h) = (12, 6);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.commands.background = Color::White;
colors.commands.pause = Color::Black;
colors.commands.restart = Color::Green;
colors.commands.stop = Color::Blue;
colors.commands.delete = Color::Magenta;
colors.commands.resume = Color::Yellow;
colors.commands.start = Color::Cyan;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭──────────╮",
"│▶ pause │",
"│ restart │",
"│ stop │",
"│ delete │",
"╰──────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Black);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Green);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
// Change the controls state
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup.app_data.lock().docker_controls_next();
let expected = [
"╭──────────╮",
"│ resume │",
"│▶ stop │",
"│ delete │",
"│ │",
"╰──────────╯",
];
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// resume
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Direction, Layout},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::{CONSTRAINT_BUTTONS, CONSTRAINT_POPUP};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::{
gui_state::{BoxLocation, Region},
DeleteButton, GuiState,
},
};
use super::popup;
/// Draw the delete confirm box in the centre of the screen
/// take in container id and container name here?
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
name: &ContainerName,
) {
let block = Block::default()
.title(" Confirm Delete ")
.border_type(BorderType::Rounded)
.style(
Style::default()
.bg(colors.popup_delete.background)
.fg(colors.popup_delete.text),
)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let confirm = Line::from(vec![
Span::from("Are you sure you want to delete container: "),
Span::styled(
name.get(),
Style::default()
.fg(colors.popup_delete.text_highlight)
.bg(colors.popup_delete.background)
.add_modifier(Modifier::BOLD),
),
]);
let yes_text = if keymap.delete_confirm == Keymap::new().delete_confirm {
"( y ) yes".to_owned()
} else if let Some(secondary) = keymap.delete_confirm.1 {
format!("( {} | {} ) yes", keymap.delete_confirm.0, secondary)
} else {
format!("( {} ) yes", keymap.delete_confirm.0)
};
let no_text = if keymap.delete_deny == Keymap::new().delete_deny {
"( n ) no".to_owned()
} else if let Some(secondary) = keymap.delete_deny.1 {
format!("( {} | {} ) no", keymap.delete_deny.0, secondary)
} else {
format!("( {} ) no", keymap.delete_deny.0)
};
// Find the maximum line width & height, and add some padding
let max_line_width = u16::try_from(confirm.width()).unwrap_or(64) + 12;
let lines = 8;
let confirm_para = Paragraph::new(confirm).alignment(Alignment::Center);
let button_block = || {
Block::default()
.border_type(BorderType::Rounded)
.borders(Borders::ALL)
.style(Style::default().bg(colors.popup_delete.background))
};
let yes_para = Paragraph::new(yes_text)
.alignment(Alignment::Center)
.block(button_block());
let no_para = Paragraph::new(no_text)
.alignment(Alignment::Center)
.block(button_block());
let area = popup::draw(
lines,
max_line_width.into(),
f.area(),
BoxLocation::MiddleCentre,
);
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints(CONSTRAINT_POPUP)
.split(area);
let split_buttons = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_BUTTONS)
.split(split_popup[3]);
let no_area = split_buttons[1];
let yes_area = split_buttons[3];
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(confirm_para, split_popup[1]);
f.render_widget(no_para, no_area);
f.render_widget(yes_para, yes_area);
// Insert button areas into region map, so can interact with them on click
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Cancel), no_area);
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Confirm), yes_area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::KeyCode;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete_long_name() {
let (w, h) = (106, 10);
let mut setup = test_setup(w, h, true, true);
let name = ContainerName::from("container_1_container_1_container_1");
setup.app_data.lock().containers.items[0].name = name.clone();
let expected = [
" ",
" ╭──────────────────────────────────── Confirm Delete ────────────────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1_container_1_container_1 │ ",
" │ │ ",
" │ ╭──────────────────────────────╮ ╭─────────────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰──────────────────────────────╯ ╰─────────────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(colors, f, &setup.gui_state, keymap, &name);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 98..=106) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=91) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom colors applied correctly to delete popup
fn test_draw_blocks_delete_custom_colors() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( n ) no │ │ ( y ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut colors = AppColors::new();
colors.popup_delete.background = Color::Black;
colors.popup_delete.text = Color::Yellow;
colors.popup_delete.text_highlight = Color::Green;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&Keymap::new(),
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
}
}
}
}
}
#[test]
/// Custom keymap, with multiple definitions for each button, applied correctly to delete popup
#[allow(clippy::too_many_lines)]
fn test_draw_blocks_delete_custom_keymap() {
let (w, h) = (82, 10);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End ) no │ │ ( F10 ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, None);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End | Up ) no │ │ ( F10 | L ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), Some(KeyCode::Char('L')));
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let expected = [
" ",
" ╭──────────────────────── Confirm Delete ────────────────────────╮ ",
" │ │ ",
" │ Are you sure you want to delete container: container_1 │ ",
" │ │ ",
" │ ╭─────────────────────╮ ╭─────────────────────╮ │ ",
" │ │ ( End | Up ) no │ │ ( F10 ) yes │ │ ",
" │ ╰─────────────────────╯ ╰─────────────────────╯ │ ",
" ╰────────────────────────────────────────────────────────────────╯ ",
" ",
];
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+358
View File
@@ -0,0 +1,358 @@
use ratatui::{
layout::Alignment,
style::Style,
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use super::{max_line_width, NAME, VERSION};
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::gui_state::BoxLocation,
};
use super::popup;
/// Draw an error popup over whole screen
pub fn draw(
f: &mut Frame,
error: &AppError,
keymap: &Keymap,
seconds: Option<u8>,
colors: AppColors,
) {
let block = Block::default()
.title(" Error ")
.border_type(BorderType::Rounded)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let to_push = if matches!(error, AppError::DockerConnect) {
format!(
"\n\n {}::v{} closing in {:02} seconds",
NAME,
VERSION,
seconds.unwrap_or(5)
)
} else {
let clear_suffix = "clear error";
let clear_text = if keymap.clear == Keymap::new().clear {
format!("( {} ) {clear_suffix}", keymap.clear.0)
} else if let Some(secondary) = keymap.clear.1 {
format!(" ( {} | {secondary} ) {clear_suffix}", keymap.clear.0)
} else {
format!(" ( {} ) {clear_suffix}", keymap.clear.0)
};
let quit_suffix = "quit oxker";
let quit_text = if keymap.quit == Keymap::new().quit {
format!("( {} ) {quit_suffix}", keymap.quit.0)
} else if let Some(secondary) = keymap.quit.1 {
format!(" ( {} | {secondary} ) {quit_suffix}", keymap.quit.0)
} else {
format!(" ( {} ) {quit_suffix}", keymap.quit.0)
};
format!("\n\n{clear_text}\n\n{quit_text}")
};
let mut text = format!("\n{error}");
text.push_str(to_push.as_str());
// Find the maximum line width & height
let padded_width = max_line_width(&text) + 8;
let line_count = text.lines().count();
let padded_height = if line_count % 2 == 0 {
line_count + 3
} else {
line_count + 2
};
let paragraph = Paragraph::new(text)
.style(
Style::default()
.bg(colors.popup_error.background)
.fg(colors.popup_error.text),
)
.block(block)
.alignment(Alignment::Center);
let area = popup::draw(
padded_height,
padded_width,
f.area(),
BoxLocation::MiddleCentre,
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::VERSION;
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
use crossterm::event::KeyCode;
use ratatui::style::Color;
#[test]
/// Test that the error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_docker_connect_error() {
let (w, h) = (46, 9);
let mut setup = test_setup(w, h, true, true);
let app_colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerConnect, keymap, Some(4), app_colors);
})
.unwrap();
let version_row = format!(" │ oxker::v{VERSION} closing in 04 seconds │ ");
let expected = [
" ",
" ╭───────────────── Error ──────────────────╮ ",
" │ │ ",
" │ Unable to access docker daemon │ ",
" │ │ ",
version_row.as_str(),
" │ │ ",
" ╰──────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 8, _) | (1..=7, 0 | 45) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Test that the clearable error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_clearable_error() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let app_colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, keymap, Some(4), app_colors);
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( q ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Custom colors applied to the error popup correctly
fn test_draw_blocks_clearable_error_custom_colors() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let keymap = &setup.app_data.lock().config.keymap;
let mut colors = AppColors::new();
colors.popup_error.background = Color::Yellow;
colors.popup_error.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, keymap, Some(4), colors);
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( q ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Yellow);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom keymap applied correct with both 1 and 2 definitions
fn test_draw_blocks_clearable_error_custom_keymap() {
let (w, h) = (39, 11);
let mut setup = test_setup(w, h, true, true);
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, None);
keymap.quit = (KeyCode::F(4), None);
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( Back Tab ) clear error │ ",
" │ │ ",
" │ ( F4 ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, Some(KeyCode::Char('m')));
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( Back Tab | m ) clear error │ ",
" │ │ ",
" │ ( F4 | End ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
let mut keymap = Keymap::new();
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new());
})
.unwrap();
let expected = [
" ",
" ╭────────────── Error ──────────────╮ ",
" │ │ ",
" │ Unable to exec into container │ ",
" │ │ ",
" │ ( c ) clear error │ ",
" │ │ ",
" │ ( F4 | End ) quit oxker │ ",
" │ │ ",
" ╰───────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+218
View File
@@ -0,0 +1,218 @@
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
Frame,
};
use crate::{app_data::FilterBy, ui::FrameData};
/// Create the filter_by by spans, coloured dependant on which one is selected
fn filter_by_spans(fd: &FrameData) -> [Span; 4] {
let selected = Style::default().bg(Color::Gray).fg(Color::Black);
let not_selected = Style::default().bg(Color::Reset).fg(Color::Reset);
let name = [" Name ", " Image ", " Status ", " All "];
let mut filter_spans = [
Span::styled(name[0], not_selected),
Span::styled(name[1], not_selected),
Span::styled(name[2], not_selected),
Span::styled(name[3], not_selected),
];
match fd.filter_by {
FilterBy::Name => filter_spans[0] = Span::styled(name[0], selected),
FilterBy::Image => filter_spans[1] = Span::styled(name[1], selected),
FilterBy::Status => filter_spans[2] = Span::styled(name[2], selected),
FilterBy::All => filter_spans[3] = Span::styled(name[3], selected),
}
filter_spans
}
/// Draw the filter bar
pub fn draw(area: Rect, frame: &mut Frame, fd: &FrameData) {
let style_but = Style::default().fg(Color::Black).bg(Color::Magenta);
let style_desc = Style::default().fg(Color::Gray).bg(Color::Reset);
let mut line = vec![
Span::styled(" Esc ", style_but),
Span::styled(" clear ", style_desc),
Span::styled(" ← by → ", style_but),
Span::from(" "),
];
line.extend_from_slice(&filter_by_spans(fd));
line.extend_from_slice(&[
Span::styled(
" term: ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(
fd.filter_term
.as_ref()
.map_or(String::new(), std::clone::Clone::clone),
Style::default().fg(Color::Gray),
),
]);
frame.render_widget(Line::from(line), area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use crate::ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData,
};
#[test]
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
/// Filter row is drawn correctly & colors are correct
/// Colours change when filter_by option is changed
fn test_draw_blocks_filter_row() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &setup.fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Test when char added to search term
setup.app_data.lock().filter_term_push('c');
setup.app_data.lock().filter_term_push('d');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: cd "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 54..=55 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
// Test when filter_by chances
setup.app_data.lock().filter_by_next();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, f, &fd);
})
.unwrap();
let expected = [
" Esc clear ← by → Name Image Status All term: cd "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 54..=55 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
27..=33 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=53 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
+557
View File
@@ -0,0 +1,557 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Paragraph},
Frame,
};
use super::{CONSTRAINT_100, MARGIN};
use crate::{
app_data::{Header, SortedOrder},
config::{AppColors, Keymap},
ui::{gui_state::Region, FrameData, GuiState, Status},
};
// Draw heading bar at top of program, always visible
/// TODO Should separate into loading icon/headers/help functions
#[allow(clippy::too_many_lines)]
pub fn draw(
area: Rect,
colors: AppColors,
frame: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
) {
let gen_style = |bg: Option<Color>, fg: Color| {
bg.map_or_else(
|| Style::default().fg(fg),
|bg| Style::default().bg(bg).fg(fg),
)
};
frame.render_widget(
Block::default().style(gen_style(Some(colors.headers_bar.background), Color::Reset)),
area,
);
// Generate a block for the header, if the header is currently being used to sort a column, then highlight it white
let header_block = |x: &Header, colors: AppColors| {
let mut color = colors.headers_bar.text;
let mut suffix = "";
if let Some((a, b)) = &fd.sorted_by {
if x == a {
match b {
SortedOrder::Asc => suffix = "",
SortedOrder::Desc => suffix = "",
}
color = colors.headers_bar.text_selected;
};
};
(color, suffix)
};
// Generate block for the headers, state and status has a specific layout, others all equal
// width is dependant on it that column is selected to sort - or not
// TODO - yes this is a mess, needs documenting correctly
let gen_header = |header: &Header, width: usize, colors: AppColors| {
let block = header_block(header, colors);
let text = format!(
"{x:<width$}{MARGIN}",
x = format!("{header}{ic}", ic = block.1),
);
let count = u16::try_from(text.chars().count()).unwrap_or_default();
let status = Paragraph::new(text)
.style(gen_style(None, block.0))
.alignment(Alignment::Left);
(status, count)
};
// Meta data to iterate over to create blocks with correct widths
let header_meta = [
(Header::Name, fd.columns.name.1),
(Header::State, fd.columns.state.1),
(Header::Status, fd.columns.status.1),
(Header::Cpu, fd.columns.cpu.1),
(Header::Memory, fd.columns.mem.1 + fd.columns.mem.2 + 3),
(Header::Id, fd.columns.id.1),
(Header::Image, fd.columns.image.1),
(Header::Rx, fd.columns.net_rx.1),
(Header::Tx, fd.columns.net_tx.1),
];
let suffix = if fd.status.contains(&Status::Help) {
"exit"
} else {
"show"
};
let info_text = if keymap.toggle_help == Keymap::new().toggle_help {
format!("( h ) {suffix} help{MARGIN}")
} else if let Some(secondary) = keymap.toggle_help.1 {
format!(
" ( {} | {secondary} ) {suffix} help{MARGIN}",
keymap.toggle_help.0
)
} else {
format!(" ( {} ) {suffix} help{MARGIN}", keymap.toggle_help.0)
};
let info_width = info_text.chars().count();
let column_width = usize::from(area.width).saturating_sub(info_width);
let column_width = if column_width > 0 { column_width } else { 1 };
let splits = if fd.has_containers {
vec![
Constraint::Max(4),
Constraint::Max(column_width.try_into().unwrap_or_default()),
Constraint::Max(info_width.try_into().unwrap_or_default()),
]
} else {
CONSTRAINT_100.to_vec()
};
let split_bar = Layout::default()
.direction(Direction::Horizontal)
.constraints(splits)
.split(area);
// Draw loading icon, or not, and a prefix with a single space
let loading_paragraph = Paragraph::new(format!("{:>2}", fd.loading_icon))
.style(gen_style(None, colors.headers_bar.loading_spinner))
.alignment(Alignment::Left);
frame.render_widget(loading_paragraph, split_bar[0]);
if fd.has_containers {
let header_section_width = split_bar[1].width;
let mut counter = 0;
// Only show a header if the header cumulative header width is less than the header section width
let header_data = header_meta
.iter()
.filter_map(|i| {
let header_block = gen_header(&i.0, i.1.into(), colors);
counter += header_block.1;
if counter <= header_section_width {
Some((header_block.0, i.0, Constraint::Max(header_block.1)))
} else {
None
}
})
.collect::<Vec<_>>();
let container_splits = header_data.iter().map(|i| i.2).collect::<Vec<_>>();
let headers_section = Layout::default()
.direction(Direction::Horizontal)
.constraints(container_splits)
.split(split_bar[1]);
for (index, (paragraph, header, _)) in header_data.into_iter().enumerate() {
let rect = headers_section[index];
gui_state
.lock()
.update_region_map(Region::Header(header), rect);
frame.render_widget(paragraph, rect);
}
}
// show/hide help
let help_text_color = if fd.status.contains(&Status::Help) {
colors.headers_bar.text
} else {
colors.headers_bar.text_selected
};
let help_paragraph = Paragraph::new(info_text)
.style(gen_style(None, help_text_color))
.alignment(Alignment::Right);
// If no containers, don't display the headers, could maybe do this first?
let help_index = if fd.has_containers { 2 } else { 0 };
gui_state
.lock()
.update_region_map(Region::HelpPanel, split_bar[help_index]);
frame.render_widget(help_paragraph, split_bar[help_index]);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::ops::RangeInclusive;
use crossterm::event::KeyCode;
use ratatui::style::Color;
use uuid::Uuid;
use crate::{
app_data::{Header, SortedOrder, StatefulList},
config::{AppColors, Keymap},
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData, Status,
},
};
#[test]
/// Heading back only has show/exit help when no containers, correctly coloured
fn test_draw_blocks_headers_no_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers = StatefulList::new(vec![]);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.fg, Color::Gray,);
}
}
fd.status.insert(Status::Help);
let expected = [" ( h ) exit help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
#[test]
/// Show all headings when containers present, colors valid
fn test_draw_blocks_headers_some_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
112..=121 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Only show the headings that fit the reduced-in-size header section
fn test_draw_blocks_headers_some_containers_reduced_width() {
let (w, h) = (80, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected =
[" name state status cpu ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=50 => Color::Black,
51..=61 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Test all combination of headers & sort by
fn test_draw_blocks_headers_sort_containers() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
// Actual test, used for each header and sorted type
let mut test =
|expected: &[&str], range: RangeInclusive<usize>, x: (Header, SortedOrder)| {
fd.sorted_by = Some(x);
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
122..=139 => Color::Gray,
// given range | help section
x if range.contains(&x) => Color::Gray,
112..=121 => Color::Reset,
_ => Color::Black,
}
);
}
}
};
// Name
test(&[" name ▲ state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 1..=17, (Header::Name, SortedOrder::Asc));
test(&[" name ▼ state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 1..=17, (Header::Name, SortedOrder::Desc));
// state
test(&[" name state ▲ status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "],18..=29, (Header::State, SortedOrder::Asc));
test(&[" name state ▼ status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 18..=29, (Header::State, SortedOrder::Desc));
// status
test(&[" name state status ▲ cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 30..=41, (Header::Status, SortedOrder::Asc));
test(&[" name state status ▼ cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "], 30..=41, (Header::Status, SortedOrder::Desc));
// cpu
test(&[" name state status cpu ▲ memory/limit id image ↓ rx ↑ tx ( h ) show help "],42..=50, (Header::Cpu, SortedOrder::Asc));
test(&[" name state status cpu ▼ memory/limit id image ↓ rx ↑ tx ( h ) show help "],42..=50, (Header::Cpu, SortedOrder::Desc));
// memory
test(&[" name state status cpu memory/limit ▲ id image ↓ rx ↑ tx ( h ) show help "], 51..=70, (Header::Memory, SortedOrder::Asc));
test(&[" name state status cpu memory/limit ▼ id image ↓ rx ↑ tx ( h ) show help "], 51..=70, (Header::Memory, SortedOrder::Desc));
//id
test(&[" name state status cpu memory/limit id ▲ image ↓ rx ↑ tx ( h ) show help "], 71..=81, (Header::Id, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id ▼ image ↓ rx ↑ tx ( h ) show help "], 71..=81, (Header::Id, SortedOrder::Desc));
// image
test(&[" name state status cpu memory/limit id image ▲ ↓ rx ↑ tx ( h ) show help "], 82..=91, (Header::Image, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ▼ ↓ rx ↑ tx ( h ) show help "], 82..=91, (Header::Image, SortedOrder::Desc));
// rx
test(&[" name state status cpu memory/limit id image ↓ rx ▲ ↑ tx ( h ) show help "], 92..=101, (Header::Rx, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ↓ rx ▼ ↑ tx ( h ) show help "], 92..=101, (Header::Rx, SortedOrder::Desc));
// tx
test(&[" name state status cpu memory/limit id image ↓ rx ↑ tx ▲ ( h ) show help "], 102..=111, (Header::Tx, SortedOrder::Asc));
test(&[" name state status cpu memory/limit id image ↓ rx ↑ tx ▼ ( h ) show help "], 102..=111, (Header::Tx, SortedOrder::Desc));
}
#[test]
/// Show animation
fn test_draw_blocks_headers_animation() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let expected = [" ⠙ name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
122..=140 => Color::Gray,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom colors are applied correctly
fn test_draw_blocks_headers_cusomt_colors() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let keymap = &setup.app_data.lock().config.keymap;
let mut colors = AppColors::new();
colors.headers_bar.background = Color::Black;
colors.headers_bar.loading_spinner = Color::Green;
colors.headers_bar.text = Color::Blue;
colors.headers_bar.text_selected = Color::Yellow;
let expected = [" ⠙ name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help "];
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd, &setup.gui_state, keymap);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::Green,
4..=111 => Color::Blue,
122..=140 => Color::Yellow,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom keymap for help panel is correctly display, with one and two definitions
fn test_draw_blocks_headers_custom_keymap() {
let (w, h) = (140, 1);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let mut keymap = Keymap::new();
keymap.toggle_help = (KeyCode::Char('T'), None);
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( T ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
keymap.toggle_help = (KeyCode::Char('T'), Some(KeyCode::Tab));
let expected = [" name state status cpu memory/limit id image ↓ rx ↑ tx ( T | Tab ) show help "];
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+886
View File
@@ -0,0 +1,886 @@
use crossterm::event::KeyCode;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use crate::{
config::{AppColors, Keymap},
ui::gui_state::BoxLocation,
};
use super::{popup, DESCRIPTION, NAME_TEXT, REPO, VERSION};
/// Help popup box needs these three pieces of information
struct HelpInfo {
lines: Vec<Line<'static>>,
width: usize,
height: usize,
}
impl HelpInfo {
/// Find the max width of a Span in &[Line]
fn calc_width(lines: &[Line]) -> usize {
lines
.iter()
.map(ratatui::prelude::Line::width)
.max()
.unwrap_or(1)
}
/// Just an empty span, i.e. a new line
fn empty_span<'a>() -> Line<'a> {
Line::from(String::new())
}
/// generate a span, of given &str and given color
fn span<'a>(input: &str, color: Color) -> Span<'a> {
Span::styled(input.to_owned(), Style::default().fg(color))
}
/// &str to black text span
fn text_span<'a>(input: &str, color: AppColors) -> Span<'a> {
Self::span(input, color.popup_help.text)
}
/// &str to white text span
fn highlighted_text_span<'a>(input: &str, color: AppColors) -> Span<'a> {
Self::span(input, color.popup_help.text_highlight)
}
/// Generate the `oxker` name span + metadata
fn gen_name(colors: AppColors) -> Self {
let mut lines = NAME_TEXT
.lines()
.map(|i| Line::from(Self::highlighted_text_span(i, colors)))
.collect::<Vec<_>>();
lines.insert(0, Self::empty_span());
let width = Self::calc_width(&lines);
let height = lines.len();
Self {
lines,
width,
height,
}
}
/// Generate the description span + metadata
fn gen_description(colors: AppColors) -> Self {
let lines = [
Self::empty_span(),
Line::from(Self::highlighted_text_span(DESCRIPTION, colors)),
Self::empty_span(),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the button information span + metadata
fn gen_keymap_info(colors: AppColors) -> Self {
let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors);
let button_desc = |x: &str| Self::text_span(x, colors);
let or = || button_desc("or");
let space = || button_desc(" ");
let lines = [
Line::from(vec![
space(),
button_item("tab"),
or(),
button_item("shift+tab"),
button_desc("change panels"),
]),
Line::from(vec![
space(),
button_item("↑ ↓"),
or(),
button_item("j k"),
or(),
button_item("PgUp PgDown"),
or(),
button_item("Home End"),
button_desc("change selected line"),
]),
Line::from(vec![
space(),
button_item("enter"),
button_desc("send docker container command"),
]),
Line::from(vec![
space(),
button_item("e"),
button_desc("exec into a container"),
#[cfg(target_os = "windows")]
button_desc(" - not available on Windows"),
]),
Line::from(vec![
space(),
button_item("h"),
button_desc("toggle this help information - or click heading"),
]),
Line::from(vec![
space(),
button_item("s"),
button_desc("save logs to file"),
]),
Line::from(vec![
space(),
button_item("m"),
button_desc(
"toggle mouse capture - if disabled, text on screen can be selected & copied",
),
]),
Line::from(vec![
space(),
button_item("F1"),
or(),
button_item("/"),
button_desc("enter filter mode"),
]),
Line::from(vec![space(), button_item("0"), button_desc("stop sort")]),
Line::from(vec![
space(),
button_item("1 - 9"),
button_desc("sort by header - or click header"),
]),
Line::from(vec![
space(),
button_item("esc"),
button_desc("close dialog"),
]),
Line::from(vec![
space(),
button_item("q"),
button_desc("quit at any time"),
]),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the final lines, GitHub link etc, + metadata
fn gen_final(colors: AppColors) -> Self {
let lines = [
Self::empty_span(),
Line::from(vec![Self::text_span(
"currently an early work in progress, all and any input appreciated",
colors,
)]),
Line::from(vec![Span::styled(
REPO,
Style::default()
.fg(colors.popup_help.text_highlight)
.add_modifier(Modifier::UNDERLINED),
)]),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
/// Generate the display information when a custom keymap is being used
fn gen_custom_keymap_info(colors: AppColors, km: &Keymap) -> Self {
let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors);
let button_desc = |x: &str| Self::text_span(x, colors);
let or = || button_desc("or");
let space = || button_desc(" ");
let or_secondary = |a: (KeyCode, Option<KeyCode>), desc: &str| {
a.1.map_or_else(
|| {
Line::from(vec![
space(),
button_item(&a.0.to_string()),
button_desc(desc),
])
},
|secondary| {
Line::from(vec![
space(),
button_item(&a.0.to_string()),
or(),
button_item(&secondary.to_string()),
button_desc(desc),
])
},
)
};
let lines = [
Line::from(vec![Span::from("Custom keymap config in use\n")])
.alignment(Alignment::Center)
.style(Style::default().fg(colors.popup_help.text_highlight)),
or_secondary(km.select_next_panel, "select next panel"),
or_secondary(km.select_previous_panel, "select previous panel"),
or_secondary(km.scroll_down_one, "scroll list down by one"),
or_secondary(km.scroll_up_one, "scroll list up by one"),
or_secondary(km.scroll_down_many, "scroll list down by many"),
or_secondary(km.scroll_up_many, "scroll list by up many"),
or_secondary(km.scroll_end, "scroll list to end"),
or_secondary(km.scroll_start, "scroll list to start"),
Line::from(vec![
space(),
button_item("enter"),
button_desc("send docker container command"),
]),
#[cfg(not(target_os = "windows"))]
or_secondary(km.exec, "exec into a container"),
#[cfg(target_os = "windows")]
or_secondary(km.exec, "exec into a container - not available on Windows"),
or_secondary(
km.toggle_help,
"toggle this help information - or click heading",
),
or_secondary(km.toggle_help, "save logs to file"),
or_secondary(
km.toggle_mouse_capture,
"toggle mouse capture - if disabled, text on screen can be selected & copied",
),
or_secondary(km.filter_mode, "enter filter mode"),
or_secondary(km.sort_reset, "reset container sorting"),
or_secondary(km.sort_by_name, "sort containers by name"),
or_secondary(km.sort_by_state, "sort containers by state"),
or_secondary(km.sort_by_status, "sort containers by status"),
or_secondary(km.sort_by_cpu, "sort containers by cpu"),
or_secondary(km.sort_by_memory, "sort containers by memory"),
or_secondary(km.sort_by_id, "sort containers by id"),
or_secondary(km.sort_by_image, "sort containers by image"),
or_secondary(km.sort_by_rx, "sort containers by rx"),
or_secondary(km.sort_by_tx, "sort containers by tx"),
or_secondary(km.clear, "close dialog"),
or_secondary(km.quit, "quit at any time"),
];
Self {
lines: lines.to_vec(),
width: Self::calc_width(&lines),
height: lines.len(),
}
}
}
/// Draw the help box in the centre of the screen
pub fn draw(f: &mut Frame, colors: AppColors, keymap: &Keymap) {
let title = format!(" {VERSION} ");
let name_info = HelpInfo::gen_name(colors);
let description_info = HelpInfo::gen_description(colors);
let final_info = HelpInfo::gen_final(colors);
let button_info = if keymap == &Keymap::new() {
HelpInfo::gen_keymap_info(colors)
} else {
HelpInfo::gen_custom_keymap_info(colors, keymap)
};
let max_line_width = [
name_info.width,
description_info.width,
button_info.width,
final_info.width,
]
.into_iter()
.max()
.unwrap_or_default()
+ 2;
let max_height =
name_info.height + description_info.height + button_info.height + final_info.height + 2;
let area = popup::draw(
max_height,
max_line_width,
f.area(),
BoxLocation::MiddleCentre,
);
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Max(name_info.height.try_into().unwrap_or_default()),
Constraint::Max(description_info.height.try_into().unwrap_or_default()),
Constraint::Max(button_info.height.try_into().unwrap_or_default()),
Constraint::Min(final_info.height.try_into().unwrap_or_default()),
])
.split(area);
let name_paragraph = Paragraph::new(name_info.lines)
.style(
Style::default()
.bg(colors.popup_help.background)
.fg(colors.popup_help.text_highlight),
)
.alignment(Alignment::Center);
let style = || {
Style::default()
.bg(colors.popup_help.background)
.fg(colors.popup_help.text)
};
let description_paragraph = Paragraph::new(description_info.lines)
.style(style())
.alignment(Alignment::Center);
let help_paragraph = Paragraph::new(button_info.lines)
.style(style())
.alignment(Alignment::Left);
let final_paragraph = Paragraph::new(final_info.lines)
.style(style())
.alignment(Alignment::Center);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::default()
.fg(colors.popup_help.text)
.bg(colors.popup_help.background),
);
// Order is important here
f.render_widget(Clear, area);
f.render_widget(name_paragraph, split_popup[0]);
f.render_widget(description_paragraph, split_popup[1]);
f.render_widget(help_paragraph, split_popup[2]);
f.render_widget(final_paragraph, split_popup[3]);
f.render_widget(block, area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::{
config::{AppColors, Keymap},
ui::draw_blocks::VERSION,
};
use crossterm::event::KeyCode;
use ratatui::style::{Color, Modifier};
use crate::ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup};
#[test]
/// This will cause issues once the version has more than the current 5 chars (0.5.0)
fn test_draw_blocks_help() {
let (w, h) = (87, 33);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(f, colors, &setup.app_data.lock().config.keymap);
})
.unwrap();
let version_row = format!("{VERSION} ────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ ( tab ) or ( shift+tab ) change panels │ ",
" │ ( ↑ ↓ ) or ( j k ) or ( PgUp PgDown ) or ( Home End ) change selected line │ ",
" │ ( enter ) send docker container command │ ",
" │ ( e ) exec into a container │ ",
" │ ( h ) toggle this help information - or click heading │ ",
" │ ( s ) save logs to file │ ",
" │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( F1 ) or ( / ) enter filter mode │ ",
" │ ( 0 ) stop sort │ ",
" │ ( 1 - 9 ) sort by header - or click header │ ",
" │ ( esc ) close dialog │ ",
" │ ( q ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰───────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
// first & last row, and first & last char on each row, is reset/reset, making sure that the help info is centered in the given area
(0 | 32, _) | (0..=33, 0 | 86) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
// border is black on magenta
(1 | 31, _) | (1..=31, 1 | 85) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
// oxker logo && description
(2..=10, 2..=85) | (12, 19..=66)
// button in the brackets
| (14, 2..=10 | 13..=27)
| (15, 2..=10 | 13..=21 | 24..=40 | 43..=56)
| (16 | 23, 2..=12)
| (17..=20 | 22 | 25, 2..=8)
| (21, 2..=9 | 12..=18)
| (24, 2..=10) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::White);
}
// The URL is white and underlined
(28, 25..=60) => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::White);
assert_eq!(result_cell.modifier, Modifier::UNDERLINED);
}
// The rest is black on magenta
_ => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Test that the help panel gets drawn with custom colors
fn test_draw_blocks_help_custom_colors() {
let (w, h) = (87, 33);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.popup_help.background = Color::Black;
colors.popup_help.text = Color::Red;
colors.popup_help.text_highlight = Color::Yellow;
setup
.terminal
.draw(|f| {
super::draw(f, colors, &setup.app_data.lock().config.keymap);
})
.unwrap();
let version_row = format!("{VERSION} ────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ ( tab ) or ( shift+tab ) change panels │ ",
" │ ( ↑ ↓ ) or ( j k ) or ( PgUp PgDown ) or ( Home End ) change selected line │ ",
" │ ( enter ) send docker container command │ ",
" │ ( e ) exec into a container │ ",
" │ ( h ) toggle this help information - or click heading │ ",
" │ ( s ) save logs to file │ ",
" │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( F1 ) or ( / ) enter filter mode │ ",
" │ ( 0 ) stop sort │ ",
" │ ( 1 - 9 ) sort by header - or click header │ ",
" │ ( esc ) close dialog │ ",
" │ ( q ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰───────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
// first & last row, and first & last char on each row, is reset/reset, making sure that the help info is centered in the given area
(0 | 32, _) | (0..=33, 0 | 86) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
// border is black on magenta
(1 | 31, _) | (1..=31, 1 | 85) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Red);
}
// oxker logo && description
(2..=10, 2..=85) | (12, 19..=66)
// button in the brackets
| (14, 2..=10 | 13..=27)
| (15, 2..=10 | 13..=21 | 24..=40 | 43..=56)
| (16 | 23, 2..=12)
| (17..=20 | 22 | 25, 2..=8)
| (21, 2..=9 | 12..=18)
| (24, 2..=10) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
}
// The URL is yellow and underlined
(28, 25..=60) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::UNDERLINED);
}
// The rest is red on black
_ => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Red);
}
}
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with one definition for each entry
fn test_draw_blocks_custom_keymap_one_definition() {
let (w, h) = (98, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), None),
delete_deny: (KeyCode::Char('c'), None),
delete_confirm: (KeyCode::Char('e'), None),
exec: (KeyCode::Char('g'), None),
filter_mode: (KeyCode::Char('i'), None),
quit: (KeyCode::Char('k'), None),
save_logs: (KeyCode::Char('m'), None),
scroll_down_many: (KeyCode::Char('o'), None),
scroll_down_one: (KeyCode::Char('q'), None),
scroll_end: (KeyCode::Char('s'), None),
scroll_start: (KeyCode::Char('u'), None),
scroll_up_many: (KeyCode::Char('w'), None),
scroll_up_one: (KeyCode::Char('y'), None),
select_next_panel: (KeyCode::Char('0'), None),
select_previous_panel: (KeyCode::Char('2'), None),
sort_by_name: (KeyCode::Char('4'), None),
sort_by_state: (KeyCode::Char('6'), None),
sort_by_status: (KeyCode::Char('8'), None),
sort_by_cpu: (KeyCode::F(1), None),
sort_by_memory: (KeyCode::Char('#'), None),
sort_by_id: (KeyCode::Char('/'), None),
sort_by_image: (KeyCode::Char(','), None),
sort_by_rx: (KeyCode::Char('.'), None),
sort_by_tx: (KeyCode::Backspace, None),
sort_reset: (KeyCode::Up, None),
toggle_help: (KeyCode::Home, None),
toggle_mouse_capture: (KeyCode::PageDown, None),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ─────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) select next panel │ ",
" │ ( 2 ) select previous panel │ ",
" │ ( q ) scroll list down by one │ ",
" │ ( y ) scroll list up by one │ ",
" │ ( o ) scroll list down by many │ ",
" │ ( w ) scroll list by up many │ ",
" │ ( s ) scroll list to end │ ",
" │ ( u ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) exec into a container │ ",
" │ ( Home ) toggle this help information - or click heading │ ",
" │ ( Home ) save logs to file │ ",
" │ ( Page Down ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) enter filter mode │ ",
" │ ( Up ) reset container sorting │ ",
" │ ( 4 ) sort containers by name │ ",
" │ ( 6 ) sort containers by state │ ",
" │ ( 8 ) sort containers by status │ ",
" │ ( F1 ) sort containers by cpu │ ",
" │ ( # ) sort containers by memory │ ",
" │ ( / ) sort containers by id │ ",
" │ ( , ) sort containers by image │ ",
" │ ( . ) sort containers by rx │ ",
" │ ( Backspace ) sort containers by tx │ ",
" │ ( a ) close dialog │ ",
" │ ( k ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" "
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if row_index == 14 && (36..=62).contains(&result_cell_index) {
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with two definition for each entry
fn test_draw_blocks_custom_keymap_two_definitions() {
let (w, h) = (110, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_deny: (KeyCode::Char('c'), Some(KeyCode::Char('d'))),
delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), Some(KeyCode::Char('h'))),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
quit: (KeyCode::Char('k'), Some(KeyCode::Char('l'))),
save_logs: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
scroll_down_many: (KeyCode::Char('o'), Some(KeyCode::Char('p'))),
scroll_down_one: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
scroll_end: (KeyCode::Char('s'), Some(KeyCode::Char('t'))),
scroll_start: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
scroll_up_many: (KeyCode::Char('w'), Some(KeyCode::Char('x'))),
scroll_up_one: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
select_next_panel: (KeyCode::Char('0'), Some(KeyCode::Char('1'))),
select_previous_panel: (KeyCode::Char('2'), Some(KeyCode::Char('3'))),
sort_by_name: (KeyCode::Char('4'), Some(KeyCode::Char('5'))),
sort_by_state: (KeyCode::Char('6'), Some(KeyCode::Char('7'))),
sort_by_status: (KeyCode::Char('8'), Some(KeyCode::Char('9'))),
sort_by_cpu: (KeyCode::F(1), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Char('#'), Some(KeyCode::Char('-'))),
sort_by_id: (KeyCode::Char('/'), Some(KeyCode::Char('='))),
sort_by_image: (KeyCode::Char(','), Some(KeyCode::Char('\\'))),
sort_by_rx: (KeyCode::Char('.'), Some(KeyCode::Char(']'))),
sort_by_tx: (KeyCode::Backspace, Some(KeyCode::BackTab)),
sort_reset: (KeyCode::Up, Some(KeyCode::Down)),
toggle_help: (KeyCode::Home, Some(KeyCode::Delete)),
toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ───────────────────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) or ( 1 ) select next panel │ ",
" │ ( 2 ) or ( 3 ) select previous panel │ ",
" │ ( q ) or ( r ) scroll list down by one │ ",
" │ ( y ) or ( z ) scroll list up by one │ ",
" │ ( o ) or ( p ) scroll list down by many │ ",
" │ ( w ) or ( x ) scroll list by up many │ ",
" │ ( s ) or ( t ) scroll list to end │ ",
" │ ( u ) or ( v ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) or ( h ) exec into a container │ ",
" │ ( Home ) or ( Del ) toggle this help information - or click heading │ ",
" │ ( Home ) or ( Del ) save logs to file │ ",
" │ ( Page Down ) or ( Page Up ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) or ( j ) enter filter mode │ ",
" │ ( Up ) or ( Down ) reset container sorting │ ",
" │ ( 4 ) or ( 5 ) sort containers by name │ ",
" │ ( 6 ) or ( 7 ) sort containers by state │ ",
" │ ( 8 ) or ( 9 ) sort containers by status │ ",
" │ ( F1 ) or ( F12 ) sort containers by cpu │ ",
" │ ( # ) or ( - ) sort containers by memory │ ",
" │ ( / ) or ( = ) sort containers by id │ ",
r" │ ( , ) or ( \ ) sort containers by image │ ",
" │ ( . ) or ( ] ) sort containers by rx │ ",
" │ ( Backspace ) or ( Back Tab ) sort containers by tx │ ",
" │ ( a ) or ( b ) close dialog │ ",
" │ ( k ) or ( l ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
/// Help panel will show custom keymap if in use, with either one or two definition for each entry
fn test_draw_blocks_custom_keymap_one_and_two_definitions() {
let (w, h) = (110, 48);
let mut setup = test_setup(w, h, true, true);
let colors = setup.app_data.lock().config.app_colors;
let input = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_deny: (KeyCode::Char('c'), None),
delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), None),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
quit: (KeyCode::Char('k'), None),
save_logs: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
scroll_down_many: (KeyCode::Char('o'), None),
scroll_down_one: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
scroll_end: (KeyCode::Char('s'), None),
scroll_start: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
scroll_up_many: (KeyCode::Char('w'), None),
scroll_up_one: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
select_next_panel: (KeyCode::Char('0'), None),
select_previous_panel: (KeyCode::Char('2'), Some(KeyCode::Char('3'))),
sort_by_name: (KeyCode::Char('4'), None),
sort_by_state: (KeyCode::Char('6'), Some(KeyCode::Char('7'))),
sort_by_status: (KeyCode::Char('8'), None),
sort_by_cpu: (KeyCode::F(1), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Char('#'), None),
sort_by_id: (KeyCode::Char('/'), Some(KeyCode::Char('='))),
sort_by_image: (KeyCode::Char(','), None),
sort_by_rx: (KeyCode::Char('.'), Some(KeyCode::Char(']'))),
sort_by_tx: (KeyCode::Backspace, None),
sort_reset: (KeyCode::Up, Some(KeyCode::Down)),
toggle_help: (KeyCode::Home, None),
toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)),
};
setup
.terminal
.draw(|f| {
super::draw(f, colors, &input);
})
.unwrap();
let version_row = format!("{VERSION} ───────────────────────────────────────────────────────────────────────────────────────────────────╮ ");
let expected = [
" ",
version_row.as_str(),
" │ │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ 88 │ ",
" │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ",
r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#,
r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#,
r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#,
r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#,
" │ │ ",
" │ A simple tui to view & control docker containers │ ",
" │ │ ",
" │ Custom keymap config in use │ ",
" │ ( 0 ) select next panel │ ",
" │ ( 2 ) or ( 3 ) select previous panel │ ",
" │ ( q ) or ( r ) scroll list down by one │ ",
" │ ( y ) or ( z ) scroll list up by one │ ",
" │ ( o ) scroll list down by many │ ",
" │ ( w ) scroll list by up many │ ",
" │ ( s ) scroll list to end │ ",
" │ ( u ) or ( v ) scroll list to start │ ",
" │ ( enter ) send docker container command │ ",
" │ ( g ) exec into a container │ ",
" │ ( Home ) toggle this help information - or click heading │ ",
" │ ( Home ) save logs to file │ ",
" │ ( Page Down ) or ( Page Up ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ",
" │ ( i ) or ( j ) enter filter mode │ ",
" │ ( Up ) or ( Down ) reset container sorting │ ",
" │ ( 4 ) sort containers by name │ ",
" │ ( 6 ) or ( 7 ) sort containers by state │ ",
" │ ( 8 ) sort containers by status │ ",
" │ ( F1 ) or ( F12 ) sort containers by cpu │ ",
" │ ( # ) sort containers by memory │ ",
" │ ( / ) or ( = ) sort containers by id │ ",
" │ ( , ) sort containers by image │ ",
" │ ( . ) or ( ] ) sort containers by rx │ ",
" │ ( Backspace ) sort containers by tx │ ",
" │ ( a ) or ( b ) close dialog │ ",
" │ ( k ) quit at any time │ ",
" │ │ ",
" │ currently an early work in progress, all and any input appreciated │ ",
" │ https://github.com/mrjackwills/oxker │ ",
" │ │ ",
" │ │ ",
" ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ",
" ",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
use std::{sync::Arc, time::Instant};
use parking_lot::Mutex;
use ratatui::{
layout::Alignment,
style::Style,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::{
config::AppColors,
ui::{gui_state::BoxLocation, GuiState},
};
use super::{max_line_width, popup};
/// Draw info box in one of the 9 BoxLocations
// TODO is this broken - I don't think so
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
instant: &Instant,
msg: String,
) {
let block = Block::default()
.title("")
.title_alignment(Alignment::Center)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.borders(Borders::NONE);
let max_line_width = max_line_width(&msg) + 8;
let lines = msg.lines().count() + 2;
let paragraph = Paragraph::new(msg)
.block(block)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.alignment(Alignment::Center);
let area = popup::draw(lines, max_line_width, f.area(), BoxLocation::BottomRight);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
if instant.elapsed().as_millis() > 4000 {
gui_state.lock().reset_info_box();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::Color;
use crate::{
config::AppColors,
ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup},
};
#[test]
/// Info box drawn in bottom right
fn test_draw_blocks_info() {
let (w, h) = (45, 9);
let mut setup = test_setup(w, h, true, true);
let expected = [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" test ",
" ",
];
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Blue, Color::White),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
#[test]
/// Info box drawn in bottom right with custom colors applied
fn test_draw_blocks_info_custom_color() {
let (w, h) = (45, 9);
let mut setup = test_setup(w, h, true, true);
let mut colors = AppColors::new();
colors.popup_info.background = Color::Red;
colors.popup_info.text = Color::Black;
let expected = [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" test ",
" ",
];
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Red, Color::Black),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
}
+386
View File
@@ -0,0 +1,386 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{List, Paragraph},
Frame,
};
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel, Status},
};
use super::{generate_block, RIGHT_ARROW};
/// Draw the logs panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Logs);
if fd.status.contains(&Status::Init) {
let paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon))
.style(Style::default())
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area);
} else {
let logs = app_data.lock().get_logs();
if logs.is_empty() {
let paragraph = Paragraph::new("no logs found")
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area);
} else {
let items = List::new(logs)
.block(block)
.highlight_symbol(RIGHT_ARROW)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
// This should always return Some, as logs is not empty
if let Some(log_state) = app_data.lock().get_log_state() {
f.render_stateful_widget(items, area, log_state);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ratatui::style::{Color, Modifier};
use uuid::Uuid;
use crate::{
app_data::{ContainerImage, ContainerName},
ui::{
draw_blocks::tests::{
expected_to_vec, get_result, insert_logs, test_setup, BORDER_CHARS,
},
FrameData, Status,
},
};
#[test]
/// No logs, panel unselected, then selected, border color changes correctly
fn test_draw_blocks_logs_none() {
let (w, h) = (35, 6);
let mut setup = test_setup(w, h, true, true);
let expected = [
"╭ Logs - container_1 - image_1 ───╮",
"│ no logs found │",
"│ │",
"│ │",
"│ │",
"╰─────────────────────────────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0 | 5, 0..=34) | (1..=4, 0) | (1..=5, 34) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
setup.gui_state.lock().next_panel();
setup.gui_state.lock().next_panel();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
// When selected, has a blue border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::LightCyan);
}
}
}
}
#[test]
/// Parsing logs, spinner visible, and then animates by one frame
fn test_draw_blocks_logs_parsing() {
let (w, h) = (32, 6);
let mut setup = test_setup(w, h, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let expected = [
"╭ Logs - container_1 - image_1 ╮",
"│ parsing logs ⠙ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
// animation moved by one frame
setup.gui_state.lock().next_loading(uuid);
let expected = [
"╭ Logs - container_1 - image_1 ╮",
"│ parsing logs ⠹ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Logs correct displayed, changing log state also draws correctly
fn test_draw_blocks_logs_some() {
let (w, h) = (36, 6);
let mut setup = test_setup(w, h, true, true);
insert_logs(&setup);
let colors = setup.app_data.lock().config.app_colors;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭ Logs 3/3 - container_1 - image_1 ╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"╰──────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 3 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
// Change selected log line
setup.app_data.lock().log_previous();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
let expected = [
"╭ Logs 2/3 - container_1 - image_1 ╮",
"│ line 1 │",
"│▶ line 2 │",
"│ line 3 │",
"│ │",
"╰──────────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 2 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Full (long) name displayed in logs border
fn test_draw_blocks_logs_long_name() {
let (w, h) = (80, 6);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
insert_logs(&setup);
let expected = [
"╭ Logs 3/3 - a_long_container_name_for_the_purposes_of_this_test - a_long_image╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+525
View File
@@ -0,0 +1,525 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Constraint, Rect},
style::Style,
widgets::{Block, BorderType, Borders},
};
use crate::config::AppColors;
use super::{gui_state::Region, FrameData, GuiState, SelectablePanel, Status};
pub mod charts;
pub mod commands;
pub mod containers;
pub mod delete_confirm;
pub mod error;
pub mod filter;
pub mod headers;
pub mod help;
pub mod info;
pub mod logs;
pub mod popup;
pub mod ports;
pub const NAME_TEXT: &str = r#"
88
88
88
,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba,
a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8
8b d8 )888( 8888[ 8PP""""""" 88
"8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88
`"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 "#;
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REPO: &str = env!("CARGO_PKG_REPOSITORY");
pub const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
pub const MARGIN: &str = " ";
pub const RIGHT_ARROW: &str = "";
pub const CIRCLE: &str = "";
pub const CONSTRAINT_50_50: [Constraint; 2] =
[Constraint::Percentage(50), Constraint::Percentage(50)];
pub const CONSTRAINT_100: [Constraint; 1] = [Constraint::Percentage(100)];
pub const CONSTRAINT_POPUP: [Constraint; 5] = [
Constraint::Min(2),
Constraint::Max(1),
Constraint::Max(1),
Constraint::Max(3),
Constraint::Min(1),
];
pub const CONSTRAINT_BUTTONS: [Constraint; 5] = [
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
];
/// From a given &str, return the maximum number of chars on a single line
pub fn max_line_width(text: &str) -> usize {
text.lines()
.map(|i| i.chars().count())
.max()
.unwrap_or_default()
}
/// Generate block, add a border if is the selected panel,
/// add custom title based on state of each panel
fn generate_block<'a>(
area: Rect,
colors: AppColors,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
panel: SelectablePanel,
) -> Block<'a> {
gui_state
.lock()
.update_region_map(Region::Panel(panel), area);
let mut title = match panel {
SelectablePanel::Containers => {
format!("{}{}", panel.title(), fd.container_title)
}
SelectablePanel::Logs => {
format!("{}{}", panel.title(), fd.log_title)
}
SelectablePanel::Commands => String::new(),
};
if !title.is_empty() {
title = format!(" {title} ");
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(title);
if !fd.status.contains(&Status::Filter) {
if fd.selected_panel == panel {
block = block.border_style(Style::default().fg(colors.borders.selected));
} else {
block = block.border_style(Style::default().fg(colors.borders.unselected));
}
}
block
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub mod tests {
use std::{
net::{IpAddr, Ipv4Addr},
sync::Arc,
};
use parking_lot::Mutex;
use ratatui::{backend::TestBackend, layout::Rect, style::Color, Terminal};
use crate::{
app_data::{AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts},
tests::{gen_appdata, gen_containers},
ui::{draw_frame, GuiState},
};
use super::FrameData;
pub struct TuiTestSetup {
pub app_data: Arc<Mutex<AppData>>,
pub gui_state: Arc<Mutex<GuiState>>,
pub fd: FrameData,
pub area: Rect,
pub terminal: Terminal<TestBackend>,
pub ids: Vec<ContainerId>,
}
pub const BORDER_CHARS: [&str; 6] = ["", "", "", "", "", ""];
pub const COLOR_RX: Color = Color::Rgb(255, 233, 193);
pub const COLOR_TX: Color = Color::Rgb(205, 140, 140);
pub const COLOR_ORANGE: Color = Color::Rgb(255, 178, 36);
impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData {
fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> Self {
let (app_data, gui_data) = (data.0.lock(), data.1.lock());
// set max height for container section, needs +5 to deal with docker commands list and borders
let height = app_data.get_container_len();
let height = if height < 12 {
u16::try_from(height + 5).unwrap_or_default()
} else {
12
};
let (filter_by, filter_term) = app_data.get_filter();
Self {
chart_data: app_data.get_chart_data(),
columns: app_data.get_width(),
container_title: app_data.get_container_title(),
delete_confirm: gui_data.get_delete_container(),
filter_by,
filter_term: filter_term.cloned(),
has_containers: app_data.get_container_len() > 0,
has_error: app_data.get_error(),
height,
ports: app_data.get_selected_ports(),
port_max_lens: app_data.get_longest_port(),
info_text: gui_data.info_box_text.clone(),
is_loading: gui_data.is_loading(),
loading_icon: gui_data.get_loading().to_string(),
log_title: app_data.get_log_title(),
selected_panel: gui_data.get_selected_panel(),
sorted_by: app_data.get_sorted(),
status: gui_data.get_status(),
}
}
}
/// Generate state to be used in *most* gui tests
pub fn test_setup(w: u16, h: u16, control_start: bool, container_start: bool) -> TuiTestSetup {
let backend = TestBackend::new(w, h);
let terminal = Terminal::new(backend).unwrap();
let (ids, containers) = gen_containers();
let mut app_data = gen_appdata(&containers);
if control_start {
app_data.docker_controls_start();
}
if container_start {
app_data.containers_start();
}
let gui_state = GuiState::default();
let app_data = Arc::new(Mutex::new(app_data));
let gui_state = Arc::new(Mutex::new(gui_state));
let fd = FrameData::from((&app_data, &gui_state));
let area = Rect::new(0, 0, w, h);
TuiTestSetup {
app_data,
gui_state,
fd,
area,
terminal,
ids,
}
}
/// Get a single row of String's from the expected data
pub fn expected_to_vec(expected: &[&str], row_index: usize) -> Vec<String> {
expected[row_index]
.chars()
.map(|i| i.to_string())
.collect::<Vec<_>>()
}
pub fn get_result(
setup: &TuiTestSetup,
w: u16,
) -> std::iter::Enumerate<std::slice::Chunks<ratatui::buffer::Cell>> {
setup
.terminal
.backend()
.buffer()
.content
.chunks(usize::from(w))
.enumerate()
}
/// Insert some logs into the first container
pub fn insert_logs(setup: &TuiTestSetup) {
let logs = (1..=3).map(|i| format!("{i} line {i}")).collect::<Vec<_>>();
setup.app_data.lock().update_log_by_id(logs, &setup.ids[0]);
}
#[allow(clippy::cast_precision_loss)]
// Add fixed data to the cpu & mem vecdeques
pub fn insert_chart_data(setup: &TuiTestSetup) {
for i in 1..=10 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
for i in 1..=3 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
}
// *************** //
// The whole layout //
// **************** //
#[test]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout() {
let (w, h) = (160, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│",
"│ │ ••• • ││ │ ••• • ││ 8001 │",
"│ │•• ••• ││ │•• ••• ││127.0.0.1 8003 8003│",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
#[allow(clippy::too_many_lines)]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout_with_filter() {
let (w, h) = (160, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[1]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│",
"│ │ ••• • ││ │ ••• • ││ 8001 │",
"│ │•• ••• ││ │•• ••• ││ │",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup.app_data.lock().filter_term_push('r');
setup.app_data.lock().filter_term_push('_');
setup.app_data.lock().filter_term_push('1');
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/1 - filtered ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮",
"│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │",
"│ ││ restart │",
"│ ││ stop │",
"│ ││ delete │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯",
"╭ Logs 3/3 - container_1 - image_1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ ••• ││100.00 kB│ •• ││ ip private public│",
"│ │ •• • ││ │ •• • ││ 8001 │",
"│ │ ••• • • ││ │ ••• • • ││ │",
"│ │• •• ││ │• •• ││ │",
"│ │ ││ │ ││ │",
"╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
" Esc clear ← by → Name Image Status All term: r_1 ",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
#[test]
/// Check that the whole layout is drawn correctly when have long container name and long image name
fn test_draw_blocks_whole_layout_long_name() {
let (w, h) = (190, 30);
let mut setup = test_setup(w, h, true, true);
insert_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
let expected = [
" name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ",
"╭ Containers 1/3 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭─────────────────╮",
"│⚪ a_long_container_name_for_the… ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 a_long_image_name_for_the_pur… 0.00 kB 0.00 kB ││▶ pause │",
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │",
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │",
"│ ││ delete │",
"│ ││ │",
"│ ││ │",
"╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰─────────────────╯",
"╭ Logs 3/3 - a_long_container_name_for_the_purposes_of_this_test - a_long_image_name_for_the_purposes_of_this_test ──────────────────────────────────────────────────────────────────────────╮",
"│ line 1 │",
"│ line 2 │",
"│▶ line 3 │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯",
"╭───────────────────────────────── cpu 03.00% ─────────────────────────────────╮╭────────────────────────────── memory 30.00 kB ───────────────────────────────╮╭────────── ports ───────────╮",
"│10.00%│ •••• ││100.00 kB│ ••••• ││ ip private public│",
"│ │ •••• • ││ │ ••• • ││ 8001 │",
"│ │••• •••• ││ │••• ••• ││127.0.0.1 8003 8003│",
"│ │ ││ │ ││ │",
"╰──────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────╯",
];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use ratatui::layout::{Direction, Layout, Rect};
use crate::ui::gui_state::BoxLocation;
/// draw a box in the one of the BoxLocations, based on max line width + number of lines
pub fn draw(text_lines: usize, text_width: usize, r: Rect, box_location: BoxLocation) -> Rect {
// Make sure blank_space can't be an negative, as will crash
let calc = |x: u16, y: usize| usize::from(x).saturating_sub(y).saturating_div(2);
let blank_vertical = calc(r.height, text_lines);
let blank_horizontal = calc(r.width, text_width);
let (h_constraints, v_constraints) = box_location.get_constraints(
blank_horizontal.try_into().unwrap_or_default(),
blank_vertical.try_into().unwrap_or_default(),
text_lines.try_into().unwrap_or_default(),
text_width.try_into().unwrap_or_default(),
);
let indexes = box_location.get_indexes();
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(v_constraints)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(h_constraints)
.split(popup_layout[indexes.0])[indexes.1]
}
+320
View File
@@ -0,0 +1,320 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::{app_data::State, config::AppColors, ui::FrameData};
/// Get the port title color, at the moment the color is only customizable if the container is alive
const fn get_port_title_color(colors: AppColors, state: State) -> Color {
if state.is_alive() {
colors.chart_ports.title
} else {
state.get_color(colors)
}
}
/// Display the ports in a formatted list
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some(ports) = fd.ports.as_ref() {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::new().fg(colors.chart_ports.border))
// .bg(colors.chart_ports.border))
.title_alignment(Alignment::Center)
.title(Span::styled(
" ports ",
Style::default()
.fg(get_port_title_color(colors, ports.1))
.add_modifier(Modifier::BOLD),
));
let (ip, private, public) = fd.port_max_lens;
if ports.0.is_empty() {
let text = match ports.1 {
State::Running(_) | State::Paused | State::Restarting => "no ports",
_ => "",
};
let paragraph = Paragraph::new(Span::from(text).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(block);
f.render_widget(paragraph, area);
} else {
let mut output = vec![Line::from(
Span::from(format!(
"{:>ip$}{:>private$}{:>public$}",
"ip", "private", "public"
))
.fg(colors.chart_ports.headings),
)];
for item in &ports.0 {
let strings = item.get_all();
let line = vec![
Span::from(format!("{:>ip$}", strings.0)).fg(colors.chart_ports.text),
Span::from(format!("{:>private$}", strings.1)).fg(colors.chart_ports.text),
Span::from(format!("{:>public$}", strings.2)).fg(colors.chart_ports.text),
];
output.push(Line::from(line));
}
let paragraph = Paragraph::new(output).block(block);
f.render_widget(paragraph, area);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use ratatui::style::{Color, Modifier};
use crate::{
app_data::{ContainerPorts, State},
ui::{
draw_blocks::tests::{expected_to_vec, get_result, test_setup},
FrameData,
},
};
#[test]
/// Port section when container has no ports
fn test_draw_blocks_ports_no_ports() {
let (w, h) = (30, 8);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0].ports = vec![];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭────────── ports ───────────╮",
"│ no ports │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
match (row_index, result_cell_index) {
(0, 11..=17) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 11..=18) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
// When state is "State::Running | State::Paused | State::Restarting, won't show "no ports"
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭────────── ports ───────────╮",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 11..=17) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Port section when container has multiple ports
fn test_draw_blocks_ports_multiple_ports() {
let (w, h) = (32, 8);
let mut setup = test_setup(w, h, true, true);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: None,
private: 8002,
public: None,
});
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
private: 8003,
public: Some(8003),
});
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭─────────── ports ────────────╮",
"│ ip private public │",
"│ 8001 │",
"│ 8002 │",
"│127.0.0.1 8003 8003 │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
(0, 12..=18) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=28) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert!(result_cell.modifier.is_empty());
}
(2..=4, 1..=28) | (0 | 2..=9, 0..=31) | (1, 0 | 29..=31) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Port section title color correct dependant on state
fn test_draw_blocks_ports_container_state() {
let (w, h) = (32, 8);
let mut setup = test_setup(w, h, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
let expected = [
"╭─────────── ports ────────────╮",
"│ ip private public │",
"│ 8001 │",
"│ │",
"│ │",
"│ │",
"│ │",
"╰──────────────────────────────╯",
];
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Exited;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup, w) {
let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
}
}