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

src/pages/processes.rs (36.3K)

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