system settings
git clone https://git.lucas.co/cce-system-interface.git
fix: don't dispatch to a page that hasn't been laid out since the switch
A page switch takes effect immediately (`app.current_page` is reassigned in the
key/dropdown handlers), but widget registration is a side effect of the view pass
and `clear_hierarchy` wipes the registry at the top of every rebuild. So for the
one frame between the switch and the next `rebuild_layout`, none of the new page's
`section_widgets()` ids resolve, and the first event to arrive — in practice the
release half of the PageDown that caused the switch — was dropped once per root.
Measured by walking all 14 pages: drops on arrival equalled that page's
`section_widgets()` count exactly (Bluetooth 1, Browser 4, Default Apps 8,
Network 1, Notifications 3, ...). The five pages implementing
`register_extra_dispatch_roots` were incidentally immune, which is why the spam
looked arbitrary rather than systematic.
Dispatching across that gap cannot reach a widget — it only emits warnings that
would mask a real stale-root bug — so `dispatch_page_event` now returns false
until `laid_out_page` matches the current page. Same walk after the fix: 0 drops
on every page, and clicks, typing and ctrl-nav all still land.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/input_handler.rs | 10 ++++++++++
src/main.rs | 7 +++++++
src/renderer.rs | 1 +
3 files changed, 18 insertions(+)
diff --git a/src/input_handler.rs b/src/input_handler.rs
index 281df5b..271a5ab 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -355,6 +355,16 @@ impl SystemInterface {
/// (hover bookkeeping); other events stop at the first handler.
pub(crate) fn dispatch_page_event(&mut self, event: &cce_ui::widget::Event) -> bool {
use cce_ui::widget::Event;
+ // A page switch takes effect immediately, but widget registration is a side effect
+ // of the view pass and `clear_hierarchy` wipes the registry every rebuild — so
+ // until the new page has been laid out once, none of its `section_widgets()` ids
+ // resolve. Dispatching anyway can't reach a widget; it only emits one
+ // "unregistered/stale root" warning per root (the pages that implement
+ // `register_extra_dispatch_roots` were incidentally immune, which is why only 9 of
+ // the 14 spammed). Suppress across the gap instead — the rebuild is one frame away.
+ if self.laid_out_page != Some(self.app.current_page) {
+ return false;
+ }
let is_pointer_event = matches!(
event,
Event::PointerMove { .. } | Event::MouseButton { .. } | Event::MouseWheel { .. }
diff --git a/src/main.rs b/src/main.rs
index f24ca46..3856531 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -87,6 +87,12 @@ struct SystemInterface {
// (single-slot with the global widget focus — descending clears it); the per-section
// widget groups come from AppPage::section_widgets each time they're needed.
focused_section: Option<usize>,
+ // The page the last `rebuild_layout` actually laid out. Registration is a side effect
+ // of the view pass (`render_widget`), and `clear_hierarchy` wipes the registry each
+ // rebuild — so between a page switch and the next rebuild, the NEW page's
+ // `section_widgets()` ids are not registered and every event to them is dropped with
+ // a router warning. `dispatch_page_event` suppresses dispatch across that gap.
+ laid_out_page: Option<Page>,
page_dropdown: cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>,
// Switcher + Page DISSOLVED (Phase 6u): the current page is app.current_page, page
// scroll is scroll_y/max_scroll_y, and the page scrollbar is this app-owned widget
@@ -186,6 +192,7 @@ impl cce_ui::engine::Application for SystemInterface {
scrollable_buttons_start_idx: 0,
last_scroll_y: 0.0,
focused_section: None,
+ laid_out_page: None,
page_dropdown,
page_scroll_bar: crate::scroll_bar::ScrollBar::new(),
content_h: 0.0,
diff --git a/src/renderer.rs b/src/renderer.rs
index ecc700b..7d04a3a 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -663,6 +663,7 @@ impl SystemInterface {
}
}
self.needs_rebuild = false;
+ self.laid_out_page = Some(self.app.current_page);
self.last_scroll_y = self.scroll_y;
self.ui_context.clear_dirty();
}