git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit4758d296730bfc7b2bf66f797a64785e72ed7690
parentd1eeb3b11e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 21:54
refactor: widgets are handles the context owns

Page states take the context (BrowseState/NetworkState/SpaceState::new, browse::update, populate_graph); the breadcrumb helpers take a handle; PageContent's buttons are bare paint stamps. The prompt dialog's text box is inserted and removed with the dialog (set_prompt). This retires a heap corruption: the old build focused a stack-local TextBox by pointer and then moved it into its box, and opening a second prompt aborted with 'double free or corruption'. Shadow A/B identical on Browse, Graph, Space, the prompts and save mode.

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

 src/main.rs          | 227 ++++++++++++++++++++++++++-------------------------
 src/pages/browse.rs  | 167 +++++++++++++++++++++----------------
 src/pages/mod.rs     |  18 ++--
 src/pages/network.rs |  85 ++++++++++---------
 src/pages/space.rs   |  22 +++--
 5 files changed, 279 insertions(+), 240 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 1ac5aeb..b165d66 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,4 @@
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::cosmic_text::FontSystem;
 
 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
@@ -83,7 +83,7 @@ impl PromptKind {
 struct PromptDialog {
     kind: PromptKind,
     path: std::path::PathBuf,
-    textbox: Owned<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
+    textbox: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
 }
 
 fn prompt_rects(win_w: f32, win_h: f32) -> PromptRects {
@@ -600,11 +600,11 @@ struct FilesystemApp {
     width: u32,
     height: u32,
     scale_factor: f64,
-    page_buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, Message)>,
+    page_buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, Message)>,
     hovered_button: Option<usize>,
     cursor_x: f32,
     cursor_y: f32,
-    paginator: Owned<cce_ui::widget::Adapted<Paginator>>,
+    paginator: Handle<cce_ui::widget::Adapted<Paginator>>,
     just_initialized: bool,
     ui_context: cce_ui::context::UiContext,
     watcher: Option<notify::RecommendedWatcher>,
@@ -643,6 +643,14 @@ struct FilesystemApp {
 // ── Layout Rebuild ──────────────────────────────────────────────────
 
 impl FilesystemApp {
+    /// Show `dialog` in place of the prompt that is up, if any, whose text box
+    /// leaves the context with it.
+    fn set_prompt(&mut self, dialog: Option<PromptDialog>) {
+        if let Some(old) = std::mem::replace(&mut self.prompt_dialog, dialog) {
+            self.ui_context.remove(old.textbox);
+        }
+    }
+
     /// Kick off a subtree scan if the Space page is showing a directory it has
     /// not scanned. Cheap to call — it no-ops off the Space page, and while a
     /// scan for the same directory is already running.
@@ -802,8 +810,8 @@ impl FilesystemApp {
         };
 
         self.ui_context.clear_hierarchy();
-        self.browse.save_name_box.prepare_text(&mut self.font_system);
-        self.browse.search_box.prepare_text(&mut self.font_system);
+        self.ui_context[self.browse.save_name_box].prepare_text(&mut self.font_system);
+        self.ui_context[self.browse.search_box].prepare_text(&mut self.font_system);
 
         let mut widgets = Vec::new();
         let mut texts = Vec::new();
@@ -816,21 +824,20 @@ impl FilesystemApp {
         // child quads).
 
         // Clear all widgets' hierarchy links
-        self.paginator.clear_children(&mut self.ui_context); self.paginator.set_parent(None, &mut self.ui_context);
+        self.ui_context.lend_h(self.paginator, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
 
 
-        self.browse.save_name_box.clear_children(&mut self.ui_context); self.browse.save_name_box.set_parent(None, &mut self.ui_context);
-        self.browse.breadcrumb.clear_children(&mut self.ui_context); self.browse.breadcrumb.set_parent(None, &mut self.ui_context);
-        self.network.breadcrumb.clear_children(&mut self.ui_context); self.network.breadcrumb.set_parent(None, &mut self.ui_context);
-        self.network.graph.clear_children(&mut self.ui_context); self.network.graph.set_parent(None, &mut self.ui_context);
-        self.space.breadcrumb.clear_children(&mut self.ui_context); self.space.breadcrumb.set_parent(None, &mut self.ui_context);
-        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
-            textbox.clear_children(&mut self.ui_context);
-            textbox.set_parent(None, &mut self.ui_context);
+        self.ui_context.lend_h(self.browse.save_name_box, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
+        self.ui_context.lend_h(self.browse.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
+        self.ui_context.lend_h(self.network.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
+        self.ui_context.lend_h(self.network.graph, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
+        self.ui_context.lend_h(self.space.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
+        if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
+            self.ui_context.lend_h(tb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
         }
 
         let has_sidebar = false;
-        let sidebar_w = if has_sidebar { self.paginator.sidebar_w() } else { 0.0 };
+        let sidebar_w = if has_sidebar { self.ui_context[self.paginator].sidebar_w() } else { 0.0 };
         // Inset from the WINDOW edge: the root plate's roll plus its padding
         // (the padding alone left most of the run on the roll, so the edge
         // read narrower than the gap between the panes).
@@ -846,14 +853,8 @@ impl FilesystemApp {
             self.height as f32 - 2.0 * pad
         };
 
-        {
-            let self_ptr = self as *mut Self;
-            unsafe {
-                if has_sidebar {
-                    self.ui_context.register_host(&mut self.paginator);
-                    (*self_ptr).paginator.set_parent(None, &mut self.ui_context);
-                }
-            }
+        if has_sidebar {
+            self.ui_context.lend_h(self.paginator, |w, ctx| w.set_parent(None, ctx));
         }
 
         // SplitBox + pane containers DISSOLVED (Phase 6y): the split is app state; the
@@ -875,17 +876,16 @@ impl FilesystemApp {
             }
         }
 
-        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
-            self.ui_context.register_host(textbox);
-            textbox.set_parent(None, &mut self.ui_context);
+        if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
+            self.ui_context.lend_h(tb, |w, ctx| w.set_parent(None, ctx));
         }
 
         // Layout widgets recursively inside the parent space
         let mut dummy_pc = pages::PageContent::new();
         if has_sidebar {
             let page_idx = Page::ALL.iter().position(|&p| p == self.current_page).unwrap_or(0);
-            self.paginator.set_selected_page(page_idx);
-            cce_ui::layout::render_widget(&mut dummy_pc, &mut self.paginator, 0.0, 0.0, sidebar_w, self.height as f32, &mut self.ui_context);
+            self.ui_context[self.paginator].set_selected_page(page_idx);
+            cce_ui::layout::render_widget_h(&mut dummy_pc, self.paginator, 0.0, 0.0, sidebar_w, self.height as f32, &mut self.ui_context);
         }
 
         // Each top-level widget rendered through the same immediate-mode path the root
@@ -921,9 +921,9 @@ impl FilesystemApp {
                         (*self_ptr).preview.push_prims(&mut plain_pc);
                     }
                 }
-                if let Some(PromptDialog { textbox, .. }) = &mut (*self_ptr).prompt_dialog {
-                    let (x, y, w, h) = textbox.rect();
-                    cce_ui::layout::render_widget(&mut plain_pc, textbox, x, y, w, h, &mut self.ui_context);
+                if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
+                    let (x, y, w, h) = self.ui_context[tb].rect();
+                    cce_ui::layout::render_widget_h(&mut plain_pc, tb, x, y, w, h, &mut self.ui_context);
                 }
 
                 // Legacy aggregate order: plain child quads, then rounded child quads.
@@ -1088,7 +1088,7 @@ impl FilesystemApp {
             dialog_pc.text(kind.prompt(), dialog_x + dialog_pad, dialog_y + 42.0, 11.0, [0.54, 0.54, 0.58, 1.0]);
 
             // Set textbox position dynamically using configured textbox height
-            textbox.set_rect(tb_x, tb_y, tb_w, tb_h);
+            self.ui_context[*textbox].set_rect(tb_x, tb_y, tb_w, tb_h);
             // Raised dialog plate + recessed command well (control_relief styling).
             dialog_pc.relief_raised(dialog_x, dialog_y, dialog_w, dialog_h, 0.0);
             dialog_pc.relief_recessed(tb_x, tb_y, tb_w, tb_h, cce_ui::layout::textbox_corner_radius());
@@ -1466,7 +1466,7 @@ impl Application for FilesystemApp {
         }
         // 3. If in the sidebar area (when sidebar is active), do not drag
         if false {
-            let sidebar_w = self.paginator.sidebar_w();
+            let sidebar_w = self.ui_context[self.paginator].sidebar_w();
             if px <= sidebar_w {
                 return false;
             }
@@ -1535,7 +1535,9 @@ impl Application for FilesystemApp {
 
         cce_ui::scale::set_scale_factor(1.0);
 
-        let browse = pages::browse::BrowseState::default();
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
+        let browse = pages::browse::BrowseState::new(&mut ui_context);
         let _current_dir = browse.current_dir.clone();
 
         let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
@@ -1546,8 +1548,8 @@ impl Application for FilesystemApp {
         let app = Self {
             current_page: Page::Browse,
             browse,
-            network: pages::network::NetworkState::default(),
-            space: pages::space::SpaceState::default(),
+            network: pages::network::NetworkState::new(&mut ui_context),
+            space: pages::space::SpaceState::new(&mut ui_context),
             preview: Default::default(),
             preview_requested: None,
             preview_generation: 0,
@@ -1571,9 +1573,9 @@ impl Application for FilesystemApp {
             hovered_button: None,
             cursor_x: 0.0,
             cursor_y: 0.0,
-            paginator: Owned::new(paginator),
+            paginator: ui_context.insert(paginator),
             just_initialized: true,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
             watcher: None,
             fs_service,
             seen_renderer: false,
@@ -1650,7 +1652,7 @@ impl Application for FilesystemApp {
             Message::SwitchPage(page) => {
                 self.current_page = page;
                 let page_idx = Page::ALL.iter().position(|&p| p == page).unwrap_or(0);
-                self.paginator.set_selected_page(page_idx);
+                self.ui_context[self.paginator].set_selected_page(page_idx);
                 // Switching to Space is what triggers the first scan — it is
                 // far too expensive to run for a page nobody is looking at.
                 self.ensure_space_scan();
@@ -1677,7 +1679,7 @@ impl Application for FilesystemApp {
                     _ => None,
                 };
 
-                if let Some(req) = pages::browse::update(&mut self.browse, msg) {
+                if let Some(req) = pages::browse::update(&mut self.browse, msg, &mut self.ui_context) {
                     self.fs_service.send(req);
                 }
 
@@ -1699,17 +1701,17 @@ impl Application for FilesystemApp {
                 if let Some(idx) = self.browse.selected {
                     if let Some(entry) = self.browse.entries.get(idx) {
                         if self.select_mode {
-                            self.browse.save_name_box.text = entry.name.clone();
-                            if self.browse.save_name_box.editing {
-                                self.browse.save_name_box.edit_buffer = entry.name.clone();
+                            self.ui_context[self.browse.save_name_box].text = entry.name.clone();
+                            if self.ui_context[self.browse.save_name_box].editing {
+                                self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
                             }
                         }
                     }
                 } else {
                     if self.select_mode {
-                        self.browse.save_name_box.text.clear();
-                        if self.browse.save_name_box.editing {
-                            self.browse.save_name_box.edit_buffer.clear();
+                        self.ui_context[self.browse.save_name_box].text.clear();
+                        if self.ui_context[self.browse.save_name_box].editing {
+                            self.ui_context[self.browse.save_name_box].edit_buffer.clear();
                         }
                     }
                 }
@@ -1745,10 +1747,10 @@ impl Application for FilesystemApp {
                     println!("{}", path.display());
                     std::process::exit(0);
                 } else {
-                    let filename = if self.browse.save_name_box.editing {
-                        self.browse.save_name_box.edit_buffer.trim()
+                    let filename = if self.ui_context[self.browse.save_name_box].editing {
+                        self.ui_context[self.browse.save_name_box].edit_buffer.trim()
                     } else {
-                        self.browse.save_name_box.text.trim()
+                        self.ui_context[self.browse.save_name_box].text.trim()
                     };
                     if !filename.is_empty() {
                         let path = self.browse.current_dir.join(filename);
@@ -1797,8 +1799,9 @@ impl Application for FilesystemApp {
                     .with_max_width(None)
                     .with_placeholder("Program/Command");
                 tb.focus();
-                self.ui_context.set_focused(&mut tb);
-                self.prompt_dialog = Some(PromptDialog { kind: PromptKind::OpenWith, path, textbox: Owned::new(tb) });
+                let textbox = self.ui_context.insert(tb);
+                self.ui_context.set_focused_id(textbox.id());
+                self.set_prompt(Some(PromptDialog { kind: PromptKind::OpenWith, path, textbox }));
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1817,18 +1820,21 @@ impl Application for FilesystemApp {
                 tb.select_anchor = Some(0);
                 tb.all_selected = false;
                 tb.sync_editor_state();
-                self.ui_context.set_focused(&mut tb);
-                self.prompt_dialog = Some(PromptDialog { kind: PromptKind::Rename, path, textbox: Owned::new(tb) });
+                let textbox = self.ui_context.insert(tb);
+                self.ui_context.set_focused_id(textbox.id());
+                self.set_prompt(Some(PromptDialog { kind: PromptKind::Rename, path, textbox }));
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
             Message::PromptSubmit => {
                 if let Some(PromptDialog { kind, path, textbox }) = self.prompt_dialog.take() {
-                    let value = if textbox.editing {
-                        textbox.edit_buffer.trim().to_string()
-                    } else {
-                        textbox.text.trim().to_string()
-                    };
+                    let value = self.ui_context.remove(textbox).map_or_else(String::new, |textbox| {
+                        if textbox.editing {
+                            textbox.edit_buffer.trim().to_string()
+                        } else {
+                            textbox.text.trim().to_string()
+                        }
+                    });
                     match kind {
                         PromptKind::OpenWith => {
                             if !value.is_empty() {
@@ -1848,7 +1854,7 @@ impl Application for FilesystemApp {
                 self.needs_rebuild = true;
             }
             Message::PromptCancel => {
-                self.prompt_dialog = None;
+                self.set_prompt(None);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1873,14 +1879,14 @@ impl Application for FilesystemApp {
         if self.just_initialized {
             self.just_initialized = false;
             if self.save_mode {
-                self.ui_context.focus_widget(&mut self.browse.save_name_box);
-                self.ui_context.set_focused(&mut self.browse.save_name_box);
+                self.ui_context.focus_id(self.browse.save_name_box.id());
+                self.ui_context.set_focused_id(self.browse.save_name_box.id());
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
         }
 
-        if self.paginator.tick(dt, &mut self.ui_context) {
+        if self.ui_context.lend_h(self.paginator, |w, ctx| w.tick(dt, ctx)).unwrap_or(false) {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
@@ -2166,7 +2172,7 @@ impl Application for FilesystemApp {
         if !self.select_mode {
             // Self-routing composite: handle_event, not propagate — the router's
             // children-first descent would let the embedded strip consume this.
-            if self.paginator.handle_event(&mv, &mut self.ui_context) {
+            if self.ui_context.lend_h(self.paginator, |w, ctx| w.handle_event(&mv, ctx)).unwrap_or(false) {
                 changed = true;
             }
         }
@@ -2324,7 +2330,7 @@ impl Application for FilesystemApp {
                         c.1 + dock::CORNER_R,
                         labels,
                         0,
-                        self.paginator.base().id(),
+                        self.ui_context[self.paginator].base().id(),
                     );
                     self.plate_menu_actions = actions;
                     *needs_rebuild = true;
@@ -2354,7 +2360,7 @@ impl Application for FilesystemApp {
             if state == ElementState::Pressed {
                 let clicked_inside = pos.x >= dialog_x && pos.x <= dialog_x + dialog_w && pos.y >= dialog_y && pos.y <= dialog_y + dialog_h;
                 if !clicked_inside {
-                    textbox.unfocus();
+                    self.ui_context[*textbox].unfocus();
                     self.ui_context.clear_focus();
                     return Some(Message::PromptCancel);
                 }
@@ -2364,27 +2370,27 @@ impl Application for FilesystemApp {
                         let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
                         let root = textbox.id();
                         if self.ui_context.propagate_event(&ev, root) {
-                            self.ui_context.set_focused(textbox);
+                            self.ui_context.set_focused_id(textbox.id());
                             *needs_rebuild = true;
                             self.needs_rebuild = true;
                         }
                     } else if pos.x >= btn_cancel_x && pos.x <= btn_cancel_x + btn_cancel_w && pos.y >= btn_cancel_y && pos.y <= btn_cancel_y + btn_cancel_h {
-                        textbox.unfocus();
+                        self.ui_context[*textbox].unfocus();
                         self.ui_context.clear_focus();
                         return Some(Message::PromptCancel);
                     } else if pos.x >= btn_open_x && pos.x <= btn_open_x + btn_open_w && pos.y >= btn_open_y && pos.y <= btn_open_y + btn_open_h {
-                        textbox.unfocus();
+                        self.ui_context[*textbox].unfocus();
                         self.ui_context.clear_focus();
                         return Some(Message::PromptSubmit);
                     } else {
-                        textbox.unfocus();
+                        self.ui_context[*textbox].unfocus();
                         self.ui_context.clear_focus();
                         *needs_rebuild = true;
                         self.needs_rebuild = true;
                     }
                 }
             } else {
-                if button == MouseButton::Left && textbox.editing {
+                if button == MouseButton::Left && self.ui_context[*textbox].editing {
                     let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
                     let root = textbox.id();
                     if self.ui_context.propagate_event(&ev, root) {
@@ -2430,14 +2436,14 @@ impl Application for FilesystemApp {
         }
 
         if button == MouseButton::Right && state == ElementState::Pressed {
-            let breadcrumb = match self.current_page {
-                Page::Browse => &mut self.browse.breadcrumb,
-                Page::Network => &mut self.network.breadcrumb,
-                Page::Space => &mut self.space.breadcrumb,
+            let crumb = match self.current_page {
+                Page::Browse => self.browse.breadcrumb,
+                Page::Network => self.network.breadcrumb,
+                Page::Space => self.space.breadcrumb,
             };
-            if breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
+            if self.ui_context[crumb].hit_test(pos.x, pos.y, &self.ui_context) {
                 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
-                let root = breadcrumb.id();
+                let root = crumb.id();
                 // The breadcrumb opens the toolkit's shared context menu itself
                 // (open_context_menu → segment header + Copy Path), recording
                 // the segment pressed. The app then shows that menu again,
@@ -2446,6 +2452,7 @@ impl Application for FilesystemApp {
                 // name and takes none from the app.
                 if self.ui_context.propagate_event(&ev, root) {
                     use cce_ui::widget::context_menu as cm;
+                    let breadcrumb = &self.ui_context[crumb];
                     let seg = breadcrumb.right_clicked_seg.unwrap_or(breadcrumb.path.len());
                     let (labels, actions) = breadcrumb_menu_rows(breadcrumb.path_to_seg(seg), self.current_page);
                     let header = cm::options().into_iter().next().unwrap_or_default();
@@ -2531,7 +2538,7 @@ impl Application for FilesystemApp {
                         // run on under the breadcrumb and the preview.
                         let (x, y, w, h) = self.network.laid_out_for;
                         let inside = pos.x >= x && pos.x < x + w && pos.y >= y && pos.y < y + h;
-                        inside.then(|| self.network.graph.inner().node_at(pos.x, pos.y)).flatten()
+                        inside.then(|| self.ui_context[self.network.graph].inner().node_at(pos.x, pos.y)).flatten()
                     })
                     .flatten()
                     .and_then(|idx| graph_node_menu(idx, &self.browse.current_dir, &self.browse.entries))
@@ -2570,10 +2577,10 @@ impl Application for FilesystemApp {
         let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
         let menu_match = !self.select_mode && {
             // Self-routing composite: handle_event, not propagate (see pointer move).
-            self.paginator.handle_event(&ev, &mut self.ui_context)
+            self.ui_context.lend_h(self.paginator, |w, ctx| w.handle_event(&ev, ctx)).unwrap_or(false)
         };
         if menu_match {
-            if let Some((idx, _)) = self.paginator.menu_click() {
+            if let Some((idx, _)) = self.ui_context[self.paginator].menu_click() {
                 if idx < Page::ALL.len() {
                     self.ui_context.clear_focus();
                     self.current_page = Page::ALL[idx];
@@ -2650,7 +2657,7 @@ impl Application for FilesystemApp {
             if self.select_mode {
                 if { let root = self.browse.save_name_box.id(); self.ui_context.propagate_event(&ev, root) } {
                     if state == ElementState::Pressed {
-                        self.ui_context.set_focused(&mut self.browse.save_name_box);
+                        self.ui_context.set_focused_id(self.browse.save_name_box.id());
                     }
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
@@ -2660,7 +2667,7 @@ impl Application for FilesystemApp {
                 && button == MouseButton::Left
                 && { let root = self.browse.search_box.id(); self.ui_context.propagate_event(&ev, root) }
             {
-                self.ui_context.set_focused(&mut self.browse.search_box);
+                self.ui_context.set_focused_id(self.browse.search_box.id());
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -2677,9 +2684,9 @@ impl Application for FilesystemApp {
                 }
             }
             if button == MouseButton::Left && state == ElementState::Pressed {
-                if self.browse.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
+                if self.ui_context[self.browse.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
                     if { let root = self.browse.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
-                        if let Some(seg) = self.browse.breadcrumb.path_click() {
+                        if let Some(seg) = self.ui_context[self.browse.breadcrumb].path_click() {
                             let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
                             self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
                             *needs_rebuild = true;
@@ -2691,9 +2698,9 @@ impl Application for FilesystemApp {
         } else if self.current_page == Page::Network {
             if button == MouseButton::Left {
                 if state == ElementState::Pressed {
-                    if self.network.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
+                    if self.ui_context[self.network.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
                         if { let root = self.network.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
-                            if let Some(seg) = self.network.breadcrumb.path_click() {
+                            if let Some(seg) = self.ui_context[self.network.breadcrumb].path_click() {
                                 let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
                                 self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
                                 changed = true;
@@ -2721,16 +2728,16 @@ impl Application for FilesystemApp {
             // Sync selection from graph to browse state
             let has_parent = self.browse.current_dir.parent().is_some();
             let offset = if has_parent { 2 } else { 1 };
-            if let Some(node_sel) = self.network.graph.selected_node() {
+            if let Some(node_sel) = self.ui_context[self.network.graph].selected_node() {
                 if node_sel >= offset {
                     let entry_idx = node_sel - offset;
                     if self.browse.selected != Some(entry_idx) {
                         self.browse.selected = Some(entry_idx);
                         if let Some(entry) = self.browse.entries.get(entry_idx) {
                             if self.select_mode {
-                                self.browse.save_name_box.text = entry.name.clone();
-                                if self.browse.save_name_box.editing {
-                                    self.browse.save_name_box.edit_buffer = entry.name.clone();
+                                self.ui_context[self.browse.save_name_box].text = entry.name.clone();
+                                if self.ui_context[self.browse.save_name_box].editing {
+                                    self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
                                 }
                             }
                             let path = entry.path.clone();
@@ -2754,8 +2761,8 @@ impl Application for FilesystemApp {
             }
 
             // Sync double click navigation
-            if let Some(dbl_idx) = self.network.graph.double_clicked_node() {
-                self.network.graph.clear_double_clicked_node();
+            if let Some(dbl_idx) = self.ui_context[self.network.graph].double_clicked_node() {
+                self.ui_context[self.network.graph].clear_double_clicked_node();
                 if has_parent && dbl_idx == 0 {
                     if let Some(parent) = self.browse.current_dir.parent() {
                         let parent_path = parent.to_path_buf();
@@ -2770,9 +2777,9 @@ impl Application for FilesystemApp {
                             self.fs_service.send(services::fs::FsRequest::ReadDirectory(path));
                             changed = true;
                         } else if self.select_mode {
-                            self.browse.save_name_box.text = entry.name.clone();
-                            if self.browse.save_name_box.editing {
-                                self.browse.save_name_box.edit_buffer = entry.name.clone();
+                            self.ui_context[self.browse.save_name_box].text = entry.name.clone();
+                            if self.ui_context[self.browse.save_name_box].editing {
+                                self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
                             }
                             return Some(Message::SelectOpen);
                         }
@@ -2786,9 +2793,9 @@ impl Application for FilesystemApp {
             }
         } else if self.current_page == Page::Space {
             if button == MouseButton::Left && state == ElementState::Pressed {
-                if self.space.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
+                if self.ui_context[self.space.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
                     if { let root = self.space.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
-                        if let Some(seg) = self.space.breadcrumb.path_click() {
+                        if let Some(seg) = self.ui_context[self.space.breadcrumb].path_click() {
                             let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
                             self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
                             changed = true;
@@ -2828,13 +2835,13 @@ impl Application for FilesystemApp {
         }
 
         if state == ElementState::Pressed {
-            let clicked_search = self.current_page == Page::Browse && self.browse.search_visible && self.browse.search_box.hit_test(pos.x, pos.y, &self.ui_context);
-            let clicked_save_name = self.select_mode && self.current_page == Page::Browse && self.browse.save_name_box.hit_test(pos.x, pos.y, &self.ui_context);
+            let clicked_search = self.current_page == Page::Browse && self.browse.search_visible && self.ui_context[self.browse.search_box].hit_test(pos.x, pos.y, &self.ui_context);
+            let clicked_save_name = self.select_mode && self.current_page == Page::Browse && self.ui_context[self.browse.save_name_box].hit_test(pos.x, pos.y, &self.ui_context);
             if !clicked_search {
-                self.ui_context.unfocus_widget(&mut self.browse.search_box);
+                self.ui_context.unfocus_id(self.browse.search_box.id());
             }
             if !clicked_save_name {
-                self.ui_context.unfocus_widget(&mut self.browse.save_name_box);
+                self.ui_context.unfocus_id(self.browse.save_name_box.id());
             }
             if !clicked_search && !clicked_save_name {
                 self.ui_context.clear_focus();
@@ -2917,12 +2924,12 @@ impl Application for FilesystemApp {
 
         if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
             if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) {
-                textbox.unfocus();
+                self.ui_context[*textbox].unfocus();
                 self.ui_context.clear_focus();
                 return Some(Message::PromptSubmit);
             }
             if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) {
-                textbox.unfocus();
+                self.ui_context[*textbox].unfocus();
                 self.ui_context.clear_focus();
                 return Some(Message::PromptCancel);
             }
@@ -2963,8 +2970,8 @@ impl Application for FilesystemApp {
                 let open_key = cce_ui::color::list_open_search_key();
                 if cce_ui::widget::match_key_shortcut(event, &open_key) {
                     self.browse.search_visible = true;
-                    self.ui_context.focus_widget(&mut self.browse.search_box);
-                    self.ui_context.set_focused(&mut self.browse.search_box);
+                    self.ui_context.focus_id(self.browse.search_box.id());
+                    self.ui_context.set_focused_id(self.browse.search_box.id());
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
                     return None;
@@ -2973,7 +2980,7 @@ impl Application for FilesystemApp {
                 let close_key = cce_ui::color::list_close_search_key();
                 if cce_ui::widget::match_key_shortcut(event, &close_key) {
                     self.browse.search_visible = false;
-                    self.ui_context.unfocus_widget(&mut self.browse.search_box);
+                    self.ui_context.unfocus_id(self.browse.search_box.id());
                     self.ui_context.clear_focus();
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
@@ -2982,9 +2989,9 @@ impl Application for FilesystemApp {
                 if { let kev = cce_ui::widget::Event::KeyInput(event.clone()); let root = self.browse.search_box.id(); self.ui_context.propagate_event(&kev, root) } {
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
-                    if self.browse.search_box.take_change() {
+                    if self.ui_context[self.browse.search_box].take_change() {
                         return Some(Message::Browse(pages::browse::BrowseMessage::SearchChanged(
-                            self.browse.search_box.text.clone()
+                            self.ui_context[self.browse.search_box].text.clone()
                         )));
                     }
                     return None;
@@ -2993,12 +3000,12 @@ impl Application for FilesystemApp {
         }
 
         // If the save_name_box is focused, forward key inputs to it
-        if self.select_mode && self.browse.save_name_box.editing {
+        if self.select_mode && self.ui_context[self.browse.save_name_box].editing {
             if { let kev = cce_ui::widget::Event::KeyInput(event.clone()); let root = self.browse.save_name_box.id(); self.ui_context.propagate_event(&kev, root) } {
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
                 if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) {
-                    self.ui_context.unfocus_widget(&mut self.browse.save_name_box);
+                    self.ui_context.unfocus_id(self.browse.save_name_box.id());
                     self.ui_context.clear_focus();
                     return Some(Message::SelectOpen);
                 }
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index d21258b..d05a529 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -1,7 +1,8 @@
 use std::path::{Path, PathBuf};
 
 use crate::pages::PageContent;
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::{Adapted, WidgetHost, Breadcrumb, PathController};
 use cce_ui::scene::arena::Arena;
 use cce_ui::scene::layout::{compute_layout, CrossAlign, LayoutBox, Length, Size, Style};
@@ -21,7 +22,7 @@ pub struct DirEntry {
     pub origin: Option<String>,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct BrowseState {
     pub current_dir: PathBuf,
     pub all_entries: Vec<DirEntry>,
@@ -29,7 +30,7 @@ pub struct BrowseState {
     pub show_hidden: bool,
     pub list: crate::row_list::RowList,
     pub search_visible: bool,
-    pub search_box: Owned<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
+    pub search_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
     pub selected: Option<usize>,
     /// The selection the list was last auto-scrolled to. The layout pass runs
     /// every frame, and an unconditional scroll_into_view there UNDID every
@@ -39,12 +40,13 @@ pub struct BrowseState {
     pub autoscrolled_to: Option<usize>,
     /// A just-created folder to select once a refresh lists it.
     pub pending_select: Option<PathBuf>,
-    pub breadcrumb: Owned<Adapted<Breadcrumb>>,
-    pub save_name_box: Owned<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
+    pub breadcrumb: Handle<Adapted<Breadcrumb>>,
+    pub save_name_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
 }
 
-impl Default for BrowseState {
-    fn default() -> Self {
+impl BrowseState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
         let initial_dir = PathBuf::from(home);
         let mut breadcrumb = Breadcrumb::new();
@@ -56,16 +58,16 @@ impl Default for BrowseState {
             show_hidden: false,
             list: crate::row_list::RowList::new(cce_ui::layout::button_height(), 2.0),
             search_visible: false,
-            search_box: Owned::new(cce_ui::widget::TextBox::new(String::new())
+            search_box: ctx.insert(cce_ui::widget::TextBox::new(String::new())
                 .with_placeholder("Search...")
                 .with_update_on_type(true)),
             selected: None,
             autoscrolled_to: None,
             pending_select: None,
-            breadcrumb: Owned::new(breadcrumb),
-            save_name_box: Owned::new(cce_ui::widget::TextBox::new(String::new()).with_max_width(None)),
+            breadcrumb: ctx.insert(breadcrumb),
+            save_name_box: ctx.insert(cce_ui::widget::TextBox::new(String::new()).with_max_width(None)),
         };
-        state.update_breadcrumb();
+        state.update_breadcrumb(ctx);
         state
     }
 }
@@ -76,7 +78,7 @@ impl BrowseState {
         self.selected.and_then(|idx| self.entries.get(idx).map(|e| e.path.clone()))
     }
 
-    pub fn update_breadcrumb(&mut self) {
+    pub fn update_breadcrumb(&mut self, ctx: &mut UiContext) {
         let mut segments = Vec::new();
         for component in self.current_dir.components() {
             let s = component.as_os_str().to_string_lossy().to_string();
@@ -84,7 +86,7 @@ impl BrowseState {
                 segments.push(s);
             }
         }
-        self.breadcrumb.set_path(&segments);
+        ctx[self.breadcrumb].set_path(&segments);
     }
 }
 
@@ -248,7 +250,7 @@ pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_
 
     // 1. The Breadcrumb, where every page puts it.
     let (bx, by, bw, _) = at(crumb);
-    crate::pages::breadcrumb_header(&mut pc, &mut state.breadcrumb, bx, by, bw, ctx);
+    crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, bx, by, bw, ctx);
 
     // 2. The list (ScrollBox).
     let (list_x, list_y, list_w, list_h) = at(list);
@@ -372,7 +374,7 @@ pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_
             list_w - 2.0 * search_pad,
             search_h,
         );
-        cce_ui::layout::render_widget(&mut pc, &mut state.search_box, sx, sy, sw, sh, ctx);
+        cce_ui::layout::render_widget_h(&mut pc, state.search_box, sx, sy, sw, sh, ctx);
         // The TextBox's recessed well lives in its modern paint(); the flat view
         // this host renders through loses it, so carve it here.
         pc.relief_recessed(sx, sy, sw, sh, cce_ui::layout::textbox_corner_radius());
@@ -394,8 +396,8 @@ pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_
     // 3. Allocate and render Textbox(es)
     if let Some(name_box) = name_box {
         let (tx, ty, tw, th) = at(name_box);
-        state.save_name_box.set_row_rect(tx, tw);
-        cce_ui::layout::render_widget(&mut pc, &mut state.save_name_box, tx, ty, tw, th, ctx);
+        ctx[state.save_name_box].set_row_rect(tx, tw);
+        cce_ui::layout::render_widget_h(&mut pc, state.save_name_box, tx, ty, tw, th, ctx);
         pc.relief_recessed(tx, ty, tw, th, cce_ui::layout::textbox_corner_radius());
     }
 
@@ -404,11 +406,12 @@ pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_
 
 // ── Update ──────────────────────────────────────────────────────────
 
-fn apply_filters(state: &mut BrowseState) {
-    let search_query = if state.search_box.editing {
-        state.search_box.edit_buffer.to_lowercase()
+fn apply_filters(state: &mut BrowseState, ctx: &UiContext) {
+    let search_box = &ctx[state.search_box];
+    let search_query = if search_box.editing {
+        search_box.edit_buffer.to_lowercase()
     } else {
-        state.search_box.text.to_lowercase()
+        search_box.text.to_lowercase()
     };
     state.entries = state
         .all_entries
@@ -425,11 +428,11 @@ fn apply_filters(state: &mut BrowseState) {
     state.selected = if state.entries.is_empty() { None } else { Some(0) };
 }
 
-pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::services::fs::FsRequest> {
+pub fn update(state: &mut BrowseState, msg: BrowseMessage, ctx: &mut UiContext) -> Option<crate::services::fs::FsRequest> {
     match msg {
         BrowseMessage::SearchChanged(q) => {
-            state.search_box.text = q;
-            apply_filters(state);
+            ctx[state.search_box].text = q;
+            apply_filters(state, ctx);
             None
         }
         BrowseMessage::SelectEntry(i) => {
@@ -451,11 +454,11 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
             state.current_dir = path.clone();
             state.all_entries = entries;
             state.pending_select = None;
-            state.search_box.text.clear();
-            state.search_box.edit_buffer.clear();
+            ctx[state.search_box].text.clear();
+            ctx[state.search_box].edit_buffer.clear();
             state.search_visible = false;
-            apply_filters(state);
-            state.update_breadcrumb();
+            apply_filters(state, ctx);
+            state.update_breadcrumb(ctx);
             Some(crate::services::fs::FsRequest::SaveLastDir(path))
         }
         BrowseMessage::DirectoryRefreshed(path, entries) => {
@@ -465,7 +468,7 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
                     .take()
                     .or_else(|| state.selected.and_then(|idx| state.entries.get(idx).map(|e| e.path.clone())));
                 state.all_entries = entries;
-                apply_filters(state);
+                apply_filters(state, ctx);
                 if let Some(path) = selected_path {
                     state.selected = state.entries.iter().position(|e| e.path == path);
                 }
@@ -474,7 +477,7 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
         }
         BrowseMessage::ToggleHidden => {
             state.show_hidden = !state.show_hidden;
-            apply_filters(state);
+            apply_filters(state, ctx);
             None
         }
         BrowseMessage::DeleteEntry(idx) => {
@@ -573,6 +576,7 @@ mod tests {
     /// the kind decides which.
     #[test]
     fn entry_icon_names_a_glyph_by_kind() {
+        let mut ui = UiContext::new();
         assert_eq!(entry_icon(true, "photos.png"), "folder");
         assert_eq!(entry_icon(false, "Shot.PNG"), "file-image");
         assert_eq!(entry_icon(false, "song.flac"), "file-audio");
@@ -587,7 +591,7 @@ mod tests {
         }
     }
 
-    fn state_with_count(count: usize) -> BrowseState {
+    fn state_with_count(ui: &mut UiContext, count: usize) -> BrowseState {
         BrowseState {
             entries: (0..count)
                 .map(|i| DirEntry {
@@ -600,25 +604,28 @@ mod tests {
                     origin: None,
                 })
                 .collect(),
-            ..BrowseState::default()
+            ..BrowseState::new(ui)
         }
     }
 
     #[test]
     fn down_from_none_selects_first_entry() {
-        let state = state_with_count(3);
+        let mut ui = UiContext::new();
+        let state = state_with_count(&mut ui, 3);
         assert_eq!(next_selection_index(&state, BrowseNavigation::Down), Some(0));
     }
 
     #[test]
     fn up_from_none_selects_last_entry() {
-        let state = state_with_count(3);
+        let mut ui = UiContext::new();
+        let state = state_with_count(&mut ui, 3);
         assert_eq!(next_selection_index(&state, BrowseNavigation::Up), Some(2));
     }
 
     #[test]
     fn navigation_stays_in_bounds() {
-        let mut state = state_with_count(3);
+        let mut ui = UiContext::new();
+        let mut state = state_with_count(&mut ui, 3);
 
         state.selected = Some(0);
         assert_eq!(next_selection_index(&state, BrowseNavigation::Up), Some(0));
@@ -629,26 +636,28 @@ mod tests {
 
     #[test]
     fn apply_filters_hides_dotfiles() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.all_entries = vec![
             DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
             DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
         ];
         state.show_hidden = false;
-        apply_filters(&mut state);
+        apply_filters(&mut state, &ui);
         assert_eq!(state.entries.len(), 1);
         assert_eq!(state.entries[0].name, "visible");
     }
 
     #[test]
     fn apply_filters_shows_dotfiles_when_enabled() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.all_entries = vec![
             DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
             DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
         ];
         state.show_hidden = true;
-        apply_filters(&mut state);
+        apply_filters(&mut state, &ui);
         assert_eq!(state.entries.len(), 2);
     }
 
@@ -666,9 +675,10 @@ mod tests {
 
     #[test]
     fn search_changed_filters_entries() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.all_entries = vec![entry("apple", "/a/apple", false), entry("banana", "/a/banana", false)];
-        let req = update(&mut state, BrowseMessage::SearchChanged("ban".to_string()));
+        let req = update(&mut state, BrowseMessage::SearchChanged("ban".to_string()), &mut ui);
         assert!(req.is_none());
         assert_eq!(state.entries.len(), 1);
         assert_eq!(state.entries[0].name, "banana");
@@ -677,47 +687,52 @@ mod tests {
 
     #[test]
     fn navigate_to_path_reads_directory() {
-        let mut state = BrowseState::default();
-        let req = update(&mut state, BrowseMessage::NavigateToPath(PathBuf::from("/tmp")));
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
+        let req = update(&mut state, BrowseMessage::NavigateToPath(PathBuf::from("/tmp")), &mut ui);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/tmp")));
     }
 
     #[test]
     fn navigate_to_plain_directory_reads_it() {
+        let mut ui = UiContext::new();
         let dir = std::env::temp_dir().join(format!("cce_nav_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
         std::fs::create_dir_all(&dir).unwrap();
-        let mut state = BrowseState::default();
+        let mut state = BrowseState::new(&mut ui);
         state.entries = vec![entry("sub", dir.to_str().unwrap(), true)];
-        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
         let _ = std::fs::remove_dir_all(&dir);
     }
 
     #[test]
     fn navigate_to_project_directory_does_not_enter() {
+        let mut ui = UiContext::new();
         let dir = std::env::temp_dir().join(format!("cce_proj_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
         std::fs::create_dir_all(&dir).unwrap();
         std::fs::write(dir.join("state.json"), "{}").unwrap();
-        let mut state = BrowseState::default();
+        let mut state = BrowseState::new(&mut ui);
         state.entries = vec![entry("proj", dir.to_str().unwrap(), true)];
-        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
         assert!(req.is_none());
         let _ = std::fs::remove_dir_all(&dir);
     }
 
     #[test]
     fn navigate_to_file_does_nothing() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.entries = vec![entry("f.txt", "/a/f.txt", false)];
-        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
         assert!(req.is_none());
     }
 
     #[test]
     fn directory_loaded_populates_and_saves() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         let entries = vec![entry("x", "/d/x", false), entry("y", "/d/y", true)];
-        let req = update(&mut state, BrowseMessage::DirectoryLoaded(PathBuf::from("/d"), entries));
+        let req = update(&mut state, BrowseMessage::DirectoryLoaded(PathBuf::from("/d"), entries), &mut ui);
         assert_eq!(state.current_dir, PathBuf::from("/d"));
         assert_eq!(state.entries.len(), 2);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::SaveLastDir(p)) if p == PathBuf::from("/d")));
@@ -725,13 +740,14 @@ mod tests {
 
     #[test]
     fn directory_refreshed_preserves_selection_by_path() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.current_dir = PathBuf::from("/d");
         state.all_entries = vec![entry("a", "/d/a", false), entry("b", "/d/b", false)];
-        apply_filters(&mut state);
+        apply_filters(&mut state, &ui);
         state.selected = Some(1); // "b"
         let new_entries = vec![entry("b", "/d/b", false), entry("a", "/d/a", false), entry("c", "/d/c", false)];
-        let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/d"), new_entries));
+        let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/d"), new_entries), &mut ui);
         assert!(req.is_none());
         assert_eq!(
             state.selected.and_then(|i| state.entries.get(i)).map(|e| e.name.as_str()),
@@ -741,11 +757,12 @@ mod tests {
 
     #[test]
     fn directory_refreshed_ignores_other_dir() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.current_dir = PathBuf::from("/d");
         state.all_entries = vec![entry("a", "/d/a", false)];
-        apply_filters(&mut state);
-        let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/other"), vec![entry("z", "/other/z", false)]));
+        apply_filters(&mut state, &ui);
+        let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/other"), vec![entry("z", "/other/z", false)]), &mut ui);
         assert!(req.is_none());
         assert_eq!(state.entries.len(), 1);
         assert_eq!(state.entries[0].name, "a");
@@ -753,11 +770,12 @@ mod tests {
 
     #[test]
     fn toggle_hidden_shows_dotfiles() {
-        let mut state = BrowseState::default();
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
         state.all_entries = vec![entry(".hidden", "/a/.hidden", false), entry("visible", "/a/visible", false)];
-        apply_filters(&mut state);
+        apply_filters(&mut state, &ui);
         assert_eq!(state.entries.len(), 1);
-        let req = update(&mut state, BrowseMessage::ToggleHidden);
+        let req = update(&mut state, BrowseMessage::ToggleHidden, &mut ui);
         assert!(req.is_none());
         assert!(state.show_hidden);
         assert_eq!(state.entries.len(), 2);
@@ -765,6 +783,7 @@ mod tests {
 
     #[test]
     fn navigate_enters_a_dir_holding_a_settings_state_kdl() {
+        let mut ui = UiContext::new();
         // The shape of ~/.config/cce/cce-designer: a plain directory whose only content is
         // the designer's own state.kdl. Treating that as a project made it un-enterable.
         let dir = std::env::temp_dir().join(format!(
@@ -774,10 +793,10 @@ mod tests {
         std::fs::create_dir_all(&dir).unwrap();
         std::fs::write(dir.join("state.kdl"), "graph {\n    grid_size_x (f64)71.0\n}").unwrap();
 
-        let mut state = BrowseState::default();
+        let mut state = BrowseState::new(&mut ui);
         state.all_entries = vec![entry("cce-designer", dir.to_str().unwrap(), true)];
-        apply_filters(&mut state);
-        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        apply_filters(&mut state, &ui);
+        let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
         assert!(
             matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(ref p)) if *p == dir),
             "navigating into it should read the directory, got {req:?}",
@@ -785,7 +804,7 @@ mod tests {
 
         // A real project (state.json) still opens instead of being entered.
         std::fs::write(dir.join("state.json"), "{}").unwrap();
-        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
         assert!(req.is_none(), "a project dir is not navigable");
 
         let _ = std::fs::remove_file(dir.join("state.kdl"));
@@ -795,15 +814,17 @@ mod tests {
 
     #[test]
     fn last_dir_loaded_some_reads_that_dir() {
-        let mut state = BrowseState::default();
-        let req = update(&mut state, BrowseMessage::LastDirLoaded(Some(PathBuf::from("/some/dir"))));
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
+        let req = update(&mut state, BrowseMessage::LastDirLoaded(Some(PathBuf::from("/some/dir"))), &mut ui);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/some/dir")));
     }
 
     #[test]
     fn last_dir_loaded_none_falls_back() {
-        let mut state = BrowseState::default();
-        let req = update(&mut state, BrowseMessage::LastDirLoaded(None));
+        let mut ui = UiContext::new();
+        let mut state = BrowseState::new(&mut ui);
+        let req = update(&mut state, BrowseMessage::LastDirLoaded(None), &mut ui);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
     }
 
@@ -843,6 +864,7 @@ mod tests {
 
     #[test]
     fn test_directory_persistence() {
+        let mut ui = UiContext::new();
         let root = std::env::temp_dir().join(format!(
             "cce_test_persist_{}",
             chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
@@ -860,6 +882,7 @@ mod tests {
 
     #[tokio::test]
     async fn test_delete_entry() {
+        let mut ui = UiContext::new();
         let temp_dir = std::env::temp_dir();
         let test_subdir = temp_dir.join(format!("cce_test_delete_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
         std::fs::create_dir_all(&test_subdir).unwrap();
@@ -893,7 +916,7 @@ mod tests {
                 }
             ],
             selected: Some(0),
-            ..BrowseState::default()
+            ..BrowseState::new(&mut ui)
         };
 
         // Assert file exists before deletion
@@ -901,9 +924,9 @@ mod tests {
 
         // DeleteEntry outside the trash routes to TrashPath (recoverable);
         // DeleteEntryPermanent is the unrecoverable path.
-        let req = update(&mut state, BrowseMessage::DeleteEntry(0));
+        let req = update(&mut state, BrowseMessage::DeleteEntry(0), &mut ui);
         assert!(matches!(req, Some(crate::services::fs::FsRequest::TrashPath(_))));
-        let req_perm = update(&mut state, BrowseMessage::DeleteEntryPermanent(0));
+        let req_perm = update(&mut state, BrowseMessage::DeleteEntryPermanent(0), &mut ui);
         assert!(matches!(req_perm, Some(crate::services::fs::FsRequest::DeletePath(_, _))));
 
         // Directly delete the file to simulate the FsService action
@@ -911,7 +934,7 @@ mod tests {
         assert!(!file_path.exists());
 
         // Perform update call for Deleted response
-        let req2 = update(&mut state, BrowseMessage::Deleted(file_path.clone(), Ok(())));
+        let req2 = update(&mut state, BrowseMessage::Deleted(file_path.clone(), Ok(())), &mut ui);
         assert!(matches!(req2, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
 
         // Check if state entries are updated
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 7c783bd..77fec03 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -3,7 +3,7 @@ pub mod preview;
 pub mod network;
 pub mod space;
 
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::layout::RenderTarget;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -55,16 +55,16 @@ pub fn breadcrumb_h() -> f32 {
 /// started their content 28px and 34px down against Browse's 24 + gap.
 pub fn breadcrumb_header(
     pc: &mut PageContent,
-    breadcrumb: &mut Owned<cce_ui::widget::Adapted<cce_ui::widget::Breadcrumb>>,
+    breadcrumb: Handle<cce_ui::widget::Adapted<cce_ui::widget::Breadcrumb>>,
     cx: f32,
     cy: f32,
     cw: f32,
     ctx: &mut cce_ui::context::UiContext,
 ) -> f32 {
     let h = breadcrumb_h();
-    cce_ui::layout::render_widget(pc, breadcrumb, cx, cy, cw, h, ctx);
+    cce_ui::layout::render_widget_h(pc, breadcrumb, cx, cy, cw, h, ctx);
     let rect = cce_ui::scene::layout::Rect { x: cx, y: cy, width: cw, height: h };
-    breadcrumb_relief(pc, breadcrumb, rect);
+    breadcrumb_relief(pc, &ctx[breadcrumb], rect);
     cy + h + cce_ui::layout::root_plate_gap()
 }
 
@@ -101,7 +101,7 @@ pub fn breadcrumb_header(
 /// this carve to `window_pc` would buy nothing.
 pub fn breadcrumb_relief(
     pc: &mut PageContent,
-    breadcrumb: &Owned<cce_ui::widget::Adapted<cce_ui::widget::Breadcrumb>>,
+    breadcrumb: &cce_ui::widget::Adapted<cce_ui::widget::Breadcrumb>,
     rect: cce_ui::scene::layout::Rect,
 ) {
     let r = cce_ui::layout::dropdown_corner_radius();
@@ -173,7 +173,7 @@ impl Stroke {
 pub struct PageContent {
     pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
-    pub buttons: Vec<(Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>, crate::Message)>,
+    pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, crate::Message)>,
     /// Relief steps for the control_relief styling — (x, y, w, h, radius, depth,
     /// kind: [`RELIEF_RECESSED`]/[`RELIEF_RAISED`]/[`RELIEF_INSET`]). The flat rects
     /// own the faces; these are the edges-only walls emitted over them (the
@@ -423,7 +423,7 @@ impl PageContent {
             .with_bg(bg)
             .with_hover_bg(hover_bg)
             .with_label_color(label_color);
-        self.buttons.push((Owned::new(btn), action));
+        self.buttons.push((btn, action));
     }
 
     /// A button wearing the toolkit's own face — no per-call colors. The
@@ -438,7 +438,7 @@ impl PageContent {
             .with_label(label)
             .with_bg([0.0, 0.0, 0.0, 0.0])
             .with_hover_bg([1.0, 1.0, 1.0, 0.10]);
-        self.buttons.push((Owned::new(btn), action));
+        self.buttons.push((btn, action));
     }
 
     pub fn button_left(
@@ -459,7 +459,7 @@ impl PageContent {
             .with_hover_bg(hover_bg)
             .with_label_color(label_color)
             .with_left_align(true);
-        self.buttons.push((Owned::new(btn), action));
+        self.buttons.push((btn, action));
     }
 }
 
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 4b9cfc8..e752a04 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,12 +1,13 @@
 use std::path::{Path, PathBuf};
 use crate::pages::PageContent;
 use crate::pages::browse::{BrowseState, DirEntry};
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::{Adapted, Graph, GraphNode, Breadcrumb, GraphController, PathController};
 
 pub struct NetworkState {
-    pub graph: Owned<Adapted<Graph>>,
-    pub breadcrumb: Owned<Adapted<Breadcrumb>>,
+    pub graph: Handle<Adapted<Graph>>,
+    pub breadcrumb: Handle<Adapted<Breadcrumb>>,
     pub last_dir: PathBuf,
     /// The graph pane `(x, y, w, h)` the nodes were last laid out for: a
     /// pane of another size is laid out again (`view`), so the columns fit
@@ -20,8 +21,9 @@ pub struct NetworkState {
     pub node_glyphs: Vec<&'static str>,
 }
 
-impl Default for NetworkState {
-    fn default() -> Self {
+impl NetworkState {
+    /// The page's state, its widgets inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         // The configured lattice and node body (`style.surface.graph`), as
         // the designer's network uses them: a node is CENTRED on a lattice
         // crossing and its name hangs off its right side. The pitch across is
@@ -42,8 +44,8 @@ impl Default for NetworkState {
         breadcrumb.set_network_opacity(0.95);
 
         Self {
-            graph: Owned::new(graph),
-            breadcrumb: Owned::new(breadcrumb),
+            graph: ctx.insert(graph),
+            breadcrumb: ctx.insert(breadcrumb),
             last_dir: PathBuf::new(),
             node_glyphs: Vec::new(),
             laid_out_for: (0.0, 0.0, 0.0, 0.0),
@@ -107,21 +109,21 @@ impl NetworkState {
     /// Lay the directory out for the pane `pane` and fill the graph — see
     /// [`layout`]. The parent and the directory stand over the middle column,
     /// its entries in rows below them.
-    pub fn lay_out(&mut self, pane: (f32, f32, f32, f32), current_dir: &Path, entries: &[DirEntry]) {
-        let node = self.graph.inner().node_size();
+    pub fn lay_out(&mut self, pane: (f32, f32, f32, f32), current_dir: &Path, entries: &[DirEntry], ctx: &mut UiContext) {
+        let node = ctx[self.graph].inner().node_size();
         let (font, _) = label_metrics(node.0);
         let names: Vec<String> = entries.iter().map(|e| fit_name(&e.name, font, NAME_MAX)).collect();
         let ((pitch_x, pitch_y), cols, origin) = layout(pane, node, self.base_pitch, &names);
-        self.graph.set_grid_pitch(pitch_x, pitch_y);
-        self.graph.set_grid_origin(origin.0, origin.1);
-        self.populate_graph(current_dir, entries, cols, &names);
+        ctx[self.graph].set_grid_pitch(pitch_x, pitch_y);
+        ctx[self.graph].set_grid_origin(origin.0, origin.1);
+        self.populate_graph(current_dir, entries, cols, &names, ctx);
         self.laid_out_for = pane;
     }
 
     /// Fill the graph: the parent at row 1 and the directory at row 2 over
     /// the middle column, then `entries` (shown as `names`) `cols` to a row,
     /// columns counted from 1.
-    pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry], cols: usize, names: &[String]) {
+    pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry], cols: usize, names: &[String], ctx: &mut UiContext) {
         let cols = cols.max(1);
         // Columns and rows from 1: the lattice's 0 lines are off the pane.
         let mid = ((cols - 1) / 2) as f32 + 1.0;
@@ -196,7 +198,7 @@ impl NetworkState {
             });
         }
 
-        self.graph.set_nodes(&nodes);
+        ctx[self.graph].set_nodes(&nodes);
         self.node_glyphs = glyphs;
         self.last_dir = current_dir.to_path_buf();
     }
@@ -205,7 +207,7 @@ impl NetworkState {
 pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut pc = PageContent::new();
 
-    let top = crate::pages::breadcrumb_header(&mut pc, &mut state.breadcrumb, cx, cy, cw, ctx);
+    let top = crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, cx, cy, cw, ctx);
 
     // The pane the graph is laid out in, under the breadcrumb.
     let pane = (cx, top, cw, (cy + ch - top).max(0.0));
@@ -215,8 +217,8 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw
         || (pane.3 - state.laid_out_for.3).abs() > 0.5;
 
     // Check if directory changed, or if last_dir is empty, and repopulate
-    if state.last_dir != browse.current_dir || state.graph.get_nodes().is_empty() {
-        state.lay_out(pane, &browse.current_dir, &browse.entries);
+    if state.last_dir != browse.current_dir || ctx[state.graph].get_nodes().is_empty() {
+        state.lay_out(pane, &browse.current_dir, &browse.entries, ctx);
 
         // Update breadcrumb path
         let mut segments = Vec::new();
@@ -226,22 +228,22 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw
                 segments.push(s);
             }
         }
-        state.breadcrumb.set_path(&segments);
+        ctx[state.breadcrumb].set_path(&segments);
 
         // Map browse selection to graph node selection if any
         let has_parent = browse.current_dir.parent().is_some();
         let offset = if has_parent { 2 } else { 1 };
         if let Some(sel) = browse.selected {
-            state.graph.set_selected_node(Some(sel + offset));
+            ctx[state.graph].set_selected_node(Some(sel + offset));
         } else {
-            state.graph.set_selected_node(None);
+            ctx[state.graph].set_selected_node(None);
         }
     } else {
         // A pane of another size: the same nodes, laid out to fit it.
         if moved {
-            let selected = state.graph.selected_node();
-            state.lay_out(pane, &browse.current_dir, &browse.entries);
-            state.graph.set_selected_node(selected);
+            let selected = ctx[state.graph].selected_node();
+            state.lay_out(pane, &browse.current_dir, &browse.entries, ctx);
+            ctx[state.graph].set_selected_node(selected);
         }
         // Sync graph selection with browse selection when they are in sync
         let has_parent = browse.current_dir.parent().is_some();
@@ -249,15 +251,15 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw
 
         if let Some(sel) = browse.selected {
             let expected_node_idx = sel + offset;
-            if state.graph.selected_node() != Some(expected_node_idx) {
-                if !state.graph.is_dragging() {
-                    state.graph.set_selected_node(Some(expected_node_idx));
+            if ctx[state.graph].selected_node() != Some(expected_node_idx) {
+                if !ctx[state.graph].is_dragging() {
+                    ctx[state.graph].set_selected_node(Some(expected_node_idx));
                 }
             }
         } else {
-            if let Some(graph_sel) = state.graph.selected_node() {
+            if let Some(graph_sel) = ctx[state.graph].selected_node() {
                 if graph_sel >= offset {
-                    state.graph.set_selected_node(None);
+                    ctx[state.graph].set_selected_node(None);
                 }
             }
         }
@@ -267,7 +269,7 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw
     // it, since a graph panned or zoomed past its edge would otherwise draw
     // over the preview beside it.
     let mut graph_pc = PageContent::new();
-    cce_ui::layout::render_widget(&mut graph_pc, &mut state.graph, pane.0, pane.1, pane.2, pane.3, ctx);
+    cce_ui::layout::render_widget_h(&mut graph_pc, state.graph, pane.0, pane.1, pane.2, pane.3, ctx);
     pc.absorb(graph_pc.clipped_to([cx, top, cx + cw, cy + ch]));
 
     // Each node's glyph on the left of its body, at the size and inset the
@@ -276,7 +278,7 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw
     let canvas = [cx, top, cx + cw, cy + ch];
     let glyph_color = [0xcc as f32 / 255.0, 0xcc as f32 / 255.0, 0xd4 as f32 / 255.0, 1.0];
     for (idx, glyph) in state.node_glyphs.iter().enumerate() {
-        let Some((nx, ny, nw, nh)) = state.graph.node_rect(idx) else { continue };
+        let Some((nx, ny, nw, nh)) = ctx[state.graph].node_rect(idx) else { continue };
         let scale_f = nw / 80.0;
         let side = (18.0 * scale_f).clamp(6.0, 50.0);
         pc.icon_bounded(glyph, nx + 6.0 * scale_f, ny + (nh - side) / 2.0, side, side, glyph_color, Some(canvas));
@@ -291,7 +293,8 @@ mod tests {
 
     #[test]
     fn test_populate_graph_has_parent() {
-        let mut state = NetworkState::default();
+        let mut ui = UiContext::new();
+        let mut state = NetworkState::new(&mut ui);
         let current_dir = Path::new("/home/user/project");
         let entries = vec![
             DirEntry {
@@ -314,8 +317,8 @@ mod tests {
             },
         ];
 
-        state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries);
-        let nodes = state.graph.get_nodes();
+        state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries, &mut ui);
+        let nodes = ui[state.graph].get_nodes();
 
         // 1 parent + 1 current + 2 children = 4 nodes
         assert_eq!(nodes.len(), 4);
@@ -354,20 +357,21 @@ mod tests {
     /// across its neighbour.
     #[test]
     fn the_graph_fits_its_pane_and_its_names_fit_their_columns() {
+        let mut ui = UiContext::new();
         use cce_ui::widget::display::TextLabel;
-        let mut state = NetworkState::default();
+        let mut state = NetworkState::new(&mut ui);
         let names = ["Adwaita", "AdwaitaMono-BoldItalic.ttf", "a-really-quite-extraordinarily-long-font-family-name.otf", "x", "gnu-free", "noto-cjk", "liberation", "spleen", "xscreensaver"];
         let entries: Vec<DirEntry> = names
             .iter()
             .map(|n| DirEntry { name: n.to_string(), path: PathBuf::from(format!("/f/{n}")), is_dir: false, size: 1, permissions: 0o644, modified: String::new(), origin: None })
             .collect();
         let pane = (20.0, 60.0, 600.0, 640.0);
-        state.lay_out(pane, Path::new("/usr/share/fonts"), &entries);
-        let g = state.graph.inner();
+        state.lay_out(pane, Path::new("/usr/share/fonts"), &entries, &mut ui);
+        let g = ui[state.graph].inner();
         let (nw, _) = g.node_size();
         let (font, gap) = label_metrics(nw);
         let (pitch_x, _) = g.grid_pitch();
-        let nodes = state.graph.get_nodes();
+        let nodes = ui[state.graph].get_nodes();
         for (i, node) in nodes.iter().enumerate() {
             let (x, _, w, _) = g.node_rect(i).unwrap();
             let label_w = TextLabel::estimate_width(&node.name, font);
@@ -385,7 +389,8 @@ mod tests {
 
     #[test]
     fn test_populate_graph_root_no_parent() {
-        let mut state = NetworkState::default();
+        let mut ui = UiContext::new();
+        let mut state = NetworkState::new(&mut ui);
         let current_dir = Path::new("/");
         let entries = vec![
             DirEntry {
@@ -399,8 +404,8 @@ mod tests {
             },
         ];
 
-        state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries);
-        let nodes = state.graph.get_nodes();
+        state.lay_out((0.0, 0.0, 800.0, 600.0), current_dir, &entries, &mut ui);
+        let nodes = ui[state.graph].get_nodes();
 
         // No parent, so 1 current + 1 child = 2 nodes
         assert_eq!(nodes.len(), 2);
diff --git a/src/pages/space.rs b/src/pages/space.rs
index 12f47a6..5796c48 100644
--- a/src/pages/space.rs
+++ b/src/pages/space.rs
@@ -19,7 +19,8 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
 
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::Handle;
 use cce_ui::widget::{Adapted, Breadcrumb, PathController};
 
 use crate::pages::PageContent;
@@ -406,7 +407,7 @@ pub fn update(state: &mut SpaceState, msg: SpaceMessage) {
 // ── Page state ──────────────────────────────────────────────────────
 
 pub struct SpaceState {
-    pub breadcrumb: Owned<Adapted<Breadcrumb>>,
+    pub breadcrumb: Handle<Adapted<Breadcrumb>>,
     /// The directory the current `tree` describes. Empty until a scan lands.
     pub scanned_dir: PathBuf,
     pub tree: Option<TreeNode>,
@@ -435,12 +436,13 @@ pub struct SpaceState {
     pub focused: bool,
 }
 
-impl Default for SpaceState {
-    fn default() -> Self {
+impl SpaceState {
+    /// The page's state, its breadcrumb inserted into `ctx`.
+    pub fn new(ctx: &mut UiContext) -> Self {
         let mut breadcrumb = Breadcrumb::new();
         breadcrumb.set_network_opacity(0.95);
         Self {
-            breadcrumb: Owned::new(breadcrumb),
+            breadcrumb: ctx.insert(breadcrumb),
             scanned_dir: PathBuf::new(),
             tree: None,
             tiles: Vec::new(),
@@ -676,7 +678,7 @@ pub fn view(
 ) -> PageContent {
     let mut pc = PageContent::new();
 
-    let top = crate::pages::breadcrumb_header(&mut pc, &mut state.breadcrumb, cx, cy, cw, ctx);
+    let top = crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, cx, cy, cw, ctx);
 
     let mut segments = Vec::new();
     for component in browse.current_dir.components() {
@@ -685,7 +687,7 @@ pub fn view(
             segments.push(s);
         }
     }
-    state.breadcrumb.set_path(&segments);
+    ctx[state.breadcrumb].set_path(&segments);
 
     // The map occupies everything below the header, less the footer readout.
     let map = (cx, top, cw, (cy + ch - top - FOOTER_H).max(0.0));
@@ -1099,6 +1101,7 @@ mod tests {
 
     #[test]
     fn hit_test_finds_the_deepest_tile() {
+        let mut ui = UiContext::new();
         let tree = TreeNode {
             name: "root".into(),
             size: 1000,
@@ -1110,7 +1113,7 @@ mod tests {
                 children: vec![file("leaf.bin", 1000)],
             }],
         };
-        let mut state = SpaceState::default();
+        let mut state = SpaceState::new(&mut ui);
         state.scanned_dir = PathBuf::from("/root");
         state.tree = Some(tree);
         state.relayout((0.0, 0.0, 400.0, 400.0));
@@ -1172,6 +1175,7 @@ mod tests {
 
     #[test]
     fn a_directory_drawn_as_one_block_wears_its_dominant_kind() {
+        let mut ui = UiContext::new();
         // `sub` gets far less than MIN_RECURSE across, so it is not opened.
         let mut children = vec![file("huge.bin", 1_000_000)];
         children.push(TreeNode {
@@ -1181,7 +1185,7 @@ mod tests {
             children: vec![file("x.mp3", 15_000), file("y.txt", 5_000)],
         });
         let tree = TreeNode { name: "root".into(), size: 1_020_000, is_dir: true, children };
-        let mut state = SpaceState::default();
+        let mut state = SpaceState::new(&mut ui);
         state.scan_finished(PathBuf::from("/root"), tree);
         state.relayout((0.0, 0.0, 200.0, 200.0));