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

commitb973e4746a13c719af53f3a938a968abd85279ef
parent8a1ff4bf05
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 19:10
refactor: list rows and the process table lay out by list_gap

Every scrolling list's row interior goes through cce-ui's lay_row on the new
list_gap rung instead of its own insets and gaps: services' controls and
name, timers' dot, name and button run, the packages / accounts / wifi row
items, accounts' marks. Word buttons are as wide as their words. A timer's
item is cut at its cell, so a long schedule no longer runs under Run Now.

The process table's columns are as wide as their header or widest likely
value, with COMMAND taking the slack: a wide window shows the whole table,
a narrow one scrolls it. Header sort targets are their whole column; text is
centred in its row. section_margin went with its last user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/app.rs             |  12 --
 src/pages/accounts.rs  |  27 ++---
 src/pages/network.rs   |  12 +-
 src/pages/packages.rs  |   9 +-
 src/pages/processes.rs | 311 ++++++++++++++++++++++++++-----------------------
 src/pages/services.rs  |  35 +++---
 src/pages/timers.rs    |  56 +++++----
 7 files changed, 241 insertions(+), 221 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 503008d..248a1d5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -490,18 +490,6 @@ impl RenderTarget for PageContent {
 }
 
 
-/// The inset from a section well's rim to the app's own content, on top of
-/// `section_padding()`. cce-ui's `SectionContext` lays its title tab, its
-/// grid and a VStack's widgets out `2 * padding + DEFAULT_MARGIN_X` in, and
-/// `finish()` lands the well's wall `padding + DEFAULT_MARGIN_X` past the
-/// content — a rect the app places inside a well has to match that number or
-/// sit off the toolkit's own geometry. A well is this app's pane, so this is
-/// where the pane rung belongs; TODO(style): become `plate_padding()` once
-/// SectionContext reads the rung instead of its literal.
-pub fn section_margin() -> f32 {
-    cce_ui::layout::SectionContext::<PageContent>::DEFAULT_MARGIN_X
-}
-
 /// Width a label needs on a button plate. Feeds [`form_button_fit`], so a row of
 /// buttons is divided by what is written on them rather than into equal
 /// slices — "Reboot" and "Hibernate" are not the same size and a row that
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index f4a8a1d..fbf458a 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,6 +1,7 @@
 use crate::app::{form_button, form_divider, form_pairs, AppAction, PageContent};
 use cce_ui::widget::Owned;
-use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::widget::TextBox;
 
@@ -616,8 +617,8 @@ const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
 const TEXT_WARN: [f32; 4] = [0.90, 0.75, 0.40, 1.0];
 
 /// Gap between account rows. style: deliberate — list rows pack tighter
-/// than the pane gap, like every list on this app's pages (the inset from
-/// the region's edges is the well margin, `section_margin()`).
+/// than the pane gap, like every list on this app's pages (what stands
+/// inside a row is `list_gap()` apart and in from the region's edges).
 const LIST_GAP: f32 = 4.0;
 /// Rows shown before the region starts scrolling. The list sits ABOVE the
 /// actions and the edit form, so it cannot fill the page the way services'
@@ -662,8 +663,6 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
             let item_h = state.list.item_height;
             let rows_shown = state.accounts.len().min(LIST_MAX_ROWS);
             let list_h = rows_shown as f32 * (item_h + LIST_GAP) + 8.0;
-            // The list's own inset of its rows inside its frame.
-            let m = crate::app::section_margin();
             let list = &mut state.list;
             let accounts = &state.accounts;
             let keyring = &state.keyring;
@@ -674,7 +673,6 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                 list.set_rect(list_x, list_y, list_w, list_h);
                 list.update_bounds(accounts.len(), list_y, list_h);
                 list.push_prims(pc);
-                let btn_w = list_w - 2.0 * m;
                 pc.push_clip_rect(list_x, list_y, list_w, list_h);
                 for (idx, acc) in accounts.iter().enumerate() {
                     // Same predicate the region virtualizes on — a row scrolled out
@@ -690,12 +688,13 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                     } else {
                         ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
                     };
+                    let cell = lay_row(Rect { x: list_x, y: draw_y, width: list_w, height: item_h }, &[Cell::grow(item_h)])[0];
                     pc.button_left(
                         &acc.email,
-                        list_x + m,
-                        draw_y,
-                        btn_w,
-                        item_h,
+                        cell.x,
+                        cell.y,
+                        cell.width,
+                        cell.height,
                         bg,
                         hover,
                         TEXT_BTN,
@@ -717,12 +716,14 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                         let size = 12.0;
                         let ty = crate::app::label_y_in(draw_y, item_h, size, Some(&font));
                         let g = 11.0;
-                        let mut mx = list_x + m + cce_ui::layout::CONTROL_TEXT_INSET
+                        // A list gap before each mark; a glyph and its word, half that.
+                        let gap = cce_ui::layout::list_gap();
+                        let mut mx = cell.x + cce_ui::layout::CONTROL_TEXT_INSET
                             + crate::app::text_width(&acc.email, size, Some(&font));
                         for (icon, word, color) in marks {
-                            mx += 18.0;
+                            mx += gap;
                             if pc.icon(icon, mx, draw_y + (item_h - g) / 2.0, g, g, *color) {
-                                mx += g + 4.0;
+                                mx += g + gap / 2.0;
                             }
                             pc.text_with_font(word, mx, ty, size, TEXT_BTN, &font);
                             mx += crate::app::text_width(word, size, Some(&font));
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 2ce21b2..27c0f15 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,7 +1,8 @@
 use crate::app::{AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{Adapted, Toggle};
 
 #[derive(Debug, Clone)]
@@ -172,8 +173,6 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
     // ── WiFi (label-less well) ──
     builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
         let sec_w = sec.cw;
-        // The list's own inset of its rows inside its frame.
-        let margin = sec.padding().max(12.0);
         let mut form = sec.form();
         let mut col = form.column();
 
@@ -219,7 +218,8 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
                     list.update_bounds(available.len(), r.y, r.height);
                     list.push_prims(pc);
 
-                    let btn_w = r.width - 2.0 * margin;
+                    let row_h = list.item_height;
+                    let btn_w = r.width - 2.0 * cce_ui::layout::list_gap();
                     let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
 
                     pc.push_clip_rect(r.x, r.y, r.width, r.height);
@@ -232,8 +232,8 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
                             };
                             let label = format!("{}  ({}%)", ssid_truncated, net.signal);
                             let active = net.in_use;
-                            let row_x = r.x + margin;
-                            let row_h = 26.0;
+                            let cell = lay_row(Rect { x: r.x, y: draw_y, width: r.width, height: row_h }, &[Cell::grow(row_h)])[0];
+                            let row_x = cell.x;
                             pc.button(&label, row_x, draw_y, btn_w, row_h,
                                 if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
                                 if active { ACCENT } else { TEXT_FG },
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index 8208c1b..a94b991 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,7 +1,8 @@
 use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{TextBox, InteractiveListItem};
 
 #[derive(Debug, Clone, Default)]
@@ -872,7 +873,8 @@ pub fn view(
                             } else {
                                 Some(&pkg.name) == selected.as_ref()
                             };
-                            render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                            let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
+                            render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
                         }
                     }
                     pc.pop_clip_rect();
@@ -897,7 +899,8 @@ pub fn view(
                             item.title = pkg.name.clone();
                             item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
                             item.selected = Some(&pkg.name) == selected.as_ref();
-                            render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                            let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
+                            render_widget(pc, item, cell.x, cell.y, cell.width, cell.height, ctx);
                         }
                     }
                     pc.pop_clip_rect();
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 20c1199..c06a341 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -1,7 +1,8 @@
 use crate::app::{AppAction, PageContent};
 use crate::power_meter::{self, Meter, Mode};
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use std::sync::Mutex;
 
 #[derive(Debug, Clone)]
@@ -247,6 +248,44 @@ const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 /// The least room the table keeps on a short window.
 const LIST_MIN_H: f32 = 120.0;
+/// The table's header and row text sizes, and the sort chevron's.
+const HDR_SIZE: f32 = 11.0;
+const ROW_SIZE: f32 = 12.0;
+const CHEVRON: f32 = 9.0;
+/// The least room COMMAND keeps, in characters, before the table scrolls sideways.
+const COMMAND_MIN_CHARS: usize = 24;
+
+/// The table's columns in content space — PID, COMMAND, MEM, MEM %, CPU %, W, WAKE/s and
+/// Kill — and the content width they need. Each column is as wide as its header (and the
+/// sort chevron) or its widest likely value, whichever is wider; COMMAND takes the slack of
+/// a box wider than that, so a wide window shows the whole table and a narrow one scrolls
+/// it. They stand `list_gap` apart and in from the edges (`lay_row`).
+fn table_columns(box_w: f32, row_h: f32, kill_w: f32) -> ([Rect; 8], f32) {
+    let gap = cce_ui::layout::list_gap();
+    let w = |s: &str| crate::app::text_width(s, ROW_SIZE, None);
+    let head = |s: &str, sortable: bool| {
+        crate::app::text_width(s, HDR_SIZE, None) + if sortable { gap / 2.0 + CHEVRON } else { 0.0 }
+    };
+    let widths = [
+        w("4194304").max(head("PID", false)),
+        w(&"x".repeat(COMMAND_MIN_CHARS)),
+        w("999.9 GB").max(head("MEM", true)),
+        w("100.0%").max(head("MEM %", false)),
+        w("1000.0%").max(head("CPU %", true)),
+        w("99.9").max(head("W", true)),
+        w("99999").max(head("WAKE/s", true)),
+        kill_w,
+    ];
+    let needed = widths.iter().sum::<f32>() + gap * (widths.len() + 1) as f32;
+    let content_w = needed.max(box_w);
+    let cells: Vec<Cell> = widths
+        .iter()
+        .enumerate()
+        .map(|(i, &wd)| Cell { width: wd, height: row_h, grow: i == 1 })
+        .collect();
+    let rects = lay_row(Rect { x: 0.0, y: 0.0, width: content_w, height: row_h }, &cells);
+    (std::array::from_fn(|i| rects[i]), content_w)
+}
 
 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 {
     let mut final_pc = PageContent::new();
@@ -263,7 +302,6 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
         }
         // The table fills the page: the well's floor lands at the page's bottom.
         form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
-        let inset = crate::app::section_margin();
         let cpu_list = &mut state.cpu_list;
         let processes = &state.processes;
         let killing = &state.killing;
@@ -273,155 +311,132 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
         col.text(summary_line(&state.power), 11.0, TEXT_DIM);
         col.fill(LIST_MIN_H, move |pc, r, _| {
             let (list_box_x, list_box_y, list_box_w, list_box_h) = (r.x, r.y, r.width, r.height);
-                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
-                // scrollable viewport starts below the header.
-                //
-                // Columns live in CONTENT space at fixed offsets; every draw
-                // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
-                // Sized so the default 820px window shows the whole table without
-                // the h-bar: COMMAND gives up the 20px the content box's inset
-                // took.
-                const COL_PID: f32 = 12.0;
-                const COL_COMMAND: f32 = 80.0;
-                const COL_RSS: f32 = 380.0;
-                const COL_MEM: f32 = 460.0;
-                const COL_CPU: f32 = 525.0;
-                const COL_WATTS: f32 = 585.0;
-                const COL_WAKE: f32 = 635.0;
-                const COL_KILL: f32 = 700.0;
-                const CONTENT_W: f32 = 725.0;
-
-                // TODO(style): the column offsets, header hit-target nudges and
-                // in-row text centring below are this table's own layout.
-                let header_h = 22.0;
-                cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-                cpu_list.set_content_w(CONTENT_W);
-                // No band for the horizontal scrollbar: it rides the rows'
-                // centre line, crossing the vertical one, behind the plate until
-                // a scroll raises it.
-                let rows_h = list_box_h - header_h - 6.0;
-                cpu_list.update_bounds(processes.len(), list_box_y + header_h, rows_h);
-                cpu_list.push_prims(pc);
-
-                let ox = cpu_list.scroll_x;
-
-                // Header row is background-less (the well shows through); only the
-                // divider separates it from the rows.
-                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. The
-                // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
-                // shows brighter with a `chevron-down` glyph after it (the sort
-                // is always descending). Both memory headers toggle the same
-                // Mem sort — one bigger target, no distinction to learn.
-                let active = |k: ProcSort| sort == k;
-                let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
-                const HDR_SIZE: f32 = 11.0;
-                const HDR_Y: f32 = 5.0;
-                const CHEVRON: f32 = 9.0;
-                pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
-                let header = |pc: &mut PageContent, label: &str, col: f32, on: bool| {
-                    let x = list_box_x + col - ox;
-                    pc.text(label, x, list_box_y + HDR_Y, HDR_SIZE, hdr(on));
-                    if on {
-                        let gx = x + crate::app::text_width(label, HDR_SIZE, None) + 3.0;
-                        let gy = list_box_y + (header_h - CHEVRON) / 2.0;
-                        pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
-                    }
-                };
-                pc.text("PID", list_box_x + COL_PID - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
-                pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
-                header(pc, "MEM", COL_RSS, active(ProcSort::Mem));
-                pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + HDR_Y, HDR_SIZE, hdr(active(ProcSort::Mem)));
-                header(pc, "CPU %", COL_CPU, active(ProcSort::Cpu));
-                header(pc, "W", COL_WATTS, active(ProcSort::Power));
-                header(pc, "WAKE/s", COL_WAKE, active(ProcSort::Wakeups));
-
-                // Header hit targets, list-row style like the rows (plateless,
-                // washed on 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.
-                pc.list_row(list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
-                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
-                pc.list_row(list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
-                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
-                pc.list_row(list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
-                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
-                pc.list_row(list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
-                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
-                pc.list_row(list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
-                    AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
-                pc.pop_clip_rect();
-
-                let row_h = 24.0;
-
-                // Visible process rows, clipped to the rows' viewport — not on
-                // into the reserve band below it, where the scroll region still
-                // hands back a row starting there and its Kill button stood alone
-                // under the list.
-                pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
-                for (idx, p) in processes.iter().enumerate() {
-                    if let Some(draw_y) = cpu_list.get_item_draw_y(idx, 4.0) {
-                        // The row itself, in the DE's list style: plateless,
-                        // washed on hover. A transparent plain button here wore a
-                        // control plate per row — the whole list read as a stack
-                        // of carved rings. Viewport-fixed on purpose: the hover
-                        // band spans the visible row whatever the horizontal pan.
-                        pc.list_row(
-                            list_box_x + 2.0,
-                            draw_y,
-                            list_box_w - 16.0,
-                            row_h,
-                            AppAction::Processes(ProcessesMessage::None),
+            let gap = cce_ui::layout::list_gap();
+            // A row, and the header, are as tall as the region virtualizes rows at.
+            let row_h = cpu_list.item_height;
+            let header_h = row_h;
+            let kill_h = cce_ui::layout::button_height();
+
+            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
+            // scrollable viewport starts below the header. Columns live in CONTENT space;
+            // every draw subtracts scroll_x, and a table wider than the box scrolls.
+            let (cols, content_w) = table_columns(list_box_w, row_h, kill_h);
+            let [c_pid, c_cmd, c_rss, c_mem, c_cpu, c_watts, c_wake, c_kill] = cols;
+            cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            cpu_list.set_content_w(content_w);
+            // No band for the horizontal scrollbar: it rides the rows'
+            // centre line, crossing the vertical one, behind the plate until
+            // a scroll raises it.
+            let rows_h = list_box_h - header_h - gap;
+            cpu_list.update_bounds(processes.len(), list_box_y + header_h, rows_h);
+            cpu_list.push_prims(pc);
+
+            let ox = cpu_list.scroll_x;
+            let at = |c: Rect| list_box_x + c.x - ox;
+
+            // Header row is background-less (the well shows through); only the
+            // divider separates it from the rows.
+            pc.rect([0.18, 0.18, 0.24, 1.0], list_box_x, list_box_y + header_h, list_box_w, 1.0);
+
+            // Header labels pan with the columns, clipped to the box. The
+            // sortable ones (MEM, MEM %, CPU %, W, WAKE/s) are hit targets the
+            // width of their column: the active key shows brighter with a
+            // `chevron-down` glyph after it (the sort is always descending).
+            // Both memory headers toggle the same Mem sort — one bigger
+            // target, no distinction to learn.
+            let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
+            let hdr_y = crate::app::label_y_in(list_box_y, header_h, HDR_SIZE, None);
+            pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
+            let header = |pc: &mut PageContent, label: &str, c: Rect, on: bool, chevron: bool| {
+                let x = at(c);
+                pc.text(label, x, hdr_y, HDR_SIZE, hdr(on));
+                if on && chevron {
+                    let gx = x + crate::app::text_width(label, HDR_SIZE, None) + gap / 2.0;
+                    let gy = list_box_y + (header_h - CHEVRON) / 2.0;
+                    pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
+                }
+            };
+            header(pc, "PID", c_pid, false, false);
+            header(pc, "COMMAND", c_cmd, false, false);
+            header(pc, "MEM", c_rss, sort == ProcSort::Mem, true);
+            header(pc, "MEM %", c_mem, sort == ProcSort::Mem, false);
+            header(pc, "CPU %", c_cpu, sort == ProcSort::Cpu, true);
+            header(pc, "W", c_watts, sort == ProcSort::Power, true);
+            header(pc, "WAKE/s", c_wake, sort == ProcSort::Wakeups, true);
+            // The hit targets, list-row style like the rows (plateless, washed on
+            // 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.
+            for (c, key) in [(c_rss, ProcSort::Mem), (c_mem, ProcSort::Mem), (c_cpu, ProcSort::Cpu), (c_watts, ProcSort::Power), (c_wake, ProcSort::Wakeups)] {
+                pc.list_row(at(c), list_box_y, c.width, header_h, AppAction::Processes(ProcessesMessage::SortBy(key)));
+            }
+            pc.pop_clip_rect();
+
+            // Visible process rows, clipped to the rows' viewport — not on
+            // into the reserve band below it, where the scroll region still
+            // hands back a row starting there and its Kill button stood alone
+            // under the list.
+            pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
+            for (idx, p) in processes.iter().enumerate() {
+                if let Some(draw_y) = cpu_list.get_item_draw_y(idx, 4.0) {
+                    // The row itself, in the DE's list style: plateless,
+                    // washed on hover. A transparent plain button here wore a
+                    // control plate per row — the whole list read as a stack
+                    // of carved rings. Viewport-fixed on purpose: the hover
+                    // band spans the visible row whatever the horizontal pan.
+                    let wash = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: row_h }, &[Cell::grow(row_h)])[0];
+                    pc.list_row(wash.x, wash.y, wash.width, wash.height, AppAction::Processes(ProcessesMessage::None));
+
+                    // A pending kill dims the row until the next refresh
+                    // settles it (gone, or alive again = the kill didn't take).
+                    let dim = killing.contains(&p.pid);
+                    let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
+                    let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
+                    let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+                    let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
+
+                    let ty = crate::app::label_y_in(draw_y, row_h, ROW_SIZE, None);
+                    let cell = |pc: &mut PageContent, text: &str, c: Rect, color: [f32; 4]| {
+                        let x = at(c);
+                        pc.text_with_bounds(text, x, ty, ROW_SIZE, color, Some([x, ty - ROW_SIZE, x + c.width, ty + 2.0 * ROW_SIZE]));
+                    };
+                    cell(pc, &p.pid, c_pid, fg);
+                    cell(pc, &p.command, c_cmd, fg);
+                    cell(pc, &format_rss(p.rss_kb), c_rss, mem_fg);
+                    cell(pc, &format!("{}%", p.mem_pct), c_mem, mem_fg);
+                    cell(pc, &format!("{}%", p.cpu), c_cpu, cpu_fg);
+                    cell(pc, &format_watts(p.watts), c_watts, watt_fg);
+                    cell(pc, &format_wakeups(p.wakeups), c_wake, fg);
+
+                    // Kill button, in content space like the columns. Emitted
+                    // AFTER the row button on purpose: overlapping page
+                    // buttons all see the click and the LAST take_click wins
+                    // the dispatched action (input_handler's collect loop), so
+                    // the Kill button beats the row's no-op exactly because it comes later.
+                    if !dim {
+                        // The `x` glyph in the danger tint; "Kill" only if
+                        // the icon set is missing.
+                        pc.button_icon_tinted(
+                            "x",
+                            "Kill",
+                            at(c_kill),
+                            draw_y + (row_h - kill_h) / 2.0,
+                            c_kill.width,
+                            kill_h,
+                            [0.0, 0.0, 0.0, 0.0],
+                            [0.75, 0.30, 0.30, 0.45],
+                            [0.85, 0.55, 0.55, 1.0],
+                            AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
                         );
-
-                        // A pending kill dims the row until the next refresh
-                        // settles it (gone, or alive again = the kill didn't take).
-                        let dim = killing.contains(&p.pid);
-                        let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
-                        let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
-                        let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
-                        let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
-
-                        pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
-                        pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
-                        pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
-                        pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
-                        pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
-                        pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
-                        pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
-
-                        // Kill button, in content space like the columns. Emitted
-                        // AFTER the row button on purpose: overlapping page
-                        // buttons all see the click and the LAST take_click wins
-                        // the dispatched action (input_handler's collect loop), so
-                        // the Kill button beats the row's no-op exactly because it comes later.
-                        if !dim {
-                            // The `x` glyph in the danger tint; "Kill" only if
-                            // the icon set is missing.
-                            let kill_h = cce_ui::layout::button_height();
-                            pc.button_icon_tinted(
-                                "x",
-                                "Kill",
-                                list_box_x + COL_KILL - ox,
-                                draw_y + (row_h - kill_h) / 2.0,
-                                kill_h,
-                                kill_h,
-                                [0.0, 0.0, 0.0, 0.0],
-                                [0.75, 0.30, 0.30, 0.45],
-                                [0.85, 0.55, 0.55, 1.0],
-                                AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
-                            );
-                        }
                     }
                 }
-                pc.pop_clip_rect();
-                // The scrollbar's fore copy, over the rows at the raise's fade.
-                cpu_list.push_scrollbar_fore(pc);
-            
-                if processes.is_empty() {
-                    pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
-                }
+            }
+            pc.pop_clip_rect();
+            // The scrollbar's fore copy, over the rows at the raise's fade.
+            cpu_list.push_scrollbar_fore(pc);
+
+            if processes.is_empty() {
+                pc.text("No active processes", list_box_x + gap, list_box_y + header_h + gap, 12.0, TEXT_DIM);
+            }
         });
         sec.place(form, ctx);
     });
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 328a02a..22fcfe7 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,7 +1,8 @@
 use crate::app::{form_button, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{TextBox, InteractiveListItem};
 
 #[derive(Debug, Clone)]
@@ -135,26 +136,24 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
                         // "sta". `upload_icon` caches per (name, px), so asking
                         // every row costs one hash lookup.
                         let icons_ok = cce_ui::upload_icon("play", 32).is_some();
-                        let is_small = sec_w < 350.0;
                         let btn_h = cce_ui::layout::button_height();
-                        let (btn_w, r_btn_w) = if icons_ok { (btn_h, btn_h) } else { (46.0, 54.0) };
-                        let btn_gap = if is_small { 4.0 } else { 6.0 };
-
-                        // TODO(style): the row's control run and text column
-                        // below are this list row's own layout.
-                        let toggle_x = list_box_x + 10.0;
-                        let restart_x = toggle_x + btn_w + btn_gap;
-                        // The row's text starts after the controls and ends the
-                        // controls' inset short of the right wall: no gutter for
-                        // the scrollbar, which rides the list's centre line.
-                        let item_x = restart_x + r_btn_w + 10.0;
-                        let item_w = (list_box_x + list_box_w - 10.0) - item_x;
-
-                        let btn_y = draw_y + (item_h - btn_h) / 2.0;
+                        // Without the icon set the controls are word buttons, as wide as
+                        // their words.
+                        let (btn_w, r_btn_w) = if icons_ok {
+                            (btn_h, btn_h)
+                        } else {
+                            (crate::app::button_need("Start").max(crate::app::button_need("Stop")), crate::app::button_need("Restart"))
+                        };
+                        // The controls, then the name and description to the row's end
+                        // (no gutter for the scrollbar, which rides the list's centre line).
+                        let row = Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h };
+                        let cells = lay_row(row, &[Cell::fixed(btn_w, btn_h), Cell::fixed(r_btn_w, btn_h), Cell::grow(item_h)]);
+                        let (toggle_x, restart_x, btn_y) = (cells[0].x, cells[1].x, cells[0].y);
+                        let (item_x, item_w) = (cells[2].x, cells[2].width);
 
                         // Service description, truncated to the room the row's text
-                        // column actually has (the item insets its labels by 8px).
-                        let text_max_w = item_w - 16.0;
+                        // column actually has (the item insets its labels from its walls).
+                        let text_max_w = item_w - 2.0 * cce_ui::layout::CONTROL_TEXT_INSET;
                         let max_chars = ((text_max_w / 6.0) as usize).max(10);
                         // `failed` was the status dot's third state and has nowhere
                         // else to show: a failed unit offers the same play button a
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index b91fa50..211929f 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -5,7 +5,8 @@
 use crate::app::{button_need, form_button, AppAction, PageContent};
 use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::layout::{lay_row, render_widget, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::scene::layout::Rect;
 use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -84,6 +85,8 @@ pub enum TimersMessage {
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 /// The least room the list keeps on a short window.
 const LIST_MIN_H: f32 = 120.0;
+/// A row's status dot: a small LED, not a control.
+const DOT: f32 = 10.0;
 
 /// "46min" / "3h" / "5d 3h" — coarse two-unit humanization.
 fn humanize(secs: u64) -> String {
@@ -501,23 +504,31 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                         let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
                         // Glyph buttons are square at the control height.
                         let btn_h = cce_ui::layout::button_height();
-                        let run_w = if compact { btn_h } else { 76.0 };
-                        let en_w = if compact { btn_h } else { 66.0 };
-                        let edit_w = if compact { btn_h } else { 50.0 };
-                        let btn_gap = if is_small { 4.0 } else { 6.0 };
-                        // TODO(style): the row's button run, dot and text
-                        // column below are this list row's own layout.
-                        let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
-
-                        let en_x = right_edge - en_w;
-                        let run_x = en_x - btn_gap - run_w;
-                        let edit_x = run_x - btn_gap - edit_w;
-
-                        let btn_y = draw_y + (item_h - btn_h) / 2.0;
-
-                        // Title + schedule subtitle (truncated to the space before the buttons).
-                        let text_left_edge = if timer.editable { edit_x } else { run_x };
-                        let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
+                        // Word buttons are as wide as their words.
+                        let run_label = if is_small { "Run" } else { "Run Now" };
+                        let word_w = |words: &[&str]| words.iter().map(|w| crate::app::button_need(w)).fold(0.0, f32::max);
+                        let run_w = if compact { btn_h } else { word_w(&[run_label]) };
+                        let en_w = if compact { btn_h } else { word_w(&["Disable", "Enable", "static"]) };
+                        let edit_w = if compact { btn_h } else { word_w(&["Edit"]) };
+
+                        // The status dot, the name and schedule, then the buttons at the
+                        // row's end (Edit only for an editable unit).
+                        let row = Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h };
+                        let mut layout = vec![Cell::fixed(DOT, DOT), Cell::grow(item_h)];
+                        if timer.editable {
+                            layout.push(Cell::fixed(edit_w, btn_h));
+                        }
+                        layout.push(Cell::fixed(run_w, btn_h));
+                        layout.push(Cell::fixed(en_w, btn_h));
+                        let cells = lay_row(row, &layout);
+                        let (dot_rect, item_rect) = (cells[0], cells[1]);
+                        let en_x = cells[cells.len() - 1].x;
+                        let run_x = cells[cells.len() - 2].x;
+                        let edit_x = if timer.editable { cells[2].x } else { run_x };
+                        let btn_y = cells[cells.len() - 1].y;
+
+                        // Title + schedule subtitle, truncated to the item's text room.
+                        let text_max_w = item_rect.width - 2.0 * cce_ui::layout::CONTROL_TEXT_INSET;
                         let max_chars = ((text_max_w / 6.0) as usize).max(10);
                         let subtitle_full = schedule_line(timer, now);
                         let subtitle = if subtitle_full.len() > max_chars {
@@ -529,11 +540,14 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                         let item_btn = &mut items[idx];
                         item_btn.title = timer.unit.clone();
                         item_btn.subtitle = Some(subtitle);
-                        render_widget(pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+                        // Cut at its cell, so a long schedule stops short of the buttons.
+                        pc.push_clip_rect(item_rect.x, item_rect.y, item_rect.width, item_rect.height);
+                        render_widget(pc, item_btn, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
+                        pc.pop_clip_rect();
 
                         let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
                         let mut dot = Owned::new(StatusDot::new(dot_state));
-                        render_widget(pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
+                        render_widget(pc, &mut dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
 
                         let active_txt = [0.90, 0.90, 0.95, 1.0];
 
@@ -610,7 +624,7 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                                 );
                             }
                             _ => {
-                                pc.text("static", en_x + 8.0, btn_y + (btn_h - 12.0) / 2.0, 11.0, TEXT_DIM);
+                                pc.text("static", en_x + cce_ui::layout::CONTROL_TEXT_INSET, crate::app::label_y_in(btn_y, btn_h, 11.0, None), 11.0, TEXT_DIM);
                             }
                         }
                     }