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