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

commit34f12364292fa7675d1fe9c46c7f3bd15fea08a3
parentd394c48d07
authorLucas Galante <[email protected]>
date2026-07-10 08:41
feat: List/ScrollBox DISSOLVED (Phase 6v) — app-owned ScrollRegions for all five lists

All five settings lists (processes cpu+services, packages installed+updates,
radios wifi) were pure scroll frames (List with columns=None; rows drawn by the
pages). Their frames, scroll state, and input are now the app-owned ScrollRegion
(src/scroll_region.rs, ported from cce-fonts' Phase 6q), leaving the section
containers as the last embedded bases in this app.

- ScrollRegion mirrors the List API the pages used (item_height adjust,
  update_bounds count math, get_item_draw_y virtualization) plus press/release/
  drag_move/wheel/keyboard from ScrollBox, and push_prims for the frame visuals.
- AppPage grows extra_dispatch_roots (InteractiveListItem rows dispatch directly;
  Adapted hit-gates presses so misses fall through), handle_mouse_wheel (inner
  lists take the wheel before the page fallback), and handle_key_input
  (hover/focus-scoped list scrolling before the page fallback).
- FIXES the dead inner-list wheel (deferred in 6u) and two legacy visual bugs of
  the columns=None List path: the border+bg pair double-composited (extra_quads
  early-return skipped the bg removal) and the scrollbar sandwiched under the
  second translucent bg wash — single-drawn now, scrollbar visible.
- A/B render dumps: rect streams byte-identical minus the duplicated pair; only
  text delta is item-label clip bounds relaxing by ScrollBox's 4px inset (rows
  are fully-visible-culled, no visible change). Live-verified on the compositor:
  wheel per list, page-scroll fallback, track-jump (sticks), focus tint, item
  click -> selection + info fetch, scroll state across watcher rebuilds.

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

 src/input_handler.rs   |  33 ++++-
 src/lib.rs             |   1 +
 src/pages/mod.rs       |  20 ++++
 src/pages/network.rs   |  57 +++++++--
 src/pages/packages.rs  |  96 ++++++++++-----
 src/pages/processes.rs |  93 +++++++++-----
 src/renderer.rs        |  16 ++-
 src/scroll_region.rs   | 320 +++++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 553 insertions(+), 83 deletions(-)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index 7421d8e..9ec603b 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -195,9 +195,13 @@ impl SystemInterface {
 
             let event = cce_ui::widget::Event::MouseWheel { delta: delta.clone(), x: lx, y: ly, local_x: lx, local_y: ly };
             // Page dissolved (6u): one dispatch path for every page — scrollbar, then
-            // sections (inner ScrollBoxes take the wheel first), then the manual page
-            // scroll below as the fallback, exactly as the non-System pages worked.
-            let handled = self.dispatch_page_event(&event);
+            // sections, then the dissolved inner lists (the app-owned ScrollRegions,
+            // hit-scoped like the old inner ScrollBoxes), then the manual page scroll
+            // below as the fallback, exactly as the non-System pages worked.
+            let mut handled = self.dispatch_page_event(&event);
+            if !handled {
+                handled = self.app.get_current_page_mut().handle_mouse_wheel(delta, lx, ly);
+            }
 
             let mut actions = Vec::new();
             self.propagate_widget_changes(&mut actions);
@@ -302,6 +306,21 @@ impl SystemInterface {
             }
         }
 
+        // Row widgets of the dissolved lists (Phase 6v): they used to receive events as
+        // ScrollBox children under the sections; now they dispatch directly. Adapted's
+        // hit-gate keeps missed presses falling through, so order vs the sections only
+        // matters for overlap — and the rows sit inside list frames the sections never
+        // claim. Collected fresh per event: the item Vecs get rebuilt across frames.
+        let extra_roots = self.app.get_current_page_mut().extra_dispatch_roots();
+        for root in extra_roots {
+            if self.ui_context.propagate_event(event, root) {
+                if !is_pointer_move {
+                    return true;
+                }
+                handled = true;
+            }
+        }
+
         let roots = self.page_dispatch_roots();
         for root in roots.into_iter().rev() {
             if self.ui_context.propagate_event(event, root) {
@@ -431,6 +450,14 @@ impl SystemInterface {
             key_handled = true;
         }
 
+        // The dissolved inner lists' keyboard scrolling (hover/focus-scoped, like the old
+        // ScrollBox::keyboard_input) — before the whole-page fallback so a hovered list
+        // takes the scroll keys first.
+        if !key_handled && self.app.get_current_page_mut().handle_key_input(event) {
+            self.needs_rebuild = true;
+            key_handled = true;
+        }
+
         // The dissolved Page's keyboard scrolling: when nothing in the page tree took the
         // key and the cursor is over the page viewport, scroll keys move the page.
         if !key_handled && event.state == cce_ui::widget::ElementState::Pressed && self.max_scroll_y > 0.0 {
diff --git a/src/lib.rs b/src/lib.rs
index e467808..72d10f0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod app;
 pub mod pages;
+pub mod scroll_region;
 pub mod widgets;
 pub mod watchers;
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index a6c9bc7..684c14f 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -102,6 +102,26 @@ pub trait AppPage {
     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
         false
     }
+
+    /// Extra top-level event-dispatch roots beyond the section containers: the per-row
+    /// widgets that used to hang under a `List`'s ScrollBox (dissolved — the rows now
+    /// dispatch directly; `Adapted` hit-gates presses/wheel so misses fall through).
+    fn extra_dispatch_roots(&mut self) -> Vec<*mut (dyn cce_ui::widget::Element + 'static)> {
+        Vec::new()
+    }
+
+    /// The dissolved inner lists' wheel (`ScrollBox::mouse_wheel`, hit-scoped). Runs after
+    /// the widget dispatch and before the manual whole-page scroll fallback — the legacy
+    /// "inner ScrollBoxes take the wheel first" order.
+    fn handle_mouse_wheel(&mut self, _delta: &cce_ui::widget::MouseScrollDelta, _lx: f32, _ly: f32) -> bool {
+        false
+    }
+
+    /// The dissolved inner lists' hover/focus-scoped keyboard scrolling
+    /// (`ScrollBox::keyboard_input`). Runs before the whole-page scroll-key fallback.
+    fn handle_key_input(&mut self, _event: &cce_ui::widget::KeyEvent) -> bool {
+        false
+    }
 }
 
 
diff --git a/src/pages/network.rs b/src/pages/network.rs
index fabe943..2a6dbca 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,6 +1,7 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
-use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
-use cce_ui::widget::{Adapted, List, Toggle, Element};
+use crate::scroll_region::ScrollRegion;
+use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
+use cce_ui::widget::{Adapted, Toggle, Element};
 
 #[derive(Debug, Clone)]
 pub struct WifiNetwork {
@@ -32,7 +33,7 @@ pub struct NetworkState {
     pub bt_enabled: bool,
     pub bt_devices: Vec<BluetoothDevice>,
     pub bt_scanning: bool,
-    pub wifi_list_box: List,
+    pub wifi_list: ScrollRegion,
     pub wifi_toggle: Adapted<Toggle>,
     pub bt_toggle: Adapted<Toggle>,
 }
@@ -52,7 +53,7 @@ impl Default for NetworkState {
             bt_enabled: false,
             bt_devices: Vec::new(),
             bt_scanning: false,
-            wifi_list_box: List::new(26.0, 4.0),
+            wifi_list: ScrollRegion::new(26.0, 4.0),
             wifi_toggle: Toggle::new(),
             bt_toggle: Toggle::new(),
         }
@@ -126,7 +127,7 @@ pub async fn fetch_network_state() -> NetworkState {
         ip_address, device, available,
         bt_installed, bt_service_active,
         bt_enabled, bt_devices, bt_scanning: false,
-        wifi_list_box: List::new(26.0, 4.0),
+        wifi_list: ScrollRegion::new(26.0, 4.0),
         wifi_toggle: Toggle::new(),
         bt_toggle: Toggle::new(),
     }
@@ -336,16 +337,17 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
                 let list_box_w = sec_w - 2.0 * margin;
                 let list_box_h = 160.0;
 
-                render_widget(sec.pc, &mut state.wifi_list_box, list_box_x, list_box_y, list_box_w, list_box_h, ctx);
-
-                state.wifi_list_box.update_bounds(state.available.len(), list_box_y, list_box_h);
+                // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                state.wifi_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                state.wifi_list.update_bounds(state.available.len(), list_box_y, list_box_h);
+                state.wifi_list.push_prims(sec.pc);
 
                 let btn_w = list_box_w - 2.0 * margin;
                 let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
 
                 sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
                 for (idx, net) in state.available.iter().enumerate() {
-                    if let Some(draw_y) = state.wifi_list_box.get_item_draw_y(idx, 4.0) {
+                    if let Some(draw_y) = state.wifi_list.get_item_draw_y(idx, 4.0) {
                         let prefix = if net.in_use { ">" } else { " " };
                         let ssid_truncated = if net.ssid.len() > max_chars {
                             format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
@@ -514,10 +516,16 @@ pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
     }
 }
 
+impl NetworkState {
+    /// The wifi list is only laid out (and its rect refreshed) when this holds — gate the
+    /// dissolved region's input on it so a stale rect can't eat events.
+    fn wifi_list_visible(&self) -> bool {
+        self.loaded && self.wifi_enabled && !self.available.is_empty()
+    }
+}
+
 impl crate::pages::AppPage for NetworkState {
     fn clear_children(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        self.wifi_list_box.scroll_box.clear_children(ctx);
-        self.wifi_list_box.scroll_box.set_parent(None, ctx);
         self.wifi_toggle.clear_children(ctx);
         self.wifi_toggle.set_parent(None, ctx);
         self.bt_toggle.clear_children(ctx);
@@ -554,7 +562,6 @@ impl crate::pages::AppPage for NetworkState {
     ) {
 
         cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.wifi_toggle, ctx);
-        cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.wifi_list_box.scroll_box, ctx);
         cce_ui::widget::link_parent_child(&mut sec_containers[1], &mut self.bt_toggle, ctx);
     }
 
@@ -583,6 +590,32 @@ impl crate::pages::AppPage for NetworkState {
             actions.push(crate::app::AppAction::Radios(NetworkMessage::ToggleBluetooth));
         }
     }
+
+    fn handle_pointer_move(
+        &mut self,
+        lx: f32,
+        ly: f32,
+        _actions: &mut Vec<crate::app::AppAction>,
+        _ctx: &mut cce_ui::context::UiContext,
+    ) -> bool {
+        self.wifi_list_visible() && self.wifi_list.cursor_moved(lx, ly)
+    }
+
+    fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.wifi_list_visible() && self.wifi_list.press(lx, ly)
+    }
+
+    fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.wifi_list.release()
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
+        self.wifi_list_visible() && self.wifi_list.wheel(delta, lx, ly)
+    }
+
+    fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
+        self.wifi_list_visible() && self.wifi_list.keyboard(event)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index 083e135..9761917 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,6 +1,7 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::scroll_region::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, SectionContext, RenderTarget};
-use cce_ui::widget::{Element, List, TextBox, InteractiveListItem};
+use cce_ui::widget::{Element, TextBox, InteractiveListItem};
 
 #[derive(Debug, Clone)]
 pub struct PackageInfo {
@@ -34,9 +35,9 @@ pub struct PackagesState {
     pub updates: Vec<UpdateInfo>,
     pub active_tab: PackageTab,
     pub search_box: cce_ui::widget::Adapted<TextBox>,
-    pub installed_list_box: List,
+    pub installed_list: ScrollRegion,
     pub installed_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
-    pub updates_list_box: List,
+    pub updates_list: ScrollRegion,
     pub updates_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
     pub updating: bool,
     pub last_update_res: Option<Result<(), String>>,
@@ -54,9 +55,9 @@ impl Default for PackagesState {
             updates: Vec::new(),
             active_tab: PackageTab::Installed,
             search_box: TextBox::new(String::new()).with_placeholder("Filter Packages..."),
-            installed_list_box: List::new(32.0, 4.0),
+            installed_list: ScrollRegion::new(32.0, 4.0),
             installed_items: Vec::new(),
-            updates_list_box: List::new(32.0, 4.0),
+            updates_list: ScrollRegion::new(32.0, 4.0),
             updates_items: Vec::new(),
             updating: false,
             last_update_res: None,
@@ -398,15 +399,15 @@ pub fn view(
 
             match state.active_tab {
                 PackageTab::Installed => {
-                    state.installed_list_box.clear_children(ctx);
-                    render_widget(sec.pc, &mut state.installed_list_box, list_box_x, list_box_y, list_box_w, list_box_h, ctx);
-
                     let filtered: Vec<&PackageInfo> = state.installed.iter()
                         .filter(|p| p.name.to_lowercase().contains(&query) || p.version.to_lowercase().contains(&query))
                         .collect();
 
-                    state.installed_list_box.update_bounds(filtered.len(), list_box_y, list_box_h);
-                    let item_h = state.installed_list_box.item_height;
+                    // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                    state.installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    state.installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
+                    state.installed_list.push_prims(sec.pc);
+                    let item_h = state.installed_list.item_height;
 
                     if state.installed_items.len() != filtered.len() {
                         state.installed_items.clear();
@@ -417,12 +418,12 @@ pub fn view(
 
                     sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
                     for (idx, pkg) in filtered.iter().enumerate() {
-                        if let Some(draw_y) = state.installed_list_box.get_item_draw_y(idx, 4.0) {
+                        if let Some(draw_y) = state.installed_list.get_item_draw_y(idx, 4.0) {
+                            // Rows dispatch as extra roots (the dissolved list is no parent).
                             let item = &mut state.installed_items[idx];
                             item.title = pkg.name.clone();
                             item.subtitle = Some(format!("Version: {}", pkg.version));
                             item.selected = Some(&pkg.name) == state.selected_package.as_ref();
-                            cce_ui::widget::link_parent_child(&mut state.installed_list_box.scroll_box, item, ctx);
                             render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
                         }
                     }
@@ -433,15 +434,15 @@ pub fn view(
                     }
                 }
                 PackageTab::Updates => {
-                    state.updates_list_box.clear_children(ctx);
-                    render_widget(sec.pc, &mut state.updates_list_box, list_box_x, list_box_y, list_box_w, list_box_h, ctx);
-
                     let filtered: Vec<&UpdateInfo> = state.updates.iter()
                         .filter(|p| p.name.to_lowercase().contains(&query))
                         .collect();
 
-                    state.updates_list_box.update_bounds(filtered.len(), list_box_y, list_box_h);
-                    let item_h = state.updates_list_box.item_height;
+                    // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+                    state.updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+                    state.updates_list.update_bounds(filtered.len(), list_box_y, list_box_h);
+                    state.updates_list.push_prims(sec.pc);
+                    let item_h = state.updates_list.item_height;
 
                     if state.updates_items.len() != filtered.len() {
                         state.updates_items.clear();
@@ -452,12 +453,12 @@ pub fn view(
 
                     sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
                     for (idx, pkg) in filtered.iter().enumerate() {
-                        if let Some(draw_y) = state.updates_list_box.get_item_draw_y(idx, 4.0) {
+                        if let Some(draw_y) = state.updates_list.get_item_draw_y(idx, 4.0) {
+                            // Rows dispatch as extra roots (the dissolved list is no parent).
                             let item = &mut state.updates_items[idx];
                             item.title = pkg.name.clone();
                             item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
                             item.selected = Some(&pkg.name) == state.selected_package.as_ref();
-                            cce_ui::widget::link_parent_child(&mut state.updates_list_box.scroll_box, item, ctx);
                             render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
                         }
                     }
@@ -632,8 +633,8 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
         }
         PackagesMessage::SetTab(tab) => {
             state.active_tab = tab;
-            state.installed_list_box.set_scroll_y(0.0);
-            state.updates_list_box.set_scroll_y(0.0);
+            state.installed_list.set_scroll_y(0.0);
+            state.updates_list.set_scroll_y(0.0);
             state.installed_items.clear();
             state.updates_items.clear();
             state.selected_package = None;
@@ -699,9 +700,19 @@ impl PackagesState {
         self.selected_package_info = None;
         self.loading_info = true;
         if let Some(idx) = self.installed.iter().position(|p| p.name == pkg_name) {
-            let item_height_full = self.installed_list_box.item_height + self.installed_list_box.item_gap;
+            let item_height_full = self.installed_list.item_height + self.installed_list.item_gap;
             let target_y = idx as f32 * item_height_full - 164.0;
-            self.installed_list_box.set_scroll_y(target_y);
+            self.installed_list.set_scroll_y(target_y);
+        }
+    }
+}
+
+impl PackagesState {
+    /// The active tab's dissolved list region (only one is laid out per frame).
+    fn active_list(&mut self) -> &mut ScrollRegion {
+        match self.active_tab {
+            PackageTab::Installed => &mut self.installed_list,
+            PackageTab::Updates => &mut self.updates_list,
         }
     }
 }
@@ -710,10 +721,6 @@ impl crate::pages::AppPage for PackagesState {
     fn clear_children(&mut self, ctx: &mut cce_ui::context::UiContext) {
         self.search_box.clear_children(ctx);
         self.search_box.set_parent(None, ctx);
-        self.installed_list_box.scroll_box.clear_children(ctx);
-        self.installed_list_box.scroll_box.set_parent(None, ctx);
-        self.updates_list_box.scroll_box.clear_children(ctx);
-        self.updates_list_box.scroll_box.set_parent(None, ctx);
     }
 
     fn get_section_containers(&self) -> Vec<cce_ui::widget::SectionContainer> {
@@ -755,8 +762,6 @@ impl crate::pages::AppPage for PackagesState {
     ) {
 
         cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.search_box, ctx);
-        cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.installed_list_box.scroll_box, ctx);
-        cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.updates_list_box.scroll_box, ctx);
     }
 
     fn view(
@@ -811,6 +816,39 @@ impl crate::pages::AppPage for PackagesState {
             }
         }
     }
+
+    fn extra_dispatch_roots(&mut self) -> Vec<*mut (dyn Element + 'static)> {
+        match self.active_tab {
+            PackageTab::Installed => self.installed_items.iter_mut().map(|i| i.as_ptr_mut()).collect(),
+            PackageTab::Updates => self.updates_items.iter_mut().map(|i| i.as_ptr_mut()).collect(),
+        }
+    }
+
+    fn handle_pointer_move(
+        &mut self,
+        lx: f32,
+        ly: f32,
+        _actions: &mut Vec<crate::app::AppAction>,
+        _ctx: &mut cce_ui::context::UiContext,
+    ) -> bool {
+        self.loaded && self.active_list().cursor_moved(lx, ly)
+    }
+
+    fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.loaded && self.active_list().press(lx, ly)
+    }
+
+    fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        self.active_list().release()
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
+        self.loaded && self.active_list().wheel(delta, lx, ly)
+    }
+
+    fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
+        self.loaded && self.active_list().keyboard(event)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 7a6477c..92f5456 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -1,6 +1,7 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::scroll_region::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
-use cce_ui::widget::{List, TextBox, StatusDot, DotStatus, InteractiveListItem, Element};
+use cce_ui::widget::{TextBox, StatusDot, DotStatus, InteractiveListItem, Element};
 
 #[derive(Debug, Clone)]
 pub struct ServiceInfo {
@@ -27,14 +28,14 @@ impl Default for ServiceTab {
 pub struct ProcessesState {
     pub loaded: bool,
     pub processes: Vec<(String, String, String)>, // (pid, cpu, comm)
-    pub cpu_list_box: List,
+    pub cpu_list: ScrollRegion,
 
     // Services-related fields
     pub services_loaded: bool,
     pub services: Vec<ServiceInfo>,
     pub services_active_tab: ServiceTab,
     pub services_search_box: cce_ui::widget::Adapted<TextBox>,
-    pub services_list_box: List,
+    pub services_list: ScrollRegion,
     pub service_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
 }
 
@@ -43,13 +44,13 @@ impl Default for ProcessesState {
         Self {
             loaded: false,
             processes: Vec::new(),
-            cpu_list_box: List::new(24.0, 2.0),
+            cpu_list: ScrollRegion::new(24.0, 2.0),
 
             services_loaded: false,
             services: Vec::new(),
             services_active_tab: ServiceTab::System,
             services_search_box: TextBox::new(String::new()).with_label("Filter Services"),
-            services_list_box: List::new(36.0, 6.0),
+            services_list: ScrollRegion::new(36.0, 6.0),
             service_items: Vec::new(),
         }
     }
@@ -92,12 +93,12 @@ pub async fn fetch_processes_state() -> ProcessesState {
     ProcessesState {
         loaded: true,
         processes,
-        cpu_list_box: List::new(24.0, 2.0),
+        cpu_list: ScrollRegion::new(24.0, 2.0),
         services_loaded: false,
         services: Vec::new(),
         services_active_tab: ServiceTab::System,
         services_search_box: TextBox::new(String::new()).with_label("Filter Services"),
-        services_list_box: List::new(36.0, 6.0),
+        services_list: ScrollRegion::new(36.0, 6.0),
         service_items: Vec::new(),
     }
 }
@@ -122,26 +123,27 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             let list_box_w = sec.cw - 24.0;
             let list_box_h = 220.0;
             
-            // Render the standardized ScrollBox widget
-            render_widget(sec.pc, &mut state.cpu_list_box, list_box_x, list_box_y, list_box_w, list_box_h, ctx);
-
-            // Header for process list columns (drawn static on top of the ScrollBox background)
+            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
+            // scrollable viewport starts below the header.
             let header_h = 22.0;
+            state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - 6.0);
+            state.cpu_list.push_prims(sec.pc);
+
+            // Header for process list columns (drawn static on top of the list background)
             sec.pc.rect([0.12, 0.12, 0.16, 0.5], list_box_x + 1.0, list_box_y + 1.0, list_box_w - 2.0, header_h);
             sec.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
-            
+
             sec.pc.text("PID", list_box_x + 12.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
             sec.pc.text("COMMAND", list_box_x + 80.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
             sec.pc.text("CPU %", list_box_x + list_box_w - 60.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
 
             let row_h = 24.0;
-            // Update List bounds for the scrollable viewport (which starts below the header)
-            state.cpu_list_box.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - 6.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);
             for (idx, (pid, cpu, comm)) in state.processes.iter().enumerate() {
-                if let Some(draw_y) = state.cpu_list_box.get_item_draw_y(idx, 4.0) {
+                if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
                     // Standard row action button (transparent background, highlights on hover)
                     sec.pc.button(
                         "",
@@ -230,9 +232,6 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             let list_box_w = sec_w - 24.0;
             let list_box_h = 360.0;
             
-            state.services_list_box.clear_children(ctx);
-            render_widget(sec.pc, &mut state.services_list_box, list_box_x, list_box_y, list_box_w, list_box_h, ctx);
-
             // Filter services
             let query = if state.services_search_box.editing {
                 state.services_search_box.edit_buffer.to_lowercase()
@@ -244,10 +243,12 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                 .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
                 .collect();
 
-            // Update List bounds
-            state.services_list_box.update_bounds(filtered_services.len(), list_box_y, list_box_h);
+            // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+            state.services_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+            state.services_list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
+            state.services_list.push_prims(sec.pc);
 
-            let item_h = state.services_list_box.item_height;
+            let item_h = state.services_list.item_height;
 
             if state.service_items.len() != filtered_services.len() {
                 state.service_items.clear();
@@ -258,7 +259,7 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
 
             sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
             for (idx, service) in filtered_services.iter().enumerate() {
-                if let Some(draw_y) = state.services_list_box.get_item_draw_y(idx, 4.0) {
+                if let Some(draw_y) = state.services_list.get_item_draw_y(idx, 4.0) {
                     let is_active = service.active_state == "active" || service.sub_state == "running";
 
                     // Control buttons: Start, Stop, Restart on the right
@@ -286,10 +287,10 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                     };
 
                     // Render InteractiveListItem background and text labels
+                    // Rows dispatch as extra roots (the dissolved list is no parent).
                     let item_btn = &mut state.service_items[idx];
                     item_btn.title = service.name.clone();
                     item_btn.subtitle = Some(desc_truncated);
-                    cce_ui::widget::link_parent_child(&mut state.services_list_box.scroll_box, item_btn, ctx);
                     render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
 
                     // Render StatusDot
@@ -376,7 +377,7 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
         }
         ProcessesMessage::ServicesSetTab(tab) => {
             state.services_active_tab = tab;
-            state.services_list_box.set_scroll_y(0.0);
+            state.services_list.set_scroll_y(0.0);
             state.service_items.clear();
         }
         ProcessesMessage::ServicesStart(name, is_system) => {
@@ -481,12 +482,8 @@ fn service_action(name: &str, action: &str, is_system: bool) {
 
 impl crate::pages::AppPage for ProcessesState {
     fn clear_children(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        self.cpu_list_box.scroll_box.clear_children(ctx);
-        self.cpu_list_box.scroll_box.set_parent(None, ctx);
         self.services_search_box.clear_children(ctx);
         self.services_search_box.set_parent(None, ctx);
-        self.services_list_box.scroll_box.clear_children(ctx);
-        self.services_list_box.scroll_box.set_parent(None, ctx);
     }
 
     fn get_section_containers(&self) -> Vec<cce_ui::widget::SectionContainer> {
@@ -518,9 +515,7 @@ impl crate::pages::AppPage for ProcessesState {
         ctx: &mut cce_ui::context::UiContext,
     ) {
 
-        cce_ui::widget::link_parent_child(&mut sec_containers[0], &mut self.cpu_list_box.scroll_box, ctx);
         cce_ui::widget::link_parent_child(&mut sec_containers[1], &mut self.services_search_box, ctx);
-        cce_ui::widget::link_parent_child(&mut sec_containers[1], &mut self.services_list_box.scroll_box, ctx);
     }
 
     fn view(
@@ -538,6 +533,44 @@ impl crate::pages::AppPage for ProcessesState {
     }
 
     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
+
+    fn extra_dispatch_roots(&mut self) -> Vec<*mut (dyn Element + 'static)> {
+        self.service_items.iter_mut().map(|i| i.as_ptr_mut()).collect()
+    }
+
+    fn handle_pointer_move(
+        &mut self,
+        lx: f32,
+        ly: f32,
+        _actions: &mut Vec<crate::app::AppAction>,
+        _ctx: &mut cce_ui::context::UiContext,
+    ) -> bool {
+        let cpu = self.loaded && self.cpu_list.cursor_moved(lx, ly);
+        let services = self.services_loaded && self.services_list.cursor_moved(lx, ly);
+        cpu || services
+    }
+
+    fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        let cpu = self.loaded && self.cpu_list.press(lx, ly);
+        let services = self.services_loaded && self.services_list.press(lx, ly);
+        cpu || services
+    }
+
+    fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
+        let cpu = self.cpu_list.release();
+        let services = self.services_list.release();
+        cpu || services
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
+        (self.loaded && self.cpu_list.wheel(delta, lx, ly))
+            || (self.services_loaded && self.services_list.wheel(delta, lx, ly))
+    }
+
+    fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
+        (self.loaded && self.cpu_list.keyboard(event))
+            || (self.services_loaded && self.services_list.keyboard(event))
+    }
 }
 
 #[cfg(test)]
diff --git a/src/renderer.rs b/src/renderer.rs
index 1def5fb..fbd8d28 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -505,19 +505,17 @@ impl SystemInterface {
             let lh = buf.metrics().line_height;
             let mut left_align = btn.justify == cce_ui::widget::Justification::Left;
 
-            // Auto-detect if inside a ScrollBox to apply left alignment by default
+            // Auto-detect if inside a list frame to apply left alignment by default
             if !left_align && base.w >= 60.0 {
                 if self.app.current_page == Page::Processes {
-                    let sb1 = &self.app.processes.cpu_list_box;
-                    let (sb1_x, sb1_y, sb1_w, sb1_h) = sb1.rect();
-                    if base.x >= sb1_x - 1.0 && base.x + base.w <= sb1_x + sb1_w + 1.0
-                       && base.y >= sb1_y - 1.0 && base.y + base.h <= sb1_y + sb1_h + 1.0 {
+                    let sb1 = &self.app.processes.cpu_list;
+                    if base.x >= sb1.x - 1.0 && base.x + base.w <= sb1.x + sb1.w + 1.0
+                       && base.y >= sb1.y - 1.0 && base.y + base.h <= sb1.y + sb1.h + 1.0 {
                         left_align = true;
                     }
-                    let sb2 = &self.app.processes.services_list_box;
-                    let (sb2_x, sb2_y, sb2_w, sb2_h) = sb2.rect();
-                    if base.x >= sb2_x - 1.0 && base.x + base.w <= sb2_x + sb2_w + 1.0
-                       && base.y >= sb2_y - 1.0 && base.y + base.h <= sb2_y + sb2_h + 1.0 {
+                    let sb2 = &self.app.processes.services_list;
+                    if base.x >= sb2.x - 1.0 && base.x + base.w <= sb2.x + sb2.w + 1.0
+                       && base.y >= sb2.y - 1.0 && base.y + base.h <= sb2.y + sb2.h + 1.0 {
                         left_align = true;
                     }
                 }
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
new file mode 100644
index 0000000..9ae139d
--- /dev/null
+++ b/src/scroll_region.rs
@@ -0,0 +1,320 @@
+//! App-owned scroll region replacing the dissolved `ScrollBox` / `List` embedded bases
+//! (ported from cce-fonts' Phase 6q dissolution). Every settings list was a pure scroll
+//! frame (`List` with `columns: None`) — the rows are drawn by the pages themselves — so
+//! the widgets contributed only: the rounded border + background, the scrollbar, the
+//! scroll state/virtualization math, and wheel/drag/keyboard input. All replicated here.
+//!
+//! One deliberate visual fix over the legacy pipeline: `List::extra_quads` skipped its
+//! bg-removal for `columns: None` lists, so `render_widget` emitted border+bg TWICE (the
+//! plain bg quad through the solid-border branch, then `all_rounded_quads` again) with
+//! the scrollbar sandwiched between the two translucent bg layers — the same
+//! double-composite class Phase 6p found on Plates. The dissolved region draws
+//! border, bg, track, thumb once, in that order.
+
+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.
+    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)
+    }
+
+    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.
+    pub fn push_prims(&self, pc: &mut dyn cce_ui::layout::RenderTarget) {
+        let radius = cce_ui::layout::list_corner_radius();
+        let border_color = if self.focused {
+            [0.30, 0.50, 0.32, 1.0]
+        } else if self.hovered {
+            [0.25, 0.25, 0.35, 1.0]
+        } else {
+            [0.18, 0.18, 0.24, 1.0]
+        };
+        let all = (true, true, true, true);
+        pc.rect_with_radius_corners(border_color, self.x, self.y, self.w, self.h, radius, all);
+        pc.rect_with_radius_corners(
+            cce_ui::color::list_bg_color(),
+            self.x + 1.0,
+            self.y + 1.0,
+            self.w - 2.0,
+            self.h - 2.0,
+            (radius - 1.0).max(0.0),
+            all,
+        );
+        if self.content_h > self.viewport_h {
+            let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+            pc.rect(cce_ui::color::scrollbar_track_color(), sb_x, track_y, sb_w, track_h);
+            pc.rect(cce_ui::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h);
+        }
+    }
+}
+
+#[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,
+        };
+        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);
+    }
+}