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

commita9b1bf8abcfd6728003db9dd5d6def0999afe32f
parentf1d004945d
authorLucas Galante <[email protected]>
date2026-08-20 15:00
feat: memory sort toggle on the Processes column headers

The MEM and MEM % headers are now click targets that switch the list to RSS-descending; clicking the active one again returns to the CPU default, and CPU % is always a plain select. Both memory headers drive the same toggle — one bigger target, no distinction to learn. The active column brightens and carries a U+25BC triangle (chosen over U+25BE, which the UI font lacks — it rendered tofu; 25BC is the glyph the page dropdown already proves out).

The sort key lives in state and Refreshed re-sorts the fresh cpu-ordered fetch under it — without that, every 3s poll silently flipped the list back. Cpu re-sorting parses the ps figure, since after a Mem spell the fetch order is long gone from the Vec; both sorts are stable so ties keep their fetch order.

Header buttons sit inside the header clip, so they pan and cut with the labels; they share no rect with the row buttons, so the last-take_click-wins ordering rule doesn't apply to them.

Shadow-verified: MEM click sorts descending with the CPU column visibly scrambled, the order and marker survive two watcher refreshes with fresh numbers, and a second MEM click restores strict cpu-descending order under CPU % ▼. Unit tests pin the toggle cycle, refresh preservation, and header button emission.

Co-Authored-By: Claude <[email protected]>

 src/pages/processes.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 141 insertions(+), 5 deletions(-)

diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 9d0ef33..e54c94d 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -11,6 +11,16 @@ pub struct ProcessRow {
     pub command: String,
 }
 
+/// Which column orders the list. Cpu is the default (ps sorts the fetch);
+/// Mem is the toggle — clicking a memory header switches to it, clicking
+/// again switches back.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum ProcSort {
+    #[default]
+    Cpu,
+    Mem,
+}
+
 #[derive(Debug, Clone)]
 pub struct ProcessesState {
     pub loaded: bool,
@@ -20,6 +30,9 @@ pub struct ProcessesState {
     /// refresh clears it: a killed process is gone from the new list, and a
     /// survivor (EPERM, ignored TERM) un-dims — an honest "didn't die".
     pub killing: std::collections::HashSet<String>,
+    /// Active sort column. Survives refreshes: Refreshed re-sorts the fresh
+    /// list under this key rather than resetting to the fetch order.
+    pub sort: ProcSort,
 }
 
 impl Default for ProcessesState {
@@ -29,6 +42,7 @@ impl Default for ProcessesState {
             processes: Vec::new(),
             cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
             killing: std::collections::HashSet::new(),
+            sort: ProcSort::Cpu,
         }
     }
 }
@@ -38,9 +52,25 @@ pub enum ProcessesMessage {
     Refreshed(ProcessesState),
     /// The row's ✕ button: SIGTERM this pid.
     Kill(String),
+    /// A column header click. Mem headers toggle (Mem ⇄ back to Cpu); the
+    /// CPU % header always selects Cpu.
+    SortBy(ProcSort),
     None,
 }
 
+/// Order rows under the active key, descending. Stable, so ties keep their
+/// relative fetch order. Cpu re-parses the ps figure — after a Mem spell the
+/// fetch order is long gone from the Vec.
+fn sort_rows(rows: &mut [ProcessRow], sort: ProcSort) {
+    match sort {
+        ProcSort::Cpu => rows.sort_by(|a, b| {
+            let (av, bv) = (a.cpu.parse::<f32>().unwrap_or(0.0), b.cpu.parse::<f32>().unwrap_or(0.0));
+            bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal)
+        }),
+        ProcSort::Mem => rows.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
+    }
+}
+
 /// Process name from a `ps … cmd` field: basename of argv[0], so cce binaries
 /// longer than the kernel's 15-char `comm` cap display whole (`comm` showed
 /// "cce-system-inte"). Kernel threads (`[kworker/0:1]`) keep their brackets.
@@ -96,6 +126,7 @@ pub async fn fetch_processes_state() -> ProcessesState {
         processes,
         cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
         killing: std::collections::HashSet::new(),
+        sort: ProcSort::Cpu,
     }
 }
 
@@ -151,13 +182,31 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             // divider separates it from the rows.
             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
 
-            // Header labels pan with the columns, clipped to the box.
+            // Header labels pan with the columns, clipped to the box. The
+            // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
+            // shows brighter with a ▾. Both memory headers toggle the same
+            // Mem sort — one bigger target, no distinction to learn.
+            let active = |k: ProcSort| state.sort == k;
+            let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
+            let mark = |label: &str, on: bool| {
+                if on { format!("{} \u{25bc}", label) } else { label.to_string() }
+            };
             sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
             sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
             sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
-            sec.pc.text("MEM", list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
-            sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
-            sec.pc.text("CPU %", list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            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)));
+            sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
+            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)));
+
+            // Invisible header hit targets (transparent, subtle hover), inside
+            // the header clip so they pan and cut with the labels. They share
+            // no rect with the row buttons, so emission order is free here.
+            sec.pc.button("", list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
+                [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+            sec.pc.button("", list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+                [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+            sec.pc.button("", list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+                [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
             sec.pc.pop_clip_rect();
 
             let row_h = 24.0;
@@ -234,10 +283,21 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
         ProcessesMessage::Refreshed(new) => {
             state.loaded = new.loaded;
             state.processes = new.processes;
+            // The fetch arrives cpu-ordered; a non-default sort re-applies so
+            // a refresh never silently flips the list back.
+            if state.sort != ProcSort::Cpu {
+                sort_rows(&mut state.processes, state.sort);
+            }
             // Fresh list = every pending kill has resolved one way or the
             // other; rows that survived un-dim (the kill didn't take).
             state.killing.clear();
         }
+        ProcessesMessage::SortBy(key) => {
+            // Clicking the already-active Mem header toggles back to the Cpu
+            // default; clicking CPU % is always a plain select.
+            state.sort = if state.sort == key { ProcSort::Cpu } else { key };
+            sort_rows(&mut state.processes, state.sort);
+        }
         ProcessesMessage::Kill(pid) => {
             // Plain SIGTERM, same privileges as the app. No confirm dialog:
             // the target is a small ✕ the pointer has to mean. Failure needs
@@ -394,6 +454,81 @@ mod tests {
         assert!(state.killing.is_empty());
     }
 
+    fn sized_row(pid: &str, cpu: &str, rss: u64) -> ProcessRow {
+        ProcessRow {
+            pid: pid.to_string(),
+            cpu: cpu.to_string(),
+            mem_pct: "0.0".to_string(),
+            rss_kb: rss,
+            command: "p".to_string(),
+        }
+    }
+
+    #[test]
+    fn sort_toggles_between_mem_and_cpu() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        // Fetch order = cpu descending; memory order differs deliberately.
+        state.processes = vec![
+            sized_row("a", "9.0", 100),
+            sized_row("b", "5.0", 900),
+            sized_row("c", "1.0", 500),
+        ];
+        let order = |s: &ProcessesState| s.processes.iter().map(|p| p.pid.clone()).collect::<Vec<_>>();
+
+        // Mem header: sort by RSS descending.
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
+        assert_eq!(state.sort, ProcSort::Mem);
+        assert_eq!(order(&state), ["b", "c", "a"]);
+
+        // Same header again: back to the cpu default, re-sorted (the fetch
+        // order is gone from the Vec, so this must actually parse and sort).
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
+        assert_eq!(state.sort, ProcSort::Cpu);
+        assert_eq!(order(&state), ["a", "b", "c"]);
+
+        // CPU % header while already Cpu: stays Cpu.
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Cpu));
+        assert_eq!(state.sort, ProcSort::Cpu);
+    }
+
+    #[test]
+    fn refresh_preserves_active_mem_sort() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Mem));
+
+        let fresh = ProcessesState {
+            loaded: true,
+            processes: vec![sized_row("x", "9.0", 10), sized_row("y", "1.0", 999)],
+            ..Default::default()
+        };
+        update(&mut state, ProcessesMessage::Refreshed(fresh));
+        // The cpu-ordered fetch was re-sorted under the surviving Mem key.
+        assert_eq!(state.sort, ProcSort::Mem);
+        assert_eq!(state.processes[0].pid, "y");
+    }
+
+    #[test]
+    fn header_sort_buttons_emitted() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        state.processes = vec![sized_row("1", "1.0", 1)];
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let sec_focused = vec![false];
+        let mut ctx = cce_ui::context::UiContext::new();
+        let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+        let sorts: Vec<ProcSort> = pc
+            .buttons
+            .iter()
+            .filter_map(|(_, a, _)| match a {
+                AppAction::Processes(ProcessesMessage::SortBy(k)) => Some(*k),
+                _ => None,
+            })
+            .collect();
+        // MEM + MEM % both toggle Mem; CPU % selects Cpu.
+        assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu]);
+        // Active-sort indicator rides the CPU % header by default.
+        assert!(pc.texts.iter().any(|t| t.0.starts_with("CPU %") && t.0.contains('\u{25bc}')));
+    }
+
     #[test]
     fn columns_pan_with_horizontal_scroll() {
         let mut state = ProcessesState { loaded: true, ..Default::default() };
@@ -402,8 +537,9 @@ mod tests {
         let sec_focused = vec![false];
         let mut ctx = cce_ui::context::UiContext::new();
 
+        // Prefix match: the active sort column carries a " ▾" suffix.
         let header_x = |pc: &crate::app::PageContent, s: &str| {
-            pc.texts.iter().find(|t| t.0 == s).map(|t| t.2).unwrap()
+            pc.texts.iter().find(|t| t.0.starts_with(s)).map(|t| t.2).unwrap()
         };
         let pc0 = view(&mut state, 10.0, 20.0, 500.0, 400.0, false, &sec_focused, &mut layout, &mut ctx);
         let x0 = header_x(&pc0, "MEM %");