git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit74f9ad4272ed08455c71da88c9c7d3d7f818e59c
parent9233b20524
authorLucas Galante <[email protected]>
date2026-09-01 08:29
fix: edge rows render cut, not culled; sliver clicks select them

The ScrollRegion copy is deleted for the toolkit's
cce_ui::widget::ScrollRegion (cce-ui@764d9e7): get_draw_y now returns
partially visible rows, and the list content (selection chip, icons,
row labels) paints under the list-viewport clip so edge rows render
cut. Row labels split out of own_labels so the chrome (query line,
empty-state) stays outside the clip. walk_text_labels now carries each
prim's merged clip bounds and prepare_text turns them into the span's
physical clip — without that, a cut row's text drew whole through the
glyph pass.

The 1063422 click gate keeps its shape — paint and click share the
get_draw_y predicate — but the predicate's new contract makes an edge
sliver legitimately selectable: visible => clickable, culled => not.
Verified in a shadow session (40-item dmenu): a half-cut row renders
clipped mid-glyph, and clicking its sliver prints that row and closes.

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

 src/main.rs          | 147 +++++++++++++++---------
 src/scroll_region.rs | 317 ---------------------------------------------------
 2 files changed, 96 insertions(+), 368 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index dc75284..56a8350 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,4 @@
-mod scroll_region;
-use scroll_region::ScrollRegion;
+use cce_ui::widget::ScrollRegion;
 
 use std::sync::{Arc, Mutex};
 use std::io::{self, BufRead, IsTerminal};
@@ -123,15 +122,23 @@ fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buff
 /// A widget subtree's text via the paint walk (not the legacy text_labels getter),
 /// reduced to the plain labels this renderer shapes: the buffer font and window bounds
 /// stay exactly as before (make_text_buffer applies the control font to every label).
-fn walk_text_labels(ui: &cce_ui::context::UiContext, w: &dyn WidgetHost) -> Vec<TextLabel> {
+/// Each label rides with its merged clip bounds (logical `[l, t, r, b]`, from
+/// the paint walk's clip ∩ the prim's own bounds — `append_widget_text` merges
+/// them): prepare_text turns them into the span's physical clip so text cut by
+/// a clip (a partially visible list row) is cut at the glyph pass too, not
+/// drawn whole.
+fn walk_text_labels(
+    ui: &cce_ui::context::UiContext,
+    w: &dyn WidgetHost,
+) -> Vec<(TextLabel, Option<[f32; 4]>)> {
     let mut pc = cce_ui::scene::paint::PaintCtx::new();
     cce_ui::scene::painter::append_widget_text(ui, w, &mut pc);
     pc.finish()
         .items
         .into_iter()
         .filter_map(|item| match item.prim {
-            cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, .. } => {
-                Some(TextLabel { text, x, y, font_size, color })
+            cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, bounds, .. } => {
+                Some((TextLabel { text, x, y, font_size, color }, bounds))
             }
             _ => None,
         })
@@ -743,38 +750,57 @@ impl cce_ui::widget::Paint for FuzzelWidget {
             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
         }
 
-        // Selected Item Highlight
-        let item_h = 25.0;
-        if !self.filtered_items.is_empty() {
-            let virtual_selected_y = self.selected as f32 * item_h;
-            if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
-                let scrollbar_w = if self.scroll_box.content_h > self.scroll_box.viewport_h { 10.0 } else { 0.0 };
-                // A raised beveled chip, not a flat tint: the selection reads
-                // as sitting proud of the list the way focused panes do.
-                let sel = Rect {
-                    x: self.x + pad + 2.0,
-                    y: draw_y,
-                    width: self.w - pad * 2.0 - 4.0 - scrollbar_w,
-                    height: item_h - 2.0,
-                };
-                let depth = cce_ui::color::plate_bevel_width().min(sel.height * 0.2);
-                ctx.bevel(sel, (4.0, 4.0, 4.0, 4.0), [0.20, 0.35, 0.65, 0.9], depth);
+        // The list content — selection chip, icons, row labels — under the
+        // list-viewport clip: `get_draw_y` returns PARTIALLY visible rows (the
+        // toolkit ScrollRegion's intersection contract), so an edge row
+        // renders cut by the clip instead of vanishing. Row text carries the
+        // clip as bounds through walk_text_labels → prepare_text.
+        let viewport = Rect {
+            x: self.scroll_box.x,
+            y: self.scroll_box.viewport_y,
+            width: self.scroll_box.w,
+            height: self.scroll_box.viewport_h,
+        };
+        ctx.clip(viewport, |ctx| {
+            // Selected Item Highlight
+            let item_h = 25.0;
+            if !self.filtered_items.is_empty() {
+                let virtual_selected_y = self.selected as f32 * item_h;
+                if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
+                    let scrollbar_w = if self.scroll_box.content_h > self.scroll_box.viewport_h { 10.0 } else { 0.0 };
+                    // A raised beveled chip, not a flat tint: the selection reads
+                    // as sitting proud of the list the way focused panes do.
+                    let sel = Rect {
+                        x: self.x + pad + 2.0,
+                        y: draw_y,
+                        width: self.w - pad * 2.0 - 4.0 - scrollbar_w,
+                        height: item_h - 2.0,
+                    };
+                    let depth = cce_ui::color::plate_bevel_width().min(sel.height * 0.2);
+                    ctx.bevel(sel, (4.0, 4.0, 4.0, 4.0), [0.20, 0.35, 0.65, 0.9], depth);
+                }
             }
-        }
 
-        // App icons, on the same virtualization predicate as the labels below:
-        // only rows `get_draw_y` places inside the viewport are emitted, so a
-        // 300-app list still costs one image quad per visible row.
-        if self.icon_gutter > 0.0 {
-            for (idx, item_text) in self.filtered_items.iter().enumerate() {
-                let Some((image, iw, ih)) = self.icons.get(item_text).copied() else { continue };
-                if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
-                    ctx.image(image, self.icon_rect(draw_y, item_h, iw, ih), 1.0);
+            // App icons, on the same virtualization predicate as the labels:
+            // only rows `get_draw_y` places in the viewport are emitted, so a
+            // 300-app list still costs one image quad per visible row.
+            if self.icon_gutter > 0.0 {
+                let item_h = 25.0;
+                for (idx, item_text) in self.filtered_items.iter().enumerate() {
+                    let Some((image, iw, ih)) = self.icons.get(item_text).copied() else { continue };
+                    if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
+                        ctx.image(image, self.icon_rect(draw_y, item_h, iw, ih), 1.0);
+                    }
                 }
             }
-        }
 
-        // Own labels: prompt/query line, visible items, empty-state notice.
+            // Visible item labels.
+            for l in self.row_labels() {
+                ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+            }
+        });
+
+        // Prompt/query line and the empty-state notice — outside the list clip.
         for l in self.own_labels() {
             ctx.text(l.text, l.x, l.y, l.font_size, l.color);
         }
@@ -794,9 +820,11 @@ impl cce_ui::widget::Input for FuzzelWidget {
             let item_h = 25.0;
             // `hit()` spans the whole region, scrollbar strip included, and the row math
             // below accepts any y inside it — so without these two gates a press on the
-            // scrollbar, or on the partially-clipped sliver at the viewport edge, resolved
-            // to a row. In Dmenu/switcher mode a press commits and closes, so that emitted
-            // an item the user never clicked (and, on the sliver, never even saw).
+            // scrollbar resolved to a row. In Dmenu/switcher mode a press commits and
+            // closes, so that emitted an item the user never clicked. The `get_draw_y`
+            // gate keeps click and paint on the SAME predicate: it now returns partially
+            // visible rows too (drawn cut by the viewport clip), so a press on an edge
+            // sliver selects the row the user can see — visible ⇒ clickable, culled ⇒ not.
             if self.scroll_box.hit(*px, *py) && !self.scroll_box.hit_scrollbar(*px, *py) {
                 let click_virtual_y = *py - self.scroll_box.viewport_y + self.scroll_box.scroll_y;
                 let clicked_idx = (click_virtual_y / item_h).floor() as usize;
@@ -1594,7 +1622,7 @@ impl State {
     fn prepare_text(&mut self) {
         let scale_f32 = self.scale as f32;
 
-        let mut widget_labels: Vec<TextLabel> = Vec::new();
+        let mut widget_labels: Vec<(TextLabel, Option<[f32; 4]>)> = Vec::new();
         if self.mode == LauncherMode::Json {
             if let Some(jl) = &self.json_layout {
                 widget_labels.extend(walk_text_labels(&self.ui_context, jl));
@@ -1604,7 +1632,7 @@ impl State {
         }
 
         let mut buffers: Vec<Buffer> = Vec::with_capacity(widget_labels.len());
-        for label in &widget_labels {
+        for (label, _) in &widget_labels {
             buffers.push(make_text_buffer(&mut self.font_system, &label.text, label.font_size));
         }
 
@@ -1612,13 +1640,22 @@ impl State {
         let spans: Vec<TextSpan> = buffers
             .iter()
             .zip(widget_labels.iter())
-            .map(|(buf, label)| TextSpan {
+            .map(|(buf, (label, bounds))| TextSpan {
                 buffer: buf,
                 left: (label.x * scale_f32).round(),
                 top: (label.y * scale_f32).round(),
                 // Buffers are shaped at logical size; the span scales to physical.
                 scale: scale_f32,
-                bounds: None,
+                // Logical merged clip (walk clip ∩ prim bounds) → physical px,
+                // so a partially visible row's text is cut at the viewport.
+                bounds: bounds.map(|b| {
+                    [
+                        (b[0] * scale_f32).floor() as i32,
+                        (b[1] * scale_f32).floor() as i32,
+                        (b[2] * scale_f32).ceil() as i32,
+                        (b[3] * scale_f32).ceil() as i32,
+                    ]
+                }),
                 default_color: [
                     label.color[0] as f32 / 255.0,
                     label.color[1] as f32 / 255.0,
@@ -3930,7 +3967,27 @@ impl FuzzelWidget {
             color: query_color,
         });
 
+        if self.filtered_items.is_empty() {
+            let list_y = self.y + pad + search_h + 10.0;
+            labels.push(TextLabel {
+                text: "No matches found".to_string(),
+                x: self.x + pad + 10.0,
+                y: list_y + 4.0,
+                font_size: 13.0,
+                color: [0x88, 0x88, 0x99],
+            });
+        }
+
+        labels
+    }
+
+    /// Visible item labels — one per row `get_draw_y` places in (or partially
+    /// in) the viewport. Emitted under the paint walk's list clip, separately
+    /// from [`Self::own_labels`], which draws chrome outside it.
+    fn row_labels(&self) -> Vec<TextLabel> {
+        let pad = 15.0;
         let item_h = 25.0;
+        let mut labels = Vec::new();
         for (idx, item_text) in self.filtered_items.iter().enumerate() {
             let virtual_y = idx as f32 * item_h;
             if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_y, item_h) {
@@ -3951,18 +4008,6 @@ impl FuzzelWidget {
                 });
             }
         }
-
-        if self.filtered_items.is_empty() {
-            let list_y = self.y + pad + search_h + 10.0;
-            labels.push(TextLabel {
-                text: "No matches found".to_string(),
-                x: self.x + pad + 10.0,
-                y: list_y + 4.0,
-                font_size: 13.0,
-                color: [0x88, 0x88, 0x99],
-            });
-        }
-
         labels
     }
 }
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
deleted file mode 100644
index 8e42e25..0000000
--- a/src/scroll_region.rs
+++ /dev/null
@@ -1,317 +0,0 @@
-//! App-owned scroll region replacing the dissolved `ScrollBox` embedded base (the Phase
-//! 6q ScrollRegion, ported via cce-mail). The fuzzel list drew its own rows at
-//! `get_draw_y` positions; the ScrollBox contributed only the flat background, the
-//! scrollbar, the scroll math, and wheel input — `push_quads` and the ScrollBox-shaped
-//! shims below reproduce those verbatim.
-
-use cce_ui::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
-
-#[derive(Debug, Clone)]
-pub struct ScrollRegion {
-    pub x: f32,
-    pub y: f32,
-    pub w: f32,
-    pub h: f32,
-    /// Row height, with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
-    pub item_height: f32,
-    pub item_gap: f32,
-    pub scroll_y: f32,
-    pub content_h: f32,
-    pub viewport_y: f32,
-    pub viewport_h: f32,
-    pub dragging: bool,
-    drag_offset_y: f32,
-    pub hovered: bool,
-    /// Local stand-in for the legacy global focus flag (`ScrollBox::focus()` on any press
-    /// inside the frame): set on a press that hits the region, cleared on one that misses.
-    pub focused: bool,
-}
-
-impl ScrollRegion {
-    pub fn new(item_height: f32, item_gap: f32) -> Self {
-        let (_, font_size) = cce_ui::layout::list_font_parsed();
-        Self {
-            x: 0.0,
-            y: 0.0,
-            w: 0.0,
-            h: 0.0,
-            item_height: item_height.max(font_size + 14.0),
-            item_gap,
-            scroll_y: 0.0,
-            content_h: 0.0,
-            viewport_y: 0.0,
-            viewport_h: 0.0,
-            dragging: false,
-            drag_offset_y: 0.0,
-            hovered: false,
-            focused: false,
-        }
-    }
-
-    pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.x = x;
-        self.y = y;
-        self.w = w;
-        self.h = h;
-    }
-
-    /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
-    pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
-        self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
-        self.viewport_y = viewport_y;
-        self.viewport_h = viewport_h;
-        self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
-    }
-
-    pub fn set_scroll_y(&mut self, val: f32) {
-        self.scroll_y = val;
-    }
-
-    fn max_scroll(&self) -> f32 {
-        (self.content_h - self.viewport_h).max(0.0)
-    }
-
-    pub fn hit(&self, px: f32, py: f32) -> bool {
-        px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
-    }
-
-    /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
-    /// when the row isn't fully inside the viewport.
-    /// `ScrollBox::update_bounds` shape: raw content height, not a row count.
-    pub fn update_bounds_raw(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
-        self.content_h = content_h;
-        self.viewport_y = viewport_y;
-        self.viewport_h = viewport_h;
-        let max_scroll = (content_h - viewport_h).max(0.0);
-        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
-    }
-
-    /// `ScrollBox::get_item_draw_y` shape: a precomputed virtual y + item height.
-    pub fn get_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
-        let draw_y = self.viewport_y + virtual_y - self.scroll_y;
-        if draw_y >= self.viewport_y - 1.0 && draw_y + item_h <= self.viewport_y + self.viewport_h + 1.0 {
-            Some(draw_y)
-        } else {
-            None
-        }
-    }
-
-    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
-        {
-            Some(draw_y)
-        } else {
-            None
-        }
-    }
-
-    /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
-    fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
-        let sb_w = cce_ui::layout::scrollbar_width();
-        let sb_x = self.x + self.w - sb_w - 4.0;
-        let track_h = self.viewport_h - 8.0;
-        let track_y = self.viewport_y + 4.0;
-        let visible_ratio = self.viewport_h / self.content_h.max(1.0);
-        let thumb_h = if track_h <= 20.0 {
-            track_h
-        } else {
-            (track_h * visible_ratio).clamp(20.0, track_h)
-        };
-        let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
-        let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
-        (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
-    }
-
-    pub fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
-        if self.content_h <= self.viewport_h {
-            return false;
-        }
-        let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
-        px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
-    }
-
-    /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
-    /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
-    /// scrollbar consumed the press — a press on the rows falls through to them.
-    pub fn press(&mut self, px: f32, py: f32) -> bool {
-        self.focused = self.hit(px, py);
-        if !self.hit_scrollbar(px, py) {
-            self.dragging = false;
-            return false;
-        }
-        self.dragging = true;
-        let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
-        let click_offset = py - thumb_y;
-        if click_offset >= 0.0 && click_offset <= thumb_h {
-            self.drag_offset_y = click_offset;
-        } else {
-            self.drag_offset_y = thumb_h / 2.0;
-            let target = py - self.drag_offset_y;
-            let ratio = if track_h - thumb_h > 0.0 {
-                ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
-            } else {
-                0.0
-            };
-            self.scroll_y = ratio * self.max_scroll();
-        }
-        true
-    }
-
-    /// Returns whether a thumb drag was in progress (the caller's redraw signal).
-    pub fn release(&mut self) -> bool {
-        std::mem::take(&mut self.dragging)
-    }
-
-    fn drag_move(&mut self, py: f32) -> bool {
-        let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
-        let target = py - self.drag_offset_y;
-        let ratio = if track_h - thumb_h > 0.0 {
-            ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
-        } else {
-            0.0
-        };
-        let old = self.scroll_y;
-        self.scroll_y = ratio * self.max_scroll();
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
-    /// treats it as a high-priority drag override), else just tracks hover for the border
-    /// tint and the keyboard scope.
-    pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
-        self.hovered = self.hit(px, py);
-        if self.dragging {
-            self.drag_move(py);
-            return true;
-        }
-        false
-    }
-
-    pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
-        if !self.hit(px, py) {
-            return false;
-        }
-        let dy = match delta {
-            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
-            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-        };
-        let old = self.scroll_y;
-        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
-    /// when focused or hovered; the dissolved region keeps both via its local flags).
-    pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
-        if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
-            return false;
-        }
-        let max = self.max_scroll();
-        let old = self.scroll_y;
-        if event.ctrl {
-            match &event.logical_key {
-                Key::Character(c) if c == "n" || c == "N" => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Character(c) if c == "p" || c == "P" => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
-                _ => return false,
-            }
-        } else {
-            match &event.logical_key {
-                Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::PageDown) => self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::PageUp) => self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
-                Key::Named(NamedKey::End) => self.scroll_y = max,
-                _ => return false,
-            }
-        }
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
-    /// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
-    /// The legacy `ScrollBox::extra_quads` emission: flat background, then the
-    /// scrollbar track + thumb when the content overflows.
-    pub fn push_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
-        quads.push((self.x, self.y, self.w, self.h, cce_ui::color::list_bg_color()));
-        if self.content_h > self.viewport_h {
-            let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
-            quads.push((sb_x, track_y, sb_w, track_h, cce_ui::color::scrollbar_track_color()));
-            quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn region() -> ScrollRegion {
-        // item_height clamps to list_font + 14, so pick one comfortably above any config.
-        let mut r = ScrollRegion::new(40.0, 4.0);
-        r.set_rect(10.0, 20.0, 200.0, 100.0);
-        r
-    }
-
-    #[test]
-    fn wheel_scrolls_and_clamps() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
-        assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
-        assert_eq!(r.scroll_y, 48.0);
-        assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
-        r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
-        assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
-    }
-
-    #[test]
-    fn virtualization_matches_list_math() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        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());
-    }
-
-    #[test]
-    fn press_focuses_and_grabs_only_scrollbar() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        // Press in the rows area: focused, not dragging, falls through.
-        assert!(!r.press(50.0, 50.0));
-        assert!(r.focused && !r.dragging);
-        // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
-        let sb_x = 10.0 + 200.0 - cce_ui::layout::scrollbar_width() - 4.0;
-        assert!(r.press(sb_x + 1.0, 50.0));
-        assert!(r.dragging);
-        assert!(r.release());
-        // Press outside: unfocuses.
-        assert!(!r.press(500.0, 500.0));
-        assert!(!r.focused);
-    }
-
-    #[test]
-    fn keyboard_is_hover_or_focus_scoped() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        let down = KeyEvent {
-            state: ElementState::Pressed,
-            logical_key: Key::Named(NamedKey::ArrowDown),
-            text: None,
-            repeat: false,
-            ctrl: false,
-            shift: false,
-            alt: false,
-        };
-        assert!(!r.keyboard(&down)); // neither hovered nor focused
-        r.cursor_moved(50.0, 50.0);
-        assert!(r.hovered);
-        assert!(r.keyboard(&down));
-        assert_eq!(r.scroll_y, 24.0);
-    }
-}