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 }