git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

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

Fields move from Owned to Handle, inserted in create; the widgets_registered flag became laid_out (first layout only) and the dropdown raw-pointer loop is a loop over handles. Shadow A/B identical.

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

 src/main.rs  | 235 ++++++++++++++++++++++++++++-------------------------------
 src/vault.rs |  39 +++++-----
 2 files changed, 131 insertions(+), 143 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index d3646f7..c30b1c9 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::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
 mod linkgraph;
 mod vault;
@@ -52,12 +52,12 @@ struct LoadedImage {
 }
 
 struct GraphApp {
-    menu_bar: Owned<Adapted<MenuBar>>,
-    dropdown_file: Owned<Adapted<Dropdown>>,
-    dropdown_edit: Owned<Adapted<Dropdown>>,
-    dropdown_view: Owned<Adapted<Dropdown>>,
+    menu_bar: Handle<Adapted<MenuBar>>,
+    dropdown_file: Handle<Adapted<Dropdown>>,
+    dropdown_edit: Handle<Adapted<Dropdown>>,
+    dropdown_view: Handle<Adapted<Dropdown>>,
 
-    graph: Owned<Adapted<Graph>>,
+    graph: Handle<Adapted<Graph>>,
     needs_rebuild: bool,
     width: u32,
     height: u32,
@@ -67,7 +67,8 @@ struct GraphApp {
     ui_context: cce_ui::context::UiContext,
     loaded_project_path: Option<std::path::PathBuf>,
     loaded_images: Vec<LoadedImage>,
-    widgets_registered: bool,
+    /// Whether the first layout has run.
+    laid_out: bool,
     message_sender: calloop::channel::Sender<AppMessage>,
     dragging_image_idx: Option<usize>,
     drag_image_ox: f32,
@@ -81,7 +82,7 @@ struct GraphApp {
     panel_drag_ox: f32,
     panel_drag_oy: f32,
     show_control_panel: bool,
-    control_panel_label: Owned<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
+    control_panel_label: Handle<cce_ui::widget::Adapted<cce_ui::widget::Label>>,
 }
 
 fn get_default_project_path() -> std::path::PathBuf {
@@ -434,10 +435,10 @@ impl GraphApp {
     /// it was made. Keeps the sentinel `selected = 999` protocol.
     fn drain_file_menu(&mut self) -> Option<AppMessage> {
         let mut msg = None;
-        if self.dropdown_file.take_change() {
-            let selected_idx = self.dropdown_file.selected;
-            if selected_idx < self.dropdown_file.options.len() {
-                let option_text = &self.dropdown_file.options[selected_idx];
+        if self.ui_context[self.dropdown_file].take_change() {
+            let selected_idx = self.ui_context[self.dropdown_file].selected;
+            if selected_idx < self.ui_context[self.dropdown_file].options.len() {
+                let option_text = &self.ui_context[self.dropdown_file].options[selected_idx];
                 match option_text.as_str() {
                     "New" => msg = Some(AppMessage::New),
                     "Open" | "Open..." => msg = Some(AppMessage::Open),
@@ -451,28 +452,28 @@ impl GraphApp {
                     }
                 }
             }
-            self.dropdown_file.selected = 999;
+            self.ui_context[self.dropdown_file].selected = 999;
         }
         msg
     }
 
     fn drain_edit_menu(&mut self) -> Option<AppMessage> {
         let mut msg = None;
-        if self.dropdown_edit.take_change() {
-            match self.dropdown_edit.selected {
+        if self.ui_context[self.dropdown_edit].take_change() {
+            match self.ui_context[self.dropdown_edit].selected {
                 0 => msg = Some(AppMessage::AddNode),
                 1 => msg = Some(AppMessage::AddImage),
                 _ => {}
             }
-            self.dropdown_edit.selected = 999;
+            self.ui_context[self.dropdown_edit].selected = 999;
         }
         msg
     }
 
     fn drain_view_menu(&mut self) -> Option<AppMessage> {
         let mut msg = None;
-        if self.dropdown_view.take_change() {
-            match self.dropdown_view.selected {
+        if self.ui_context[self.dropdown_view].take_change() {
+            match self.ui_context[self.dropdown_view].selected {
                 0 => msg = Some(AppMessage::ToggleGrid),
                 1 => msg = Some(AppMessage::SetOpacity95),
                 2 => msg = Some(AppMessage::SetOpacity75),
@@ -480,14 +481,14 @@ impl GraphApp {
                 4 => msg = Some(AppMessage::ToggleControlPanel),
                 _ => {}
             }
-            self.dropdown_view.selected = 999;
+            self.ui_context[self.dropdown_view].selected = 999;
         }
         msg
     }
 
     fn delete_selected_node(&mut self) {
-        if let Some(idx) = self.graph.selected_node() {
-            let mut nodes = self.graph.get_nodes();
+        if let Some(idx) = self.ui_context[self.graph].selected_node() {
+            let mut nodes = self.ui_context[self.graph].get_nodes();
             if idx < nodes.len() {
                 let deleted_node_name = nodes[idx].name.clone();
                 nodes.remove(idx);
@@ -501,8 +502,8 @@ impl GraphApp {
                     }
                 }
                 
-                self.graph.set_nodes(&nodes);
-                self.graph.set_selected_node(None);
+                self.ui_context[self.graph].set_nodes(&nodes);
+                self.ui_context[self.graph].set_selected_node(None);
                 self.needs_rebuild = true;
             }
         } else if let Some(idx) = self.selected_image_idx {
@@ -515,7 +516,7 @@ impl GraphApp {
     }
 
     fn update_view_options(&mut self) {
-        self.dropdown_view.options = get_view_options(self.show_grid, self.opacity, self.show_control_panel);
+        self.ui_context[self.dropdown_view].options = get_view_options(self.show_grid, self.opacity, self.show_control_panel);
     }
 
     /// The dissolved control panel's rect (fixed 210x160, app-tracked position).
@@ -540,14 +541,14 @@ impl GraphApp {
         let center_x = left_x + available_w / 2.0;
         let center_y = start_y + available_h / 2.0;
 
-        let (_, _, lw, lh) = self.control_panel_label.rect();
+        let (_, _, lw, lh) = self.ui_context[self.control_panel_label].rect();
         let use_w = if lw > 0.0 { lw.min(available_w) } else { available_w };
         let use_h = if lh > 0.0 { lh } else { 50.0 };
         let cx = (center_x - use_w / 2.0).clamp(left_x, (left_x + available_w - use_w).max(left_x));
         let cy = (center_y - use_h / 2.0).clamp(start_y, (start_y + available_h - use_h).max(start_y));
         let cw = use_w.min(px + pw - pad - cx);
         let ch = use_h.min(py + ph - pad - cy);
-        self.control_panel_label.set_rect(cx, cy, cw, ch);
+        self.ui_context[self.control_panel_label].set_rect(cx, cy, cw, ch);
     }
 
     /// The dissolved Plate's visual: config plate color (else page-low, with the drag tint),
@@ -571,7 +572,7 @@ impl GraphApp {
     }
 
     fn new_project(&mut self) {
-        self.graph.set_nodes(&[]);
+        self.ui_context[self.graph].set_nodes(&[]);
         self.loaded_images.clear();
         self.loaded_project_path = None;
         self.needs_rebuild = true;
@@ -600,7 +601,7 @@ impl GraphApp {
                 .and_then(|n| n.to_str())
                 .unwrap_or("Graph Project")
                 .to_string(),
-            nodes: self.graph.get_nodes(),
+            nodes: self.ui_context[self.graph].get_nodes(),
             images: project_images,
             show_grid: self.show_grid,
             opacity: self.opacity,
@@ -654,8 +655,8 @@ impl GraphApp {
         }
         options.push("-".to_string());
         options.push("Exit".to_string());
-        self.dropdown_file.options = options;
-        self.dropdown_file.selected = 999;
+        self.ui_context[self.dropdown_file].options = options;
+        self.ui_context[self.dropdown_file].selected = 999;
         self.needs_rebuild = true;
     }
 
@@ -684,7 +685,7 @@ impl GraphApp {
 
         let mut nodes = state.nodes.clone();
         wiring::ensure_ids(&mut nodes);
-        self.graph.set_nodes(&nodes);
+        self.ui_context[self.graph].set_nodes(&nodes);
         self.show_grid = state.show_grid;
         self.opacity = state.opacity;
 
@@ -711,9 +712,9 @@ impl GraphApp {
             }
         }
 
-        // Apply grid/background settings to self.graph
-        self.graph.set_show_network_grid(self.show_grid);
-        self.graph.set_network_opacity(self.opacity);
+        // Apply grid/background settings to self.ui_context[self.graph]
+        self.ui_context[self.graph].set_show_network_grid(self.show_grid);
+        self.ui_context[self.graph].set_network_opacity(self.opacity);
 
         // Update view dropdown options
         self.update_view_options();
@@ -724,9 +725,9 @@ impl GraphApp {
     }
 
     fn hit_test_image(&self, px: f32, py: f32) -> Option<usize> {
-        let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
-        let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
-        let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
+        let (grid_origin_x, grid_origin_y) = self.ui_context[self.graph].grid_origin();
+        let (grid_size_x, grid_size_y) = self.ui_context[self.graph].grid_sizes();
+        let (skipped_row_h, skipped_col_w) = self.ui_context[self.graph].skipped_sizes();
         
         let step_x = grid_size_x + skipped_col_w;
         let step_y = grid_size_y + skipped_row_h;
@@ -864,23 +865,26 @@ impl Application for GraphApp {
             .with_font_size(12.0)
             .with_color([204, 204, 221]);
 
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
+        let control_panel_label = ui_context.insert(control_panel_label);
         let mut app = Self {
 
-            menu_bar: Owned::new(menu_bar),
-            dropdown_file: Owned::new(dropdown_file),
-            dropdown_edit: Owned::new(dropdown_edit),
-            dropdown_view: Owned::new(dropdown_view),
-            graph: Owned::new(graph),
+            menu_bar: ui_context.insert(menu_bar),
+            dropdown_file: ui_context.insert(dropdown_file),
+            dropdown_edit: ui_context.insert(dropdown_edit),
+            dropdown_view: ui_context.insert(dropdown_view),
+            graph: ui_context.insert(graph),
             needs_rebuild: true,
             width: 1024,
             height: 768,
             scale_factor: 1.0,
             show_grid,
             opacity,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
             loaded_project_path: None,
             loaded_images: Vec::new(),
-            widgets_registered: false,
+            laid_out: false,
             message_sender: _sender.clone().into(),
             dragging_image_idx: None,
             drag_image_ox: 0.0,
@@ -892,17 +896,17 @@ impl Application for GraphApp {
             panel_drag_ox: 0.0,
             panel_drag_oy: 0.0,
             show_control_panel,
-            control_panel_label: Owned::new(control_panel_label),
+            control_panel_label,
         };
         
 
-        app.menu_bar.set_rect(0.0, 0.0, 1024.0, 42.0);
+        app.ui_context[app.menu_bar].set_rect(0.0, 0.0, 1024.0, 42.0);
         let dd_h = cce_ui::layout::dropdown_height();
         let dd_y = (42.0 - dd_h) / 2.0;
-        app.dropdown_file.set_rect(10.0, dd_y, 70.0, dd_h);
-        app.dropdown_edit.set_rect(90.0, dd_y, 70.0, dd_h);
-        app.dropdown_view.set_rect(170.0, dd_y, 70.0, dd_h);
-        app.graph.set_rect(0.0, 42.0, 1024.0, 768.0 - 42.0);
+        app.ui_context[app.dropdown_file].set_rect(10.0, dd_y, 70.0, dd_h);
+        app.ui_context[app.dropdown_edit].set_rect(90.0, dd_y, 70.0, dd_h);
+        app.ui_context[app.dropdown_view].set_rect(170.0, dd_y, 70.0, dd_h);
+        app.ui_context[app.graph].set_rect(0.0, 42.0, 1024.0, 768.0 - 42.0);
 
         let args: Vec<String> = std::env::args().collect();
         if args.len() > 1 {
@@ -1007,7 +1011,7 @@ impl Application for GraphApp {
             }
             AppMessage::ToggleGrid => {
                 self.show_grid = !self.show_grid;
-                self.graph.set_show_network_grid(self.show_grid);
+                self.ui_context[self.graph].set_show_network_grid(self.show_grid);
                 self.update_view_options();
                 write_config_value("graph_show_grid", &self.show_grid.to_string());
                 *needs_rebuild = true;
@@ -1015,7 +1019,7 @@ impl Application for GraphApp {
             }
             AppMessage::SetOpacity95 => {
                 self.opacity = 0.95;
-                self.graph.set_network_opacity(0.95);
+                self.ui_context[self.graph].set_network_opacity(0.95);
                 self.update_view_options();
                 write_config_value("graph_network_opacity", "0.95");
                 *needs_rebuild = true;
@@ -1023,7 +1027,7 @@ impl Application for GraphApp {
             }
             AppMessage::SetOpacity75 => {
                 self.opacity = 0.75;
-                self.graph.set_network_opacity(0.75);
+                self.ui_context[self.graph].set_network_opacity(0.75);
                 self.update_view_options();
                 write_config_value("graph_network_opacity", "0.75");
                 *needs_rebuild = true;
@@ -1031,7 +1035,7 @@ impl Application for GraphApp {
             }
             AppMessage::SetOpacity50 => {
                 self.opacity = 0.50;
-                self.graph.set_network_opacity(0.50);
+                self.ui_context[self.graph].set_network_opacity(0.50);
                 self.update_view_options();
                 write_config_value("graph_network_opacity", "0.50");
                 *needs_rebuild = true;
@@ -1044,7 +1048,7 @@ impl Application for GraphApp {
                 self.needs_rebuild = true;
             }
             AppMessage::AddNode => {
-                let mut nodes = self.graph.get_nodes();
+                let mut nodes = self.ui_context[self.graph].get_nodes();
                 let next_id = nodes.len() + 1;
                 nodes.push(GraphNode {
                     id: String::new(),
@@ -1057,7 +1061,7 @@ impl Application for GraphApp {
                     outputs: 1,
                 });
                 wiring::ensure_ids(&mut nodes);
-                self.graph.set_nodes(&nodes);
+                self.ui_context[self.graph].set_nodes(&nodes);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1099,27 +1103,13 @@ impl Application for GraphApp {
         // through its per-label hatch, the control panel via the 6i container-text fix).
         self.ui_context.clear_popovers();
 
-        let is_first_layout = !self.widgets_registered;
-        if !self.widgets_registered {
-            // The root plate container is DISSOLVED (Phase 6m recipe): top-level widgets register
-            // directly (parentless), the window plate is emitted below as prims, and the two
-            // Plates keep their own children.
-            self.ui_context.register_host(&mut self.menu_bar);
-            self.ui_context.register_host(&mut self.dropdown_file);
-            self.ui_context.register_host(&mut self.dropdown_edit);
-            self.ui_context.register_host(&mut self.dropdown_view);
-            // Registered under the widget's OWN base id (the id-rooted router resolves
-            // dispatch roots through the registry; the old synthetic `graph_id` key left
-            // `graph.id()` unresolvable — a latent hole the pointer router masked).
-            self.ui_context.register_host(&mut self.graph);
-            self.ui_context.register_host(&mut self.control_panel_label);
-            self.widgets_registered = true;
-        }
+        let is_first_layout = !self.laid_out;
+        self.laid_out = true;
 
         // Check selected node and update control panel label
-        let selected_node_idx = self.graph.selected_node();
+        let selected_node_idx = self.ui_context[self.graph].selected_node();
         let label_text = if let Some(idx) = selected_node_idx {
-            let nodes = self.graph.get_nodes();
+            let nodes = self.ui_context[self.graph].get_nodes();
             if let Some(node) = nodes.get(idx) {
                 let mut info = format!("Selected Node:\nID: {}\nName: {}\nType: {}\nInputs: {}\nOutputs: {}",
                     node.id, node.name, node.node_type, node.inputs, node.outputs
@@ -1154,31 +1144,31 @@ impl Application for GraphApp {
         };
 
         let text_changed = {
-            let current_text = self.control_panel_label.base().label.as_ref();
+            let current_text = self.ui_context[self.control_panel_label].base().label.as_ref();
             current_text != Some(&label_text)
         };
         if text_changed {
-            self.control_panel_label.set_text(&label_text);
+            self.ui_context[self.control_panel_label].set_text(&label_text);
             self.needs_rebuild = true;
         }
 
-        if self.dropdown_file.popover_rect().is_some() {
+        if self.ui_context[self.dropdown_file].popover_rect().is_some() {
             // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
             // the walk; a global registration spawns a render-only xdg popup that swallows
             // clicks on the open menu.
-            self.ui_context.register_popover(&mut self.dropdown_file);
+            self.ui_context.register_popover_id(self.dropdown_file.id());
         }
-        if self.dropdown_edit.popover_rect().is_some() {
+        if self.ui_context[self.dropdown_edit].popover_rect().is_some() {
             // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
             // the walk; a global registration spawns a render-only xdg popup that swallows
             // clicks on the open menu.
-            self.ui_context.register_popover(&mut self.dropdown_edit);
+            self.ui_context.register_popover_id(self.dropdown_edit.id());
         }
-        if self.dropdown_view.popover_rect().is_some() {
+        if self.ui_context[self.dropdown_view].popover_rect().is_some() {
             // ui_context ONLY (the 6l pattern): the popover is drawn in the display list by
             // the walk; a global registration spawns a render-only xdg popup that swallows
             // clicks on the open menu.
-            self.ui_context.register_popover(&mut self.dropdown_view);
+            self.ui_context.register_popover_id(self.dropdown_view.id());
         }
 
         let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
@@ -1188,7 +1178,7 @@ impl Application for GraphApp {
             self.scale_factor = scale;
             
             // Layout MenuBar at the top
-            self.menu_bar.set_rect(0.0, 0.0, size.width, 42.0);
+            self.ui_context[self.menu_bar].set_rect(0.0, 0.0, size.width, 42.0);
             
             // The File/Edit/View dropdown row, laid out directly (the transparent layout
             // Plate is DISSOLVED): a row from x=10 with 10px gaps, centred in the 42px
@@ -1196,11 +1186,8 @@ impl Application for GraphApp {
             // sizes the scene solver used).
             {
                 let mut x = 10.0;
-                let self_ptr = self as *mut Self;
-                let dds: [&mut cce_ui::widget::Adapted<Dropdown>; 3] = unsafe {
-                    [&mut (*self_ptr).dropdown_file, &mut (*self_ptr).dropdown_edit, &mut (*self_ptr).dropdown_view]
-                };
-                for dd in dds {
+                for h in [self.dropdown_file, self.dropdown_edit, self.dropdown_view] {
+                    let dd = &mut self.ui_context[h];
                     // Same sizing rule the retired scene bridge used: the dropdown's intrinsic
                     // size (widest option x configured dropdown height).
                     let sz = dd.intrinsic_size()
@@ -1211,7 +1198,7 @@ impl Application for GraphApp {
             }
 
             // Layout Graph below MenuBar
-            self.graph.set_rect(0.0, 42.0, size.width, size.height - 42.0);
+            self.ui_context[self.graph].set_rect(0.0, 42.0, size.width, size.height - 42.0);
 
             // Initial control panel positioning (bounds are clamped at drag time)
             if size_changed || is_first_layout {
@@ -1233,11 +1220,11 @@ impl Application for GraphApp {
         {
             // The walk takes shared borrows now — no self-alias, no pointers.
             let tops: [&dyn cce_ui::widget::WidgetHost; 5] = [
-                &self.menu_bar,
-                &self.dropdown_file,
-                &self.dropdown_edit,
-                &self.dropdown_view,
-                &self.graph,
+                &self.ui_context[self.menu_bar],
+                &self.ui_context[self.dropdown_file],
+                &self.ui_context[self.dropdown_edit],
+                &self.ui_context[self.dropdown_view],
+                &self.ui_context[self.graph],
             ];
             for top in tops {
                 cce_ui::scene::painter::paint_root_into(&self.ui_context, top, &mut pc);
@@ -1260,18 +1247,18 @@ impl Application for GraphApp {
                     pc.quad(rect, fill);
                 }
             }
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.control_panel_label, &mut pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.control_panel_label], &mut pc);
         }
 
         // Draw foreground images in the graph grid (above nodes, fully opaque, preserving aspect ratio)
-        let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
-        let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
-        let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
+        let (grid_origin_x, grid_origin_y) = self.ui_context[self.graph].grid_origin();
+        let (grid_size_x, grid_size_y) = self.ui_context[self.graph].grid_sizes();
+        let (skipped_row_h, skipped_col_w) = self.ui_context[self.graph].skipped_sizes();
         
         let step_x = grid_size_x + skipped_col_w;
         let step_y = grid_size_y + skipped_row_h;
 
-        let (graph_x, graph_y, graph_w, graph_h) = self.graph.rect();
+        let (graph_x, graph_y, graph_w, graph_h) = self.ui_context[self.graph].rect();
         let min_x = graph_x;
         let min_y = graph_y;
         let max_x = graph_x + graph_w;
@@ -1376,11 +1363,11 @@ impl Application for GraphApp {
             return;
         }
 
-        let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
-            || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.dropdown_view.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.menu_bar.hit_test(pos.x, pos.y, &self.ui_context);
+        let over_menu = self.ui_context[self.dropdown_file].open || self.ui_context[self.dropdown_edit].open || self.ui_context[self.dropdown_view].open
+            || self.ui_context[self.dropdown_file].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.dropdown_edit].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.dropdown_view].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.menu_bar].hit_test(pos.x, pos.y, &self.ui_context);
 
         // Routed dispatch (6bd shrink): one Event through the router per root; the panel
         // and loaded-image drags stay app-owned (they are not widgets).
@@ -1414,9 +1401,9 @@ impl Application for GraphApp {
             if !handled_by_panel {
                 // Otherwise feed it to Graph
                 if let Some(img_idx) = self.dragging_image_idx {
-                    let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
-                    let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
-                    let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
+                    let (grid_origin_x, grid_origin_y) = self.ui_context[self.graph].grid_origin();
+                    let (grid_size_x, grid_size_y) = self.ui_context[self.graph].grid_sizes();
+                    let (skipped_row_h, skipped_col_w) = self.ui_context[self.graph].skipped_sizes();
                     let step_x = grid_size_x + skipped_col_w;
                     let step_y = grid_size_y + skipped_row_h;
 
@@ -1426,7 +1413,7 @@ impl Application for GraphApp {
                     let mut col = (nx - grid_origin_x) / step_x;
                     let mut row = (ny - grid_origin_y) / step_y;
 
-                    if self.graph.grid_snap_enabled() {
+                    if self.ui_context[self.graph].grid_snap_enabled() {
                         col = (col * 2.0).round() / 2.0;
                         row = (row * 2.0).round() / 2.0;
                     }
@@ -1482,11 +1469,11 @@ impl Application for GraphApp {
             return None;
         }
 
-        let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
-            || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.dropdown_view.hit_test(pos.x, pos.y, &self.ui_context)
-            || self.menu_bar.hit_test(pos.x, pos.y, &self.ui_context);
+        let over_menu = self.ui_context[self.dropdown_file].open || self.ui_context[self.dropdown_edit].open || self.ui_context[self.dropdown_view].open
+            || self.ui_context[self.dropdown_file].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.dropdown_edit].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.dropdown_view].hit_test(pos.x, pos.y, &self.ui_context)
+            || self.ui_context[self.menu_bar].hit_test(pos.x, pos.y, &self.ui_context);
 
         let ev = Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
         if over_menu {
@@ -1559,18 +1546,18 @@ impl Application for GraphApp {
                             // A press on a port can complete a wire drawn
                             // with the mouse; write it into the node.
                             if let Some((to, from, port)) =
-                                GraphController::take_pending_connection_to_port(&mut **self.graph)
+                                GraphController::take_pending_connection_to_port(&mut *self.ui_context[self.graph])
                             {
-                                let mut nodes = self.graph.get_nodes();
+                                let mut nodes = self.ui_context[self.graph].get_nodes();
                                 if wiring::connect(&mut nodes, &to, &from, port) {
-                                    self.graph.set_nodes(&nodes);
+                                    self.ui_context[self.graph].set_nodes(&nodes);
                                 }
                             }
                         } else if let Some(img_idx) = self.hit_test_image(pos.x, pos.y) {
                             let img = &self.loaded_images[img_idx];
-                            let (grid_origin_x, grid_origin_y) = self.graph.grid_origin();
-                            let (grid_size_x, grid_size_y) = self.graph.grid_sizes();
-                            let (skipped_row_h, skipped_col_w) = self.graph.skipped_sizes();
+                            let (grid_origin_x, grid_origin_y) = self.ui_context[self.graph].grid_origin();
+                            let (grid_size_x, grid_size_y) = self.ui_context[self.graph].grid_sizes();
+                            let (skipped_row_h, skipped_col_w) = self.ui_context[self.graph].skipped_sizes();
                             let step_x = grid_size_x + skipped_col_w;
                             let step_y = grid_size_y + skipped_row_h;
 
@@ -1581,11 +1568,11 @@ impl Application for GraphApp {
                             self.drag_image_ox = pos.x - img_screen_x;
                             self.drag_image_oy = pos.y - img_screen_y;
                             self.selected_image_idx = Some(img_idx);
-                            self.graph.set_selected_node(None);
+                            self.ui_context[self.graph].set_selected_node(None);
                             changed = true;
                         } else {
                             self.selected_image_idx = None;
-                            self.graph.set_selected_node(None);
+                            self.ui_context[self.graph].set_selected_node(None);
                             changed = true;
                         }
                     } else if state == ElementState::Released {
@@ -1636,11 +1623,11 @@ impl Application for GraphApp {
         // its mouse events above, drained through the same helpers. The
         // widget has handled these keys itself since cce-ui's routed events;
         // this app just never forwarded a key to it (the cce-files bug).
-        if self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open {
+        if self.ui_context[self.dropdown_file].open || self.ui_context[self.dropdown_edit].open || self.ui_context[self.dropdown_view].open {
             let kev = Event::KeyInput(event.clone());
-            let root = if self.dropdown_file.open {
+            let root = if self.ui_context[self.dropdown_file].open {
                 self.dropdown_file.id()
-            } else if self.dropdown_edit.open {
+            } else if self.ui_context[self.dropdown_edit].open {
                 self.dropdown_edit.id()
             } else {
                 self.dropdown_view.id()
diff --git a/src/vault.rs b/src/vault.rs
index 1af073b..0f37cc3 100644
--- a/src/vault.rs
+++ b/src/vault.rs
@@ -18,7 +18,7 @@ use std::path::PathBuf;
 use std::sync::OnceLock;
 use std::time::{Duration, Instant};
 
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::engine::{Application, CursorIcon, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
@@ -173,7 +173,7 @@ pub struct VaultApp {
     center: Option<String>,
     next_poll: Instant,
 
-    filter_input: Owned<Adapted<TextBox>>,
+    filter_input: Handle<Adapted<TextBox>>,
     filter_seen: String,
 
     /// World point at the middle of the graph area, and px per world unit.
@@ -228,10 +228,10 @@ impl VaultApp {
     }
 
     fn filter_query(&self) -> String {
-        if self.filter_input.editing {
-            self.filter_input.edit_buffer.clone()
+        if self.ui_context[self.filter_input].editing {
+            self.ui_context[self.filter_input].edit_buffer.clone()
         } else {
-            self.filter_input.text.clone()
+            self.ui_context[self.filter_input].text.clone()
         }
     }
 
@@ -387,7 +387,7 @@ impl VaultApp {
         if self.local {
             chip(pc, m.depth_chip, &format!("Depth {}", self.depth), false);
         }
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.filter_input, pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.filter_input], pc);
     }
 
     fn paint_status(&self, pc: &mut PaintCtx) {
@@ -553,6 +553,8 @@ impl Application for VaultApp {
         spawn_listener(sender.clone());
         let graph = index.as_ref().map(|ix| LinkGraph::build(ix, None)).unwrap_or_default();
         let n = graph.len();
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
         let mut app = VaultApp {
             index,
             error,
@@ -563,7 +565,7 @@ impl Application for VaultApp {
             depth: 1,
             center: None,
             next_poll: Instant::now(),
-            filter_input: Owned::new(TextBox::new(String::new()).with_placeholder("Filter: words, #tag, path:")),
+            filter_input: ui_context.insert(TextBox::new(String::new()).with_placeholder("Filter: words, #tag, path:")),
             filter_seen: String::new(),
             cam: (0.0, 0.0),
             zoom: 1.0,
@@ -576,7 +578,7 @@ impl Application for VaultApp {
             width: 1000,
             height: 700,
             needs_rebuild: true,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
             widgets_registered: false,
         };
         app.set_mode(args.local, args.note.clone());
@@ -636,7 +638,6 @@ impl Application for VaultApp {
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
         if !self.widgets_registered {
             self.widgets_registered = true;
-            self.ui_context.register_host(&mut self.filter_input);
         }
         let resized = self.width != size.width as u32 || self.height != size.height as u32;
         self.width = size.width as u32;
@@ -645,7 +646,7 @@ impl Application for VaultApp {
         let m = self.metrics();
         if self.needs_rebuild || resized {
             let f = m.filter;
-            self.filter_input.set_rect(f.x, f.y, f.width, f.height);
+            self.ui_context[self.filter_input].set_rect(f.x, f.y, f.width, f.height);
             self.ui_context.rebuild_spatial_grid();
             self.needs_rebuild = false;
         }
@@ -736,15 +737,15 @@ impl Application for VaultApp {
         let pressed = state == ElementState::Pressed;
 
         if m.filter.contains(s.0, s.1) {
-            if pressed && !self.filter_input.editing {
-                self.ui_context.set_focused(&mut self.filter_input);
-                WidgetHost::focus(&mut self.filter_input);
+            if pressed && !self.ui_context[self.filter_input].editing {
+                self.ui_context.set_focused_id(self.filter_input.id());
+                WidgetHost::focus(&mut self.ui_context[self.filter_input]);
             }
             self.ui_context.propagate_event(&ev, self.filter_input.id());
             return None;
         }
-        if pressed && self.filter_input.editing {
-            self.ui_context.unfocus_widget(&mut self.filter_input);
+        if pressed && self.ui_context[self.filter_input].editing {
+            self.ui_context.unfocus_id(self.filter_input.id());
         }
         if pressed && button == MouseButton::Left {
             if m.global_chip.contains(s.0, s.1) {
@@ -816,9 +817,9 @@ impl Application for VaultApp {
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
         *needs_rebuild = true;
         let pressed = event.state == ElementState::Pressed;
-        if self.filter_input.editing {
+        if self.ui_context[self.filter_input].editing {
             if pressed && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
-                self.ui_context.unfocus_widget(&mut self.filter_input);
+                self.ui_context.unfocus_id(self.filter_input.id());
                 return None;
             }
             let ev = Event::KeyInput(event.clone());
@@ -840,8 +841,8 @@ impl Application for VaultApp {
         } else if k("fit", "ctrl+0") {
             self.fit();
         } else if k("filter", "ctrl+f") {
-            self.ui_context.set_focused(&mut self.filter_input);
-            WidgetHost::focus(&mut self.filter_input);
+            self.ui_context.set_focused_id(self.filter_input.id());
+            WidgetHost::focus(&mut self.ui_context[self.filter_input]);
         } else if k("depth_more", "ctrl+=") || k("depth_more_plus", "ctrl+shift+=") {
             self.depth = (self.depth + 1).min(4);
             self.recompute_visible();