git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

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

Fields move from Owned to Handle; the message rows are removed and reinserted when the list's length changes, and register_dispatch_roots is gone. Shadow A/B identical (selection, search, compose).

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

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

diff --git a/src/main.rs b/src/main.rs
index 19fe3a8..480bb94 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,7 +3,7 @@
 mod ipc;
 mod wpe;
 mod accounts_file;
-use cce_ui::widget::Owned;
+use cce_ui::widget::Handle;
 use cce_ui::widget::ScrollRegion;
 use cce_ui::cosmic_text::FontSystem;
 use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
@@ -260,35 +260,35 @@ struct ClearEmailApp {
     /// The app menu trigger: the cce-icons `mail` glyph, plateless. Its
     /// rows fire through `context_menu_actions`, the same path as a card's
     /// right-click menu, so the bar owns no second menu implementation.
-    mail_button: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    mail_button: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
     /// Folder switcher: options[0] carries the live inbox unread count
     /// ("Inbox (6)"), refreshed each rebuild, so rows and trigger agree.
-    folder_dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    folder_dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
     /// Account switcher beside the folder dropdown: options are the account
     /// emails plus a trailing "Manage Accounts…" pseudo-entry (management
     /// lives in cce-system-interface). Options refresh from accounts.json on
     /// every open — the job the retired Accounts page did on entry.
-    account_dropdown: Owned<cce_ui::widget::Adapted<Dropdown>>,
+    account_dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
 
     // Search and List View
-    search_box: Owned<cce_ui::widget::Adapted<TextBox>>,
+    search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
     email_list: ScrollRegion,
-    email_buttons: Vec<Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>>,
+    email_buttons: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>>,
 
     // Details View (Reply/Delete/Mark Read/Unread live in the Message menu)
-    detail_body: Owned<cce_ui::widget::Adapted<TextBox>>,
+    detail_body: Handle<cce_ui::widget::Adapted<TextBox>>,
 
     // Compose Dialog
-    compose_to: Owned<cce_ui::widget::Adapted<TextBox>>,
-    compose_cc: Owned<cce_ui::widget::Adapted<TextBox>>,
-    compose_bcc: Owned<cce_ui::widget::Adapted<TextBox>>,
-    compose_subject: Owned<cce_ui::widget::Adapted<TextBox>>,
-    compose_body: Owned<cce_ui::widget::Adapted<TextBox>>,
+    compose_to: Handle<cce_ui::widget::Adapted<TextBox>>,
+    compose_cc: Handle<cce_ui::widget::Adapted<TextBox>>,
+    compose_bcc: Handle<cce_ui::widget::Adapted<TextBox>>,
+    compose_subject: Handle<cce_ui::widget::Adapted<TextBox>>,
+    compose_body: Handle<cce_ui::widget::Adapted<TextBox>>,
     /// Absolute paths queued for the next send; drawn as removable chips.
     compose_attachments: Vec<String>,
-    btn_compose_send: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
-    btn_compose_cancel: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
-    btn_compose_attach: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_compose_send: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_compose_cancel: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_compose_attach: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 
     // Accounts (switch via the bar dropdown — management lives in cce-system-interface)
     accounts: Vec<AccountInfo>,
@@ -350,9 +350,9 @@ struct ClearEmailApp {
     /// HTML-view toggles in the detail header band (text/HTML, remote
     /// images); laid out each frame by `layout_html_buttons`.
     #[cfg(feature = "wpe")]
-    btn_html_view: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_html_view: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
     #[cfg(feature = "wpe")]
-    btn_html_images: Owned<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
+    btn_html_images: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
     /// Where the page was last drawn (logical px), for routing input to it.
     #[cfg(feature = "wpe")]
     webview_rect: (f32, f32, f32, f32),
@@ -3410,14 +3410,14 @@ impl ClearEmailApp {
             if tb.editing { tb.edit_buffer.trim().to_string() } else { tb.text.trim().to_string() }
         }
         OutgoingMail {
-            to: val(&self.compose_to),
-            cc: val(&self.compose_cc),
-            bcc: val(&self.compose_bcc),
-            subject: val(&self.compose_subject),
-            body: if self.compose_body.editing {
-                self.compose_body.edit_buffer.clone()
+            to: val(&self.ui_context[self.compose_to]),
+            cc: val(&self.ui_context[self.compose_cc]),
+            bcc: val(&self.ui_context[self.compose_bcc]),
+            subject: val(&self.ui_context[self.compose_subject]),
+            body: if self.ui_context[self.compose_body].editing {
+                self.ui_context[self.compose_body].edit_buffer.clone()
             } else {
-                self.compose_body.text.clone()
+                self.ui_context[self.compose_body].text.clone()
             },
             attachments: self.compose_attachments.clone(),
         }
@@ -3427,12 +3427,13 @@ impl ClearEmailApp {
     /// picks a side by the editing flag, so they must always agree).
     fn clear_compose(&mut self) {
         for tb in [
-            &mut self.compose_to,
-            &mut self.compose_cc,
-            &mut self.compose_bcc,
-            &mut self.compose_subject,
-            &mut self.compose_body,
+            self.compose_to,
+            self.compose_cc,
+            self.compose_bcc,
+            self.compose_subject,
+            self.compose_body,
         ] {
+            let tb = &mut self.ui_context[tb];
             tb.text = String::new();
             tb.edit_buffer = String::new();
         }
@@ -3468,34 +3469,6 @@ impl ClearEmailApp {
         self.save_emails();
     }
 
-    /// Register every dispatch root in the ui_context (idempotent, runs each frame).
-    /// The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots through
-    /// the registry; email assembles its frame by hand and never goes through
-    /// `render_widget`'s registration side effect. `email_buttons` is rebuilt on list
-    /// refresh — per-frame registration follows the fresh allocations.
-    fn register_dispatch_roots(&mut self) {
-        self.ui_context.register_host(&mut self.search_box);
-        self.ui_context.register_host(&mut self.detail_body);
-        self.ui_context.register_host(&mut self.compose_to);
-        self.ui_context.register_host(&mut self.compose_cc);
-        self.ui_context.register_host(&mut self.compose_bcc);
-        self.ui_context.register_host(&mut self.compose_subject);
-        self.ui_context.register_host(&mut self.compose_body);
-        self.ui_context.register_host(&mut self.mail_button);
-        self.ui_context.register_host(&mut self.folder_dropdown);
-        self.ui_context.register_host(&mut self.account_dropdown);
-        self.ui_context.register_host(&mut self.btn_compose_send);
-        self.ui_context.register_host(&mut self.btn_compose_cancel);
-        self.ui_context.register_host(&mut self.btn_compose_attach);
-        #[cfg(feature = "wpe")]
-        for btn in [&mut self.btn_html_view, &mut self.btn_html_images] {
-            self.ui_context.register_host(btn);
-        }
-        for btn in self.email_buttons.iter_mut() {
-            self.ui_context.register_host(btn);
-        }
-    }
-
 
     /// Spawn an IMAP sync for the selected account. Unforced calls (folder
     /// switches) are throttled so tab-hopping doesn't stack connections;
@@ -3739,7 +3712,7 @@ impl ClearEmailApp {
     /// lines sharing their band are ellipsized short of it.
     #[cfg(feature = "wpe")]
     fn html_buttons_left(&self) -> Option<f32> {
-        [&self.btn_html_view, &self.btn_html_images]
+        [&self.ui_context[self.btn_html_view], &self.ui_context[self.btn_html_images]]
             .into_iter()
             .map(|b| b.rect().0)
             .filter(|x| *x > -9000.0)
@@ -3926,10 +3899,10 @@ impl ClearEmailApp {
     /// filter plus the search box. A row index means nothing without this:
     /// it is what maps the card under the pointer to its message.
     fn filtered_emails(&self) -> Vec<&Email> {
-        let search_text = if self.search_box.editing {
-            &self.search_box.edit_buffer
+        let search_text = if self.ui_context[self.search_box].editing {
+            &self.ui_context[self.search_box].edit_buffer
         } else {
-            &self.search_box.text
+            &self.ui_context[self.search_box].text
         };
         filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache)
     }
@@ -3939,7 +3912,7 @@ impl ClearEmailApp {
     /// labels to the toolkit, messages into the parallel
     /// `context_menu_actions`, fired by `context_menu_press`.
     fn open_mail_menu(&mut self) {
-        let (bx, _, _, _) = self.mail_button.rect();
+        let (bx, _, _, _) = self.ui_context[self.mail_button].rect();
         let options = vec!["New Message".to_string(), "Sync Now".to_string(), "Quit".to_string()];
         self.context_menu_actions = vec![
             Some(AppMessage::ComposeNew),
@@ -3952,8 +3925,8 @@ impl ClearEmailApp {
     /// Right-press on a message card: open its context menu at the pointer.
     /// Returns false when the press missed every card.
     fn open_card_context_menu(&mut self, px: f32, py: f32) -> bool {
-        let hit = self.email_buttons.iter().position(|b| {
-            let (bx, by, bw, bh) = b.rect();
+        let hit = self.email_buttons.iter().position(|&b| {
+            let (bx, by, bw, bh) = self.ui_context[b].rect();
             bx > -9000.0 && px >= bx && px <= bx + bw && py >= by && py <= by + bh
         });
         let Some(idx) = hit else { return false };
@@ -4103,9 +4076,10 @@ impl ClearEmailApp {
         let images_shown = shown && !self.show_text;
         // Built right-to-left: the view toggle holds the corner.
         for (btn, label, visible) in [
-            (&mut self.btn_html_view, view_label, shown),
-            (&mut self.btn_html_images, images_label, images_shown),
+            (self.btn_html_view, view_label, shown),
+            (self.btn_html_images, images_label, images_shown),
         ] {
+            let btn = &mut self.ui_context[btn];
             if !visible {
                 btn.set_rect(-9999.0, -9999.0, 0.0, 0.0);
                 continue;
@@ -4616,12 +4590,13 @@ impl ClearEmailApp {
     fn prefill_compose(&mut self, m: &MailtoPrefill) {
         self.clear_compose();
         for (tb, v) in [
-            (&mut self.compose_to, &m.to),
-            (&mut self.compose_cc, &m.cc),
-            (&mut self.compose_bcc, &m.bcc),
-            (&mut self.compose_subject, &m.subject),
-            (&mut self.compose_body, &m.body),
+            (self.compose_to, &m.to),
+            (self.compose_cc, &m.cc),
+            (self.compose_bcc, &m.bcc),
+            (self.compose_subject, &m.subject),
+            (self.compose_body, &m.body),
         ] {
+            let tb = &mut self.ui_context[tb];
             tb.text = v.clone();
             tb.edit_buffer = v.clone();
         }
@@ -4864,6 +4839,8 @@ impl Application for ClearEmailApp {
             .map(|a| load_folders(&a.email))
             .unwrap_or_else(default_folders);
 
+        // The context owns the widgets; the app keeps their handles.
+        let mut ui_context = cce_ui::context::UiContext::new();
         let mut app = Self {
             last_sync_start: None,
             secret_retry_at: None,
@@ -4874,22 +4851,22 @@ impl Application for ClearEmailApp {
             backfill_remaining: std::collections::HashMap::new(),
             resync_pending: false,
             keys: EmailKeys::load(),
-            mail_button: Owned::new(mail_button),
-            folder_dropdown: Owned::new(folder_dropdown),
-            account_dropdown: Owned::new(account_dropdown),
-            search_box: Owned::new(search_box),
+            mail_button: ui_context.insert(mail_button),
+            folder_dropdown: ui_context.insert(folder_dropdown),
+            account_dropdown: ui_context.insert(account_dropdown),
+            search_box: ui_context.insert(search_box),
             email_list,
             email_buttons: Vec::new(),
-            detail_body: Owned::new(detail_body),
-            compose_to: Owned::new(compose_to),
-            compose_cc: Owned::new(compose_cc),
-            compose_bcc: Owned::new(compose_bcc),
-            compose_subject: Owned::new(compose_subject),
-            compose_body: Owned::new(compose_body),
+            detail_body: ui_context.insert(detail_body),
+            compose_to: ui_context.insert(compose_to),
+            compose_cc: ui_context.insert(compose_cc),
+            compose_bcc: ui_context.insert(compose_bcc),
+            compose_subject: ui_context.insert(compose_subject),
+            compose_body: ui_context.insert(compose_body),
             compose_attachments: Vec::new(),
-            btn_compose_send: Owned::new(btn_compose_send),
-            btn_compose_cancel: Owned::new(btn_compose_cancel),
-            btn_compose_attach: Owned::new(btn_compose_attach),
+            btn_compose_send: ui_context.insert(btn_compose_send),
+            btn_compose_cancel: ui_context.insert(btn_compose_cancel),
+            btn_compose_attach: ui_context.insert(btn_compose_attach),
             accounts,
             selected_account_idx,
             emails,
@@ -4919,9 +4896,9 @@ impl Application for ClearEmailApp {
             #[cfg(feature = "wpe")]
             show_text: false,
             #[cfg(feature = "wpe")]
-            btn_html_view: Owned::new(Button::new(-9999.0, -9999.0, 0.0, 0.0).with_label("View: HTML")),
+            btn_html_view: ui_context.insert(Button::new(-9999.0, -9999.0, 0.0, 0.0).with_label("View: HTML")),
             #[cfg(feature = "wpe")]
-            btn_html_images: Owned::new(Button::new(-9999.0, -9999.0, 0.0, 0.0).with_label("Load Images")),
+            btn_html_images: ui_context.insert(Button::new(-9999.0, -9999.0, 0.0, 0.0).with_label("Load Images")),
             #[cfg(feature = "wpe")]
             webview_rect: (0.0, 0.0, 0.0, 0.0),
             #[cfg(feature = "wpe")]
@@ -4940,7 +4917,7 @@ impl Application for ClearEmailApp {
             scale_factor: 1.0,
             font_system: cce_ui::create_font_system(),
             needs_rebuild: true,
-            ui_context: UiContext::new(),
+            ui_context,
         };
 
         // A mailto: launch opens the compose dialog prefilled — the same
@@ -5035,12 +5012,13 @@ impl Application for ClearEmailApp {
                     self.save_emails();
                     self.clear_compose();
                     for (tb, v) in [
-                        (&mut self.compose_to, &draft.to),
-                        (&mut self.compose_cc, &draft.cc),
-                        (&mut self.compose_bcc, &draft.bcc),
-                        (&mut self.compose_subject, &draft.subject),
-                        (&mut self.compose_body, &draft.body),
+                        (self.compose_to, &draft.to),
+                        (self.compose_cc, &draft.cc),
+                        (self.compose_bcc, &draft.bcc),
+                        (self.compose_subject, &draft.subject),
+                        (self.compose_body, &draft.body),
                     ] {
+                        let tb = &mut self.ui_context[tb];
                         tb.text = v.clone();
                         tb.edit_buffer = v.clone();
                     }
@@ -5242,19 +5220,19 @@ impl Application for ClearEmailApp {
                         // Start from a clean slate: a Cc/Bcc/attachment left
                         // over from an earlier compose must not ride along.
                         self.clear_compose();
-                        self.compose_to.text = email.from.clone();
-                        self.compose_to.edit_buffer = email.from.clone();
-                        self.compose_subject.text = if email.subject.starts_with("Re:") {
+                        self.ui_context[self.compose_to].text = email.from.clone();
+                        self.ui_context[self.compose_to].edit_buffer = email.from.clone();
+                        self.ui_context[self.compose_subject].text = if email.subject.starts_with("Re:") {
                             email.subject.clone()
                         } else {
                             format!("Re: {}", email.subject)
                         };
-                        self.compose_subject.edit_buffer = self.compose_subject.text.clone();
+                        self.ui_context[self.compose_subject].edit_buffer = self.ui_context[self.compose_subject].text.clone();
 
                         let reply_intro = format!("\n\nOn {}, {} wrote:\n> {}", email.date, email.from, email.body.replace('\n', "\n> "));
-                        self.compose_body.text = reply_intro.clone();
-                        self.compose_body.edit_buffer = reply_intro;
-                        self.compose_body.cursor_idx = 0;
+                        self.ui_context[self.compose_body].text = reply_intro.clone();
+                        self.ui_context[self.compose_body].edit_buffer = reply_intro;
+                        self.ui_context[self.compose_body].cursor_idx = 0;
                         self.compose_title = "Reply".to_string();
                         self.compose_open = true;
                     }
@@ -5374,7 +5352,7 @@ impl Application for ClearEmailApp {
                 // The outgoing account's unsaved changes go first.
                 self.flush_emails();
                 self.selected_account_idx = idx;
-                self.account_dropdown.selected = idx.min(self.accounts.len().saturating_sub(1));
+                self.ui_context[self.account_dropdown].selected = idx.min(self.accounts.len().saturating_sub(1));
                 if let Some(email) = self.accounts.get(idx).map(|a| a.email.clone()) {
                     self.emails_rev = self.emails_rev.wrapping_add(1);
                     self.emails = load_emails_for_account(&email);
@@ -5693,7 +5671,6 @@ impl Application for ClearEmailApp {
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
-        self.register_dispatch_roots();
         // Phase 6ai single paint path: the view() geometry (all plain quads) and the text
         // (the old rebuild_text_items assembly, now prims via emit_text_prims) are this one
         // list. The app FontSystem stays for the TextBoxes' prepare_text measurement, but is
@@ -5719,15 +5696,15 @@ impl Application for ClearEmailApp {
             // feeds the dl-text occlusion clamp, and the popover pass at the
             // end of this function draws it on top of everything.
             self.ui_context.clear_popovers();
-            if self.folder_dropdown.popover_rect().is_some() {
-                self.ui_context.register_popover(&mut self.folder_dropdown);
+            if self.ui_context[self.folder_dropdown].popover_rect().is_some() {
+                self.ui_context.register_popover_id(self.folder_dropdown.id());
             }
-            if self.account_dropdown.popover_rect().is_some() {
-                self.ui_context.register_popover(&mut self.account_dropdown);
+            if self.ui_context[self.account_dropdown].popover_rect().is_some() {
+                self.ui_context.register_popover_id(self.account_dropdown.id());
             }
             // Labels and the index the switcher reports both come from the
             // one list, so a discovered folder cannot leave them disagreeing.
-            self.folder_dropdown.options =
+            self.ui_context[self.folder_dropdown].options =
                 self.folders.iter().map(|f| f.label.clone()).collect();
 
             // Folder rows and trigger both carry the live inbox unread count.
@@ -5740,11 +5717,11 @@ impl Application for ClearEmailApp {
                 .count();
             if inbox_unread > 0 {
                 if let Some(i) = self.folders.iter().position(|f| f.tag == TAG_INBOX) {
-                    self.folder_dropdown.options[i] =
+                    self.ui_context[self.folder_dropdown].options[i] =
                         format!("{} ({})", self.folders[i].label, inbox_unread);
                 }
             }
-            self.folder_dropdown.selected = self
+            self.ui_context[self.folder_dropdown].selected = self
                 .folders
                 .iter()
                 .position(|f| f.tag == self.current_folder)
@@ -5763,10 +5740,10 @@ impl Application for ClearEmailApp {
             // Bottoms on the band's, should button and dropdown heights differ.
             let (btn_h, dd_h) = (cce_ui::layout::button_height(), cce_ui::layout::dropdown_height());
             let bottom = edge + bar_item_h();
-            self.mail_button.set_rect(edge, bottom - btn_h, btn_h, btn_h);
+            self.ui_context[self.mail_button].set_rect(edge, bottom - btn_h, btn_h, btn_h);
             let folder_x = w_f32 - edge - FOLDER_W;
-            self.folder_dropdown.set_rect(folder_x, bottom - dd_h, FOLDER_W, dd_h);
-            self.account_dropdown
+            self.ui_context[self.folder_dropdown].set_rect(folder_x, bottom - dd_h, FOLDER_W, dd_h);
+            self.ui_context[self.account_dropdown]
                 .set_rect(folder_x - gap - ACCOUNT_W, bottom - dd_h, ACCOUNT_W, dd_h);
 
             cce_ui::scale::set_scale_factor(scale as f32);
@@ -5775,9 +5752,9 @@ impl Application for ClearEmailApp {
             // when closed so a stale rect can't be hit by anything that still
             // routes to it.
             if self.search_open {
-                self.search_box.set_rect(list_x, menubar_h() + pane_pad(), list_w, cce_ui::layout::textbox_height());
+                self.ui_context[self.search_box].set_rect(list_x, menubar_h() + pane_pad(), list_w, cce_ui::layout::textbox_height());
             } else {
-                self.search_box.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                self.ui_context[self.search_box].set_rect(-9999.0, -9999.0, 0.0, 0.0);
             }
 
             // Get filtered emails count for bounds setup
@@ -5788,25 +5765,16 @@ impl Application for ClearEmailApp {
             self.email_list.update_bounds(list_count, list_top, list_h);
 
             if self.email_buttons.len() != list_count {
-                // Widget ids are globally monotonic and never reused, so the fresh buttons
-                // register under NEW ids — the outgoing ones would linger in the registry
-                // pointing into this Vec's freed buffer, and the engine walks the whole
-                // registry and derefs it on every left press
-                // (`close_popovers_missed_by_press`). Drop them before the reallocation.
-                let stale: Vec<_> = self.email_buttons.iter().map(|b| b.id()).collect();
-                for id in stale {
-                    self.ui_context.unregister_widget(id);
+                // The context owns the rows: the outgoing ones are taken out of it
+                // (ids are never reused) and the fresh ones put in, registered from
+                // the moment they exist.
+                for h in std::mem::take(&mut self.email_buttons) {
+                    self.ui_context.remove(h);
                 }
+                let ctx = &mut self.ui_context;
                 self.email_buttons = (0..list_count)
-                    .map(|_| Owned::new(Button::new_list_row(0.0, 0.0, 0.0, 0.0)))
+                    .map(|_| ctx.insert(Button::new_list_row(0.0, 0.0, 0.0, 0.0)))
                     .collect();
-                // register_dispatch_roots() already ran this frame with the OLD
-                // buttons; the fresh ids must be registered now or every click on
-                // the list is dropped as a stale root (and nothing re-triggers a
-                // rebuild, so the list stays dead).
-                for btn in self.email_buttons.iter_mut() {
-                    self.ui_context.register_host(btn);
-                }
             }
 
             // Get selected email state to avoid borrowing self while mutating
@@ -5824,11 +5792,12 @@ impl Application for ClearEmailApp {
                 .collect();
 
             for (idx, &(_email_id, is_selected)) in filtered_email_ids.iter().enumerate() {
-                self.email_buttons[idx].selected = is_selected;
+                let btn = &mut self.ui_context[self.email_buttons[idx]];
+                btn.selected = is_selected;
                 if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
-                    self.email_buttons[idx].set_rect(list_x, draw_y, list_w, 54.0);
+                    btn.set_rect(list_x, draw_y, list_w, 54.0);
                 } else {
-                    self.email_buttons[idx].set_rect(-9999.0, -9999.0, 0.0, 0.0);
+                    btn.set_rect(-9999.0, -9999.0, 0.0, 0.0);
                 }
             }
 
@@ -5836,8 +5805,8 @@ impl Application for ClearEmailApp {
             if let Some(body) = selected_email_state {
                 let detail_w = self.detail_content_w();
                 let (body_y, body_h) = self.detail_body_geom();
-                self.detail_body.set_rect(detail_x, body_y, detail_w, body_h);
-                self.detail_body.text = body;
+                self.ui_context[self.detail_body].set_rect(detail_x, body_y, detail_w, body_h);
+                self.ui_context[self.detail_body].text = body;
             }
 
             // Compose inputs layout
@@ -5846,17 +5815,17 @@ impl Application for ClearEmailApp {
                 let l = compose_layout(modal_x, modal_y);
 
                 let field_h = cce_ui::layout::textbox_height();
-                self.compose_to.set_rect(l.field_x, l.rows_y[0], l.field_w, field_h);
-                self.compose_cc.set_rect(l.field_x, l.rows_y[1], l.field_w, field_h);
-                self.compose_bcc.set_rect(l.field_x, l.rows_y[2], l.field_w, field_h);
-                self.compose_subject.set_rect(l.field_x, l.rows_y[3], l.field_w, field_h);
+                self.ui_context[self.compose_to].set_rect(l.field_x, l.rows_y[0], l.field_w, field_h);
+                self.ui_context[self.compose_cc].set_rect(l.field_x, l.rows_y[1], l.field_w, field_h);
+                self.ui_context[self.compose_bcc].set_rect(l.field_x, l.rows_y[2], l.field_w, field_h);
+                self.ui_context[self.compose_subject].set_rect(l.field_x, l.rows_y[3], l.field_w, field_h);
                 let (bx, by, bw, bh) = l.body;
-                self.compose_body.set_rect(bx, by, bw, bh);
+                self.ui_context[self.compose_body].set_rect(bx, by, bw, bh);
 
                 let btn_h = cce_ui::layout::button_height();
-                self.btn_compose_attach.set_rect(l.attach_x, l.buttons_y, COMPOSE_ATTACH_W, btn_h);
-                self.btn_compose_send.set_rect(l.send_x, l.buttons_y, COMPOSE_BTN_W, btn_h);
-                self.btn_compose_cancel.set_rect(l.cancel_x, l.buttons_y, COMPOSE_BTN_W, btn_h);
+                self.ui_context[self.btn_compose_attach].set_rect(l.attach_x, l.buttons_y, COMPOSE_ATTACH_W, btn_h);
+                self.ui_context[self.btn_compose_send].set_rect(l.send_x, l.buttons_y, COMPOSE_BTN_W, btn_h);
+                self.ui_context[self.btn_compose_cancel].set_rect(l.cancel_x, l.buttons_y, COMPOSE_BTN_W, btn_h);
             }
 
 
@@ -5870,7 +5839,7 @@ impl Application for ClearEmailApp {
 
         // Now compute `filtered` only for rendering (immutable borrow of self)
         let filtered: Vec<&Email> = {
-            let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+            let 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 };
             filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache)
         };
 
@@ -5897,9 +5866,9 @@ impl Application for ClearEmailApp {
         // the only thing marking the band. It used to be a plateau one step
         // down, carved with a single bottom wall (`recess_edges`, the
         // inherited MenuBar paint) — that wall is gone.
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.mail_button, &mut *quads.pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.folder_dropdown, &mut *quads.pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.account_dropdown, &mut *quads.pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.mail_button], &mut *quads.pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.folder_dropdown], &mut *quads.pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.account_dropdown], &mut *quads.pc);
 
         // 3. The detail column's two plates: the header above, the preview
         // below. Same fill and same radius as the list: the three are one
@@ -5923,8 +5892,8 @@ impl Application for ClearEmailApp {
 
         // Search band (only while open) and the list
         if self.search_open {
-            self.search_box.prepare_text(&mut self.font_system);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search_box, &mut *quads.pc);
+            self.ui_context[self.search_box].prepare_text(&mut self.font_system);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search_box], &mut *quads.pc);
         }
         {
             // The list's behind copy is NOT emitted here — it goes down with
@@ -5965,7 +5934,7 @@ impl Application for ClearEmailApp {
         );
         for idx in 0..filtered.len() {
             if self.email_list.get_item_draw_y(idx, 0.0).is_some() {
-                cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.email_buttons[idx], &mut *quads.pc);
+                cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.email_buttons[idx]], &mut *quads.pc);
 
                 // Unread: the `circle` glyph in blue, a 6px dot (the glyph's
                 // disc is three quarters of its box) centred where the
@@ -6039,7 +6008,7 @@ impl Application for ClearEmailApp {
                         // (placed by layout_html_buttons; parked ones paint
                         // off-screen).
                         #[cfg(feature = "wpe")]
-                        for btn in [&self.btn_html_view, &self.btn_html_images] {
+                        for btn in [&self.ui_context[self.btn_html_view], &self.ui_context[self.btn_html_images]] {
                             cce_ui::scene::painter::paint_root_into(&self.ui_context, btn, &mut *quads.pc);
                         }
 
@@ -6178,19 +6147,19 @@ impl Application for ClearEmailApp {
                 );
             }
 
-            self.compose_to.prepare_text(&mut self.font_system);
-            self.compose_cc.prepare_text(&mut self.font_system);
-            self.compose_bcc.prepare_text(&mut self.font_system);
-            self.compose_subject.prepare_text(&mut self.font_system);
-            self.compose_body.prepare_text(&mut self.font_system);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.compose_to, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.compose_cc, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.compose_bcc, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.compose_subject, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.compose_body, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_compose_send, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_compose_cancel, &mut *quads.pc);
-            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_compose_attach, &mut *quads.pc);
+            self.ui_context[self.compose_to].prepare_text(&mut self.font_system);
+            self.ui_context[self.compose_cc].prepare_text(&mut self.font_system);
+            self.ui_context[self.compose_bcc].prepare_text(&mut self.font_system);
+            self.ui_context[self.compose_subject].prepare_text(&mut self.font_system);
+            self.ui_context[self.compose_body].prepare_text(&mut self.font_system);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.compose_to], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.compose_cc], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.compose_bcc], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.compose_subject], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.compose_body], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.btn_compose_send], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.btn_compose_cancel], &mut *quads.pc);
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.btn_compose_attach], &mut *quads.pc);
         }
 
 
@@ -6326,8 +6295,8 @@ impl Application for ClearEmailApp {
             // Hover scope for the detail-pane body scroll (wheel + keys).
             self.detail_hovered = px > separator_x;
 
-            for btn in &mut self.email_buttons {
-                if btn.rect().0 > -9000.0 {
+            for &btn in &self.email_buttons {
+                if ctx[btn].rect().0 > -9000.0 {
                     if ctx.propagate_event(&mv, btn.id()) { changed = true; }
                 }
             }
@@ -6480,9 +6449,9 @@ impl Application for ClearEmailApp {
         // an open menu overlays the panes, so a handled press must not fall
         // through to what's beneath it).
         {
-            let was_open = self.account_dropdown.open;
+            let was_open = ctx[self.account_dropdown].open;
             if ctx.propagate_event(&ev, self.account_dropdown.id()) {
-                if !was_open && self.account_dropdown.open {
+                if !was_open && ctx[self.account_dropdown].open {
                     // Freshly opened: re-read the shared accounts.json so
                     // cce-system-interface edits appear without a restart (the
                     // job the retired Accounts page did on entry). Keep the
@@ -6501,8 +6470,8 @@ impl Application for ClearEmailApp {
                         .unwrap_or(0);
                     let changed_account =
                         self.accounts.get(new_idx).map(|a| a.email.as_str()) != prev_email.as_deref();
-                    self.account_dropdown.options = account_dropdown_options(&self.accounts);
-                    self.account_dropdown.selected = new_idx;
+                    ctx[self.account_dropdown].options = account_dropdown_options(&self.accounts);
+                    ctx[self.account_dropdown].selected = new_idx;
                     if changed_account {
                         // The account under the selection vanished — switch to
                         // what the trigger now shows.
@@ -6512,8 +6481,8 @@ impl Application for ClearEmailApp {
                     }
                 }
                 let mut msg_out = None;
-                if self.account_dropdown.take_change() {
-                    let idx = self.account_dropdown.selected;
+                if ctx[self.account_dropdown].take_change() {
+                    let idx = ctx[self.account_dropdown].selected;
                     if idx < self.accounts.len() {
                         if idx != self.selected_account_idx {
                             msg_out = Some(AppMessage::SelectAccount(idx));
@@ -6521,7 +6490,7 @@ impl Application for ClearEmailApp {
                     } else {
                         // The trailing "Manage Accounts…" pseudo-entry: not an
                         // account — restore the trigger to the active one.
-                        self.account_dropdown.selected = self.selected_account_idx;
+                        ctx[self.account_dropdown].selected = self.selected_account_idx;
                         msg_out = Some(AppMessage::ManageAccounts);
                     }
                 }
@@ -6535,7 +6504,7 @@ impl Application for ClearEmailApp {
         // rows fire on the NEXT press, through the context-menu gate at the
         // top of this function.
         if ctx.propagate_event(&ev, self.mail_button.id()) {
-            if state == ElementState::Released && self.mail_button.take_click() {
+            if state == ElementState::Released && ctx[self.mail_button].take_click() {
                 self.open_mail_menu();
             }
             *needs_rebuild = true;
@@ -6544,8 +6513,8 @@ impl Application for ClearEmailApp {
         }
         // Folder switcher.
         if ctx.propagate_event(&ev, self.folder_dropdown.id()) {
-            if self.folder_dropdown.take_change() {
-                if let Some(f) = self.folders.get(self.folder_dropdown.selected) {
+            if ctx[self.folder_dropdown].take_change() {
+                if let Some(f) = self.folders.get(ctx[self.folder_dropdown].selected) {
                     msg_out = Some(AppMessage::SwitchFolder(f.tag.clone()));
                 }
             }
@@ -6557,40 +6526,40 @@ impl Application for ClearEmailApp {
         if self.compose_open {
             if ctx.propagate_event(&ev, self.compose_to.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.compose_to); }
+                if state == ElementState::Pressed { ctx.set_focused_id(self.compose_to.id()); }
             }
             if ctx.propagate_event(&ev, self.compose_cc.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.compose_cc); }
+                if state == ElementState::Pressed { ctx.set_focused_id(self.compose_cc.id()); }
             }
             if ctx.propagate_event(&ev, self.compose_bcc.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.compose_bcc); }
+                if state == ElementState::Pressed { ctx.set_focused_id(self.compose_bcc.id()); }
             }
             if ctx.propagate_event(&ev, self.compose_subject.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.compose_subject); }
+                if state == ElementState::Pressed { ctx.set_focused_id(self.compose_subject.id()); }
             }
             if ctx.propagate_event(&ev, self.compose_body.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.compose_body); }
+                if state == ElementState::Pressed { ctx.set_focused_id(self.compose_body.id()); }
             }
 
             if ctx.propagate_event(&ev, self.btn_compose_send.id()) {
                 changed = true;
-                if state == ElementState::Released && self.btn_compose_send.take_click() {
+                if state == ElementState::Released && ctx[self.btn_compose_send].take_click() {
                     msg_out = Some(AppMessage::ComposeSend);
                 }
             }
             if ctx.propagate_event(&ev, self.btn_compose_cancel.id()) {
                 changed = true;
-                if state == ElementState::Released && self.btn_compose_cancel.take_click() {
+                if state == ElementState::Released && ctx[self.btn_compose_cancel].take_click() {
                     msg_out = Some(AppMessage::ComposeCancel);
                 }
             }
             if ctx.propagate_event(&ev, self.btn_compose_attach.id()) {
                 changed = true;
-                if state == ElementState::Released && self.btn_compose_attach.take_click() {
+                if state == ElementState::Released && ctx[self.btn_compose_attach].take_click() {
                     msg_out = Some(AppMessage::ComposeAttach);
                 }
             }
@@ -6616,11 +6585,11 @@ impl Application for ClearEmailApp {
 
                 if px < modal_x || px > modal_x + COMPOSE_W || py < modal_y || py > modal_y + compose_h() {
                     ctx.clear_focus();
-                    self.compose_to.unfocus();
-                    self.compose_cc.unfocus();
-                    self.compose_bcc.unfocus();
-                    self.compose_subject.unfocus();
-                    self.compose_body.unfocus();
+                    ctx[self.compose_to].unfocus();
+                    ctx[self.compose_cc].unfocus();
+                    ctx[self.compose_bcc].unfocus();
+                    ctx[self.compose_subject].unfocus();
+                    ctx[self.compose_body].unfocus();
                     changed = true;
                 }
             }
@@ -6628,17 +6597,17 @@ impl Application for ClearEmailApp {
             // Search input — only reachable while the band is open.
             if self.search_open && ctx.propagate_event(&ev, self.search_box.id()) {
                 changed = true;
-                if state == ElementState::Pressed { ctx.set_focused(&mut self.search_box); }
-                if self.search_box.take_change() {
+                if state == ElementState::Pressed { ctx.set_focused_id(self.search_box.id()); }
+                if ctx[self.search_box].take_change() {
                     msg_out = Some(AppMessage::SearchChanged);
                 }
             } else if state == ElementState::Pressed && button == MouseButton::Left {
                 ctx.clear_focus();
-                self.search_box.unfocus();
+                ctx[self.search_box].unfocus();
                 // Clicking away from an EMPTY search collapses the band —
                 // an unfocused box with no query is dead chrome. One holding
                 // a query stays: it is the visible reason the list is short.
-                if self.search_open && self.search_box.text.is_empty() && self.search_box.edit_buffer.is_empty() {
+                if self.search_open && ctx[self.search_box].text.is_empty() && ctx[self.search_box].edit_buffer.is_empty() {
                     self.search_open = false;
                 }
                 changed = true;
@@ -6670,17 +6639,17 @@ impl Application for ClearEmailApp {
                 // email a row click lands on. No wildcard: a new folder
                 // absorbed into "inbox" here routes clicks to the wrong list
                 // (Drafts was, briefly).
-                let search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
+                let search_text = if ctx[self.search_box].editing { &ctx[self.search_box].edit_buffer } else { &ctx[self.search_box].text };
                 let filtered: Vec<&Email> =
                     filtered_from(&self.emails, self.emails_rev, &self.current_folder, search_text, &self.filter_cache);
 
                 for (idx, email) in filtered.iter().enumerate() {
                     if idx < self.email_buttons.len() {
-                        let btn = &mut self.email_buttons[idx];
-                        if btn.rect().0 > -9000.0 {
+                        let btn = self.email_buttons[idx];
+                        if ctx[btn].rect().0 > -9000.0 {
                             if ctx.propagate_event(&ev, btn.id()) {
                                 changed = true;
-                                if state == ElementState::Released && btn.take_click() {
+                                if state == ElementState::Released && ctx[btn].take_click() {
                                     msg_out = Some(AppMessage::SelectEmail(email.id));
                                 }
                             }
@@ -6695,13 +6664,13 @@ impl Application for ClearEmailApp {
             {
                 if ctx.propagate_event(&ev, self.btn_html_view.id()) {
                     changed = true;
-                    if state == ElementState::Released && self.btn_html_view.take_click() {
+                    if state == ElementState::Released && ctx[self.btn_html_view].take_click() {
                         self.show_text = !self.show_text;
                     }
                 }
                 if ctx.propagate_event(&ev, self.btn_html_images.id()) {
                     changed = true;
-                    if state == ElementState::Released && self.btn_html_images.take_click() {
+                    if state == ElementState::Released && ctx[self.btn_html_images].take_click() {
                         let lift = !self.webview.images_allowed();
                         self.webview.set_images_allowed(lift);
                     }
@@ -6835,17 +6804,17 @@ impl Application for ClearEmailApp {
         // dropdown). No key ever reached these before: every propagate below
         // is gated on compose/search state that an open dropdown never
         // satisfies (the cce-files bug).
-        if self.account_dropdown.open {
+        if self.ui_context[self.account_dropdown].open {
             if self.ui_context.propagate_event(&kev, self.account_dropdown.id()) {
-                if self.account_dropdown.take_change() {
-                    let idx = self.account_dropdown.selected;
+                if self.ui_context[self.account_dropdown].take_change() {
+                    let idx = self.ui_context[self.account_dropdown].selected;
                     if idx < self.accounts.len() {
                         if idx != self.selected_account_idx {
                             msg_out = Some(AppMessage::SelectAccount(idx));
                         }
                     } else {
                         // The trailing "Manage Accounts…" pseudo-entry.
-                        self.account_dropdown.selected = self.selected_account_idx;
+                        self.ui_context[self.account_dropdown].selected = self.selected_account_idx;
                         msg_out = Some(AppMessage::ManageAccounts);
                     }
                 }
@@ -6853,10 +6822,10 @@ impl Application for ClearEmailApp {
                 self.needs_rebuild = true;
                 return msg_out;
             }
-        } else if self.folder_dropdown.open {
+        } else if self.ui_context[self.folder_dropdown].open {
             if self.ui_context.propagate_event(&kev, self.folder_dropdown.id()) {
-                if self.folder_dropdown.take_change() {
-                    if let Some(f) = self.folders.get(self.folder_dropdown.selected) {
+                if self.ui_context[self.folder_dropdown].take_change() {
+                    if let Some(f) = self.folders.get(self.ui_context[self.folder_dropdown].selected) {
                         msg_out = Some(AppMessage::SwitchFolder(f.tag.clone()));
                     }
                 }
@@ -6874,7 +6843,7 @@ impl Application for ClearEmailApp {
         // is. move_selection takes &mut self, so it cannot run while ctx is
         // borrowed anyway.
         if !self.compose_open
-            && !self.search_box.editing
+            && !self.ui_context[self.search_box].editing
             && event.state == ElementState::Pressed
         {
             // Pane focus first: the chords carry Ctrl, so they cannot be
@@ -6943,15 +6912,15 @@ impl Application for ClearEmailApp {
         let ctx = &mut self.ui_context;
 
         if self.compose_open {
-            if self.compose_to.editing {
+            if ctx[self.compose_to].editing {
                 if ctx.propagate_event(&kev, self.compose_to.id()) { handled = true; }
-            } else if self.compose_cc.editing {
+            } else if ctx[self.compose_cc].editing {
                 if ctx.propagate_event(&kev, self.compose_cc.id()) { handled = true; }
-            } else if self.compose_bcc.editing {
+            } else if ctx[self.compose_bcc].editing {
                 if ctx.propagate_event(&kev, self.compose_bcc.id()) { handled = true; }
-            } else if self.compose_subject.editing {
+            } else if ctx[self.compose_subject].editing {
                 if ctx.propagate_event(&kev, self.compose_subject.id()) { handled = true; }
-            } else if self.compose_body.editing {
+            } else if ctx[self.compose_body].editing {
                 if ctx.propagate_event(&kev, self.compose_body.id()) { handled = true; }
             }
 
@@ -6973,9 +6942,9 @@ impl Application for ClearEmailApp {
                 && event.logical_key == Key::Named(cce_ui::widget::NamedKey::Escape)
             {
                 ctx.clear_focus();
-                self.search_box.unfocus();
-                self.search_box.text.clear();
-                self.search_box.edit_buffer.clear();
+                ctx[self.search_box].unfocus();
+                ctx[self.search_box].text.clear();
+                ctx[self.search_box].edit_buffer.clear();
                 self.search_open = false;
                 msg_out = Some(AppMessage::SearchChanged);
                 handled = true;
@@ -6986,15 +6955,15 @@ impl Application for ClearEmailApp {
                 if cce_ui::widget::match_key_shortcut(event, &self.keys.compose) {
                     msg_out = Some(AppMessage::ComposeNew);
                     handled = true;
-                } else if !self.search_box.editing
+                } else if !ctx[self.search_box].editing
                     && cce_ui::widget::match_key_shortcut(event, &self.keys.open_search)
                 {
                     // The !editing guard is load-bearing now the chord is a
                     // bare "/": without it, typing a slash into the open box
                     // would re-match here and never reach the text.
                     self.search_open = true;
-                    ctx.set_focused(&mut self.search_box);
-                    self.search_box.focus();
+                    ctx.set_focused_id(self.search_box.id());
+                    ctx[self.search_box].focus();
                     handled = true;
                 }
             }
@@ -7006,7 +6975,7 @@ impl Application for ClearEmailApp {
             if !handled
                 && (self.detail_hovered || self.focused_pane == Pane::Detail)
                 && html_on_show
-                && !self.search_box.editing
+                && !ctx[self.search_box].editing
                 && event.state == ElementState::Pressed
             {
                 use cce_ui::widget::NamedKey;
@@ -7032,7 +7001,7 @@ impl Application for ClearEmailApp {
             if !handled
                 && (self.detail_hovered || self.focused_pane == Pane::Detail)
                 && self.selected_email_id.is_some()
-                && !self.search_box.editing
+                && !ctx[self.search_box].editing
                 && event.state == ElementState::Pressed
             {
                 let max = (self.body_content_h - body_h).max(0.0);
@@ -7066,14 +7035,14 @@ impl Application for ClearEmailApp {
             // hover-or-focus (a row click focuses the region, a press elsewhere
             // unfocuses); the search box owns the keys while editing, and a
             // body-scroll above wins when the detail pane is hovered.
-            if !handled && !self.search_box.editing && self.email_list.keyboard(event) {
+            if !handled && !ctx[self.search_box].editing && self.email_list.keyboard(event) {
                 handled = true;
             }
 
-            if !handled && self.search_box.editing {
+            if !handled && ctx[self.search_box].editing {
                 if ctx.propagate_event(&kev, self.search_box.id()) {
                     handled = true;
-                    if self.search_box.take_change() {
+                    if ctx[self.search_box].take_change() {
                         msg_out = Some(AppMessage::SearchChanged);
                     }
                 }