git.lucas.co / cce-fonts
font browser

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

The font rows are rebuilt on every keystroke in the filter: the outgoing handles leave the context and the new ones are inserted, so nothing re-registers per frame. The picker's Cancel / Select are hidden outside select mode (they stayed unregistered before) so they are no Tab stop. Shadow A/B identical but for the preview line, which the runner now shapes first with the font system its glyph pass draws with.

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

 src/main.rs | 349 +++++++++++++++++++++++++++---------------------------------
 1 file changed, 158 insertions(+), 191 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 8522ef2..d7fd0df 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,6 @@
 mod pages;
 
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::cosmic_text::FontSystem;
 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
@@ -213,23 +213,23 @@ struct TypefaceApp {
     keys: FontsKeys,
 
     // Browse panel
-    search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     list_region: ScrollRegion,
     list_item_h: f32,
-    font_buttons: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>>,
+    font_buttons: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>>,
 
     // Preview panel
-    style_dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
-    size_spinbox: Owned<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
-    preview_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    style_dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
+    size_spinbox: Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>,
+    preview_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 
     // Details panel
-    btn_open_folder: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
-    btn_remove_font: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_open_folder: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_remove_font: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 
     // Selection mode buttons
-    select_cancel_btn: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
-    select_confirm_btn: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    select_cancel_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    select_confirm_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
     select_mode: bool,
     last_click_idx: Option<usize>,
     /// When that click landed. A wall clock, not a `dt` countdown: `dt` is
@@ -280,38 +280,10 @@ struct TypefaceApp {
     // plates and scroll frames are prims, scroll state lives in the ScrollRegions,
     // children are dispatched/walked directly, panel rects are computed)
     mid_region: ScrollRegion,
-    widgets_registered: bool,
     ui_context: cce_ui::context::UiContext,
 }
 
 impl TypefaceApp {
-    /// Register the widgets (parentless — the panel Plates are dissolved). Static widgets
-    /// once; the font-list buttons every rebuild (they are recreated on search changes,
-    /// same cadence the old per-rebuild link_parent_child re-registration had).
-    fn register_widgets(&mut self) {
-        if !self.widgets_registered {
-            self.widgets_registered = true;
-            self.ui_context.register_host(&mut self.search_box);
-            self.ui_context.register_host(&mut self.btn_open_folder);
-            self.ui_context.register_host(&mut self.btn_remove_font);
-            self.ui_context.register_host(&mut self.style_dropdown);
-            self.ui_context.register_host(&mut self.size_spinbox);
-            self.ui_context.register_host(&mut self.preview_box);
-            // The picker's Cancel / Select exist only in select mode (`--select`), where they
-            // are laid out and drawn; registered otherwise they sat unplaced at the window's
-            // corner as the first two stops of the Tab walk and the accessibility tree.
-            if self.select_mode {
-                self.ui_context.register_host(&mut self.select_cancel_btn);
-                self.ui_context.register_host(&mut self.select_confirm_btn);
-            }
-        }
-        for btn in self.font_buttons.iter_mut() {
-            if btn.rect().0 > -9000.0 {
-                self.ui_context.register_host(btn);
-            }
-        }
-    }
-
     /// The panes' height: the window less the root inset at top and bottom,
     /// and in select mode the bar and the root gap that separates it.
     fn content_h(&self) -> f32 {
@@ -339,8 +311,8 @@ impl TypefaceApp {
         let pad = cce_ui::layout::plate_padding();
         let gap = cce_ui::layout::plate_gap();
         let control_gap = cce_ui::layout::control_gap();
-        let dropdown_h = cce_ui::layout::dropdown_height() + self.style_dropdown.label_strip();
-        let spinbox_h = cce_ui::layout::spinbox_height() + self.size_spinbox.label_strip();
+        let dropdown_h = cce_ui::layout::dropdown_height() + self.ui_context[self.style_dropdown].label_strip();
+        let spinbox_h = cce_ui::layout::spinbox_height() + self.ui_context[self.size_spinbox].label_strip();
         let preview_h = if self.select_mode { 120.0 } else { 180.0 };
         let alphabet_h = if self.select_mode { 102.0 } else { 120.0 };
         let buttons_y = pad;
@@ -377,8 +349,8 @@ impl TypefaceApp {
     fn dispatch_widgets(&mut self, forward: bool) -> Vec<cce_ui::widget::WidgetId> {
         let mut v: Vec<cce_ui::widget::WidgetId> = Vec::new();
         v.push(self.search_box.id());
-        for btn in self.font_buttons.iter() {
-            if btn.rect().0 > -9000.0 {
+        for &btn in self.font_buttons.iter() {
+            if self.ui_context[btn].rect().0 > -9000.0 {
                 v.push(btn.id());
             }
         }
@@ -411,23 +383,15 @@ impl TypefaceApp {
     fn reload_fonts(&mut self) {
         self.all_fonts = previewable_fonts(self.font_system.db());
         self.families = self.extract_families(&self.all_fonts);
-        let query = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+        let query = if self.ui_context[self.search_box].editing { &self.ui_context[self.search_box].edit_buffer } else { &self.ui_context[self.search_box].text };
         self.filtered = self.filter_families(&self.families, query);
-        // Ids are globally monotonic and never reused, so the fresh buttons register under
-        // NEW ids; the outgoing ones would keep pointing into this Vec's freed buffer, and
-        // the engine derefs the whole registry on every left press. Drop them first.
-        let stale: Vec<_> = self.font_buttons.iter().map(|b| b.id()).collect();
-        for id in stale {
-            self.ui_context.unregister_widget(id);
-        }
-        self.font_buttons = self.filtered.iter().map(|f| Owned::new(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
-        // ...and register the replacements now. `dispatch_widgets()` reports every row whose
-        // rect passes the parked-sentinel gate, and fresh rows are (0,0,0,0) so they pass it
-        // immediately — but `register_widgets()` only runs in the next layout pass, so the
-        // ids were reported unregistered until then and the router dropped those events.
-        for btn in self.font_buttons.iter_mut() {
-            self.ui_context.register_host(btn);
+        // The context owns the rows: the outgoing ones are taken out of it (their ids are
+        // never reused) and the fresh ones put in, registered from the moment they exist.
+        for h in std::mem::take(&mut self.font_buttons) {
+            self.ui_context.remove(h);
         }
+        let ctx = &mut self.ui_context;
+        self.font_buttons = self.filtered.iter().map(|f| ctx.insert(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
 
         // A rescan can add families above the selection: keep it by name, not
         // by row. (A removed family leaves its row to the one after it.)
@@ -446,8 +410,8 @@ impl TypefaceApp {
                 self.selected_attrs = cce_ui::scene::paint::TextAttrs::default();
                 self.preview_family = None;
                 self.set_sample(latin_sample());
-                self.style_dropdown.options.clear();
-                self.style_dropdown.selected = 0;
+                self.ui_context[self.style_dropdown].options.clear();
+                self.ui_context[self.style_dropdown].selected = 0;
                 self.charset_count = 0;
                 self.charset_str = String::from("0");
                 self.is_user_font = false;
@@ -489,8 +453,8 @@ impl TypefaceApp {
 
         // Pick default style: "Regular" if available, else first
         let default_idx = self.family_styles.iter().position(|s| s == "Regular").unwrap_or(0);
-        self.style_dropdown.options = self.family_styles.clone();
-        self.style_dropdown.selected = default_idx;
+        self.ui_context[self.style_dropdown].options = self.family_styles.clone();
+        self.ui_context[self.style_dropdown].selected = default_idx;
 
         if self.family_styles.is_empty() {
             self.selected_style = None;
@@ -508,8 +472,8 @@ impl TypefaceApp {
 
         // Highlight selected button
         if let Some(sel) = self.selected_idx {
-            for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
-                btn.selected = idx == sel;
+            for (idx, &btn) in self.font_buttons.iter().enumerate() {
+                self.ui_context[btn].selected = idx == sel;
             }
         }
         
@@ -521,7 +485,7 @@ impl TypefaceApp {
     fn select_style(&mut self, idx: usize) {
         let style = self.family_styles[idx].clone();
         let file = self.family_files[idx].clone();
-        self.style_dropdown.selected = idx;
+        self.ui_context[self.style_dropdown].selected = idx;
         self.selected_style = Some(style);
         self.selected_attrs = self.face_attrs(&file, self.family_indices[idx]);
         self.preview_family = Some(cce_ui::backend::text::face_family(&file, self.family_indices[idx]));
@@ -566,8 +530,8 @@ impl TypefaceApp {
             }
         };
         let (old, new) = (auto(&self.sample), auto(&sample));
-        if !self.preview_box.editing && self.preview_box.text == old {
-            self.preview_box.text = new;
+        if !self.ui_context[self.preview_box].editing && self.ui_context[self.preview_box].text == old {
+            self.ui_context[self.preview_box].text = new;
         }
         self.sample = sample;
     }
@@ -578,9 +542,9 @@ impl TypefaceApp {
     /// its size by the toolkit's font-string split.
     fn sync_preview_font(&mut self) {
         if let Some(family) = self.preview_family.as_ref().or(self.selected_family.as_ref()) {
-            self.preview_box.font_family = format!("{} {}", family, self.preview_box.font_size);
+            self.ui_context[self.preview_box].font_family = format!("{} {}", family, self.ui_context[self.preview_box].font_size);
         }
-        self.preview_box.font_attrs = self.selected_attrs;
+        self.ui_context[self.preview_box].font_attrs = self.selected_attrs;
     }
 
     fn scroll_to_index(&mut self, index: usize) {
@@ -602,17 +566,17 @@ impl TypefaceApp {
     /// engine (which also carries the system fonts via `load_system_fonts`).
     fn refresh_widget_text(&mut self) {
         let font_system = &mut self.font_system;
-        self.search_box.prepare_text(font_system);
-        for btn in &mut self.font_buttons {
-            btn.prepare_text(font_system);
-        }
-        self.style_dropdown.prepare_text(font_system);
-        self.size_spinbox.prepare_text(font_system);
-        self.preview_box.prepare_text(font_system);
-        self.btn_open_folder.prepare_text(font_system);
-        self.btn_remove_font.prepare_text(font_system);
-        self.select_cancel_btn.prepare_text(font_system);
-        self.select_confirm_btn.prepare_text(font_system);
+        self.ui_context[self.search_box].prepare_text(font_system);
+        for &btn in &self.font_buttons {
+            self.ui_context[btn].prepare_text(font_system);
+        }
+        self.ui_context[self.style_dropdown].prepare_text(font_system);
+        self.ui_context[self.size_spinbox].prepare_text(font_system);
+        self.ui_context[self.preview_box].prepare_text(font_system);
+        self.ui_context[self.btn_open_folder].prepare_text(font_system);
+        self.ui_context[self.btn_remove_font].prepare_text(font_system);
+        self.ui_context[self.select_cancel_btn].prepare_text(font_system);
+        self.ui_context[self.select_confirm_btn].prepare_text(font_system);
     }
 
     /// The alphabet preview as display-list text: one prim per line (the legacy single
@@ -622,7 +586,7 @@ impl TypefaceApp {
     fn push_alphabet_preview(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
         let Some(family) = self.preview_family.as_ref().or(self.selected_family.as_ref()) else { return };
 
-        let font_size = self.size_spinbox.value as f32;
+        let font_size = self.ui_context[self.size_spinbox].value as f32;
         let attrs = self.selected_attrs;
 
         let (mid_panel_x, panel_y, mid_panel_w, mid_panel_h) = self.mid_panel_rect();
@@ -722,20 +686,22 @@ impl Application for TypefaceApp {
 
         let font_system = cce_ui::create_font_system_with_system_fonts();
         let all_fonts = previewable_fonts(font_system.db());
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
         let mut app = Self {
             keys: FontsKeys::load(),
-            search_box: Owned::new(search_box),
+            search_box: ui_context.insert(search_box),
             list_region: ScrollRegion::new(0.0, 4.0)
                 .with_sink_behind(true),
             list_item_h,
             font_buttons: Vec::new(),
-            style_dropdown: Owned::new(style_dropdown),
-            size_spinbox: Owned::new(size_spinbox),
-            preview_box: Owned::new(preview_box),
-            btn_open_folder: Owned::new(btn_open_folder),
-            btn_remove_font: Owned::new(btn_remove_font),
-            select_cancel_btn: Owned::new(select_cancel_btn),
-            select_confirm_btn: Owned::new(select_confirm_btn),
+            style_dropdown: ui_context.insert(style_dropdown),
+            size_spinbox: ui_context.insert(size_spinbox),
+            preview_box: ui_context.insert(preview_box),
+            btn_open_folder: ui_context.insert(btn_open_folder),
+            btn_remove_font: ui_context.insert(btn_remove_font),
+            select_cancel_btn: ui_context.insert(select_cancel_btn),
+            select_confirm_btn: ui_context.insert(select_confirm_btn),
             select_mode,
             last_click_idx: None,
             last_click_at: None,
@@ -762,13 +728,18 @@ impl Application for TypefaceApp {
             needs_rebuild: true,
             mid_region: ScrollRegion::new(0.0, 4.0)
                 .with_sink_behind(true),
-            widgets_registered: false,
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context,
         };
         
+        // The picker's Cancel / Select exist only in select mode (`--select`), where they
+        // are laid out and drawn; shown otherwise they sat unplaced at the window's corner
+        // as the first two stops of the Tab walk and the accessibility tree.
+        app.ui_context[app.select_cancel_btn].set_visible(select_mode);
+        app.ui_context[app.select_confirm_btn].set_visible(select_mode);
+
         app.families = app.extract_families(&app.all_fonts);
         app.filtered = app.filter_families(&app.families, "");
-        app.font_buttons = app.filtered.iter().map(|f| Owned::new(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
+        app.font_buttons = app.filtered.iter().map(|f| app.ui_context.insert(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
         
         // Parse preselected family and size from CLI args (passed by FontSelector)
         let mut preselected_family = None;
@@ -796,11 +767,11 @@ impl Application for TypefaceApp {
         }
 
         if let Some(sz) = preselected_size {
-            app.size_spinbox.value = sz as i32;
-            if app.size_spinbox.editing {
-                app.size_spinbox.edit_buffer = app.size_spinbox.value.to_string();
+            app.ui_context[app.size_spinbox].value = sz as i32;
+            if app.ui_context[app.size_spinbox].editing {
+                app.ui_context[app.size_spinbox].edit_buffer = app.ui_context[app.size_spinbox].value.to_string();
             }
-            app.preview_box.font_size = sz;
+            app.ui_context[app.preview_box].font_size = sz;
             app.sync_preview_font();
         }
 
@@ -852,27 +823,20 @@ impl Application for TypefaceApp {
                 }
             }
             AppMessage::FontSizeChanged => {
-                self.preview_box.font_size = self.size_spinbox.value as f32;
+                self.ui_context[self.preview_box].font_size = self.ui_context[self.size_spinbox].value as f32;
                 self.sync_preview_font();
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
             AppMessage::SearchChanged => {
-                let query = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+                let query = if self.ui_context[self.search_box].editing { &self.ui_context[self.search_box].edit_buffer } else { &self.ui_context[self.search_box].text };
                 self.filtered = self.filter_families(&self.families, query);
-                // See the note in refresh(): monotonic ids mean the outgoing buttons must be
-                // unregistered or the registry keeps dangling pointers. This path runs on
-                // every keystroke in the filter box, so it is the hottest producer of them.
-                let stale: Vec<_> = self.font_buttons.iter().map(|b| b.id()).collect();
-                for id in stale {
-                    self.ui_context.unregister_widget(id);
-                }
-                self.font_buttons = self.filtered.iter().map(|f| Owned::new(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
-                // Register the replacements now rather than waiting for the next layout pass —
-                // see refresh(). This is the path that produced the stale-root spam.
-                for btn in self.font_buttons.iter_mut() {
-                    self.ui_context.register_host(btn);
+                // The rows are rebuilt on every keystroke in the filter box (see refresh()).
+                for h in std::mem::take(&mut self.font_buttons) {
+                    self.ui_context.remove(h);
                 }
+                let ctx = &mut self.ui_context;
+                self.font_buttons = self.filtered.iter().map(|f| ctx.insert(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f))).collect();
 
                 // reset or re-evaluate selection
                 self.selected_idx = None;
@@ -885,8 +849,8 @@ impl Application for TypefaceApp {
                 self.selected_attrs = cce_ui::scene::paint::TextAttrs::default();
                 self.preview_family = None;
                 self.set_sample(latin_sample());
-                self.style_dropdown.options.clear();
-                self.style_dropdown.selected = 0;
+                self.ui_context[self.style_dropdown].options.clear();
+                self.ui_context[self.style_dropdown].selected = 0;
                 self.charset_count = 0;
                 self.charset_str = String::from("0");
                 self.is_user_font = false;
@@ -987,7 +951,6 @@ impl Application for TypefaceApp {
 
         if self.needs_rebuild || size_changed {
             cce_ui::scale::set_scale_factor(scale as f32);
-            self.register_widgets();
 
             self.mid_region.set_rect(mid_panel_x, panel_y, mid_panel_w, content_h);
 
@@ -996,7 +959,7 @@ impl Application for TypefaceApp {
 
             // Search box, inset from the pane's rim.
             let search_h = cce_ui::layout::textbox_height();
-            self.search_box.set_rect(left_panel_x + pad, panel_y + pad, left_panel_w - 2.0 * pad, search_h);
+            self.ui_context[self.search_box].set_rect(left_panel_x + pad, panel_y + pad, left_panel_w - 2.0 * pad, search_h);
 
             // Scrolling list: a pane gap below the search box, down to the
             // pane's padded bottom rim.
@@ -1010,7 +973,8 @@ impl Application for TypefaceApp {
             self.list_region.update_bounds_raw(self.filtered.len() as f32 * item_full + row_gap, list_y, list_h);
 
             // Layout list buttons, inset from the list by its row gap.
-            for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
+            for (idx, &btn) in self.font_buttons.iter().enumerate() {
+                let btn = &mut self.ui_context[btn];
                 if let Some(draw_y) = self.list_region.get_draw_y(idx as f32 * item_full, self.list_item_h) {
                     btn.set_rect(list_x + row_gap, draw_y, list_w - 2.0 * row_gap, LIST_ROW_H);
                 } else {
@@ -1029,35 +993,35 @@ impl Application for TypefaceApp {
             let btn_h = cce_ui::layout::button_height();
             let btn_draw_y = panel_y + form.buttons_y - scroll_y;
             if btn_draw_y + btn_h >= viewport_top && btn_draw_y <= viewport_bottom {
-                self.btn_open_folder.set_rect(control_x, btn_draw_y, 110.0, btn_h);
-                self.btn_remove_font.set_rect(control_x + 110.0 + gap, btn_draw_y, 110.0, btn_h);
+                self.ui_context[self.btn_open_folder].set_rect(control_x, btn_draw_y, 110.0, btn_h);
+                self.ui_context[self.btn_remove_font].set_rect(control_x + 110.0 + gap, btn_draw_y, 110.0, btn_h);
             } else {
-                self.btn_open_folder.set_rect(-9999.0, -9999.0, 0.0, 0.0);
-                self.btn_remove_font.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.btn_open_folder].set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.btn_remove_font].set_rect(-9999.0, -9999.0, 0.0, 0.0);
             }
 
             // Style dropdown
             let dropdown_draw_y = panel_y + form.dropdown_y - scroll_y;
             if dropdown_draw_y + form.dropdown_h >= viewport_top && dropdown_draw_y <= viewport_bottom {
-                self.style_dropdown.set_rect(control_x, dropdown_draw_y, control_w, form.dropdown_h);
+                self.ui_context[self.style_dropdown].set_rect(control_x, dropdown_draw_y, control_w, form.dropdown_h);
             } else {
-                self.style_dropdown.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.style_dropdown].set_rect(-9999.0, -9999.0, 0.0, 0.0);
             }
 
             // Size spinbox
             let spinbox_draw_y = panel_y + form.spinbox_y - scroll_y;
             if spinbox_draw_y + form.spinbox_h >= viewport_top && spinbox_draw_y <= viewport_bottom {
-                self.size_spinbox.set_rect(control_x, spinbox_draw_y, control_w, form.spinbox_h);
+                self.ui_context[self.size_spinbox].set_rect(control_x, spinbox_draw_y, control_w, form.spinbox_h);
             } else {
-                self.size_spinbox.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.size_spinbox].set_rect(-9999.0, -9999.0, 0.0, 0.0);
             }
 
             // Preview box
             let preview_draw_y = panel_y + form.preview_y - scroll_y;
             if preview_draw_y + form.preview_h >= viewport_top && preview_draw_y <= viewport_bottom {
-                self.preview_box.set_rect(control_x, preview_draw_y, control_w, form.preview_h);
+                self.ui_context[self.preview_box].set_rect(control_x, preview_draw_y, control_w, form.preview_h);
             } else {
-                self.preview_box.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.preview_box].set_rect(-9999.0, -9999.0, 0.0, 0.0);
             }
 
             if self.select_mode {
@@ -1066,15 +1030,15 @@ impl Application for TypefaceApp {
                 // right — the pane padding off the bar's rim, a pane gap apart —
                 // and vertically centered: the old row style.
                 let btn_h = cce_ui::layout::button_height();
-                let cancel_sz = self.select_cancel_btn.intrinsic_size()
+                let cancel_sz = self.ui_context[self.select_cancel_btn].intrinsic_size()
                     .unwrap_or(cce_ui::scene::layout::Size::new(80.0, btn_h));
-                let confirm_sz = self.select_confirm_btn.intrinsic_size()
+                let confirm_sz = self.ui_context[self.select_confirm_btn].intrinsic_size()
                     .unwrap_or(cce_ui::scene::layout::Size::new(80.0, btn_h));
                 let bar_w = w_f32 - 2.0 * inset;
                 let mut x = left_panel_x + bar_w - pad - confirm_sz.width;
-                self.select_confirm_btn.set_rect(x, bar_y + (select_bar_h - confirm_sz.height) / 2.0, confirm_sz.width, confirm_sz.height);
+                self.ui_context[self.select_confirm_btn].set_rect(x, bar_y + (select_bar_h - confirm_sz.height) / 2.0, confirm_sz.width, confirm_sz.height);
                 x -= gap + cancel_sz.width;
-                self.select_cancel_btn.set_rect(x, bar_y + (select_bar_h - cancel_sz.height) / 2.0, cancel_sz.width, cancel_sz.height);
+                self.ui_context[self.select_cancel_btn].set_rect(x, bar_y + (select_bar_h - cancel_sz.height) / 2.0, cancel_sz.width, cancel_sz.height);
             }
 
             self.refresh_widget_text();
@@ -1087,8 +1051,8 @@ impl Application for TypefaceApp {
         // display list below (not on an engine xdg popup), and a global registration would
         // spawn an empty popup surface (no render_popovers override here).
         self.ui_context.clear_popovers();
-        if self.selected_family.is_some() && self.style_dropdown.popover_rect().is_some() {
-            self.ui_context.register_popover(&mut self.style_dropdown);
+        if self.selected_family.is_some() && self.ui_context[self.style_dropdown].popover_rect().is_some() {
+            self.ui_context.register_popover_id(self.style_dropdown.id());
         }
 
         // 1. The window base, then the dissolved panels' plates and their children
@@ -1098,22 +1062,22 @@ impl Application for TypefaceApp {
         // The standard root plate (cce-ui PlateSpec::window).
         pc.root_plate(w_f32, h_f32);
         {
-            let self_ptr = self as *mut Self;
             // Left panel plate + children (the list's idle scrollbar under the plate).
             emit_region_idle_bar(&self.list_region, &mut pc);
             self.plate_prims(Rect { x: left_panel_x, y: panel_y, width: left_panel_w, height: content_h }, &mut pc);
-            unsafe {
-                cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).search_box, &mut pc);
+            {
+                let ui = &self.ui_context;
+                cce_ui::scene::painter::paint_root_into(ui, &ui[self.search_box], &mut pc);
                 // Rows under the list-viewport clip: `get_draw_y` returns
                 // PARTIALLY visible rows (toolkit ScrollRegion intersection
                 // contract), so an edge row renders cut instead of vanishing.
                 // (Its full rect can overlap the search box's hit area above;
                 // the router dispatches the search box first, so it wins.)
-                let lr = &(*self_ptr).list_region;
+                let lr = &self.list_region;
                 pc.push_clip(Rect { x: lr.x, y: lr.viewport_y, width: lr.w, height: lr.viewport_h });
-                for btn in (*self_ptr).font_buttons.iter_mut() {
-                    if btn.rect().0 > -9000.0 {
-                        cce_ui::scene::painter::paint_root_into(&self.ui_context, &*btn, &mut pc);
+                for &btn in &self.font_buttons {
+                    if ui[btn].rect().0 > -9000.0 {
+                        cce_ui::scene::painter::paint_root_into(ui, &ui[btn], &mut pc);
                     }
                 }
                 pc.pop_clip();
@@ -1124,20 +1088,22 @@ impl Application for TypefaceApp {
             }
             self.plate_prims(Rect { x: mid_panel_x, y: panel_y, width: mid_panel_w, height: content_h }, &mut pc);
             if self.selected_family.is_some() {
-                unsafe {
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).btn_open_folder, &mut pc);
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).btn_remove_font, &mut pc);
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).style_dropdown, &mut pc);
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).size_spinbox, &mut pc);
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).preview_box, &mut pc);
+                {
+                    let ui = &self.ui_context;
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.btn_open_folder], &mut pc);
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.btn_remove_font], &mut pc);
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.style_dropdown], &mut pc);
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.size_spinbox], &mut pc);
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.preview_box], &mut pc);
                 }
             }
             // Bottom bar plate + children (select mode).
             if self.select_mode {
                 self.plate_prims(Rect { x: left_panel_x, y: bar_y, width: w_f32 - 2.0 * inset, height: select_bar_h }, &mut pc);
-                unsafe {
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).select_cancel_btn, &mut pc);
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).select_confirm_btn, &mut pc);
+                {
+                    let ui = &self.ui_context;
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.select_cancel_btn], &mut pc);
+                    cce_ui::scene::painter::paint_root_into(ui, &ui[self.select_confirm_btn], &mut pc);
                 }
             }
         }
@@ -1186,10 +1152,10 @@ impl Application for TypefaceApp {
         // 5. Style-dropdown popover — geometry and labels last, on top of everything. Its
         // labels carry bounds equal to the popover rect, which both clips them to the plate
         // and exempts them from the occlusion clamp (the is-overlay-text convention).
-        if self.selected_family.is_some() && self.style_dropdown.open {
+        if self.selected_family.is_some() && self.ui_context[self.style_dropdown].open {
             // PaintCtx is a RenderTarget: the popover draws its real prims (the
             // expanded inset-plate surface) with its own per-label bounds.
-            self.style_dropdown.render_popover(&mut pc);
+            self.ui_context[self.style_dropdown].render_popover(&mut pc);
         }
 
         Some(pc.finish())
@@ -1210,7 +1176,7 @@ impl Application for TypefaceApp {
             return;
         }
 
-        let old_size = self.preview_box.font_size;
+        let old_size = self.ui_context[self.preview_box].font_size;
 
         // The dissolved scroll regions: scrollbar-thumb drags and hover tracking.
         // Toolkit cursor_moved = the old drag_move + manual hover tracking.
@@ -1231,9 +1197,9 @@ impl Application for TypefaceApp {
             }
         }
 
-        let new_size = self.size_spinbox.value as f32;
+        let new_size = self.ui_context[self.size_spinbox].value as f32;
         if (new_size - old_size).abs() > 0.001 {
-            self.preview_box.font_size = new_size;
+            self.ui_context[self.preview_box].font_size = new_size;
             changed = true;
         }
 
@@ -1259,26 +1225,26 @@ impl Application for TypefaceApp {
                 self.needs_rebuild = true;
             }
             // A size pasted into the spinbox from the menu.
-            let new_size = self.size_spinbox.value as f32;
-            if (new_size - self.preview_box.font_size).abs() > 0.001 {
-                self.preview_box.font_size = new_size;
+            let new_size = self.ui_context[self.size_spinbox].value as f32;
+            if (new_size - self.ui_context[self.preview_box].font_size).abs() > 0.001 {
+                self.ui_context[self.preview_box].font_size = new_size;
                 return Some(AppMessage::FontSizeChanged);
             }
             return None;
         }
 
         if state == ElementState::Pressed && button == MouseButton::Left {
-            if !self.search_box.hit_test(px, py, &self.ui_context) {
-                self.ui_context.unfocus_widget(&mut self.search_box);
+            if !self.ui_context[self.search_box].hit_test(px, py, &self.ui_context) {
+                self.ui_context.unfocus_id(self.search_box.id());
                 changed = true;
             } else {
-                self.ui_context.set_focused(&mut self.search_box);
+                self.ui_context.set_focused_id(self.search_box.id());
             }
-            if !self.preview_box.hit_test(px, py, &self.ui_context) {
-                self.ui_context.unfocus_widget(&mut self.preview_box);
+            if !self.ui_context[self.preview_box].hit_test(px, py, &self.ui_context) {
+                self.ui_context.unfocus_id(self.preview_box.id());
                 changed = true;
             } else {
-                self.ui_context.set_focused(&mut self.preview_box);
+                self.ui_context.set_focused_id(self.preview_box.id());
             }
         }
 
@@ -1341,16 +1307,17 @@ impl Application for TypefaceApp {
             }
         }
 
-        if self.selected_family.is_some() && self.style_dropdown.take_change() {
-            msg_out = Some(AppMessage::SelectStyle(self.style_dropdown.selected));
+        if self.selected_family.is_some() && self.ui_context[self.style_dropdown].take_change() {
+            msg_out = Some(AppMessage::SelectStyle(self.ui_context[self.style_dropdown].selected));
         }
 
-        for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
+        for idx in 0..self.font_buttons.len() {
+            let btn = &mut self.ui_context[self.font_buttons[idx]];
             if btn.rect().0 > -9000.0 && btn.take_click() {
                 let double = self.last_click_at.is_some_and(|t| t.elapsed() < DOUBLE_CLICK);
                 if self.select_mode && self.last_click_idx == Some(idx) && double {
                     let selected = self.filtered[idx].clone();
-                    print!("{} {}", selected, self.size_spinbox.value);
+                    print!("{} {}", selected, self.ui_context[self.size_spinbox].value);
                     use std::io::Write;
                     let _ = std::io::stdout().flush();
                     std::process::exit(0);
@@ -1362,27 +1329,27 @@ impl Application for TypefaceApp {
             }
         }
 
-        let old_size = self.preview_box.font_size;
-        let new_size = self.size_spinbox.value as f32;
+        let old_size = self.ui_context[self.preview_box].font_size;
+        let new_size = self.ui_context[self.size_spinbox].value as f32;
         if (new_size - old_size).abs() > 0.001 {
-            self.preview_box.font_size = new_size;
+            self.ui_context[self.preview_box].font_size = new_size;
             msg_out = Some(AppMessage::FontSizeChanged);
         }
 
-        if self.btn_open_folder.take_click() {
+        if self.ui_context[self.btn_open_folder].take_click() {
             msg_out = Some(AppMessage::OpenFolder);
         }
-        if self.btn_remove_font.take_click() {
+        if self.ui_context[self.btn_remove_font].take_click() {
             msg_out = Some(AppMessage::RemoveFont);
         }
 
         if self.select_mode {
-            if self.select_cancel_btn.take_click() {
+            if self.ui_context[self.select_cancel_btn].take_click() {
                 std::process::exit(1);
             }
-            if self.select_confirm_btn.take_click() {
+            if self.ui_context[self.select_confirm_btn].take_click() {
                 let selected = self.selected_family.clone().unwrap_or_default();
-                print!("{} {}", selected, self.size_spinbox.value);
+                print!("{} {}", selected, self.ui_context[self.size_spinbox].value);
                 use std::io::Write;
                 let _ = std::io::stdout().flush();
                 std::process::exit(0);
@@ -1434,14 +1401,14 @@ impl Application for TypefaceApp {
                 msg_out = Some(AppMessage::RefreshFonts);
                 handled = true;
             } else if m(&self.keys.open_search) {
-                self.ui_context.focus_widget(&mut self.search_box);
-                self.ui_context.set_focused(&mut self.search_box);
-                self.ui_context.unfocus_widget(&mut self.preview_box);
+                self.ui_context.focus_id(self.search_box.id());
+                self.ui_context.set_focused_id(self.search_box.id());
+                self.ui_context.unfocus_id(self.preview_box.id());
                 handled = true;
             } else if m(&self.keys.focus_preview) {
-                self.ui_context.focus_widget(&mut self.preview_box);
-                self.ui_context.set_focused(&mut self.preview_box);
-                self.ui_context.unfocus_widget(&mut self.search_box);
+                self.ui_context.focus_id(self.preview_box.id());
+                self.ui_context.set_focused_id(self.preview_box.id());
+                self.ui_context.unfocus_id(self.search_box.id());
                 handled = true;
             } else if m(&self.keys.open_folder) {
                 msg_out = Some(AppMessage::OpenFolder);
@@ -1456,11 +1423,11 @@ impl Application for TypefaceApp {
             match event.logical_key {
                 Key::Character(ref ch) if ch == "+" || ch == "=" => {
                     if self.selected_family.is_some() {
-                        let (_, max) = self.size_spinbox.range();
-                        let old_val = self.size_spinbox.value;
-                        self.size_spinbox.value = (self.size_spinbox.value + 2).min(max);
-                        if self.size_spinbox.value != old_val && self.size_spinbox.editing {
-                            self.size_spinbox.edit_buffer = self.size_spinbox.value.to_string();
+                        let (_, max) = self.ui_context[self.size_spinbox].range();
+                        let old_val = self.ui_context[self.size_spinbox].value;
+                        self.ui_context[self.size_spinbox].value = (self.ui_context[self.size_spinbox].value + 2).min(max);
+                        if self.ui_context[self.size_spinbox].value != old_val && self.ui_context[self.size_spinbox].editing {
+                            self.ui_context[self.size_spinbox].edit_buffer = self.ui_context[self.size_spinbox].value.to_string();
                         }
                         msg_out = Some(AppMessage::FontSizeChanged);
                         handled = true;
@@ -1468,11 +1435,11 @@ impl Application for TypefaceApp {
                 }
                 Key::Character(ref ch) if ch == "-" => {
                     if self.selected_family.is_some() {
-                        let (min, _) = self.size_spinbox.range();
-                        let old_val = self.size_spinbox.value;
-                        self.size_spinbox.value = (self.size_spinbox.value - 2).max(min);
-                        if self.size_spinbox.value != old_val && self.size_spinbox.editing {
-                            self.size_spinbox.edit_buffer = self.size_spinbox.value.to_string();
+                        let (min, _) = self.ui_context[self.size_spinbox].range();
+                        let old_val = self.ui_context[self.size_spinbox].value;
+                        self.ui_context[self.size_spinbox].value = (self.ui_context[self.size_spinbox].value - 2).max(min);
+                        if self.ui_context[self.size_spinbox].value != old_val && self.ui_context[self.size_spinbox].editing {
+                            self.ui_context[self.size_spinbox].edit_buffer = self.ui_context[self.size_spinbox].value.to_string();
                         }
                         msg_out = Some(AppMessage::FontSizeChanged);
                         handled = true;
@@ -1482,7 +1449,7 @@ impl Application for TypefaceApp {
             }
         }
 
-        if !handled && !self.style_dropdown.open {
+        if !handled && !self.ui_context[self.style_dropdown].open {
             // Arrow navigation — family list only while no dropdown is open:
             // an open style dropdown takes the arrows for its own hover (via
             // the propagate loop below), and consuming them here left it
@@ -1510,7 +1477,7 @@ impl Application for TypefaceApp {
 
         // TextBox and other focused widgets input propagation
         if !handled {
-            let old_search_text = if self.search_box.editing { self.search_box.edit_buffer.clone() } else { self.search_box.text.clone() };
+            let old_search_text = if self.ui_context[self.search_box].editing { self.ui_context[self.search_box].edit_buffer.clone() } else { self.ui_context[self.search_box].text.clone() };
             
             // Routed (6bd shrink); short-circuits on the first handler — the router
             // delivers KeyInput to the focused widget first on EVERY call (the 6ac trap).
@@ -1529,7 +1496,7 @@ impl Application for TypefaceApp {
             }
 
             if handled {
-                let new_search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+                let new_search_text = if self.ui_context[self.search_box].editing { &self.ui_context[self.search_box].edit_buffer } else { &self.ui_context[self.search_box].text };
                 if old_search_text != *new_search_text {
                     msg_out = Some(AppMessage::SearchChanged);
                 }