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

commitb92139ccfca9c84a064417f1d2816e4f33296f47
parent91ddf37523
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 22:08
refactor: hold widgets in Owned boxes (cce-ui Owned<W>)

The UiContext registry keeps raw pointers to registered widgets, and a
widget that moved while registered (its Vec reallocating, its struct
returned by value) left one pointing at the old address. Each widget
field is now an Owned<Adapted<..>> — a heap box whose allocation, not the
widget, carries the liveness token the registry watches — so the
registered address never moves and is dropped from the registry when the
box is freed. Field access is unchanged through Deref; construction
sites wrap in Owned::new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/app.rs                 | 13 +++++++------
 src/main.rs                | 17 +++++++++--------
 src/pages/accounts.rs      | 25 +++++++++++++------------
 src/pages/audio.rs         | 39 ++++++++++++++++++++-------------------
 src/pages/bluetooth.rs     |  7 ++++---
 src/pages/browser.rs       | 39 ++++++++++++++++++++-------------------
 src/pages/default_apps.rs  |  5 +++--
 src/pages/network.rs       |  7 ++++---
 src/pages/notifications.rs | 19 ++++++++++---------
 src/pages/packages.rs      | 13 +++++++------
 src/pages/power.rs         | 17 +++++++++--------
 src/pages/services.rs      |  9 +++++----
 src/pages/storage.rs       | 15 ++++++++-------
 src/pages/system_info.rs   | 21 +++++++++++----------
 src/pages/timers.rs        | 25 +++++++++++++------------
 src/renderer.rs            |  2 +-
 16 files changed, 144 insertions(+), 129 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index b13e9ee..7e8b215 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,3 +1,4 @@
+use cce_ui::widget::Owned;
 use cce_ui::layout::RenderTarget;
 
 use crate::pages::audio;
@@ -204,7 +205,7 @@ pub struct PageContent {
     /// (button, action, clip): clip is the innermost push_clip_rect at emission
     /// time (page coords) — the renderer clamps the drawn quad, label bounds,
     /// and the dispatch clone's hit rect to it.
-    pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
+    pub buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction, Option<[f32; 4]>)>,
     /// Glyphs placed with [`PageContent::icon`] (and a widget's own, through
     /// `RenderTarget::icon` — the expanded Dropdown's chevron).
     pub icons: Vec<PageIcon>,
@@ -343,7 +344,7 @@ impl PageContent {
             .with_hover_bg(hover_bg)
             .with_label_color(label_color);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((btn, action, clip));
+        self.buttons.push((Owned::new(btn), action, clip));
     }
 
     /// A button whose face is a bundled cce-icons glyph instead of a label.
@@ -369,7 +370,7 @@ impl PageContent {
             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(alpha);
         }
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((btn, action, clip));
+        self.buttons.push((Owned::new(btn), action, clip));
     }
 
     /// A row of a list, in the toolkit's list style (`Button::new_list_row`,
@@ -384,7 +385,7 @@ impl PageContent {
         if self.measure_only { return; }
         let btn = cce_ui::widget::Button::new_list_row(x, y, w, h);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((btn, action, clip));
+        self.buttons.push((Owned::new(btn), action, clip));
     }
 
     /// [`button_icon`](Self::button_icon) with the glyph tinted
@@ -404,7 +405,7 @@ impl PageContent {
             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(label_color[3]);
         }
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((btn, action, clip));
+        self.buttons.push((Owned::new(btn), action, clip));
     }
 
     pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
@@ -418,7 +419,7 @@ impl PageContent {
             .with_label_color(label_color)
             .with_left_align(true);
         let clip = self.clip_stack.last().copied();
-        self.buttons.push((btn, action, clip));
+        self.buttons.push((Owned::new(btn), action, clip));
     }
 }
 
diff --git a/src/main.rs b/src/main.rs
index 3f8c2f7..7e5a096 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+use cce_ui::widget::Owned;
 use cce_ui::widget::hover_animation;
 use cce_ui::cosmic_text::{Buffer, FontSystem};
 
@@ -74,7 +75,7 @@ struct SystemInterface {
     /// (`PageContent::control_relief_marks`) — display_list slots each carve
     /// back between the rects the widget drew before and after it.
     popover_control_relief_marks: Vec<usize>,
-    page_buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction)>,
+    page_buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
 
     sidebar_width: f32,
     header_height: f32,
@@ -138,12 +139,12 @@ struct SystemInterface {
     /// a frame that only replays the cached layout paints no widget, and
     /// would otherwise read as the field closing.
     text_claim: Option<[f32; 4]>,
-    page_dropdown: cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>,
+    page_dropdown: Owned<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
     // (rendered into the window assembly, evented directly). content_h feeds it — the
     // window pass reads last frame's value, exactly as the legacy Page did.
-    page_scroll_bar: cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>,
+    page_scroll_bar: Owned<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
     content_h: f32,
     // Section wells: body box + title tab (page coords, pre-scroll) — carved by
     // display_list.
@@ -185,7 +186,7 @@ struct SystemInterface {
     scroll_logs: Vec<String>,
     search_open: bool,
     search_query: String,
-    search_box: cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>,
+    search_box: Owned<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
 
 }
 
@@ -301,8 +302,8 @@ impl cce_ui::engine::Application for SystemInterface {
             laid_out_page: None,
             refocus_rect: None,
             text_claim: None,
-            page_dropdown,
-            page_scroll_bar: crate::scroll_bar::ScrollBar::new(),
+            page_dropdown: Owned::new(page_dropdown),
+            page_scroll_bar: Owned::new(crate::scroll_bar::ScrollBar::new()),
             content_h: 0.0,
             page_reliefs: Vec::new(),
             page_control_reliefs: Vec::new(),
@@ -320,9 +321,9 @@ impl cce_ui::engine::Application for SystemInterface {
             scroll_logs: Vec::new(),
             search_open: false,
             search_query: String::new(),
-            search_box: cce_ui::widget::input::TextBox::new(String::new())
+            search_box: Owned::new(cce_ui::widget::input::TextBox::new(String::new())
                 .with_placeholder("Search sections & parameters...")
-                .with_draw_bg_border(false),
+                .with_draw_bg_border(false)),
         };
         this.app.system_info.sender = Some(this.sender.clone());
 
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 2cec31a..fc6c055 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent, SectionContextExt, section_divider, section_kv_row};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
 use cce_ui::widget::ScrollRegion;
 use cce_ui::widget::{TextBox, WidgetHost};
@@ -79,10 +80,10 @@ pub struct AccountsState {
     pub accounts: Vec<AccountInfo>,
     pub selected_idx: Option<usize>,
     pub adding_new: bool,
-    pub email_box: cce_ui::widget::Adapted<TextBox>,
-    pub password_box: cce_ui::widget::Adapted<TextBox>,
-    pub imap_box: cce_ui::widget::Adapted<TextBox>,
-    pub smtp_box: cce_ui::widget::Adapted<TextBox>,
+    pub email_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub password_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub imap_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub smtp_box: Owned<cce_ui::widget::Adapted<TextBox>>,
     pub status_msg: Option<String>,
     pub status_msg_timer: f32,
     pub oauth_listener_running: bool,
@@ -92,8 +93,8 @@ pub struct AccountsState {
     pub editing_email: Option<String>,
     /// Per-account OAuth credentials — the copy in `accounts.json` that
     /// cce-mail actually refreshes with, not the global template.
-    pub oauth_client_id_box: cce_ui::widget::Adapted<TextBox>,
-    pub oauth_client_secret_box: cce_ui::widget::Adapted<TextBox>,
+    pub oauth_client_id_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub oauth_client_secret_box: Owned<cce_ui::widget::Adapted<TextBox>>,
     /// Per-address keyring status from the last snapshot, plus optimistic
     /// updates from Save/Delete (the 3s watcher pass corrects them).
     pub keyring: std::collections::HashMap<String, KeyringStatus>,
@@ -108,19 +109,19 @@ pub struct AccountsState {
 impl AccountsState {
     pub fn default_mock() -> Self {
         let mut state = Self::default();
-        state.email_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address");
+        state.email_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address"));
         state.password_box = {
             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Password / App Password");
             tb.is_password = true;
-            tb
+            Owned::new(tb)
         };
-        state.imap_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server");
-        state.smtp_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server");
-        state.oauth_client_id_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID");
+        state.imap_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server"));
+        state.smtp_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server"));
+        state.oauth_client_id_box = Owned::new(TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID"));
         state.oauth_client_secret_box = {
             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client Secret");
             tb.is_password = true;
-            tb
+            Owned::new(tb)
         };
         state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP).with_sink_behind(true);
         state
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index a3dae0d..4f0978b 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Spinbox, Slider, WidgetHost};
 
@@ -25,10 +26,10 @@ pub struct AudioState {
     pub loaded: bool,
     pub sinks: Vec<AudioSink>,
     pub sources: Vec<AudioSource>,
-    pub sink_spinboxes: Vec<Box<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
-    pub source_spinboxes: Vec<Box<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
-    pub sink_sliders: Vec<Box<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
-    pub source_sliders: Vec<Box<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
+    pub sink_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
+    pub source_spinboxes: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>>,
+    pub sink_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
+    pub source_sliders: Vec<Box<Owned<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>>,
 }
 
 #[derive(Debug, Clone)]
@@ -232,8 +233,8 @@ fn device_row(
     active: bool,
     muted: bool,
     volume: f32,
-    slider: &mut cce_ui::widget::Adapted<Slider>,
-    spin: &mut cce_ui::widget::Adapted<Spinbox>,
+    slider: &mut Owned<cce_ui::widget::Adapted<Slider>>,
+    spin: &mut Owned<cce_ui::widget::Adapted<Spinbox>>,
     mute_action: AppAction,
     ctx: &mut cce_ui::context::UiContext,
 ) {
@@ -356,10 +357,10 @@ pub fn update(state: &mut AudioState, msg: AudioMessage) {
             state.loaded = new.loaded;
             state.sinks = new.sinks;
             state.sources = new.sources;
-            state.sink_spinboxes.resize_with(state.sinks.len(), || Box::new(Spinbox::new(50, 0, 100, 1)));
-            state.source_spinboxes.resize_with(state.sources.len(), || Box::new(Spinbox::new(50, 0, 100, 1)));
-            state.sink_sliders.resize_with(state.sinks.len(), || Box::new(Slider::new().with_range(0.0, 1.0).with_scroll(true)));
-            state.source_sliders.resize_with(state.sources.len(), || Box::new(Slider::new().with_range(0.0, 1.0).with_scroll(true)));
+            state.sink_spinboxes.resize_with(state.sinks.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
+            state.source_spinboxes.resize_with(state.sources.len(), || Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
+            state.sink_sliders.resize_with(state.sinks.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
+            state.source_sliders.resize_with(state.sources.len(), || Box::new(Owned::new(Slider::new().with_range(0.0, 1.0).with_scroll(true))));
         }
         AudioMessage::SinkVolume(id, vol) => {
             if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
@@ -490,17 +491,17 @@ mod tests {
             ],
             sources: vec![],
             sink_spinboxes: vec![
-                Box::new(Spinbox::new(57, 0, 100, 1)),
-                Box::new(Spinbox::new(50, 0, 100, 1)),
-                Box::new(Spinbox::new(50, 0, 100, 1)),
-                Box::new(Spinbox::new(50, 0, 100, 1)),
+                Box::new(Owned::new(Spinbox::new(57, 0, 100, 1))),
+                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
+                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
+                Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))),
             ],
             source_spinboxes: vec![],
             sink_sliders: vec![
-                Box::new(Slider::new()),
-                Box::new(Slider::new()),
-                Box::new(Slider::new()),
-                Box::new(Slider::new()),
+                Box::new(Owned::new(Slider::new())),
+                Box::new(Owned::new(Slider::new())),
+                Box::new(Owned::new(Slider::new())),
+                Box::new(Owned::new(Slider::new())),
             ],
             source_sliders: vec![],
         };
@@ -522,7 +523,7 @@ mod tests {
     fn test_boxed_spinbox_right_click_crash() {
         use cce_ui::widget::{WidgetHost, Spinbox};
         let mut state = AudioState::default();
-        state.sink_spinboxes.push(Box::new(Spinbox::new(50, 0, 100, 1)));
+        state.sink_spinboxes.push(Box::new(Owned::new(Spinbox::new(50, 0, 100, 1))));
         let mut ctx = cce_ui::context::UiContext::new();
         let sb = &mut state.sink_spinboxes[0];
         sb.set_rect(0.0, 0.0, 100.0, 44.0);
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index 933db27..0c6254c 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Adapted, Toggle};
 
@@ -18,7 +19,7 @@ pub struct BluetoothState {
     pub enabled: bool,
     pub devices: Vec<BluetoothDevice>,
     pub scanning: bool,
-    pub toggle: Adapted<Toggle>,
+    pub toggle: Owned<Adapted<Toggle>>,
 }
 
 impl Default for BluetoothState {
@@ -30,7 +31,7 @@ impl Default for BluetoothState {
             enabled: false,
             devices: Vec::new(),
             scanning: false,
-            toggle: Toggle::new(),
+            toggle: Owned::new(Toggle::new()),
         }
     }
 }
@@ -80,7 +81,7 @@ pub async fn fetch_bluetooth_page_state() -> BluetoothState {
         .unwrap_or(false);
 
     let devices = if enabled { fetch_devices().await } else { Vec::new() };
-    BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Toggle::new() }
+    BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Owned::new(Toggle::new()) }
 }
 
 async fn fetch_devices() -> Vec<BluetoothDevice> {
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
index affa241..e9659b6 100644
--- a/src/pages/browser.rs
+++ b/src/pages/browser.rs
@@ -7,6 +7,7 @@
 
 use std::fs;
 
+use cce_ui::widget::Owned;
 use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
 use cce_ui::widget::input::{Dropdown, Toggle};
 use cce_ui::widget::{TextBox, WidgetHost};
@@ -61,14 +62,14 @@ pub struct BrowserState {
     pub vi_mode: bool,
     pub bar_position: String,
     pub color_scheme: String,
-    pub homepage_box: cce_ui::widget::Adapted<TextBox>,
-    pub search_menu: cce_ui::widget::Adapted<Dropdown>,
-    pub bar_position_menu: cce_ui::widget::Adapted<Dropdown>,
-    pub color_scheme_menu: cce_ui::widget::Adapted<Dropdown>,
-    pub download_dir_box: cce_ui::widget::Adapted<TextBox>,
-    pub history_toggle: cce_ui::widget::Adapted<Toggle>,
-    pub raindrop_toggle: cce_ui::widget::Adapted<Toggle>,
-    pub vi_mode_toggle: cce_ui::widget::Adapted<Toggle>,
+    pub homepage_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub search_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    pub bar_position_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    pub color_scheme_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    pub download_dir_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub history_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+    pub raindrop_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
+    pub vi_mode_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
 }
 
 impl Default for BrowserState {
@@ -92,30 +93,30 @@ impl Default for BrowserState {
             vi_mode: config.vi_mode,
             bar_position: config.bar_position.clone(),
             color_scheme: config.color_scheme.clone(),
-            homepage_box,
-            search_menu: Dropdown::new(
+            homepage_box: Owned::new(homepage_box),
+            search_menu: Owned::new(Dropdown::new(
                 SEARCH_ENGINES.iter().map(|(_, label)| label.to_string()).collect(),
                 search_index(&config.search),
             )
-            .with_label("Search Engine"),
-            bar_position_menu: Dropdown::new(
+            .with_label("Search Engine")),
+            bar_position_menu: Owned::new(Dropdown::new(
                 BAR_POSITIONS.iter().map(|(_, label)| label.to_string()).collect(),
                 bar_position_index(&config.bar_position),
             )
-            .with_label("Navigation Bar Position"),
-            color_scheme_menu: Dropdown::new(
+            .with_label("Navigation Bar Position")),
+            color_scheme_menu: Owned::new(Dropdown::new(
                 COLOR_SCHEMES.iter().map(|(_, label)| label.to_string()).collect(),
                 color_scheme_index(&config.color_scheme),
             )
-            .with_label("Page Color Scheme"),
-            download_dir_box,
+            .with_label("Page Color Scheme")),
+            download_dir_box: Owned::new(download_dir_box),
             // Left-aligned, as the designer's parameter pane sets its toggles:
             // a toggle's run is half its width, so the seam where run meets
             // well sits at the midpoint, and a centred label on a row-wide
             // toggle had it drawn straight through the text.
-            history_toggle: Toggle::new().with_label("Record History").with_left_align(true),
-            raindrop_toggle: Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true),
-            vi_mode_toggle: Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true),
+            history_toggle: Owned::new(Toggle::new().with_label("Record History").with_left_align(true)),
+            raindrop_toggle: Owned::new(Toggle::new().with_label("Sync Bookmarks with Raindrop").with_left_align(true)),
+            vi_mode_toggle: Owned::new(Toggle::new().with_label("Vi Keys (qutebrowser-style)").with_left_align(true)),
         }
     }
 }
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
index 77fb85d..025aedc 100644
--- a/src/pages/default_apps.rs
+++ b/src/pages/default_apps.rs
@@ -11,6 +11,7 @@
 //! entries, and startcce exports as `$TERMINAL` for everything else.
 
 use crate::app::{AppAction, PageContent};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Dropdown, WidgetHost};
 use std::collections::HashMap;
@@ -60,7 +61,7 @@ pub struct CategoryEntry {
     /// Applied value per dropdown option (desktop id for MIME categories, a
     /// command for Terminal; None = the "not set" placeholder row).
     pub option_ids: Vec<Option<String>>,
-    pub dropdown: cce_ui::widget::Adapted<Dropdown>,
+    pub dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
 }
 
 #[derive(Debug, Clone)]
@@ -80,7 +81,7 @@ impl Default for DefaultAppsState {
                     kind,
                     info: CategoryInfo { candidates: Vec::new(), current: None },
                     option_ids: vec![None],
-                    dropdown: Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label),
+                    dropdown: Owned::new(Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label)),
                 })
                 .collect(),
         }
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 59c5266..f1df89d 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent};
+use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
 use cce_ui::widget::{Adapted, Toggle};
@@ -21,7 +22,7 @@ pub struct NetworkState {
     pub device: String,
     pub available: Vec<WifiNetwork>,
     pub wifi_list: ScrollRegion,
-    pub wifi_toggle: Adapted<Toggle>,
+    pub wifi_toggle: Owned<Adapted<Toggle>>,
 }
 
 impl Default for NetworkState {
@@ -35,7 +36,7 @@ impl Default for NetworkState {
             device: String::new(),
             available: Vec::new(),
             wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
-            wifi_toggle: Toggle::new(),
+            wifi_toggle: Owned::new(Toggle::new()),
         }
     }
 }
@@ -99,7 +100,7 @@ pub async fn fetch_network_state() -> NetworkState {
         wifi_enabled, connected_ssid, signal_strength: signal,
         ip_address, device, available,
         wifi_list: ScrollRegion::new(26.0, 4.0).with_sink_behind(true),
-        wifi_toggle: Toggle::new(),
+        wifi_toggle: Owned::new(Toggle::new()),
     }
 }
 
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 72fd50a..f00e1df 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -1,5 +1,6 @@
 use std::fs;
 use std::io::Write;
+use cce_ui::widget::Owned;
 use cce_ui::widget::input::{Toggle, Dropdown, Spinbox};
 use cce_ui::widget::WidgetHost;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
@@ -18,11 +19,11 @@ pub struct NotificationsConfig {
 pub struct NotificationsState {
     pub loaded: bool,
     pub enable: bool,
-    pub enable_toggle: cce_ui::widget::Adapted<Toggle>,
+    pub enable_toggle: Owned<cce_ui::widget::Adapted<Toggle>>,
     pub bell: String,
-    pub bell_menu: cce_ui::widget::Adapted<Dropdown>,
+    pub bell_menu: Owned<cce_ui::widget::Adapted<Dropdown>>,
     pub duration: i32,
-    pub duration_spinbox: cce_ui::widget::Adapted<cce_ui::widget::Spinbox>,
+    pub duration_spinbox: Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
 }
 
 impl Default for NotificationsState {
@@ -32,12 +33,12 @@ impl Default for NotificationsState {
             enable: true,
             // Left-aligned: centred, the label straddled the seam at the
             // toggle's midpoint (see the Browser page's toggles).
-            enable_toggle: Toggle::new()
+            enable_toggle: Owned::new(Toggle::new()
                 .with_label("Enable Notifications")
                 .with_left_align(true)
-                .with_config(&get_config_path(), "enable"),
+                .with_config(&get_config_path(), "enable")),
             bell: "none".to_string(),
-            bell_menu: Dropdown::new(
+            bell_menu: Owned::new(Dropdown::new(
                 vec![
                     "None".to_string(),
                     "Bell".to_string(),
@@ -45,12 +46,12 @@ impl Default for NotificationsState {
                     "Message".to_string(),
                 ],
                 0,
-            ).with_label("Notification Sound"),
+            ).with_label("Notification Sound")),
             duration: 5,
-            duration_spinbox: Spinbox::new(5, 1, 60, 1)
+            duration_spinbox: Owned::new(Spinbox::new(5, 1, 60, 1)
                 .with_label("Notification Duration")
                 .with_unit("s")
-                .with_config(&get_config_path(), "duration"),
+                .with_config(&get_config_path(), "duration")),
         }
     }
 }
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index a81c524..6fccf4d 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, SectionContext, RenderTarget};
 use cce_ui::widget::{WidgetHost, TextBox, InteractiveListItem};
@@ -67,11 +68,11 @@ pub struct PackagesState {
     pub installed: Vec<PackageInfo>,
     pub updates: Vec<UpdateInfo>,
     pub active_tab: PackageTab,
-    pub search_box: cce_ui::widget::Adapted<TextBox>,
+    pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
     pub installed_list: ScrollRegion,
-    pub installed_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
+    pub installed_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub updates_list: ScrollRegion,
-    pub updates_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
+    pub updates_items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub updating: bool,
     pub last_update_res: Option<Result<(), String>>,
     pub selected_package: Option<String>,
@@ -97,7 +98,7 @@ impl Default for PackagesState {
             installed: Vec::new(),
             updates: Vec::new(),
             active_tab: PackageTab::Installed,
-            search_box: TextBox::new(String::new()).with_placeholder("Filter Packages..."),
+            search_box: Owned::new(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
             installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
             installed_items: Vec::new(),
             updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
@@ -919,7 +920,7 @@ pub fn view(
                 if state.installed_items.len() != filtered.len() {
                     state.installed_items.clear();
                     for _ in 0..filtered.len() {
-                        state.installed_items.push(InteractiveListItem::new(""));
+                        state.installed_items.push(Owned::new(InteractiveListItem::new("")));
                     }
                 }
 
@@ -970,7 +971,7 @@ pub fn view(
                 if state.updates_items.len() != filtered.len() {
                     state.updates_items.clear();
                     for _ in 0..filtered.len() {
-                        state.updates_items.push(InteractiveListItem::new(""));
+                        state.updates_items.push(Owned::new(InteractiveListItem::new("")));
                     }
                 }
 
diff --git a/src/pages/power.rs b/src/pages/power.rs
index fe4df2e..3d38c3b 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -44,6 +44,7 @@
 
 use crate::app::{AppAction, PageContent};
 use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
 use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
 use std::path::{Path, PathBuf};
@@ -129,7 +130,7 @@ pub struct PowerFacts {
 #[derive(Debug, Clone)]
 pub struct LeverSet {
     /// One dropdown per [`Lever::ALL`] entry, in that order.
-    pub dds: Vec<Adapted<Dropdown>>,
+    pub dds: Vec<Owned<Adapted<Dropdown>>>,
     /// The plan value behind each row of each dropdown (options are display
     /// text). Row 0 is always the "Not set" row and holds the empty string;
     /// an empty Vec means the interface is absent on this host and the
@@ -142,7 +143,7 @@ impl Default for LeverSet {
         Self {
             dds: Lever::ALL
                 .iter()
-                .map(|l| Dropdown::new(vec!["—".to_string()], 0).with_label(l.label()))
+                .map(|l| Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
                 .collect(),
             rows: vec![Vec::new(); Lever::ALL.len()],
         }
@@ -157,17 +158,17 @@ fn lever_index(lever: Lever) -> usize {
 pub struct PowerState {
     pub loaded: bool,
     pub facts: PowerFacts,
-    pub dd_limit: Adapted<Dropdown>,
+    pub dd_limit: Owned<Adapted<Dropdown>>,
     /// The charge window per charge-limit dropdown row (options are display
     /// text).
     pub limit_values: Vec<ChargeLimit>,
     /// Which mode the lever section is editing. Page state, not plan state:
     /// it says what is on screen, never what the machine runs.
     pub editing: Mode,
-    pub dd_mode: Adapted<Dropdown>,
+    pub dd_mode: Owned<Adapted<Dropdown>>,
     pub levers: LeverSet,
     /// One mode picker per [`Source::ALL`] entry, in that order.
-    pub dd_assign: Vec<Adapted<Dropdown>>,
+    pub dd_assign: Vec<Owned<Adapted<Dropdown>>>,
 }
 
 impl Default for PowerState {
@@ -175,14 +176,14 @@ impl Default for PowerState {
         Self {
             loaded: false,
             facts: PowerFacts::default(),
-            dd_limit: Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit"),
+            dd_limit: Owned::new(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
             limit_values: Vec::new(),
             editing: Mode::default(),
-            dd_mode: Dropdown::new(mode_options(), 0).with_label("Mode"),
+            dd_mode: Owned::new(Dropdown::new(mode_options(), 0).with_label("Mode")),
             levers: LeverSet::default(),
             dd_assign: Source::ALL
                 .iter()
-                .map(|s| Dropdown::new(mode_options(), 0).with_label(s.label()))
+                .map(|s| Owned::new(Dropdown::new(mode_options(), 0).with_label(s.label())))
                 .collect(),
         }
     }
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 1d62535..568014e 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,4 +1,5 @@
 use crate::app::{PageContent, SectionContextExt};
+use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
 use cce_ui::widget::{TextBox, InteractiveListItem, WidgetHost};
@@ -29,9 +30,9 @@ pub struct ServicesState {
     pub loaded: bool,
     pub services: Vec<ServiceInfo>,
     pub active_tab: ServiceTab,
-    pub search_box: cce_ui::widget::Adapted<TextBox>,
+    pub search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
     pub list: ScrollRegion,
-    pub items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
+    pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
 }
 
 impl Default for ServicesState {
@@ -40,7 +41,7 @@ impl Default for ServicesState {
             loaded: false,
             services: Vec::new(),
             active_tab: ServiceTab::System,
-            search_box: TextBox::new(String::new()).with_label("Filter Services"),
+            search_box: Owned::new(TextBox::new(String::new()).with_label("Filter Services")),
             list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
             items: Vec::new(),
         }
@@ -148,7 +149,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
             if state.items.len() != filtered_services.len() {
                 state.items.clear();
                 for _ in 0..filtered_services.len() {
-                    state.items.push(InteractiveListItem::new(""));
+                    state.items.push(Owned::new(InteractiveListItem::new("")));
                 }
             }
 
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index 7527c3b..b3505f1 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
 use std::fs;
 
@@ -34,7 +35,7 @@ pub struct StorageState {
     pub error_message: Option<String>,
     /// Retained so it can hold keyboard focus: ctrl+i descends into the Full
     /// System Backup section and lands here, and Enter/Space runs the backup.
-    pub backup_button: cce_ui::widget::Adapted<cce_ui::widget::Button>,
+    pub backup_button: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 }
 
 impl Default for StorageState {
@@ -50,7 +51,7 @@ impl Default for StorageState {
             last_backup_time: "Never".to_string(),
             backup_size: "0 B".to_string(),
             error_message: None,
-            backup_button: cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
+            backup_button: Owned::new(cce_ui::widget::Button::new(0.0, 0.0, 0.0, 32.0)
                 // Flat fill + border, not the SDF bevel: PageContent's RenderTarget
                 // has no `bevel`, so a raised plate silently draws nothing here
                 // (the label renders, the plate does not). The border path is also
@@ -59,7 +60,7 @@ impl Default for StorageState {
                 .with_label("Run Backup")
                 .with_bg(BTN_BG)
                 .with_hover_bg(BTN_HOVER)
-                .with_label_color(WHITE),
+                .with_label_color(WHITE)),
         }
     }
 }
@@ -249,8 +250,8 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
             let bar_w = sec_w - 2.0 * crate::app::section_margin();
             let yt = sec.ay();
             let disk_bar_x = sec.ax(crate::app::section_margin());
-            let mut disk_bar = cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
-                .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]);
+            let mut disk_bar = Owned::new(cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
+                .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]));
             render_widget(sec.pc, &mut disk_bar, disk_bar_x, yt, bar_w, 8.0, ctx);
         }
     });
@@ -276,8 +277,8 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
             let bar_w = sec_w - 2.0 * crate::app::section_margin();
             let yt = sec.ay();
             let ram_bar_x = sec.ax(crate::app::section_margin());
-            let mut ram_bar = cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
-                .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]);
+            let mut ram_bar = Owned::new(cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
+                .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]));
             render_widget(sec.pc, &mut ram_bar, ram_bar_x, yt, bar_w, 8.0, ctx);
         }
     });
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 806b5e1..ee666c5 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent, SectionContextExt};
+use cce_ui::widget::Owned;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Label, WidgetHost, Button};
 
@@ -50,9 +51,9 @@ pub struct SystemState {
     pub gpus: Vec<String>,
     pub gpu_strings: Vec<String>,
     pub builds: Vec<InstalledBuild>,
-    pub cpu_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub cpu_usage_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub cpu_temp_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
+    pub cpu_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub cpu_usage_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub cpu_temp_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 
     // Power-related fields
 
@@ -60,8 +61,8 @@ pub struct SystemState {
     pub initialized: bool,
     pub sender: Option<calloop::channel::Sender<AppAction>>,
     pub sysfiles: SysFiles,
-    pub hostname_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
-    pub uptime_label: cce_ui::widget::Adapted<cce_ui::widget::Label>,
+    pub hostname_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    pub uptime_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 }
 
 impl std::fmt::Debug for SystemState {
@@ -89,16 +90,16 @@ impl Default for SystemState {
             gpus: Vec::new(),
             gpu_strings: Vec::new(),
             builds: Vec::new(),
-            cpu_label: Label::new("CPU Info"),
-            cpu_usage_label: Label::new("CPU Usage"),
-            cpu_temp_label: Label::new("CPU Temp"),
+            cpu_label: Owned::new(Label::new("CPU Info")),
+            cpu_usage_label: Owned::new(Label::new("CPU Usage")),
+            cpu_temp_label: Owned::new(Label::new("CPU Temp")),
 
 
             initialized: false,
             sender: None,
             sysfiles: SysFiles::default(),
-            hostname_label: Label::new(""),
-            uptime_label: Label::new(""),
+            hostname_label: Owned::new(Label::new("")),
+            uptime_label: Owned::new(Label::new("")),
         }
     }
 }
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index f7a4705..f8c884c 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -3,6 +3,7 @@
 //! timer units (system scope through pkexec).
 
 use crate::app::{PageContent, SectionContextExt};
+use cce_ui::widget::Owned;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, RenderTarget};
 use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox, WidgetHost};
@@ -34,13 +35,13 @@ pub struct TimersState {
     pub timers: Vec<TimerInfo>,
     pub active_tab: TimerTab,
     pub list: ScrollRegion,
-    pub items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
+    pub items: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
     pub creating: bool,
     /// Base unit name (without .timer) being edited, form shared with create.
     pub editing: Option<String>,
-    pub name_box: cce_ui::widget::Adapted<TextBox>,
-    pub command_box: cce_ui::widget::Adapted<TextBox>,
-    pub schedule_box: cce_ui::widget::Adapted<TextBox>,
+    pub name_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub command_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    pub schedule_box: Owned<cce_ui::widget::Adapted<TextBox>>,
     pub status_msg: Option<String>,
 }
 
@@ -54,12 +55,12 @@ impl Default for TimersState {
             items: Vec::new(),
             creating: false,
             editing: None,
-            name_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
-                .with_placeholder("backup"),
-            command_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
-                .with_placeholder("/home/me/bin/backup.sh --fast"),
-            schedule_box: TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
-                .with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00"),
+            name_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
+                .with_placeholder("backup")),
+            command_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
+                .with_placeholder("/home/me/bin/backup.sh --fast")),
+            schedule_box: Owned::new(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
+                .with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00")),
             status_msg: None,
         }
     }
@@ -524,7 +525,7 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
             if state.items.len() != filtered.len() {
                 state.items.clear();
                 for _ in 0..filtered.len() {
-                    state.items.push(InteractiveListItem::new(""));
+                    state.items.push(Owned::new(InteractiveListItem::new("")));
                 }
             }
 
@@ -571,7 +572,7 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
                     render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
 
                     let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
-                    let mut dot = StatusDot::new(dot_state);
+                    let mut dot = Owned::new(StatusDot::new(dot_state));
                     render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
 
                     let active_txt = [0.90, 0.90, 0.95, 1.0];
diff --git a/src/renderer.rs b/src/renderer.rs
index 1657dab..f619710 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -489,7 +489,7 @@ impl SystemInterface {
             // The button's own idle and hover faces: the page's colours when
             // it passed them, else the toolkit's for the button's kind — a
             // list row's transparent-until-hover wash (`PageContent::list_row`).
-            let mut probe = (**btn).clone();
+            let mut probe = (***btn).clone();
             probe.set_hovered(false);
             let bg = cce_ui::widget::Paint::color(&probe);
             probe.set_hovered(true);