#![allow(clippy::too_many_lines)]
#![allow(clippy::match_same_arms)]
#![allow(clippy::items_after_statements)]
#![allow(clippy::option_map_or_none)]
use crate::direct::{CellBuffer, DirectTerminalCanvas};
use crate::{
Border, BorderStyle, BrailleGraph, CpuGrid, GraphMode, NetworkInterface, NetworkPanel,
ProcessEntry, ProcessState, ProcessTable, TitleBar, Treemap, TreemapNode,
};
use presentar_core::{Canvas, Color, Point, Rect, TextStyle, Widget};
use crate::ptop::analyzers::{ContainerState, SensorStatus, SensorType, TcpState};
use crate::ptop::app::{App, ProcessSortColumn};
use crate::ptop::config::{calculate_grid_layout, snap_to_grid, DetailLevel, PanelType};
use crate::ptop::ui::core::layout::push_if_visible;
use crate::ptop::ui::core::panel_cpu::{
build_cpu_title, build_cpu_title_compact, build_load_bar, consumer_cpu_color, load_color,
load_trend_arrow, CpuMeterLayout, DIM_LABEL_COLOR, PROCESS_NAME_COLOR,
};
#[allow(unused_imports)]
use crate::ptop::ui::core::panel_gpu::{
build_gpu_bar, build_gpu_title, format_proc_util, gpu_proc_badge, gpu_temp_color,
truncate_name, HEADER_COLOR, POWER_COLOR, PROC_INFO_COLOR, VRAM_GRAPH_COLOR,
};
use crate::ptop::ui::core::panel_memory::{
has_swap_activity, psi_memory_indicator, swap_color, thrashing_indicator,
MemoryStats as MemStats, ZramDisplay, CACHED_COLOR, DIM_COLOR, FREE_COLOR, RATIO_COLOR,
ZRAM_COLOR,
};
use crate::ptop::ui::panels::connections::{
build_sparkline, ACTIVE_COLOR, DIM_COLOR as CONN_DIM_COLOR, LISTEN_COLOR,
};
#[allow(unused_imports)]
use crate::ptop::ui_atoms::{draw_colored_text, severity_color, usage_color};
fn make_bar(ratio: f64, width: usize) -> String {
let filled = ((ratio * width as f64) as usize).min(width);
"█".repeat(filled) + &"░".repeat(width.saturating_sub(filled))
}
fn safe_pct(numerator: u64, denominator: u64) -> f64 {
if denominator == 0 {
0.0
} else {
(numerator as f64 / denominator as f64) * 100.0
}
}
fn can_draw_row(y: f32, inner: &Rect) -> bool {
y < inner.y + inner.height
}
fn panel_too_small(inner: &Rect, min_h: f32, min_w: f32) -> bool {
inner.height < min_h || inner.width < min_w
}
const CPU_COLOR: Color = Color {
r: 0.392,
g: 0.784,
b: 1.0,
a: 1.0,
}; const MEMORY_COLOR: Color = Color {
r: 0.706,
g: 0.471,
b: 1.0,
a: 1.0,
}; const DISK_COLOR: Color = Color {
r: 0.392,
g: 0.706,
b: 1.0,
a: 1.0,
}; const NETWORK_COLOR: Color = Color {
r: 1.0,
g: 0.588,
b: 0.392,
a: 1.0,
}; const PROCESS_COLOR: Color = Color {
r: 0.863,
g: 0.706,
b: 0.392,
a: 1.0,
}; const GPU_COLOR: Color = Color {
r: 0.392,
g: 1.0,
b: 0.588,
a: 1.0,
}; const BATTERY_COLOR: Color = Color {
r: 1.0,
g: 0.863,
b: 0.392,
a: 1.0,
}; const SENSORS_COLOR: Color = Color {
r: 1.0,
g: 0.392,
b: 0.588,
a: 1.0,
}; const PSI_COLOR: Color = Color {
r: 0.784,
g: 0.314,
b: 0.314,
a: 1.0,
}; const CONNECTIONS_COLOR: Color = Color {
r: 0.471,
g: 0.706,
b: 0.863,
a: 1.0,
}; const FILES_COLOR: Color = Color {
r: 0.706,
g: 0.549,
b: 0.392,
a: 1.0,
}; const CONTAINERS_COLOR: Color = Color {
r: 0.392,
g: 0.706,
b: 0.863,
a: 1.0,
};
const NET_RX_COLOR: Color = Color {
r: 0.392,
g: 0.784,
b: 1.0,
a: 1.0,
}; const NET_TX_COLOR: Color = Color {
r: 1.0,
g: 0.392,
b: 0.392,
a: 1.0,
};
use crate::widgets::selection::{SELECTION_ACCENT, SELECTION_BG};
const FOCUS_ACCENT_COLOR: Color = SELECTION_ACCENT;
const ROW_SELECT_BG: Color = SELECTION_BG;
const COL_SELECT_BG: Color = Color {
r: 0.15,
g: 0.4,
b: 0.65,
a: 1.0,
};
const STATUS_BAR_BG: Color = Color {
r: 0.08,
g: 0.08,
b: 0.12,
a: 1.0,
};
fn percent_color(percent: f64) -> Color {
let p = percent.clamp(0.0, 100.0);
if p >= 90.0 {
Color {
r: 1.0,
g: 0.25,
b: 0.25,
a: 1.0,
}
} else if p >= 75.0 {
let t = (p - 75.0) / 15.0;
Color {
r: 1.0,
g: (0.706 - t * 0.456) as f32,
b: 0.25,
a: 1.0,
}
} else if p >= 50.0 {
let t = (p - 50.0) / 25.0;
Color {
r: 1.0,
g: (0.863 - t * 0.157) as f32,
b: 0.25,
a: 1.0,
}
} else if p >= 25.0 {
let t = (p - 25.0) / 25.0;
Color {
r: (0.392 + t * 0.608) as f32,
g: 0.863,
b: (0.392 - t * 0.142) as f32,
a: 1.0,
}
} else {
let t = p / 25.0;
Color {
r: (0.25 + t * 0.142) as f32,
g: (0.706 + t * 0.157) as f32,
b: (0.863 - t * 0.471) as f32,
a: 1.0,
}
}
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.1}T", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.1}G", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}M", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}K", bytes as f64 / KB as f64)
} else {
format!("{bytes}B")
}
}
fn format_bytes_rate(bytes_per_sec: f64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
if bytes_per_sec >= GB {
format!("{:.1}G", bytes_per_sec / GB)
} else if bytes_per_sec >= MB {
format!("{:.1}M", bytes_per_sec / MB)
} else if bytes_per_sec >= KB {
format!("{:.0}K", bytes_per_sec / KB)
} else {
format!("{bytes_per_sec:.0}B")
}
}
fn create_panel_border(title: &str, color: Color, is_focused: bool) -> Border {
let style = if is_focused {
BorderStyle::Double } else {
BorderStyle::Rounded };
let border_color = if is_focused {
Color {
r: (color.r * 0.5 + FOCUS_ACCENT_COLOR.r * 0.5).min(1.0),
g: (color.g * 0.5 + FOCUS_ACCENT_COLOR.g * 0.5).min(1.0),
b: (color.b * 0.5 + FOCUS_ACCENT_COLOR.b * 0.5).min(1.0),
a: color.a,
}
} else {
Color {
r: color.r * 0.5,
g: color.g * 0.5,
b: color.b * 0.5,
a: color.a,
}
};
let display_title = if is_focused {
format!("► {title}")
} else {
title.to_string()
};
Border::new()
.with_title(&display_title)
.with_style(style)
.with_color(border_color)
.with_title_left_aligned()
}
#[allow(dead_code)] fn paint_panel_with_clip<F>(
canvas: &mut DirectTerminalCanvas<'_>,
title: &str,
color: Color,
is_focused: bool,
bounds: Rect,
content_painter: F,
) where
F: FnOnce(&mut DirectTerminalCanvas<'_>, Rect),
{
let mut border = create_panel_border(title, color, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 || inner.width < 1.0 {
return;
}
canvas.push_clip(inner);
content_painter(canvas, inner);
canvas.pop_clip();
}
#[derive(Debug, Default)]
struct ZramStats {
orig_data_size: u64,
compr_data_size: u64,
algorithm: String,
}
impl ZramStats {
fn ratio(&self) -> f64 {
if self.compr_data_size == 0 {
1.0
} else {
self.orig_data_size as f64 / self.compr_data_size as f64
}
}
fn is_active(&self) -> bool {
self.orig_data_size > 0
}
}
#[cfg(target_os = "linux")]
fn parse_zram_algorithm(content: &str) -> String {
content
.split_whitespace()
.find(|p| p.starts_with('[') && p.ends_with(']'))
.map(|p| p.trim_matches(|c| c == '[' || c == ']').to_string())
.unwrap_or_else(|| "?".to_string())
}
#[cfg(target_os = "linux")]
fn read_zram_device(base_path: &str) -> Option<ZramStats> {
use std::fs;
let mm_stat_path = format!("{base_path}/mm_stat");
let content = fs::read_to_string(&mm_stat_path).ok()?;
let parts: Vec<&str> = content.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let orig = parts[0].parse::<u64>().unwrap_or(0);
let compr = parts[1].parse::<u64>().unwrap_or(0);
if orig == 0 {
return None;
}
let algo_path = format!("{base_path}/comp_algorithm");
let algorithm = fs::read_to_string(&algo_path)
.map(|s| parse_zram_algorithm(&s))
.unwrap_or_else(|_| "?".to_string());
Some(ZramStats {
orig_data_size: orig,
compr_data_size: compr,
algorithm,
})
}
fn read_zram_stats() -> Option<ZramStats> {
#[cfg(target_os = "linux")]
{
for i in 0..4 {
let base_path = format!("/sys/block/zram{i}");
if !std::path::Path::new(&base_path).exists() {
continue;
}
if let Some(stats) = read_zram_device(&base_path) {
return Some(stats);
}
}
None
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
fn get_keybinds(exploded: bool) -> &'static [(&'static str, &'static str)] {
if exploded {
&[
("←→", "Column"),
("↵", "Sort"),
("↑↓", "Row"),
("Esc", "Exit"),
]
} else {
&[
("q", "Quit"),
("?", "Help"),
("/", "Filter"),
("Tab", "Nav"),
]
}
}
fn draw_title_bar(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32) {
let keybinds = get_keybinds(app.exploded_panel.is_some());
let mut title_bar = TitleBar::new("ptop")
.with_version(env!("CARGO_PKG_VERSION"))
.with_search_placeholder("Filter processes...")
.with_search_text(&app.filter)
.with_search_active(app.show_filter_input)
.with_keybinds(keybinds)
.with_primary_color(CPU_COLOR);
if app.exploded_panel.is_some() {
title_bar = title_bar.with_mode_indicator("[▣]");
}
title_bar.layout(Rect::new(0.0, 0.0, w, 1.0));
title_bar.paint(canvas);
}
fn compute_panel_layout(content_h: f32, top_count: u32, has_process: bool) -> (f32, f32) {
let top_h = if top_count > 0 && has_process {
(content_h * 0.45).max(8.0)
} else if top_count > 0 {
content_h
} else {
0.0
};
(top_h, content_h - top_h)
}
fn draw_bottom_row(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
bottom_y: f32,
bottom_h: f32,
w: f32,
) {
if !app.panels.process || bottom_h <= 3.0 {
return;
}
let proc_w = (w * 0.4).round();
let remaining = w - proc_w;
let conn_w = (remaining / 2.0).floor();
let files_w = remaining - conn_w;
draw_process_panel(app, canvas, Rect::new(0.0, bottom_y, proc_w, bottom_h));
if app.panels.connections {
draw_connections_panel(app, canvas, Rect::new(proc_w, bottom_y, conn_w, bottom_h));
}
if app.panels.files {
draw_files_panel(
app,
canvas,
Rect::new(proc_w + conn_w, bottom_y, files_w, bottom_h),
);
} else if app.panels.treemap {
draw_treemap_panel(
app,
canvas,
Rect::new(proc_w + conn_w, bottom_y, files_w, bottom_h),
);
}
}
fn draw_overlays(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32, h: f32) {
if app.show_help {
draw_help_overlay(canvas, w, h);
}
if app.pending_signal.is_some() {
draw_signal_dialog(app, canvas, w, h);
}
if app.show_filter_input {
draw_filter_overlay(app, canvas, w, h);
}
if app.show_fps {
draw_fps_overlay(app, canvas, w);
}
}
pub fn draw(app: &App, buffer: &mut CellBuffer) {
let w = buffer.width() as f32;
let h = buffer.height() as f32;
if w < 10.0 || h < 5.0 {
return;
}
let mut canvas = DirectTerminalCanvas::new(buffer);
draw_title_bar(app, &mut canvas, w);
let content_y = 1.0_f32;
let content_h = h - 2.0;
if let Some(panel) = app.exploded_panel {
draw_exploded_panel(
app,
&mut canvas,
Rect::new(0.0, content_y, w, content_h),
panel,
);
draw_status_bar(app, &mut canvas, w, h);
return;
}
let top_count = count_top_panels(app);
let (top_h, bottom_h) = compute_panel_layout(content_h, top_count, app.panels.process);
if top_count > 0 {
draw_top_panels(app, &mut canvas, Rect::new(0.0, content_y, w, top_h));
}
draw_bottom_row(app, &mut canvas, content_y + top_h, bottom_h, w);
draw_overlays(app, &mut canvas, w, h);
draw_status_bar(app, &mut canvas, w, h);
}
fn count_top_panels(app: &App) -> u32 {
let mut count = 0;
if app.panels.cpu {
count += 1;
}
if app.panels.memory {
count += 1;
}
if app.panels.disk {
count += 1;
}
if app.panels.network {
count += 1;
}
if app.panels.gpu {
count += 1;
}
if app.panels.battery {
count += 1;
}
if app.panels.sensors {
count += 1;
}
if app.panels.psi {
count += 1;
}
count
}
fn draw_fps_overlay(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32) {
let fps_str = format!(" Frame: {}μs ", app.avg_frame_time_us);
let style = TextStyle {
color: Color::new(0.4, 1.0, 0.4, 1.0),
..Default::default()
};
canvas.draw_text(
&fps_str,
Point::new(w - fps_str.len() as f32 - 1.0, 0.0),
&style,
);
}
fn draw_status_bar(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32, h: f32) {
let y = h - 1.0;
let bracket_style = TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0), ..Default::default()
};
let key_style = TextStyle {
color: FOCUS_ACCENT_COLOR, ..Default::default()
};
let action_style = TextStyle {
color: Color::new(0.7, 0.7, 0.7, 1.0), ..Default::default()
};
let focus_indicator_style = TextStyle {
color: FOCUS_ACCENT_COLOR, ..Default::default()
};
canvas.fill_rect(Rect::new(0.0, y, w, 1.0), STATUS_BAR_BG);
let hints = if app.exploded_panel.is_some() {
" [Esc]Exit [↑↓]Row [←→]Col [?]Help [q]Quit "
} else {
" [Tab]Panel [Enter]Explode [↑↓]Row [/]Filter [?]Help [q]Quit "
};
let mut x = 0.0;
let mut in_bracket = false;
for ch in hints.chars() {
let style = if ch == '[' {
in_bracket = true;
&bracket_style
} else if ch == ']' {
in_bracket = false;
&bracket_style
} else if in_bracket {
&key_style } else {
&action_style
};
canvas.draw_text(&ch.to_string(), Point::new(x, y), style);
x += 1.0;
}
if let Some(panel) = app.focused_panel {
let panel_name = match panel {
PanelType::Cpu => "CPU",
PanelType::Memory => "Memory",
PanelType::Disk => "Disk",
PanelType::Network => "Network",
PanelType::Process => "Process",
PanelType::Gpu => "GPU",
PanelType::Battery => "Battery",
PanelType::Sensors => "Sensors",
PanelType::Files => "Files",
PanelType::Connections => "Connections",
PanelType::Psi => "PSI",
PanelType::Containers => "Containers",
};
let focus_text = format!("► {panel_name} ");
let focus_x = w - focus_text.chars().count() as f32 - 1.0;
if focus_x > x {
canvas.draw_text(&focus_text, Point::new(focus_x, y), &focus_indicator_style);
}
}
}
fn is_ttop_layout(app: &App) -> bool {
app.panels.cpu
&& app.panels.memory
&& app.panels.disk
&& app.panels.network
&& app.panels.gpu
&& app.panels.sensors
&& !app.panels.psi
&& !app.panels.battery
}
fn draw_ttop_grid(app: &App, canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
let cell_w = area.width / 3.0;
let cell_h = area.height / 2.0;
draw_cpu_panel(app, canvas, Rect::new(area.x, area.y, cell_w, cell_h));
draw_memory_panel(
app,
canvas,
Rect::new(area.x + cell_w, area.y, cell_w, cell_h),
);
draw_disk_panel(
app,
canvas,
Rect::new(area.x + 2.0 * cell_w, area.y, cell_w, cell_h),
);
let row1_y = area.y + cell_h;
draw_network_panel(app, canvas, Rect::new(area.x, row1_y, cell_w, cell_h));
draw_gpu_panel(
app,
canvas,
Rect::new(area.x + cell_w, row1_y, cell_w, cell_h),
);
let col3_x = area.x + 2.0 * cell_w;
let sensors_h = (cell_h / 3.0).round();
draw_sensors_panel(app, canvas, Rect::new(col3_x, row1_y, cell_w, sensors_h));
draw_containers_panel(
app,
canvas,
Rect::new(col3_x, row1_y + sensors_h, cell_w, cell_h - sensors_h),
);
}
#[allow(clippy::type_complexity)]
fn build_panel_list(app: &App) -> Vec<fn(&App, &mut DirectTerminalCanvas<'_>, Rect)> {
let mut panels: Vec<fn(&App, &mut DirectTerminalCanvas<'_>, Rect)> = Vec::new();
if app.panels.cpu {
panels.push(draw_cpu_panel);
}
if app.panels.memory {
panels.push(draw_memory_panel);
}
if app.panels.disk {
panels.push(draw_disk_panel);
}
if app.panels.network {
panels.push(draw_network_panel);
}
push_if_visible(
&mut panels,
app,
app.panels.gpu,
PanelType::Gpu,
draw_gpu_panel,
None,
);
push_if_visible(
&mut panels,
app,
app.panels.sensors,
PanelType::Sensors,
draw_sensors_panel,
Some(draw_sensors_compact_panel),
);
push_if_visible(
&mut panels,
app,
app.panels.psi,
PanelType::Psi,
draw_psi_panel,
None,
);
push_if_visible(
&mut panels,
app,
app.panels.battery,
PanelType::Battery,
draw_battery_panel,
None,
);
if app.panels.sensors_compact {
panels.push(draw_sensors_compact_panel);
}
if app.panels.system {
panels.push(draw_system_panel);
}
panels
}
fn draw_top_panels(app: &App, canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
if is_ttop_layout(app) && area.width >= 100.0 {
draw_ttop_grid(app, canvas, area);
return;
}
let panels = build_panel_list(app);
if panels.is_empty() {
return;
}
let layout_config = &app.config.layout;
let grid_rects = calculate_grid_layout(
panels.len() as u32,
area.width as u16,
area.height as u16,
layout_config,
);
for (i, draw_fn) in panels.iter().enumerate() {
if let Some(rect) = grid_rects.get(i) {
let snapped_x = snap_to_grid(rect.x, layout_config.grid_size);
let snapped_y = snap_to_grid(rect.y, layout_config.grid_size);
let bounds = Rect::new(
area.x + snapped_x as f32,
area.y + snapped_y as f32,
rect.width as f32,
rect.height as f32,
);
draw_fn(app, canvas, bounds);
}
}
}
fn get_cpu_load_freq(app: &App) -> (sysinfo::LoadAvg, u64) {
use sysinfo::Cpu;
if app.deterministic {
(
sysinfo::LoadAvg {
one: 0.0,
five: 0.0,
fifteen: 0.0,
},
0,
)
} else {
let freq = app
.system
.cpus()
.iter()
.map(Cpu::frequency)
.max()
.unwrap_or(0);
(app.load_avg.clone(), freq)
}
}
fn draw_cpu_meters_graph(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
core_area_height: f32,
max_freq_mhz: u64,
) {
let core_count = app.per_core_percent.len();
let is_exploded = inner.width > 100.0;
let layout = CpuMeterLayout::calculate(core_count, core_area_height, is_exploded);
let max_meter_ratio = if is_exploded { 0.70 } else { 0.5 };
let meters_width =
(layout.num_meter_cols as f32 * layout.meter_bar_width).min(inner.width * max_meter_ratio);
let mut grid = CpuGrid::new(app.per_core_percent.clone())
.with_frequencies(
app.per_core_freq.iter().map(|&f| f as u32).collect(),
vec![max_freq_mhz as u32; core_count],
)
.with_freq_indicators();
if is_exploded {
grid = grid.with_percentages();
}
grid.layout(Rect::new(inner.x, inner.y, meters_width, core_area_height));
grid.paint(canvas);
let graph_x = inner.x + meters_width + 1.0;
let graph_width = inner.width - meters_width - 1.0;
if graph_width > 5.0 && !app.cpu_history.as_slice().is_empty() {
let history: Vec<f64> = app
.cpu_history
.as_slice()
.iter()
.map(|&v| v * 100.0)
.collect();
let mut graph = BrailleGraph::new(history)
.with_color(CPU_COLOR)
.with_range(0.0, 100.0)
.with_mode(GraphMode::Block);
graph.layout(Rect::new(graph_x, inner.y, graph_width, core_area_height));
graph.paint(canvas);
}
}
fn format_load_string(
load: &sysinfo::LoadAvg,
core_count: usize,
freq_ghz: f64,
width: usize,
deterministic: bool,
) -> String {
let load_normalized = load.one / core_count as f64;
let trend_1_5 = load_trend_arrow(load.one, load.five);
let trend_5_15 = load_trend_arrow(load.five, load.fifteen);
let load_pct = (load_normalized / 2.0).min(1.0);
if deterministic {
let bar = build_load_bar(load_pct, 10);
format!(
"Load {bar} {:.2}{trend_1_5} {:.2}{trend_5_15} {:.2} │ Fre",
load.one, load.five, load.fifteen
)
} else if width >= 45 && freq_ghz > 0.0 {
let bar = build_load_bar(load_pct, 10);
format!(
"Load {bar} {:.2}{trend_1_5} {:.2}{trend_5_15} {:.2}→ │ {freq_ghz:.1}GHz",
load.one, load.five, load.fifteen
)
} else if width >= 35 {
let bar = build_load_bar(load_pct, 10);
format!(
"Load {bar} {:.2}{trend_1_5} {:.2}{trend_5_15} {:.2}→",
load.one, load.five, load.fifteen
)
} else {
let bar = build_load_bar(load_pct, 4);
format!(
"Load {bar} {:.1}{trend_1_5} {:.1}{trend_5_15} {:.1}→",
load.one, load.five, load.fifteen
)
}
}
fn draw_load_gauge(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
load_y: f32,
load: &sysinfo::LoadAvg,
core_count: usize,
freq_ghz: f64,
deterministic: bool,
) {
if load_y >= inner.y + inner.height || inner.width <= 20.0 {
return;
}
let load_normalized = load.one / core_count as f64;
let load_str = format_load_string(
load,
core_count,
freq_ghz,
inner.width as usize,
deterministic,
);
canvas.draw_text(
&load_str,
Point::new(inner.x, load_y),
&TextStyle {
color: load_color(load_normalized),
..Default::default()
},
);
}
fn draw_top_consumers(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
consumers_y: f32,
) {
if app.deterministic || consumers_y >= inner.y + inner.height || inner.width <= 20.0 {
return;
}
let mut top_procs: Vec<_> = app
.system
.processes()
.values()
.filter(|p| p.cpu_usage() > 0.1)
.collect();
top_procs.sort_by(|a, b| {
b.cpu_usage()
.partial_cmp(&a.cpu_usage())
.unwrap_or(std::cmp::Ordering::Equal)
});
if top_procs.is_empty() {
return;
}
canvas.draw_text(
"Top ",
Point::new(inner.x, consumers_y),
&TextStyle {
color: DIM_LABEL_COLOR,
..Default::default()
},
);
let mut x_offset = 4.0;
for (i, proc) in top_procs.iter().take(3).enumerate() {
let cpu = proc.cpu_usage() as f64;
let name: String = proc.name().to_string_lossy().chars().take(12).collect();
if i > 0 {
canvas.draw_text(
" │ ",
Point::new(inner.x + x_offset, consumers_y),
&TextStyle {
color: DIM_LABEL_COLOR,
..Default::default()
},
);
x_offset += 3.0;
}
let cpu_str = format!("{cpu:.0}%");
canvas.draw_text(
&cpu_str,
Point::new(inner.x + x_offset, consumers_y),
&TextStyle {
color: consumer_cpu_color(cpu),
..Default::default()
},
);
x_offset += cpu_str.len() as f32;
canvas.draw_text(
&format!(" {name}"),
Point::new(inner.x + x_offset, consumers_y),
&TextStyle {
color: PROCESS_NAME_COLOR,
..Default::default()
},
);
x_offset += 1.0 + name.len() as f32;
}
}
fn draw_cpu_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let cpu_pct = app.cpu_history.last().copied().unwrap_or(0.0) * 100.0;
let core_count = app.per_core_percent.len();
let uptime = app.uptime();
let (load, max_freq_mhz) = get_cpu_load_freq(app);
let is_boosting = max_freq_mhz > 3000;
let freq_ghz = max_freq_mhz as f64 / 1000.0;
let title = if bounds.width < 35.0 {
build_cpu_title_compact(cpu_pct, core_count, freq_ghz, is_boosting)
} else {
build_cpu_title(
cpu_pct,
core_count,
freq_ghz,
is_boosting,
uptime,
load.one,
app.deterministic,
)
};
let is_focused = app.is_panel_focused(PanelType::Cpu);
let mut border = create_panel_border(&title, CPU_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if panel_too_small(&inner, 2.0, 10.0) {
return;
}
let reserved_bottom = 2.0_f32;
let core_area_height = (inner.height - reserved_bottom).max(1.0);
let has_cpu_data = !app.deterministic || app.per_core_percent.iter().any(|&p| p > 0.0);
if has_cpu_data {
draw_cpu_meters_graph(app, canvas, inner, core_area_height, max_freq_mhz);
}
draw_load_gauge(
canvas,
inner,
inner.y + core_area_height,
&load,
core_count,
freq_ghz,
app.deterministic,
);
draw_top_consumers(app, canvas, inner, inner.y + core_area_height + 1.0);
}
struct MemoryStats {
used_gb: f64,
cached_gb: f64,
free_gb: f64,
}
impl MemoryStats {
fn from_app(app: &App) -> Self {
let stats = MemStats::from_bytes(
app.mem_used,
app.mem_cached,
app.mem_available,
app.mem_total,
);
Self {
used_gb: stats.used_gb,
cached_gb: stats.cached_gb,
free_gb: stats.free_gb,
}
}
}
fn draw_memory_stacked_bar(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: f32, app: &App) {
let bar_width = inner.width as usize;
let used_actual_pct = if app.mem_total > 0 {
((app.mem_total - app.mem_available) as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let cached_pct = if app.mem_total > 0 {
(app.mem_cached as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let used_chars = ((used_actual_pct / 100.0) * bar_width as f64) as usize;
let cached_chars = ((cached_pct / 100.0) * bar_width as f64) as usize;
let free_chars = bar_width.saturating_sub(used_chars + cached_chars);
let used_color = percent_color(used_actual_pct);
let free_color = Color::new(0.3, 0.3, 0.3, 1.0);
if used_chars > 0 {
let used_bar: String = "█".repeat(used_chars);
canvas.draw_text(
&used_bar,
Point::new(inner.x, y),
&TextStyle {
color: used_color,
..Default::default()
},
);
}
if cached_chars > 0 {
let cached_bar: String = "█".repeat(cached_chars);
canvas.draw_text(
&cached_bar,
Point::new(inner.x + used_chars as f32, y),
&TextStyle {
color: CACHED_COLOR,
..Default::default()
},
);
}
if free_chars > 0 {
let free_bar: String = "░".repeat(free_chars);
canvas.draw_text(
&free_bar,
Point::new(inner.x + used_chars as f32 + cached_chars as f32, y),
&TextStyle {
color: free_color,
..Default::default()
},
);
}
}
fn draw_memory_rows_deterministic(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
mut y: f32,
stats: &MemoryStats,
) -> f32 {
canvas.draw_text(
&format!(" Used: {:.1}G 0", stats.used_gb),
Point::new(inner.x, y),
&TextStyle {
color: percent_color(0.0),
..Default::default()
},
);
y += 1.0;
if y < inner.y + inner.height {
canvas.draw_text(
&format!("Cached: {:.1}G 0", stats.cached_gb),
Point::new(inner.x, y),
&TextStyle {
color: CACHED_COLOR,
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
canvas.draw_text(
&format!(" Free: {:.1}G 0", stats.free_gb),
Point::new(inner.x, y),
&TextStyle {
color: FREE_COLOR,
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
canvas.draw_text(
"PSI ○ 0.0 cpu ○ 0.0 mem ○ 0.0 io",
Point::new(inner.x, y),
&TextStyle {
color: DIM_COLOR,
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
canvas.draw_text(
"── Top Memory Consumers ──────────────",
Point::new(inner.x, y),
&TextStyle {
color: DIM_COLOR,
..Default::default()
},
);
}
y
}
#[allow(clippy::too_many_lines)]
fn compute_mem_percentages(app: &App) -> (f64, f64, f64, f64) {
let mem_total = app.mem_total;
let swap_total = app.swap_total;
let used_pct = safe_pct(app.mem_used, mem_total);
let cached_pct = safe_pct(app.mem_cached, mem_total);
let free_pct = safe_pct(app.mem_available, mem_total);
let swap_pct = safe_pct(app.swap_used, swap_total);
(used_pct, cached_pct, free_pct, swap_pct)
}
fn build_memory_rows(app: &App, has_zram: bool) -> Vec<(&'static str, f64, f64, Color)> {
let gb = |b: u64| b as f64 / 1024.0 / 1024.0 / 1024.0;
let (used_pct, cached_pct, free_pct, swap_pct) = compute_mem_percentages(app);
let mut rows: Vec<(&str, f64, f64, Color)> = vec![
("Used", gb(app.mem_used), used_pct, percent_color(used_pct)),
("Swap", gb(app.swap_used), swap_pct, swap_color(swap_pct)),
("Cached", gb(app.mem_cached), cached_pct, CACHED_COLOR),
("Free", gb(app.mem_available), free_pct, FREE_COLOR),
];
if has_zram {
rows.insert(
2,
(
"ZRAM",
0.0,
0.0,
Color {
r: 0.8,
g: 0.4,
b: 1.0,
a: 1.0,
},
),
);
}
rows
}
fn draw_zram_row(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: f32,
zram_data: &(f64, f64, f64, &str),
) {
let (orig_gb, compr_gb, ratio, algo) = zram_data;
let orig_str = ZramDisplay::format_size(*orig_gb);
let compr_str = ZramDisplay::format_size(*compr_gb);
canvas.draw_text(
" ZRAM ",
Point::new(inner.x, y),
&TextStyle {
color: DIM_COLOR,
..Default::default()
},
);
canvas.draw_text(
&format!("{orig_str}→{compr_str} "),
Point::new(inner.x + 7.0, y),
&TextStyle {
color: ZRAM_COLOR,
..Default::default()
},
);
let ratio_x = inner.x + 7.0 + orig_str.len() as f32 + 1.0 + compr_str.len() as f32 + 1.0;
canvas.draw_text(
&format!("{ratio:.1}x"),
Point::new(ratio_x, y),
&TextStyle {
color: RATIO_COLOR,
..Default::default()
},
);
canvas.draw_text(
&format!(" {algo}"),
Point::new(ratio_x + 4.0, y),
&TextStyle {
color: DIM_COLOR,
..Default::default()
},
);
}
fn draw_memory_row_bar(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: f32,
label: &str,
value: f64,
pct: f64,
color: Color,
) {
let bar_width = 10.min((inner.width as usize).saturating_sub(22));
let bar = make_bar(pct / 100.0, bar_width);
let text = format!("{label:>6} {value:>5.1}G {bar} {pct:>5.1}%");
canvas.draw_text(
&text,
Point::new(inner.x, y),
&TextStyle {
color,
..Default::default()
},
);
}
fn draw_swap_thrash_indicator(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: f32,
) {
if let Some(swap_data) = app.analyzers.swap_data() {
let (is_thrashing, severity) = swap_data.is_thrashing();
if has_swap_activity(
is_thrashing,
swap_data.swap_in_rate,
swap_data.swap_out_rate,
) {
let (indicator, ind_color) = thrashing_indicator(severity);
let bar_width = 10.min((inner.width as usize).saturating_sub(22));
let thrash_x = inner.x + 28.0 + bar_width as f32;
let thrash_text = format!(
" {indicator} I:{:.0}/O:{:.0}",
swap_data.swap_in_rate, swap_data.swap_out_rate
);
canvas.draw_text(
&thrash_text,
Point::new(thrash_x, y),
&TextStyle {
color: ind_color,
..Default::default()
},
);
}
}
}
fn draw_mem_psi_indicator(app: &App, canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: f32) {
if let Some(psi) = app.psi_data() {
let mem_some = psi.memory.some.avg10;
let mem_full = psi.memory.full.as_ref().map_or(0.0, |f| f.avg10);
let (symbol, color) = psi_memory_indicator(mem_some, mem_full);
let psi_text = format!(" PSI {symbol} {mem_some:>5.1}% some {mem_full:>5.1}% full");
canvas.draw_text(
&psi_text,
Point::new(inner.x, y),
&TextStyle {
color,
..Default::default()
},
);
}
}
fn draw_memory_rows_normal(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
mut y: f32,
rows: &[(&str, f64, f64, Color)],
zram_data: Option<(f64, f64, f64, &str)>,
) {
for (label, value, pct, color) in rows {
if y >= inner.y + inner.height {
break;
}
if *label == "ZRAM" {
if let Some(ref data) = zram_data {
draw_zram_row(canvas, inner, y, data);
}
y += 1.0;
continue;
}
draw_memory_row_bar(canvas, inner, y, label, *value, *pct, *color);
if *label == "Swap" {
draw_swap_thrash_indicator(app, canvas, inner, y);
}
y += 1.0;
}
if y < inner.y + inner.height {
draw_mem_psi_indicator(app, canvas, inner, y);
}
}
fn draw_memory_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let _detail_level = DetailLevel::for_height(bounds.height as u16);
let gb = |b: u64| b as f64 / 1024.0 / 1024.0 / 1024.0;
let mem_pct = if app.mem_total > 0 {
(app.mem_used as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let zram_stats = if app.deterministic {
None
} else {
read_zram_stats()
};
let zram_info = zram_stats
.as_ref()
.filter(|z| z.is_active())
.map(|z| format!(" │ ZRAM:{:.1}x", z.ratio()))
.unwrap_or_default();
let title = format!(
"Memory │ {:.1}G / {:.1}G ({:.0}%){}",
gb(app.mem_used),
gb(app.mem_total),
mem_pct,
zram_info
);
let is_focused = app.is_panel_focused(PanelType::Memory);
let mut border = create_panel_border(&title, MEMORY_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 || inner.width < 10.0 {
return;
}
let mut y = inner.y;
draw_memory_stacked_bar(canvas, inner, y, app);
y += 1.0;
if y >= inner.y + inner.height {
return;
}
let zram_row_data = zram_stats.as_ref().filter(|z| z.is_active()).map(|z| {
(
gb(z.orig_data_size),
gb(z.compr_data_size),
z.ratio(),
z.algorithm.as_str(),
)
});
let rows = build_memory_rows(app, zram_row_data.is_some());
if app.deterministic {
let stats = MemoryStats::from_app(app);
draw_memory_rows_deterministic(canvas, inner, y, &stats);
} else {
draw_memory_rows_normal(app, canvas, inner, y, &rows, zram_row_data);
}
}
fn compute_disk_stats(app: &App) -> (u64, u64, f64, f64) {
if app.deterministic {
return (0, 0, 0.0, 0.0);
}
let disk_io = app.disk_io_data();
let (used, space): (u64, u64) = app
.disks
.iter()
.map(|d| (d.total_space() - d.available_space(), d.total_space()))
.fold((0, 0), |(au, at), (u, t)| (au + u, at + t));
let r_rate = disk_io.map_or(0.0, |d| d.total_read_bytes_per_sec);
let w_rate = disk_io.map_or(0.0, |d| d.total_write_bytes_per_sec);
(used, space, r_rate, w_rate)
}
fn format_disk_title(
deterministic: bool,
used: u64,
space: u64,
r_rate: f64,
w_rate: f64,
) -> String {
let gb = |b: u64| b as f64 / 1024.0 / 1024.0 / 1024.0;
if deterministic {
"Disk │ R: 0B/s │ W: 0B/s │ -0 IOPS │".to_string()
} else if r_rate > 0.0 || w_rate > 0.0 {
format!(
"Disk │ R: {} │ W: {} │ {:.0}G / {:.0}G",
format_bytes_rate(r_rate),
format_bytes_rate(w_rate),
gb(used),
gb(space)
)
} else {
let pct = if space > 0 {
(used as f64 / space as f64) * 100.0
} else {
0.0
};
format!("Disk │ {:.0}G / {:.0}G ({:.0}%)", gb(used), gb(space), pct)
}
}
fn draw_disk_deterministic(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect) {
let dim_color = Color {
r: 0.3,
g: 0.3,
b: 0.3,
a: 1.0,
};
canvas.draw_text(
"I/O Pressure ○ 0.0% some 0.0% full",
Point::new(inner.x, inner.y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
if inner.height >= 2.0 {
canvas.draw_text(
"── Top Active Processes ──────────────",
Point::new(inner.x, inner.y + 1.0),
&TextStyle {
color: dim_color,
..Default::default()
},
);
}
}
fn get_disk_io_rates(app: &App, device_name: &str) -> (f64, f64) {
app.disk_io_data()
.and_then(|data| data.rates.get(device_name))
.map_or((0.0, 0.0), |rate| {
(rate.read_bytes_per_sec, rate.write_bytes_per_sec)
})
}
fn draw_disk_row(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: f32,
disk: &sysinfo::Disk,
d_read: f64,
d_write: f64,
) {
let mount = disk.mount_point().to_string_lossy();
let mount_short: String = if mount == "/" {
"/".to_string()
} else {
mount
.split('/')
.next_back()
.unwrap_or(&mount)
.chars()
.take(8)
.collect()
};
let total = disk.total_space();
let used = total - disk.available_space();
let pct = safe_pct(used, total);
let total_gb = total as f64 / 1024.0 / 1024.0 / 1024.0;
let io_str = if d_read > 0.0 || d_write > 0.0 {
format!(
" R:{} W:{}",
format_bytes_rate(d_read),
format_bytes_rate(d_write)
)
} else {
String::new()
};
let bar_width = (inner.width as usize)
.saturating_sub(24 + io_str.len())
.max(2);
let bar = make_bar(pct / 100.0, bar_width);
let text = format!("{mount_short:<8} {total_gb:>5.0}G {bar} {pct:>5.1}%{io_str}");
let color = if d_read > 1024.0 || d_write > 1024.0 {
Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}
} else {
percent_color(pct)
};
canvas.draw_text(
&text,
Point::new(inner.x, y),
&TextStyle {
color,
..Default::default()
},
);
}
fn draw_disk_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let (total_used, total_space, read_rate, write_rate) = compute_disk_stats(app);
let title = format_disk_title(
app.deterministic,
total_used,
total_space,
read_rate,
write_rate,
);
let is_focused = app.is_panel_focused(PanelType::Disk);
let mut border = create_panel_border(&title, DISK_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
if app.deterministic {
draw_disk_deterministic(canvas, inner);
return;
}
let max_disks = inner.height as usize;
for (i, disk) in app.disks.iter().take(max_disks).enumerate() {
let y = inner.y + i as f32;
if y >= inner.y + inner.height {
break;
}
let disk_name = disk.name().to_string_lossy();
let device_name = disk_name.trim_start_matches("/dev/");
let (d_read, d_write) = get_disk_io_rates(app, device_name);
draw_disk_row(canvas, inner, y, disk, d_read, d_write);
}
}
fn compute_network_stats(app: &App) -> (u64, u64, &str) {
if app.deterministic {
return (0, 0, "none");
}
let (rx, tx): (u64, u64) = app
.networks
.values()
.map(|d| (d.received(), d.transmitted()))
.fold((0, 0), |(ar, at), (r, t)| (ar + r, at + t));
let iface = app
.networks
.iter()
.filter(|(name, _)| !name.starts_with("lo"))
.max_by_key(|(_, data)| data.received() + data.transmitted())
.map_or("none", |(name, _)| name.as_str());
(rx, tx, iface)
}
fn draw_net_dl_ul_rows(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: &mut f32) {
let cyan = Color {
r: 0.3,
g: 0.8,
b: 0.9,
a: 1.0,
};
let red = Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
};
let white = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
canvas.draw_text(
"↓",
Point::new(inner.x, *y),
&TextStyle {
color: cyan,
..Default::default()
},
);
canvas.draw_text(
" Download ",
Point::new(inner.x + 1.0, *y),
&TextStyle {
color: cyan,
..Default::default()
},
);
canvas.draw_text(
"0B/s",
Point::new(inner.x + 11.0, *y),
&TextStyle {
color: white,
..Default::default()
},
);
*y += 1.0;
if *y < inner.y + inner.height {
canvas.draw_text(
&"⠀".repeat(inner.width as usize),
Point::new(inner.x, *y),
&TextStyle {
color: cyan,
..Default::default()
},
);
*y += 1.0;
}
if *y < inner.y + inner.height {
canvas.draw_text(
"↑",
Point::new(inner.x, *y),
&TextStyle {
color: red,
..Default::default()
},
);
canvas.draw_text(
" Upload ",
Point::new(inner.x + 1.0, *y),
&TextStyle {
color: red,
..Default::default()
},
);
canvas.draw_text(
"0B/s",
Point::new(inner.x + 11.0, *y),
&TextStyle {
color: white,
..Default::default()
},
);
*y += 1.0;
}
for _ in 0..2 {
if *y < inner.y + inner.height {
canvas.draw_text(
&"⠀".repeat(inner.width as usize),
Point::new(inner.x, *y),
&TextStyle {
color: red,
..Default::default()
},
);
*y += 1.0;
}
}
}
fn draw_net_session_stats(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: f32) {
let cyan = Color {
r: 0.3,
g: 0.8,
b: 0.9,
a: 1.0,
};
let red = Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
};
let dim = Color {
r: 0.3,
g: 0.3,
b: 0.3,
a: 1.0,
};
let white = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
let green = Color {
r: 0.3,
g: 0.9,
b: 0.3,
a: 1.0,
};
let mut y = y;
if y < inner.y + inner.height {
canvas.draw_text(
"Session ",
Point::new(inner.x, y),
&TextStyle {
color: dim,
..Default::default()
},
);
canvas.draw_text(
"↓",
Point::new(inner.x + 8.0, y),
&TextStyle {
color: cyan,
..Default::default()
},
);
canvas.draw_text(
"0B",
Point::new(inner.x + 9.0, y),
&TextStyle {
color: white,
..Default::default()
},
);
canvas.draw_text(
" ↑",
Point::new(inner.x + 11.0, y),
&TextStyle {
color: red,
..Default::default()
},
);
canvas.draw_text(
"0B",
Point::new(inner.x + 13.0, y),
&TextStyle {
color: white,
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
let tcp_col = Color {
r: 0.3,
g: 0.7,
b: 0.9,
a: 1.0,
};
let udp_col = Color {
r: 0.8,
g: 0.3,
b: 0.8,
a: 1.0,
};
canvas.draw_text(
"TCP ",
Point::new(inner.x, y),
&TextStyle {
color: tcp_col,
..Default::default()
},
);
canvas.draw_text(
"0",
Point::new(inner.x + 4.0, y),
&TextStyle {
color: green,
..Default::default()
},
);
canvas.draw_text(
"/",
Point::new(inner.x + 5.0, y),
&TextStyle {
color: dim,
..Default::default()
},
);
canvas.draw_text(
"0",
Point::new(inner.x + 6.0, y),
&TextStyle {
color: tcp_col,
..Default::default()
},
);
canvas.draw_text(
" UDP ",
Point::new(inner.x + 7.0, y),
&TextStyle {
color: udp_col,
..Default::default()
},
);
canvas.draw_text(
"0",
Point::new(inner.x + 12.0, y),
&TextStyle {
color: white,
..Default::default()
},
);
canvas.draw_text(
" │ RTT ",
Point::new(inner.x + 13.0, y),
&TextStyle {
color: dim,
..Default::default()
},
);
canvas.draw_text(
"●●●●●",
Point::new(inner.x + 20.0, y),
&TextStyle {
color: green,
..Default::default()
},
);
}
}
fn draw_network_deterministic(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect) {
let mut y = inner.y;
draw_net_dl_ul_rows(canvas, inner, &mut y);
draw_net_session_stats(canvas, inner, y);
}
fn build_network_interfaces(app: &App) -> Vec<NetworkInterface> {
let network_stats_data = app.analyzers.network_stats_data();
let mut interfaces: Vec<NetworkInterface> = Vec::new();
for (name, data) in &app.networks {
let mut iface = NetworkInterface::new(name);
iface.update(data.received() as f64, data.transmitted() as f64);
iface.set_totals(data.total_received(), data.total_transmitted());
if let Some(stats_data) = network_stats_data {
if let Some(stats) = stats_data.stats.get(name.as_str()) {
iface.set_stats(
stats.rx_errors,
stats.tx_errors,
stats.rx_dropped,
stats.tx_dropped,
);
}
if let Some(rates) = stats_data.rates.get(name.as_str()) {
iface.set_rates(rates.errors_per_sec, rates.drops_per_sec);
iface.set_utilization(rates.utilization_percent());
}
}
interfaces.push(iface);
}
interfaces.sort_by(|a, b| {
(b.rx_bps + b.tx_bps)
.partial_cmp(&(a.rx_bps + a.tx_bps))
.unwrap_or(std::cmp::Ordering::Equal)
});
interfaces
}
fn draw_network_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let (rx_total, tx_total, primary_iface) = compute_network_stats(app);
let title = format!(
"Network ({}) │ ↓ {}/s │ ↑ {}/s",
primary_iface,
format_bytes(rx_total),
format_bytes(tx_total)
);
let is_focused = app.is_panel_focused(PanelType::Network);
let mut border = create_panel_border(&title, NETWORK_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if app.deterministic {
draw_network_deterministic(canvas, inner);
return;
}
let mut interfaces = build_network_interfaces(app);
for iface in interfaces.iter_mut() {
if let Some((rx_hist, tx_hist)) = app.net_iface_history.get(&iface.name) {
iface.rx_history = rx_hist.as_slice().to_vec();
iface.tx_history = tx_hist.as_slice().to_vec();
}
}
interfaces.truncate(4);
if !interfaces.is_empty() && inner.height > 0.0 {
let spark_w = (inner.width as usize / 4).max(5);
let mut panel = NetworkPanel::new()
.with_spark_width(spark_w)
.with_rx_color(NET_RX_COLOR)
.with_tx_color(NET_TX_COLOR)
.compact();
panel.set_interfaces(interfaces);
panel.layout(inner);
panel.paint(canvas);
}
}
fn sort_column_name(col: ProcessSortColumn) -> &'static str {
match col {
ProcessSortColumn::Cpu => "CPU%",
ProcessSortColumn::Mem => "MEM%",
ProcessSortColumn::Pid => "PID",
ProcessSortColumn::User => "USER",
ProcessSortColumn::Command => "CMD",
}
}
fn convert_process_status(status: sysinfo::ProcessStatus) -> ProcessState {
match status {
sysinfo::ProcessStatus::Run => ProcessState::Running,
sysinfo::ProcessStatus::Sleep => ProcessState::Sleeping,
sysinfo::ProcessStatus::Idle => ProcessState::Idle,
sysinfo::ProcessStatus::Zombie => ProcessState::Zombie,
sysinfo::ProcessStatus::Stop => ProcessState::Stopped,
sysinfo::ProcessStatus::UninterruptibleDiskSleep => ProcessState::DiskWait,
_ => ProcessState::Sleeping,
}
}
fn get_process_command(p: &sysinfo::Process, is_exploded: bool, max_len: usize) -> String {
if is_exploded {
let cmdline: Vec<String> = p
.cmd()
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect();
if cmdline.is_empty() {
p.name().to_string_lossy().chars().take(max_len).collect()
} else {
cmdline.join(" ").chars().take(max_len).collect()
}
} else {
p.name().to_string_lossy().chars().take(max_len).collect()
}
}
fn draw_process_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let sort_name = sort_column_name(app.sort_column);
let arrow = if app.sort_descending { "▼" } else { "▲" };
let filter_str = if app.filter.is_empty() {
String::new()
} else {
format!(" │ Filter: \"{}\"", app.filter)
};
let title = format!(
"Processes ({}) │ Sort: {} {}{}",
app.process_count(),
sort_name,
arrow,
filter_str
);
let is_focused = app.is_panel_focused(PanelType::Process);
let mut border = create_panel_border(&title, PROCESS_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 2.0 {
return;
}
if app.deterministic {
canvas.draw_text(
"PID S C% M% COMMAND",
Point::new(inner.x, inner.y),
&TextStyle {
color: PROCESS_COLOR,
..Default::default()
},
);
return;
}
let procs = app.sorted_processes();
let total_mem = app.mem_total as f64;
let process_extra_data = app.analyzers.process_extra_data();
let is_exploded = inner.height > 30.0 || inner.width > 100.0;
let max_cmd_len = if is_exploded { 200 } else { 40 };
let entries: Vec<ProcessEntry> = procs
.iter()
.take(if is_exploded { 500 } else { 100 })
.map(|p| {
let pid = p.pid().as_u32();
let mem_pct = if total_mem > 0.0 {
(p.memory() as f64 / total_mem) * 100.0
} else {
0.0
};
let user = p
.user_id()
.and_then(|uid| app.users.get_user_by_id(uid))
.map(|u| u.name().to_string())
.unwrap_or_else(|| "-".to_string());
let user_short: String = user.chars().take(8).collect();
let cmd = get_process_command(p, is_exploded, max_cmd_len);
let state = convert_process_status(p.status());
let mut entry =
ProcessEntry::new(pid, &user_short, p.cpu_usage(), mem_pct as f32, &cmd)
.with_state(state);
if let Some(extra_data) = process_extra_data {
if let Some(extra) = extra_data.get(pid) {
entry = entry
.with_oom_score(extra.oom_score)
.with_cgroup(extra.cgroup_short())
.with_nice(extra.nice)
.with_threads(extra.num_threads);
}
}
entry
})
.collect();
let mut table = if is_exploded {
ProcessTable::new().with_cmdline().with_threads_column()
} else {
ProcessTable::new().compact().with_threads_column()
};
table.set_processes(entries);
table.select(app.process_selected);
table.layout(inner);
table.paint(canvas);
}
fn draw_help_overlay(canvas: &mut DirectTerminalCanvas<'_>, w: f32, h: f32) {
let popup_w = 55.0;
let popup_h = 27.0; let px = (w - popup_w) / 2.0;
let py = (h - popup_h) / 2.0;
for y in 0..popup_h as u16 {
let spaces: String = (0..popup_w as usize).map(|_| ' ').collect();
canvas.draw_text(
&spaces,
Point::new(px, py + y as f32),
&TextStyle {
color: Color::new(0.1, 0.1, 0.15, 1.0),
..Default::default()
},
);
}
let mut border = Border::new()
.with_title(" Help ")
.with_style(BorderStyle::Double)
.with_color(Color::new(0.3, 0.8, 0.9, 1.0));
border.layout(Rect::new(px, py, popup_w, popup_h));
border.paint(canvas);
let text_style = TextStyle {
color: Color::new(0.9, 0.9, 0.9, 1.0),
..Default::default()
};
let key_style = TextStyle {
color: Color::new(0.3, 0.8, 0.9, 1.0),
..Default::default()
};
let section_style = TextStyle {
color: Color::new(0.8, 0.8, 0.2, 1.0),
..Default::default()
};
let help_lines: &[(&str, &str, bool)] = &[
("", "-- General --", true),
("q, Esc, Ctrl+C", "Quit", false),
("h, ?", "Toggle help", false),
("", "-- Panel Navigation --", true),
("Tab", "Focus next panel", false),
("Shift+Tab", "Focus previous panel", false),
("hjkl", "Vim-style focus navigation", false),
("Enter, z", "Explode/zoom focused panel", false),
("", "-- Process List --", true),
("j/k, ↑/↓", "Navigate processes", false),
("PgUp/PgDn", "Page up/down", false),
("g/G", "Go to top/bottom", false),
("c/m/p", "Sort by CPU/Memory/PID", false),
("s", "Cycle sort column", false),
("r", "Reverse sort", false),
("/, f", "Filter processes", false),
("Delete", "Clear filter", false),
("", "-- Signals --", true),
("x", "SIGTERM (graceful stop)", false),
("X", "SIGKILL (force kill)", false),
("", "-- Panels --", true),
("1-5", "Toggle panels", false),
("0", "Reset panels", false),
];
for (i, (key, desc, is_section)) in help_lines.iter().enumerate() {
let y = py + 1.0 + i as f32;
if *is_section {
canvas.draw_text(desc, Point::new(px + 2.0, y), §ion_style);
} else {
canvas.draw_text(&format!("{key:>14}"), Point::new(px + 2.0, y), &key_style);
canvas.draw_text(desc, Point::new(px + 18.0, y), &text_style);
}
}
}
fn draw_signal_dialog(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32, h: f32) {
use crate::ptop::config::SignalType;
let Some((pid, ref name, signal)) = app.pending_signal else {
return;
};
let popup_w = 50.0;
let popup_h = 7.0;
let px = (w - popup_w) / 2.0;
let py = (h - popup_h) / 2.0;
for y in 0..popup_h as u16 {
let spaces: String = (0..popup_w as usize).map(|_| ' ').collect();
canvas.draw_text(
&spaces,
Point::new(px, py + y as f32),
&TextStyle {
color: Color::new(0.15, 0.1, 0.1, 1.0),
..Default::default()
},
);
}
let border_color = match signal {
SignalType::Kill => Color::new(1.0, 0.3, 0.3, 1.0), SignalType::Term => Color::new(1.0, 0.8, 0.2, 1.0), SignalType::Stop => Color::new(0.8, 0.4, 1.0, 1.0), _ => Color::new(0.3, 0.8, 0.9, 1.0), };
let mut border = Border::new()
.with_title(format!(" Send SIG{} ", signal.name()))
.with_style(BorderStyle::Double)
.with_color(border_color);
border.layout(Rect::new(px, py, popup_w, popup_h));
border.paint(canvas);
let text_style = TextStyle {
color: Color::new(0.9, 0.9, 0.9, 1.0),
..Default::default()
};
let warning_style = TextStyle {
color: border_color,
..Default::default()
};
let hint_style = TextStyle {
color: Color::new(0.6, 0.6, 0.6, 1.0),
..Default::default()
};
let max_name_len = 25;
let display_name = if name.len() > max_name_len {
format!("{}...", &name[..max_name_len - 3])
} else {
name.clone()
};
canvas.draw_text(
&format!("Process: {} (PID {})", display_name, pid),
Point::new(px + 2.0, py + 1.0),
&text_style,
);
canvas.draw_text(
&format!("Signal: {} - {}", signal.name(), signal.description()),
Point::new(px + 2.0, py + 2.0),
&warning_style,
);
canvas.draw_text("", Point::new(px + 2.0, py + 3.0), &text_style);
canvas.draw_text(
"Send signal? [Y]es / [n]o / [Esc] cancel",
Point::new(px + 2.0, py + 4.0),
&text_style,
);
canvas.draw_text(
"x=TERM K=KILL H=HUP i=INT p=STOP",
Point::new(px + 2.0, py + 5.0),
&hint_style,
);
}
fn draw_filter_overlay(app: &App, canvas: &mut DirectTerminalCanvas<'_>, w: f32, h: f32) {
let popup_w = 45.0;
let popup_h = 3.0;
let px = (w - popup_w) / 2.0;
let py = (h - popup_h) / 2.0;
let mut border = Border::new()
.with_title(" Filter Processes ")
.with_style(BorderStyle::Rounded)
.with_color(Color::new(0.3, 0.8, 0.9, 1.0));
border.layout(Rect::new(px, py, popup_w, popup_h));
border.paint(canvas);
let filter_display = format!("{}_", app.filter);
canvas.draw_text(
&filter_display,
Point::new(px + 2.0, py + 1.0),
&TextStyle {
color: Color::new(1.0, 1.0, 1.0, 1.0),
..Default::default()
},
);
}
#[derive(Debug, Default, Clone)]
pub struct GpuInfo {
pub name: String,
pub utilization: Option<u8>,
pub temperature: Option<u32>,
pub power_watts: Option<f32>,
pub vram_used: Option<u64>,
pub vram_total: Option<u64>,
}
#[cfg(target_os = "linux")]
fn try_read_nvidia_gpu() -> Option<GpuInfo> {
use std::process::Command;
let output = Command::new("nvidia-smi")
.args([
"--query-gpu=name,utilization.gpu,temperature.gpu,power.draw,memory.used,memory.total",
"--format=csv,noheader,nounits",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.lines().next()?.split(", ").collect();
if parts.len() < 6 {
return None;
}
Some(GpuInfo {
name: parts[0].trim().to_string(),
utilization: parts[1].trim().parse().ok(),
temperature: parts[2].trim().parse().ok(),
power_watts: parts[3].trim().parse().ok(),
vram_used: parts[4].trim().parse::<u64>().ok().map(|v| v * 1024 * 1024),
vram_total: parts[5].trim().parse::<u64>().ok().map(|v| v * 1024 * 1024),
})
}
#[cfg(target_os = "linux")]
fn read_amd_hwmon(hwmon_dir: &std::path::Path, card_path: &str) -> Option<GpuInfo> {
use std::fs;
let temp = fs::read_to_string(hwmon_dir.join("temp1_input"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.map(|t| t / 1000);
let power = fs::read_to_string(hwmon_dir.join("power1_average"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|p| p as f32 / 1_000_000.0);
if temp.is_none() && power.is_none() {
return None;
}
let name = fs::read_to_string(hwmon_dir.join("name"))
.ok()
.map_or_else(|| "AMD GPU".to_string(), |s| s.trim().to_string());
let vram_used = fs::read_to_string(format!("{card_path}/mem_info_vram_used"))
.ok()
.and_then(|s| s.trim().parse().ok());
let vram_total = fs::read_to_string(format!("{card_path}/mem_info_vram_total"))
.ok()
.and_then(|s| s.trim().parse().ok());
let utilization = fs::read_to_string(format!("{card_path}/gpu_busy_percent"))
.ok()
.and_then(|s| s.trim().parse().ok());
Some(GpuInfo {
name,
utilization,
temperature: temp,
power_watts: power,
vram_used,
vram_total,
})
}
#[cfg(target_os = "linux")]
fn try_read_amd_gpu() -> Option<GpuInfo> {
use std::fs;
for card in 0..4 {
let card_path = format!("/sys/class/drm/card{card}/device");
if !std::path::Path::new(&card_path).exists() {
continue;
}
let hwmon_path = format!("{card_path}/hwmon");
if let Ok(entries) = fs::read_dir(&hwmon_path) {
for entry in entries.flatten() {
if let Some(info) = read_amd_hwmon(&entry.path(), &card_path) {
return Some(info);
}
}
}
}
None
}
pub fn read_gpu_info() -> Option<GpuInfo> {
#[cfg(target_os = "linux")]
{
try_read_nvidia_gpu().or_else(try_read_amd_gpu)
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
fn format_gpu_title(gpu: Option<&GpuInfo>, detail_level: DetailLevel) -> String {
gpu.map(|g| {
if detail_level == DetailLevel::Minimal {
g.name.clone()
} else {
let temp_str = g
.temperature
.map(|t| format!(" │ {t}°C"))
.unwrap_or_default();
let power_str = g
.power_watts
.map(|p| format!(" │ {p:.0}W"))
.unwrap_or_default();
format!("{}{}{}", g.name, temp_str, power_str)
}
})
.unwrap_or_else(|| "GPU".to_string())
}
fn draw_gpu_util_bar(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: &mut f32, util: u8) {
let bar_width = (inner.width as usize).min(20);
let bar = make_bar(util as f64 / 100.0, bar_width);
canvas.draw_text(
&format!("GPU {bar} {util:>3}%"),
Point::new(inner.x, *y),
&TextStyle {
color: percent_color(util as f64),
..Default::default()
},
);
*y += 1.0;
}
fn draw_vram_bar(
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: &mut f32,
used: u64,
total: u64,
) {
if total == 0 || !can_draw_row(*y, &inner) {
return;
}
let pct = safe_pct(used, total);
let bar_width = (inner.width as usize).min(20);
let bar = make_bar(pct / 100.0, bar_width);
canvas.draw_text(
&format!(
"VRAM {bar} {}M/{}M",
used / 1024 / 1024,
total / 1024 / 1024
),
Point::new(inner.x, *y),
&TextStyle {
color: percent_color(pct),
..Default::default()
},
);
*y += 1.0;
}
fn draw_gpu_history_graphs(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
y: &mut f32,
) {
let gpu_history: Vec<f64> = app.gpu_history.as_slice().to_vec();
if !gpu_history.is_empty() {
let mut graph = BrailleGraph::new(gpu_history)
.with_color(GPU_COLOR)
.with_label("GPU History")
.with_range(0.0, 100.0);
graph.layout(Rect::new(inner.x, *y, inner.width, 6.0));
graph.paint(canvas);
*y += 7.0;
}
let vram_history: Vec<f64> = app.vram_history.as_slice().to_vec();
if !vram_history.is_empty() {
let mut graph = BrailleGraph::new(vram_history)
.with_color(VRAM_GRAPH_COLOR)
.with_label("VRAM History")
.with_range(0.0, 100.0);
graph.layout(Rect::new(inner.x, *y, inner.width, 6.0));
graph.paint(canvas);
*y += 7.0;
}
}
fn draw_gpu_procs(app: &App, canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: &mut f32) {
let Some(gpu_data) = app.analyzers.gpu_procs_data() else {
return;
};
if gpu_data.processes.is_empty() {
return;
}
*y += 1.0;
canvas.draw_text(
"TY PID SM% MEM% CMD",
Point::new(inner.x, *y),
&TextStyle {
color: HEADER_COLOR,
..Default::default()
},
);
*y += 1.0;
for proc in gpu_data.processes.iter().take(3) {
if *y >= inner.y + inner.height {
break;
}
let (type_badge, badge_color) = gpu_proc_badge(proc.proc_type.as_str());
canvas.draw_text(
type_badge,
Point::new(inner.x, *y),
&TextStyle {
color: badge_color,
..Default::default()
},
);
let sm_str = format_proc_util(proc.gpu_util());
let mem_str = format_proc_util(if proc.mem_util > 0 {
Some(proc.mem_util as f32)
} else {
None
});
let proc_info = format!(
" {:>5} {}% {}% {}",
proc.pid,
sm_str,
mem_str,
truncate_name(&proc.name, 12)
);
canvas.draw_text(
&proc_info,
Point::new(inner.x + 1.0, *y),
&TextStyle {
color: PROC_INFO_COLOR,
..Default::default()
},
);
*y += 1.0;
}
}
fn draw_gpu_temp(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: &mut f32, temp: u32) {
if *y >= inner.y + inner.height {
return;
}
canvas.draw_text(
&format!("Temp {temp}°C"),
Point::new(inner.x, *y),
&TextStyle {
color: gpu_temp_color(temp),
..Default::default()
},
);
*y += 1.0;
}
fn draw_gpu_power(canvas: &mut DirectTerminalCanvas<'_>, inner: Rect, y: &mut f32, power: f32) {
if *y >= inner.y + inner.height {
return;
}
canvas.draw_text(
&format!("Power {power:.0}W"),
Point::new(inner.x, *y),
&TextStyle {
color: POWER_COLOR,
..Default::default()
},
);
*y += 1.0;
}
fn draw_gpu_content(
app: &App,
canvas: &mut DirectTerminalCanvas<'_>,
inner: Rect,
gpu: &GpuInfo,
detail_level: DetailLevel,
) {
let mut y = inner.y;
if let Some(util) = gpu.utilization {
draw_gpu_util_bar(canvas, inner, &mut y, util);
}
if let (Some(used), Some(total)) = (gpu.vram_used, gpu.vram_total) {
draw_vram_bar(canvas, inner, &mut y, used, total);
}
if let Some(temp) = gpu.temperature {
draw_gpu_temp(canvas, inner, &mut y, temp);
}
if let Some(power) = gpu.power_watts {
draw_gpu_power(canvas, inner, &mut y, power);
}
if detail_level == DetailLevel::Exploded && y < inner.y + inner.height - 10.0 {
draw_gpu_history_graphs(app, canvas, inner, &mut y);
}
if detail_level >= DetailLevel::Expanded && y < inner.y + inner.height - 3.0 {
draw_gpu_procs(app, canvas, inner, &mut y);
}
}
fn draw_gpu_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let detail_level = DetailLevel::for_height(bounds.height as u16);
let gpu = app.gpu_info.clone();
let title = format_gpu_title(gpu.as_ref(), detail_level);
let is_focused = app.is_panel_focused(PanelType::Gpu);
let mut border = create_panel_border(&title, GPU_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
canvas.push_clip(inner);
if let Some(ref g) = gpu {
draw_gpu_content(app, canvas, inner, g, detail_level);
} else if !app.deterministic {
canvas.draw_text(
"No GPU detected or nvidia-smi not available",
Point::new(inner.x, inner.y),
&TextStyle {
color: HEADER_COLOR,
..Default::default()
},
);
}
canvas.pop_clip();
}
#[derive(Debug, Default)]
struct BatteryInfo {
capacity: u8,
status: String,
time_remaining_mins: Option<u32>,
#[allow(dead_code)]
present: bool,
}
fn read_battery_info() -> Option<BatteryInfo> {
#[cfg(target_os = "linux")]
{
use std::fs;
use std::path::Path;
for i in 0..4 {
let bat_path = format!("/sys/class/power_supply/BAT{i}");
let path = Path::new(&bat_path);
if !path.exists() {
continue;
}
let capacity = fs::read_to_string(format!("{bat_path}/capacity"))
.ok()
.and_then(|s| s.trim().parse::<u8>().ok())
.unwrap_or(0);
let status = fs::read_to_string(format!("{bat_path}/status"))
.ok()
.map_or_else(|| "Unknown".to_string(), |s| s.trim().to_string());
let time_remaining_mins = {
let energy_now = fs::read_to_string(format!("{bat_path}/energy_now"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
let power_now = fs::read_to_string(format!("{bat_path}/power_now"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
let energy_full = fs::read_to_string(format!("{bat_path}/energy_full"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
match (energy_now, power_now, energy_full, status.as_str()) {
(Some(en), Some(pn), _, "Discharging") if pn > 0 => Some((en * 60 / pn) as u32),
(Some(en), Some(pn), Some(ef), "Charging") if pn > 0 => {
let remaining = ef.saturating_sub(en);
Some((remaining * 60 / pn) as u32)
}
_ => None,
}
};
return Some(BatteryInfo {
capacity,
status,
time_remaining_mins,
present: true,
});
}
None
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
fn draw_battery_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let battery = read_battery_info();
let title = battery
.as_ref()
.map(|b| {
let time_str = b
.time_remaining_mins
.map(|m| {
if m >= 60 {
format!(" │ {}h{}m", m / 60, m % 60)
} else {
format!(" │ {m}m")
}
})
.unwrap_or_default();
format!("Battery │ {}% │ {}{}", b.capacity, b.status, time_str)
})
.unwrap_or_else(|| "Battery │ No battery".to_string());
let is_focused = app.is_panel_focused(PanelType::Battery);
let mut border = create_panel_border(&title, BATTERY_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
if let Some(bat) = battery {
let bar_width = (inner.width as usize).min(30);
let bar = make_bar(bat.capacity as f64 / 100.0, bar_width);
let color = if bat.capacity < 20 {
Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
} } else if bat.capacity < 50 {
Color {
r: 1.0,
g: 0.8,
b: 0.2,
a: 1.0,
} } else {
Color {
r: 0.3,
g: 0.9,
b: 0.3,
a: 1.0,
} };
canvas.draw_text(
&bar,
Point::new(inner.x, inner.y),
&TextStyle {
color,
..Default::default()
},
);
if inner.height >= 2.0 {
let status_icon = match bat.status.as_str() {
"Charging" => "⚡ Charging",
"Discharging" => "🔋 Discharging",
"Full" => "✓ Full",
"Not charging" => "— Idle",
_ => "? Unknown",
};
canvas.draw_text(
status_icon,
Point::new(inner.x, inner.y + 1.0),
&TextStyle {
color: Color {
r: 0.7,
g: 0.7,
b: 0.7,
a: 1.0,
},
..Default::default()
},
);
}
} else {
canvas.draw_text(
"No battery detected",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
}
fn temp_indicator_color(temp: f32) -> (&'static str, Color) {
if temp > 85.0 {
(
"✗",
Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
},
)
} else if temp > 70.0 {
(
"⚠",
Color {
r: 1.0,
g: 0.8,
b: 0.2,
a: 1.0,
},
)
} else {
(
"✓",
Color {
r: 0.3,
g: 0.9,
b: 0.3,
a: 1.0,
},
)
}
}
fn sensor_status_indicator(
status: crate::ptop::analyzers::SensorStatus,
is_fan: bool,
) -> (&'static str, Color) {
use crate::ptop::analyzers::SensorStatus;
match status {
SensorStatus::Critical | SensorStatus::Fault => (
"✗",
Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
},
),
SensorStatus::Warning | SensorStatus::Low => (
"⚠",
Color {
r: 1.0,
g: 0.8,
b: 0.2,
a: 1.0,
},
),
SensorStatus::Normal => {
if is_fan {
(
"✓",
Color {
r: 0.3,
g: 0.8,
b: 0.9,
a: 1.0,
},
)
} else {
(
"✓",
Color {
r: 0.9,
g: 0.7,
b: 0.3,
a: 1.0,
},
)
}
}
}
}
fn draw_sensor_row(canvas: &mut dyn Canvas, x: f32, y: f32, text: &str, color: Color) {
canvas.draw_text(
text,
Point::new(x, y),
&TextStyle {
color,
..Default::default()
},
);
}
fn build_sensor_extra_info(
health_data: Option<&crate::ptop::analyzers::SensorHealthData>,
) -> String {
use crate::ptop::analyzers::SensorType;
let Some(data) = health_data else {
return String::new();
};
let fan_count = data.type_counts.get(&SensorType::Fan).copied().unwrap_or(0);
let volt_count = data
.type_counts
.get(&SensorType::Voltage)
.copied()
.unwrap_or(0);
if fan_count > 0 || volt_count > 0 {
format!(" │ {fan_count}F {volt_count}V")
} else {
String::new()
}
}
fn collect_sensor_components(deterministic: bool) -> (Option<sysinfo::Components>, f32) {
use sysinfo::{Component, Components};
if deterministic {
(None, 0.0_f32)
} else {
let comps = Components::new_with_refreshed_list();
let temp = comps
.iter()
.filter_map(Component::temperature)
.fold(0.0_f32, f32::max);
(Some(comps), temp)
}
}
fn draw_temp_sensors(
canvas: &mut dyn Canvas,
comps: &sysinfo::Components,
inner: Rect,
y: &mut f32,
rows_used: &mut usize,
max_rows: usize,
) {
for component in comps {
if *rows_used >= max_rows {
break;
}
let Some(temp) = component.temperature() else {
continue;
};
let label_short: String = component.label().chars().take(12).collect();
let (indicator, color) = temp_indicator_color(temp);
let text = format!("{indicator} {label_short:<12} {temp:>5.1}°C");
draw_sensor_row(canvas, inner.x, *y, &text, color);
*y += 1.0;
*rows_used += 1;
}
}
fn draw_health_sensors(
canvas: &mut dyn Canvas,
health_data: &crate::ptop::analyzers::SensorHealthData,
inner: Rect,
y: &mut f32,
rows_used: &mut usize,
max_rows: usize,
) {
use crate::ptop::analyzers::SensorType;
for fan in health_data.fans() {
if *rows_used >= max_rows {
break;
}
let (indicator, color) = sensor_status_indicator(fan.status, true);
let text = format!(
"{indicator} {:<12} {:>5.0} RPM",
fan.short_label(),
fan.value
);
draw_sensor_row(canvas, inner.x, *y, &text, color);
*y += 1.0;
*rows_used += 1;
}
for volt in health_data.by_type(SensorType::Voltage) {
if *rows_used >= max_rows {
break;
}
let (indicator, color) = sensor_status_indicator(volt.status, false);
let text = format!(
"{indicator} {:<12} {:>6.2}V",
volt.short_label(),
volt.value
);
draw_sensor_row(canvas, inner.x, *y, &text, color);
*y += 1.0;
*rows_used += 1;
}
}
fn draw_sensors_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let (components, max_temp) = collect_sensor_components(app.deterministic);
let sensor_health_data = app.snapshot_sensor_health.as_ref();
let extra_info = build_sensor_extra_info(sensor_health_data);
let title = format!("Sensors │ {max_temp:.0}°C{extra_info}");
let is_focused = app.is_panel_focused(PanelType::Sensors);
let mut border = create_panel_border(&title, SENSORS_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let Some(ref comps) = components else {
return;
};
let mut y = inner.y;
let max_rows = inner.height as usize;
let mut rows_used = 0;
draw_temp_sensors(canvas, comps, inner, &mut y, &mut rows_used, max_rows);
if let Some(health_data) = sensor_health_data {
draw_health_sensors(canvas, health_data, inner, &mut y, &mut rows_used, max_rows);
}
if comps.is_empty() && sensor_health_data.is_none() {
draw_sensor_row(
canvas,
inner.x,
inner.y,
"No sensors detected",
Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
);
}
}
fn draw_containers_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let title = "Containers";
let is_focused = app.is_panel_focused(PanelType::Containers);
let mut border = create_panel_border(title, CONTAINERS_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
if app.deterministic {
canvas.draw_text(
"No running containers",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
return;
}
if let Some(data) = app.analyzers.containers_data() {
if data.containers.is_empty() {
canvas.draw_text(
"No running containers",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
} else {
let mut y = inner.y;
for container in data.containers.iter().take(inner.height as usize) {
let status_icon = match container.state {
ContainerState::Running => "●",
ContainerState::Paused => "◐",
ContainerState::Exited => "○",
ContainerState::Created => "◎",
ContainerState::Restarting => "↻",
ContainerState::Removing => "⊘",
ContainerState::Dead => "✗",
ContainerState::Unknown => "?",
};
let name: String = container.name.chars().take(20).collect();
let cpu = container.stats.cpu_percent;
let mem_mb = container.stats.memory_bytes / (1024 * 1024);
let text = format!("{status_icon} {name:<20} {cpu:>5.1}% {mem_mb:>4}MB");
canvas.draw_text(
&text,
Point::new(inner.x, y),
&TextStyle {
color: CONTAINERS_COLOR,
..Default::default()
},
);
y += 1.0;
}
}
} else {
canvas.draw_text(
"No container runtime",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
}
fn draw_psi_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let title = "Pressure │ —";
let is_focused = app.is_panel_focused(PanelType::Psi);
let mut border = create_panel_border(title, PSI_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
if let Some(psi) = app.psi_data() {
if psi.available {
let mut y = inner.y;
let cpu = psi.cpu.some.avg10;
let cpu_symbol = pressure_symbol(cpu);
let cpu_color = pressure_color(cpu);
canvas.draw_text(
&format!("CPU {cpu_symbol} {cpu:>5.1}%"),
Point::new(inner.x, y),
&TextStyle {
color: cpu_color,
..Default::default()
},
);
y += 1.0;
if y < inner.y + inner.height {
let mem = psi.memory.some.avg10;
let mem_symbol = pressure_symbol(mem);
let mem_color = pressure_color(mem);
canvas.draw_text(
&format!("MEM {mem_symbol} {mem:>5.1}%"),
Point::new(inner.x, y),
&TextStyle {
color: mem_color,
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
let io = psi.io.some.avg10;
let io_symbol = pressure_symbol(io);
let io_color = pressure_color(io);
canvas.draw_text(
&format!("I/O {io_symbol} {io:>5.1}%"),
Point::new(inner.x, y),
&TextStyle {
color: io_color,
..Default::default()
},
);
}
} else {
canvas.draw_text(
"PSI not available",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
} else {
canvas.draw_text(
"PSI not available",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
}
fn pressure_symbol(pct: f64) -> &'static str {
if pct > 50.0 {
"▲▲"
} else if pct > 20.0 {
"▲"
} else if pct > 5.0 {
"▼"
} else if pct > 1.0 {
"◐"
} else {
"—"
}
}
fn pressure_color(pct: f64) -> Color {
if pct > 50.0 {
Color {
r: 1.0,
g: 0.2,
b: 0.2,
a: 1.0,
} } else if pct > 20.0 {
Color {
r: 1.0,
g: 0.5,
b: 0.3,
a: 1.0,
} } else if pct > 5.0 {
Color {
r: 1.0,
g: 0.8,
b: 0.2,
a: 1.0,
} } else if pct > 1.0 {
Color {
r: 0.3,
g: 0.9,
b: 0.3,
a: 1.0,
} } else {
Color {
r: 0.4,
g: 0.4,
b: 0.4,
a: 1.0,
} }
}
fn port_to_service(port: u16) -> &'static str {
match port {
22 => "SSH",
80 => "HTTP",
443 => "HTTPS",
53 => "DNS",
25 => "SMTP",
21 => "FTP",
3306 => "MySQL",
5432 => "Pgsql",
6379 => "Redis",
27017 => "Mongo",
8080 => "HTTP",
8443 => "HTTPS",
9000..=9999 => "App",
_ => "",
}
}
fn get_connection_counts(
conn_data: Option<&crate::ptop::analyzers::ConnectionsData>,
) -> (usize, usize) {
let Some(data) = conn_data else {
return (0, 0);
};
let listen = data
.connections
.iter()
.filter(|c| c.state == TcpState::Listen)
.count();
let active = data
.connections
.iter()
.filter(|c| c.state == TcpState::Established)
.count();
(listen, active)
}
fn state_display_color(state: TcpState) -> Color {
match state {
TcpState::Established => ACTIVE_COLOR,
TcpState::Listen => LISTEN_COLOR,
_ => CONN_DIM_COLOR,
}
}
fn state_short_code(state: TcpState) -> &'static str {
match state {
TcpState::Established => "E",
TcpState::Listen => "L",
TcpState::TimeWait => "T",
TcpState::CloseWait => "C",
TcpState::SynSent => "S",
_ => "?",
}
}
fn is_local_address(addr: &std::net::IpAddr) -> bool {
match addr {
std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
std::net::IpAddr::V6(ip) => ip.is_loopback(),
}
}
fn format_remote_addr(conn: &crate::ptop::analyzers::TcpConnection) -> String {
if conn.state == TcpState::Listen {
"*".to_string()
} else {
let addr_str = format!("{}:{}", conn.remote_addr, conn.remote_port);
if addr_str.len() > 17 {
format!("{}…", &addr_str[..16])
} else {
addr_str
}
}
}
fn format_process_name(conn: &crate::ptop::analyzers::TcpConnection) -> String {
conn.process_name
.as_ref()
.map(|s| {
if s.len() > 10 {
format!("{}…", &s[..9])
} else {
s.clone()
}
})
.or_else(|| conn.pid.map(|p| p.to_string()))
.unwrap_or_else(|| "-".to_string())
}
fn hot_indicator_color(indicator: &str) -> Color {
if indicator == "●" {
Color {
r: 1.0,
g: 0.4,
b: 0.2,
a: 1.0,
}
}
else {
Color {
r: 1.0,
g: 0.7,
b: 0.3,
a: 1.0,
}
} }
fn get_geo_indicator(state: TcpState, addr: &std::net::IpAddr) -> &'static str {
if state == TcpState::Listen {
"-"
} else if is_local_address(addr) {
"L"
} else {
"R"
}
}
fn tcp_state_order(state: TcpState) -> u8 {
match state {
TcpState::Listen => 0,
TcpState::Established => 1,
_ => 2,
}
}
fn draw_connections_deterministic(canvas: &mut dyn Canvas, inner: Rect) {
let header = "SVC LOCA REMOT GE ST AGE PROC";
canvas.draw_text(
header,
Point::new(inner.x, inner.y),
&TextStyle {
color: CONNECTIONS_COLOR,
..Default::default()
},
);
}
fn draw_connections_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let (listen_count, active_count) = get_connection_counts(app.snapshot_connections.as_ref());
let connections = app.snapshot_connections.as_ref().map(|c| &c.connections);
let sparkline_str = app
.snapshot_connections
.as_ref()
.map(|conn_data| build_sparkline(&conn_data.established_sparkline(), 12))
.unwrap_or_default();
let title =
format!("Connections │ {active_count} active │ {listen_count} listen{sparkline_str}");
let is_focused = app.is_panel_focused(PanelType::Connections);
let mut border = create_panel_border(&title, CONNECTIONS_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
if app.deterministic {
draw_connections_deterministic(canvas, inner);
return;
}
let header = "SVC LOCAL REMOTE GE ST AGE PROC";
canvas.draw_text(
header,
Point::new(inner.x, inner.y),
&TextStyle {
color: CONNECTIONS_COLOR,
..Default::default()
},
);
let Some(conns) = connections else {
canvas.draw_text(
"No data",
Point::new(inner.x, inner.y + 1.0),
&TextStyle {
color: CONN_DIM_COLOR,
..Default::default()
},
);
return;
};
use std::net::{IpAddr, Ipv4Addr};
let loopback_v4: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
let mut display_conns: Vec<_> = conns
.iter()
.filter(|c| c.remote_addr != loopback_v4 || c.state == TcpState::Listen)
.collect();
display_conns.sort_by(|a, b| tcp_state_order(a.state).cmp(&tcp_state_order(b.state)));
let max_rows = (inner.height as usize).saturating_sub(1);
for (i, conn) in display_conns.iter().take(max_rows).enumerate() {
let y = inner.y + 1.0 + i as f32;
if y >= inner.y + inner.height {
break;
}
let svc = port_to_service(conn.local_port);
let local = format!(":{}", conn.local_port);
let remote = format_remote_addr(conn);
let geo = get_geo_indicator(conn.state, &conn.remote_addr);
let state_short = state_short_code(conn.state);
let proc_name = format_process_name(conn);
let state_color = state_display_color(conn.state);
let age = conn.age_display();
let (hot_indicator, _hot_level) = conn.hot_indicator();
let line = format!(
"{svc:<5} {local:<12} {remote:<17} {geo:<2} {state_short:<3} {age:<5} {proc_name}"
);
canvas.draw_text(
&line,
Point::new(inner.x, y),
&TextStyle {
color: state_color,
..Default::default()
},
);
if !hot_indicator.is_empty() {
let hot_x = inner.x + 56.0;
if hot_x < inner.x + inner.width {
canvas.draw_text(
hot_indicator,
Point::new(hot_x, y),
&TextStyle {
color: hot_indicator_color(hot_indicator),
..Default::default()
},
);
}
}
}
if display_conns.is_empty() && inner.height > 1.0 {
canvas.draw_text(
"No active connections",
Point::new(inner.x, inner.y + 1.0),
&TextStyle {
color: CONN_DIM_COLOR,
..Default::default()
},
);
}
}
fn sensor_type_char(label: &str) -> char {
if label.contains("CPU") || label.contains("Core") {
'C'
} else if label.contains("GPU") {
'G'
} else if label.contains("nvme") || label.contains("SSD") || label.contains("HDD") {
'D'
} else if label.contains("fan") || label.contains("Fan") {
'F'
} else {
'M'
}
}
fn sensor_temp_display_color(temp: f32) -> Color {
if temp > 85.0 {
Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
}
} else if temp > 70.0 {
Color {
r: 1.0,
g: 0.8,
b: 0.2,
a: 1.0,
}
} else {
Color {
r: 0.3,
g: 0.9,
b: 0.3,
a: 1.0,
}
}
}
fn build_temp_bar(temp: f32) -> String {
let pct = (temp / 100.0).clamp(0.0, 1.0);
let filled = (pct * 4.0).round() as usize;
(0..4).map(|i| if i < filled { '▄' } else { '░' }).collect()
}
fn draw_sensor_compact_row(canvas: &mut dyn Canvas, x: f32, y: f32, label: &str, temp: f32) {
let type_char = sensor_type_char(label);
let bar = build_temp_bar(temp);
let label_short: String = label.chars().take(8).collect();
let text = format!("{type_char} {bar} {temp:>4.0}°C {label_short}");
let color = sensor_temp_display_color(temp);
canvas.draw_text(
&text,
Point::new(x, y),
&TextStyle {
color,
..Default::default()
},
);
}
fn draw_sensors_compact_panel(_app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
use sysinfo::{Component, Components};
let components = Components::new_with_refreshed_list();
let max_temp = components
.iter()
.filter_map(Component::temperature)
.fold(0.0_f32, f32::max);
let title = format!("Sensors │ {max_temp:.0}°C");
let mut border = Border::new()
.with_title(&title)
.with_style(BorderStyle::Rounded)
.with_color(SENSORS_COLOR)
.with_title_left_aligned();
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
for component in components.iter().take(inner.height as usize) {
let label = component.label();
let Some(temp) = component.temperature() else {
continue;
};
draw_sensor_compact_row(canvas, inner.x, y, label, temp);
y += 1.0;
}
if components.is_empty() {
canvas.draw_text(
"No sensors",
Point::new(inner.x, inner.y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
}
fn draw_system_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let title = "System";
let mut border = Border::new()
.with_title(title)
.with_style(BorderStyle::Rounded)
.with_color(Color {
r: 0.5,
g: 0.7,
b: 0.9,
a: 1.0,
})
.with_title_left_aligned();
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
if !app.hostname.is_empty() {
canvas.draw_text(
&format!("Host: {}", app.hostname),
Point::new(inner.x, y),
&TextStyle {
color: Color {
r: 0.7,
g: 0.9,
b: 1.0,
a: 1.0,
},
..Default::default()
},
);
y += 1.0;
}
if !app.kernel_version.is_empty() && y < inner.y + inner.height {
canvas.draw_text(
&app.kernel_version,
Point::new(inner.x, y),
&TextStyle {
color: Color {
r: 0.6,
g: 0.8,
b: 0.9,
a: 1.0,
},
..Default::default()
},
);
y += 1.0;
}
if y < inner.y + inner.height {
let container_text = if app.in_container {
"Container: Yes"
} else {
"Container: No"
};
canvas.draw_text(
container_text,
Point::new(inner.x, y),
&TextStyle {
color: Color {
r: 0.5,
g: 0.7,
b: 0.5,
a: 1.0,
},
..Default::default()
},
);
}
}
fn mount_point_style(mount: &str) -> (&str, Color) {
if mount == "/" {
("/", Color::new(0.8, 0.5, 0.3, 1.0)) } else if mount.contains("nvme") || mount.starts_with("/dev/nvme") {
("nvme", Color::new(0.3, 0.8, 0.3, 1.0)) } else if mount.contains("/home") {
("home", Color::new(0.5, 0.5, 0.9, 1.0)) } else if mount.contains("/tmp") || mount.contains("/var/tmp") {
("tmp", Color::new(0.9, 0.9, 0.3, 1.0)) } else if mount.contains("/boot") {
("boot", Color::new(0.9, 0.3, 0.3, 1.0)) } else {
("other", Color::new(0.6, 0.6, 0.6, 1.0)) }
}
fn mount_short_name(mount: &str) -> &str {
let name = mount.split('/').next_back().unwrap_or("disk");
if name.len() > 6 {
&name[..6]
} else {
name
}
}
fn build_disk_node(disk: &sysinfo::Disk) -> Option<TreemapNode> {
let mount = disk.mount_point().to_string_lossy();
let used = disk.total_space() - disk.available_space();
let total = disk.total_space();
if total == 0 {
return None;
}
let (known_name, color) = mount_point_style(&mount);
let short_name = if known_name == "other" {
mount_short_name(&mount)
} else {
known_name
};
let used_pct = (used as f64 / total as f64) * 100.0;
let used_color = percent_color(used_pct);
let free_color = Color::new(0.2, 0.3, 0.2, 1.0);
let children = vec![
TreemapNode::leaf_colored("used", used as f64, used_color),
TreemapNode::leaf_colored("free", disk.available_space() as f64, free_color),
];
let mut node = TreemapNode::branch(short_name, children);
node.color = Some(color);
Some(node)
}
fn draw_treemap_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
let (total_used, total_space): (u64, u64) = app
.disks
.iter()
.map(|d| (d.total_space() - d.available_space(), d.total_space()))
.fold((0, 0), |(au, at), (u, t)| (au + u, at + t));
let disk_count = app.disks.iter().count();
let title = format!(
"Treemap │ {} disk{} │ {:.0}G / {:.0}G",
disk_count,
if disk_count == 1 { "" } else { "s" },
total_used as f64 / 1024.0 / 1024.0 / 1024.0,
total_space as f64 / 1024.0 / 1024.0 / 1024.0,
);
let mut border = Border::new()
.with_title(&title)
.with_style(BorderStyle::Rounded)
.with_color(FILES_COLOR)
.with_title_left_aligned();
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 2.0 || inner.width < 4.0 {
return;
}
let disk_nodes: Vec<TreemapNode> = app.disks.iter().filter_map(build_disk_node).collect();
if disk_nodes.is_empty() {
canvas.draw_text(
"No disks found",
Point::new(inner.x + 1.0, inner.y),
&TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
},
);
return;
}
let root = TreemapNode::branch("Disks", disk_nodes);
let mut treemap = Treemap::new()
.with_root(root)
.with_max_depth(2)
.with_labels(inner.width >= 8.0);
treemap.layout(inner);
treemap.paint(canvas);
}
struct FilesDisplayItem {
name: String,
size: u64,
is_dir: bool,
ratio: f64,
}
fn build_file_items_from_analyzer(
fd: &crate::ptop::analyzers::FileAnalyzerData,
max_rows: usize,
) -> Vec<FilesDisplayItem> {
let max_size = fd
.hot_files
.iter()
.map(|f| f.size)
.max()
.unwrap_or(1)
.max(1);
fd.hot_files
.iter()
.take(max_rows)
.map(|f| FilesDisplayItem {
name: f
.path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
size: f.size,
is_dir: f.path.is_dir(),
ratio: (f.size as f64 / max_size as f64).min(1.0),
})
.collect()
}
fn build_file_items_from_treemap(
td: &crate::ptop::analyzers::TreemapData,
max_rows: usize,
) -> Vec<FilesDisplayItem> {
let max_size = td.top_items.first().map_or(1, |i| i.size).max(1);
td.top_items
.iter()
.take(max_rows)
.map(|i| FilesDisplayItem {
name: i.name.clone(),
size: i.size,
is_dir: i.is_dir,
ratio: (i.size as f64 / max_size as f64).min(1.0),
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn draw_file_row(
canvas: &mut dyn Canvas,
x: f32,
y: f32,
item: &FilesDisplayItem,
name_width: usize,
bar_width: usize,
file_color: Color,
dir_color: Color,
dim_color: Color,
) {
use crate::widgets::display_rules::{format_column, ColumnAlign, TruncateStrategy};
let item_color = if item.is_dir { dir_color } else { file_color };
let name = format_column(
&item.name,
name_width,
ColumnAlign::Left,
TruncateStrategy::Path,
);
let size_str = format_column(
&format_bytes(item.size),
7,
ColumnAlign::Right,
TruncateStrategy::End,
);
let bar = make_bar(item.ratio, bar_width);
canvas.draw_text(
&name,
Point::new(x, y),
&TextStyle {
color: item_color,
..Default::default()
},
);
canvas.draw_text(
&size_str,
Point::new(x + name_width as f32, y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
let bar_color = Color::new(
0.4 + 0.4 * item.ratio as f32,
0.6 - 0.3 * item.ratio as f32,
0.3,
1.0,
);
canvas.draw_text(
&format!(" {}", bar),
Point::new(x + name_width as f32 + 7.0, y),
&TextStyle {
color: bar_color,
..Default::default()
},
);
}
#[inline]
fn compute_files_totals(app: &App) -> (u64, u32) {
if let Some(t) = app.snapshot_treemap.as_ref() {
(t.total_size, t.total_files)
} else {
let total_size = app.disks.iter().map(sysinfo::Disk::total_space).sum();
let file_count = app
.snapshot_file_analyzer
.as_ref()
.map_or(0, |f| f.total_open_files as u32);
(total_size, file_count)
}
}
fn build_files_items(
file_data: Option<&crate::ptop::analyzers::FileAnalyzerData>,
treemap_data: Option<&crate::ptop::analyzers::TreemapData>,
max_rows: usize,
) -> Vec<FilesDisplayItem> {
file_data
.map(|fd| build_file_items_from_analyzer(fd, max_rows))
.or_else(|| treemap_data.map(|td| build_file_items_from_treemap(td, max_rows)))
.unwrap_or_default()
}
#[inline]
fn files_empty_message(has_file_data: bool, has_treemap_data: bool) -> &'static str {
if !has_file_data && !has_treemap_data {
"Scanning filesystem..."
} else {
"No files found"
}
}
fn draw_files_panel(app: &App, canvas: &mut DirectTerminalCanvas<'_>, bounds: Rect) {
use crate::widgets::display_rules::{format_column, ColumnAlign, TruncateStrategy};
let file_data = app.snapshot_file_analyzer.as_ref();
let treemap_data = app.snapshot_treemap.as_ref();
let disk_entropy = app.snapshot_disk_entropy.as_ref();
let (total_size, file_count) = compute_files_totals(app);
let encryption_indicator = if disk_entropy.map_or(0, |d| d.encrypted_count) > 0 {
"🔒"
} else {
""
};
let title = format!(
"Files │ {} {} │ {} files",
format_bytes(total_size),
encryption_indicator,
file_count
);
let is_focused = app.is_panel_focused(PanelType::Files);
let mut border = create_panel_border(&title, FILES_COLOR, is_focused);
border.layout(bounds);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let dim_color = Color::new(0.5, 0.5, 0.5, 1.0);
let file_color = Color::new(0.7, 0.7, 0.5, 1.0);
let dir_color = Color::new(0.5, 0.7, 0.9, 1.0);
let bg_color = Color::new(0.05, 0.05, 0.07, 1.0);
canvas.fill_rect(inner, bg_color);
if app.deterministic {
canvas.draw_text(
"...",
Point::new(inner.x, inner.y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
return;
}
let width = inner.width as usize;
let bar_width = 12.min(width.saturating_sub(20));
let name_width = width.saturating_sub(bar_width + 10);
let header = format!(
"{}{} {}",
format_column("NAME", name_width, ColumnAlign::Left, TruncateStrategy::End),
format_column("SIZE", 7, ColumnAlign::Right, TruncateStrategy::End),
format_column("%", bar_width, ColumnAlign::Left, TruncateStrategy::End)
);
canvas.draw_text(
&header,
Point::new(inner.x, inner.y),
&TextStyle {
color: FILES_COLOR,
..Default::default()
},
);
let max_rows = inner.height as usize;
let items = build_files_items(file_data, treemap_data, max_rows);
if items.is_empty() {
let msg = files_empty_message(file_data.is_some(), treemap_data.is_some());
canvas.draw_text(
msg,
Point::new(inner.x, inner.y + 1.0),
&TextStyle {
color: dim_color,
..Default::default()
},
);
return;
}
for (i, item) in items
.iter()
.take((inner.height as usize).saturating_sub(1))
.enumerate()
{
let y = inner.y + 1.0 + i as f32;
if y >= inner.y + inner.height {
break;
}
draw_file_row(
canvas, inner.x, y, item, name_width, bar_width, file_color, dir_color, dim_color,
);
}
}
#[allow(clippy::too_many_lines)]
fn dataframe_header_style(
is_sorted: bool,
is_selected: bool,
sort_color: Color,
dim_color: Color,
) -> TextStyle {
if is_sorted {
TextStyle {
color: sort_color,
..Default::default()
}
} else if is_selected {
TextStyle {
color: Color::WHITE,
..Default::default()
}
} else {
TextStyle {
color: dim_color,
..Default::default()
}
}
}
fn mem_display_color(mem_pct: f32, is_selected: bool, text_color: Color) -> Color {
if mem_pct > 10.0 {
Color::new(0.7, 0.5, 0.9, 1.0)
} else if is_selected {
Color::WHITE
} else {
text_color
}
}
fn draw_process_row_bg(
canvas: &mut dyn Canvas,
x: f32,
y: f32,
width: f32,
is_selected: bool,
selected_bg: Color,
) {
if is_selected {
canvas.fill_rect(Rect::new(x, y, width, 1.0), selected_bg);
canvas.draw_text(
"▶",
Point::new(x - 1.5, y),
&TextStyle {
color: FOCUS_ACCENT_COLOR,
..Default::default()
},
);
} else {
canvas.fill_rect(
Rect::new(x, y, width, 1.0),
Color::new(0.05, 0.05, 0.07, 1.0),
);
}
}
fn draw_process_dataframe(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::ptop::app::ProcessSortColumn;
use crate::widgets::display_rules::{
format_column, format_percent, ColumnAlign, TruncateStrategy,
};
use crate::HeatScheme;
let col_widths = [7usize, 10, 8, 8];
let cmd_width = (area.width as usize).saturating_sub(col_widths.iter().sum::<usize>() + 5);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
let selected_col_bg = COL_SELECT_BG;
let selected_row_bg = ROW_SELECT_BG;
let sort_color = FOCUS_ACCENT_COLOR;
let dim_color = Color::new(0.5, 0.5, 0.5, 1.0);
let text_color = Color::new(0.9, 0.9, 0.9, 1.0);
let mut y = area.y;
let x = area.x;
let columns = [
(ProcessSortColumn::Pid, "PID", col_widths[0]),
(ProcessSortColumn::User, "USER", col_widths[1]),
(ProcessSortColumn::Cpu, "CPU%", col_widths[2]),
(ProcessSortColumn::Mem, "MEM%", col_widths[3]),
(ProcessSortColumn::Command, "COMMAND", cmd_width),
];
canvas.fill_rect(Rect::new(x, y, area.width, 1.0), header_bg);
let mut col_x = x;
let valid_selected = app.selected_column.min(columns.len().saturating_sub(1));
for (i, (col, label, width)) in columns.iter().enumerate() {
let is_selected = valid_selected == i;
let is_sorted = app.sort_column == *col;
if is_selected {
canvas.fill_rect(Rect::new(col_x, y, *width as f32, 1.0), selected_col_bg);
}
let header_raw = if is_sorted {
format!("{}{}", label, if app.sort_descending { "▼" } else { "▲" })
} else {
(*label).to_string()
};
let header_text = format_column(
&header_raw,
*width,
ColumnAlign::Left,
TruncateStrategy::End,
);
let style = dataframe_header_style(is_sorted, is_selected, sort_color, dim_color);
canvas.draw_text(&header_text, Point::new(col_x, y), &style);
col_x += *width as f32 + 1.0;
}
y += 1.0;
canvas.draw_text(
&"─".repeat((area.width as usize).min(200)),
Point::new(x, y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
y += 1.0;
let mut processes: Vec<_> = app
.system
.processes()
.iter()
.filter(|(_, p)| {
let matches_filter = app.filter.is_empty()
|| p.name()
.to_string_lossy()
.to_lowercase()
.contains(&app.filter.to_lowercase());
matches_filter && (p.cpu_usage() > 0.001 || p.memory() > 1024 * 1024)
})
.collect();
use crate::ptop::ui::panels::process::sort_processes;
sort_processes(&mut processes, app.sort_column, app.sort_descending);
let visible_rows = (area.height as usize).saturating_sub(2);
let scroll_offset = app
.process_scroll_offset
.min(processes.len().saturating_sub(visible_rows));
for (rel_idx, (pid, proc)) in processes
.iter()
.skip(scroll_offset)
.take(visible_rows)
.enumerate()
{
let abs_idx = scroll_offset + rel_idx;
let is_selected = abs_idx == app.process_selected;
draw_process_row_bg(canvas, x, y, area.width, is_selected, selected_row_bg);
let row_style = if is_selected {
TextStyle {
color: Color::WHITE,
..Default::default()
}
} else {
TextStyle {
color: text_color,
..Default::default()
}
};
let mut col_x = x;
let pid_str = format_column(
&pid.as_u32().to_string(),
col_widths[0],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(&pid_str, Point::new(col_x, y), &row_style);
col_x += col_widths[0] as f32 + 1.0;
let user_raw = proc
.user_id()
.and_then(|uid| app.users.get_user_by_id(uid))
.map(|u| u.name().to_string())
.unwrap_or_else(|| "-".to_string());
let user = format_column(
&user_raw,
col_widths[1],
ColumnAlign::Left,
TruncateStrategy::End,
);
canvas.draw_text(&user, Point::new(col_x, y), &row_style);
col_x += col_widths[1] as f32 + 1.0;
let cpu = proc.cpu_usage();
let cpu_color = if is_selected {
Color::WHITE
} else {
HeatScheme::Thermal.color_for_percent(cpu as f64)
};
let cpu_str = format_column(
&format_percent(cpu),
col_widths[2],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&cpu_str,
Point::new(col_x, y),
&TextStyle {
color: cpu_color,
..Default::default()
},
);
col_x += col_widths[2] as f32 + 1.0;
let mem_pct = (proc.memory() as f64 / app.mem_total as f64 * 100.0) as f32;
let mem_color = mem_display_color(mem_pct, is_selected, text_color);
let mem_str = format_column(
&format_percent(mem_pct),
col_widths[3],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&mem_str,
Point::new(col_x, y),
&TextStyle {
color: mem_color,
..Default::default()
},
);
col_x += col_widths[3] as f32 + 1.0;
let cmd_parts = proc.cmd();
let cmd_full = if cmd_parts.is_empty() {
proc.name().to_string_lossy().to_string()
} else {
cmd_parts
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join(" ")
};
let cmd_display = format_column(
&cmd_full,
cmd_width,
ColumnAlign::Left,
TruncateStrategy::Command,
);
canvas.draw_text(&cmd_display, Point::new(col_x, y), &row_style);
y += 1.0;
}
if processes.len() > visible_rows {
let scroll_pct = scroll_offset as f32 / (processes.len() - visible_rows) as f32;
let bar_y =
area.y + 2.0 + (scroll_pct * (visible_rows - 1) as f32).min((visible_rows - 1) as f32);
canvas.draw_text(
"█",
Point::new(area.x + area.width - 1.0, bar_y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
}
}
#[allow(clippy::too_many_lines)]
fn draw_core_stats_dataframe(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{
format_column, format_freq_mhz, format_percent, ColumnAlign, TruncateStrategy,
};
use crate::widgets::selection::RowHighlight;
use crate::{HeatScheme, MicroHeatBar};
let col_widths = [4usize, 6, 5, 5, 5, 4, 5];
let breakdown_width =
(area.width as usize).saturating_sub(col_widths.iter().sum::<usize>() + 8);
let header_bg = Color::new(0.15, 0.2, 0.3, 1.0);
let dim_color = Color::new(0.5, 0.5, 0.5, 1.0);
let text_color = Color::new(0.9, 0.9, 0.9, 1.0);
let user_color = Color::new(0.3, 0.7, 0.3, 1.0); let sys_color = Color::new(0.9, 0.5, 0.2, 1.0); let io_color = Color::new(0.9, 0.2, 0.2, 1.0);
let selected_row = if app.exploded_panel == Some(PanelType::Cpu) {
app.process_selected
} else {
usize::MAX };
let mut y = area.y;
let x = area.x;
let headers = [
"CORE",
"FREQ",
"TEMP",
"USR%",
"SYS%",
"IO%",
"IDL%",
"BREAKDOWN",
];
canvas.fill_rect(Rect::new(x, y, area.width, 1.0), header_bg);
let mut col_x = x;
for (i, header) in headers.iter().enumerate() {
let width = if i < col_widths.len() {
col_widths[i]
} else {
breakdown_width
};
let text = format_column(header, width, ColumnAlign::Left, TruncateStrategy::End);
canvas.draw_text(
&text,
Point::new(col_x, y),
&TextStyle {
color: CPU_COLOR,
..Default::default()
},
);
col_x += width as f32 + 1.0;
}
y += 1.0;
let sep_width = (area.width as usize).min(100);
let sep = "─".repeat(sep_width);
canvas.draw_text(
&sep,
Point::new(x, y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
y += 1.0;
let visible_rows = (area.height as usize).saturating_sub(2);
let core_count = app.per_core_percent.len();
for i in 0..core_count.min(visible_rows) {
let is_selected = i == selected_row;
let row_rect = Rect::new(x, y, area.width, 1.0);
let row_highlight = RowHighlight::new(row_rect, is_selected).with_gutter(is_selected);
row_highlight.paint(canvas);
let _row_style = row_highlight.text_style();
let mut col_x = x;
let total = app.per_core_percent.get(i).copied().unwrap_or(0.0) as f32;
let user_pct = total * 0.7;
let sys_pct = total * 0.25;
let io_pct = total * 0.05;
let idle_pct = 100.0 - total;
let freq = app.per_core_freq.get(i).copied().unwrap_or(0);
let temp = app
.per_core_temp
.get(i)
.copied()
.filter(|&t| t > 0.0)
.or_else(|| {
app.snapshot_sensor_health.as_ref().and_then(|data| {
data.temperatures()
.find(|s| s.label == format!("Core {i}"))
.map(|s| s.value as f32)
})
});
let core_str = format_column(
&i.to_string(),
col_widths[0],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&core_str,
Point::new(col_x, y),
&TextStyle {
color: text_color,
..Default::default()
},
);
col_x += col_widths[0] as f32 + 1.0;
let freq_str = format_column(
&format_freq_mhz(freq),
col_widths[1],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&freq_str,
Point::new(col_x, y),
&TextStyle {
color: text_color,
..Default::default()
},
);
col_x += col_widths[1] as f32 + 1.0;
let temp_str = temp.map_or("-".to_string(), |t| format!("{t:.0}°"));
let temp_str = format_column(
&temp_str,
col_widths[2],
ColumnAlign::Right,
TruncateStrategy::End,
);
let temp_color = temp.map_or(dim_color, |t| {
let temp_pct = ((t - 30.0) / 70.0 * 100.0).clamp(0.0, 100.0);
HeatScheme::Warm.color_for_percent(temp_pct as f64)
});
canvas.draw_text(
&temp_str,
Point::new(col_x, y),
&TextStyle {
color: temp_color,
..Default::default()
},
);
col_x += col_widths[2] as f32 + 1.0;
let usr_str = format_column(
&format_percent(user_pct),
col_widths[3],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&usr_str,
Point::new(col_x, y),
&TextStyle {
color: user_color,
..Default::default()
},
);
col_x += col_widths[3] as f32 + 1.0;
let sys_str = format_column(
&format_percent(sys_pct),
col_widths[4],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&sys_str,
Point::new(col_x, y),
&TextStyle {
color: sys_color,
..Default::default()
},
);
col_x += col_widths[4] as f32 + 1.0;
let io_str = format_column(
&format_percent(io_pct),
col_widths[5],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&io_str,
Point::new(col_x, y),
&TextStyle {
color: io_color,
..Default::default()
},
);
col_x += col_widths[5] as f32 + 1.0;
let idl_str = format_column(
&format_percent(idle_pct),
col_widths[6],
ColumnAlign::Right,
TruncateStrategy::End,
);
canvas.draw_text(
&idl_str,
Point::new(col_x, y),
&TextStyle {
color: dim_color,
..Default::default()
},
);
col_x += col_widths[6] as f32 + 1.0;
if breakdown_width > 3 {
let bar_width = breakdown_width.saturating_sub(1);
let bar = MicroHeatBar::new(&[
user_pct as f64,
sys_pct as f64,
io_pct as f64,
idle_pct as f64,
])
.with_width(bar_width)
.with_scheme(HeatScheme::Thermal);
bar.paint(canvas, Point::new(col_x, y));
}
y += 1.0;
}
}
#[allow(clippy::too_many_lines)]
fn draw_cpu_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::{CoreUtilizationHistogram, SystemStatus, TrendSparkline};
let cpu_pct = app.cpu_history.last().copied().unwrap_or(0.0) * 100.0;
let core_count = app.per_core_percent.len();
let uptime = app.uptime();
let load = &app.load_avg;
let max_freq_mhz = app.per_core_freq.iter().copied().max().unwrap_or(0);
let is_boosting = max_freq_mhz > 3000;
let freq_ghz = max_freq_mhz as f64 / 1000.0;
let title = build_cpu_title(
cpu_pct,
core_count,
freq_ghz,
is_boosting,
uptime,
load.one,
app.deterministic,
);
let is_focused = app.is_panel_focused(PanelType::Cpu);
let mut border = create_panel_border(&title, CPU_COLOR, is_focused);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 15.0 || inner.width < 60.0 {
draw_cpu_panel(app, canvas, area);
return;
}
let left_width = (inner.width * 0.58).floor();
let right_width = inner.width - left_width - 1.0;
let bottom_height = 9.0; let top_height = (inner.height - bottom_height - 1.0).max(10.0);
let proc_rect = Rect::new(inner.x, inner.y, left_width, top_height);
draw_process_dataframe(app, canvas, proc_rect);
let core_rect = Rect::new(inner.x + left_width + 1.0, inner.y, right_width, top_height);
draw_core_stats_dataframe(app, canvas, core_rect);
let bottom_y = inner.y + top_height + 1.0;
let hist_width = inner.width * 0.45;
let histogram_rect = Rect::new(inner.x, bottom_y, hist_width, 6.0);
let mut histogram = CoreUtilizationHistogram::new(app.per_core_percent.to_vec());
histogram.layout(histogram_rect);
histogram.paint(canvas);
let trend_width = inner.width * 0.30;
let trend_rect = Rect::new(inner.x + hist_width + 1.0, bottom_y, trend_width, 5.0);
let history: Vec<f64> = app
.cpu_history
.as_slice()
.iter()
.map(|&v| v * 100.0)
.collect();
let mut trend = TrendSparkline::new("60s TREND", history);
trend.layout(trend_rect);
trend.paint(canvas);
let status_width = inner.width - hist_width - trend_width - 2.0;
let status_rect = Rect::new(
inner.x + hist_width + trend_width + 2.0,
bottom_y,
status_width,
5.0,
);
let mut status = SystemStatus::new(load.one, load.five, load.fifteen, core_count);
if let Some(sensor_data) = app.snapshot_sensor_health.as_ref() {
let temps: Vec<f64> = sensor_data
.temperatures()
.filter(|s| s.label.starts_with("Core "))
.map(|s| s.value)
.collect();
if !temps.is_empty() {
let avg_temp = temps.iter().sum::<f64>() / temps.len() as f64;
let max_temp = temps.iter().copied().fold(0.0_f64, f64::max);
status = status.with_thermal(avg_temp, max_temp);
}
}
status.layout(status_rect);
status.paint(canvas);
}
#[allow(clippy::too_many_lines)]
fn draw_memory_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{
format_bytes_si, format_column, format_percent, ColumnAlign, TruncateStrategy,
};
use crate::widgets::selection::RowHighlight;
use crate::HeatScheme;
let gb = |b: u64| b as f64 / 1024.0 / 1024.0 / 1024.0;
let mem_pct = if app.mem_total > 0 {
(app.mem_used as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let title = format!(
"Memory │ {:.1}G / {:.1}G ({:.0}%) │ Swap: {:.1}G / {:.1}G │ Cached: {:.1}G │ [FULLSCREEN]",
gb(app.mem_used),
gb(app.mem_total),
mem_pct,
gb(app.swap_used),
gb(app.swap_total),
gb(app.mem_cached),
);
let is_focused = app.is_panel_focused(PanelType::Memory);
let mut border = create_panel_border(&title, MEMORY_COLOR, is_focused);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 10.0 || inner.width < 40.0 {
draw_memory_panel(app, canvas, area);
return;
}
let breakdown_height = (inner.height * 0.25).max(6.0).min(12.0);
let process_height = inner.height - breakdown_height - 1.0;
let mut y = inner.y;
let bar_width = inner.width as usize;
let used_pct = if app.mem_total > 0 {
((app.mem_total - app.mem_available) as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let cached_pct = if app.mem_total > 0 {
(app.mem_cached as f64 / app.mem_total as f64) * 100.0
} else {
0.0
};
let free_pct = 100.0 - used_pct;
let used_chars = ((used_pct / 100.0) * bar_width as f64) as usize;
let cached_chars = ((cached_pct / 100.0) * bar_width as f64) as usize;
let free_chars = bar_width.saturating_sub(used_chars + cached_chars);
let used_color = HeatScheme::Warm.color_for_percent(used_pct);
if used_chars > 0 {
canvas.draw_text(
&"█".repeat(used_chars),
Point::new(inner.x, y),
&TextStyle {
color: used_color,
..Default::default()
},
);
}
if cached_chars > 0 {
canvas.draw_text(
&"▓".repeat(cached_chars),
Point::new(inner.x + used_chars as f32, y),
&TextStyle {
color: CACHED_COLOR,
..Default::default()
},
);
}
if free_chars > 0 {
canvas.draw_text(
&"░".repeat(free_chars),
Point::new(inner.x + used_chars as f32 + cached_chars as f32, y),
&TextStyle {
color: Color::new(0.3, 0.3, 0.3, 1.0),
..Default::default()
},
);
}
y += 1.0;
let col_width = (inner.width / 4.0).floor() as usize;
let segments = [
(
"Used",
app.mem_used,
used_pct,
HeatScheme::Warm.color_for_percent(used_pct),
),
("Cached", app.mem_cached, cached_pct, CACHED_COLOR),
("Available", app.mem_available, free_pct, FREE_COLOR),
(
"Swap",
app.swap_used,
if app.swap_total > 0 {
(app.swap_used as f64 / app.swap_total as f64) * 100.0
} else {
0.0
},
swap_color(if app.swap_total > 0 {
(app.swap_used as f64 / app.swap_total as f64) * 100.0
} else {
0.0
}),
),
];
let dim_style = TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
};
for (i, (label, bytes, pct, color)) in segments.iter().enumerate() {
let x = inner.x + (i as f32 * col_width as f32);
let text = format!("{}: {} ({:.0}%)", label, format_bytes_si(*bytes), pct);
canvas.draw_text(
&format_column(
&text,
col_width.saturating_sub(1),
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(x, y),
&TextStyle {
color: *color,
..Default::default()
},
);
}
y += 1.0;
canvas.draw_text(
&"─".repeat(inner.width as usize),
Point::new(inner.x, y),
&dim_style,
);
y += 1.0;
let process_y = y;
let process_rect = Rect::new(inner.x, process_y, inner.width, process_height);
let col_pid = 8;
let col_user = 10;
let col_mem_pct = 8;
let col_mem_bytes = 10;
let col_rss = 10;
let col_virt = 10;
let col_cmd = (inner.width as usize)
.saturating_sub(col_pid + col_user + col_mem_pct + col_mem_bytes + col_rss + col_virt + 10);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(
Rect::new(process_rect.x, process_rect.y, process_rect.width, 1.0),
header_bg,
);
let headers = ["PID", "USER", "MEM%", "MEM", "RSS", "VIRT", "COMMAND"];
let widths = [
col_pid,
col_user,
col_mem_pct,
col_mem_bytes,
col_rss,
col_virt,
col_cmd,
];
let mut hx = process_rect.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, process_rect.y),
&TextStyle {
color: MEMORY_COLOR,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
canvas.draw_text(
&"─".repeat(process_rect.width as usize),
Point::new(process_rect.x, process_rect.y + 1.0),
&dim_style,
);
let mut processes: Vec<_> = app.system.processes().iter().collect();
processes.sort_by(|a, b| b.1.memory().cmp(&a.1.memory()));
let visible_rows = (process_rect.height as usize).saturating_sub(2);
let mut row_y = process_rect.y + 2.0;
for (idx, (pid, proc)) in processes.iter().take(visible_rows).enumerate() {
let is_selected = idx == app.process_selected;
let row_bounds = Rect::new(process_rect.x, row_y, process_rect.width, 1.0);
let highlight = RowHighlight::new(row_bounds, is_selected);
highlight.paint(canvas);
let text_style = highlight.text_style();
let mut col_x = process_rect.x;
canvas.draw_text(
&format_column(
&pid.as_u32().to_string(),
col_pid,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, row_y),
&text_style,
);
col_x += col_pid as f32 + 1.0;
let user = proc
.user_id()
.and_then(|uid| app.users.get_user_by_id(uid))
.map(|u| u.name().to_string())
.unwrap_or_else(|| "-".to_string());
canvas.draw_text(
&format_column(&user, col_user, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, row_y),
&text_style,
);
col_x += col_user as f32 + 1.0;
let mem_pct = (proc.memory() as f64 / app.mem_total as f64) * 100.0;
let mem_color = if is_selected {
Color::WHITE
} else {
HeatScheme::Cool.color_for_percent(mem_pct * 10.0) };
canvas.draw_text(
&format_column(
&format_percent(mem_pct as f32),
col_mem_pct,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, row_y),
&TextStyle {
color: mem_color,
..Default::default()
},
);
col_x += col_mem_pct as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(proc.memory()),
col_mem_bytes,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, row_y),
&text_style,
);
col_x += col_mem_bytes as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(proc.memory()),
col_rss,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, row_y),
&text_style,
);
col_x += col_rss as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(proc.virtual_memory()),
col_virt,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, row_y),
&text_style,
);
col_x += col_virt as f32 + 1.0;
let cmd = proc.name().to_string_lossy().to_string();
canvas.draw_text(
&format_column(&cmd, col_cmd, ColumnAlign::Left, TruncateStrategy::Command),
Point::new(col_x, row_y),
&text_style,
);
row_y += 1.0;
}
}
fn io_rate_color(rate: f64, is_read: bool) -> Color {
if rate > 10_000_000.0 {
if is_read {
Color::new(0.3, 0.9, 0.5, 1.0)
} else {
Color::new(0.9, 0.6, 0.3, 1.0)
}
} else {
Color::new(0.7, 0.7, 0.7, 1.0)
}
}
fn draw_disk_io_section(
canvas: &mut dyn Canvas,
io: &crate::ptop::analyzers::DiskIoData,
inner: Rect,
mut y: f32,
io_section_height: usize,
header_bg: Color,
border_color: Color,
) {
use crate::widgets::display_rules::{format_column, ColumnAlign, TruncateStrategy};
use crate::widgets::selection::DIMMED_BG;
canvas.draw_text(
"I/O RATES BY DEVICE",
Point::new(inner.x, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
y += 1.0;
let io_col_dev = 12;
let io_col_read = 12;
let io_col_write = 12;
let io_col_iops = 10;
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let io_headers = ["DEVICE", "READ/s", "WRITE/s", "IOPS"];
let io_widths = [io_col_dev, io_col_read, io_col_write, io_col_iops];
let mut ihx = inner.x;
for (h, w) in io_headers.iter().zip(io_widths.iter()) {
canvas.draw_text(
&format_column(h, *w, ColumnAlign::Left, TruncateStrategy::End),
Point::new(ihx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
ihx += *w as f32 + 1.0;
}
y += 1.0;
let mut devices: Vec<_> = io.physical_disks().collect();
devices.sort_by(|a, b| a.0.cmp(b.0));
for (dev_name, _stats) in devices.iter().take(io_section_height.saturating_sub(2)) {
let rates = io.rates.get(*dev_name);
let read_rate = rates.map_or(0.0, |r| r.read_bytes_per_sec);
let write_rate = rates.map_or(0.0, |r| r.write_bytes_per_sec);
let iops = rates.map_or(0.0, |r| r.reads_per_sec + r.writes_per_sec);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), DIMMED_BG);
let mut col_x = inner.x;
canvas.draw_text(
&format_column(
dev_name,
io_col_dev,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: Color::new(0.85, 0.85, 0.85, 1.0),
..Default::default()
},
);
col_x += io_col_dev as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_rate(read_rate),
io_col_read,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: io_rate_color(read_rate, true),
..Default::default()
},
);
col_x += io_col_read as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_rate(write_rate),
io_col_write,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: io_rate_color(write_rate, false),
..Default::default()
},
);
col_x += io_col_write as f32 + 1.0;
canvas.draw_text(
&format_column(
&format!("{:.0}", iops),
io_col_iops,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: Color::new(0.7, 0.7, 0.7, 1.0),
..Default::default()
},
);
y += 1.0;
}
}
fn draw_disk_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{
format_bytes_si, format_column, format_percent, ColumnAlign, TruncateStrategy,
};
use crate::widgets::selection::RowHighlight;
use crate::HeatScheme;
let border_color = DISK_COLOR;
let disk_io = app.disk_io_data();
let (total_read, total_write) = disk_io.map_or((0.0, 0.0), |io| {
(io.total_read_bytes_per_sec, io.total_write_bytes_per_sec)
});
let title = format!(
"▼ DISK │ R: {} │ W: {} │ {} Volumes",
format_bytes_rate(total_read),
format_bytes_rate(total_write),
app.disks.len()
);
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let disk_count = app.disks.len();
let disk_section_height = (disk_count.min(inner.height as usize / 2)).max(4);
let io_section_height = (inner.height as usize).saturating_sub(disk_section_height + 2);
let dim_style = TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
};
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
let mut y = inner.y;
let col_mount = 25.min(inner.width as usize / 4);
let col_fs = 10;
let col_used = 10;
let col_total = 10;
let col_pct = 8;
let col_bar = (inner.width as usize)
.saturating_sub(col_mount + col_fs + col_used + col_total + col_pct + 8);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let headers = ["MOUNT", "FS", "USED", "TOTAL", "USE%", ""];
let widths = [col_mount, col_fs, col_used, col_total, col_pct, col_bar];
let mut hx = inner.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
y += 1.0;
for (i, disk) in app.disks.iter().enumerate() {
if (y - inner.y) as usize >= disk_section_height {
break;
}
let row_hl = RowHighlight::new(Rect::new(inner.x, y, inner.width, 1.0), i == 0);
row_hl.paint(canvas);
let text_style = row_hl.text_style();
let mount = disk.mount_point().to_string_lossy().to_string();
let fs_type = disk.file_system().to_string_lossy().to_string();
let total = disk.total_space();
let used = total.saturating_sub(disk.available_space());
let use_pct = if total > 0 {
(used as f64 / total as f64) * 100.0
} else {
0.0
};
let mut col_x = inner.x;
canvas.draw_text(
&format_column(
&mount,
col_mount,
ColumnAlign::Left,
TruncateStrategy::Command,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_mount as f32 + 1.0;
canvas.draw_text(
&format_column(&fs_type, col_fs, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_fs as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(used),
col_used,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_used as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(total),
col_total,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_total as f32 + 1.0;
let pct_color = HeatScheme::Warm.color_for_percent(use_pct);
canvas.draw_text(
&format_column(
&format_percent(use_pct as f32),
col_pct,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: if i == 0 { Color::WHITE } else { pct_color },
..Default::default()
},
);
col_x += col_pct as f32 + 1.0;
if col_bar >= 3 {
let bar_str = make_bar(use_pct / 100.0, col_bar);
canvas.draw_text(
&bar_str,
Point::new(col_x, y),
&TextStyle {
color: HeatScheme::Warm.color_for_percent(use_pct),
..Default::default()
},
);
}
y += 1.0;
}
y += 0.5;
canvas.draw_text(
&"─".repeat(inner.width as usize),
Point::new(inner.x, y),
&dim_style,
);
y += 1.0;
if let Some(io) = disk_io {
draw_disk_io_section(
canvas,
io,
inner,
y,
io_section_height,
header_bg,
border_color,
);
}
}
#[inline]
fn network_rate_color(rate: u64, is_rx: bool, is_selected: bool) -> Color {
if rate > 1_000_000 {
if is_rx {
Color::new(0.3, 0.9, 0.5, 1.0) } else {
Color::new(0.9, 0.6, 0.3, 1.0) }
} else if is_selected {
Color::WHITE
} else {
Color::new(0.7, 0.7, 0.7, 1.0)
}
}
fn draw_network_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{
format_bytes_si, format_column, ColumnAlign, TruncateStrategy,
};
use crate::widgets::selection::RowHighlight;
let border_color = NETWORK_COLOR;
let (rx_total, tx_total): (u64, u64) = if app.deterministic {
(0, 0)
} else {
app.networks
.values()
.map(|d| (d.received(), d.transmitted()))
.fold((0, 0), |(ar, at), (r, t)| (ar + r, at + t))
};
let title = format!(
"▼ NETWORK │ ↓ {}/s │ ↑ {}/s │ {} Interfaces",
format_bytes(rx_total),
format_bytes(tx_total),
app.networks.len()
);
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
let col_iface = 15;
let col_rx = 12;
let col_tx = 12;
let col_rx_total = 14;
let col_tx_total = 14;
let col_bar = (inner.width as usize)
.saturating_sub(col_iface + col_rx + col_tx + col_rx_total + col_tx_total + 10);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let headers = ["INTERFACE", "↓ RX/s", "↑ TX/s", "TOTAL RX", "TOTAL TX", ""];
let widths = [
col_iface,
col_rx,
col_tx,
col_rx_total,
col_tx_total,
col_bar,
];
let mut hx = inner.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
y += 1.0;
let mut interfaces: Vec<_> = app.networks.iter().collect();
interfaces.sort_by(|a, b| b.1.received().cmp(&a.1.received()));
for (i, (iface_name, data)) in interfaces.iter().enumerate() {
if (y - inner.y) as usize >= (inner.height as usize).saturating_sub(2) {
break;
}
let row_rect = Rect::new(inner.x, y, inner.width, 1.0);
let is_selected = i == 0;
let row_hl = RowHighlight::new(row_rect, is_selected);
row_hl.paint(canvas);
let text_style = row_hl.text_style();
let rx_rate = data.received();
let tx_rate = data.transmitted();
let total_rx = data.total_received();
let total_tx = data.total_transmitted();
let mut col_x = inner.x;
canvas.draw_text(
&format_column(
iface_name,
col_iface,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_iface as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_rate(rx_rate as f64),
col_rx,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: network_rate_color(rx_rate, true, is_selected),
..Default::default()
},
);
col_x += col_rx as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_rate(tx_rate as f64),
col_tx,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: network_rate_color(tx_rate, false, is_selected),
..Default::default()
},
);
col_x += col_tx as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(total_rx),
col_rx_total,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_rx_total as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(total_tx),
col_tx_total,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_tx_total as f32 + 1.0;
if col_bar >= 3 {
let max_rate = 100_000_000.0; let rx_pct = ((rx_rate as f64 / max_rate) * 100.0).min(100.0);
let tx_pct = ((tx_rate as f64 / max_rate) * 100.0).min(100.0);
let rx_chars = ((rx_pct / 100.0) * (col_bar / 2) as f64) as usize;
let tx_chars = ((tx_pct / 100.0) * (col_bar / 2) as f64) as usize;
let rx_bar: String = "▁".repeat(rx_chars);
let tx_bar: String = "▁".repeat(tx_chars);
canvas.draw_text(
&rx_bar,
Point::new(col_x, y),
&TextStyle {
color: Color::new(0.3, 0.9, 0.5, 1.0),
..Default::default()
},
);
canvas.draw_text(
&tx_bar,
Point::new(col_x + (col_bar / 2) as f32, y),
&TextStyle {
color: Color::new(0.9, 0.6, 0.3, 1.0),
..Default::default()
},
);
}
y += 1.0;
}
}
fn draw_gpu_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::format_bytes_si;
use crate::HeatScheme;
let border_color = GPU_COLOR;
let gpu = app.gpu_info.clone();
let title = gpu
.as_ref()
.map(|g| {
let temp_str = g
.temperature
.map(|t| format!(" │ {}°C", t as i32))
.unwrap_or_default();
let power_str = g
.power_watts
.map(|p| format!(" │ {:.0}W", p))
.unwrap_or_default();
let util_str = g
.utilization
.map(|u| format!(" │ {}%", u as i32))
.unwrap_or_default();
format!("▼ GPU: {}{}{}{}", g.name, util_str, temp_str, power_str)
})
.unwrap_or_else(|| "▼ GPU │ No GPU detected".to_string());
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
if let Some(g) = gpu {
let util = g.utilization.unwrap_or(0) as f64;
let util_bar_width = (inner.width as usize).min(60);
let bar = make_bar(util / 100.0, util_bar_width);
let util_color = HeatScheme::Warm.color_for_percent(util);
canvas.draw_text(
&format!("GPU Utilization: {} {:>5.1}%", bar, util),
Point::new(inner.x, y),
&TextStyle {
color: util_color,
..Default::default()
},
);
y += 1.0;
let vram_used = g.vram_used.unwrap_or(0);
let vram_total = g.vram_total.unwrap_or(0);
let vram_pct = safe_pct(vram_used, vram_total);
let vram_bar = make_bar(vram_pct / 100.0, util_bar_width);
let vram_color = HeatScheme::Warm.color_for_percent(vram_pct);
canvas.draw_text(
&format!(
"VRAM: {} {:>5.1}% ({} / {})",
vram_bar,
vram_pct,
format_bytes_si(vram_used),
format_bytes_si(vram_total)
),
Point::new(inner.x, y),
&TextStyle {
color: vram_color,
..Default::default()
},
);
y += 1.0;
if let Some(temp) = g.temperature {
let temp_color = HeatScheme::Thermal.color_for_percent((temp as f64 / 100.0) * 100.0);
canvas.draw_text(
&format!("Temperature: {}°C", temp as i32),
Point::new(inner.x, y),
&TextStyle {
color: temp_color,
..Default::default()
},
);
}
if let Some(power) = g.power_watts {
canvas.draw_text(
&format!(" Power: {:.1}W", power),
Point::new(inner.x + 25.0, y),
&TextStyle {
color: Color::new(0.9, 0.7, 0.3, 1.0),
..Default::default()
},
);
}
y += 2.0;
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
canvas.draw_text(
"GPU PROCESSES",
Point::new(inner.x, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
y += 1.0;
canvas.draw_text(
" (GPU process list requires nvidia-smi)",
Point::new(inner.x, y),
&TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
},
);
} else {
canvas.draw_text(
"No GPU detected or nvidia-smi not available",
Point::new(inner.x, y),
&TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
},
);
}
}
#[inline]
fn sensor_value_display_color(sensor_type: SensorType, value: f64, is_selected: bool) -> Color {
use crate::HeatScheme;
match sensor_type {
SensorType::Temperature => HeatScheme::Thermal.color_for_percent(value),
SensorType::Fan => Color::new(0.3, 0.7, 0.9, 1.0),
_ => {
if is_selected {
Color::WHITE
} else {
Color::new(0.8, 0.8, 0.8, 1.0)
}
}
}
}
#[inline]
fn sensor_status_display_color(status: SensorStatus) -> Color {
match status {
SensorStatus::Normal => Color::new(0.3, 0.9, 0.3, 1.0),
SensorStatus::Warning => Color::new(0.9, 0.7, 0.2, 1.0),
SensorStatus::Critical => Color::new(0.9, 0.2, 0.2, 1.0),
SensorStatus::Low => Color::new(0.3, 0.5, 0.9, 1.0),
SensorStatus::Fault => Color::new(0.5, 0.5, 0.5, 1.0),
}
}
fn draw_sensors_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{format_column, ColumnAlign, TruncateStrategy};
use crate::widgets::selection::RowHighlight;
let border_color = SENSORS_COLOR;
let sensor_data = app.snapshot_sensor_health.as_ref();
let sensor_count = sensor_data.map(|d| d.sensors.len()).unwrap_or(0);
let title = format!("▼ SENSORS │ {} readings", sensor_count);
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
let col_name = 25;
let col_value = 15;
let col_status = 10;
let col_bar = (inner.width as usize).saturating_sub(col_name + col_value + col_status + 6);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let headers = ["SENSOR", "VALUE", "STATUS", ""];
let widths = [col_name, col_value, col_status, col_bar];
let mut hx = inner.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
y += 1.0;
if let Some(data) = sensor_data {
for (i, reading) in data.sensors.iter().enumerate() {
if (y - inner.y) as usize >= (inner.height as usize).saturating_sub(2) {
break;
}
let row_rect = Rect::new(inner.x, y, inner.width, 1.0);
let is_selected = i == 0;
let row_hl = RowHighlight::new(row_rect, is_selected);
row_hl.paint(canvas);
let text_style = row_hl.text_style();
let mut col_x = inner.x;
canvas.draw_text(
&format_column(
&reading.label,
col_name,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_name as f32 + 1.0;
let value_color =
sensor_value_display_color(reading.sensor_type, reading.value, is_selected);
canvas.draw_text(
&format_column(
&reading.value_display(),
col_value,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: value_color,
..Default::default()
},
);
col_x += col_value as f32 + 1.0;
let status_color = if is_selected {
Color::WHITE
} else {
sensor_status_display_color(reading.status)
};
canvas.draw_text(
&format_column(
reading.status.as_str(),
col_status,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: status_color,
..Default::default()
},
);
y += 1.0;
}
} else {
canvas.draw_text(
"No sensor data available",
Point::new(inner.x, y),
&TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
},
);
}
}
fn draw_process_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{
format_bytes_si, format_column, ColumnAlign, TruncateStrategy,
};
use crate::widgets::selection::RowHighlight;
use crate::HeatScheme;
let border_color = PROCESS_COLOR;
let proc_count = app.system.processes().len();
let title = format!("▼ PROCESSES │ {} total", proc_count);
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
let col_pid = 8;
let col_user = 12;
let col_cpu = 7;
let col_mem = 7;
let col_virt = 10;
let col_rss = 10;
let col_state = 6;
let col_time = 10;
let col_cmd = (inner.width as usize).saturating_sub(
col_pid + col_user + col_cpu + col_mem + col_virt + col_rss + col_state + col_time + 12,
);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let headers = [
"PID", "USER", "CPU%", "MEM%", "VIRT", "RSS", "STATE", "TIME", "COMMAND",
];
let widths = [
col_pid, col_user, col_cpu, col_mem, col_virt, col_rss, col_state, col_time, col_cmd,
];
let mut hx = inner.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
y += 1.0;
let mut procs: Vec<_> = app.system.processes().iter().collect();
procs.sort_by(|a, b| {
b.1.cpu_usage()
.partial_cmp(&a.1.cpu_usage())
.unwrap_or(std::cmp::Ordering::Equal)
});
for (i, (_pid, proc)) in procs.iter().enumerate() {
if (y - inner.y) as usize >= (inner.height as usize).saturating_sub(2) {
break;
}
let row_rect = Rect::new(inner.x, y, inner.width, 1.0);
let is_selected = app.process_selected == i;
let row_hl = RowHighlight::new(row_rect, is_selected);
row_hl.paint(canvas);
let text_style = row_hl.text_style();
let cpu_pct = proc.cpu_usage() as f64;
let mem_pct = (proc.memory() as f64 / app.mem_total as f64) * 100.0;
let mut col_x = inner.x;
canvas.draw_text(
&format_column(
&proc.pid().to_string(),
col_pid,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_pid as f32 + 1.0;
let user = proc
.user_id()
.map(|u| u.to_string())
.unwrap_or_else(|| "-".to_string());
canvas.draw_text(
&format_column(&user, col_user, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_user as f32 + 1.0;
let cpu_color = HeatScheme::Warm.color_for_percent(cpu_pct);
canvas.draw_text(
&format_column(
&format!("{:.1}", cpu_pct),
col_cpu,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: if is_selected { Color::WHITE } else { cpu_color },
..Default::default()
},
);
col_x += col_cpu as f32 + 1.0;
let mem_color = HeatScheme::Warm.color_for_percent(mem_pct);
canvas.draw_text(
&format_column(
&format!("{:.1}", mem_pct),
col_mem,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: if is_selected { Color::WHITE } else { mem_color },
..Default::default()
},
);
col_x += col_mem as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(proc.virtual_memory()),
col_virt,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_virt as f32 + 1.0;
canvas.draw_text(
&format_column(
&format_bytes_si(proc.memory()),
col_rss,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_rss as f32 + 1.0;
let state = format!("{:?}", proc.status());
canvas.draw_text(
&format_column(&state, col_state, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_state as f32 + 1.0;
let run_time = proc.run_time();
let time_str = format!(
"{}:{:02}:{:02}",
run_time / 3600,
(run_time % 3600) / 60,
run_time % 60
);
canvas.draw_text(
&format_column(
&time_str,
col_time,
ColumnAlign::Right,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_time as f32 + 1.0;
let cmd = proc.name().to_string_lossy().to_string();
canvas.draw_text(
&format_column(&cmd, col_cmd, ColumnAlign::Left, TruncateStrategy::Command),
Point::new(col_x, y),
&text_style,
);
y += 1.0;
}
}
fn draw_connections_exploded(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect) {
use crate::widgets::display_rules::{format_column, ColumnAlign, TruncateStrategy};
use crate::widgets::selection::RowHighlight;
let border_color = CONNECTIONS_COLOR;
let conn_data = app.snapshot_connections.as_ref();
let conn_count = conn_data.map(|d| d.connections.len()).unwrap_or(0);
let title = format!("▼ CONNECTIONS │ {} active", conn_count);
let mut border = create_panel_border(&title, border_color, true);
border.layout(area);
border.paint(canvas);
let inner = border.inner_rect();
if inner.height < 1.0 {
return;
}
let mut y = inner.y;
let col_proto = 6;
let col_local = 25;
let col_remote = 25;
let col_state = 12;
let col_pid = 8;
let col_proc = (inner.width as usize)
.saturating_sub(col_proto + col_local + col_remote + col_state + col_pid + 10);
let header_bg = Color::new(0.12, 0.15, 0.22, 1.0);
canvas.fill_rect(Rect::new(inner.x, y, inner.width, 1.0), header_bg);
let headers = [
"PROTO",
"LOCAL ADDRESS",
"REMOTE ADDRESS",
"STATE",
"PID",
"PROCESS",
];
let widths = [
col_proto, col_local, col_remote, col_state, col_pid, col_proc,
];
let mut hx = inner.x;
for (header, width) in headers.iter().zip(widths.iter()) {
canvas.draw_text(
&format_column(header, *width, ColumnAlign::Left, TruncateStrategy::End),
Point::new(hx, y),
&TextStyle {
color: border_color,
..Default::default()
},
);
hx += *width as f32 + 1.0;
}
y += 1.0;
if let Some(data) = conn_data {
for (i, conn) in data.connections.iter().enumerate() {
if (y - inner.y) as usize >= (inner.height as usize).saturating_sub(2) {
break;
}
let row_rect = Rect::new(inner.x, y, inner.width, 1.0);
let is_selected = i == 0;
let row_hl = RowHighlight::new(row_rect, is_selected);
row_hl.paint(canvas);
let text_style = row_hl.text_style();
let mut col_x = inner.x;
canvas.draw_text(
&format_column("TCP", col_proto, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_proto as f32 + 1.0;
let local = format!("{}:{}", conn.local_addr, conn.local_port);
canvas.draw_text(
&format_column(&local, col_local, ColumnAlign::Left, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_local as f32 + 1.0;
let remote = format!("{}:{}", conn.remote_addr, conn.remote_port);
canvas.draw_text(
&format_column(
&remote,
col_remote,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&text_style,
);
col_x += col_remote as f32 + 1.0;
let state_str = format!("{:?}", conn.state);
let state_color = match conn.state {
TcpState::Established => Color::new(0.3, 0.9, 0.3, 1.0),
TcpState::Listen => Color::new(0.3, 0.7, 0.9, 1.0),
TcpState::TimeWait => Color::new(0.7, 0.7, 0.3, 1.0),
TcpState::CloseWait => Color::new(0.9, 0.5, 0.3, 1.0),
_ => Color::new(0.6, 0.6, 0.6, 1.0),
};
canvas.draw_text(
&format_column(
&state_str,
col_state,
ColumnAlign::Left,
TruncateStrategy::End,
),
Point::new(col_x, y),
&TextStyle {
color: if is_selected {
Color::WHITE
} else {
state_color
},
..Default::default()
},
);
col_x += col_state as f32 + 1.0;
let pid_str = conn
.pid
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
canvas.draw_text(
&format_column(&pid_str, col_pid, ColumnAlign::Right, TruncateStrategy::End),
Point::new(col_x, y),
&text_style,
);
col_x += col_pid as f32 + 1.0;
let proc_name = conn.process_name.as_deref().unwrap_or("-");
canvas.draw_text(
&format_column(
proc_name,
col_proc,
ColumnAlign::Left,
TruncateStrategy::Command,
),
Point::new(col_x, y),
&text_style,
);
y += 1.0;
}
} else {
canvas.draw_text(
"No connection data available",
Point::new(inner.x, y),
&TextStyle {
color: Color::new(0.5, 0.5, 0.5, 1.0),
..Default::default()
},
);
}
}
fn draw_exploded_panel(app: &App, canvas: &mut DirectTerminalCanvas, area: Rect, panel: PanelType) {
match panel {
PanelType::Cpu => draw_cpu_exploded(app, canvas, area),
PanelType::Memory => draw_memory_exploded(app, canvas, area),
PanelType::Disk => draw_disk_exploded(app, canvas, area),
PanelType::Network => draw_network_exploded(app, canvas, area),
PanelType::Process => draw_process_exploded(app, canvas, area),
PanelType::Gpu => draw_gpu_exploded(app, canvas, area),
PanelType::Sensors => draw_sensors_exploded(app, canvas, area),
PanelType::Connections => draw_connections_exploded(app, canvas, area),
PanelType::Psi => draw_psi_panel(app, canvas, area),
PanelType::Files => draw_files_panel(app, canvas, area),
PanelType::Battery => draw_battery_panel(app, canvas, area),
PanelType::Containers => draw_containers_panel(app, canvas, area),
}
}
pub fn panel_border_color(panel: PanelType) -> Color {
match panel {
PanelType::Cpu => CPU_COLOR,
PanelType::Memory => MEMORY_COLOR,
PanelType::Disk => DISK_COLOR,
PanelType::Network => NETWORK_COLOR,
PanelType::Process => PROCESS_COLOR,
PanelType::Gpu => GPU_COLOR,
PanelType::Battery => BATTERY_COLOR,
PanelType::Sensors => SENSORS_COLOR,
PanelType::Psi => PSI_COLOR,
PanelType::Connections => CONNECTIONS_COLOR,
PanelType::Files => FILES_COLOR,
PanelType::Containers => CONTAINERS_COLOR,
}
}
#[cfg(test)]
mod explode_tests {
use super::*;
#[test]
fn test_f_explode_001_detection_threshold() {
let normal_width = 50.0;
let is_exploded_normal = normal_width > 100.0;
assert!(
!is_exploded_normal,
"Normal panel should NOT be detected as exploded"
);
let exploded_width = 148.0; let is_exploded_full = exploded_width > 100.0;
assert!(
is_exploded_full,
"Exploded panel SHOULD be detected as exploded"
);
}
#[test]
fn test_f_explode_002_core_spread() {
let core_count: usize = 48;
let core_area_height = 35.0_f32;
let cores_per_col_normal = core_area_height as usize; let cols_normal = core_count.div_ceil(cores_per_col_normal);
assert_eq!(
cols_normal, 2,
"Normal mode: 48 cores / 35 per col = 2 cols"
);
let max_per_col: usize = 12;
let cores_per_col_exploded = (core_area_height as usize).min(max_per_col);
let cols_exploded = core_count.div_ceil(cores_per_col_exploded);
assert_eq!(
cols_exploded, 4,
"Exploded mode: 48 cores / 12 per col = 4 cols"
);
}
#[test]
fn test_f_explode_003_bar_length() {
let bar_len_normal: usize = 6;
let bar_len_exploded: usize = 8;
assert!(
bar_len_exploded > bar_len_normal,
"Exploded bars should be longer"
);
assert_eq!(
bar_len_exploded - bar_len_normal,
2,
"Exploded bars 2 chars longer"
);
}
}
#[cfg(test)]
mod helper_tests {
use super::*;
use crate::ptop::ui::core::format::format_uptime;
#[test]
fn test_percent_color_low() {
let color = percent_color(10.0);
assert!(color.b > 0.5, "Low percent should have blue/cyan component");
assert!(color.g > 0.5, "Low percent should have green component");
}
#[test]
fn test_percent_color_medium_low() {
let color = percent_color(35.0);
assert!(color.g > 0.7, "Medium-low should be greenish");
}
#[test]
fn test_percent_color_medium_high() {
let color = percent_color(60.0);
assert!(color.r > 0.7, "Medium-high should have high red");
assert!(
color.g > 0.5,
"Medium-high should have some green (yellow-orange)"
);
}
#[test]
fn test_percent_color_high() {
let color = percent_color(80.0);
assert_eq!(color.r, 1.0, "High should be red component");
}
#[test]
fn test_percent_color_critical() {
let color = percent_color(95.0);
assert_eq!(color.r, 1.0, "Critical should be full red");
assert!(color.g < 0.5, "Critical should have low green");
}
#[test]
fn test_percent_color_clamped() {
let neg = percent_color(-10.0);
let over = percent_color(150.0);
let zero = percent_color(0.0);
let hundred = percent_color(100.0);
assert_eq!(neg.r, zero.r);
assert_eq!(over.r, hundred.r);
}
#[test]
fn test_percent_color_boundary_90() {
let color = percent_color(90.0);
assert_eq!(color.r, 1.0);
assert_eq!(color.g, 0.25);
}
#[test]
fn test_percent_color_boundary_75() {
let color = percent_color(75.0);
assert_eq!(color.r, 1.0);
}
#[test]
fn test_percent_color_boundary_50() {
let color = percent_color(50.0);
assert_eq!(color.r, 1.0);
}
#[test]
fn test_percent_color_boundary_25() {
let color = percent_color(25.0);
assert!(color.g > 0.8);
}
#[test]
fn test_format_bytes_small() {
assert_eq!(format_bytes(500), "500B");
assert_eq!(format_bytes(1023), "1023B");
}
#[test]
fn test_format_bytes_kb() {
assert_eq!(format_bytes(1024), "1.0K");
assert_eq!(format_bytes(1536), "1.5K");
assert_eq!(format_bytes(1024 * 10), "10.0K");
}
#[test]
fn test_format_bytes_mb() {
assert_eq!(format_bytes(1024 * 1024), "1.0M");
assert_eq!(format_bytes(1024 * 1024 * 5), "5.0M");
}
#[test]
fn test_format_bytes_gb() {
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0G");
assert_eq!(format_bytes(1024 * 1024 * 1024 * 8), "8.0G");
}
#[test]
fn test_format_bytes_tb() {
assert_eq!(format_bytes(1024u64 * 1024 * 1024 * 1024), "1.0T");
assert_eq!(format_bytes(1024u64 * 1024 * 1024 * 1024 * 2), "2.0T");
}
#[test]
fn test_format_bytes_rate_small() {
assert_eq!(format_bytes_rate(500.0), "500B");
}
#[test]
fn test_format_bytes_rate_kb() {
assert_eq!(format_bytes_rate(1024.0), "1K");
}
#[test]
fn test_format_bytes_rate_mb() {
assert_eq!(format_bytes_rate(1024.0 * 1024.0), "1.0M");
}
#[test]
fn test_format_bytes_rate_gb() {
assert_eq!(format_bytes_rate(1024.0 * 1024.0 * 1024.0), "1.0G");
}
#[test]
fn test_format_uptime_seconds() {
assert_eq!(format_uptime(30), "0m");
assert_eq!(format_uptime(59), "0m");
}
#[test]
fn test_format_uptime_minutes() {
assert_eq!(format_uptime(60), "1m");
assert_eq!(format_uptime(90), "1m");
assert_eq!(format_uptime(3599), "59m");
}
#[test]
fn test_format_uptime_hours() {
assert_eq!(format_uptime(3600), "1h 0m");
assert_eq!(format_uptime(3660), "1h 1m");
assert_eq!(format_uptime(7200), "2h 0m");
}
#[test]
fn test_format_uptime_days() {
assert_eq!(format_uptime(86400), "1d 0h");
assert_eq!(format_uptime(90000), "1d 1h");
assert_eq!(format_uptime(172800), "2d 0h");
}
#[test]
fn test_swap_color_low() {
let color = swap_color(10.0);
assert!(color.g > 0.5);
}
#[test]
fn test_swap_color_medium() {
let color = swap_color(40.0);
assert!(color.r > 0.5 || color.g > 0.5);
}
#[test]
fn test_swap_color_high() {
let color = swap_color(80.0);
assert!(color.r > 0.7);
}
#[test]
fn test_swap_color_clamped() {
let neg = swap_color(-10.0);
let over = swap_color(110.0);
assert!(neg.r >= 0.0 && neg.r <= 1.0);
assert!(over.r >= 0.0 && over.r <= 1.0);
}
#[test]
fn test_pressure_symbol_none() {
assert_eq!(pressure_symbol(0.0), "—");
assert_eq!(pressure_symbol(0.5), "—");
assert_eq!(pressure_symbol(1.0), "—");
}
#[test]
fn test_pressure_symbol_low() {
assert_eq!(pressure_symbol(2.0), "◐");
assert_eq!(pressure_symbol(10.0), "▼");
}
#[test]
fn test_pressure_symbol_high() {
assert_eq!(pressure_symbol(30.0), "▲");
assert_eq!(pressure_symbol(60.0), "▲▲");
}
#[test]
fn test_pressure_color_none() {
let color = pressure_color(0.0);
assert!(color.r < 0.5);
}
#[test]
fn test_pressure_color_low() {
let color = pressure_color(5.0);
assert!(color.g > 0.0);
}
#[test]
fn test_pressure_color_high() {
let color = pressure_color(50.0);
assert!(color.r > 0.5);
}
#[test]
fn test_port_to_service_known() {
assert_eq!(port_to_service(22), "SSH");
assert_eq!(port_to_service(80), "HTTP");
assert_eq!(port_to_service(443), "HTTPS");
assert_eq!(port_to_service(53), "DNS");
assert_eq!(port_to_service(25), "SMTP");
assert_eq!(port_to_service(21), "FTP");
}
#[test]
fn test_port_to_service_database() {
assert_eq!(port_to_service(3306), "MySQL");
assert_eq!(port_to_service(5432), "Pgsql");
assert_eq!(port_to_service(6379), "Redis");
assert_eq!(port_to_service(27017), "Mongo");
}
#[test]
fn test_port_to_service_unknown() {
assert_eq!(port_to_service(12345), "");
}
#[test]
fn test_port_to_service_app_range() {
assert_eq!(port_to_service(9000), "App");
assert_eq!(port_to_service(9999), "App");
}
#[test]
fn test_cpu_color_is_cyan() {
assert!(CPU_COLOR.b > 0.9);
assert!(CPU_COLOR.g > 0.7);
}
#[test]
fn test_memory_color_is_purple() {
assert!(MEMORY_COLOR.b > 0.9);
assert!(MEMORY_COLOR.r > 0.6);
}
#[test]
fn test_network_color_is_orange() {
assert!(NETWORK_COLOR.r > 0.9);
assert!(NETWORK_COLOR.g > 0.5);
}
#[test]
fn test_process_color_is_yellow() {
assert!(PROCESS_COLOR.r > 0.8);
assert!(PROCESS_COLOR.g > 0.6);
}
#[test]
fn test_gpu_color_is_green() {
assert!(GPU_COLOR.g > 0.9);
assert!(GPU_COLOR.b > 0.5);
}
#[test]
fn test_create_panel_border_unfocused() {
let border = create_panel_border("Test", CPU_COLOR, false);
let _ = border;
}
#[test]
fn test_create_panel_border_focused() {
let border = create_panel_border("Test", CPU_COLOR, true);
let _ = border;
}
#[test]
fn test_selection_colors() {
assert!(FOCUS_ACCENT_COLOR.g >= 0.9, "Accent should be bright green");
assert!(
ROW_SELECT_BG.b > ROW_SELECT_BG.r,
"Selection bg should have purple/blue tint"
);
assert!(ROW_SELECT_BG.r < 0.25, "Selection bg should be subtle/dark");
}
#[test]
fn test_status_bar_bg() {
assert!(STATUS_BAR_BG.r < 0.15);
assert!(STATUS_BAR_BG.g < 0.15);
assert!(STATUS_BAR_BG.b < 0.15);
}
#[test]
fn test_col_select_bg() {
assert!(COL_SELECT_BG.b > COL_SELECT_BG.r);
}
#[test]
fn test_net_colors() {
assert!(NET_RX_COLOR.b > 0.9);
assert!(NET_TX_COLOR.r > 0.9);
}
#[test]
fn test_zram_stats_default() {
let stats = ZramStats::default();
assert_eq!(stats.orig_data_size, 0);
assert_eq!(stats.compr_data_size, 0);
assert!(stats.algorithm.is_empty());
}
#[test]
fn test_zram_stats_ratio_zero_compressed() {
let stats = ZramStats {
orig_data_size: 1000,
compr_data_size: 0,
algorithm: "lz4".to_string(),
};
assert!((stats.ratio() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_zram_stats_ratio_normal() {
let stats = ZramStats {
orig_data_size: 1000,
compr_data_size: 500,
algorithm: "lz4".to_string(),
};
assert!((stats.ratio() - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_zram_stats_ratio_high_compression() {
let stats = ZramStats {
orig_data_size: 10000,
compr_data_size: 1000,
algorithm: "zstd".to_string(),
};
assert!((stats.ratio() - 10.0).abs() < f64::EPSILON);
}
#[test]
fn test_zram_stats_is_active_true() {
let stats = ZramStats {
orig_data_size: 100,
compr_data_size: 50,
algorithm: "lzo".to_string(),
};
assert!(stats.is_active());
}
#[test]
fn test_zram_stats_is_active_false() {
let stats = ZramStats {
orig_data_size: 0,
compr_data_size: 0,
algorithm: "".to_string(),
};
assert!(!stats.is_active());
}
#[test]
fn test_zram_stats_debug() {
let stats = ZramStats {
orig_data_size: 1024,
compr_data_size: 512,
algorithm: "lz4".to_string(),
};
let debug = format!("{:?}", stats);
assert!(debug.contains("ZramStats"));
assert!(debug.contains("1024"));
assert!(debug.contains("lz4"));
}
#[test]
fn test_cpu_meter_layout_normal_mode() {
let layout = CpuMeterLayout::calculate(8, 20.0, false);
assert_eq!(layout.bar_len, 6);
assert!(layout.meter_bar_width > 0.0);
assert!(layout.cores_per_col > 0);
assert!(layout.num_meter_cols > 0);
}
#[test]
fn test_cpu_meter_layout_exploded_mode() {
let layout = CpuMeterLayout::calculate(8, 20.0, true);
assert_eq!(layout.bar_len, 8);
assert!(layout.meter_bar_width > 0.0);
}
#[test]
fn test_cpu_meter_layout_exploded_caps_cores_per_col() {
let layout = CpuMeterLayout::calculate(48, 35.0, true);
assert!(layout.cores_per_col <= 12);
}
#[test]
fn test_cpu_meter_layout_normal_uses_full_height() {
let layout = CpuMeterLayout::calculate(48, 35.0, false);
assert_eq!(layout.cores_per_col, 35);
}
#[test]
fn test_cpu_meter_layout_single_core() {
let layout = CpuMeterLayout::calculate(1, 10.0, false);
assert_eq!(layout.num_meter_cols, 1);
assert_eq!(layout.cores_per_col, 10);
}
#[test]
fn test_cpu_meter_layout_zero_height() {
let layout = CpuMeterLayout::calculate(4, 0.0, false);
assert!(layout.cores_per_col >= 1);
}
#[test]
fn test_cpu_meter_layout_many_cores() {
let layout = CpuMeterLayout::calculate(128, 30.0, false);
assert!(layout.num_meter_cols >= 5);
}
#[test]
fn test_cpu_meter_layout_bar_width_calculation() {
let layout_normal = CpuMeterLayout::calculate(8, 20.0, false);
let layout_exploded = CpuMeterLayout::calculate(8, 20.0, true);
assert!(layout_exploded.meter_bar_width > layout_normal.meter_bar_width);
}
#[test]
fn test_memory_stats_creation() {
use crate::ptop::app::App;
let app = App::new(true);
let stats = MemoryStats::from_app(&app);
assert!(stats.used_gb >= 0.0);
assert!(stats.cached_gb >= 0.0);
assert!(stats.free_gb >= 0.0);
}
#[test]
fn test_panel_border_color_cpu() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Cpu);
assert_eq!(color.r, CPU_COLOR.r);
assert_eq!(color.g, CPU_COLOR.g);
assert_eq!(color.b, CPU_COLOR.b);
}
#[test]
fn test_panel_border_color_memory() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Memory);
assert_eq!(color.r, MEMORY_COLOR.r);
}
#[test]
fn test_panel_border_color_disk() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Disk);
assert_eq!(color.r, DISK_COLOR.r);
}
#[test]
fn test_panel_border_color_network() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Network);
assert_eq!(color.r, NETWORK_COLOR.r);
}
#[test]
fn test_panel_border_color_process() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Process);
assert_eq!(color.r, PROCESS_COLOR.r);
}
#[test]
fn test_panel_border_color_gpu() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Gpu);
assert_eq!(color.r, GPU_COLOR.r);
}
#[test]
fn test_panel_border_color_battery() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Battery);
assert_eq!(color.r, BATTERY_COLOR.r);
}
#[test]
fn test_panel_border_color_sensors() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Sensors);
assert_eq!(color.r, SENSORS_COLOR.r);
}
#[test]
fn test_panel_border_color_psi() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Psi);
assert_eq!(color.r, PSI_COLOR.r);
}
#[test]
fn test_panel_border_color_connections() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Connections);
assert_eq!(color.r, CONNECTIONS_COLOR.r);
}
#[test]
fn test_panel_border_color_files() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Files);
assert_eq!(color.r, FILES_COLOR.r);
}
#[test]
fn test_panel_border_color_containers() {
use crate::ptop::config::PanelType;
let color = panel_border_color(PanelType::Containers);
assert_eq!(color.r, CONTAINERS_COLOR.r);
}
#[test]
fn test_format_bytes_zero() {
assert_eq!(format_bytes(0), "0B");
}
#[test]
fn test_format_bytes_rate_zero() {
assert_eq!(format_bytes_rate(0.0), "0B");
}
#[test]
fn test_format_uptime_zero() {
assert_eq!(format_uptime(0), "0m");
}
#[test]
fn test_format_uptime_large() {
let secs = 365 * 24 * 60 * 60;
let result = format_uptime(secs);
assert!(result.contains("365d"));
}
#[test]
fn test_percent_color_exact_boundaries() {
let _ = percent_color(0.0);
let _ = percent_color(25.0);
let _ = percent_color(50.0);
let _ = percent_color(75.0);
let _ = percent_color(90.0);
let _ = percent_color(100.0);
}
#[test]
fn test_swap_color_boundaries() {
let low = swap_color(10.0);
let med = swap_color(10.1);
let high = swap_color(50.1);
assert!(low.g > 0.8); assert!(med.g > 0.7); assert!(high.r > 0.9); }
#[test]
fn test_pressure_symbol_boundary_values() {
assert_eq!(pressure_symbol(1.0), "—");
assert_eq!(pressure_symbol(1.1), "◐");
assert_eq!(pressure_symbol(5.0), "◐");
assert_eq!(pressure_symbol(5.1), "▼");
assert_eq!(pressure_symbol(20.0), "▼");
assert_eq!(pressure_symbol(20.1), "▲");
assert_eq!(pressure_symbol(50.0), "▲");
assert_eq!(pressure_symbol(50.1), "▲▲");
}
#[test]
fn test_pressure_color_boundaries() {
let none = pressure_color(1.0);
let low = pressure_color(5.0);
let med = pressure_color(20.0);
let high = pressure_color(50.0);
assert!(none.r >= 0.0 && none.r <= 1.0);
assert!(low.g >= 0.0 && low.g <= 1.0);
assert!(med.r >= 0.0 && med.r <= 1.0);
assert!(high.r >= 0.0 && high.r <= 1.0);
}
#[test]
fn test_port_to_service_edge_cases() {
assert_eq!(port_to_service(8999), "");
assert_eq!(port_to_service(10000), "");
}
#[test]
fn test_dim_color_constant() {
assert!(DIM_COLOR.r < 0.5);
assert!(DIM_COLOR.g < 0.5);
assert!(DIM_COLOR.b < 0.5);
}
#[test]
fn test_cached_color_constant() {
assert!(CACHED_COLOR.g > 0.7);
assert!(CACHED_COLOR.b > 0.8);
}
#[test]
fn test_free_color_constant() {
assert!(FREE_COLOR.b > 0.8);
}
#[test]
fn test_battery_color_is_yellow() {
assert!(BATTERY_COLOR.r > 0.9);
assert!(BATTERY_COLOR.g > 0.8);
}
#[test]
fn test_sensors_color_is_pink() {
assert!(SENSORS_COLOR.r > 0.9);
assert!(SENSORS_COLOR.b > 0.5);
}
#[test]
fn test_psi_color_is_red() {
assert!(PSI_COLOR.r > 0.7);
}
#[test]
fn test_disk_color_is_blue() {
assert!(DISK_COLOR.b > 0.9);
}
#[test]
fn test_files_color_is_brown() {
assert!(FILES_COLOR.r > 0.6);
assert!(FILES_COLOR.g > 0.4);
}
#[test]
fn test_containers_color_is_docker_blue() {
assert!(CONTAINERS_COLOR.b > 0.8);
}
#[test]
fn test_connections_color_is_light_blue() {
assert!(CONNECTIONS_COLOR.b > 0.8);
}
}
#[cfg(test)]
mod draw_integration_tests {
use super::*;
use crate::direct::CellBuffer;
#[test]
fn test_draw_small_terminal() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(80, 24);
draw(&app, &mut buffer);
}
#[test]
fn test_draw_large_terminal() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(160, 50);
draw(&app, &mut buffer);
}
#[test]
fn test_draw_minimum_size() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(10, 5);
draw(&app, &mut buffer);
}
#[test]
fn test_draw_too_small_width() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(5, 24);
draw(&app, &mut buffer);
}
#[test]
fn test_draw_too_small_height() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(80, 3);
draw(&app, &mut buffer);
}
#[test]
fn test_draw_standard_sizes() {
use crate::ptop::app::App;
let app = App::new(true);
let sizes = [(80, 24), (120, 40), (132, 43), (200, 60)];
for (w, h) in sizes {
let mut buffer = CellBuffer::new(w, h);
draw(&app, &mut buffer);
}
}
#[test]
fn test_draw_multiple_times() {
use crate::ptop::app::App;
let app = App::new(true);
let mut buffer = CellBuffer::new(100, 30);
for _ in 0..10 {
draw(&app, &mut buffer);
}
}
#[test]
fn test_count_top_panels() {
use crate::ptop::app::App;
let app = App::new(true);
let count = count_top_panels(&app);
assert!(count >= 2);
assert!(count <= 10);
}
}