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

commite5f73975fee9009754410e64bf84f903a6179b76
parent2be53708e5
authorLucas Galante <[email protected]>
date2026-08-01 23:34
fix: partially visible list rows render cut instead of vanishing

get_item_draw_y virtualizes on viewport intersection, not full
containment — rows entering/leaving the viewport draw clipped by the
caller's clip rect. Row buttons now carry the emission-time clip rect
through PageContent so the renderer clamps their quad, label bounds,
and dispatch hit rect to the list edge (they previously bled past it,
and a hidden row's button could steal clicks from widgets above the
list). Regression tests for the new virtualization contract.

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

 src/app.rs            | 11 ++++++++---
 src/pages/accounts.rs |  2 +-
 src/renderer.rs       | 52 ++++++++++++++++++++++++++++++++++-----------------
 src/scroll_region.rs  | 28 ++++++++++++++++++++++-----
 4 files changed, 67 insertions(+), 26 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index d9e625d..beb4d07 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -103,7 +103,10 @@ pub enum AppAction {
 pub struct PageContent {
     pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
-    pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction)>,
+    /// (button, action, clip): clip is the innermost push_clip_rect at emission
+    /// time (page coords) — the renderer clamps the drawn quad, label bounds,
+    /// and the dispatch clone's hit rect to it.
+    pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
     /// Section wells claimed via `RenderTarget::section_relief` — the body box
     /// plus the title tab box, carved into the window plate by display_list as
     /// recess prims (page coordinates, pre-scroll).
@@ -194,7 +197,8 @@ impl PageContent {
             .with_bg(bg)
             .with_hover_bg(hover_bg)
             .with_label_color(label_color);
-        self.buttons.push((btn, action));
+        let clip = self.clip_stack.last().copied();
+        self.buttons.push((btn, action, clip));
     }
 
     pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
@@ -207,7 +211,8 @@ impl PageContent {
             .with_hover_bg(hover_bg)
             .with_label_color(label_color)
             .with_left_align(true);
-        self.buttons.push((btn, action));
+        let clip = self.clip_stack.last().copied();
+        self.buttons.push((btn, action, clip));
     }
 }
 
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index e63eb2f..4e0671c 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -916,7 +916,7 @@ mod tests {
         let mut layout = AdaptiveGrid::new(260.0, 20.0);
         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &mut layout, &mut cce_ui::context::UiContext::new());
         println!("PC BUTTONS COUNT: {}", pc.buttons.len());
-        for (i, (btn, _)) in pc.buttons.iter().enumerate() {
+        for (i, (btn, _, _)) in pc.buttons.iter().enumerate() {
             let base = btn.base();
             println!(
                 "Button {}: label={:?}, x={}, y={}, w={}, h={}, bg={:?}, hover_bg={:?}, label_color={:?}",
diff --git a/src/renderer.rs b/src/renderer.rs
index c127f21..ab2f54b 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -279,7 +279,7 @@ impl SystemInterface {
                 for (_, size, _, y, _, _, _) in &pc.texts {
                     max_y = max_y.max(y + size);
                 }
-                for (btn, _) in &pc.buttons {
+                for (btn, _, _) in &pc.buttons {
                     let base = btn.base();
                     max_y = max_y.max(base.y + base.h);
                 }
@@ -295,7 +295,7 @@ impl SystemInterface {
         for (_, size, _, y, _, _, _) in &pc.texts {
             max_y = max_y.max(y + size);
         }
-        for (btn, _) in &pc.buttons {
+        for (btn, _, _) in &pc.buttons {
             let base = btn.base();
             max_y = max_y.max(base.y + base.h);
         }
@@ -428,14 +428,27 @@ impl SystemInterface {
 
             texts.push((t.clone(), *size, *x, *y - scroll_offset_y, text_color, font_opt.clone(), final_bounds));
         }
-        for (btn, action) in &pc.buttons {
+        for (btn, action, clip) in &pc.buttons {
             let base = btn.base();
             let bg = btn.bg.unwrap_or([0.16, 0.16, 0.24, 1.0]);
             let hover_bg = btn.hover_bg.unwrap_or([0.25, 0.30, 0.26, 1.0]);
-            let wx = base.x * s;
-            let mut wy = (base.y - scroll_offset_y) * s;
-            let ww = base.w * s;
-            let mut wh = base.h * s;
+            // Clamp to the emission-time clip rect (page coords) so a partially
+            // scrolled list row's button draws cut at the list edge, not bleeding.
+            let (mut px0, mut py0, mut px1, mut py1) =
+                (base.x, base.y, base.x + base.w, base.y + base.h);
+            if let Some(c) = clip {
+                px0 = px0.max(c[0]);
+                py0 = py0.max(c[1]);
+                px1 = px1.min(c[0] + c[2]);
+                py1 = py1.min(c[1] + c[3]);
+                if px0 >= px1 || py0 >= py1 {
+                    continue;
+                }
+            }
+            let wx = px0 * s;
+            let mut wy = (py0 - scroll_offset_y) * s;
+            let ww = (px1 - px0) * s;
+            let mut wh = (py1 - py0) * s;
 
             let viewport_bottom = logical_sh - self.status_height;
             if wy >= viewport_bottom || wy + wh <= 0.0 {
@@ -500,12 +513,15 @@ impl SystemInterface {
             };
 
             let label_color = btn.label_color.unwrap_or([0.83, 0.83, 0.83, 1.0]);
-            let button_bounds = Some([
-                0.0,
-                0.0,
-                logical_sw,
-                viewport_bottom,
-            ]);
+            let button_bounds = Some(match clip {
+                Some(c) => [
+                    c[0],
+                    (c[1] - scroll_offset_y).max(0.0),
+                    (c[0] + c[2]).min(logical_sw),
+                    (c[1] + c[3] - scroll_offset_y).min(viewport_bottom),
+                ],
+                None => [0.0, 0.0, logical_sw, viewport_bottom],
+            });
             texts.push((
                 label.to_string(),
                 label_size,
@@ -517,11 +533,13 @@ impl SystemInterface {
             ));
             let mut btn_clone = btn.clone();
             {
+                // The dispatch clone hit-tests at the CLAMPED rect, so clicks in
+                // a row's clipped-away region fall through to what's visible there.
                 let base_mut = btn_clone.base_mut();
-                base_mut.x *= s;
-                base_mut.y = (base_mut.y - scroll_offset_y) * s;
-                base_mut.w *= s;
-                base_mut.h *= s;
+                base_mut.x = wx;
+                base_mut.y = wy;
+                base_mut.w = ww;
+                base_mut.h = wh;
             }
             page_buttons.push((btn_clone, action.clone()));
         }
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
index a3d1d45..03cc818 100644
--- a/src/scroll_region.rs
+++ b/src/scroll_region.rs
@@ -92,12 +92,13 @@ impl ScrollRegion {
     }
 
     /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
-    /// when the row isn't fully inside the viewport.
+    /// when the row doesn't intersect the viewport at all. Partially visible rows
+    /// ARE returned — callers draw under a clip rect, so they render cut, not culled.
     pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
         let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
         let draw_y = self.viewport_y + virtual_y - self.scroll_y;
-        if draw_y >= self.viewport_y - 1.0
-            && draw_y + self.item_height <= self.viewport_y + self.viewport_h + 1.0
+        if draw_y + self.item_height >= self.viewport_y - 1.0
+            && draw_y <= self.viewport_y + self.viewport_h + 1.0
         {
             Some(draw_y)
         } else {
@@ -291,8 +292,25 @@ mod tests {
         r.set_scroll_y(0.0);
         // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
         assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
-        // Row 2 at 20 + 92 - 0 = 112; 112 + 40 > 121 → culled.
-        assert!(r.get_item_draw_y(2, 4.0).is_none());
+        // Row 2 at 20 + 92 - 0 = 112: extends past the viewport bottom (121) but
+        // still intersects it — returned so the caller draws it cut by the clip.
+        assert_eq!(r.get_item_draw_y(2, 4.0), Some(112.0));
+        // Row 3 at 20 + 136 = 156: fully below the viewport → culled.
+        assert!(r.get_item_draw_y(3, 4.0).is_none());
+    }
+
+    #[test]
+    fn virtualization_keeps_partial_row_at_top() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0);
+        // Scrolled so row 0 (virtual 4..44) is half above the viewport top:
+        // draw_y = 20 + 4 - 24 = 0 < viewport_y, but its bottom (40) intersects.
+        r.set_scroll_y(24.0);
+        assert_eq!(r.get_item_draw_y(0, 4.0), Some(0.0));
+        // A row whose bottom ends above the viewport top would be culled; with
+        // this geometry row 0 always intersects, so scroll far and check row 0.
+        r.set_scroll_y(80.0);
+        assert!(r.get_item_draw_y(0, 4.0).is_none());
     }
 
     #[test]