system settings
git clone https://git.lucas.co/cce-system-interface.git
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]