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

commit54c45b69c28fde57e284151b6d1b2a7ae56332ae
parentb827a75a15
authorLucas Galante <[email protected]>
date2026-08-14 12:56
fix: dispatch roots follow row virtualization, so clicks hit the row on screen

`render_widget` is the only caller of a row's `layout()`, so a row scrolled out of
the viewport keeps the rect from the last frame it was drawn. But the row lists
re-registered and reported EVERY row as a dispatch root, and `dispatch_page_event`
takes the first root that hit-tests true in index order — so a stale low-index row
reliably beat the row actually painted at those coordinates.

Live repro on Packages (1359 rows): scroll ~25 notches, then jump the list, then
click the top visible row. It read "fontforge"; the app selected "appstream" — the
package that had been at the top of the list at the previous scroll position. Hover
highlight was wrong the same way.

Both `extra_dispatch_roots` and `register_extra_dispatch_roots` now filter on
`get_item_draw_y`, the same predicate the view's paint loop virtualizes on, so the
root set is exactly the set that had its rect refreshed this frame. Applied to
packages, services and timers — all three carried the identical register-all loop.
(Services only mis-highlighted, since its item clicks are never drained.)

Verified live after the fix: the same scroll-jump-click sequence selects fontforge.

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

 src/pages/packages.rs | 31 +++++++++++++++++++++++--------
 src/pages/services.rs | 17 +++++++++++++++--
 src/pages/timers.rs   | 17 +++++++++++++++--
 3 files changed, 53 insertions(+), 12 deletions(-)

diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index 86cbcc9..b36bb4b 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -772,19 +772,34 @@ impl crate::pages::AppPage for PackagesState {
         }
     }
 
+    // Both root methods below filter by `get_item_draw_y`, the SAME predicate the view's
+    // paint loop uses to virtualize rows. Only a row that was drawn had `render_widget`
+    // refresh its rect; a scrolled-out row keeps the rect from the last frame it was
+    // visible, and since `dispatch_page_event` takes the first root that hit-tests true
+    // in index order, an unfiltered list let a stale low-index row swallow clicks meant
+    // for the row actually on screen (click "fontforge", select "appstream").
     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
-        match self.active_tab {
-            PackageTab::Installed => self.installed_items.iter().map(|i| i.id()).collect(),
-            PackageTab::Updates => self.updates_items.iter().map(|i| i.id()).collect(),
-        }
+        let (list, items) = match self.active_tab {
+            PackageTab::Installed => (&self.installed_list, &self.installed_items),
+            PackageTab::Updates => (&self.updates_list, &self.updates_items),
+        };
+        items
+            .iter()
+            .enumerate()
+            .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
+            .map(|(_, i)| i.id())
+            .collect()
     }
 
     fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        let items = match self.active_tab {
-            PackageTab::Installed => &mut self.installed_items,
-            PackageTab::Updates => &mut self.updates_items,
+        let (list, items) = match self.active_tab {
+            PackageTab::Installed => (&self.installed_list, &mut self.installed_items),
+            PackageTab::Updates => (&self.updates_list, &mut self.updates_items),
         };
-        for i in items.iter_mut() {
+        for (idx, i) in items.iter_mut().enumerate() {
+            if list.get_item_draw_y(idx, 4.0).is_none() {
+                continue;
+            }
             let (id, ptr) = (i.id(), i.as_ptr_mut());
             ctx.register_widget(id, ptr);
         }
diff --git a/src/pages/services.rs b/src/pages/services.rs
index fc2d03b..fbd4be1 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -391,12 +391,25 @@ impl crate::pages::AppPage for ServicesState {
 
     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
 
+    // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
+    // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
+    // hit-test against the row actually on screen. See the note in packages.rs.
     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
-        self.items.iter().map(|i| i.id()).collect()
+        let (list, items) = (&self.list, &self.items);
+        items
+            .iter()
+            .enumerate()
+            .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
+            .map(|(_, i)| i.id())
+            .collect()
     }
 
     fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        for i in self.items.iter_mut() {
+        let (list, items) = (&self.list, &mut self.items);
+        for (idx, i) in items.iter_mut().enumerate() {
+            if list.get_item_draw_y(idx, 4.0).is_none() {
+                continue;
+            }
             let (id, ptr) = (i.id(), i.as_ptr_mut());
             ctx.register_widget(id, ptr);
         }
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index d68ed37..adbb8e3 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -689,12 +689,25 @@ impl crate::pages::AppPage for TimersState {
 
     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {}
 
+    // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
+    // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
+    // hit-test against the row actually on screen. See the note in packages.rs.
     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
-        self.items.iter().map(|i| i.id()).collect()
+        let (list, items) = (&self.list, &self.items);
+        items
+            .iter()
+            .enumerate()
+            .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
+            .map(|(_, i)| i.id())
+            .collect()
     }
 
     fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
-        for i in self.items.iter_mut() {
+        let (list, items) = (&self.list, &mut self.items);
+        for (idx, i) in items.iter_mut().enumerate() {
+            if list.get_item_draw_y(idx, 4.0).is_none() {
+                continue;
+            }
             let (id, ptr) = (i.id(), i.as_ptr_mut());
             ctx.register_widget(id, ptr);
         }