git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

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

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

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

 src/app.rs    | 34 +++++++++++++++++-----------------
 src/dialog.rs | 16 ++++++++--------
 src/slots.rs  |  8 ++++++--
 3 files changed, 31 insertions(+), 27 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 7cc7bb6..b0c10f4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -8177,37 +8177,37 @@ pub(crate) fn geometry_to_spreadsheet_columns(geom: &Detail) -> (Vec<String>, Ve
             "Main Menu".to_string(),
         ];
         let mut slots = Box::new(WidgetSlots {
-            header: MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Designer").with_label("Main Menu Bar").with_item("File", &["New Project", "Save", "Save As", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom", "Detach Circular Window", "Show Network Pane", "Show Viewport Pane", "Show Parameters Pane", "Show Spreadsheet Pane", "Show Playbar Pane"]).with_item("Help", &["About"]).with_z_index(110).with_context_options(context_opts.clone(), 4),
-            content: Graph::new(),
-            splitter1: Splitter::new(SPLITTER_W),
-            viewport: Viewport3D::new(),
-            splitter2: Splitter::new(SPLITTER_W),
-            param: ParametersBg::new(),
-            canvas: Canvas::new(),
-            left_menubar: MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("0: Network").with_label("Network Menu Bar").with_item("File", &["New", "Save", "Save As"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Circular Pane", "Detach Pane", "Close Pane"]).with_context_options(context_opts.clone(), 0),
-            right_menubar: MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("1: Viewport").with_label("Viewport Menu Bar").with_item("Camera", &["Perspective", "Orthographic"]).with_item("Display", &["square_aspect"]).with_item("Guides", &["Show Grid", "Origin", "Camera Pivot"]).with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 1),
-            param_menubar: MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("2: Parameters").with_label("Parameters Menu Bar").with_item("Preset", &["Default"]).with_item("Reset", &["All"]).with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 2),
-            status: StatusBar::new().with_text("Ready"),
+            header: cce_ui::widget::Owned::new(MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Designer").with_label("Main Menu Bar").with_item("File", &["New Project", "Save", "Save As", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom", "Detach Circular Window", "Show Network Pane", "Show Viewport Pane", "Show Parameters Pane", "Show Spreadsheet Pane", "Show Playbar Pane"]).with_item("Help", &["About"]).with_z_index(110).with_context_options(context_opts.clone(), 4)),
+            content: cce_ui::widget::Owned::new(Graph::new()),
+            splitter1: cce_ui::widget::Owned::new(Splitter::new(SPLITTER_W)),
+            viewport: cce_ui::widget::Owned::new(Viewport3D::new()),
+            splitter2: cce_ui::widget::Owned::new(Splitter::new(SPLITTER_W)),
+            param: cce_ui::widget::Owned::new(ParametersBg::new()),
+            canvas: cce_ui::widget::Owned::new(Canvas::new()),
+            left_menubar: cce_ui::widget::Owned::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("0: Network").with_label("Network Menu Bar").with_item("File", &["New", "Save", "Save As"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Circular Pane", "Detach Pane", "Close Pane"]).with_context_options(context_opts.clone(), 0)),
+            right_menubar: cce_ui::widget::Owned::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("1: Viewport").with_label("Viewport Menu Bar").with_item("Camera", &["Perspective", "Orthographic"]).with_item("Display", &["square_aspect"]).with_item("Guides", &["Show Grid", "Origin", "Camera Pivot"]).with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 1)),
+            param_menubar: cce_ui::widget::Owned::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("2: Parameters").with_label("Parameters Menu Bar").with_item("Preset", &["Default"]).with_item("Reset", &["All"]).with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 2)),
+            status: cce_ui::widget::Owned::new(StatusBar::new().with_text("Ready")),
             breadcrumb: {
                 let mut bc = Breadcrumb::new();
                 // Raised, not the default trough: the segments float in front
                 // of the network plate rather than reading as inset into it.
                 bc.set_raised(true);
-                bc
+                cce_ui::widget::Owned::new(bc)
             },
-            spreadsheet: Spreadsheet::new(),
+            spreadsheet: cce_ui::widget::Owned::new(Spreadsheet::new()),
             spreadsheet_menubar: {
                 let mut mb = MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("3: Spreadsheet").with_label("Spreadsheet Menu Bar").with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 3);
                 mb.set_visible(false);
-                mb
+                cce_ui::widget::Owned::new(mb)
             },
-            network_panel: PassivePlate::new(),
+            network_panel: cce_ui::widget::Owned::new(PassivePlate::new()),
             playbar: {
                 let mut pb = Playbar::new();
                 pb.set_visible(false);
-                pb
+                cce_ui::widget::Owned::new(pb)
             },
-            dialog: crate::dialog::Dialog::new(),
+            dialog: cce_ui::widget::Owned::new(crate::dialog::Dialog::new()),
         });
 
         slots.playbar.inner_mut().repeat = settings.playbar_repeat;
diff --git a/src/dialog.rs b/src/dialog.rs
index 2135e90..89330c7 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -345,7 +345,7 @@ pub struct Dialog {
     /// plate grows out of the trigger into the list and back. Boxed and
     /// never replaced, because the UI context holds a pointer to it while
     /// it is registered as an occluder.
-    pub dropdown: Box<Adapted<Dropdown>>,
+    pub dropdown: cce_ui::widget::Owned<Adapted<Dropdown>>,
     /// The row id the open dropdown serves.
     pub dropdown_row: Option<String>,
     /// Whether the dropdown was last seen expanded. The runner hands every
@@ -391,7 +391,7 @@ pub struct Dialog {
     /// not stamps, because each carries state of its own: a hex edit in
     /// progress, a picker process streaming values. Kept across
     /// re-rankings so a query that drops the row does not kill its picker.
-    colors: Vec<(String, Adapted<ColorSelector>)>,
+    colors: Vec<(String, cce_ui::widget::Owned<Adapted<ColorSelector>>)>,
     /// Colour rows the selectors changed, `(row id, hex)`, drained by the app.
     color_changes: Vec<(String, String)>,
 }
@@ -421,7 +421,7 @@ impl Dialog {
             hover_ctl: None,
             activated: None,
             dropdown_stamp: RefCell::new(Dropdown::new(Vec::new(), 0)),
-            dropdown: Box::new(Dropdown::new(Vec::new(), 0)),
+            dropdown: cce_ui::widget::Owned::new(Dropdown::new(Vec::new(), 0)),
             dropdown_row: None,
             dropdown_armed: false,
             occluding: true,
@@ -685,13 +685,13 @@ impl Dialog {
 
     /// The colour selector behind a colour row, if that row has one.
     pub fn color_selector(&self, id: &str) -> Option<&Adapted<ColorSelector>> {
-        self.colors.iter().find(|(k, _)| k == id).map(|(_, s)| s)
+        self.colors.iter().find(|(k, _)| k == id).map(|(_, s)| &**s)
     }
 
     /// The colour selector whose hex well is being typed into, if any — the
     /// app hands it the keyboard ahead of the filter.
     pub fn editing_color(&mut self) -> Option<&mut Adapted<ColorSelector>> {
-        self.colors.iter_mut().find(|(_, s)| s.inner().editing).map(|(_, s)| s)
+        self.colors.iter_mut().find(|(_, s)| s.inner().editing).map(|(_, s)| &mut **s)
     }
 
     pub fn take_color_changes(&mut self) -> Vec<(String, String)> {
@@ -708,7 +708,7 @@ impl Dialog {
             let k = match self.colors.iter().position(|(k, _)| *k == row.id) {
                 Some(k) => k,
                 None => {
-                    self.colors.push((row.id.clone(), ColorSelector::new([0; 3])));
+                    self.colors.push((row.id.clone(), cce_ui::widget::Owned::new(ColorSelector::new([0; 3]))));
                     self.colors.len() - 1
                 }
             };
@@ -2987,7 +2987,7 @@ impl State {
         // below, or dragging a value past the dialog's edge and letting go
         // would close the dialog instead of committing.
         if state == ElementState::Released && self.drag_widget == Some(DIALOG_IDX) {
-            let ptr = &mut self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
+            let ptr = &mut *self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
             unsafe {
                 (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context);
                 (*ptr).handle_event(
@@ -3019,7 +3019,7 @@ impl State {
         // wherever it goes until the release.
         if state == ElementState::Pressed && self.slots.dialog.slider_dragging() {
             let ev = cce_ui::widget::Event::DragStart { start_x: x, start_y: y };
-            let ptr = &mut self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
+            let ptr = &mut *self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
             unsafe {
                 (*ptr).handle_event(&ev, &mut self.ui_context);
             }
diff --git a/src/slots.rs b/src/slots.rs
index 98d1144..1265604 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -34,7 +34,7 @@ macro_rules! widget_roster {
         /// `get_dyn`/`get_dyn_mut` for the genuinely index-driven paths (draw order, focus cycling,
         /// broadcast loops); everything else reaches the concrete field.
         pub struct WidgetSlots {
-            $(pub $field: Adapted<$ty>,)+
+            $(pub $field: cce_ui::widget::Owned<Adapted<$ty>>,)+
         }
 
         impl WidgetSlots {
@@ -55,13 +55,17 @@ macro_rules! widget_roster {
                 }
             }
 
+            /// The slot's widget itself — the address the registry holds for it, which is what
+            /// `find_index` compares — not the `Owned` box around it.
             pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
                 match idx {
-                    $($idx => &self.$field,)+
+                    $($idx => &*self.$field,)+
                     _ => slot_out_of_range(idx),
                 }
             }
 
+            /// The slot's `Owned` box, so registering it records the stable address and the
+            /// box's liveness rather than the widget's own.
             pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
                 match idx {
                     $($idx => &mut self.$field,)+