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

commit33241e0ac3378cbf44c86c759e7819f4c4966dbd
parentac07fb417e
authorLucas Galante <[email protected]>
date2026-07-13 09:29
refactor: dispatch roots go by WidgetId (cce-ui plumbing retype)

propagate_event takes a WidgetId resolved through the registry; dispatch
sites name roots by id instead of minting raw pointers, and roots the
render path never registered are registered explicitly.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N4ajhvVZtyEEEus9bodsj3

 src/input_handler.rs       | 47 +++++++++++++++++++++++-----------------------
 src/pages/accounts.rs      | 17 ++++++++---------
 src/pages/audio.rs         | 15 +++++++--------
 src/pages/fonts.rs         | 47 ++++++++++++++++++++++++++++++++++++++--------
 src/pages/mod.rs           | 10 ++++++++--
 src/pages/network.rs       |  8 ++++----
 src/pages/notifications.rs |  9 ++++-----
 src/pages/packages.rs      | 21 ++++++++++++++++-----
 src/pages/processes.rs     | 15 +++++++++++----
 src/pages/storage.rs       |  2 +-
 src/pages/system_info.rs   | 17 +++++++++++++----
 src/renderer.rs            | 21 +++++++++++++++++++++
 12 files changed, 155 insertions(+), 74 deletions(-)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index 357694c..4b260f5 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -16,7 +16,7 @@ impl SystemInterface {
         if self.search_open && ly_no_scroll >= (sh_logical - 42.0) {
             // Routed (6bd): chrome coords, no scroll offset — same as the direct call.
             let ev = cce_ui::widget::Event::PointerMove { x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
-            let sb = self.search_box.as_ptr_mut();
+            let sb = self.search_box.id();
             if self.ui_context.propagate_event(&ev, sb) {
                 self.needs_rebuild = true;
             }
@@ -37,7 +37,7 @@ impl SystemInterface {
         let mut changed = false;
         {
             let ev = cce_ui::widget::Event::PointerMove { x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
-            let dd = self.page_dropdown.as_ptr_mut();
+            let dd = self.page_dropdown.id();
             if self.ui_context.propagate_event(&ev, dd) {
                 changed = true;
             }
@@ -97,7 +97,7 @@ impl SystemInterface {
         let sh_logical = self.height as f32 / s;
         if self.search_open && ly_no_scroll >= (sh_logical - 42.0) {
             let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
-            let sb = self.search_box.as_ptr_mut();
+            let sb = self.search_box.id();
             if self.ui_context.propagate_event(&ev, sb) {
                 self.needs_rebuild = true;
             }
@@ -127,8 +127,8 @@ impl SystemInterface {
         }
 
         let dd_ev = cce_ui::widget::Event::MouseButton { button, state, x: lx_no_scroll, y: ly_no_scroll, local_x: lx_no_scroll, local_y: ly_no_scroll };
-        let dd_ptr = self.page_dropdown.as_ptr_mut();
-        if self.ui_context.propagate_event(&dd_ev, dd_ptr) {
+        let dd_root = self.page_dropdown.id();
+        if self.ui_context.propagate_event(&dd_ev, dd_root) {
             if self.page_dropdown.take_change() {
                 let idx = self.page_dropdown.selected;
                 if idx < Page::ALL.len() {
@@ -153,9 +153,9 @@ impl SystemInterface {
         let mut button_handled = false;
         let mut clicked_action = None;
         let btn_ev = cce_ui::widget::Event::MouseButton { button, state, x: phys_x, y: phys_y, local_x: phys_x, local_y: phys_y };
-        let btn_ptrs: Vec<_> = self.page_buttons[self.scrollable_buttons_start_idx..].iter_mut().map(|(b, _)| b.as_ptr_mut()).collect();
-        for ptr in btn_ptrs {
-            if self.ui_context.propagate_event(&btn_ev, ptr) {
+        let btn_roots: Vec<_> = self.page_buttons[self.scrollable_buttons_start_idx..].iter().map(|(b, _)| b.id()).collect();
+        for root in btn_roots {
+            if self.ui_context.propagate_event(&btn_ev, root) {
                 button_handled = true;
             }
         }
@@ -280,7 +280,7 @@ impl SystemInterface {
     /// the pages' widgets themselves, flattened in the legacy propagate order (sections
     /// last-to-first, and within a section the container children were visited in
     /// reverse link order).
-    pub(crate) fn page_dispatch_roots(&mut self) -> Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)> {
+    pub(crate) fn page_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
         self.app
             .get_current_page_mut()
             .section_widgets()
@@ -333,8 +333,8 @@ impl SystemInterface {
                 *y -= self.scroll_y;
                 *local_y -= self.scroll_y;
             }
-            let sb_ptr = self.page_scroll_bar.as_ptr_mut();
-            if self.ui_context.propagate_event(&sb_event, sb_ptr) {
+            let sb_root = self.page_scroll_bar.id();
+            if self.ui_context.propagate_event(&sb_event, sb_root) {
                 if !is_pointer_move {
                     return true;
                 }
@@ -347,7 +347,11 @@ impl SystemInterface {
         // 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();
+        let extra_roots = {
+            let page = self.app.get_current_page_mut();
+            page.register_extra_dispatch_roots(&mut self.ui_context);
+            page.extra_dispatch_roots()
+        };
         for root in extra_roots {
             if self.ui_context.propagate_event(event, root) {
                 if !is_pointer_move {
@@ -408,10 +412,7 @@ impl SystemInterface {
                         self.search_open = true;
                         self.search_box.set_value_string("");
                         self.search_query.clear();
-                        let search_box_ptr = self.search_box.as_ptr_mut();
-                        unsafe {
-                            (*search_box_ptr).focus();
-                        }
+                        cce_ui::widget::WidgetHost::focus(&mut self.search_box);
                         self.ui_context.set_focused(&mut self.search_box);
                         self.needs_rebuild = true;
                         return true;
@@ -446,7 +447,7 @@ impl SystemInterface {
                     let groups = self.app.get_current_page_mut().section_widgets();
                     let focused_pos = groups.iter().enumerate().find_map(|(si, g)| {
                         g.iter()
-                            .position(|&w| unsafe { cce_ui::widget::focus::is_focused(&*w) })
+                            .position(|&id| cce_ui::widget::focus::is_focused_id(id))
                             .map(|wi| (si, wi))
                     });
                     if let Some((si, wi)) = focused_pos {
@@ -459,10 +460,9 @@ impl SystemInterface {
                             } else {
                                 wi - 1
                             };
-                            let next_ptr = group[next];
-                            unsafe {
-                                let w = &mut *next_ptr;
-                                cce_ui::widget::focus::set_focused(w, Some(&mut self.ui_context));
+                            let next_id = group[next];
+                            cce_ui::widget::focus::set_focused_id(next_id, Some(&mut self.ui_context));
+                            if let Some(w) = self.ui_context.get_widget_mut(next_id) {
                                 w.focus();
                             }
                             self.needs_rebuild = true;
@@ -492,9 +492,8 @@ impl SystemInterface {
                         }
                         if descend {
                             if let Some(&first) = groups[idx].first() {
-                                unsafe {
-                                    let w = &mut *first;
-                                    cce_ui::widget::focus::set_focused(w, Some(&mut self.ui_context));
+                                cce_ui::widget::focus::set_focused_id(first, Some(&mut self.ui_context));
+                                if let Some(w) = self.ui_context.get_widget_mut(first) {
                                     w.focus();
                                 }
                                 self.focused_section = None;
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 08aa4a3..d5cb6ec 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -824,19 +824,18 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
 
 impl crate::pages::AppPage for AccountsState {
     // Sections: [Accounts, Modify Accounts] — the modify group depends on the mode.
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
-        use cce_ui::widget::WidgetHost;
-        let modify: Vec<*mut (dyn WidgetHost + 'static)> = if self.editing_oauth_creds {
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        let modify: Vec<cce_ui::widget::WidgetId> = if self.editing_oauth_creds {
             vec![
-                self.oauth_client_id_box.as_ptr_mut(),
-                self.oauth_client_secret_box.as_ptr_mut(),
+                self.oauth_client_id_box.id(),
+                self.oauth_client_secret_box.id(),
             ]
         } else if self.adding_new {
             vec![
-                self.email_box.as_ptr_mut(),
-                self.password_box.as_ptr_mut(),
-                self.imap_box.as_ptr_mut(),
-                self.smtp_box.as_ptr_mut(),
+                self.email_box.id(),
+                self.password_box.id(),
+                self.imap_box.id(),
+                self.smtp_box.id(),
             ]
         } else {
             Vec::new()
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 1795cd2..25c2645 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -405,27 +405,26 @@ pub fn update(state: &mut AudioState, msg: AudioMessage) {
 impl crate::pages::AppPage for AudioState {
     // Sections: [Output, Input] — only active devices' controls, spinbox before slider
     // per device (the old link order).
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
-        use cce_ui::widget::WidgetHost;
-        let mut output: Vec<*mut (dyn WidgetHost + 'static)> = Vec::new();
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        let mut output: Vec<cce_ui::widget::WidgetId> = Vec::new();
         for (i, sink) in self.sinks.iter().enumerate() {
             if sink.active {
                 if let Some(sb) = self.sink_spinboxes.get_mut(i) {
-                    output.push(sb.as_ptr_mut());
+                    output.push(sb.id());
                 }
                 if let Some(sl) = self.sink_sliders.get_mut(i) {
-                    output.push(sl.as_ptr_mut());
+                    output.push(sl.id());
                 }
             }
         }
-        let mut input: Vec<*mut (dyn WidgetHost + 'static)> = Vec::new();
+        let mut input: Vec<cce_ui::widget::WidgetId> = Vec::new();
         for (i, src) in self.sources.iter().enumerate() {
             if src.active {
                 if let Some(sb) = self.source_spinboxes.get_mut(i) {
-                    input.push(sb.as_ptr_mut());
+                    input.push(sb.id());
                 }
                 if let Some(sl) = self.source_sliders.get_mut(i) {
-                    input.push(sl.as_ptr_mut());
+                    input.push(sl.id());
                 }
             }
         }
diff --git a/src/pages/fonts.rs b/src/pages/fonts.rs
index f51effa..99c6424 100644
--- a/src/pages/fonts.rs
+++ b/src/pages/fonts.rs
@@ -1,6 +1,6 @@
 use std::fs;
 use cce_ui::layout::LayoutStrategy;
-use cce_ui::widget::{TextBox, Dropdown, Spinbox, WidgetHost};
+use cce_ui::widget::{TextBox, Dropdown, Spinbox};
 use crate::app::{PageContent, AppAction};
 use crate::pages::AppPage;
 
@@ -98,17 +98,48 @@ impl Default for FontsState {
 
 impl AppPage for FontsState {
     // Sections: [Preferred Fonts, Borders, Status Interface, Fuzzel, Terminal]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
-        use cce_ui::widget::WidgetHost;
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
-            vec![self.sans_box.as_ptr_mut(), self.serif_box.as_ptr_mut(), self.mono_box.as_ptr_mut()],
-            vec![self.borders_menu.as_ptr_mut(), self.borders_box.as_ptr_mut()],
-            vec![self.status_menu.as_ptr_mut(), self.status_box.as_ptr_mut()],
-            vec![self.fuzzel_menu.as_ptr_mut(), self.fuzzel_box.as_ptr_mut(), self.fuzzel_size_box.as_ptr_mut()],
-            vec![self.terminal_menu.as_ptr_mut(), self.terminal_box.as_ptr_mut(), self.terminal_size_box.as_ptr_mut()],
+            vec![self.sans_box.id(), self.serif_box.id(), self.mono_box.id()],
+            vec![self.borders_menu.id(), self.borders_box.id()],
+            vec![self.status_menu.id(), self.status_box.id()],
+            vec![self.fuzzel_menu.id(), self.fuzzel_box.id(), self.fuzzel_size_box.id()],
+            vec![self.terminal_menu.id(), self.terminal_box.id(), self.terminal_size_box.id()],
         ]
     }
 
+    // Not every section widget passes through `render_widget`'s registration side
+    // effect (the menus draw custom); the id-rooted router needs them all resolvable.
+    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
+        use cce_ui::widget::WidgetHost;
+        let (id, ptr) = (self.sans_box.id(), self.sans_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.serif_box.id(), self.serif_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.mono_box.id(), self.mono_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.borders_menu.id(), self.borders_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.borders_box.id(), self.borders_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.status_menu.id(), self.status_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.status_box.id(), self.status_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.fuzzel_menu.id(), self.fuzzel_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.fuzzel_box.id(), self.fuzzel_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.fuzzel_size_box.id(), self.fuzzel_size_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.terminal_menu.id(), self.terminal_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.terminal_box.id(), self.terminal_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.terminal_size_box.id(), self.terminal_size_box.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+    }
+
     fn view(
         &mut self,
         cx: f32,
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index fd12931..58f7f01 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -64,7 +64,7 @@ pub trait AppPage {
     /// outer length drives the `sec_focused` flags) — holding the widgets that used to
     /// hang under that section's container, in the old link order. The widgets dispatch
     /// directly as propagate roots and the groups drive the app-side ctrl-nav.
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>>;
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>>;
 
     fn view(
         &mut self,
@@ -101,10 +101,16 @@ pub trait AppPage {
     /// 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::WidgetHost + 'static)> {
+    fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
         Vec::new()
     }
 
+    /// Refresh the extra dispatch roots' registrations (id-rooted router: roots resolve
+    /// through the registry). The row Vecs are rebuilt on data refresh, so pages
+    /// re-register the current allocations right before each dispatch — the same
+    /// liveness contract the pointer-rooted dispatch had. Default no-op.
+    fn register_extra_dispatch_roots(&mut self, _ctx: &mut cce_ui::context::UiContext) {}
+
     /// 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.
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 6b3f3c2..89ff7b1 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,7 +1,7 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
 use crate::scroll_region::ScrollRegion;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
-use cce_ui::widget::{Adapted, Toggle, WidgetHost};
+use cce_ui::widget::{Adapted, Toggle};
 
 #[derive(Debug, Clone)]
 pub struct WifiNetwork {
@@ -526,10 +526,10 @@ impl NetworkState {
 
 impl crate::pages::AppPage for NetworkState {
     // Sections: [WiFi, Bluetooth]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn WidgetHost + 'static)>> {
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
-            vec![self.wifi_toggle.as_ptr_mut()],
-            vec![self.bt_toggle.as_ptr_mut()],
+            vec![self.wifi_toggle.id()],
+            vec![self.bt_toggle.id()],
         ]
     }
 
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 8e8c9a4..a7c0a33 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -185,12 +185,11 @@ fn get_config_path() -> String {
 
 impl AppPage for NotificationsState {
     // Sections: [Notifications Settings]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
-        use cce_ui::widget::WidgetHost;
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![vec![
-            self.enable_toggle.as_ptr_mut(),
-            self.bell_menu.as_ptr_mut(),
-            self.duration_spinbox.as_ptr_mut(),
+            self.enable_toggle.id(),
+            self.bell_menu.id(),
+            self.duration_spinbox.id(),
         ]]
     }
 
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index 41cfd22..9e39f90 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -719,9 +719,9 @@ impl PackagesState {
 
 impl crate::pages::AppPage for PackagesState {
     // Sections: [Packages, Package Info, System Update]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn WidgetHost + 'static)>> {
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
-            vec![self.search_box.as_ptr_mut()],
+            vec![self.search_box.id()],
             Vec::new(),
             Vec::new(),
         ]
@@ -780,10 +780,21 @@ impl crate::pages::AppPage for PackagesState {
         }
     }
 
-    fn extra_dispatch_roots(&mut self) -> Vec<*mut (dyn WidgetHost + 'static)> {
+    fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
         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(),
+            PackageTab::Installed => self.installed_items.iter().map(|i| i.id()).collect(),
+            PackageTab::Updates => self.updates_items.iter().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,
+        };
+        for i in items.iter_mut() {
+            let (id, ptr) = (i.id(), i.as_ptr_mut());
+            ctx.register_widget(id, ptr);
         }
     }
 
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 0a0b37f..0d51f1f 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -482,10 +482,10 @@ fn service_action(name: &str, action: &str, is_system: bool) {
 
 impl crate::pages::AppPage for ProcessesState {
     // Sections: [Processes, Services]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn WidgetHost + 'static)>> {
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
             Vec::new(),
-            vec![self.services_search_box.as_ptr_mut()],
+            vec![self.services_search_box.id()],
         ]
     }
 
@@ -505,8 +505,15 @@ 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 WidgetHost + 'static)> {
-        self.service_items.iter_mut().map(|i| i.as_ptr_mut()).collect()
+    fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
+        self.service_items.iter().map(|i| i.id()).collect()
+    }
+
+    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
+        for i in self.service_items.iter_mut() {
+            let (id, ptr) = (i.id(), i.as_ptr_mut());
+            ctx.register_widget(id, ptr);
+        }
     }
 
     fn handle_pointer_move(
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index 16beb8a..0c00000 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -310,7 +310,7 @@ pub fn update(state: &mut StorageState, msg: StorageMessage) {
 
 impl crate::pages::AppPage for StorageState {
     // Sections: [Local Storage, Memory, Full System Backup] — no evented widgets.
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![Vec::new(), Vec::new(), Vec::new()]
     }
 
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 90d85e2..21140dd 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -820,19 +820,28 @@ pub fn update(state: &mut SystemState, msg: SystemMessage, ctx: &mut cce_ui::con
 
 impl crate::pages::AppPage for SystemState {
     // Sections: [System, System Actions, CPU, GPU, CPU Governor, GPU Power, Battery]
-    fn section_widgets(&mut self) -> Vec<Vec<*mut (dyn cce_ui::widget::WidgetHost + 'static)>> {
-        use cce_ui::widget::WidgetHost;
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         vec![
             Vec::new(),
             Vec::new(),
             Vec::new(),
             Vec::new(),
-            vec![self.cpu_gov_menu.as_ptr_mut()],
-            vec![self.gpu_gov_menu.as_ptr_mut()],
+            vec![self.cpu_gov_menu.id()],
+            vec![self.gpu_gov_menu.id()],
             Vec::new(),
         ]
     }
 
+    // The governor menus draw custom (no `render_widget` registration side effect);
+    // the id-rooted router needs them resolvable.
+    fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
+        use cce_ui::widget::WidgetHost;
+        let (id, ptr) = (self.cpu_gov_menu.id(), self.cpu_gov_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        let (id, ptr) = (self.gpu_gov_menu.id(), self.gpu_gov_menu.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+    }
+
     fn view(
         &mut self,
         cx: f32,
diff --git a/src/renderer.rs b/src/renderer.rs
index 78c0946..26ad154 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -641,6 +641,27 @@ impl SystemInterface {
         self.widgets = widgets;
         self.texts = texts;
         self.page_buttons = page_buttons;
+
+        // The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
+        // through the registry, and `clear_hierarchy` above wiped it. The view pass
+        // re-registers page widgets through `render_widget`; the chrome dispatch roots
+        // never go through it, so re-register them here. The page buttons are
+        // per-rebuild clones — registration follows the fresh allocations.
+        {
+            let id = self.search_box.id();
+            let ptr = self.search_box.as_ptr_mut();
+            self.ui_context.register_widget(id, ptr);
+            let id = self.page_dropdown.id();
+            let ptr = self.page_dropdown.as_ptr_mut();
+            self.ui_context.register_widget(id, ptr);
+            let id = self.page_scroll_bar.id();
+            let ptr = self.page_scroll_bar.as_ptr_mut();
+            self.ui_context.register_widget(id, ptr);
+            for (btn, _) in self.page_buttons.iter_mut() {
+                let (id, ptr) = (btn.id(), btn.as_ptr_mut());
+                self.ui_context.register_widget(id, ptr);
+            }
+        }
         self.needs_rebuild = false;
         self.last_scroll_y = self.scroll_y;
         self.ui_context.clear_dirty();