git.lucas.co / cce-gallery
widget gallery and compositor test bench

commit28a52fb8ac9bcf065e578c8d1e75ed0cc486e68e
parent9282fd28ff
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 21:54
refactor: the roster names widgets the context owns

Gallery and child slots are handles (child slots as ids plus the Close button's handle); exhibits are inserted ids. The roster resolves slots through the context, and draggable / is_dragging / take_click go through the widget's input model. The header dropdowns' layout and the tick loop lend the widget. Shadow A/B identical in the gallery (two layouts, both styles) and the Ramp and Floating child windows.

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

 src/main.rs | 763 +++++++++++++++++++-----------------------------------------
 1 file changed, 239 insertions(+), 524 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index dd83bf6..130bea4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
-use cce_ui::widget::Owned;
+use cce_ui::context::UiContext;
+use cce_ui::widget::{Handle, WidgetId};
 use cce_ui::widget::{Button, Checkbox, ContentBg, Dropdown, Label, ProgressBar, RangeSlider, Slider, Spinbox, StatusBar, Toggle, WidgetHost, Trackpad, hover_animation, TextBox, MenuBar, Group, Ramp, RampKey, ColorRamp, MouseButton, ElementState, Key, NamedKey, KeyEvent, MouseScrollDelta, ColorSelector, FontSelector, KeybindRecorder, ButtonStrip, Float3, UsageBar, StatusDot, DotStatus, InfoBox, InteractiveListItem, Breadcrumb, TreeList, BevelPreview, RampPreview, Separator, Splitter, Paginator, VerticalLayout, ColumnsLayout, GridLayout, AdaptiveGridLayout, MosaicLayout, ReverseMosaicLayout, OverlayLayout, ScrollBox, WidgetHostExt};
 mod gallery_widgets;
 use gallery_widgets::{RootPlate, Plate};
@@ -156,47 +157,47 @@ impl Visibility {
 
 /// The gallery roster: 32 named slots. The numeric indexes used by the positions
 /// table, the visibility filter and the dispatch loops address these slots through
-/// `get_dyn`/`get_dyn_mut`.
+/// `Roster::id` / `get_dyn` / `get_dyn_mut`.
 pub struct GallerySlots {
-    pub menu_bar: Owned<Adapted<MenuBar>>,
-    pub status_bar: Owned<Adapted<StatusBar>>,
-    pub button_demo: Owned<Adapted<Button>>,
-    pub checkbox_demo: Owned<Adapted<Checkbox>>,
-    pub toggle_demo: Owned<Adapted<Toggle>>,
-    pub progress_demo: Owned<Adapted<ProgressBar>>,
-    pub slider_demo: Owned<Adapted<Slider>>,
-    pub spinbox_demo: Owned<Adapted<Spinbox>>,
-    pub range_slider_demo: Owned<Adapted<RangeSlider>>,
-    pub trackpad_demo: Owned<Adapted<Trackpad>>,
-    pub textbox_demo: Owned<Adapted<TextBox>>,
-    pub plate_demo: Owned<Adapted<Plate>>,
-    pub color_ramp_btn: Owned<Adapted<Button>>,
-    pub bevel_ramp: Owned<Adapted<Ramp>>,
-    pub ramp_btn: Owned<Adapted<Button>>,
-    pub layout_dd: Owned<Adapted<Dropdown>>,
-    pub color_selector_demo: Owned<Adapted<ColorSelector>>,
-    pub font_selector_demo: Owned<Adapted<FontSelector>>,
-    pub keybind_demo: Owned<Adapted<KeybindRecorder>>,
-    pub button_strip_demo: Owned<Adapted<ButtonStrip>>,
-    pub slider2d_demo: Owned<Adapted<Slider2D>>,
-    pub float3_demo: Owned<Adapted<Float3>>,
-    pub usage_bar_demo: Owned<Adapted<UsageBar>>,
-    pub status_dot_demo: Owned<Adapted<StatusDot>>,
-    pub info_box_demo: Owned<Adapted<InfoBox>>,
-    pub list_item_demo: Owned<Adapted<InteractiveListItem>>,
-    pub breadcrumb_demo: Owned<Adapted<Breadcrumb>>,
-    pub tree_list_demo: Owned<Adapted<TreeList>>,
-    pub bevel_preview_demo: Owned<Adapted<BevelPreview>>,
-    pub ramp_preview_demo: Owned<Adapted<RampPreview>>,
-    pub separator_demo: Owned<Adapted<Separator>>,
-    pub splitter_demo: Owned<Adapted<Splitter>>,
+    pub menu_bar: Handle<Adapted<MenuBar>>,
+    pub status_bar: Handle<Adapted<StatusBar>>,
+    pub button_demo: Handle<Adapted<Button>>,
+    pub checkbox_demo: Handle<Adapted<Checkbox>>,
+    pub toggle_demo: Handle<Adapted<Toggle>>,
+    pub progress_demo: Handle<Adapted<ProgressBar>>,
+    pub slider_demo: Handle<Adapted<Slider>>,
+    pub spinbox_demo: Handle<Adapted<Spinbox>>,
+    pub range_slider_demo: Handle<Adapted<RangeSlider>>,
+    pub trackpad_demo: Handle<Adapted<Trackpad>>,
+    pub textbox_demo: Handle<Adapted<TextBox>>,
+    pub plate_demo: Handle<Adapted<Plate>>,
+    pub color_ramp_btn: Handle<Adapted<Button>>,
+    pub bevel_ramp: Handle<Adapted<Ramp>>,
+    pub ramp_btn: Handle<Adapted<Button>>,
+    pub layout_dd: Handle<Adapted<Dropdown>>,
+    pub color_selector_demo: Handle<Adapted<ColorSelector>>,
+    pub font_selector_demo: Handle<Adapted<FontSelector>>,
+    pub keybind_demo: Handle<Adapted<KeybindRecorder>>,
+    pub button_strip_demo: Handle<Adapted<ButtonStrip>>,
+    pub slider2d_demo: Handle<Adapted<Slider2D>>,
+    pub float3_demo: Handle<Adapted<Float3>>,
+    pub usage_bar_demo: Handle<Adapted<UsageBar>>,
+    pub status_dot_demo: Handle<Adapted<StatusDot>>,
+    pub info_box_demo: Handle<Adapted<InfoBox>>,
+    pub list_item_demo: Handle<Adapted<InteractiveListItem>>,
+    pub breadcrumb_demo: Handle<Adapted<Breadcrumb>>,
+    pub tree_list_demo: Handle<Adapted<TreeList>>,
+    pub bevel_preview_demo: Handle<Adapted<BevelPreview>>,
+    pub ramp_preview_demo: Handle<Adapted<RampPreview>>,
+    pub separator_demo: Handle<Adapted<Separator>>,
+    pub splitter_demo: Handle<Adapted<Splitter>>,
     /// Two `Group` lassos over other exhibits (slots 32 and 33): overlays, not
     /// exhibits — laid out by their members, drawn under the exhibit clip.
-    pub group_loose: Owned<Adapted<Group>>,
-    pub group_fitted: Owned<Adapted<Group>>,
+    pub group_loose: Handle<Adapted<Group>>,
+    pub group_fitted: Handle<Adapted<Group>>,
     /// The Style dropdown (slot 34), beside Layout in the header: Relief or Flat
     /// for every control at once (`cce_ui::layout::set_control_relief`).
-    pub style_dd: Owned<Adapted<Dropdown>>,
+    pub style_dd: Handle<Adapted<Dropdown>>,
     /// The variant exhibits (`variant_exhibits`): every further style of a widget one of
     /// the named slots already shows, addressed as slots `GALLERY_COUNT..`.
     pub extra: Vec<Exhibit>,
@@ -209,7 +210,8 @@ pub const GALLERY_COUNT: usize = 35;
 /// A variant exhibit: a widget in a style other than the one its named slot shows, with
 /// the content size `layout_exhibits` resets it to.
 pub struct Exhibit {
-    pub widget: Box<dyn WidgetHost + 'static>,
+    /// The widget, which the context owns.
+    pub id: WidgetId,
     pub w: f32,
     pub h: f32,
     /// A content width narrower than `w`: the strategy lays the exhibit out at `w` (room
@@ -219,15 +221,15 @@ pub struct Exhibit {
 
 impl Exhibit {
     /// Sized by the widget's own preferred (content) height, `fallback_h` when it declares none.
-    fn new<W: WidgetHost + 'static>(widget: W, w: f32, fallback_h: f32) -> Self {
+    fn new<W: WidgetHost + 'static>(ctx: &mut UiContext, widget: W, w: f32, fallback_h: f32) -> Self {
         let h = widget.preferred_height().unwrap_or(fallback_h);
-        Exhibit { widget: Box::new(Owned::new(widget)), w, h, content_w: None }
+        Exhibit { id: ctx.insert(widget).id(), w, h, content_w: None }
     }
 
     /// Sized by hand: for widgets whose declared height is a single row (a multiline
     /// TextBox, a vertical ButtonStrip) when the exhibit wants several.
-    fn sized<W: WidgetHost + 'static>(widget: W, w: f32, h: f32) -> Self {
-        Exhibit { widget: Box::new(Owned::new(widget)), w, h, content_w: None }
+    fn sized<W: WidgetHost + 'static>(ctx: &mut UiContext, widget: W, w: f32, h: f32) -> Self {
+        Exhibit { id: ctx.insert(widget).id(), w, h, content_w: None }
     }
 
     fn with_content_width(mut self, w: f32) -> Self {
@@ -237,315 +239,62 @@ impl Exhibit {
 }
 
 impl GallerySlots {
-
-    // Per-slot drag queries.
-    pub fn draggable(&self, idx: usize) -> bool {
-        if idx >= GALLERY_COUNT {
-            return false; // variant exhibits never drag
-        }
-        match idx {
-            0 => self.menu_bar.draggable(),
-            1 => self.status_bar.draggable(),
-            2 => self.button_demo.draggable(),
-            3 => self.checkbox_demo.draggable(),
-            4 => self.toggle_demo.draggable(),
-            5 => self.progress_demo.draggable(),
-            6 => self.slider_demo.draggable(),
-            7 => self.spinbox_demo.draggable(),
-            8 => self.range_slider_demo.draggable(),
-            9 => self.trackpad_demo.draggable(),
-            10 => self.textbox_demo.draggable(),
-            11 => self.plate_demo.draggable(),
-            12 => self.color_ramp_btn.draggable(),
-            13 => self.bevel_ramp.draggable(),
-            14 => self.ramp_btn.draggable(),
-            15 => self.layout_dd.draggable(),
-            34 => self.style_dd.draggable(),
-            16 => self.color_selector_demo.draggable(),
-            17 => self.font_selector_demo.draggable(),
-            18 => self.keybind_demo.draggable(),
-            19 => self.button_strip_demo.draggable(),
-            20 => self.slider2d_demo.draggable(),
-            21 => self.float3_demo.draggable(),
-            22 => self.usage_bar_demo.draggable(),
-            23 => self.status_dot_demo.draggable(),
-            24 => self.info_box_demo.draggable(),
-            25 => self.list_item_demo.draggable(),
-            26 => self.breadcrumb_demo.draggable(),
-            27 => self.tree_list_demo.draggable(),
-            28 => self.bevel_preview_demo.draggable(),
-            29 => self.ramp_preview_demo.draggable(),
-            30 => self.separator_demo.draggable(),
-            31 => self.splitter_demo.draggable(),
-            32 | 33 => false,
-            _ => panic!("gallery slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn is_dragging(&self, idx: usize) -> bool {
-        if idx >= GALLERY_COUNT {
-            return false;
-        }
-        match idx {
-            0 => self.menu_bar.is_dragging(),
-            1 => self.status_bar.is_dragging(),
-            2 => self.button_demo.is_dragging(),
-            3 => self.checkbox_demo.is_dragging(),
-            4 => self.toggle_demo.is_dragging(),
-            5 => self.progress_demo.is_dragging(),
-            6 => self.slider_demo.is_dragging(),
-            7 => self.spinbox_demo.is_dragging(),
-            8 => self.range_slider_demo.is_dragging(),
-            9 => self.trackpad_demo.is_dragging(),
-            10 => self.textbox_demo.is_dragging(),
-            11 => self.plate_demo.is_dragging(),
-            12 => self.color_ramp_btn.is_dragging(),
-            13 => self.bevel_ramp.is_dragging(),
-            14 => self.ramp_btn.is_dragging(),
-            15 => self.layout_dd.is_dragging(),
-            34 => self.style_dd.is_dragging(),
-            16 => self.color_selector_demo.is_dragging(),
-            17 => self.font_selector_demo.is_dragging(),
-            18 => self.keybind_demo.is_dragging(),
-            19 => self.button_strip_demo.is_dragging(),
-            20 => self.slider2d_demo.is_dragging(),
-            21 => self.float3_demo.is_dragging(),
-            22 => self.usage_bar_demo.is_dragging(),
-            23 => self.status_dot_demo.is_dragging(),
-            24 => self.info_box_demo.is_dragging(),
-            25 => self.list_item_demo.is_dragging(),
-            26 => self.breadcrumb_demo.is_dragging(),
-            27 => self.tree_list_demo.is_dragging(),
-            28 => self.bevel_preview_demo.is_dragging(),
-            29 => self.ramp_preview_demo.is_dragging(),
-            30 => self.separator_demo.is_dragging(),
-            31 => self.splitter_demo.is_dragging(),
-            32 | 33 => false,
-            _ => panic!("gallery slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
-        if idx >= GALLERY_COUNT {
-            return &*self.extra[idx - GALLERY_COUNT].widget;
-        }
-        match idx {
-            0 => &self.menu_bar,
-            1 => &self.status_bar,
-            2 => &self.button_demo,
-            3 => &self.checkbox_demo,
-            4 => &self.toggle_demo,
-            5 => &self.progress_demo,
-            6 => &self.slider_demo,
-            7 => &self.spinbox_demo,
-            8 => &self.range_slider_demo,
-            9 => &self.trackpad_demo,
-            10 => &self.textbox_demo,
-            11 => &self.plate_demo,
-            12 => &self.color_ramp_btn,
-            13 => &self.bevel_ramp,
-            14 => &self.ramp_btn,
-            15 => &self.layout_dd,
-            16 => &self.color_selector_demo,
-            17 => &self.font_selector_demo,
-            18 => &self.keybind_demo,
-            19 => &self.button_strip_demo,
-            20 => &self.slider2d_demo,
-            21 => &self.float3_demo,
-            22 => &self.usage_bar_demo,
-            23 => &self.status_dot_demo,
-            24 => &self.info_box_demo,
-            25 => &self.list_item_demo,
-            26 => &self.breadcrumb_demo,
-            27 => &self.tree_list_demo,
-            28 => &self.bevel_preview_demo,
-            29 => &self.ramp_preview_demo,
-            30 => &self.separator_demo,
-            31 => &self.splitter_demo,
-            32 => &self.group_loose,
-            33 => &self.group_fitted,
-            34 => &self.style_dd,
-            _ => panic!("gallery slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
+    /// The widget in slot `idx`.
+    pub fn id(&self, idx: usize) -> WidgetId {
         if idx >= GALLERY_COUNT {
-            return &mut *self.extra[idx - GALLERY_COUNT].widget;
+            return self.extra[idx - GALLERY_COUNT].id;
         }
         match idx {
-            0 => &mut self.menu_bar,
-            1 => &mut self.status_bar,
-            2 => &mut self.button_demo,
-            3 => &mut self.checkbox_demo,
-            4 => &mut self.toggle_demo,
-            5 => &mut self.progress_demo,
-            6 => &mut self.slider_demo,
-            7 => &mut self.spinbox_demo,
-            8 => &mut self.range_slider_demo,
-            9 => &mut self.trackpad_demo,
-            10 => &mut self.textbox_demo,
-            11 => &mut self.plate_demo,
-            12 => &mut self.color_ramp_btn,
-            13 => &mut self.bevel_ramp,
-            14 => &mut self.ramp_btn,
-            15 => &mut self.layout_dd,
-            16 => &mut self.color_selector_demo,
-            17 => &mut self.font_selector_demo,
-            18 => &mut self.keybind_demo,
-            19 => &mut self.button_strip_demo,
-            20 => &mut self.slider2d_demo,
-            21 => &mut self.float3_demo,
-            22 => &mut self.usage_bar_demo,
-            23 => &mut self.status_dot_demo,
-            24 => &mut self.info_box_demo,
-            25 => &mut self.list_item_demo,
-            26 => &mut self.breadcrumb_demo,
-            27 => &mut self.tree_list_demo,
-            28 => &mut self.bevel_preview_demo,
-            29 => &mut self.ramp_preview_demo,
-            30 => &mut self.separator_demo,
-            31 => &mut self.splitter_demo,
-            32 => &mut self.group_loose,
-            33 => &mut self.group_fitted,
-            34 => &mut self.style_dd,
+            0 => self.menu_bar.id(),
+            1 => self.status_bar.id(),
+            2 => self.button_demo.id(),
+            3 => self.checkbox_demo.id(),
+            4 => self.toggle_demo.id(),
+            5 => self.progress_demo.id(),
+            6 => self.slider_demo.id(),
+            7 => self.spinbox_demo.id(),
+            8 => self.range_slider_demo.id(),
+            9 => self.trackpad_demo.id(),
+            10 => self.textbox_demo.id(),
+            11 => self.plate_demo.id(),
+            12 => self.color_ramp_btn.id(),
+            13 => self.bevel_ramp.id(),
+            14 => self.ramp_btn.id(),
+            15 => self.layout_dd.id(),
+            16 => self.color_selector_demo.id(),
+            17 => self.font_selector_demo.id(),
+            18 => self.keybind_demo.id(),
+            19 => self.button_strip_demo.id(),
+            20 => self.slider2d_demo.id(),
+            21 => self.float3_demo.id(),
+            22 => self.usage_bar_demo.id(),
+            23 => self.status_dot_demo.id(),
+            24 => self.info_box_demo.id(),
+            25 => self.list_item_demo.id(),
+            26 => self.breadcrumb_demo.id(),
+            27 => self.tree_list_demo.id(),
+            28 => self.bevel_preview_demo.id(),
+            29 => self.ramp_preview_demo.id(),
+            30 => self.separator_demo.id(),
+            31 => self.splitter_demo.id(),
+            32 => self.group_loose.id(),
+            33 => self.group_fitted.id(),
+            34 => self.style_dd.id(),
             _ => panic!("gallery slot index out of range: {idx}"),
         }
     }
 }
 
-pub enum ChildBg {
-    RootPlate(Adapted<RootPlate>),
-    ContentBg(Adapted<ContentBg>),
-}
-
-pub enum ChildMain {
-    ColorRamp(Adapted<ColorRamp>),
-    Ramp(Adapted<Ramp>),
-    Desc(Adapted<Label>),
-}
-
-pub enum ChildAux3 {
-    Label(Adapted<Label>),
-    MenuBar(Adapted<MenuBar>),
-}
-
-pub enum ChildAux4 {
-    Label(Adapted<Label>),
-    StatusBar(Adapted<StatusBar>),
-}
-
+/// A child window's five slots — background, main, Close, and the optional menu bar
+/// and status bar rows (3, 4) — as the ids of the widgets the context owns.
 pub struct ChildSlots {
-    pub bg: ChildBg,
-    pub main: ChildMain,
-    pub close: Owned<Adapted<Button>>,
-    pub aux3: ChildAux3,
-    pub aux4: ChildAux4,
+    pub ids: [WidgetId; CHILD_COUNT],
+    pub close: Handle<Adapted<Button>>,
 }
 
 pub const CHILD_COUNT: usize = 5;
 
-impl ChildSlots {
-
-    pub fn draggable(&self, idx: usize) -> bool {
-        match idx {
-            0 => match &self.bg {
-                ChildBg::RootPlate(w) => w.draggable(),
-                ChildBg::ContentBg(w) => w.draggable(),
-            },
-            1 => match &self.main {
-                ChildMain::ColorRamp(w) => w.draggable(),
-                ChildMain::Ramp(w) => w.draggable(),
-                ChildMain::Desc(w) => w.draggable(),
-            },
-            2 => self.close.draggable(),
-            3 => match &self.aux3 {
-                ChildAux3::Label(w) => w.draggable(),
-                ChildAux3::MenuBar(w) => w.draggable(),
-            },
-            4 => match &self.aux4 {
-                ChildAux4::Label(w) => w.draggable(),
-                ChildAux4::StatusBar(w) => w.draggable(),
-            },
-            _ => panic!("child slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn is_dragging(&self, idx: usize) -> bool {
-        match idx {
-            0 => match &self.bg {
-                ChildBg::RootPlate(w) => w.is_dragging(),
-                ChildBg::ContentBg(w) => w.is_dragging(),
-            },
-            1 => match &self.main {
-                ChildMain::ColorRamp(w) => w.is_dragging(),
-                ChildMain::Ramp(w) => w.is_dragging(),
-                ChildMain::Desc(w) => w.is_dragging(),
-            },
-            2 => self.close.is_dragging(),
-            3 => match &self.aux3 {
-                ChildAux3::Label(w) => w.is_dragging(),
-                ChildAux3::MenuBar(w) => w.is_dragging(),
-            },
-            4 => match &self.aux4 {
-                ChildAux4::Label(w) => w.is_dragging(),
-                ChildAux4::StatusBar(w) => w.is_dragging(),
-            },
-            _ => panic!("child slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
-        match idx {
-            0 => match &self.bg {
-                ChildBg::RootPlate(w) => w,
-                ChildBg::ContentBg(w) => w,
-            },
-            1 => match &self.main {
-                ChildMain::ColorRamp(w) => w,
-                ChildMain::Ramp(w) => w,
-                ChildMain::Desc(w) => w,
-            },
-            2 => &self.close,
-            3 => match &self.aux3 {
-                ChildAux3::Label(w) => w,
-                ChildAux3::MenuBar(w) => w,
-            },
-            4 => match &self.aux4 {
-                ChildAux4::Label(w) => w,
-                ChildAux4::StatusBar(w) => w,
-            },
-            _ => panic!("child slot index out of range: {idx}"),
-        }
-    }
-
-    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
-        match idx {
-            0 => match &mut self.bg {
-                ChildBg::RootPlate(w) => w,
-                ChildBg::ContentBg(w) => w,
-            },
-            1 => match &mut self.main {
-                ChildMain::ColorRamp(w) => w,
-                ChildMain::Ramp(w) => w,
-                ChildMain::Desc(w) => w,
-            },
-            2 => &mut self.close,
-            3 => match &mut self.aux3 {
-                ChildAux3::Label(w) => w,
-                ChildAux3::MenuBar(w) => w,
-            },
-            4 => match &mut self.aux4 {
-                ChildAux4::Label(w) => w,
-                ChildAux4::StatusBar(w) => w,
-            },
-            _ => panic!("child slot index out of range: {idx}"),
-        }
-    }
-}
-
-/// The two roster modes. Boxed slot structs keep registered widget pointers stable while
-/// the containing `State` moves.
+/// The two roster modes. The widgets are the context's; a roster names them by slot.
 pub enum Roster {
     Gallery(Box<GallerySlots>),
     Child(Box<ChildSlots>),
@@ -559,102 +308,63 @@ impl Roster {
         }
     }
 
-    pub fn get_dyn(&self, idx: usize) -> &(dyn WidgetHost + 'static) {
+    /// The widget in slot `idx`.
+    pub fn id(&self, idx: usize) -> WidgetId {
         match self {
-            Roster::Gallery(s) => s.get_dyn(idx),
-            Roster::Child(s) => s.get_dyn(idx),
+            Roster::Gallery(s) => s.id(idx),
+            Roster::Child(s) => *s.ids.get(idx).unwrap_or_else(|| panic!("child slot index out of range: {idx}")),
         }
     }
 
-    pub fn draggable(&self, idx: usize) -> bool {
-        match self {
-            Roster::Gallery(s) => s.draggable(idx),
-            Roster::Child(s) => s.draggable(idx),
-        }
+    pub fn get_dyn<'a>(&self, ui: &'a UiContext, idx: usize) -> &'a (dyn WidgetHost + 'static) {
+        ui.get_widget(self.id(idx)).expect("a roster slot's widget is in the context")
     }
 
-    pub fn is_dragging(&self, idx: usize) -> bool {
-        match self {
-            Roster::Gallery(s) => s.is_dragging(idx),
-            Roster::Child(s) => s.is_dragging(idx),
-        }
+    pub fn get_dyn_mut<'a>(&self, ui: &'a mut UiContext, idx: usize) -> &'a mut (dyn WidgetHost + 'static) {
+        ui.get_widget_mut(self.id(idx)).expect("a roster slot's widget is in the context")
     }
 
-    pub fn get_dyn_mut(&mut self, idx: usize) -> &mut (dyn WidgetHost + 'static) {
-        match self {
-            Roster::Gallery(s) => s.get_dyn_mut(idx),
-            Roster::Child(s) => s.get_dyn_mut(idx),
-        }
+    /// A slot that is never dragged or drained: the variant exhibits are looked at, and
+    /// the lassos (32, 33) are frames.
+    fn inert(&self, idx: usize) -> bool {
+        matches!(self, Roster::Gallery(_)) && (idx >= GALLERY_COUNT || matches!(idx, 32 | 33))
     }
 
-    /// The gallery slots; panics in child mode (gallery-only paths assert their mode).
-    pub fn gallery(&self) -> &GallerySlots {
-        match self {
-            Roster::Gallery(s) => s,
-            Roster::Child(_) => panic!("gallery slots requested in child mode"),
+    pub fn draggable(&self, ui: &UiContext, idx: usize) -> bool {
+        if self.inert(idx) {
+            return false;
         }
+        let w = self.get_dyn(ui, idx);
+        w.input_model().draggable(w.content_rect())
+    }
+
+    pub fn is_dragging(&self, ui: &UiContext, idx: usize) -> bool {
+        !self.inert(idx) && self.get_dyn(ui, idx).input_model().is_dragging()
     }
 
-    pub fn gallery_mut(&mut self) -> &mut GallerySlots {
+    /// The gallery slots; panics in child mode (gallery-only paths assert their mode).
+    pub fn gallery(&self) -> &GallerySlots {
         match self {
             Roster::Gallery(s) => s,
             Roster::Child(_) => panic!("gallery slots requested in child mode"),
         }
     }
 
-    // --- Value drains: route a slot index to its concrete slot's `take_click` /
-    // `value`. Arms exist for every slot the page logic drains.
+    // --- Value drains: route a slot index to its widget's `take_click` / `value`.
 
-    pub fn take_click(&mut self, idx: usize) -> bool {
-        let s = match self {
-            Roster::Gallery(s) => s,
+    pub fn take_click(&self, ui: &mut UiContext, idx: usize) -> bool {
+        match self {
             // The child roster drains one slot: its Close button.
-            Roster::Child(c) => return idx == 2 && c.close.take_click(),
-        };
-        match idx {
-            2 => s.button_demo.take_click(),
-            3 => s.checkbox_demo.take_click(),
-            4 => s.toggle_demo.take_click(),
-            5 => s.progress_demo.take_click(),
-            6 => s.slider_demo.take_click(),
-            7 => s.spinbox_demo.take_click(),
-            8 => s.range_slider_demo.take_click(),
-            9 => s.trackpad_demo.take_click(),
-            10 => s.textbox_demo.take_click(),
-            11 => s.plate_demo.take_click(),
-            12 => s.color_ramp_btn.take_click(),
-            13 => s.bevel_ramp.take_click(),
-            14 => s.ramp_btn.take_click(),
-            15 => s.layout_dd.take_click(),
-            34 => s.style_dd.take_click(),
-            16 => s.color_selector_demo.take_click(),
-            17 => s.font_selector_demo.take_click(),
-            18 => s.keybind_demo.take_click(),
-            19 => s.button_strip_demo.take_click(),
-            20 => s.slider2d_demo.take_click(),
-            21 => s.float3_demo.take_click(),
-            22 => s.usage_bar_demo.take_click(),
-            23 => s.status_dot_demo.take_click(),
-            24 => s.info_box_demo.take_click(),
-            25 => s.list_item_demo.take_click(),
-            26 => s.breadcrumb_demo.take_click(),
-            27 => s.tree_list_demo.take_click(),
-            28 => s.bevel_preview_demo.take_click(),
-            29 => s.ramp_preview_demo.take_click(),
-            30 => s.separator_demo.take_click(),
-            31 => s.splitter_demo.take_click(),
-            32 | 33 => false,
-            // The variant exhibits are looked at, not drained.
-            i if i >= GALLERY_COUNT => false,
-            _ => panic!("take_click: unwired gallery slot {idx}"),
+            Roster::Child(c) => idx == 2 && ui[c.close].take_click(),
+            Roster::Gallery(_) => !self.inert(idx) && self.get_dyn_mut(ui, idx).input_model_mut().take_click(),
         }
     }
 
-    pub fn value(&self, idx: usize) -> i32 {
+    pub fn value(&self, ui: &UiContext, idx: usize) -> i32 {
         let s = self.gallery();
         match idx {
-            15 => s.layout_dd.value(),
-            34 => s.style_dd.value(),
+            15 => ui[s.layout_dd].value(),
+            34 => ui[s.style_dd].value(),
             _ => panic!("value: unwired gallery slot {idx}"),
         }
     }
@@ -747,7 +457,7 @@ fn load_bevel_ramp() -> (Vec<RampKey>, String) {
 /// The relief-off look is NOT a variant here: the header's Style dropdown switches
 /// every control between Relief and Flat at once (`set_control_relief`), so each
 /// exhibit shows both, and no widget appears twice for its style alone.
-fn variant_exhibits() -> Vec<Exhibit> {
+fn variant_exhibits(ctx: &mut UiContext) -> Vec<Exhibit> {
     const W: f32 = 190.0;
     let bh = cce_ui::layout::button_height();
     let slh = cce_ui::layout::slider_height();
@@ -762,39 +472,41 @@ fn variant_exhibits() -> Vec<Exhibit> {
     let three = || vec!["One".to_string(), "Two".to_string(), "Three".to_string()];
     vec![
         // Button: the other four kinds.
-        Exhibit::new(Button::new_reset(0.0, 0.0, W, bh).with_label("Button (reset)"), W, bh),
-        Exhibit::new(Button::new_list_row(0.0, 0.0, W, bh).with_label("Button (list row)"), W, bh),
-        Exhibit::new(Button::new_menu_item(0.0, 0.0, W, bh).with_label("Button (menu item)"), W, bh),
-        Exhibit::new(Button::new_copy_icon(0.0, 0.0, bh, bh), bh, bh),
+        Exhibit::new(ctx, Button::new_reset(0.0, 0.0, W, bh).with_label("Button (reset)"), W, bh),
+        Exhibit::new(ctx, Button::new_list_row(0.0, 0.0, W, bh).with_label("Button (list row)"), W, bh),
+        Exhibit::new(ctx, Button::new_menu_item(0.0, 0.0, W, bh).with_label("Button (menu item)"), W, bh),
+        Exhibit::new(ctx, Button::new_copy_icon(0.0, 0.0, bh, bh), bh, bh),
         // Slider: with a readout.
-        Exhibit::new(Slider::new().with_label("Slider (readout)").with_readout(true), W, slh),
+        Exhibit::new(ctx, Slider::new().with_label("Slider (readout)").with_readout(true), W, slh),
         // TextBox: multiline, chromeless, password.
         Exhibit::sized(
+            ctx,
             TextBox::new("TextBox (multiline)\nA second line of text.".to_string()).with_multiline(true),
             W,
             3.0 * tbh,
         ),
-        Exhibit::new(TextBox::new("TextBox (chromeless)".to_string()).with_draw_bg_border(false), W, tbh),
-        Exhibit::new(TextBox::new("hunter2".to_string()).with_password(true).with_label("TextBox (password)"), W, tbh),
+        Exhibit::new(ctx, TextBox::new("TextBox (chromeless)".to_string()).with_draw_bg_border(false), W, tbh),
+        Exhibit::new(ctx, TextBox::new("hunter2".to_string()).with_password(true).with_label("TextBox (password)"), W, tbh),
         // ButtonStrip: the vertical column (rotated tabs), and the Paginator sidebar built on it.
         Exhibit::sized(
+            ctx,
             Adapted::new(ButtonStrip::new(0.0, 0.0, W, TABS_H).with_buttons(three()).with_selected(Some(0)).with_vertical(true))
                 .with_label("ButtonStrip (vertical)"),
             W,
             TABS_H,
         )
         .with_content_width(TAB_COLUMN_W),
-        Exhibit::sized(Paginator::new(three()).with_label("Paginator"), W, TABS_H),
+        Exhibit::sized(ctx, Paginator::new(three()).with_label("Paginator"), W, TABS_H),
         // ColorSelector: the alpha swatch.
-        Exhibit::new(ColorSelector::new_rgba([64, 128, 255, 128]).with_label("ColorSelector (alpha)"), W, csh),
+        Exhibit::new(ctx, ColorSelector::new_rgba([64, 128, 255, 128]).with_label("ColorSelector (alpha)"), W, csh),
         // Label: the plain text widget.
-        Exhibit::new(Label::new("Label"), W, ddh),
+        Exhibit::new(ctx, Label::new("Label"), W, ddh),
         // StatusDot: the other three statuses.
-        Exhibit::new(StatusDot::new(DotStatus::Inactive).with_label("StatusDot (inactive)"), DOT_W, StatusDot::SIZE)
+        Exhibit::new(ctx, StatusDot::new(DotStatus::Inactive).with_label("StatusDot (inactive)"), DOT_W, StatusDot::SIZE)
             .with_content_width(StatusDot::SIZE),
-        Exhibit::new(StatusDot::new(DotStatus::Warning).with_label("StatusDot (warning)"), DOT_W, StatusDot::SIZE)
+        Exhibit::new(ctx, StatusDot::new(DotStatus::Warning).with_label("StatusDot (warning)"), DOT_W, StatusDot::SIZE)
             .with_content_width(StatusDot::SIZE),
-        Exhibit::new(StatusDot::new(DotStatus::Error).with_label("StatusDot (error)"), DOT_W, StatusDot::SIZE)
+        Exhibit::new(ctx, StatusDot::new(DotStatus::Error).with_label("StatusDot (error)"), DOT_W, StatusDot::SIZE)
             .with_content_width(StatusDot::SIZE),
     ]
 }
@@ -860,7 +572,7 @@ impl State {
         const W: f32 = 190.0;
         let s2d_w = self
             .roster
-            .get_dyn(20)
+            .get_dyn(&self.ui_context, 20)
             .measure(LayoutConstraints::new(0.0, f32::MAX, 0.0, f32::MAX), &self.ui_context)
             .width;
         // A StatusDot is 12px square; its exhibit is as wide as its label
@@ -911,7 +623,7 @@ impl State {
     /// dropdown's), one root gap below them and one root gap above the status band.
     fn exhibit_viewport(&self) -> (f32, f32, f32, f32) {
         let gap = cce_ui::layout::root_plate_gap();
-        let (dx, dy, _, dh) = self.roster.get_dyn(15).rect();
+        let (dx, dy, _, dh) = self.roster.get_dyn(&self.ui_context, 15).rect();
         let (x, y) = (dx, dy + dh + gap);
         let w = (self.width - 2.0 * x).max(300.0);
         let h = ((self.height - 24.0 - gap) - y).max(100.0);
@@ -954,15 +666,15 @@ impl State {
         // plate is that floor, so its sides snap to the foot of the wall.
         let (floor, floor_r) = self.exhibit_floor();
         if let Roster::Gallery(s) = &mut self.roster {
-            s.group_fitted.inner_mut().set_plate(floor, floor_r);
+            self.ui_context[s.group_fitted].inner_mut().set_plate(floor, floor_r);
         }
         let mut children: Vec<*mut (dyn WidgetHost + 'static)> = Vec::new();
         let mut indices: Vec<usize> = Vec::new();
         for (idx, cw, ch) in self.exhibit_sizes() {
-            if self.roster.is_dragging(idx) {
+            if self.roster.is_dragging(&self.ui_context, idx) {
                 continue;
             }
-            let widget = self.roster.get_dyn_mut(idx);
+            let widget = self.roster.get_dyn_mut(&mut self.ui_context, idx);
             // Seed the block: the content height plus the label strip above it.
             let strip = widget.label_strip();
             widget.set_rect(0.0, 0.0, cw, ch + strip);
@@ -977,7 +689,7 @@ impl State {
         // the frame's wall on its left edge and the tab over its labels.
         let (inset_x, inset_top) = match &self.roster {
             Roster::Gallery(s) => {
-                let g = s.group_fitted.inner();
+                let g = self.ui_context[s.group_fitted].inner();
                 (2.0 * g.padding(), g.padding() + g.headroom())
             }
             _ => (0.0, 0.0),
@@ -1019,9 +731,6 @@ impl State {
     /// resolves roots through this registry; the gallery's own paint loop never goes
     /// through `render_widget`, where other apps pick registration up as a side effect.
     fn register_roster(&mut self) {
-        for i in 0..self.roster.len() {
-            self.ui_context.register_host(self.roster.get_dyn_mut(i));
-        }
     }
 
     /// Drain the header dropdowns' selections and apply them: Layout (15) picks the
@@ -1031,12 +740,12 @@ impl State {
     /// keyboard too (Down, Enter), and a selection must not wait for the next click.
     fn apply_header_dropdowns(&mut self) -> bool {
         let mut applied = false;
-        if self.roster.take_click(15) {
-            self.layout_idx = self.roster.value(15) as usize;
+        if self.roster.take_click(&mut self.ui_context, 15) {
+            self.layout_idx = self.roster.value(&self.ui_context, 15) as usize;
             applied = true;
         }
-        if self.roster.take_click(34) {
-            cce_ui::layout::set_control_relief(self.roster.value(34) == 0);
+        if self.roster.take_click(&mut self.ui_context, 34) {
+            cce_ui::layout::set_control_relief(self.roster.value(&self.ui_context, 34) == 0);
             applied = true;
         }
         if applied {
@@ -1048,7 +757,7 @@ impl State {
 
     fn apply_layout(&mut self) {
         for i in 0..self.roster.len() {
-            if self.roster.is_dragging(i) {
+            if self.roster.is_dragging(&self.ui_context, i) {
                 continue;
             }
             // The exhibits are laid out by layout_exhibits below.
@@ -1057,15 +766,17 @@ impl State {
             }
             let visible = self.is_widget_visible(i);
             let (x, y, w, h) = self.positions[i];
-            let widget = self.roster.get_dyn_mut(i);
             if visible && (i == 15 || i == 34) {
                 // The header dropdowns land through `layout` at their own preferred
                 // height: `y` is where the label goes, the content sits a strip below.
-                let h = widget.preferred_height().unwrap_or(h);
-                let strip = widget.label_strip();
-                widget.layout(Point { x, y: y + strip }, LayoutConstraints::new(w, w, h, h), &mut self.ui_context);
+                self.ui_context.lend(self.roster.id(i), |widget, ctx| {
+                    let h = widget.preferred_height().unwrap_or(h);
+                    let strip = widget.label_strip();
+                    widget.layout(Point { x, y: y + strip }, LayoutConstraints::new(w, w, h, h), ctx);
+                });
                 continue;
             }
+            let widget = self.roster.get_dyn_mut(&mut self.ui_context, i);
             if visible {
                 widget.set_rect(x, y, w, h);
             } else {
@@ -1115,21 +826,24 @@ impl cce_ui::engine::Application for State {
         let opacity = opacity || child_kind.is_some_and(ChildKind::is_editor);
         let status_text = "Ready.".to_string();
 
+        // The context owns the widgets; the roster names them by slot.
+        let mut ui_context = cce_ui::context::UiContext::new();
+        let ctx = &mut ui_context;
         let roster = if let Some(kind) = child_kind {
             let bg = if use_root_plate {
-                ChildBg::RootPlate(RootPlate::new(0.0, 0.0, c_w, c_h))
+                ctx.insert(RootPlate::new(0.0, 0.0, c_w, c_h)).id()
             } else {
-                ChildBg::ContentBg(ContentBg::new())
+                ctx.insert(ContentBg::new()).id()
             };
             let (main, aux3, aux4) = if kind == ChildKind::ColorRamp {
                 (
-                    ChildMain::ColorRamp(ColorRamp::new()),
-                    ChildAux3::Label(Label::new("").with_font_size(12.0)),
-                    ChildAux4::Label(Label::new("").with_font_size(12.0)),
+                    ctx.insert(ColorRamp::new()).id(),
+                    ctx.insert(Label::new("").with_font_size(12.0)).id(),
+                    ctx.insert(Label::new("").with_font_size(12.0)).id(),
                 )
             } else if kind == ChildKind::Ramp {
                 (
-                    ChildMain::Ramp({
+                    ctx.insert({
                         let mut ramp = Ramp::new();
                         let (loaded_keys, loaded_type) = load_bevel_ramp();
                         ramp.keys = loaded_keys;
@@ -1138,9 +852,10 @@ impl cce_ui::engine::Application for State {
                             _ => 0,
                         };
                         ramp
-                    }),
-                    ChildAux3::Label(Label::new("").with_font_size(12.0)),
-                    ChildAux4::Label(Label::new("").with_font_size(12.0)),
+                    })
+                    .id(),
+                    ctx.insert(Label::new("").with_font_size(12.0)).id(),
+                    ctx.insert(Label::new("").with_font_size(12.0)).id(),
                 )
             } else {
                 let menu_bar = MenuBar::new(0.0, 0.0, c_w, 40.0)
@@ -1148,18 +863,13 @@ impl cce_ui::engine::Application for State {
                     .with_item("Edit", &["Undo", "Redo", "Cut", "Copy", "Paste"])
                     .with_right_aligned_title(true);
                 (
-                    ChildMain::Desc(Label::new(kind.child_text()).with_font_size(12.0).with_color([0xcc, 0xcc, 0xd4])),
-                    ChildAux3::MenuBar(menu_bar),
-                    ChildAux4::StatusBar(StatusBar::new()),
+                    ctx.insert(Label::new(kind.child_text()).with_font_size(12.0).with_color([0xcc, 0xcc, 0xd4])).id(),
+                    ctx.insert(menu_bar).id(),
+                    ctx.insert(StatusBar::new()).id(),
                 )
             };
-            Roster::Child(Box::new(ChildSlots {
-                bg,
-                main,
-                close: Owned::new(Button::new(0.0, 0.0, 100.0, cce_ui::layout::button_height()).with_label("Close")),
-                aux3,
-                aux4,
-            }))
+            let close = ctx.insert(Button::new(0.0, 0.0, 100.0, cce_ui::layout::button_height()).with_label("Close"));
+            Roster::Child(Box::new(ChildSlots { ids: [bg, main, close.id(), aux3, aux4], close }))
         } else {
             let menu_bar = MenuBar::new(0.0, 0.0, c_w, 40.0)
                 .with_item("File", &["Exit"])
@@ -1167,47 +877,47 @@ impl cce_ui::engine::Application for State {
                 .with_item("Help", &["About"])
                 .with_right_aligned_title(true);
             Roster::Gallery(Box::new(GallerySlots {
-                menu_bar: Owned::new(menu_bar),
-                status_bar: Owned::new(StatusBar::new()),
-                button_demo: Owned::new(Button::new(0.0, 0.0, 140.0, cce_ui::layout::button_height()).with_label("Button")),
-                checkbox_demo: Owned::new(Checkbox::new().with_label("Checkbox")),
-                toggle_demo: Owned::new(Toggle::new().with_label("Toggle")),
-                progress_demo: Owned::new(ProgressBar::new(0.43).with_label("ProgressBar")),
-                slider_demo: Owned::new(Slider::new().with_label("Slider")),
-                spinbox_demo: Owned::new(Spinbox::new(10, 1, 100, 5).with_label("Spinbox")),
-                range_slider_demo: Owned::new(RangeSlider::new().with_label("RangeSlider")),
-                trackpad_demo: Owned::new(Trackpad::new().with_label("Trackpad")),
-                textbox_demo: Owned::new(TextBox::new("Interactive TextBox".to_string())),
-                plate_demo: Owned::new(Plate::new(0.0, 0.0, 120.0, 120.0, true).with_label("Plate")),
-                color_ramp_btn: Owned::new(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Color Ramp...")),
-                bevel_ramp: Owned::new(Ramp::new()),
-                ramp_btn: Owned::new(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Ramp...")),
-                layout_dd: Owned::new(Dropdown::new(
+                menu_bar: ctx.insert(menu_bar),
+                status_bar: ctx.insert(StatusBar::new()),
+                button_demo: ctx.insert(Button::new(0.0, 0.0, 140.0, cce_ui::layout::button_height()).with_label("Button")),
+                checkbox_demo: ctx.insert(Checkbox::new().with_label("Checkbox")),
+                toggle_demo: ctx.insert(Toggle::new().with_label("Toggle")),
+                progress_demo: ctx.insert(ProgressBar::new(0.43).with_label("ProgressBar")),
+                slider_demo: ctx.insert(Slider::new().with_label("Slider")),
+                spinbox_demo: ctx.insert(Spinbox::new(10, 1, 100, 5).with_label("Spinbox")),
+                range_slider_demo: ctx.insert(RangeSlider::new().with_label("RangeSlider")),
+                trackpad_demo: ctx.insert(Trackpad::new().with_label("Trackpad")),
+                textbox_demo: ctx.insert(TextBox::new("Interactive TextBox".to_string())),
+                plate_demo: ctx.insert(Plate::new(0.0, 0.0, 120.0, 120.0, true).with_label("Plate")),
+                color_ramp_btn: ctx.insert(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Color Ramp...")),
+                bevel_ramp: ctx.insert(Ramp::new()),
+                ramp_btn: ctx.insert(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Ramp...")),
+                layout_dd: ctx.insert(Dropdown::new(
                     LAYOUTS.iter().map(|(name, _)| name.to_string()).collect(),
                     DEFAULT_LAYOUT,
                 ).with_label("Layout")),
-                style_dd: Owned::new(Dropdown::new(
+                style_dd: ctx.insert(Dropdown::new(
                     vec!["Relief".to_string(), "Flat".to_string()],
                     if cce_ui::layout::control_relief() { 0 } else { 1 },
                 ).with_label("Style")),
-                color_selector_demo: Owned::new(ColorSelector::new([64, 128, 255]).with_label("ColorSelector")),
-                font_selector_demo: Owned::new(FontSelector::new("Sans".to_string()).with_label("FontSelector")),
-                keybind_demo: Owned::new(KeybindRecorder::new("ctrl+1".to_string()).with_label("KeybindRecorder")),
-                button_strip_demo: Owned::new(Adapted::new(
+                color_selector_demo: ctx.insert(ColorSelector::new([64, 128, 255]).with_label("ColorSelector")),
+                font_selector_demo: ctx.insert(FontSelector::new("Sans".to_string()).with_label("FontSelector")),
+                keybind_demo: ctx.insert(KeybindRecorder::new("ctrl+1".to_string()).with_label("KeybindRecorder")),
+                button_strip_demo: ctx.insert(Adapted::new(
                     ButtonStrip::new(0.0, 0.0, 200.0, cce_ui::layout::button_height())
                         .with_buttons(vec!["One".to_string(), "Two".to_string(), "Three".to_string()])
                         .with_selected(Some(0)),
                 ).with_label("ButtonStrip")),
-                slider2d_demo: Owned::new(Slider2D::new().with_label("Slider2D")),
-                float3_demo: Owned::new(Float3::new().with_label("Float3")),
-                usage_bar_demo: Owned::new(UsageBar::new(0.62).with_label("UsageBar")),
-                status_dot_demo: Owned::new(StatusDot::new(DotStatus::Active).with_label("StatusDot")),
-                info_box_demo: Owned::new(InfoBox::new("InfoBox", vec!["A titled box of".to_string(), "plain text lines.".to_string()])),
-                list_item_demo: Owned::new(InteractiveListItem::new("InteractiveListItem")),
+                slider2d_demo: ctx.insert(Slider2D::new().with_label("Slider2D")),
+                float3_demo: ctx.insert(Float3::new().with_label("Float3")),
+                usage_bar_demo: ctx.insert(UsageBar::new(0.62).with_label("UsageBar")),
+                status_dot_demo: ctx.insert(StatusDot::new(DotStatus::Active).with_label("StatusDot")),
+                info_box_demo: ctx.insert(InfoBox::new("InfoBox", vec!["A titled box of".to_string(), "plain text lines.".to_string()])),
+                list_item_demo: ctx.insert(InteractiveListItem::new("InteractiveListItem")),
                 breadcrumb_demo: {
                     let mut b = Breadcrumb::new();
                     b.path = vec!["home".to_string(), "lsgalante".to_string(), "projects".to_string()];
-                    Owned::new(b.with_label("Breadcrumb"))
+                    ctx.insert(b.with_label("Breadcrumb"))
                 },
                 tree_list_demo: {
                     let mut t = TreeList::new();
@@ -1217,18 +927,18 @@ impl cce_ui::engine::Application for State {
                         ("theme/name".to_string(), serde_json::json!("cce")),
                     ]);
                     t.rebuild_tree();
-                    Owned::new(t.with_label("TreeList"))
+                    ctx.insert(t.with_label("TreeList"))
                 },
-                bevel_preview_demo: Owned::new(BevelPreview::new().with_label("BevelPreview")),
-                ramp_preview_demo: Owned::new(RampPreview::new().with_label("RampPreview")),
-                separator_demo: Owned::new(Separator::new(0.0, 0.0, 200.0, 1.0, [0.5, 0.5, 0.6, 1.0]).with_label("Separator")),
-                splitter_demo: Owned::new(Splitter::new(200.0).with_label("Splitter")),
+                bevel_preview_demo: ctx.insert(BevelPreview::new().with_label("BevelPreview")),
+                ramp_preview_demo: ctx.insert(RampPreview::new().with_label("RampPreview")),
+                separator_demo: ctx.insert(Separator::new(0.0, 0.0, 200.0, 1.0, [0.5, 0.5, 0.6, 1.0]).with_label("Separator")),
+                splitter_demo: ctx.insert(Splitter::new(200.0).with_label("Splitter")),
                 // Members are wired below, once the slots have ids.
                 // style: deliberate — tight padding: the gallery packs its rows closer than a
                 // settings page, and the fitted lasso's padding is what insets the exhibits.
-                group_loose: Owned::new(Group::new(Vec::new()).with_label("Group").with_padding(6.0)),
-                group_fitted: Owned::new(Group::new(Vec::new()).with_label("Group (fitted)").with_fit(true).with_padding(6.0)),
-                extra: variant_exhibits(),
+                group_loose: ctx.insert(Group::new(Vec::new()).with_label("Group").with_padding(6.0)),
+                group_fitted: ctx.insert(Group::new(Vec::new()).with_label("Group (fitted)").with_fit(true).with_padding(6.0)),
+                extra: variant_exhibits(ctx),
             }))
         };
 
@@ -1239,11 +949,11 @@ impl cce_ui::engine::Application for State {
                 // one around the top row (Button, ButtonStrip, Checkbox, Toggle) that
                 // fits the exhibit area's edges — its top snaps to the area's top with
                 // the title tab kept inside, its left to the area's left edge.
-                let ids = |s: &GallerySlots, idx: &[usize]| idx.iter().map(|&i| s.get_dyn(i).base().id()).collect::<Vec<_>>();
+                let ids = |s: &GallerySlots, idx: &[usize]| idx.iter().map(|&i| s.id(i)).collect::<Vec<_>>();
                 let loose = ids(s, &[17, 23]);
                 let fitted = ids(s, &[2, 19, 3, 4]);
-                s.group_loose.inner_mut().set_members(loose);
-                s.group_fitted.inner_mut().set_members(fitted);
+                ui_context[s.group_loose].inner_mut().set_members(loose);
+                ui_context[s.group_fitted].inner_mut().set_members(fitted);
             }
             roster
         };
@@ -1263,7 +973,7 @@ impl cce_ui::engine::Application for State {
             use_statusbar,
             border_enabled,
             child_kind,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
             layout_idx: DEFAULT_LAYOUT,
             exhibit_scroll: {
                 let mut sb = ScrollBox::new();
@@ -1276,7 +986,8 @@ impl cce_ui::engine::Application for State {
             },
         };
 
-        if let Some(ramp) = state.roster.get_dyn_mut(1).as_any_mut().downcast_mut::<Ramp>().filter(|_| is_child) {
+        let main = state.roster.get_dyn_mut(&mut state.ui_context, 1);
+        if let Some(ramp) = main.as_any_mut().downcast_mut::<Ramp>().filter(|_| is_child) {
             // Only a `--type Ramp` child hosts a Ramp in slot 1: it opens with the
             // preset dropdown focused so the keyboard drives it at once. Every other
             // child kind (ColorRamp, or the description Label of the Toplevel /
@@ -1362,11 +1073,11 @@ impl cce_ui::engine::Application for State {
         let vis = self.visibility();
         let is_visible = move |index: usize| vis.is_visible(index);
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn_mut(i);
             if is_visible(i) {
-                if w.tick(dt, &mut self.ui_context) {
-                    changed = true;
-                    if self.child_kind == Some(ChildKind::Ramp) && i == 1 {
+                let ramp_child = self.child_kind == Some(ChildKind::Ramp) && i == 1;
+                let ticked = self.ui_context.lend(self.roster.id(i), |w, ctx| {
+                    let ticked = w.tick(dt, ctx);
+                    if ticked && ramp_child {
                         if let Some(ramp) = w.as_any().downcast_ref::<Ramp>() {
                             let line_type_str = match ramp.line_type_dropdown.selected {
                                 1 => "bezier",
@@ -1375,6 +1086,10 @@ impl cce_ui::engine::Application for State {
                             save_bevel_ramp(&ramp.keys, line_type_str);
                         }
                     }
+                    ticked
+                });
+                if ticked == Some(true) {
+                    changed = true;
                 }
             }
         }
@@ -1435,7 +1150,7 @@ impl cce_ui::engine::Application for State {
         // every plain quad, then the other prims replayed — which put a widget's quads
         // over everything else it painted, whatever its own order.)
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn(i);
+            let w = self.roster.get_dyn(&self.ui_context, i);
             if !self.is_widget_visible(i) || self.ui_context.tree.parent_ptr(w.base().id()).is_some() {
                 continue;
             }
@@ -1464,7 +1179,7 @@ impl cce_ui::engine::Application for State {
         // surface) with per-label bounds; glyphs render in the later text pass
         // regardless of emission order. ──
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn(i);
+            let w = self.roster.get_dyn(&self.ui_context, i);
             if !self.is_widget_visible(i) {
                 continue;
             }
@@ -1480,7 +1195,7 @@ impl cce_ui::engine::Application for State {
         };
         let mut has_menu_bar = false;
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn_mut(i);
+            let w = self.roster.get_dyn_mut(&mut self.ui_context, i);
             if let Some(menu_bar) = w.as_any_mut().downcast_mut::<MenuBar>() {
                 menu_bar.title = label_text.clone();
                 has_menu_bar = true;
@@ -1494,7 +1209,7 @@ impl cce_ui::engine::Application for State {
 
         let mut popover_rects = Vec::new();
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn(i);
+            let w = self.roster.get_dyn(&self.ui_context, i);
             if !self.is_widget_visible(i) {
                 continue;
             }
@@ -1512,7 +1227,7 @@ impl cce_ui::engine::Application for State {
         };
 
         for i in 0..self.roster.len() {
-            let w = self.roster.get_dyn(i);
+            let w = self.roster.get_dyn(&self.ui_context, i);
             if !self.is_widget_visible(i) {
                 continue;
             }
@@ -1633,7 +1348,7 @@ impl cce_ui::engine::Application for State {
                 if !is_visible(i) {
                     continue;
                 }
-                let root = self.roster.get_dyn(i).base().id();
+                let root = self.roster.id(i);
                 if self.ui_context.propagate_event(&mv, root) {
                     changed = true;
                 }
@@ -1689,7 +1404,7 @@ impl cce_ui::engine::Application for State {
                     if !self.is_child && is_exhibit(i) && !self.in_exhibit_viewport(lx, ly) {
                         continue;
                     }
-                    if self.roster.get_dyn_mut(i).hit_test(lx, ly, &self.ui_context) {
+                    if self.roster.get_dyn(&self.ui_context, i).hit_test(lx, ly, &self.ui_context) {
                         clicked_idx = Some(i);
                         break;
                     }
@@ -1698,7 +1413,7 @@ impl cce_ui::engine::Application for State {
             if button == MouseButton::Left {
                 if let Some(old) = self.focused_widget {
                     if Some(old) != clicked_idx {
-                        self.ui_context.unfocus_widget(self.roster.get_dyn_mut(old));
+                        self.ui_context.unfocus_id(self.roster.id(old));
                         self.focused_widget = None;
                     }
                 }
@@ -1708,17 +1423,17 @@ impl cce_ui::engine::Application for State {
                 // DragStart past its threshold; a draggable slot whose press handler
                 // returned false is armed explicitly.
                 let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
-                let root = self.roster.get_dyn(i).base().id();
+                let root = self.roster.id(i);
                 let press_handled = self.ui_context.propagate_event(&ev, root);
                 if press_handled {
                     changed = true;
                 }
-                if button == MouseButton::Left && !press_handled && self.roster.draggable(i) {
-                    let id = self.roster.get_dyn(i).base().id();
+                if button == MouseButton::Left && !press_handled && self.roster.draggable(&self.ui_context, i) {
+                    let id = self.roster.id(i);
                     self.ui_context.drag_target = Some(id);
                 }
                 if button == MouseButton::Left {
-                    self.ui_context.focus_widget(self.roster.get_dyn_mut(i));
+                    self.ui_context.focus_id(self.roster.id(i));
                     self.focused_widget = Some(i);
                 }
             }
@@ -1736,7 +1451,7 @@ impl cce_ui::engine::Application for State {
                 if !is_visible(i) {
                     continue;
                 }
-                let root = self.roster.get_dyn(i).base().id();
+                let root = self.roster.id(i);
                 if self.ui_context.propagate_event(&ev, root) {
                     changed = true;
                 }
@@ -1744,19 +1459,19 @@ impl cce_ui::engine::Application for State {
 
             if button == MouseButton::Left {
                 if self.is_child {
-                    if self.roster.take_click(2) {
+                    if self.roster.take_click(&mut self.ui_context, 2) {
                         return Some("exit".to_string());
                     }
                 } else {
                     if self.apply_header_dropdowns() {
                         changed = true;
-                    } else if self.roster.take_click(12) {
+                    } else if self.roster.take_click(&mut self.ui_context, 12) {
                         spawn_editor("ColorRamp");
-                    } else if self.roster.take_click(14) {
+                    } else if self.roster.take_click(&mut self.ui_context, 14) {
                         spawn_editor("Ramp");
                     } else {
                         for i in [2, 3, 4, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 20, 21, 25, 26, 27, 28, 29] {
-                            if self.roster.take_click(i) {
+                            if self.roster.take_click(&mut self.ui_context, i) {
                                 changed = true;
                             }
                         }
@@ -1789,7 +1504,7 @@ impl cce_ui::engine::Application for State {
             if !self.is_child && is_exhibit(i) && !in_exhibits {
                 continue;
             }
-            let root = self.roster.get_dyn(i).base().id();
+            let root = self.roster.id(i);
             if self.ui_context.propagate_event(&ev, root) {
                 widget_took_wheel = true;
                 changed = true;
@@ -1813,7 +1528,7 @@ impl cce_ui::engine::Application for State {
         let mut handled = false;
         let key_ev = cce_ui::widget::Event::KeyInput(event.clone());
         if let Some(focused) = self.focused_widget {
-            let root = self.roster.get_dyn(focused).base().id();
+            let root = self.roster.id(focused);
             if self.ui_context.propagate_event(&key_ev, root) {
                 changed = true;
                 handled = true;
@@ -1829,7 +1544,7 @@ impl cce_ui::engine::Application for State {
                     continue;
                 }
                 // Panel children take keys only through the focused path above.
-                let root = self.roster.get_dyn(i).base().id();
+                let root = self.roster.id(i);
                 if self.ui_context.propagate_event(&key_ev, root) {
                     changed = true;
                     // A consumed key is HANDLED, not just repaint-worthy: the