use std::cell::RefCell;
use std::collections::HashSet;
use std::io::Write;
use crossterm::event::KeyCode;
use crossterm::terminal;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use serde::{Deserialize, Serialize};
use crate::extensions::aggregate::{aggregate, GroupBy};
use crate::extensions::alerts::{AlertEngine, Firing, Metric, Rule};
use crate::extensions::filter::{Compiled, Field, Filter, FilterStore};
use crate::extensions::graph::Scalar;
use crate::extensions::model::Proc;
use crate::extensions::overlay::{
blit, draw_box, modal_palette, ncurses_to_keycode, set_str, ModalPalette,
};
use crate::extensions::procring::ProcRing;
use crate::extensions::snapshot::{diff, Diff, Snapshot};
use crate::extensions::{export, finder};
use crate::ported::crt::{ColorElements, ColorScheme};
use crate::ported::functionbar::Ncurses;
use crate::ported::table::Table;
const HISTORY: usize = 300;
const SPARK_W: usize = 12;
const SPARK_GRAPH_H: i32 = 3;
const LIST_ROWS: usize = 16;
fn toast(msg: impl Into<String>) {
crate::extensions::overlay::set_status(msg);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum SparkMode {
#[default]
Off,
Column,
Double,
}
#[derive(Clone, Copy, PartialEq)]
enum Modal {
None,
Finder,
Filter,
Diff,
Export,
Alerts,
Graph,
Aggregate,
Palette,
}
const CMDS: &[(&str, i32)] = &[
("finder — fuzzy process search", b'f' as i32),
("filter — regex / saved filters", b'r' as i32),
("snapshot / diff process table", b'd' as i32),
("export table to JSON / CSV", b'o' as i32),
("alerts — threshold rules", b'A' as i32),
("cpu history graph", b'G' as i32),
("aggregate / pivot totals", b'y' as i32),
("sparkline — cycle per-PID CPU graph", b'v' as i32),
("bar style — cycle fill glyph", b'b' as i32),
("border — toggle", b'B' as i32),
("header — toggle", b'g' as i32),
("theme — chooser", b'c' as i32),
("theme — editor", b'C' as i32),
("help", b'h' as i32),
("kill process", b'k' as i32),
("tree view — toggle", b't' as i32),
("search", b'/' as i32),
("setup", b'S' as i32),
];
struct PanelState {
ring: ProcRing,
alerts: AlertEngine,
filters: FilterStore,
cpu_hist: Scalar,
baseline: Option<Snapshot>,
table: Vec<Proc>,
firing: HashSet<u32>,
firings: Vec<Firing>,
selected_pid: Option<u32>,
cpu_peak: f64,
tick: u64,
modal: Modal,
spark: SparkMode,
agg_by: GroupBy,
alert_hl: bool,
pending_select: Option<u32>,
pending_key: Option<i32>,
finder_query: String,
finder_hits: Vec<finder::Match>,
finder_sel: usize,
palette_query: String,
palette_hits: Vec<finder::Match>,
palette_sel: usize,
filter_query: String,
filter_field: Field,
filter_regex: bool,
filter_msg: String,
diff: Option<Diff>,
export_msg: String,
}
impl PanelState {
fn new() -> Self {
let saved = super::prefs::load();
PanelState {
ring: ProcRing::new(HISTORY),
alerts: AlertEngine::new(default_rules()),
filters: load_filters(),
cpu_hist: Scalar::new(HISTORY),
baseline: None,
table: Vec::new(),
firing: HashSet::new(),
firings: Vec::new(),
selected_pid: None,
cpu_peak: 100.0,
tick: 0,
modal: Modal::None,
spark: saved.as_ref().map(|p| p.spark).unwrap_or_default(),
agg_by: saved.as_ref().map(|p| p.agg_by).unwrap_or_default(),
alert_hl: saved.as_ref().and_then(|p| p.alert_hl).unwrap_or(true),
pending_select: None,
pending_key: None,
finder_query: String::new(),
finder_hits: Vec::new(),
finder_sel: 0,
palette_query: String::new(),
palette_hits: Vec::new(),
palette_sel: 0,
filter_query: String::new(),
filter_field: Field::Any,
filter_regex: false,
filter_msg: String::new(),
diff: None,
export_msg: String::new(),
}
}
fn ingest(&mut self, rows: Vec<Proc>, selected: Option<u32>) {
self.ring.record(&rows);
let total: f64 = rows.iter().map(|p| p.cpu as f64).sum();
self.cpu_hist.push(total);
if total > self.cpu_peak {
self.cpu_peak = total;
}
self.firings = self.alerts.evaluate(&rows);
self.firing = self.firings.iter().map(|f| f.pid).collect();
self.selected_pid = selected;
self.table = rows;
self.tick += 1;
if self.modal == Modal::Finder {
self.recompute_finder();
}
}
fn any_active(&self) -> bool {
self.modal != Modal::None
}
fn candidates(&self) -> Vec<String> {
self.table
.iter()
.map(|p| format!("{} {}", p.comm, p.cmdline))
.collect()
}
fn recompute_finder(&mut self) {
let cands = self.candidates();
self.finder_hits = finder::fuzzy(&self.finder_query, &cands);
if self.finder_sel >= self.finder_hits.len() {
self.finder_sel = self.finder_hits.len().saturating_sub(1);
}
}
fn compiled_filter(&self) -> Option<Compiled> {
Filter {
name: self.filter_query.clone(),
pattern: self.filter_query.clone(),
regex: self.filter_regex,
field: self.filter_field,
}
.compile()
.ok()
}
fn handle(&mut self, ch: i32) -> bool {
if self.modal != Modal::None {
let code = match ch {
9 => Some(KeyCode::Char('\t')),
_ => ncurses_to_keycode(ch),
};
self.handle_modal(code);
return true;
}
if crate::extensions::overlay::overlay_active() {
return false;
}
match ch {
0x66 => self.open_finder(), 0x72 => self.open_filter(), 0x64 => self.snapshot_action(), 0x6f => self.export_action(), 0x41 => {
self.modal = Modal::Alerts;
toast(format!("Alerts — {} firing", self.firing.len()));
}
0x47 => {
self.modal = Modal::Graph;
toast("CPU history graph");
}
0x76 => self.toggle_spark(), 0x79 => {
self.modal = Modal::Aggregate;
toast(format!("Aggregate by {}", self.agg_by.label()));
}
0x3a => self.open_palette(), _ => return false,
}
true
}
fn handle_modal(&mut self, code: Option<KeyCode>) {
let Some(code) = code else { return };
if code == KeyCode::Esc {
self.modal = Modal::None;
return;
}
match self.modal {
Modal::Finder => self.finder_key(code),
Modal::Palette => self.palette_key(code),
Modal::Filter => self.filter_key(code),
Modal::Diff => self.diff_key(code),
Modal::Alerts => {
if code == KeyCode::Char('t') {
self.alert_hl = !self.alert_hl;
let on = self.alert_hl;
super::prefs::update(|p| p.alert_hl = Some(on));
toast(if on {
"Hot-row highlight: on"
} else {
"Hot-row highlight: off"
});
} else {
self.modal = Modal::None;
}
}
Modal::Aggregate => {
if code == KeyCode::Char('\t') {
self.agg_by = self.agg_by.next();
let by = self.agg_by;
super::prefs::update(|p| p.agg_by = by);
toast(format!("Aggregate by {}", by.label()));
} else {
self.modal = Modal::None;
}
}
Modal::Export | Modal::Graph => {
self.modal = Modal::None;
}
Modal::None => {}
}
}
fn open_finder(&mut self) {
self.modal = Modal::Finder;
self.finder_query.clear();
self.finder_sel = 0;
self.recompute_finder();
toast("Process finder");
}
fn finder_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char(c) => {
self.finder_query.push(c);
self.finder_sel = 0;
self.recompute_finder();
}
KeyCode::Backspace => {
self.finder_query.pop();
self.finder_sel = 0;
self.recompute_finder();
}
KeyCode::Down => {
if self.finder_sel + 1 < self.finder_hits.len() {
self.finder_sel += 1;
}
}
KeyCode::Up => self.finder_sel = self.finder_sel.saturating_sub(1),
KeyCode::Enter => {
if let Some(m) = self.finder_hits.get(self.finder_sel) {
if let Some(p) = self.table.get(m.idx) {
self.pending_select = Some(p.pid);
}
}
self.modal = Modal::None;
}
_ => {}
}
}
fn open_palette(&mut self) {
self.modal = Modal::Palette;
self.palette_query.clear();
self.palette_sel = 0;
self.recompute_palette();
toast("Command palette");
}
fn recompute_palette(&mut self) {
let names: Vec<String> = CMDS.iter().map(|(n, _)| n.to_string()).collect();
self.palette_hits = finder::fuzzy(&self.palette_query, &names);
if self.palette_sel >= self.palette_hits.len() {
self.palette_sel = self.palette_hits.len().saturating_sub(1);
}
}
fn palette_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char(c) => {
self.palette_query.push(c);
self.palette_sel = 0;
self.recompute_palette();
}
KeyCode::Backspace => {
self.palette_query.pop();
self.palette_sel = 0;
self.recompute_palette();
}
KeyCode::Down => {
if self.palette_sel + 1 < self.palette_hits.len() {
self.palette_sel += 1;
}
}
KeyCode::Up => self.palette_sel = self.palette_sel.saturating_sub(1),
KeyCode::Enter => {
if let Some(m) = self.palette_hits.get(self.palette_sel) {
if let Some((_, key)) = CMDS.get(m.idx) {
self.pending_key = Some(*key);
}
}
self.modal = Modal::None;
}
_ => {}
}
}
fn open_filter(&mut self) {
self.modal = Modal::Filter;
self.filter_msg.clear();
toast("Filter");
}
fn filter_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('\t') => {
self.filter_field = match self.filter_field {
Field::Any => Field::Comm,
Field::Comm => Field::Cmdline,
Field::Cmdline => Field::User,
Field::User => Field::Any,
};
}
KeyCode::Char('~') => self.filter_regex = !self.filter_regex,
KeyCode::Char(c) => self.filter_query.push(c),
KeyCode::Backspace => {
self.filter_query.pop();
}
KeyCode::Enter => self.save_filter(),
_ => {}
}
}
fn save_filter(&mut self) {
if self.filter_query.is_empty() {
self.filter_msg = "empty pattern — nothing saved".into();
return;
}
match self.compiled_filter() {
Some(_) => {
self.filters.put(Filter {
name: self.filter_query.clone(),
pattern: self.filter_query.clone(),
regex: self.filter_regex,
field: self.filter_field,
});
save_filters(&self.filters);
self.filter_msg = format!("saved \"{}\"", self.filter_query);
}
None => self.filter_msg = "invalid regex — not saved".into(),
}
}
fn snapshot_action(&mut self) {
match &self.baseline {
None => {
self.baseline = Some(Snapshot::capture(self.tick, &self.table));
self.diff = None;
toast("Snapshot baseline captured");
}
Some(base) => {
let now = Snapshot::capture(self.tick, &self.table);
let d = diff(base, &now);
toast(format!(
"Diff: +{} -{} ~{}",
d.added.len(),
d.removed.len(),
d.changed.len()
));
self.diff = Some(d);
}
}
self.modal = Modal::Diff;
}
fn diff_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('r') => {
self.baseline = Some(Snapshot::capture(self.tick, &self.table));
self.diff = None;
toast("Baseline reset");
}
KeyCode::Char('w') => {
if let Some(base) = &self.baseline {
let name = format!("snapshot-{}.json", base.tick);
match write_artifact(&name, &base.to_json()) {
Some(path) => {
self.export_msg = format!("wrote {path}");
toast("Snapshot written");
}
None => {
self.export_msg = "write failed (no config dir)".into();
toast("Snapshot write failed");
}
}
}
}
_ => self.modal = Modal::None,
}
}
fn export_action(&mut self) {
let json = export::to_json(&self.table);
let csv = export::to_csv(&self.table);
let jn = format!("export-{}.json", self.tick);
let cn = format!("export-{}.csv", self.tick);
let jp = write_artifact(&jn, &json);
let cp = write_artifact(&cn, &csv);
self.export_msg = match (jp, cp) {
(Some(a), Some(b)) => {
toast("Exported JSON + CSV");
format!("{a}\n{b}")
}
_ => {
toast("Export failed — no config dir");
"export failed (no config dir)".into()
}
};
self.modal = Modal::Export;
}
fn toggle_spark(&mut self) {
self.spark = match self.spark {
SparkMode::Off => SparkMode::Column,
SparkMode::Column => SparkMode::Double,
SparkMode::Double => SparkMode::Off,
};
toast(match self.spark {
SparkMode::Off => "CPU graph: off",
SparkMode::Column => "CPU graph: column",
SparkMode::Double => "CPU graph: inline (taller = busier)",
});
let spark = self.spark;
super::prefs::update(|p| p.spark = spark);
}
fn render(&self, buf: &mut Buffer, area: Rect) {
let s = Sty::new();
match self.modal {
Modal::Finder => self.render_finder(buf, area, &s),
Modal::Filter => self.render_filter(buf, area, &s),
Modal::Diff => self.render_diff(buf, area, &s),
Modal::Export => self.render_lines(
buf,
area,
&s,
"Export — current table written",
self.export_msg
.lines()
.map(|l| (l.to_string(), s.body))
.collect(),
),
Modal::Alerts => self.render_alerts(buf, area, &s),
Modal::Graph => self.render_graph(buf, area, &s),
Modal::Aggregate => self.render_aggregate(buf, area, &s),
Modal::Palette => self.render_palette(buf, area, &s),
Modal::None => {}
}
}
fn render_palette(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let mut lines = Vec::new();
lines.push((
format!("> {}▏", self.palette_query),
s.body.add_modifier(Modifier::BOLD),
));
lines.push((
format!(
"{} commands · ↑/↓ move · Enter run · Esc cancel",
self.palette_hits.len()
),
s.dim,
));
for (row, m) in self.palette_hits.iter().take(LIST_ROWS).enumerate() {
let Some((name, _)) = CMDS.get(m.idx) else {
continue;
};
let st = if row == self.palette_sel {
s.sel
} else {
s.body
};
lines.push((format!(" {name}"), st));
}
self.render_lines(buf, area, s, "Command palette", lines);
}
fn render_aggregate(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let groups = aggregate(&self.table, self.agg_by);
let mut lines = Vec::new();
lines.push((
format!(
"by {} · {} groups · Tab cycle · Esc close",
self.agg_by.label(),
groups.len()
),
s.dim,
));
lines.push((
format!("{:<22} {:>5} {:>7} {:>10}", "KEY", "PROCS", "CPU%", "MEM"),
s.body.add_modifier(Modifier::BOLD),
));
for g in groups.iter().take(LIST_ROWS) {
lines.push((
format!(
"{:<22} {:>5} {:>6.1} {:>10}",
trunc(&g.key, 22),
g.count,
g.cpu,
human_kb(g.mem_kb)
),
s.body,
));
}
self.render_lines(buf, area, s, "Aggregate", lines);
}
fn render_lines(
&self,
buf: &mut Buffer,
area: Rect,
s: &Sty,
title: &str,
lines: Vec<(String, Style)>,
) {
let inner_w = lines
.iter()
.map(|(t, _)| t.chars().count())
.chain(std::iter::once(title.chars().count()))
.max()
.unwrap_or(20)
.clamp(24, area.width.saturating_sub(4).max(24) as usize);
let bw = (inner_w as u16 + 4).min(area.width);
let bh = (lines.len() as u16 + 4).min(area.height);
let (x0, y0) = draw_box(buf, area, bw, bh, s.bg, s.border);
set_str(buf, x0 + 2, y0, &format!(" {title} "), s.title, bw - 3);
for (i, (t, st)) in lines.iter().enumerate() {
if i as u16 + 1 >= bh - 1 {
break;
}
set_str(buf, x0 + 2, y0 + 2 + i as u16, t, *st, bw - 3);
}
}
fn render_finder(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let mut lines = Vec::new();
lines.push((
format!("> {}▏", self.finder_query),
s.body.add_modifier(Modifier::BOLD),
));
lines.push((
format!(
"{} matches · ↑/↓ move · Enter jump · Esc cancel",
self.finder_hits.len()
),
s.dim,
));
for (row, m) in self.finder_hits.iter().take(LIST_ROWS).enumerate() {
let Some(p) = self.table.get(m.idx) else {
continue;
};
let line = format!(
"{:>7} {:<14} {}",
p.pid,
trunc(&p.comm, 14),
trunc(&p.cmdline, 48)
);
let st = if row == self.finder_sel {
s.sel
} else {
s.body
};
lines.push((line, st));
}
self.render_lines(buf, area, s, "Fuzzy process finder", lines);
}
fn render_filter(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let field = match self.filter_field {
Field::Any => "any",
Field::Comm => "comm",
Field::Cmdline => "cmdline",
Field::User => "user",
};
let mode = if self.filter_regex {
"regex"
} else {
"substring"
};
let mut lines = vec![
(
format!("/ {}▏", self.filter_query),
s.body.add_modifier(Modifier::BOLD),
),
(
format!(
"field: {field} mode: {mode} · Tab field · ~ regex · Enter save · Esc close"
),
s.dim,
),
];
match self.compiled_filter() {
Some(c) => {
let hits: Vec<&Proc> = self.table.iter().filter(|p| c.matches(p)).collect();
lines.push((format!("{} live matches", hits.len()), s.body));
for p in hits.iter().take(LIST_ROWS - 2) {
lines.push((
format!(
"{:>7} {:<14} {}",
p.pid,
trunc(&p.comm, 14),
trunc(&p.cmdline, 46)
),
s.body,
));
}
}
None if !self.filter_query.is_empty() => {
lines.push(("invalid regex".to_string(), s.alert))
}
None => {}
}
if !self.filter_msg.is_empty() {
lines.push((self.filter_msg.clone(), s.title));
}
if !self.filters.filters.is_empty() {
let names: Vec<&str> = self
.filters
.filters
.iter()
.map(|f| f.name.as_str())
.collect();
lines.push((format!("saved: {}", names.join(", ")), s.dim));
}
self.render_lines(buf, area, s, "Regex / saved filters", lines);
}
fn render_diff(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let mut lines = Vec::new();
match &self.diff {
None => {
let n = self.baseline.as_ref().map(|b| b.procs.len()).unwrap_or(0);
lines.push((format!("baseline captured — {n} processes"), s.body));
lines.push(("press d again to diff against it".into(), s.dim));
}
Some(d) => {
lines.push((
format!(
"+{} started -{} exited ~{} changed",
d.added.len(),
d.removed.len(),
d.changed.len()
),
s.body.add_modifier(Modifier::BOLD),
));
for p in d.added.iter().take(5) {
lines.push((format!("+ {:>7} {}", p.pid, trunc(&p.comm, 40)), s.started));
}
for p in d.removed.iter().take(5) {
lines.push((format!("- {:>7} {}", p.pid, trunc(&p.comm, 40)), s.alert));
}
for c in d.changed.iter().take(6) {
lines.push((
format!(
"~ {:>7} {} cpu {:.0}→{:.0}",
c.pid,
trunc(&c.after.comm, 20),
c.before.cpu,
c.after.cpu
),
s.body,
));
}
}
}
lines.push(("r reset baseline · w write json · Esc close".into(), s.dim));
self.render_lines(buf, area, s, "Snapshot diff", lines);
}
fn render_alerts(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let hl = if self.alert_hl { "on" } else { "off" };
let mut lines = vec![
(format!("row highlight: {hl} (t: toggle)"), s.title),
("rules:".to_string(), s.dim),
];
for r in self.alerts_rules_view() {
lines.push((r, s.body));
}
lines.push((format!("firing now: {}", self.firings.len()), s.title));
for f in self.firings.iter().take(LIST_ROWS - 4) {
lines.push((
format!(
"! {:>7} {} = {:.0} ({} ticks)",
f.pid, f.rule, f.value, f.sustained
),
s.alert,
));
}
lines.push(("t toggle highlight · Esc close".into(), s.dim));
self.render_lines(buf, area, s, "Threshold alerts", lines);
}
fn alerts_rules_view(&self) -> Vec<String> {
default_rules()
.iter()
.map(|r| {
let m = match r.metric {
Metric::Cpu => "cpu%",
Metric::MemKb => "mem_kb",
};
format!(
" {} : {} ≥ {:.0} for {} ticks",
r.name, m, r.threshold, r.for_ticks
)
})
.collect()
}
fn render_graph(&self, buf: &mut Buffer, area: Rect, s: &Sty) {
let width = 48usize;
let height = 6usize;
let max = self.cpu_peak.max(1.0);
let rows = self.cpu_hist.render(width, height, max);
let mut lines: Vec<(String, Style)> = vec![(
format!(
"total CPU — peak {max:.0}% ({} samples)",
self.cpu_hist.len()
),
s.dim,
)];
for r in rows {
lines.push((r, s.spark));
}
match self.selected_pid {
Some(pid) => {
let spark = self.ring.cpu_sparkline(pid, width, 100.0);
lines.push((format!("pid {pid}: {spark}"), s.spark));
}
None => lines.push(("(select a process for its CPU history)".into(), s.dim)),
}
lines.push(("Esc close".into(), s.dim));
self.render_lines(buf, area, s, "CPU history graph", lines);
}
}
fn default_rules() -> Vec<Rule> {
vec![
Rule {
name: "hot-cpu".into(),
metric: Metric::Cpu,
threshold: 90.0,
for_ticks: 3,
},
Rule {
name: "big-mem".into(),
metric: Metric::MemKb,
threshold: 2_000_000.0,
for_ticks: 3,
},
]
}
fn filters_path() -> Option<std::path::PathBuf> {
crate::extensions::prefs::config_dir().map(|d| d.join("filters.json"))
}
fn load_filters() -> FilterStore {
filters_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| FilterStore::from_json(&s).ok())
.unwrap_or_default()
}
fn save_filters(store: &FilterStore) {
if let Some(p) = filters_path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(p, store.to_json());
}
}
fn write_artifact(name: &str, contents: &str) -> Option<String> {
let dir = crate::extensions::prefs::config_dir()?;
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(name);
std::fs::write(&path, contents).ok()?;
Some(path.display().to_string())
}
struct Sty {
bg: Color,
border: Style,
title: Style,
body: Style,
dim: Style,
sel: Style,
alert: Style,
started: Style,
spark: Style,
}
impl Sty {
fn new() -> Self {
let p: ModalPalette = modal_palette();
Sty {
bg: p.bg,
border: Style::default().fg(p.border),
title: Style::default()
.fg(p.title)
.bg(p.bg)
.add_modifier(Modifier::BOLD),
body: Style::default().fg(p.text).bg(p.bg),
dim: Style::default().fg(Color::Indexed(240)).bg(p.bg),
sel: Style::default().fg(p.bg).bg(p.accent),
alert: Style::default()
.fg(Color::Red)
.bg(p.bg)
.add_modifier(Modifier::BOLD),
started: Style::default().fg(Color::Green).bg(p.bg),
spark: Style::default().fg(p.accent).bg(p.bg),
}
}
}
fn trunc(s: &str, w: usize) -> String {
if s.chars().count() <= w {
s.to_string()
} else {
let mut out: String = s.chars().take(w.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn human_kb(kb: u64) -> String {
const M: u64 = 1024;
const G: u64 = 1024 * 1024;
if kb >= G {
format!("{:.1}G", kb as f64 / G as f64)
} else if kb >= M {
format!("{:.1}M", kb as f64 / M as f64)
} else {
format!("{kb}K")
}
}
thread_local! {
static PANELS: RefCell<PanelState> = RefCell::new(PanelState::new());
}
pub fn ingest(table: &Table) {
let rows = crate::extensions::bridge::snapshot_table(table);
let selected = crate::extensions::bridge::selected_pid(table);
PANELS.with(|p| p.borrow_mut().ingest(rows, selected));
}
pub fn dispatch_key(ch: i32) -> bool {
PANELS.with(|p| p.borrow_mut().handle(ch))
}
pub fn panel_active() -> bool {
PANELS.with(|p| p.borrow().any_active())
}
pub fn take_pending_select() -> Option<u32> {
PANELS.with(|p| p.borrow_mut().pending_select.take())
}
pub fn take_pending_key() -> Option<i32> {
PANELS.with(|p| p.borrow_mut().pending_key.take())
}
pub fn draw_active<W: Write>(out: &mut W) {
let (cols, rows) = terminal::size().unwrap_or((80, 24));
if cols < 30 || rows < 8 {
return;
}
PANELS.with(|p| {
let s = p.borrow();
if !s.any_active() {
return;
}
let area = Rect::new(0, 0, cols, rows);
let mut b = Buffer::empty(area);
s.render(&mut b, area);
blit(out, &b);
});
let _ = out.flush();
}
pub fn alert_attr(pid: u32) -> Option<i32> {
PANELS.with(|p| {
let s = p.borrow();
if s.alert_hl && s.firing.contains(&pid) {
Some(hot_row_attr())
} else {
None
}
})
}
fn hot_row_attr() -> i32 {
use crate::ported::crt::{ColorPair, Red, White, A_BOLD, A_REVERSE};
if matches!(ColorScheme::active(), ColorScheme::COLORSCHEME_MONOCHROME) {
A_REVERSE | A_BOLD
} else {
A_BOLD | ColorPair(White, Red)
}
}
pub fn row_height() -> i32 {
PANELS.with(|p| {
if p.borrow().spark == SparkMode::Double {
1 + SPARK_GRAPH_H
} else {
1
}
})
}
pub fn graph_lines(pid: u32) -> i32 {
PANELS.with(|p| {
let s = p.borrow();
if s.spark != SparkMode::Double {
return 0;
}
let cpu = s.ring.latest_cpu(pid);
if cpu <= 0.0 {
0
} else {
((cpu / 100.0 * SPARK_GRAPH_H as f32).ceil() as i32).clamp(1, SPARK_GRAPH_H)
}
})
}
pub fn draw_spark_col<W: Write>(out: &mut W, y: i32, x: i32, w: i32, pid: u32) {
PANELS.with(|p| {
let s = p.borrow();
if s.spark != SparkMode::Column || w as usize <= SPARK_W + 2 {
return;
}
let spark = s
.ring
.cpu_braille(pid, SPARK_W, 1, 100.0)
.into_iter()
.next()
.unwrap_or_default();
let sx = x + w - SPARK_W as i32;
Ncurses::attrset(
out,
ColorElements::PROCESS_MEGABYTES.packed(ColorScheme::active()),
);
Ncurses::mvaddstr(out, y, sx, &spark);
Ncurses::attrset(
out,
ColorElements::RESET_COLOR.packed(ColorScheme::active()),
);
});
}
pub fn draw_spark_row<W: Write>(out: &mut W, y_top: i32, x: i32, w: i32, n_rows: i32, pid: u32) {
PANELS.with(|p| {
let s = p.borrow();
if s.spark != SparkMode::Double || w <= 0 || n_rows <= 0 {
return;
}
let rows = s.ring.cpu_braille(pid, w as usize, n_rows as usize, 100.0);
Ncurses::attrset(
out,
ColorElements::PROCESS_MEGABYTES.packed(ColorScheme::active()),
);
for (i, row) in rows.iter().enumerate() {
Ncurses::mvaddstr(out, y_top + i as i32, x, row);
}
Ncurses::attrset(
out,
ColorElements::RESET_COLOR.packed(ColorScheme::active()),
);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::model::synthetic_table;
fn ingest_ticks(n: u64) {
for t in 0..n {
let rows = synthetic_table(t);
PANELS.with(|p| p.borrow_mut().ingest(rows, Some(200)));
}
}
#[test]
fn hotkey_opens_and_esc_closes_finder() {
assert!(!panel_active());
assert!(dispatch_key(0x66)); assert!(panel_active());
assert!(dispatch_key(27)); assert!(!panel_active());
}
#[test]
fn idle_non_hotkey_not_consumed() {
assert!(!dispatch_key(0x6b)); }
#[test]
fn finder_filters_by_query() {
ingest_ticks(1);
dispatch_key(0x66); for b in b"firefox" {
dispatch_key(*b as i32);
}
let hits = PANELS.with(|p| p.borrow().finder_hits.len());
assert!(hits >= 1, "expected firefox rows, got {hits}");
dispatch_key(27);
}
#[test]
fn aggregate_opens_cycles_and_closes() {
ingest_ticks(1);
assert!(!panel_active());
assert!(dispatch_key(0x79)); assert!(panel_active());
let first = PANELS.with(|p| p.borrow().agg_by);
dispatch_key(9); let second = PANELS.with(|p| p.borrow().agg_by);
assert_ne!(first, second);
assert_eq!(second, first.next()); dispatch_key(27); assert!(!panel_active());
}
#[test]
fn palette_matches_and_injects_action_key() {
ingest_ticks(1);
assert!(dispatch_key(0x3a)); assert!(panel_active());
for b in b"aggreg" {
dispatch_key(*b as i32);
}
let top = PANELS.with(|p| p.borrow().palette_hits.first().map(|m| CMDS[m.idx].1));
assert_eq!(top, Some(b'y' as i32));
dispatch_key(13); assert!(!panel_active());
assert_eq!(take_pending_key(), Some(b'y' as i32));
assert_eq!(take_pending_key(), None);
}
#[test]
fn palette_reaches_htop_actions_too() {
ingest_ticks(1);
dispatch_key(0x3a);
for b in b"kill" {
dispatch_key(*b as i32);
}
let top = PANELS.with(|p| p.borrow().palette_hits.first().map(|m| CMDS[m.idx].1));
assert_eq!(top, Some(b'k' as i32));
dispatch_key(27); assert!(!panel_active());
assert_eq!(take_pending_key(), None);
}
#[test]
fn aggregate_rolls_up_synthetic_table() {
ingest_ticks(1);
let groups =
PANELS.with(|p| aggregate(&p.borrow().table, crate::extensions::aggregate::GroupBy::User));
assert!(!groups.is_empty());
for w in groups.windows(2) {
assert!(w[0].cpu >= w[1].cpu, "groups must be CPU-descending");
}
}
#[test]
fn snapshot_then_diff_populates() {
ingest_ticks(1); dispatch_key(0x64); assert!(panel_active());
dispatch_key(27);
PANELS.with(|p| p.borrow_mut().ingest(synthetic_table(4), None));
dispatch_key(0x64); let removed = PANELS.with(|p| {
p.borrow()
.diff
.as_ref()
.map(|d| d.removed.len())
.unwrap_or(0)
});
assert!(removed >= 1, "pid 500 should show as removed");
dispatch_key(27);
}
#[test]
fn spark_toggle_and_alert_attr() {
assert_eq!(PANELS.with(|p| p.borrow().spark), SparkMode::Off);
assert_eq!(row_height(), 1);
dispatch_key(0x76);
assert_eq!(PANELS.with(|p| p.borrow().spark), SparkMode::Column);
assert_eq!(row_height(), 1); dispatch_key(0x76);
assert_eq!(PANELS.with(|p| p.borrow().spark), SparkMode::Double);
assert_eq!(row_height(), 1 + SPARK_GRAPH_H); dispatch_key(0x76);
assert_eq!(PANELS.with(|p| p.borrow().spark), SparkMode::Off);
assert_eq!(row_height(), 1);
let hot = Proc {
pid: 999,
ppid: 1,
user: "u".into(),
comm: "hot".into(),
cmdline: "hot".into(),
state: 'R',
cpu: 99.0,
mem_kb: 1,
};
for _ in 0..3 {
PANELS.with(|p| p.borrow_mut().ingest(vec![hot.clone()], None));
}
assert!(alert_attr(999).is_some());
assert!(alert_attr(1).is_none());
let selection = ColorElements::PANEL_SELECTION_FOCUS.packed(ColorScheme::active());
assert_ne!(alert_attr(999), Some(selection));
assert_eq!(alert_attr(999), Some(hot_row_attr()));
PANELS.with(|p| p.borrow_mut().alert_hl = false);
assert!(alert_attr(999).is_none());
assert_eq!(PANELS.with(|p| p.borrow().firing.len()), 1);
PANELS.with(|p| p.borrow_mut().alert_hl = true);
assert!(alert_attr(999).is_some());
}
#[test]
fn spark_col_never_panics_on_braille() {
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Column);
for t in 0..40 {
PANELS.with(|p| p.borrow_mut().ingest(synthetic_table(t), Some(200)));
}
let mut sink: Vec<u8> = Vec::new();
draw_spark_col(&mut sink, 3, 0, 80, 200);
assert!(!sink.is_empty());
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Off); }
#[test]
fn spark_row_full_width_never_panics() {
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Double);
for t in 0..40 {
PANELS.with(|p| p.borrow_mut().ingest(synthetic_table(t), Some(200)));
}
assert_eq!(row_height(), 1 + SPARK_GRAPH_H);
let mut sink: Vec<u8> = Vec::new();
draw_spark_row(&mut sink, 4, 0, 120, SPARK_GRAPH_H, 200);
assert!(!sink.is_empty());
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Off);
let mut empty: Vec<u8> = Vec::new();
draw_spark_row(&mut empty, 4, 0, 120, SPARK_GRAPH_H, 200);
assert!(empty.is_empty());
}
#[test]
fn hotkeys_emit_confirmation_toasts() {
use crate::extensions::overlay::status_text;
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Off);
dispatch_key(0x76); assert_eq!(status_text().as_deref(), Some("CPU graph: column"));
dispatch_key(0x47); assert_eq!(status_text().as_deref(), Some("CPU history graph"));
dispatch_key(27); dispatch_key(0x6f); assert!(status_text().is_some(), "export must toast");
dispatch_key(27);
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Off); }
#[test]
fn graph_lines_scale_with_cpu() {
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Double);
let mk = |pid: u32, cpu: f32| Proc {
pid,
ppid: 1,
user: "u".into(),
comm: "c".into(),
cmdline: "c".into(),
state: 'R',
cpu,
mem_kb: 1,
};
PANELS.with(|p| {
p.borrow_mut()
.ingest(vec![mk(10, 0.0), mk(11, 20.0), mk(12, 100.0)], None)
});
assert_eq!(graph_lines(10), 0);
assert_eq!(graph_lines(11), 1);
assert_eq!(graph_lines(12), SPARK_GRAPH_H);
PANELS.with(|p| p.borrow_mut().spark = SparkMode::Off);
assert_eq!(graph_lines(12), 0);
}
}