use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{App, Row};
use crate::output::{exit_cell, human_bytes, human_duration};
pub const MIN_WIDTH: u16 = 31;
pub const MIN_HEIGHT: u16 = 6;
pub const NAME_MIN: u16 = 8;
pub const GUTTER: u16 = 2;
#[must_use]
pub const fn mark(selected: bool) -> &'static str {
if selected { ">" } else { " " }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Column {
Id,
Name,
Status,
Pid,
Restarts,
Exit,
Cpu,
Mem,
Uptime,
Fold,
}
impl Column {
#[must_use]
pub const fn header(self) -> &'static str {
match self {
Self::Id => "ID",
Self::Name => "NAME",
Self::Status => "STATUS",
Self::Pid => "PID",
Self::Restarts => "RESTARTS",
Self::Exit => "EXIT",
Self::Cpu => "CPU",
Self::Mem => "MEM",
Self::Uptime => "UPTIME",
Self::Fold => "FOLD",
}
}
#[must_use]
pub const fn width(self) -> u16 {
match self {
Self::Id => 4,
Self::Name => 0,
Self::Status => 15,
Self::Pid => 7,
Self::Restarts => 8,
Self::Exit => 9,
Self::Cpu => 6,
Self::Mem => 8,
Self::Uptime => 8,
Self::Fold => 10,
}
}
}
const ALL: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Exit,
Column::Cpu,
Column::Mem,
Column::Uptime,
Column::Fold,
];
const NO_FOLD: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Exit,
Column::Cpu,
Column::Mem,
Column::Uptime,
];
const NO_EXIT: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Cpu,
Column::Mem,
Column::Uptime,
];
const NO_RESTARTS: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Cpu,
Column::Mem,
Column::Uptime,
];
const NO_PID: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Cpu,
Column::Mem,
Column::Uptime,
];
const NO_MEM: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Cpu,
Column::Uptime,
];
const NO_CPU: &[Column] = &[Column::Id, Column::Name, Column::Status, Column::Uptime];
const FLOOR: &[Column] = &[Column::Id, Column::Name, Column::Status];
const TIERS: &[(u16, &[Column])] = &[
(101, ALL),
(89, NO_FOLD),
(78, NO_EXIT),
(68, NO_RESTARTS),
(59, NO_PID),
(49, NO_MEM),
(41, NO_CPU),
(MIN_WIDTH, FLOOR),
];
#[must_use]
pub fn columns_for(width: u16) -> &'static [Column] {
TIERS
.iter()
.find(|(threshold, _)| width >= *threshold)
.map_or(FLOOR, |(_, columns)| *columns)
}
#[must_use]
pub fn name_width(width: u16, columns: &[Column]) -> u16 {
let fixed: u16 = columns.iter().map(|column| column.width()).sum();
let gaps = u16::try_from(columns.len().saturating_sub(1)).unwrap_or(0) * 2;
width
.saturating_sub(fixed)
.saturating_sub(gaps)
.max(NAME_MIN)
}
#[must_use]
pub fn fit(text: &str, width: u16) -> String {
let width = usize::from(width);
let count = text.chars().count();
if count <= width {
let mut out = String::from(text);
out.extend(core::iter::repeat_n(' ', width - count));
return out;
}
if width == 0 {
return String::new();
}
let mut out: String = text.chars().take(width - 1).collect();
out.push('…');
out
}
#[must_use]
pub fn header_line(columns: &[Column], width: u16, style: Style) -> Line<'static> {
let name = name_width(width, columns);
let mut text = String::new();
for (index, column) in columns.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let cell_width = if *column == Column::Name {
name
} else {
column.width()
};
text.push_str(&fit(column.header(), cell_width));
}
Line::from(Span::styled(text, style))
}
#[must_use]
pub fn row_line(app: &App, row: &Row, columns: &[Column], width: u16) -> Line<'static> {
let palette = app.palette();
let name = name_width(width, columns);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2);
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let cell_width = if *column == Column::Name {
name
} else {
column.width()
};
let text = fit(&cell(app, row, *column), cell_width);
let style = if *column == Column::Status {
palette.status(row.info.status)
} else {
Style::default()
};
spans.push(Span::styled(text, style));
}
Line::from(spans)
}
fn cell(app: &App, row: &Row, column: Column) -> String {
let info = &row.info;
match column {
Column::Id => info.id.to_string(),
Column::Name => info.name.clone(),
Column::Status => info.status.to_string(),
Column::Pid => info
.pid
.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
Column::Restarts => info.restarts.to_string(),
Column::Exit => exit_cell(info.pid, info.last_exit),
Column::Cpu => info
.cpu_percent
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
Column::Mem => info
.memory_bytes
.map_or_else(|| "-".to_string(), human_bytes),
Column::Uptime => app
.uptime_ms(info.id)
.map_or_else(|| "-".to_string(), human_duration),
Column::Fold => info.fold.clone().unwrap_or_else(|| "-".to_string()),
}
}
#[must_use]
pub fn scroll_offset(selected: usize, viewport: usize, total: usize) -> usize {
if viewport == 0 || total <= viewport {
return 0;
}
let last = total - viewport;
selected.saturating_sub(viewport / 2).min(last)
}
#[cfg(test)]
mod tests {
use super::super::fixtures;
use super::*;
#[test]
fn columns_drop_in_a_fixed_order_as_the_terminal_narrows() {
assert_eq!(columns_for(300).len(), 10);
assert_eq!(columns_for(101).len(), 10);
assert!(!columns_for(100).contains(&Column::Fold));
assert!(columns_for(100).contains(&Column::Exit));
assert!(!columns_for(88).contains(&Column::Exit));
assert!(columns_for(88).contains(&Column::Restarts));
assert!(!columns_for(77).contains(&Column::Restarts));
assert!(!columns_for(67).contains(&Column::Pid));
assert!(!columns_for(58).contains(&Column::Mem));
assert!(!columns_for(48).contains(&Column::Cpu));
assert_eq!(columns_for(31), &[Column::Id, Column::Name, Column::Status]);
for width in [31u16, 40, 48, 58, 67, 77, 88, 100, 300] {
let cols = columns_for(width);
for required in [Column::Id, Column::Name, Column::Status] {
assert!(
cols.contains(&required),
"width {width} dropped {required:?}"
);
}
}
}
#[test]
fn the_full_column_set_matches_flock_rows_headers_exactly() {
use crate::output::Render;
let headers: Vec<&str> = ALL.iter().map(|column| column.header()).collect();
assert_eq!(headers, crate::output::FlockRows::headers());
}
#[test]
fn the_exit_cell_reuses_the_same_rendering_flock_rows_uses() {
use shep_core::protocol::{ExitInfo, ProcessInfo};
use shep_core::status::ProcStatus;
let crashed = ProcessInfo::builder(1, "crashed", ProcStatus::Errored)
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let killed = ProcessInfo::builder(2, "killed", ProcStatus::Stopped)
.last_exit(Some(ExitInfo {
code: None,
signal: Some(9),
}))
.build();
let running = ProcessInfo::builder(3, "running", ProcStatus::Online)
.pid(Some(4_242))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let app = fixtures::app_with(vec![crashed, killed, running], fixtures::plain());
let rows = app.rows();
let cell_for = |id: u32| {
let row = rows.iter().find(|row| row.info.id == id).unwrap();
cell(&app, row, Column::Exit)
};
assert_eq!(cell_for(1), "1");
assert_eq!(cell_for(2), "SIGKILL");
assert_eq!(
cell_for(3),
"-",
"a running sheep has nothing for EXIT to say"
);
}
#[test]
fn every_tier_fits_the_width_it_claims() {
for width in MIN_WIDTH..=200 {
let cols = columns_for(width);
let fixed: u16 = cols.iter().map(|c| c.width()).sum();
let gaps = u16::try_from(cols.len() - 1).unwrap() * 2;
assert!(
fixed + gaps + NAME_MIN <= width,
"width {width} chose {} columns needing {}",
cols.len(),
fixed + gaps + NAME_MIN
);
}
}
#[test]
fn a_name_too_long_for_its_column_ends_in_an_ellipsis() {
let cut = fit("payments-reconciliation-worker", 12);
assert_eq!(cut.chars().count(), 12);
assert!(cut.ends_with('…'));
assert!(cut.starts_with("payments"));
assert_eq!(fit("web", 12), "web ");
}
#[test]
fn fit_counts_characters_not_bytes_when_it_pads_and_when_it_truncates() {
assert_eq!(fit("日本語", 6), "日本語 ");
assert_eq!(fit("ünïcödé", 7), "ünïcödé");
assert_eq!(fit("日本語アプリ", 5), "日本語ア…");
}
#[test]
fn the_marker_is_one_ascii_column_wide_in_both_states() {
assert_eq!(
mark(true),
">",
"not `▸`: East-Asian Ambiguous width would shift the row"
);
assert_eq!(mark(false), " ");
}
#[test]
fn the_offset_keeps_the_selection_visible_and_centred_where_it_can() {
assert_eq!(scroll_offset(0, 10, 6), 0);
assert_eq!(scroll_offset(5, 10, 6), 0);
assert_eq!(scroll_offset(0, 5, 20), 0);
assert_eq!(scroll_offset(2, 5, 20), 0);
assert_eq!(scroll_offset(3, 5, 20), 1);
assert_eq!(scroll_offset(10, 5, 20), 8);
assert_eq!(scroll_offset(19, 5, 20), 15, "the last page, not past it");
assert_eq!(scroll_offset(usize::MAX, 5, 20), 15);
assert_eq!(scroll_offset(3, 0, 20), 0);
for total in [1usize, 2, 7, 40, 200] {
for viewport in [1usize, 3, 8, 25] {
for selected in 0..total {
let offset = scroll_offset(selected, viewport, total);
assert!(
selected >= offset && selected < offset + viewport,
"selected {selected} fell outside [{offset}, {}) for total {total}",
offset + viewport
);
}
}
}
}
}