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

commit34ff51a27bf2dab3eae040612258f9dc5db4c5ef
parentd4df45b0cf
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:59
fix(processes): the list inside its section's clip, buttons inside theirs

The Processes list laid itself out at `left + 12`, the section margin it
was written against before sections clipped their content to the toolkit's
content box (`left + 2 * padding + 12`). The list sat 2 * padding outside
that clip, so the power summary lost its first letter and each row's hover
wash its left edge. It now fills `content_left()` / `content_width()`, and
COMMAND gives up the 20px that costs so the default window still shows the
whole table without the horizontal bar.

A lone Kill button stood under the last row. The scroll region hands back
a row that starts just past its viewport, and the rows were clipped at the
list's bottom rather than the viewport's, so that row survived into the
reserve band. And though the renderer clamps a page button's quad and
label to its clip, its plate carve and icon face ignored it: a button cut
by its list edge drew its whole plate and glyph past it. Rows now clip at
their viewport, and a page button's plate and icon face carry its clip —
which also covers the half-scrolled row buttons on Services and Timers.
Button faces join the page's own glyphs as `PlacedIcon`s, which already
had a clip, instead of a clipless tuple list of their own.

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

 src/input_handler.rs   |  3 --
 src/main.rs            | 42 ++++++++++++++------------
 src/pages/processes.rs | 81 ++++++++++++++++++++++++++++++++++++++++----------
 src/renderer.rs        | 27 ++++++++++++-----
 4 files changed, 107 insertions(+), 46 deletions(-)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index 53a662c..a3ca70b 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -346,9 +346,6 @@ impl SystemInterface {
         // The glyphs ride with the page too — a button's icon face and the
         // page's own icons — or they stand still while their rows scroll
         // past them until something rebuilds.
-        for (_, _, y, _, _, _) in &mut self.page_button_images {
-            *y -= actual_dy;
-        }
         for icon in &mut self.page_icon_images {
             icon.y -= actual_dy;
             if let Some(c) = icon.clip.as_mut() {
diff --git a/src/main.rs b/src/main.rs
index 87b15ff..78800ea 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -152,15 +152,17 @@ struct SystemInterface {
     /// `popover_control_relief_marks`; the page layer interleaves the same way.
     page_control_relief_marks: Vec<usize>,
     /// Control troughs from `PageContent::control_reliefs` (page coords,
-    /// pre-scroll) — each carved as a flush inset plate after the section wells.
-    page_control_reliefs: Vec<ControlCarve>,
-    /// Icon faces for the page's buttons — `(image, x, y, w, h, alpha)`, page
-    /// coords already scroll-shifted by the renderer. A flat host draws no
-    /// images at all otherwise: `all_quads` carries quads and the text list
-    /// carries labels, and an icon is neither.
-    page_button_images: Vec<(u32, f32, f32, f32, f32, f32)>,
-    /// The page's own glyphs (`PageContent::icon`: a sort chevron, an in-use
-    /// check), drawn with the button faces under the page clip.
+    /// pre-scroll) — each carved as a flush inset plate after the section
+    /// wells — with the clip its control was emitted under (`[x, y, w, h]`,
+    /// page coords, pre-scroll). A button's plate needs it: the renderer
+    /// clamps the button's quad to its list, but a carve is drawn whole, so a
+    /// row half-scrolled out of a list left its plate standing past the edge.
+    page_control_reliefs: Vec<(ControlCarve, Option<[f32; 4]>)>,
+    /// The page's glyphs, drawn under the page clip and each its own: a
+    /// button's icon face, and the page's own (`PageContent::icon`: a sort
+    /// chevron, an in-use check). A flat host draws no images at all
+    /// otherwise: `all_quads` carries quads and the text list carries labels,
+    /// and an icon is neither.
     page_icon_images: Vec<PlacedIcon>,
     /// Glyphs the popovers drew (the expanded Dropdown's chevron), drawn over
     /// the popover plates.
@@ -303,7 +305,6 @@ impl cce_ui::engine::Application for SystemInterface {
             page_reliefs: Vec::new(),
             page_control_reliefs: Vec::new(),
             page_control_relief_marks: Vec::new(),
-            page_button_images: Vec::new(),
             page_icon_images: Vec::new(),
             popover_icon_images: Vec::new(),
             window_icon_images: Vec::new(),
@@ -456,8 +457,13 @@ impl cce_ui::engine::Application for SystemInterface {
                 .peekable();
             let mut emit_pending = |pc: &mut cce_ui::scene::paint::PaintCtx, upto: usize| {
                 while carves.peek().map_or(false, |&(_, mark)| mark <= upto) {
-                    let (carve, _) = carves.next().unwrap();
-                    pc.clip(view, |pc| emit_control_carve(pc, carve.shifted_y(-scroll_y)));
+                    let ((carve, clip), _) = carves.next().unwrap();
+                    pc.clip(view, |pc| match clip {
+                        Some([x, y, w, h]) => pc.clip(Rect { x, y: y - scroll_y, width: w, height: h }, |pc| {
+                            emit_control_carve(pc, carve.shifted_y(-scroll_y))
+                        }),
+                        None => emit_control_carve(pc, carve.shifted_y(-scroll_y)),
+                    });
                 }
             };
             for (i, w) in self.widgets.iter().enumerate() {
@@ -565,15 +571,13 @@ impl cce_ui::engine::Application for SystemInterface {
             });
         }
 
-        // Button icon faces, over the page's quads and its carves — clipped to
-        // the page viewport, which is what cuts a half-scrolled list row's icon
-        // at the list edge (an image has no geometry to trim, only a clip).
-        if !self.page_button_images.is_empty() || !self.page_icon_images.is_empty() {
+        // The page's glyphs — button icon faces and the page's own — over
+        // its quads and carves, under the page viewport and each under the
+        // clip it was placed with, which is what cuts a half-scrolled list
+        // row's glyph at the list edge.
+        if !self.page_icon_images.is_empty() {
             let view = self.page_view(width, height);
             pc.clip(view, |pc| {
-                for &(image, x, y, w, h, alpha) in &self.page_button_images {
-                    pc.image(image, Rect { x, y, width: w, height: h }, alpha);
-                }
                 for icon in &self.page_icon_images {
                     draw_placed_icon(pc, icon);
                 }
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 896a3bc..9ebb07c 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -253,16 +253,17 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
 
     // ── Processes Section (label-less well) ──
     builder.add_section_spanned(&mut final_pc, "", 1, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
-        let rx = sec.left;
         if !state.loaded {
             sec.text("Loading processes...", 12.0, 0.0, 12.0, TEXT_FG);
         } else {
-            // Scrolling box configuration for process list: one even inset
-            // between the list and the well's walls on all four sides — the
-            // well margin the toolkit's SectionContext lays out on.
+            // The list fills the section's content box across — the box the
+            // section clips everything it draws to. Laid out at `left + 12`
+            // instead, it started 2 * padding outside that clip, which cut
+            // the first letter off the summary line and the left edge off
+            // every row's hover wash. Top and bottom keep the well margin.
             let inset = crate::app::section_margin();
-            let list_box_x = rx + inset;
-            let list_box_w = sec.cw - 2.0 * inset;
+            let list_box_x = sec.content_left();
+            let list_box_w = sec.content_width();
             // Power summary line above the list; the list starts below it.
             let summary_h = 18.0;
             sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
@@ -276,15 +277,18 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             //
             // 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, Kill
+            // column clear of the scrollbar, 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 = 400.0;
-            const COL_MEM: f32 = 480.0;
-            const COL_CPU: f32 = 545.0;
-            const COL_WATTS: f32 = 605.0;
-            const COL_WAKE: f32 = 655.0;
-            const COL_KILL: f32 = 720.0;
-            const CONTENT_W: f32 = 745.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.
@@ -294,7 +298,8 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             // The bottom scrollbar needs its own band: rows must stop above
             // it or the last row draws under the pills.
             let bottom_reserve = if state.cpu_list.h_scroll_active() { 18.0 } else { 6.0 };
-            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - bottom_reserve);
+            let rows_h = list_box_h - header_h - bottom_reserve;
+            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, rows_h);
             state.cpu_list.push_prims(sec.pc);
 
             let ox = state.cpu_list.scroll_x;
@@ -349,8 +354,11 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
 
             let row_h = 24.0;
 
-            // Visible process rows rendering (virtualized/clipped)
-            sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, list_box_h - header_h);
+            // 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.
+            sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
             for (idx, p) in state.processes.iter().enumerate() {
                 if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
                     // The row itself, in the DE's list style: plateless,
@@ -728,6 +736,47 @@ mod tests {
         assert_eq!(order(&state), ["a", "b", "c"]);
     }
 
+    #[test]
+    fn list_lies_inside_the_section_clip() {
+        // The list used to sit at `left + 12`, 2 * padding outside the
+        // content box the section clips to: the summary lost its first
+        // letter and every row's hover wash its left edge.
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        state.processes = (0..40).map(|i| row(&i.to_string())).collect();
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let mut ctx = cce_ui::context::UiContext::new();
+        let pc = view(&mut state, 10.0, 20.0, 820.0, 640.0, false, &[false], &mut layout, &mut ctx);
+        for t in &pc.texts {
+            if let Some(b) = t.6 {
+                assert!(t.2 >= b[0], "{:?} starts at {} left of its clip {}", t.0, t.2, b[0]);
+            }
+        }
+        for (b, a, clip) in &pc.buttons {
+            if let Some(c) = clip {
+                assert!(cce_ui::widget::WidgetHost::base(b).x >= c[0], "{a:?} starts left of its clip");
+            }
+        }
+    }
+
+    #[test]
+    fn rows_clip_at_their_viewport_not_the_band_below() {
+        // The scroll region hands back a row that starts just past its
+        // viewport; clipped only at the list's bottom, that row's Kill button
+        // stood alone in the reserve band under the last row.
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        state.processes = (0..40).map(|i| row(&i.to_string())).collect();
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let mut ctx = cce_ui::context::UiContext::new();
+        let pc = view(&mut state, 10.0, 20.0, 820.0, 640.0, false, &[false], &mut layout, &mut ctx);
+        let rows_bottom = state.cpu_list.viewport_y + state.cpu_list.viewport_h;
+        for (_, a, clip) in &pc.buttons {
+            if matches!(a, AppAction::Processes(ProcessesMessage::Kill(_))) {
+                let c = clip.expect("rows are clipped");
+                assert_eq!(c[1] + c[3], rows_bottom);
+            }
+        }
+    }
+
     #[test]
     fn watts_and_wakeups_format_with_dashes_for_noise() {
         assert_eq!(format_watts(None), "\u{2014}");
diff --git a/src/renderer.rs b/src/renderer.rs
index d4d5db9..de14d98 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -123,7 +123,6 @@ impl SystemInterface {
         let mut widgets = Vec::new();
         let mut texts = Vec::new();
         let mut page_buttons = Vec::new();
-        let mut page_button_images: Vec<(u32, f32, f32, f32, f32, f32)> = Vec::new();
         let mut page_icon_images: Vec<crate::PlacedIcon> = Vec::new();
 
         cce_ui::widget::hover_animation::reset_frame_registration();
@@ -303,7 +302,7 @@ impl SystemInterface {
         // Page content in LOGICAL coordinates, then scale to physical
         let pc = self.render_page_content(lcx, lcy, lcw, lch);
         self.page_reliefs = pc.reliefs.clone();
-        self.page_control_reliefs = pc.control_reliefs.clone();
+        self.page_control_reliefs = pc.control_reliefs.iter().map(|&c| (c, None)).collect();
         self.page_control_relief_marks.clear();
 
 
@@ -563,7 +562,10 @@ impl SystemInterface {
                     if refocused {
                         plate.tint = Some(cce_ui::widget::ControlPlate::focus_tint());
                     }
-                    self.page_control_reliefs.push(ControlCarve::Plate {
+                    // Under the button's own clip, as its quad is clamped:
+                    // drawn whole, a row half-scrolled out of a list kept
+                    // its plate standing past the list's edge.
+                    let carve = ControlCarve::Plate {
                         x: plate.rect.x,
                         y: plate.rect.y,
                         w: plate.rect.width,
@@ -572,15 +574,17 @@ impl SystemInterface {
                         depth: plate.depth,
                         color: plate.face_fill(),
                         tint: plate.tint,
-                    });
+                    };
+                    self.page_control_reliefs.push((carve, *clip));
                     self.page_control_relief_marks.push(widgets.len());
                 }
             }
             // An icon face replaces the label entirely (as it does in
             // `Button::paint`). The rect comes from the button's own
             // `icon_rect` so the glyph lands where the paint path would put
-            // it; display_list draws these under the page clip, which is what
-            // cuts a half-scrolled row's icon at the list edge.
+            // it, and it keeps the button's clip: the page clip alone let a
+            // row half-scrolled out of a list show its whole glyph past the
+            // list's edge.
             let has_icon = if let Some((image, irect, alpha)) =
                 btn.icon_rect(cce_ui::scene::layout::Rect {
                     x: base.x,
@@ -589,7 +593,15 @@ impl SystemInterface {
                     height: base.h,
                 })
             {
-                page_button_images.push((image, irect.x, irect.y, irect.width, irect.height, alpha));
+                page_icon_images.push(crate::PlacedIcon {
+                    image,
+                    x: irect.x,
+                    y: irect.y,
+                    w: irect.width,
+                    h: irect.height,
+                    alpha,
+                    clip: clip.map(|[x, y, w, h]| [x, y - scroll_offset_y, w, h]),
+                });
                 true
             } else {
                 false
@@ -782,7 +794,6 @@ impl SystemInterface {
         self.widgets = widgets;
         self.texts = texts;
         self.page_buttons = page_buttons;
-        self.page_button_images = page_button_images;
         self.page_icon_images = page_icon_images;
         self.window_icon_images = window_icon_images;