use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use super::super::app::{App, GroupTotals, Row, RowKey};
use super::super::theme::Palette;
use super::cell;
use crate::output::width::char_columns;
use crate::output::{cfg_cell, 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 NAME_MAX: u16 = 32;
pub const GUTTER: u16 = 2;
#[must_use]
pub const fn mark(selected: bool) -> &'static str {
if selected { ">" } else { " " }
}
#[must_use]
pub fn gutter(selected: bool, palette: Palette) -> (&'static str, Style) {
match (selected, palette.ground()) {
(false, _) => (" ", Style::default()),
(true, ground) if ground.bg.is_some() => (" ", ground),
(true, _) => (mark(true), Style::default()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Column {
Id,
Name,
Status,
Pid,
Restarts,
Exit,
Cfg,
CpuSpark,
Cpu,
MemCeil,
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::Cfg => "CFG",
Self::CpuSpark => "CPU 20s",
Self::Cpu => "CPU",
Self::MemCeil => "MEM/CEIL",
Self::Mem => "MEM",
Self::Uptime => "UPTIME",
Self::Fold => "FOLD",
Self::Smit => "SMIT",
}
}
#[must_use]
pub const fn frozen_header(self) -> &'static str {
match self {
Self::Uptime => "FROZEN",
_ => self.header(),
}
}
#[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::Cfg => 4,
Self::CpuSpark => 10,
Self::Cpu => 6,
Self::MemCeil => 10,
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::Cfg,
Column::CpuSpark,
Column::Cpu,
Column::MemCeil,
Column::Mem,
Column::Uptime,
Column::Fold,
Column::Smit,
];
const NO_CEIL: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Exit,
Column::Cfg,
Column::CpuSpark,
Column::Cpu,
Column::Mem,
Column::Uptime,
Column::Fold,
Column::Smit,
];
const NO_SPARK: &[Column] = &[
Column::Id,
Column::Name,
Column::Status,
Column::Pid,
Column::Restarts,
Column::Exit,
Column::Cfg,
Column::Cpu,
Column::Mem,
Column::Uptime,
Column::Fold,
Column::Smit,
];
const NO_CFG: &[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];
#[cfg(test)]
pub(super) fn cfg_tier_width() -> u16 {
TIERS
.iter()
.filter(|(_, columns)| columns.contains(&Column::Cfg))
.map(|(threshold, _)| *threshold)
.min()
.expect("a tier draws CFG")
}
const TIERS: &[(u16, &[Column])] = &[
(146, ALL),
(134, NO_CEIL),
(122, NO_SPARK),
(116, NO_CFG),
(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();
name_width_from(width, fixed, columns.len())
}
fn name_width_from(width: u16, fixed: u16, columns_len: usize) -> u16 {
let gaps = u16::try_from(columns_len.saturating_sub(1)).unwrap_or(0) * 2;
width
.saturating_sub(fixed)
.saturating_sub(gaps)
.clamp(NAME_MIN, NAME_MAX)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FoldColumn {
Name,
Status,
Share,
Mem,
Cpu,
Uptime,
Restarts,
Notes,
}
impl FoldColumn {
#[must_use]
pub const fn width(self) -> u16 {
match self {
Self::Name => 0,
Self::Status => 12,
Self::Share => 22,
Self::Mem => 10,
Self::Cpu => 9,
Self::Uptime => 10,
Self::Restarts => 8,
Self::Notes => 63,
}
}
#[must_use]
pub const fn header(self) -> &'static str {
match self {
Self::Name => "NAME",
Self::Status => "STATUS",
Self::Share => "SHARE",
Self::Mem => "MEM",
Self::Cpu => "CPU",
Self::Uptime => "UPTIME",
Self::Restarts => "RESTARTS",
Self::Notes => "NOTES",
}
}
}
const FOLD_ALL: &[FoldColumn] = &[
FoldColumn::Name,
FoldColumn::Status,
FoldColumn::Share,
FoldColumn::Mem,
FoldColumn::Cpu,
FoldColumn::Uptime,
FoldColumn::Restarts,
FoldColumn::Notes,
];
const FOLD_NO_SHARE: &[FoldColumn] = &[
FoldColumn::Name,
FoldColumn::Status,
FoldColumn::Mem,
FoldColumn::Cpu,
FoldColumn::Uptime,
FoldColumn::Restarts,
FoldColumn::Notes,
];
const FOLD_NO_NOTES: &[FoldColumn] = &[
FoldColumn::Name,
FoldColumn::Status,
FoldColumn::Mem,
FoldColumn::Cpu,
FoldColumn::Uptime,
FoldColumn::Restarts,
];
const FOLD_NO_RESTARTS: &[FoldColumn] = &[
FoldColumn::Name,
FoldColumn::Status,
FoldColumn::Mem,
FoldColumn::Cpu,
FoldColumn::Uptime,
];
const FOLD_NO_CPU: &[FoldColumn] = &[
FoldColumn::Name,
FoldColumn::Status,
FoldColumn::Mem,
FoldColumn::Uptime,
];
const FOLD_NO_UPTIME: &[FoldColumn] = &[FoldColumn::Name, FoldColumn::Status, FoldColumn::Mem];
const FOLD_FLOOR: &[FoldColumn] = &[FoldColumn::Name, FoldColumn::Status];
const FOLD_TIERS: &[(u16, &[FoldColumn])] = &[
(158, FOLD_ALL),
(134, FOLD_NO_SHARE),
(69, FOLD_NO_NOTES),
(59, FOLD_NO_RESTARTS),
(48, FOLD_NO_CPU),
(36, FOLD_NO_UPTIME),
(MIN_WIDTH, FOLD_FLOOR),
];
#[must_use]
pub fn fold_columns_for(width: u16) -> &'static [FoldColumn] {
FOLD_TIERS
.iter()
.find(|(threshold, _)| width >= *threshold)
.map_or(FOLD_FLOOR, |(_, columns)| *columns)
}
fn fold_name_width(width: u16, columns: &[FoldColumn]) -> u16 {
let fixed: u16 = columns.iter().map(|column| column.width()).sum();
name_width_from(width, fixed, columns.len()).max(FOLD_NAME_MIN)
}
const FOLD_NAME_MIN: u16 = NAME_MIN + 2;
#[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, frozen: bool) -> 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()
};
let header = if frozen {
column.frozen_header()
} else {
column.header()
};
text.push_str(&fit(header, cell_width));
}
Line::from(Span::styled(text, style))
}
#[must_use]
pub fn fold_columns_header_line(columns: &[FoldColumn], width: u16, style: Style) -> Line<'static> {
let name = fold_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 == FoldColumn::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,
selected: bool,
) -> 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),
selected,
)
},
),
RowKey::Group(name) => group_line(app, name, columns, width, selected),
RowKey::Section(label) => section_line(label, width, app.data_palette().muted()),
RowKey::Fold(_) => unreachable!("flat view emits no fold header"),
}
}
fn section_line(label: &str, width: u16, style: Style) -> Line<'static> {
let used = label.chars().count() + 1;
let rule = "─".repeat(usize::from(width).saturating_sub(used));
Line::from(Span::styled(format!("{label} {rule}"), style))
}
fn group_line(
app: &App,
name: &str,
columns: &[Column],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let totals = app.group_totals(name);
let name_width = self::name_width(width, columns);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == Column::Name {
name_width
} else {
column.width()
};
let text = fit(&group_cell(app, name, *column, &totals), cell_width);
let status = app.group_uniform_status(name);
let status_style = status.map_or(Style::default(), |status| palette.status(status));
let style = cell_style(palette, *column, status_style, status, None);
let tail_style = mem_ceil_tail_style(palette, status, None, style);
push_row_cell(
&mut spans,
*column,
text,
style.patch(ground),
tail_style.patch(ground),
0,
);
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn group_cell(app: &App, name: &str, column: Column, totals: &GroupTotals) -> String {
match column {
Column::Id
| Column::Pid
| Column::Exit
| Column::Cfg
| Column::CpuSpark
| Column::MemCeil => 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 fold_key_line(
app: &App,
key: &RowKey,
columns: &[FoldColumn],
width: u16,
selected: bool,
) -> Line<'static> {
match key {
RowKey::Fold(name) => fold_header_line(app, name, columns, width, selected),
RowKey::Group(name) => fold_group_line(app, name, columns, width, selected),
RowKey::Sheep(id) => app.row(*id).map_or_else(
|| Line::from(Span::raw(" ".repeat(usize::from(width)))),
|row| fold_member_line(app, row, columns, width, selected),
),
RowKey::Section(label) => section_line(label, width, app.data_palette().muted()),
}
}
fn fold_header_line(
app: &App,
name: &str,
columns: &[FoldColumn],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let totals = app.fold_totals(name);
let total_memory = total_flock_memory(app);
let share_percent = fold_share_percent(totals.memory, total_memory);
let share_fill = cell::gauge_fill(totals.memory.unwrap_or(0), total_memory, 20);
let status = app.fold_uniform_status(name);
let status_style = status.map_or(Style::default(), |status| palette.status(status));
let name_width = fold_name_width(width, columns);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == FoldColumn::Name {
name_width
} else {
column.width()
};
let text = fit(
&fold_header_cell(app, name, *column, &totals, total_memory, share_percent),
cell_width,
);
match column {
FoldColumn::Share => {
push_fold_share_cell(&mut spans, palette, text, share_fill, ground)
}
FoldColumn::Status => spans.push(Span::styled(text, status_style.patch(ground))),
_ => spans.push(Span::styled(text, Style::default().patch(ground))),
}
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn fold_header_cell(
app: &App,
name: &str,
column: FoldColumn,
totals: &GroupTotals,
total_memory: Option<u64>,
share_percent: Option<u32>,
) -> String {
match column {
FoldColumn::Name => {
let marker = if app.is_fold_collapsed(name) {
'\u{25b8}'
} else {
'\u{25be}'
};
format!("{marker} {name} \u{d7}{}", totals.count)
}
FoldColumn::Status => app.fold_status_text(name),
FoldColumn::Share => cell::gauge(totals.memory.unwrap_or(0), total_memory, 20),
FoldColumn::Mem => totals.memory.map_or_else(|| "-".to_string(), human_bytes),
FoldColumn::Cpu => totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
FoldColumn::Uptime => totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
FoldColumn::Restarts => totals.restarts.to_string(),
FoldColumn::Notes => {
share_percent.map_or_else(String::new, |percent| format!("{percent}% of flock memory"))
}
}
}
fn push_fold_share_cell(
spans: &mut Vec<Span<'static>>,
palette: Palette,
text: String,
fill: usize,
ground: Style,
) {
let fill = fill.min(text.chars().count());
let mut chars = text.chars();
let filled: String = chars.by_ref().take(fill).collect();
let rest: String = chars.collect();
spans.push(Span::styled(filled, palette.sky().patch(ground)));
spans.push(Span::styled(rest, palette.gauge_rest().patch(ground)));
}
fn total_flock_memory(app: &App) -> Option<u64> {
app.all_rows()
.iter()
.filter_map(|row| row.info.memory_bytes)
.fold(None, |sum, value| Some(sum.unwrap_or(0) + value))
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] fn fold_share_percent(memory: Option<u64>, total_memory: Option<u64>) -> Option<u32> {
let memory = memory?;
let total_memory = total_memory?;
if total_memory == 0 {
return None;
}
Some((memory as f64 / total_memory as f64 * 100.0).round() as u32)
}
fn fold_group_line(
app: &App,
name: &str,
columns: &[FoldColumn],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let totals = app.group_totals(name);
let status = app.group_uniform_status(name);
let status_style = status.map_or(Style::default(), |status| palette.status(status));
let name_width = fold_name_width(width, columns);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let base = palette.muted();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == FoldColumn::Name {
name_width
} else {
column.width()
};
let text = fit(&fold_group_cell(app, name, *column, &totals), cell_width);
let style = if *column == FoldColumn::Status {
status_style
} else {
base
};
spans.push(Span::styled(text, style.patch(ground)));
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn fold_group_cell(app: &App, name: &str, column: FoldColumn, totals: &GroupTotals) -> String {
match column {
FoldColumn::Name => format!("{name} \u{d7}{}", totals.count),
FoldColumn::Status => app.group_status_text(name),
FoldColumn::Share | FoldColumn::Notes => String::new(),
FoldColumn::Mem => totals.memory.map_or_else(|| "-".to_string(), human_bytes),
FoldColumn::Cpu => totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
FoldColumn::Uptime => totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
FoldColumn::Restarts => totals.restarts.to_string(),
}
}
fn fold_member_line(
app: &App,
row: &Row,
columns: &[FoldColumn],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let status_style = palette.reported(row.reported());
let name_width = fold_name_width(width, columns);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let base = palette.muted();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == FoldColumn::Name {
name_width
} else {
column.width()
};
let text = fit(&fold_member_cell(app, row, *column), cell_width);
let style = if *column == FoldColumn::Status {
status_style
} else {
base
};
spans.push(Span::styled(text, style.patch(ground)));
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn fold_member_cell(app: &App, row: &Row, column: FoldColumn) -> String {
let info = &row.info;
match column {
FoldColumn::Name => info.name.clone(),
FoldColumn::Status => row.reported().word(),
FoldColumn::Share | FoldColumn::Notes => String::new(),
FoldColumn::Mem => info
.memory_bytes
.map_or_else(|| "-".to_string(), human_bytes),
FoldColumn::Cpu => app
.cpu_now(info.id)
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
FoldColumn::Uptime => app
.uptime_ms(info.id)
.map_or_else(|| "-".to_string(), human_duration),
FoldColumn::Restarts => info.restarts.to_string(),
}
}
#[must_use]
pub fn row_line(
app: &App,
row: &Row,
columns: &[Column],
width: u16,
grouped: bool,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let name = name_width(width, columns);
let status_style = palette.reported(row.reported());
let status = Some(row.info.status);
let mem_ceil_ratio = mem_ceil_ratio(&row.info);
let mem_ceil_fill = self::mem_ceil_fill(&row.info);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == Column::Name {
name
} else {
column.width()
};
let text = fit(&cell(app, row, *column, grouped), cell_width);
let style = cell_style(palette, *column, status_style, status, mem_ceil_ratio);
let tail_style = mem_ceil_tail_style(palette, status, mem_ceil_ratio, style);
push_row_cell(
&mut spans,
*column,
text,
style.patch(ground),
tail_style.patch(ground),
mem_ceil_fill,
);
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
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::Cfg => cfg_cell(info.pending.as_deref(), info.overridden.as_deref()),
Column::CpuSpark => cpu_spark_cell(app, info),
Column::Cpu => app
.cpu_now(info.id)
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
Column::MemCeil => mem_ceil_cell(info),
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()),
}
}
fn cpu_spark_cell(app: &App, info: &ProcessInfo) -> String {
cell::sparkline(app.cpu_history(info.id), 10, app.cpu_ceiling())
}
fn mem_ceil_cell(info: &ProcessInfo) -> String {
cell::gauge(info.memory_bytes.unwrap_or(0), info.max_memory, 10)
}
fn mem_ceil_fill(info: &ProcessInfo) -> usize {
cell::gauge_fill(info.memory_bytes.unwrap_or(0), info.max_memory, 10)
}
fn mem_ceil_ratio(info: &ProcessInfo) -> Option<f64> {
let value = info.memory_bytes?;
let ceiling = info.max_memory?;
if ceiling == 0 {
return None;
}
Some(value as f64 / ceiling as f64)
}
fn mem_ceil_measuring(status: Option<ProcStatus>, ratio: Option<f64>) -> bool {
status == Some(ProcStatus::Online) && ratio.is_some()
}
fn cell_style(
palette: Palette,
column: Column,
status_style: Style,
status: Option<ProcStatus>,
mem_ceil_ratio: Option<f64>,
) -> Style {
match column {
Column::Status => status_style,
Column::CpuSpark => palette.status(ProcStatus::Online),
Column::MemCeil if !mem_ceil_measuring(status, mem_ceil_ratio) => palette.muted(),
Column::MemCeil => {
if mem_ceil_ratio.is_some_and(|ratio| ratio >= 0.9) {
palette.attention()
} else {
palette.sky()
}
}
_ => Style::default(),
}
}
fn mem_ceil_tail_style(
palette: Palette,
status: Option<ProcStatus>,
mem_ceil_ratio: Option<f64>,
fill_style: Style,
) -> Style {
if mem_ceil_measuring(status, mem_ceil_ratio) {
palette.gauge_rest()
} else {
fill_style
}
}
fn push_row_cell(
spans: &mut Vec<Span<'static>>,
column: Column,
text: String,
style: Style,
tail_style: Style,
fill: usize,
) {
if column == Column::MemCeil {
let fill = fill.min(text.chars().count());
let mut chars = text.chars();
let filled: String = chars.by_ref().take(fill).collect();
let rest: String = chars.collect();
spans.push(Span::styled(filled, style));
spans.push(Span::styled(rest, tail_style));
} else {
spans.push(Span::styled(text, style));
}
}
fn pad_ground(spans: &mut Vec<Span<'static>>, used: u16, width: u16, ground: Style) {
let short = width.saturating_sub(used);
if short > 0 {
spans.push(Span::styled(" ".repeat(usize::from(short)), ground));
}
}
#[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::*;
use std::ffi::OsStr;
#[test]
fn the_frozen_header_fits_the_column_it_renames() {
use crate::output::width::visible_width;
assert_eq!(Column::Uptime.frozen_header(), "FROZEN");
assert!(
visible_width(Column::Uptime.frozen_header()) <= usize::from(Column::Uptime.width()),
"FROZEN does not fit UPTIME's own 8 cells"
);
assert!(
visible_width("FROZEN AT") > usize::from(Column::Uptime.width()),
"the design's own header now fits, so this rename has no reason to exist"
);
}
#[test]
fn only_the_uptime_column_is_renamed_by_a_freeze() {
let renamed: Vec<&str> = ALL
.iter()
.filter(|column| column.frozen_header() != column.header())
.map(|column| column.header())
.collect();
assert_eq!(renamed, vec!["UPTIME"]);
}
#[test]
fn the_painted_gutter_is_one_column_and_holds_no_glyph() {
let deep = Palette::detect(None, None, Some(OsStr::new("truecolor")));
let (text, style) = gutter(true, deep);
assert_eq!(
text, " ",
"a space, not a block: a block is Ambiguous width"
);
assert_eq!(
crate::output::width::char_columns(text.chars().next().unwrap()),
1
);
assert!(style.bg.is_some());
}
#[test]
fn without_colour_the_gutter_falls_back_to_the_ascii_marker() {
let off = Palette::detect(Some(OsStr::new("1")), None, None);
let (text, style) = gutter(true, off);
assert_eq!(text, ">");
assert_eq!(style, Style::default());
assert_eq!(gutter(false, off).0, " ");
}
#[test]
fn columns_drop_in_a_fixed_order_as_the_terminal_narrows() {
assert_eq!(columns_for(300).len(), 14);
assert_eq!(columns_for(122).len(), 12);
assert!(!columns_for(121).contains(&Column::Cfg));
assert!(columns_for(121).contains(&Column::Smit));
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 smit_survives_the_gallerys_own_wide_fixtures() {
assert!(columns_for(120).contains(&Column::Smit));
}
#[test]
fn the_two_new_rungs_restore_todays_table_before_shedding_anything_old() {
assert_eq!(columns_for(146), ALL);
assert!(!columns_for(134).contains(&Column::MemCeil));
assert!(columns_for(134).contains(&Column::CpuSpark));
assert!(!columns_for(122).contains(&Column::CpuSpark));
assert_eq!(
columns_for(122),
NO_SPARK,
"122 is today's full set, unchanged"
);
}
#[test]
fn every_shared_header_still_matches_flock_rows_exactly() {
use crate::output::Render;
let shared: Vec<&str> = ALL
.iter()
.map(|column| column.header())
.filter(|header| crate::output::FlockRows::headers().contains(header))
.collect();
assert_eq!(shared, crate::output::FlockRows::headers());
}
#[test]
fn the_only_headers_lookout_adds_are_the_two_shep_flock_cannot_draw() {
use crate::output::Render;
let extra: Vec<&str> = ALL
.iter()
.map(|column| column.header())
.filter(|header| !crate::output::FlockRows::headers().contains(header))
.collect();
assert_eq!(extra, vec!["CPU 20s", "MEM/CEIL"]);
}
#[test]
fn a_running_sheep_with_no_ceiling_draws_an_empty_gauge() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.memory_bytes(Some(48 * 1024 * 1024))
.build();
assert_eq!(mem_ceil_cell(&info), "░░░░░░░░░░");
}
#[test]
fn a_sheep_at_its_ceiling_fills_the_gauge() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let info = ProcessInfo::builder(1, "hungry", ProcStatus::Online)
.memory_bytes(Some(52 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
assert_eq!(mem_ceil_cell(&info), "██████████");
}
#[test]
fn the_gauges_unfilled_tail_carries_gauge_rest_not_the_fill_role() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
assert_ne!(
palette.sky().fg,
palette.gauge_rest().fg,
"the two roles must actually differ for this test to mean anything"
);
let info = ProcessInfo::builder(1, "hungry", ProcStatus::Online)
.memory_bytes(Some(26 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, row, ALL, 200, false, false);
let fill: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.fg == palette.sky().fg && !span.content.is_empty())
.map(|span| span.content.as_ref())
.collect();
let tail: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.fg == palette.gauge_rest().fg && !span.content.is_empty())
.map(|span| span.content.as_ref())
.collect();
assert_eq!(fill, vec!["█████"], "got fill spans {fill:?}");
assert_eq!(tail, vec!["░░░░░"], "got tail spans {tail:?}");
}
#[test]
fn a_running_sheep_with_no_ceiling_draws_muted_not_sky() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.memory_bytes(Some(48 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, row, ALL, 200, false, false);
let mem_ceil_text: Vec<&str> = line
.spans
.iter()
.filter(|span| span.content.as_ref() == "░░░░░░░░░░")
.map(|span| span.content.as_ref())
.collect();
assert_eq!(mem_ceil_text, vec!["░░░░░░░░░░"]);
assert!(
line.spans
.iter()
.any(|span| span.content.as_ref() == "░░░░░░░░░░"
&& span.style.fg == palette.muted().fg),
"expected the all-tail bar in muted, got {:?}",
line.spans
.iter()
.map(|s| (s.content.as_ref(), s.style.fg))
.collect::<Vec<_>>()
);
assert!(
!line
.spans
.iter()
.any(|span| span.content.as_ref() == "░░░░░░░░░░"
&& span.style.fg == palette.sky().fg),
"must not read sky, which claims a real measurement"
);
}
#[test]
fn a_stopped_sheeps_gauge_draws_muted() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
let info = ProcessInfo::builder(1, "stopped", ProcStatus::Stopped)
.memory_bytes(Some(26 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, row, ALL, 200, false, false);
assert!(
!line
.spans
.iter()
.any(|span| span.style.fg == palette.sky().fg),
"a stopped sheep has nothing live to gauge, so no span reads sky"
);
}
#[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, false);
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::Cfg.width()), fit("", Column::CpuSpark.width()), fit("-", Column::Cpu.width()), fit("", Column::MemCeil.width()), fit("300.0M", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("-", Column::Fold.width()),
fit("-", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered.trim_end(), expected.trim_end(), "got {rendered:?}");
assert_eq!(
crate::output::width::visible_width(&rendered),
200,
"the row is padded to the table's width, so its ground reaches the edge"
);
}
#[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, false);
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::Cfg.width()),
fit(" \u{2581}", Column::CpuSpark.width()),
fit("-", Column::Cpu.width()),
fit(
"\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}",
Column::MemCeil.width(),
),
fit("-", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("", Column::Fold.width()),
fit("", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered.trim_end(), expected.trim_end(), "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, false);
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, 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, 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, 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, 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:?}");
}
#[test]
fn every_fold_tier_fits_the_width_it_claims() {
for width in MIN_WIDTH..=200 {
let cols = fold_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 + FOLD_NAME_MIN <= width,
"width {width} chose {} columns needing {}",
cols.len(),
fixed + gaps + FOLD_NAME_MIN
);
}
}
#[test]
fn the_share_bar_is_the_first_column_to_go() {
let wide = fold_columns_for(200);
assert!(wide.contains(&FoldColumn::Share));
let narrow = fold_columns_for(100);
assert!(!narrow.contains(&FoldColumn::Share), "got {narrow:?}");
}
#[test]
fn a_fold_header_shows_its_rollup_and_its_share() {
let app = fixtures::app_with(
vec![
fixtures::sheep_with(1, "api", Some("edge"), 120_000, Some(100 << 20), 2),
fixtures::sheep_with(2, "cdn", Some("edge"), 30_000, Some(150 << 20), 5),
fixtures::sheep_with(3, "batch", None, 60_000, Some(50 << 20), 0),
],
fixtures::plain(),
);
let line = fold_key_line(
&app,
&RowKey::Fold("edge".into()),
fold_columns_for(160),
160,
false,
);
let text = fixtures::rendered(&line);
assert!(text.contains("edge ×2"), "got {text}");
assert!(text.contains("250.0M"), "memory sums: {text}");
assert!(text.contains("30s"), "uptime is the minimum: {text}");
assert!(
text.contains("83%"),
"250 of 300 MiB of flock memory: {text}"
);
}
}