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 }