use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{App, GroupTotals, Row, RowKey};
use crate::output::width::char_columns;
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,
Smit,
}
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",
Self::Smit => "SMIT",
}
}
#[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,
Self::Smit => 13,
}
}
}
const ALL: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Exit,
Column::Cpu,
Column::Mem,
Column::Uptime,
Column::Fold,
Column::Smit,
];
const NO_SMIT: &[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])] = &[
(116, ALL),
(101, NO_SMIT),
(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 columns: usize = text.chars().map(char_columns).sum();
if columns <= width {
let mut out = String::from(text);
out.extend(core::iter::repeat_n(' ', width - columns));
return out;
}
if width == 0 {
return String::new();
}
let budget = width - 1;
let mut out = String::new();
let mut used = 0;
for c in text.chars() {
let c_width = char_columns(c);
if used + c_width > budget {
break;
}
out.push(c);
used += c_width;
}
out.push('…');
out.extend(core::iter::repeat_n(' ', budget - used));
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 key_line(app: &App, key: &RowKey, columns: &[Column], width: u16) -> Line<'static> {
match key {
RowKey::Sheep(id) => app.row(*id).map_or_else(
|| Line::from(Span::raw(" ".repeat(usize::from(width)))),
|row| row_line(app, row, columns, width, app.is_grouped(&row.info.name)),
),
RowKey::Group(name) => group_line(app, name, columns, width),
}
}
fn group_line(app: &App, name: &str, columns: &[Column], width: u16) -> Line<'static> {
let palette = app.palette();
let totals = app.group_totals(name);
let name_width = self::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_width
} else {
column.width()
};
let text = fit(&group_cell(app, name, *column, &totals), cell_width);
let style = if *column == Column::Status {
app.group_uniform_status(name)
.map_or(Style::default(), |status| palette.status(status))
} else {
Style::default()
};
spans.push(Span::styled(text, style));
}
Line::from(spans)
}
fn group_cell(app: &App, name: &str, column: Column, totals: &GroupTotals) -> String {
match column {
Column::Id | Column::Pid | Column::Exit => String::new(),
Column::Name => format!("{name} \u{d7}{}", totals.count),
Column::Status => app.group_status_text(name),
Column::Restarts => totals.restarts.to_string(),
Column::Cpu => totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
Column::Mem => totals.memory.map_or_else(|| "-".to_string(), human_bytes),
Column::Uptime => totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
Column::Fold => app
.group_members(name)
.first()
.and_then(|row| row.info.fold.clone())
.unwrap_or_else(|| "-".to_string()),
Column::Smit => app
.group_members(name)
.first()
.and_then(|row| row.info.smit.clone())
.unwrap_or_else(|| "-".to_string()),
}
}
#[must_use]
pub fn row_line(
app: &App,
row: &Row,
columns: &[Column],
width: u16,
grouped: bool,
) -> 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, grouped), cell_width);
let style = if *column == Column::Status {
palette.reported(row.reported())
} else {
Style::default()
};
spans.push(Span::styled(text, style));
}
Line::from(spans)
}
fn cell(app: &App, row: &Row, column: Column, grouped: bool) -> String {
let info = &row.info;
match column {
Column::Id => info.id.to_string(),
Column::Name if grouped => info
.instance
.map_or_else(String::new, |slot| format!(" \u{21b3} :{slot}")),
Column::Name => info.name.clone(),
Column::Status => row.reported().word(),
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 | Column::Smit if grouped => String::new(),
Column::Fold => info.fold.clone().unwrap_or_else(|| "-".to_string()),
Column::Smit => info.smit.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(), 11);
assert_eq!(columns_for(116).len(), 11);
assert!(!columns_for(115).contains(&Column::Smit));
assert!(columns_for(115).contains(&Column::Fold));
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());
}
#[cfg(unix)]
#[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, false)
};
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_columns_not_bytes_when_it_pads_and_when_it_truncates() {
assert_eq!(fit("日本語", 6), "日本語");
assert_eq!(fit("ünïcödé", 7), "ünïcödé");
}
#[test]
fn a_double_width_name_is_cut_to_the_columns_it_draws_in() {
assert_eq!(fit("日本語アプリ", 5), "日本…");
assert_eq!(columns_of(&fit("日本語アプリ", 5)), 5);
assert_eq!(fit("日本語", 3), "日…");
assert_eq!(fit("日本語", 4), "日… ");
assert_eq!(columns_of(&fit("日本語", 4)), 4);
assert_eq!(fit("日本語", 1), "…");
assert_eq!(fit("日本語", 2), "… ");
}
#[test]
fn every_cell_measures_exactly_the_width_it_was_given() {
let names = [
"web",
"payments-reconciliation-worker",
"日本語アプリ",
"café",
"cafe\u{301}",
"羊",
"",
];
for name in names {
for width in 0..=12u16 {
let cell = fit(name, width);
assert_eq!(
columns_of(&cell),
usize::from(width),
"fit({name:?}, {width}) == {cell:?}"
);
}
}
}
#[test]
fn an_escape_sequence_is_measured_as_the_text_it_will_be_drawn_as() {
let styled = "\u{1b}[32mup";
assert_eq!(columns_of(styled), 6);
assert_eq!(columns_of(&fit(styled, 4)), 4);
assert!(fit(styled, 4).ends_with('…'));
}
fn columns_of(s: &str) -> usize {
s.chars().map(char_columns).sum()
}
#[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
);
}
}
}
}
#[test]
fn a_group_rows_cells_show_the_apps_rollup() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.memory_bytes(Some(100 << 20))
.uptime_ms(120_000)
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Online)
.instance(Some(1))
.memory_bytes(Some(150 << 20))
.uptime_ms(30_000)
.build(),
ProcessInfo::builder(3, "web", ProcStatus::Online)
.instance(Some(2))
.memory_bytes(Some(50 << 20))
.uptime_ms(600_000)
.build(),
],
fixtures::plain(),
);
let line = key_line(&app, &RowKey::Group("web".to_string()), ALL, 200);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let name = name_width(200, ALL);
let expected = [
fit("", Column::Id.width()), fit("web \u{d7}3", name), fit("online", Column::Status.width()), fit("", Column::Pid.width()), fit("0", Column::Restarts.width()), fit("", Column::Exit.width()), fit("-", Column::Cpu.width()), fit("300.0M", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("-", Column::Fold.width()),
fit("-", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered, expected, "got {rendered:?}");
}
#[test]
fn a_slot_row_under_a_group_header_renders_as_a_slot() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let member = |id: u32, slot: u32| {
ProcessInfo::builder(id, "web", ProcStatus::Online)
.instance(Some(slot))
.pid(Some(4_000 + id))
.fold(Some("edge".to_string()))
.smit(Some("web".to_string()))
.uptime_ms(30_000)
.build()
};
let app = fixtures::app_with(vec![member(1, 0), member(2, 1)], fixtures::plain());
let line = key_line(&app, &RowKey::Sheep(2), ALL, 200);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let name = name_width(200, ALL);
let expected = [
fit("2", Column::Id.width()),
fit(" \u{21b3} :1", name),
fit("online", Column::Status.width()),
fit("4002", Column::Pid.width()),
fit("0", Column::Restarts.width()),
fit("-", Column::Exit.width()),
fit("-", Column::Cpu.width()),
fit("-", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("", Column::Fold.width()),
fit("", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered, expected, "got {rendered:?}");
}
#[test]
fn an_ungrouped_sheep_still_shows_its_own_name_and_fold() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let app = fixtures::app_with(
vec![
ProcessInfo::builder(7, "solo", ProcStatus::Online)
.instance(Some(0))
.fold(Some("edge".to_string()))
.build(),
],
fixtures::plain(),
);
let line = key_line(&app, &RowKey::Sheep(7), ALL, 200);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("solo"), "got {rendered:?}");
assert!(rendered.contains("edge"), "got {rendered:?}");
assert!(!rendered.contains('\u{21b3}'), "got {rendered:?}");
}
#[test]
fn a_silent_dog_reads_silent_not_online() {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
let dog = ProcessInfo::builder(9, "log-rotate", ProcStatus::Online)
.pid(Some(4_242))
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.handshook(Some(false))
.build();
let app = fixtures::app_with(vec![dog], fixtures::plain());
let row = app.row(9).unwrap();
let line = row_line(&app, row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("silent"),
"expected silent, got {rendered:?}"
);
assert!(
!rendered.contains("online"),
"must not say online: {rendered:?}"
);
}
#[test]
fn a_dog_that_has_handshook_still_reads_online() {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
let dog = ProcessInfo::builder(9, "log-rotate", ProcStatus::Online)
.pid(Some(4_242))
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.handshook(Some(true))
.build();
let app = fixtures::app_with(vec![dog], fixtures::plain());
let row = app.row(9).unwrap();
let line = row_line(&app, row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("online"), "got {rendered:?}");
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
#[test]
fn a_sheep_still_reads_online_and_has_no_handshake() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let sheep = ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(4_000))
.build();
assert_eq!(sheep.handshook, None, "a sheep is never sent one");
let app = fixtures::app_with(vec![sheep], fixtures::plain());
let row = app.row(1).unwrap();
let line = row_line(&app, row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("online"), "got {rendered:?}");
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
#[test]
fn a_sheep_never_reads_as_silent() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut impossible = ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(4_000))
.build();
impossible.handshook = Some(false);
let app = fixtures::app_with(vec![impossible], fixtures::plain());
let row = app.row(1).unwrap();
let line = row_line(&app, row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("online"),
"the sheep table has no dogs in it, and no silence rule either: {rendered:?}"
);
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
}