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 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 LIST_ROWS: usize = 16;
#[derive(Clone, Copy, PartialEq)]
enum Modal {
None,
Finder,
Filter,
Diff,
Export,
Alerts,
Graph,
}
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_col: bool,
pending_select: Option<u32>,
finder_query: String,
finder_hits: Vec<finder::Match>,
finder_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 {
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_col: false,
pending_select: None,
finder_query: String::new(),
finder_hits: Vec::new(),
finder_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, 0x47 => self.modal = Modal::Graph, 0x76 => self.toggle_spark(), _ => 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::Filter => self.filter_key(code),
Modal::Diff => self.diff_key(code),
Modal::Export | Modal::Alerts | 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();
}
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_filter(&mut self) {
self.modal = Modal::Filter;
self.filter_msg.clear();
}
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;
}
Some(base) => {
let now = Snapshot::capture(self.tick, &self.table);
self.diff = Some(diff(base, &now));
}
}
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;
}
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}"),
None => self.export_msg = "write failed (no config dir)".into(),
}
}
}
_ => 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)) => format!("{a}\n{b}"),
_ => "export failed (no config dir)".into(),
};
self.modal = Modal::Export;
}
fn toggle_spark(&mut self) {
self.spark_col = !self.spark_col;
}
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::None => {}
}
}
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 mut lines = vec![("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(("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
}
}
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 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.firing.contains(&pid) {
Some(ColorElements::FAILED_SEARCH.packed(ColorScheme::active()))
} else {
None
}
})
}
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_col || w as usize <= SPARK_W + 2 {
return;
}
let spark = s.ring.cpu_sparkline(pid, SPARK_W, 100.0);
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()),
);
});
}
#[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 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() {
dispatch_key(0x76);
assert!(PANELS.with(|p| p.borrow().spark_col));
dispatch_key(0x76);
assert!(!PANELS.with(|p| p.borrow().spark_col));
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());
}
#[test]
fn spark_col_never_panics_on_braille() {
dispatch_key(0x76); 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());
dispatch_key(0x76); }
}