git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/processes.rs (32.7K)

  1 use crate::app::{AppAction, PageContent};
  2 use crate::power_meter::{self, Meter, Mode};
  3 use cce_ui::widget::ScrollRegion;
  4 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
  5 use std::sync::Mutex;
  6 
  7 #[derive(Debug, Clone)]
  8 pub struct ProcessRow {
  9     pub pid: String,
 10     pub cpu: String,
 11     pub mem_pct: String,
 12     pub rss_kb: u64,
 13     pub command: String,
 14     /// Estimated draw from [`power_meter`]; None until the meter has two
 15     /// samples of this pid, or when nothing on the host reports watts.
 16     pub watts: Option<f32>,
 17     /// Context switches per second over the last interval — the honest
 18     /// signal for a process that burns power while looking idle.
 19     pub wakeups: Option<f32>,
 20 }
 21 
 22 /// The whole-machine side of the estimate, for the line above the list.
 23 #[derive(Debug, Clone, PartialEq)]
 24 pub struct PowerSummary {
 25     pub mode: Mode,
 26     pub total_w: Option<f32>,
 27     pub floor_w: Option<f32>,
 28     pub attributed_w: f32,
 29 }
 30 
 31 impl Default for PowerSummary {
 32     fn default() -> Self {
 33         Self { mode: Mode::Warming, total_w: None, floor_w: None, attributed_w: 0.0 }
 34     }
 35 }
 36 
 37 /// One meter for the page's lifetime: attribution is a delta between
 38 /// consecutive fetches, so the previous snapshot has to outlive the fetch.
 39 static METER: Mutex<Option<Meter>> = Mutex::new(None);
 40 
 41 /// Which column orders the list. Cpu is the default (ps sorts the fetch);
 42 /// Mem is the toggle — clicking a memory header switches to it, clicking
 43 /// again switches back.
 44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
 45 pub enum ProcSort {
 46     #[default]
 47     Cpu,
 48     Mem,
 49     Power,
 50     Wakeups,
 51 }
 52 
 53 #[derive(Debug, Clone)]
 54 pub struct ProcessesState {
 55     pub loaded: bool,
 56     pub processes: Vec<ProcessRow>,
 57     pub cpu_list: ScrollRegion,
 58     /// Pids a kill was requested for, dimmed until the next refresh. The
 59     /// refresh clears it: a killed process is gone from the new list, and a
 60     /// survivor (EPERM, ignored TERM) un-dims — an honest "didn't die".
 61     pub killing: std::collections::HashSet<String>,
 62     /// Active sort column. Survives refreshes: Refreshed re-sorts the fresh
 63     /// list under this key rather than resetting to the fetch order.
 64     pub sort: ProcSort,
 65     pub power: PowerSummary,
 66 }
 67 
 68 impl Default for ProcessesState {
 69     fn default() -> Self {
 70         Self {
 71             loaded: false,
 72             processes: Vec::new(),
 73             cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
 74             killing: std::collections::HashSet::new(),
 75             sort: ProcSort::Cpu,
 76             power: PowerSummary::default(),
 77         }
 78     }
 79 }
 80 
 81 #[derive(Debug, Clone)]
 82 pub enum ProcessesMessage {
 83     Refreshed(ProcessesState),
 84     /// The row's ✕ button: SIGTERM this pid.
 85     Kill(String),
 86     /// A column header click. Mem headers toggle (Mem ⇄ back to Cpu); the
 87     /// CPU % header always selects Cpu.
 88     SortBy(ProcSort),
 89     None,
 90 }
 91 
 92 /// Order rows under the active key, descending. Stable, so ties keep their
 93 /// relative fetch order. Cpu re-parses the ps figure — after a Mem spell the
 94 /// fetch order is long gone from the Vec.
 95 fn sort_rows(rows: &mut [ProcessRow], sort: ProcSort) {
 96     match sort {
 97         ProcSort::Cpu => rows.sort_by(|a, b| {
 98             let (av, bv) = (a.cpu.parse::<f32>().unwrap_or(0.0), b.cpu.parse::<f32>().unwrap_or(0.0));
 99             bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal)
100         }),
101         ProcSort::Mem => rows.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
102         // Unknowns sink below every known figure, however small.
103         ProcSort::Power => rows.sort_by(|a, b| {
104             b.watts.unwrap_or(-1.0).partial_cmp(&a.watts.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
105         }),
106         ProcSort::Wakeups => rows.sort_by(|a, b| {
107             b.wakeups.unwrap_or(-1.0).partial_cmp(&a.wakeups.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
108         }),
109     }
110 }
111 
112 /// Watts to one decimal; a dash below 0.05 W, so hundreds of idle rows do
113 /// not read as fake precision, and for rows the meter has no figure for.
114 pub fn format_watts(w: Option<f32>) -> String {
115     match w {
116         Some(w) if w >= 0.05 => format!("{:.1}", w),
117         _ => "\u{2014}".to_string(),
118     }
119 }
120 
121 pub fn format_wakeups(w: Option<f32>) -> String {
122     match w {
123         Some(w) if w >= 0.5 => format!("{:.0}", w),
124         _ => "\u{2014}".to_string(),
125     }
126 }
127 
128 /// The line above the list: what was measured, what is baseline, what the
129 /// column adds up to — and, when the column is dashes, why.
130 pub fn summary_line(p: &PowerSummary) -> String {
131     let how = match p.mode {
132         Mode::Warming => return "Measuring power\u{2026}".to_string(),
133         Mode::Unavailable => {
134             return "Per-process power needs the battery discharging or readable RAPL counters  \u{00b7}  wakeups only"
135                 .to_string()
136         }
137         Mode::Battery => "estimated from battery draw",
138         Mode::Rapl => "RAPL",
139     };
140     let mut parts = Vec::new();
141     if let Some(t) = p.total_w {
142         parts.push(format!("{:.1} W total", t));
143     }
144     if let Some(f) = p.floor_w {
145         parts.push(format!("{:.1} W baseline", f));
146     }
147     parts.push(format!("{:.1} W attributed to processes ({})", p.attributed_w, how));
148     parts.join("  \u{00b7}  ")
149 }
150 
151 /// nvidia-smi's draw figure, asked for only while the card is awake: the
152 /// query itself would wake a suspended card, costing watts to report a zero.
153 async fn dgpu_draw_w() -> Option<f64> {
154     if !power_meter::dgpu_awake() {
155         return None;
156     }
157     let out = tokio::process::Command::new("nvidia-smi")
158         .args(["--query-gpu=power.draw", "--format=csv,noheader,nounits"])
159         .output()
160         .await
161         .ok()?;
162     if !out.status.success() {
163         return None;
164     }
165     String::from_utf8_lossy(&out.stdout).lines().next()?.trim().parse().ok()
166 }
167 
168 /// Process name from a `ps … cmd` field: basename of argv[0], so cce binaries
169 /// longer than the kernel's 15-char `comm` cap display whole (`comm` showed
170 /// "cce-system-inte"). Kernel threads (`[kworker/0:1]`) keep their brackets.
171 pub fn command_display(cmd: &str) -> String {
172     let first = cmd.split_whitespace().next().unwrap_or(cmd);
173     if first.starts_with('[') {
174         return first.to_string();
175     }
176     std::path::Path::new(first)
177         .file_name()
178         .and_then(|n| n.to_str())
179         .unwrap_or(first)
180         .to_string()
181 }
182 
183 /// Humanized RSS from ps's KiB figure.
184 pub fn format_rss(kb: u64) -> String {
185     if kb >= 1_048_576 {
186         format!("{:.1} GB", kb as f64 / 1_048_576.0)
187     } else if kb >= 1024 {
188         format!("{} MB", kb / 1024)
189     } else {
190         format!("{} KB", kb)
191     }
192 }
193 
194 pub async fn fetch_processes_state() -> ProcessesState {
195     let dgpu_w = dgpu_draw_w().await;
196     let attribution = tokio::task::spawn_blocking(move || {
197         let snap = power_meter::sample(dgpu_w);
198         METER.lock().unwrap().get_or_insert_with(Meter::new).tick(snap)
199     })
200     .await
201     .ok();
202     let power = attribution
203         .as_ref()
204         .map(|a| PowerSummary { mode: a.mode, total_w: a.total_w, floor_w: a.floor_w, attributed_w: a.attributed_w })
205         .unwrap_or_default();
206 
207     let processes = {
208         let mut list = Vec::new();
209         if let Some(o) = tokio::process::Command::new("ps")
210             .args(["-eo", "pid,%cpu,%mem,rss,cmd", "--sort=-%cpu"])
211             .output().await.ok()
212         {
213             let text = String::from_utf8_lossy(&o.stdout);
214             for line in text.lines().skip(1) {
215                 let parts: Vec<&str> = line.split_whitespace().collect();
216                 if parts.len() >= 5 {
217                     let pp = parts[0]
218                         .parse::<u32>()
219                         .ok()
220                         .and_then(|pid| attribution.as_ref().and_then(|a| a.per_pid.get(&pid)).copied());
221                     list.push(ProcessRow {
222                         pid: parts[0].to_string(),
223                         cpu: parts[1].to_string(),
224                         mem_pct: parts[2].to_string(),
225                         rss_kb: parts[3].parse().unwrap_or(0),
226                         command: command_display(&parts[4..].join(" ")),
227                         watts: pp.and_then(|p| p.watts),
228                         wakeups: pp.map(|p| p.wakeups_per_s),
229                     });
230                 }
231             }
232         }
233         list
234     };
235 
236     ProcessesState {
237         loaded: true,
238         processes,
239         cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
240         killing: std::collections::HashSet::new(),
241         sort: ProcSort::Cpu,
242         power,
243     }
244 }
245 
246 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
247 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
248 
249 pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
250     let mut final_pc = PageContent::new();
251     let sec_w = 320.0f32;
252     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
253 
254     // ── Processes Section (label-less well) ──
255     builder.add_section_spanned(&mut final_pc, "", 1, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
256         let rx = sec.left;
257         if !state.loaded {
258             sec.text("Loading processes...", 12.0, 0.0, 12.0, TEXT_FG);
259         } else {
260             // Scrolling box configuration for process list: one even inset
261             // between the list and the well's walls on all four sides — the
262             // well margin the toolkit's SectionContext lays out on.
263             let inset = crate::app::section_margin();
264             let list_box_x = rx + inset;
265             let list_box_w = sec.cw - 2.0 * inset;
266             // Power summary line above the list; the list starts below it.
267             let summary_h = 18.0;
268             sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
269             let list_box_y = sec.well_top() + inset + summary_h;
270             // Fill the page: the well's bottom wall lands at the page bottom,
271             // the list keeps its even inset inside the well.
272             let list_box_h = ((cy + ch) - inset - list_box_y).max(120.0);
273 
274             // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
275             // scrollable viewport starts below the header.
276             //
277             // Columns live in CONTENT space at fixed offsets; every draw
278             // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
279             const COL_PID: f32 = 12.0;
280             const COL_COMMAND: f32 = 80.0;
281             const COL_RSS: f32 = 400.0;
282             const COL_MEM: f32 = 480.0;
283             const COL_CPU: f32 = 545.0;
284             const COL_WATTS: f32 = 605.0;
285             const COL_WAKE: f32 = 655.0;
286             const COL_KILL: f32 = 720.0;
287             const CONTENT_W: f32 = 745.0;
288 
289             // TODO(style): the column offsets, header hit-target nudges and
290             // in-row text centring below are this table's own layout.
291             let header_h = 22.0;
292             state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
293             state.cpu_list.set_content_w(CONTENT_W);
294             // The bottom scrollbar needs its own band: rows must stop above
295             // it or the last row draws under the pills.
296             let bottom_reserve = if state.cpu_list.h_scroll_active() { 18.0 } else { 6.0 };
297             state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - bottom_reserve);
298             state.cpu_list.push_prims(sec.pc);
299 
300             let ox = state.cpu_list.scroll_x;
301 
302             // Header row is background-less (the well shows through); only the
303             // divider separates it from the rows.
304             sec.pc.rect([0.18, 0.18, 0.24, 1.0], list_box_x + 1.0, list_box_y + header_h, list_box_w - 2.0, 1.0); // Divider
305 
306             // Header labels pan with the columns, clipped to the box. The
307             // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
308             // shows brighter with a ▾. Both memory headers toggle the same
309             // Mem sort — one bigger target, no distinction to learn.
310             let active = |k: ProcSort| state.sort == k;
311             let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
312             let mark = |label: &str, on: bool| {
313                 if on { format!("{} \u{25bc}", label) } else { label.to_string() }
314             };
315             sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
316             sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
317             sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
318             sec.pc.text(&mark("MEM", active(ProcSort::Mem)), list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
319             sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
320             sec.pc.text(&mark("CPU %", active(ProcSort::Cpu)), list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Cpu)));
321             sec.pc.text(&mark("W", active(ProcSort::Power)), list_box_x + COL_WATTS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Power)));
322             sec.pc.text(&mark("WAKE/s", active(ProcSort::Wakeups)), list_box_x + COL_WAKE - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Wakeups)));
323 
324             // Invisible header hit targets (transparent, subtle hover), inside
325             // the header clip so they pan and cut with the labels. They share
326             // no rect with the row buttons, so emission order is free here.
327             sec.pc.button("", list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
328                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
329             sec.pc.button("", list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
330                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
331             sec.pc.button("", list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
332                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
333             sec.pc.button("", list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
334                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
335             sec.pc.button("", list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
336                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
337             sec.pc.pop_clip_rect();
338 
339             let row_h = 24.0;
340 
341             // Visible process rows rendering (virtualized/clipped)
342             sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, list_box_h - header_h);
343             for (idx, p) in state.processes.iter().enumerate() {
344                 if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
345                     // Standard row action button (transparent background, highlights
346                     // on hover). Viewport-fixed on purpose: the hover band spans the
347                     // visible row whatever the horizontal pan.
348                     sec.pc.button(
349                         "",
350                         list_box_x + 2.0,
351                         draw_y,
352                         list_box_w - 16.0,
353                         row_h,
354                         [0.0, 0.0, 0.0, 0.0],
355                         [1.0, 1.0, 1.0, 0.06],
356                         [0.0, 0.0, 0.0, 0.0],
357                         AppAction::Processes(ProcessesMessage::None),
358                     );
359 
360                     // A pending kill dims the row until the next refresh
361                     // settles it (gone, or alive again = the kill didn't take).
362                     let dim = state.killing.contains(&p.pid);
363                     let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
364                     let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
365                     let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
366                     let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
367 
368                     sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
369                     sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
370                     sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
371                     sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
372                     sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
373                     sec.pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
374                     sec.pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
375 
376                     // Kill button, in content space like the columns. Emitted
377                     // AFTER the row button on purpose: overlapping page
378                     // buttons all see the click and the LAST take_click wins
379                     // the dispatched action (input_handler's collect loop), so
380                     // ✕ beats the row's no-op exactly because it comes later.
381                     if !dim {
382                         sec.pc.button(
383                             "\u{00d7}",
384                             list_box_x + COL_KILL - ox,
385                             draw_y + 3.0,
386                             20.0,
387                             row_h - 6.0,
388                             [0.0, 0.0, 0.0, 0.0],
389                             [0.75, 0.30, 0.30, 0.45],
390                             [0.85, 0.55, 0.55, 1.0],
391                             AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
392                         );
393                     }
394                 }
395             }
396             sec.pc.pop_clip_rect();
397             
398             if state.processes.is_empty() {
399                 sec.pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
400             }
401 
402             // End the section so the well's bottom wall sits one margin below
403             // the list: finish() places the wall at content_y + padding +
404             // margin, so the list's own bottom margin and that one cancel.
405             sec.content_y = list_box_y + list_box_h - sec.padding();
406         }
407     });
408 
409     final_pc
410 }
411 
412 pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
413     match msg {
414         ProcessesMessage::Refreshed(new) => {
415             state.loaded = new.loaded;
416             state.processes = new.processes;
417             state.power = new.power;
418             // The fetch arrives cpu-ordered; a non-default sort re-applies so
419             // a refresh never silently flips the list back.
420             if state.sort != ProcSort::Cpu {
421                 sort_rows(&mut state.processes, state.sort);
422             }
423             // Fresh list = every pending kill has resolved one way or the
424             // other; rows that survived un-dim (the kill didn't take).
425             state.killing.clear();
426         }
427         ProcessesMessage::SortBy(key) => {
428             // Clicking the already-active header (Mem, Power, Wakeups)
429             // toggles back to the Cpu default; CPU % is always a plain select.
430             state.sort = if state.sort == key { ProcSort::Cpu } else { key };
431             sort_rows(&mut state.processes, state.sort);
432         }
433         ProcessesMessage::Kill(pid) => {
434             // Plain SIGTERM, same privileges as the app. No confirm dialog:
435             // the target is a small ✕ the pointer has to mean. Failure needs
436             // no channel — a survivor un-dims on the next 3s refresh.
437             // Parsed, not passed through: `kill 0` signals the whole process
438             // group (this app included), and negative pids kill groups too.
439             if pid.parse::<u32>().is_ok_and(|n| n > 0) {
440                 let _ = std::process::Command::new("kill").arg(&pid).spawn();
441                 state.killing.insert(pid);
442             }
443         }
444         ProcessesMessage::None => {}
445     }
446 }
447 
448 impl crate::pages::AppPage for ProcessesState {
449     // Sections: [Processes]
450     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
451         vec![Vec::new()]
452     }
453 
454     fn view(
455         &mut self,
456         cx: f32,
457         cy: f32,
458         cw: f32,
459         ch: f32,
460         root_focused: bool,
461         sec_focused: &[bool],
462         layout: &mut dyn LayoutStrategy,
463         ctx: &mut cce_ui::context::UiContext,
464     ) -> crate::app::PageContent {
465         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
466     }
467 
468     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
469 
470     fn handle_pointer_move(
471         &mut self,
472         lx: f32,
473         ly: f32,
474         _actions: &mut Vec<crate::app::AppAction>,
475         _ctx: &mut cce_ui::context::UiContext,
476     ) -> bool {
477         self.loaded && self.cpu_list.cursor_moved(lx, ly)
478     }
479 
480     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
481         self.loaded && self.cpu_list.press(lx, ly)
482     }
483 
484     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
485         self.cpu_list.release()
486     }
487 
488     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
489         self.loaded && self.cpu_list.wheel(delta, lx, ly)
490     }
491 
492     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
493         self.loaded && self.cpu_list.keyboard(event)
494     }
495 
496     fn tick(&mut self, dt: f32) -> bool {
497         self.cpu_list.tick(dt)
498     }
499 }
500 
501 #[cfg(test)]
502 mod tests {
503     use super::*;
504 
505     #[test]
506     fn test_view_layout_grid() {
507         let mut state = ProcessesState::default();
508         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
509         let sec_focused = vec![false];
510         let mut ctx = cce_ui::context::UiContext::new();
511         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
512         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
513     }
514 
515     #[test]
516     fn command_display_prefers_basename_and_keeps_kernel_threads() {
517         // Longer than the 15-char comm cap ps used to truncate at.
518         assert_eq!(command_display("/home/x/.local/bin/cce-system-interface --flag"), "cce-system-interface");
519         assert_eq!(command_display("bash"), "bash");
520         assert_eq!(command_display("[kworker/0:1-events]"), "[kworker/0:1-events]");
521     }
522 
523     #[test]
524     fn format_rss_humanizes() {
525         assert_eq!(format_rss(512), "512 KB");
526         assert_eq!(format_rss(4096), "4 MB");
527         assert_eq!(format_rss(2_200_000), "2.1 GB");
528     }
529 
530     fn row(pid: &str) -> ProcessRow {
531         ProcessRow {
532             pid: pid.to_string(),
533             cpu: "1.0".to_string(),
534             mem_pct: "2.0".to_string(),
535             rss_kb: 1024,
536             command: "proc".to_string(),
537             watts: None,
538             wakeups: None,
539         }
540     }
541 
542     #[test]
543     fn kill_buttons_emitted_after_row_buttons_and_skip_pending() {
544         let mut state = ProcessesState { loaded: true, ..Default::default() };
545         state.processes = (1..=3).map(|i| row(&i.to_string())).collect();
546         state.killing.insert("2".to_string());
547         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
548         let sec_focused = vec![false];
549         let mut ctx = cce_ui::context::UiContext::new();
550         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
551 
552         let kills: Vec<usize> = pc
553             .buttons
554             .iter()
555             .enumerate()
556             .filter(|(_, (_, a, _))| matches!(a, AppAction::Processes(ProcessesMessage::Kill(_))))
557             .map(|(i, _)| i)
558             .collect();
559         // One ✕ per row except the pending one (pid 2).
560         assert_eq!(kills.len(), 2, "{:?}", pc.buttons.iter().map(|(_, a, _)| a).collect::<Vec<_>>());
561         // Ordering invariant the dispatch relies on: each ✕ comes after its
562         // row's hover button — last take_click wins, so ✕ must be later.
563         let rows: Vec<usize> = pc
564             .buttons
565             .iter()
566             .enumerate()
567             .filter(|(_, (_, a, _))| matches!(a, AppAction::Processes(ProcessesMessage::None)))
568             .map(|(i, _)| i)
569             .collect();
570         assert!(kills[0] > rows[0]);
571     }
572 
573     #[test]
574     fn kill_guards_and_dim_lifecycle() {
575         let mut state = ProcessesState { loaded: true, ..Default::default() };
576         // Group-signal and garbage pids are refused outright.
577         update(&mut state, ProcessesMessage::Kill("0".to_string()));
578         update(&mut state, ProcessesMessage::Kill("-1".to_string()));
579         update(&mut state, ProcessesMessage::Kill("abc".to_string()));
580         update(&mut state, ProcessesMessage::Kill(String::new()));
581         assert!(state.killing.is_empty());
582 
583         // A real (nonexistent, > pid_max) pid marks the row...
584         update(&mut state, ProcessesMessage::Kill("99999999".to_string()));
585         assert!(state.killing.contains("99999999"));
586 
587         // ...and the next refresh clears every pending mark.
588         update(
589             &mut state,
590             ProcessesMessage::Refreshed(ProcessesState { loaded: true, ..Default::default() }),
591         );
592         assert!(state.killing.is_empty());
593     }
594 
595     fn sized_row(pid: &str, cpu: &str, rss: u64) -> ProcessRow {
596         ProcessRow {
597             pid: pid.to_string(),
598             cpu: cpu.to_string(),
599             mem_pct: "0.0".to_string(),
600             rss_kb: rss,
601             command: "p".to_string(),
602             watts: None,
603             wakeups: None,
604         }
605     }
606 
607     #[test]
608     fn sort_toggles_between_mem_and_cpu() {
609         let mut state = ProcessesState { loaded: true, ..Default::default() };
610         // Fetch order = cpu descending; memory order differs deliberately.
611         state.processes = vec![
612             sized_row("a", "9.0", 100),
613             sized_row("b", "5.0", 900),
614             sized_row("c", "1.0", 500),
615         ];
616         let order = |s: &ProcessesState| s.processes.iter().map(|p| p.pid.clone()).collect::<Vec<_>>();
617 
618         // Mem header: sort by RSS descending.
619         update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
620         assert_eq!(state.sort, ProcSort::Mem);
621         assert_eq!(order(&state), ["b", "c", "a"]);
622 
623         // Same header again: back to the cpu default, re-sorted (the fetch
624         // order is gone from the Vec, so this must actually parse and sort).
625         update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
626         assert_eq!(state.sort, ProcSort::Cpu);
627         assert_eq!(order(&state), ["a", "b", "c"]);
628 
629         // CPU % header while already Cpu: stays Cpu.
630         update(&mut state, ProcessesMessage::SortBy(ProcSort::Cpu));
631         assert_eq!(state.sort, ProcSort::Cpu);
632     }
633 
634     #[test]
635     fn refresh_preserves_active_mem_sort() {
636         let mut state = ProcessesState { loaded: true, ..Default::default() };
637         update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
638 
639         let fresh = ProcessesState {
640             loaded: true,
641             processes: vec![sized_row("x", "9.0", 10), sized_row("y", "1.0", 999)],
642             ..Default::default()
643         };
644         update(&mut state, ProcessesMessage::Refreshed(fresh));
645         // The cpu-ordered fetch was re-sorted under the surviving Mem key.
646         assert_eq!(state.sort, ProcSort::Mem);
647         assert_eq!(state.processes[0].pid, "y");
648     }
649 
650     #[test]
651     fn header_sort_buttons_emitted() {
652         let mut state = ProcessesState { loaded: true, ..Default::default() };
653         state.processes = vec![sized_row("1", "1.0", 1)];
654         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
655         let sec_focused = vec![false];
656         let mut ctx = cce_ui::context::UiContext::new();
657         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
658         let sorts: Vec<ProcSort> = pc
659             .buttons
660             .iter()
661             .filter_map(|(_, a, _)| match a {
662                 AppAction::Processes(ProcessesMessage::SortBy(k)) => Some(*k),
663                 _ => None,
664             })
665             .collect();
666         // MEM + MEM % both toggle Mem; CPU % selects Cpu; then W and WAKE/s.
667         assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu, ProcSort::Power, ProcSort::Wakeups]);
668         // Active-sort indicator rides the CPU % header by default.
669         assert!(pc.texts.iter().any(|t| t.0.starts_with("CPU %") && t.0.contains('\u{25bc}')));
670     }
671 
672     #[test]
673     fn columns_pan_with_horizontal_scroll() {
674         let mut state = ProcessesState { loaded: true, ..Default::default() };
675         state.processes = (0..3).map(|i| row(&i.to_string())).collect();
676         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
677         let sec_focused = vec![false];
678         let mut ctx = cce_ui::context::UiContext::new();
679 
680         // Prefix match: the active sort column carries a " ▾" suffix.
681         let header_x = |pc: &crate::app::PageContent, s: &str| {
682             pc.texts.iter().find(|t| t.0.starts_with(s)).map(|t| t.2).unwrap()
683         };
684         let pc0 = view(&mut state, 10.0, 20.0, 500.0, 400.0, false, &sec_focused, &mut layout, &mut ctx);
685         let x0 = header_x(&pc0, "MEM %");
686         // Content (650) is wider than the ~500px page, so the list is
687         // h-scrollable; pan and every column shifts left by exactly that.
688         assert!(state.cpu_list.h_scroll_active());
689         state.cpu_list.scroll_x = 40.0;
690         let pc1 = view(&mut state, 10.0, 20.0, 500.0, 400.0, false, &sec_focused, &mut layout, &mut ctx);
691         assert_eq!(header_x(&pc1, "MEM %"), x0 - 40.0);
692         assert_eq!(header_x(&pc1, "CPU %"), header_x(&pc0, "CPU %") - 40.0);
693     }
694 
695     #[test]
696     fn power_sort_puts_unknowns_last_and_toggles_back() {
697         let mut state = ProcessesState { loaded: true, ..Default::default() };
698         let mut a = sized_row("a", "9.0", 1);
699         let mut b = sized_row("b", "5.0", 1);
700         let c = sized_row("c", "1.0", 1);
701         a.watts = Some(0.2);
702         b.watts = Some(1.5);
703         state.processes = vec![a, b, c];
704         let order = |s: &ProcessesState| s.processes.iter().map(|p| p.pid.clone()).collect::<Vec<_>>();
705         update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
706         assert_eq!(order(&state), ["b", "a", "c"]);
707         update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
708         assert_eq!(state.sort, ProcSort::Cpu);
709         assert_eq!(order(&state), ["a", "b", "c"]);
710     }
711 
712     #[test]
713     fn watts_and_wakeups_format_with_dashes_for_noise() {
714         assert_eq!(format_watts(None), "\u{2014}");
715         assert_eq!(format_watts(Some(0.04)), "\u{2014}");
716         assert_eq!(format_watts(Some(0.05)), "0.1");
717         assert_eq!(format_watts(Some(2.345)), "2.3");
718         assert_eq!(format_wakeups(None), "\u{2014}");
719         assert_eq!(format_wakeups(Some(0.2)), "\u{2014}");
720         assert_eq!(format_wakeups(Some(12.6)), "13");
721     }
722 
723     #[test]
724     fn summary_line_states_mode_and_reason() {
725         let p = PowerSummary { mode: Mode::Battery, total_w: Some(16.0), floor_w: Some(10.0), attributed_w: 6.0 };
726         assert_eq!(
727             summary_line(&p),
728             "16.0 W total  \u{00b7}  10.0 W baseline  \u{00b7}  6.0 W attributed to processes (estimated from battery draw)"
729         );
730         assert!(summary_line(&PowerSummary::default()).starts_with("Measuring"));
731         let u = PowerSummary { mode: Mode::Unavailable, ..Default::default() };
732         assert!(summary_line(&u).contains("wakeups only"));
733     }
734 
735     #[test]
736     fn refresh_carries_the_power_summary_and_rows_show_watts() {
737         let mut state = ProcessesState { loaded: true, ..Default::default() };
738         let mut r = sized_row("7", "1.0", 1);
739         r.watts = Some(1.26);
740         r.wakeups = Some(40.0);
741         let fresh = ProcessesState {
742             loaded: true,
743             processes: vec![r],
744             power: PowerSummary { mode: Mode::Battery, total_w: Some(20.0), floor_w: Some(15.0), attributed_w: 1.26 },
745             ..Default::default()
746         };
747         update(&mut state, ProcessesMessage::Refreshed(fresh));
748         assert_eq!(state.power.mode, Mode::Battery);
749         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
750         let mut ctx = cce_ui::context::UiContext::new();
751         let pc = view(&mut state, 10.0, 20.0, 900.0, 600.0, false, &[false], &mut layout, &mut ctx);
752         let all: Vec<&str> = pc.texts.iter().map(|t| t.0.as_str()).collect();
753         assert!(pc.texts.iter().any(|t| t.0 == "1.3"), "{all:?}");
754         assert!(pc.texts.iter().any(|t| t.0 == "40"));
755         assert!(pc.texts.iter().any(|t| t.0.starts_with("20.0 W total")));
756     }
757 }