git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/context.rs (63.4K)

   1 use std::collections::HashMap;
   2 use crate::widget::{Handle, WidgetHost, WidgetId, Key, NamedKey, MouseButton, ElementState, Event, WidgetHostExt};
   3 
   4 pub struct SpatialGrid {
   5     pub cell_size: f32,
   6     pub cells: HashMap<(i32, i32), Vec<WidgetId>>,
   7 }
   8 
   9 impl SpatialGrid {
  10     pub fn new(cell_size: f32) -> Self {
  11         Self {
  12             cell_size,
  13             cells: HashMap::new(),
  14         }
  15     }
  16 
  17     pub fn clear(&mut self) {
  18         self.cells.clear();
  19     }
  20 
  21     pub fn insert(&mut self, id: WidgetId, rect: (f32, f32, f32, f32)) {
  22         let (x, y, w, h) = rect;
  23         if w <= 0.0 || h <= 0.0 {
  24             return;
  25         }
  26         let start_x = (x / self.cell_size).floor() as i32;
  27         let end_x = ((x + w) / self.cell_size).floor() as i32;
  28         let start_y = (y / self.cell_size).floor() as i32;
  29         let end_y = ((y + h) / self.cell_size).floor() as i32;
  30 
  31         let start_x = start_x.max(-1000);
  32         let end_x = end_x.min(1000);
  33         let start_y = start_y.max(-1000);
  34         let end_y = end_y.min(1000);
  35 
  36         for cx in start_x..=end_x {
  37             for cy in start_y..=end_y {
  38                 self.cells.entry((cx, cy)).or_default().push(id);
  39             }
  40         }
  41     }
  42 
  43     pub fn query(&self, px: f32, py: f32) -> &[WidgetId] {
  44         let cx = (px / self.cell_size).floor() as i32;
  45         let cy = (py / self.cell_size).floor() as i32;
  46         self.cells.get(&(cx, cy)).map(|v| v.as_slice()).unwrap_or(&[])
  47     }
  48 }
  49 
  50 /// One open modal: who opened it, what is inside it, and where focus was before.
  51 #[derive(Debug, Clone)]
  52 struct ModalScope {
  53     owner: WidgetId,
  54     members: Vec<WidgetId>,
  55     restore: Option<WidgetId>,
  56 }
  57 
  58 pub struct UiContext {
  59     /// The widget tree + registry, consolidated into one generational store (Phase 1b of the
  60     /// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
  61     /// `scene/tree.rs`.
  62     pub tree: crate::scene::WidgetTree,
  63     /// The focused widget's id (Phase 6bc: stored ids, not pointers — a stale id resolves to
  64     /// `None` through the generational tree instead of dereferencing freed memory).
  65     pub focused_widget: Option<WidgetId>,
  66     /// Open-popover registrations, id-keyed like focus (Phase 6bc slice 2).
  67     pub active_popovers: Vec<WidgetId>,
  68     /// Open modals, innermost last ([`UiContext::open_modal`]): while one is open the Tab
  69     /// walk visits only its members, and every widget outside it reads as covered, so no
  70     /// press or hover reaches what lies behind.
  71     modals: Vec<ModalScope>,
  72     /// Memo for `is_coordinate_covered` at a single cursor position: the ids of
  73     /// every widget whose popover rect contains it. That query scans the entire
  74     /// registry, and `hit_test` calls it — so dispatching one PointerMove to N
  75     /// roots cost N×N `popover_rect()` calls (the 1359-row Packages list: 1.85M
  76     /// per motion event, ~14ms, which starved the whole frame loop). Every one
  77     /// of those queries shares the same point and differs only in which id it
  78     /// excludes, so the scan runs once per position and each caller then asks
  79     /// whether some *other* id covers it. Invalidated whenever the registry
  80     /// changes or a frame's registration is reset.
  81     /// `RefCell` because `hit_test` receives `&UiContext` — the memo is an
  82     /// implementation detail of a read-only query, not shared state.
  83     covered_cache: std::cell::RefCell<(Option<(f32, f32)>, Vec<WidgetId>)>,
  84     pub cursor_pos: (f32, f32),
  85     pub active_grab: Option<WidgetId>,
  86     pub drag_start_pos: Option<(f32, f32)>,
  87     pub drag_target: Option<WidgetId>,
  88     pub is_dragging: bool,
  89     pub any_dirty: bool,
  90     pub tick_receivers: Vec<WidgetId>,
  91     /// How many times `tick` has run. The runner reads it around the app's
  92     /// own `Application::tick` to see whether the app already advanced the
  93     /// roster this frame — receivers integrate `dt` (scroll glides, slider
  94     /// inertia), so a second tick per frame would run them at double speed.
  95     tick_count: u64,
  96     pub spatial_grid: SpatialGrid,
  97     pub last_scroll_time: Option<web_time::Instant>,
  98     pub scroll_initiate_widget_id: Option<WidgetId>,
  99     pub scroll_gesture_new: bool,
 100     pub ctrl_pressed: bool,
 101     pub shift_pressed: bool,
 102     pub alt_pressed: bool,
 103     pub logo_pressed: bool,
 104 }
 105 
 106 impl UiContext {
 107     pub fn new() -> Self {
 108         Self {
 109             tree: crate::scene::WidgetTree::new(),
 110             focused_widget: None,
 111             active_popovers: Vec::new(),
 112             modals: Vec::new(),
 113             covered_cache: std::cell::RefCell::new((None, Vec::new())),
 114             cursor_pos: (0.0, 0.0),
 115             active_grab: None,
 116             drag_start_pos: None,
 117             drag_target: None,
 118             is_dragging: false,
 119             any_dirty: false,
 120             tick_receivers: Vec::new(),
 121             tick_count: 0,
 122             spatial_grid: SpatialGrid::new(100.0),
 123             last_scroll_time: None,
 124             scroll_initiate_widget_id: None,
 125             scroll_gesture_new: false,
 126             ctrl_pressed: false,
 127             shift_pressed: false,
 128             alt_pressed: false,
 129             logo_pressed: false,
 130         }
 131     }
 132 
 133     pub fn get_widget(&self, id: WidgetId) -> Option<&(dyn WidgetHost + 'static)> {
 134         self.tree.get_ptr(id).map(|ptr| unsafe { &*ptr })
 135     }
 136 
 137     pub fn get_widget_mut(&mut self, id: WidgetId) -> Option<&mut (dyn WidgetHost + 'static)> {
 138         self.tree.get_ptr(id).map(|ptr| unsafe { &mut *ptr })
 139     }
 140 
 141     /// Every widget the context holds (and has not lent out), with its id, in no particular
 142     /// order — for a sweep over the whole window (a focus heir, a hit search).
 143     pub fn widgets(&self) -> impl Iterator<Item = (WidgetId, &(dyn WidgetHost + 'static))> + '_ {
 144         // SAFETY: as `get_widget`: the context's own widgets, borrowed through `&self`.
 145         self.tree.iter_registered().map(|(id, ptr)| (id, unsafe { &*ptr }))
 146     }
 147 
 148     // ── Widgets the context owns, by handle (docs/rfc-owning-registry.md) ──────────────
 149 
 150     /// Take ownership of `widget`, register it under its own id, and hand back its handle.
 151     /// The widget lives in the context from here on; reach it with [`get`](Self::get) /
 152     /// [`get_mut`](Self::get_mut) (or `ctx[h]`), give it back with [`remove`](Self::remove).
 153     pub fn insert<W: WidgetHost + 'static>(&mut self, widget: W) -> Handle<W> {
 154         // A widget that animates is ticked by the context (`tick`): a tree list applies its
 155         // search there.
 156         let wants_tick = crate::widget::WidgetHostExt::wants_tick(&widget);
 157         let id = self.tree.insert_owned(widget);
 158         self.invalidate_coverage_cache();
 159         if wants_tick {
 160             self.register_tick_receiver(id);
 161         }
 162         // Its embedded children (`widget::Embedded`) follow it in.
 163         self.lend(id, |w, ctx| w.attach_embedded(ctx));
 164         Handle::from_id(id)
 165     }
 166 
 167     /// The widget `h` names: `None` once it is removed, or while the context has it out on
 168     /// loan (a widget reaching for itself while it handles an event).
 169     pub fn get<W: WidgetHost + 'static>(&self, h: Handle<W>) -> Option<&W> {
 170         // SAFETY: the tree's own allocation, typed by `owned_root`, not lent; borrowed from
 171         // `&self`, so no `&mut` to it can be live.
 172         self.tree.owned_root::<W>(h.id()).map(|p| unsafe { &*p.as_ptr() })
 173     }
 174 
 175     /// [`get`](Self::get), mutably. The borrow is of the whole context, so an app cannot
 176     /// hold the widget across another context call — the one rule the pointer registry left
 177     /// to discipline, now the compiler's.
 178     pub fn get_mut<W: WidgetHost + 'static>(&mut self, h: Handle<W>) -> Option<&mut W> {
 179         // SAFETY: as in `get`, and `&mut self` is exclusive: nothing else in the context is
 180         // touching the widget while this borrow lives.
 181         self.tree.owned_root::<W>(h.id()).map(|p| unsafe { &mut *p.as_ptr() })
 182     }
 183 
 184     /// Give the widget `h` names back by value, unregistered: its links, its focus and its
 185     /// place in the tick list go with it, and its embedded children come back inside it.
 186     /// `None` if it is gone or out on loan.
 187     pub fn remove<W: WidgetHost + 'static>(&mut self, h: Handle<W>) -> Option<W> {
 188         let id = h.id();
 189         self.tree.owned_root::<W>(id)?;
 190         // Its embedded children (`widget::Embedded`) come back into it first.
 191         self.lend(id, |w, ctx| w.release_embedded(ctx));
 192         let widget = self.tree.take_owned::<W>(id)?;
 193         if self.focused_widget == Some(id) {
 194             self.focused_widget = None;
 195         }
 196         self.tick_receivers.retain(|&r| r != id);
 197         self.invalidate_coverage_cache();
 198         Some(widget)
 199     }
 200 
 201     /// Lend the widget `id` out for one call: `f` gets it and the context, and it is back in
 202     /// the registry when `f` returns. While it is out nothing in the context resolves it, so
 203     /// whatever `f` does with the context — dispatch, focus, a handle lookup — cannot reach
 204     /// the widget a second time. `None` if `id` is unknown, stale, or already out.
 205     ///
 206     /// Every call the context makes into a widget that hands it the context comes through
 207     /// here; so does a host that places or drives a widget it holds by handle.
 208     pub fn lend<R>(&mut self, id: WidgetId, f: impl FnOnce(&mut (dyn WidgetHost + 'static), &mut UiContext) -> R) -> Option<R> {
 209         let ptr = self.tree.get_ptr(id)?;
 210         if !self.tree.set_lent(id, true) {
 211             return None;
 212         }
 213         // SAFETY: a live widget the registry resolved, now out on loan: until it is put back
 214         // no resolver in the context hands it out, so this `&mut` is the only one.
 215         let out = f(unsafe { &mut *ptr }, self);
 216         self.tree.set_lent(id, false);
 217         Some(out)
 218     }
 219 
 220     /// [`lend`](Self::lend) by handle, typed.
 221     pub fn lend_h<W: WidgetHost + 'static, R>(&mut self, h: Handle<W>, f: impl FnOnce(&mut W, &mut UiContext) -> R) -> Option<R> {
 222         let root = self.tree.owned_root::<W>(h.id())?;
 223         if !self.tree.set_lent(h.id(), true) {
 224             return None;
 225         }
 226         // SAFETY: as in `lend`; the root is the tree's own `W`.
 227         let out = f(unsafe { &mut *root.as_ptr() }, self);
 228         self.tree.set_lent(h.id(), false);
 229         Some(out)
 230     }
 231 
 232     /// Hand `event` to widget `id`, lent for the call; a widget that takes it is marked dirty.
 233     fn deliver(&mut self, id: WidgetId, event: &Event) -> bool {
 234         self.lend(id, |w, ctx| {
 235             let handled = w.handle_event(event, ctx);
 236             if handled {
 237                 w.mark_dirty(ctx);
 238             }
 239             handled
 240         })
 241         .unwrap_or(false)
 242     }
 243 
 244     /// [`deliver`](Self::deliver), marking the widget dirty whatever it answers (the drag
 245     /// lifecycle's start and end).
 246     fn deliver_dirty(&mut self, id: WidgetId, event: &Event) {
 247         self.lend(id, |w, ctx| {
 248             w.handle_event(event, ctx);
 249             w.mark_dirty(ctx);
 250         });
 251     }
 252 
 253     /// A widget's rect, read without lending it.
 254     fn rect_of(&self, id: WidgetId) -> Option<(f32, f32, f32, f32)> {
 255         self.get_widget(id).map(|w| w.rect())
 256     }
 257 
 258     /// Dispatch an event into the tree rooted at `root` — a `WidgetId` resolved through the
 259     /// registry (the plumbing retype: the router's last raw-pointer API boundary is gone; apps
 260     /// name roots by id and the registry is the one place a pointer lives). The root must be
 261     /// registered — apps already register every widget for focus/coverage — and an
 262     /// unresolvable root is a loud no-op, never a deref.
 263     /// The scroll-gesture bookkeeping every wheel dispatch must pass
 264     /// through: a gap over 250ms since the last wheel starts a NEW gesture
 265     /// (`scroll_gesture_new`, and the initiator is cleared), a shorter gap
 266     /// continues the current one. `propagate_event` calls this for the
 267     /// wheels it routes; a host that hands a wheel straight to a widget's
 268     /// `handle_event` (the designer's modal dialog, whose panes it dispatches
 269     /// itself) calls it first — or the flags stay whatever the last routed
 270     /// wheel left, and a pane inside the dialog reads a fresh gesture as the
 271     /// tail of one the MAIN pane owned and lets the control under the pointer
 272     /// take it (2026-09-20: the Settings list would not scroll after the
 273     /// params pane had).
 274     pub fn note_scroll_event(&mut self) {
 275         let now = web_time::Instant::now();
 276         let elapsed_ms = match self.last_scroll_time {
 277             None => 999999,
 278             Some(last) => now.duration_since(last).as_millis(),
 279         };
 280         if elapsed_ms >= 5 {
 281             let is_new_gesture = elapsed_ms > 250;
 282             if is_new_gesture {
 283                 self.scroll_initiate_widget_id = None;
 284                 self.scroll_gesture_new = true;
 285             } else {
 286                 self.scroll_gesture_new = false;
 287             }
 288             if crate::scroll_debug() {
 289                 eprintln!(
 290                     "[scroll] router: gap={elapsed_ms}ms new_gesture={is_new_gesture} initiator={:?}",
 291                     self.scroll_initiate_widget_id
 292                 );
 293             }
 294             self.last_scroll_time = Some(now);
 295         }
 296     }
 297 
 298     pub fn propagate_event(&mut self, event: &Event, root: WidgetId) -> bool {
 299         if self.tree.get_ptr(root).is_none() {
 300             eprintln!("propagate_event: unregistered/stale root {root:?} — event dropped");
 301             return false;
 302         }
 303         if let Event::MouseWheel { .. } = event {
 304             self.note_scroll_event();
 305         }
 306         if let Event::KeyInput(ref key_event) = event {
 307             let is_scroll_key = matches!(
 308                 &key_event.logical_key,
 309                 Key::Named(NamedKey::PageUp)
 310                     | Key::Named(NamedKey::PageDown)
 311                     | Key::Named(NamedKey::Home)
 312                     | Key::Named(NamedKey::End)
 313                     | Key::Named(NamedKey::ArrowUp)
 314                     | Key::Named(NamedKey::ArrowDown)
 315             );
 316             if is_scroll_key {
 317                 if let Some(focused) = self.focused_widget {
 318                     if self.deliver(focused, event) {
 319                         return true;
 320                     }
 321                 }
 322                 let (cx, cy) = self.cursor_pos;
 323                 if let Some(scrollable) = self.find_hovered_scrollable(root, cx, cy) {
 324                     if self.deliver(scrollable, event) {
 325                         return true;
 326                     }
 327                 }
 328             }
 329         }
 330         self.propagate_event_impl(event, root)
 331     }
 332 
 333     /// The dispatch body, by id: each widget it calls is lent for the call.
 334     fn propagate_event_impl(&mut self, event: &Event, root: WidgetId) -> bool {
 335         if let Event::Tick(_) = event {
 336             return false;
 337         }
 338         // Track drag gestures based on mouse events
 339         match event {
 340             Event::MouseButton { button, state, x, y, .. } if *button == MouseButton::Left => {
 341                 if *state == ElementState::Pressed {
 342                     // Apps re-dispatch the SAME press to several roots (a plain loop
 343                     // over their top-level widgets); only the first call for a given
 344                     // press may reset the drag bookkeeping — a later call would wipe
 345                     // the target an earlier root just armed, killing the drag before
 346                     // its first move. drag_start_pos is cleared on release, so an
 347                     // equal position here means "same press, next root".
 348                     if self.drag_start_pos != Some((*x, *y)) {
 349                         // A fresh press while a grab is still armed means the
 350                         // release never arrived (lost to a focus change or eaten
 351                         // compositor-side). End the stale drag and drop the grab —
 352                         // otherwise active_grab redirects every event to the old
 353                         // target forever and the whole UI stops responding.
 354                         if self.active_grab.is_some() {
 355                             if self.is_dragging {
 356                                 if let Some(target) = self.drag_target {
 357                                     self.deliver_dirty(target, &Event::DragEnd);
 358                                 }
 359                             }
 360                             self.active_grab = None;
 361                         }
 362                         self.drag_start_pos = Some((*x, *y));
 363                         self.is_dragging = false;
 364                         self.drag_target = None;
 365                     }
 366                 } else if *state == ElementState::Released {
 367                     if self.is_dragging {
 368                         if let Some(target) = self.drag_target {
 369                             self.deliver_dirty(target, &Event::DragEnd);
 370                         }
 371                         self.active_grab = None;
 372                     }
 373                     self.drag_start_pos = None;
 374                     self.drag_target = None;
 375                     self.is_dragging = false;
 376                 }
 377             }
 378             Event::PointerMove { x, y, .. } => {
 379                 if let Some((sx, sy)) = self.drag_start_pos {
 380                     if let Some(target_id) = self.drag_target {
 381                         let (dx, dy) = (*x - sx, *y - sy);
 382                         if self.is_dragging {
 383                             if let Some((cx, cy, _, _)) = self.rect_of(target_id) {
 384                                 let drag_evt = Event::DragUpdate { dx, dy, x: *x, y: *y, local_x: *x - cx, local_y: *y - cy };
 385                                 self.deliver_dirty(target_id, &drag_evt);
 386                             }
 387                         } else if (dx * dx + dy * dy).sqrt() > 3.0 {
 388                             self.is_dragging = true;
 389                             self.active_grab = Some(target_id);
 390                             self.deliver_dirty(target_id, &Event::DragStart { start_x: sx, start_y: sy });
 391                         }
 392                     }
 393                 }
 394             }
 395             _ => {}
 396         }
 397 
 398         // Normal grab redirection for mouse events if active
 399         if let Some(grabbed_id) = self.active_grab {
 400             if let Event::PointerMove { .. }
 401             | Event::MouseButton { .. }
 402             | Event::MouseWheel { .. }
 403             | Event::DragStart { .. }
 404             | Event::DragUpdate { .. }
 405             | Event::DragEnd = event
 406             {
 407                 if self.tree.get_ptr(grabbed_id).is_some() {
 408                     return self.deliver(grabbed_id, event);
 409                 }
 410             }
 411         }
 412 
 413         // For KeyInput, send directly to focused widget if it exists
 414         if let Event::KeyInput(_) = event {
 415             if let Some(focused) = self.focused_widget {
 416                 if self.deliver(focused, event) {
 417                     return true;
 418                 }
 419             }
 420         }
 421 
 422         let mut handled = false;
 423         let mut children: Vec<WidgetId> =
 424             self.tree.child_ids(root).into_iter().filter(|&c| self.tree.get_ptr(c).is_some()).collect();
 425         children.sort_by_key(|&c| self.get_widget(c).map_or(0, |w| w.z_index()));
 426 
 427         // Determine if we should record a drag target candidate
 428         let check_drag_target = matches!(
 429             event,
 430             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. }
 431         );
 432 
 433         let localized = |event: &Event, rect: Option<(f32, f32, f32, f32)>| {
 434             let (cx, cy, _, _) = rect.unwrap_or_default();
 435             let mut local_adjusted = event.clone();
 436             match &mut local_adjusted {
 437                 Event::PointerMove { local_x, local_y, .. }
 438                 | Event::MouseButton { local_x, local_y, .. }
 439                 | Event::MouseWheel { local_x, local_y, .. }
 440                 | Event::DragUpdate { local_x, local_y, .. } => {
 441                     *local_x -= cx;
 442                     *local_y -= cy;
 443                 }
 444                 _ => {}
 445             }
 446             local_adjusted
 447         };
 448 
 449         match event {
 450             Event::PointerMove { .. } | Event::Tick(_) => {
 451                 for child in children.into_iter().rev() {
 452                     let local_adjusted = localized(event, self.rect_of(child));
 453                     if self.propagate_event_impl(&local_adjusted, child) {
 454                         handled = true;
 455                     }
 456                 }
 457                 if self.deliver(root, event) {
 458                     handled = true;
 459                 }
 460             }
 461             _ => {
 462                 for child in children.into_iter().rev() {
 463                     let local_adjusted = localized(event, self.rect_of(child));
 464                     if self.propagate_event_impl(&local_adjusted, child) {
 465                         if check_drag_target {
 466                             self.drag_target = Some(child);
 467                         }
 468                         return true;
 469                     }
 470                 }
 471                 if self.deliver(root, event) {
 472                     if check_drag_target {
 473                         self.drag_target = Some(root);
 474                     }
 475                     return true;
 476                 }
 477             }
 478         }
 479         handled
 480     }
 481 
 482     pub fn is_dirty(&self) -> bool {
 483         self.any_dirty
 484     }
 485 
 486     pub fn clear_dirty(&mut self) {
 487         self.any_dirty = false;
 488         let ptrs: Vec<*mut (dyn WidgetHost + 'static)> =
 489             self.tree.iter_registered().map(|(_, ptr)| ptr).collect();
 490         for ptr in ptrs {
 491             unsafe {
 492                 (*ptr).base_mut().dirty = false;
 493             }
 494         }
 495         self.rebuild_spatial_grid();
 496     }
 497 
 498     pub fn rebuild_spatial_grid(&mut self) {
 499         self.spatial_grid.clear();
 500         let entries: Vec<(WidgetId, *mut (dyn WidgetHost + 'static))> =
 501             self.tree.iter_registered().collect();
 502         for (id, ptr) in entries {
 503             unsafe {
 504                 let rect = (*ptr).rect();
 505                 self.spatial_grid.insert(id, rect);
 506             }
 507         }
 508     }
 509 
 510     pub fn register_tick_receiver(&mut self, id: WidgetId) {
 511         if !self.tick_receivers.contains(&id) {
 512             self.tick_receivers.push(id);
 513         }
 514     }
 515 
 516     pub fn unregister_tick_receiver(&mut self, id: WidgetId) {
 517         self.tick_receivers.retain(|&x| x != id);
 518     }
 519 
 520     pub fn is_widget_visible(&self, id: WidgetId) -> bool {
 521         let mut curr = id;
 522         loop {
 523             if let Some(w_ptr) = self.tree.get_ptr(curr) {
 524                 unsafe {
 525                     if !(*w_ptr).visible() {
 526                         return false;
 527                     }
 528                 }
 529             } else {
 530                 return false;
 531             }
 532             if let Some(parent_id) = self.tree.parent_id(curr) {
 533                 curr = parent_id;
 534             } else {
 535                 break;
 536             }
 537         }
 538         true
 539     }
 540 
 541     /// Number of `tick` calls so far (see the field doc).
 542     pub fn tick_count(&self) -> u64 {
 543         self.tick_count
 544     }
 545 
 546     pub fn tick(&mut self, dt: f32) -> bool {
 547         self.tick_count = self.tick_count.wrapping_add(1);
 548         let mut changed = false;
 549         let ids = self.tick_receivers.clone();
 550         for id in ids {
 551             if self.is_widget_visible(id) {
 552                 let ticked = self.lend(id, |w, ctx| {
 553                     let moved = w.tick(dt, ctx);
 554                     if moved {
 555                         w.mark_dirty(ctx);
 556                     }
 557                     moved
 558                 });
 559                 changed |= ticked.unwrap_or(false);
 560             }
 561         }
 562         changed
 563     }
 564 
 565     // --- Focus management (id-keyed; Phase 6bc) ---
 566     pub fn set_focused_id(&mut self, id: WidgetId) {
 567         if self.focused_widget == Some(id) {
 568             return;
 569         }
 570         if let Some(old_id) = self.focused_widget {
 571             self.lend(old_id, |w, ctx| {
 572                 w.unfocus();
 573                 w.handle_event(&Event::FocusOut, ctx);
 574             });
 575         }
 576         self.focused_widget = Some(id);
 577         // A widget focusing itself mid-event is out on loan: it is not told (`claim_focus`).
 578         self.lend(id, |w, ctx| {
 579             w.handle_event(&Event::FocusIn, ctx);
 580         });
 581     }
 582 
 583     pub fn is_focused(&self, w: &dyn WidgetHost) -> bool {
 584         self.is_focused_id(w.base().id())
 585     }
 586 
 587     pub fn is_focused_id(&self, id: WidgetId) -> bool {
 588         self.focused_widget == Some(id)
 589     }
 590 
 591     /// Offer a context action to the focused widget — the runner's first stop
 592     /// for the `undo` / `redo` chords. Returns whether the widget applied it;
 593     /// a widget that did is marked dirty.
 594     pub fn focused_context_action(&mut self, action: crate::widget::ContextAction) -> bool {
 595         let Some(id) = self.focused_widget else { return false };
 596         self.lend(id, |w, ctx| {
 597             let applied = w.context_action(action);
 598             if applied {
 599                 w.mark_dirty(ctx);
 600             }
 601             applied
 602         })
 603         .unwrap_or(false)
 604     }
 605 
 606     pub fn clear_focus(&mut self) {
 607         if let Some(id) = self.focused_widget.take() {
 608             self.lend(id, |w, ctx| {
 609                 w.unfocus();
 610                 w.handle_event(&Event::FocusOut, ctx);
 611             });
 612         }
 613     }
 614 
 615     /// Open a modal owned by `owner` (a `Dialog`) around `members`: the Tab walk is trapped
 616     /// among them (and their embedded children), every widget outside reads as covered
 617     /// ([`UiContext::is_coordinate_covered`], which every hit test and hover asks), and focus
 618     /// moves to the first stop inside, remembering where it was. Modals nest; the innermost
 619     /// rules. Opening one already open replaces its members and keeps its focus memory.
 620     pub fn open_modal(&mut self, owner: WidgetId, members: Vec<WidgetId>) {
 621         self.invalidate_coverage_cache();
 622         if let Some(scope) = self.modals.iter_mut().find(|m| m.owner == owner) {
 623             scope.members = members;
 624             return;
 625         }
 626         let restore = self.focused_widget;
 627         self.modals.push(ModalScope { owner, members, restore });
 628         match self.focus_stops().first() {
 629             Some(&first) => self.set_focused_id(first),
 630             None => self.clear_focus(),
 631         }
 632     }
 633 
 634     /// Close the modal `owner` opened, giving focus back to what had it before (if that is
 635     /// still registered and reachable), else to nothing.
 636     pub fn close_modal(&mut self, owner: WidgetId) {
 637         let Some(i) = self.modals.iter().position(|m| m.owner == owner) else { return };
 638         let scope = self.modals.remove(i);
 639         self.invalidate_coverage_cache();
 640         let inside = |ctx: &Self, id: WidgetId| ctx.tree.is_registered(id) && ctx.in_modal_scope(id);
 641         match scope.restore.filter(|&id| inside(self, id)) {
 642             Some(id) => self.set_focused_id(id),
 643             None => self.clear_focus(),
 644         }
 645     }
 646 
 647     /// The owner of the innermost open modal.
 648     pub fn modal_owner(&self) -> Option<WidgetId> {
 649         self.modals.last().map(|m| m.owner)
 650     }
 651 
 652     /// Whether `id` may take input: true with no modal open; with one, true for the modal's
 653     /// owner, its members and anything under them in the tree.
 654     pub fn in_modal_scope(&self, id: WidgetId) -> bool {
 655         let Some(scope) = self.modals.last() else { return true };
 656         let mut at = Some(id);
 657         // Bounded: a malformed parent chain must not hang the walk.
 658         for _ in 0..64 {
 659             let Some(cur) = at else { return false };
 660             if cur == scope.owner || scope.members.contains(&cur) {
 661                 return true;
 662             }
 663             at = self.tree.parent_id(cur);
 664         }
 665         false
 666     }
 667 
 668     /// Make `id` the window's focus from INSIDE that widget's own event handling: recorded,
 669     /// and the holder before told (`unfocus`), but no FocusIn delivered back to `id` —
 670     /// it is running, holding `&mut self`, and a FocusIn through the registry would be a
 671     /// second `&mut` to it while the first is live. What a widget that focuses itself does
 672     /// (`EventCtx::request_focus`; a tree list on a click into its rows), setting whatever
 673     /// its FocusIn would have set itself.
 674     pub fn claim_focus(&mut self, id: WidgetId) {
 675         if let Some(old) = self.focused_widget.filter(|old| *old != id) {
 676             if let Some(ptr) = self.tree.get_ptr(old) {
 677                 // SAFETY: a registry-resolved live widget other than the claimant.
 678                 unsafe { (*ptr).unfocus() };
 679             }
 680         }
 681         self.focused_widget = Some(id);
 682     }
 683 
 684     /// Focus `id` as a direct `w.focus()` did — the widget is told (`focus`), the holder
 685     /// before it is told it lost focus (`unfocus`) — and record it as the window's focus,
 686     /// which a direct call never did: the Tab walk and the accessibility tree read the
 687     /// record. For an app that drives a widget's focus itself (`docs/rfc-global-state.md`,
 688     /// phase 2); a focus change the context should announce with FocusIn / FocusOut is
 689     /// [`set_focused_id`](Self::set_focused_id).
 690     pub fn focus_id(&mut self, id: WidgetId) {
 691         if let Some(old) = self.focused_widget.filter(|old| *old != id) {
 692             if let Some(w) = self.get_widget_mut(old) {
 693                 w.unfocus();
 694             }
 695         }
 696         self.focused_widget = Some(id);
 697         if let Some(w) = self.get_widget_mut(id) {
 698             w.focus();
 699         }
 700     }
 701 
 702     /// Unfocus `id` as a direct `w.unfocus()` did, and drop the window's record of focus if
 703     /// it was `id` — which a direct call never did, leaving the Tab walk and the
 704     /// accessibility tree on a widget that had let go.
 705     pub fn unfocus_id(&mut self, id: WidgetId) {
 706         if self.focused_widget == Some(id) {
 707             self.focused_widget = None;
 708         }
 709         if let Some(w) = self.get_widget_mut(id) {
 710             w.unfocus();
 711         }
 712     }
 713 
 714     pub fn has_focus(&self) -> bool {
 715         self.focused_widget.is_some()
 716     }
 717 
 718     /// The keyboard stops in reading order (row, then x): the registered,
 719     /// visible, on-screen widgets with a `focus_role`. Rows are bucketed by
 720     /// vertical overlap, so a short control centred beside a taller one is on
 721     /// its row. `CCE_FOCUS_DEBUG=1` prints them.
 722     fn focus_stops(&self) -> Vec<WidgetId> {
 723         // (y, bottom, x, id) per stop.
 724         let mut found: Vec<(f32, f32, f32, WidgetId)> = Vec::new();
 725         for (id, ptr) in self.tree.iter_registered() {
 726             if ptr.is_null() {
 727                 continue;
 728             }
 729             let w = unsafe { &*ptr };
 730             if w.focus_role() == crate::widget::FocusRole::None || !w.visible() || !self.in_modal_scope(id) {
 731                 continue;
 732             }
 733             let (x, y, width, height) = w.rect();
 734             if width <= 0.0 || height <= 0.0 {
 735                 continue;
 736             }
 737             // Parked off-screen (the hidden-editor idiom: a 1x1 rect at
 738             // (-1000, -1000)) — nothing to see, so not a stop.
 739             if x + width <= 0.0 || y + height <= 0.0 {
 740                 continue;
 741             }
 742             found.push((y, y + height, x, id));
 743         }
 744         if found.is_empty() {
 745             if std::env::var_os("CCE_FOCUS_DEBUG").is_some() {
 746                 eprintln!("[focus] no stops: no registered, visible widget with a focus role and a rect");
 747             }
 748             return Vec::new();
 749         }
 750         // Reading order: rows first, x within a row. A stop joins the current
 751         // row when its top lies above the row's first stop's bottom — a 12px
 752         // checkbox centred a few px below the 26px button beside it is on the
 753         // button's row, not a row of its own.
 754         found.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
 755         let mut rows: Vec<Vec<(f32, f32, f32, WidgetId)>> = Vec::new();
 756         for s in found {
 757             match rows.last_mut() {
 758                 Some(row) if s.0 < row[0].1 => row.push(s),
 759                 _ => rows.push(vec![s]),
 760             }
 761         }
 762         let mut stops: Vec<WidgetId> = Vec::new();
 763         for mut row in rows {
 764             row.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
 765             stops.extend(row.into_iter().map(|s| s.3));
 766         }
 767         stops
 768     }
 769 
 770     /// The stops in WALK order, clustered: a registered `Group`'s members are
 771     /// one contiguous run, placed where the group's first member falls in
 772     /// reading order and ordered among themselves by reading order; every
 773     /// other stop is a run of one. A member of two groups belongs to the
 774     /// group that comes first. Tab walks the runs end to end
 775     /// (`focus_step`); the group chords jump between them (`focus_step_group`).
 776     pub fn focus_clusters(&self) -> Vec<Vec<WidgetId>> {
 777         let stops = self.focus_stops();
 778         if stops.is_empty() {
 779             return Vec::new();
 780         }
 781         // Each group's member positions among the stops, groups ordered by
 782         // their first member.
 783         let mut groups: Vec<Vec<usize>> = Vec::new();
 784         for (_, ptr) in self.tree.iter_registered() {
 785             if ptr.is_null() {
 786                 continue;
 787             }
 788             let w = unsafe { &*ptr };
 789             let Some(g) = w.as_any().downcast_ref::<crate::widget::Group>() else { continue };
 790             let mut pos: Vec<usize> = g.members().iter().filter_map(|m| stops.iter().position(|s| s == m)).collect();
 791             pos.sort_unstable();
 792             pos.dedup();
 793             if !pos.is_empty() {
 794                 groups.push(pos);
 795             }
 796         }
 797         groups.sort_by_key(|p| p[0]);
 798         let mut claimed = vec![false; stops.len()];
 799         let mut clusters: Vec<(usize, Vec<WidgetId>)> = Vec::new();
 800         for pos in groups {
 801             let free: Vec<usize> = pos.into_iter().filter(|&i| !claimed[i]).collect();
 802             if free.is_empty() {
 803                 continue;
 804             }
 805             for &i in &free {
 806                 claimed[i] = true;
 807             }
 808             clusters.push((free[0], free.iter().map(|&i| stops[i]).collect()));
 809         }
 810         for (i, id) in stops.iter().enumerate() {
 811             if !claimed[i] {
 812                 clusters.push((i, vec![*id]));
 813             }
 814         }
 815         clusters.sort_by_key(|c| c.0);
 816         let clusters: Vec<Vec<WidgetId>> = clusters.into_iter().map(|c| c.1).collect();
 817         // CCE_FOCUS_DEBUG=1: the runs in walk order, with what each stop is.
 818         if std::env::var_os("CCE_FOCUS_DEBUG").is_some() {
 819             for (ci, run) in clusters.iter().enumerate() {
 820                 for (i, id) in run.iter().enumerate() {
 821                     if let Some(ptr) = self.tree.get_ptr(*id) {
 822                         let w = unsafe { &*ptr };
 823                         let (x, y, width, height) = w.rect();
 824                         eprintln!(
 825                             "[focus] run {ci} stop {i}: {} {:?} at ({x:.0},{y:.0} {width:.0}x{height:.0}){}",
 826                             w.type_name(),
 827                             w.focus_role(),
 828                             if self.focused_widget == Some(*id) { " <- focused" } else { "" }
 829                         );
 830                     }
 831                 }
 832             }
 833         }
 834         clusters
 835     }
 836 
 837     /// Keyboard navigation in plate terms (see "Plates, wells and seams" in
 838     /// `CLAUDE.md`): move focus to the next (`reverse` = previous) plate or
 839     /// well in walk order — reading order, a group's members walked together
 840     /// (`focus_clusters`). The traversal wraps, and with nothing focused the
 841     /// first (or last) stop takes it. Focusing goes through `set_focused_id`,
 842     /// so the new stop gets its `FocusIn` — a well opens for typing, a plate
 843     /// arms Enter / Space. Returns whether focus moved. The runner calls this
 844     /// for Tab when the app opts in (`Application::plate_navigation`).
 845     pub fn focus_step(&mut self, reverse: bool) -> bool {
 846         let stops: Vec<WidgetId> = self.focus_clusters().into_iter().flatten().collect();
 847         if stops.is_empty() {
 848             return false;
 849         }
 850         let n = stops.len();
 851         let current = self.focused_widget.and_then(|f| stops.iter().position(|s| *s == f));
 852         let next = match (current, reverse) {
 853             (Some(i), false) => (i + 1) % n,
 854             (Some(i), true) => (i + n - 1) % n,
 855             (None, false) => 0,
 856             (None, true) => n - 1,
 857         };
 858         let id = stops[next];
 859         if self.focused_widget == Some(id) {
 860             return false;
 861         }
 862         self.set_focused_id(id);
 863         true
 864     }
 865 
 866     /// Jump to the next (`reverse` = previous) run of `focus_clusters` — the
 867     /// next group, or the next ungrouped stop — landing on its first stop;
 868     /// wraps. The runner calls this for the `focus_next_group` /
 869     /// `focus_prev_group` chords (input.kdl, cce-ui domain; defaults
 870     /// `ctrl+tab` / `ctrl+shift+tab`) when the app opts in.
 871     pub fn focus_step_group(&mut self, reverse: bool) -> bool {
 872         let clusters = self.focus_clusters();
 873         if clusters.is_empty() {
 874             return false;
 875         }
 876         let n = clusters.len();
 877         let current = self.focused_widget.and_then(|f| clusters.iter().position(|c| c.contains(&f)));
 878         let next = match (current, reverse) {
 879             (Some(i), false) => (i + 1) % n,
 880             (Some(i), true) => (i + n - 1) % n,
 881             (None, false) => 0,
 882             (None, true) => n - 1,
 883         };
 884         let id = clusters[next][0];
 885         if self.focused_widget == Some(id) {
 886             return false;
 887         }
 888         self.set_focused_id(id);
 889         true
 890     }
 891 
 892     // `navigate_focus` (tree-walk ctrl-nav) is DELETED (the plumbing retype): it had
 893     // zero callers — its `focus::navigate_focus` twin was the one wired up, and that one
 894     // walked an empty dummy context (provably inert). Section-level keyboard nav lives
 895     // app-side (settings' focused_section machinery).
 896 
 897     pub fn link_ids(&mut self, parent: WidgetId, child: WidgetId) {
 898         self.tree.link(parent, child);
 899     }
 900 
 901     pub fn unlink_child(&mut self, parent: WidgetId, child: WidgetId) {
 902         self.tree.unlink(parent, child);
 903     }
 904 
 905     pub fn clear_children_ids(&mut self, parent: WidgetId) {
 906         self.tree.clear_children(parent);
 907     }
 908 
 909     pub fn clear_hierarchy(&mut self) {
 910         self.tree.clear_all();
 911         self.invalidate_coverage_cache();
 912     }
 913 
 914     // --- Popovers ---
 915     pub fn clear_popovers(&mut self) {
 916         self.active_popovers.clear();
 917         self.invalidate_coverage_cache();
 918     }
 919 
 920     /// Close any open popover whose owner the press MISSED — the engine calls
 921     /// this on every Left press before the app's dispatch, so an outside click
 922     /// always reaches an open menu even in apps that region-gate their event
 923     /// routing (a canvas click never reaching a sidebar dropdown's root).
 924     /// Scans the whole registry (like `is_coordinate_covered`'s fallback) —
 925     /// popover registration is optional and spotty across apps. A press ON the
 926     /// owner (trigger or popover) is left entirely to the app's own dispatch:
 927     /// its `take_change` plumbing is gated on that delivery. Owners receive the
 928     /// real press event, so their ordinary outside-press handling runs; a
 929     /// second delivery through the app's own dispatch is idempotent (a closing
 930     /// dropdown ignores further presses).
 931     pub fn close_popovers_missed_by_press(&mut self, x: f32, y: f32) {
 932         self.close_popovers_missed_by_press_with(x, y, |_| (0.0, 0.0));
 933     }
 934 
 935     /// The widgets with an open popover, in registry order.
 936     pub fn popover_owners(&self) -> Vec<WidgetId> {
 937         self.tree
 938             .iter_registered()
 939             .filter_map(|(id, ptr)| unsafe {
 940                 ptr.as_ref().and_then(|w| {
 941                     (w.visible() && w.popover_rect().is_some()).then_some(id)
 942                 })
 943             })
 944             .collect()
 945     }
 946 
 947     /// [`close_popovers_missed_by_press`](Self::close_popovers_missed_by_press)
 948     /// for a press in SURFACE coordinates: `offset` is where each owner is
 949     /// drawn relative to where it was laid out
 950     /// (`Application::popover_offset`), and the press is carried back into
 951     /// the owner's own coordinates before it is tested and delivered. On a
 952     /// scrolled page the unshifted test called a press on a menu row a miss,
 953     /// and closed the menu under the click.
 954     pub fn close_popovers_missed_by_press_with(&mut self, x: f32, y: f32, offset: impl Fn(WidgetId) -> (f32, f32)) {
 955         for id in self.popover_owners() {
 956             let Some(w) = self.get_widget(id) else { continue };
 957             let (dx, dy) = offset(id);
 958             let (x, y) = (x - dx, y - dy);
 959             if !w.hit_test(x, y, self) {
 960                 let ev = Event::MouseButton {
 961                     button: crate::widget::MouseButton::Left,
 962                     state: crate::widget::ElementState::Pressed,
 963                     x,
 964                     y,
 965                     local_x: x,
 966                     local_y: y,
 967                 };
 968                 self.lend(id, |w, ctx| {
 969                     w.handle_event(&ev, ctx);
 970                 });
 971             }
 972         }
 973     }
 974 
 975     /// The registered widget whose OPEN popover contains `(x, y)`, if any — the
 976     /// press-priority companion to
 977     /// [`close_popovers_missed_by_press`](Self::close_popovers_missed_by_press).
 978     /// A popover paints OVER whatever sits beneath it, but positional dispatch
 979     /// knows nothing about z-order: an app iterating its roots can hand the
 980     /// press to a closed sibling whose trigger band lies under the open menu
 981     /// (the Default Apps page's Terminal dropdown covering the Images row).
 982     /// Apps route a `MouseButton` to this owner before their positional
 983     /// dispatch. Scans the registry like the missed-press walk — popover
 984     /// registration is optional and spotty, so `active_popovers` alone cannot
 985     /// be trusted to know about every open menu.
 986     pub fn popover_owner_at(&self, x: f32, y: f32) -> Option<WidgetId> {
 987         self.tree.iter_registered().find_map(|(id, ptr)| unsafe {
 988             ptr.as_ref().and_then(|w| {
 989                 if !w.visible() {
 990                     return None;
 991                 }
 992                 let (rx, ry, rw, rh) = w.popover_rect()?;
 993                 (x >= rx && x <= rx + rw && y >= ry && y <= ry + rh).then_some(id)
 994             })
 995         })
 996     }
 997 
 998     /// Register the open popover of the widget `id` names (the occlusion walks resolve it
 999     /// through the tree).
1000     pub fn register_popover_id(&mut self, id: WidgetId) {
1001         if !self.active_popovers.contains(&id) {
1002             self.active_popovers.push(id);
1003         }
1004         self.invalidate_coverage_cache();
1005     }
1006 
1007     /// Whether `(px, py)` is covered by an open popover or a popover-carrying widget other
1008     /// than `query_id` (the querying widget excludes itself). Every widget has a base id
1009     /// now (the flip) — the old `WidgetId(0)` no-base sentinel is gone.
1010     /// Is `(px, py)` covered by some widget's popover rect other than `query_id` — or does
1011     /// `query_id` lie behind an open modal ([`UiContext::open_modal`]), where everything is?
1012     ///
1013     /// The covering set depends only on the point, so it is computed once and
1014     /// memoized; `query_id` is applied afterwards as an exclusion. See the
1015     /// `covered_at` field for why the previous per-call registry scan mattered.
1016     pub fn is_coordinate_covered(&self, query_id: WidgetId, px: f32, py: f32) -> bool {
1017         // Behind an open modal everything is covered, wherever the point is.
1018         if !self.in_modal_scope(query_id) {
1019             return true;
1020         }
1021         let mut cache = self.covered_cache.borrow_mut();
1022         if cache.0 != Some((px, py)) {
1023             cache.1.clear();
1024             for &pop_id in self.active_popovers.iter() {
1025                 if let Some(ptr) = self.tree.get_ptr(pop_id) {
1026                     unsafe {
1027                         if let Some((x, y, width, height)) = (*ptr).popover_rect() {
1028                             if px >= x && px <= x + width && py >= y && py <= y + height {
1029                                 cache.1.push(pop_id);
1030                             }
1031                         }
1032                     }
1033                 }
1034             }
1035             for (id, ptr) in self.tree.iter_registered() {
1036                 unsafe {
1037                     if let Some(w) = ptr.as_ref() {
1038                         if w.visible() {
1039                             if let Some((x, y, width, height)) = w.popover_rect() {
1040                                 if px >= x && px <= x + width && py >= y && py <= y + height {
1041                                     cache.1.push(id);
1042                                 }
1043                             }
1044                         }
1045                     }
1046                 }
1047             }
1048             cache.0 = Some((px, py));
1049         }
1050         cache.1.iter().any(|&id| id != query_id)
1051     }
1052 
1053     /// Drop the `is_coordinate_covered` memo — whenever the registry changes or
1054     /// a popover's geometry may have moved under a stationary cursor.
1055     pub fn invalidate_coverage_cache(&self) {
1056         let mut cache = self.covered_cache.borrow_mut();
1057         cache.0 = None;
1058         cache.1.clear();
1059     }
1060 
1061     // The hover highlight is `widget::hover_animation`'s (one store; the context's
1062     // write-only copy went with the global-state RFC's phase 1). The cursor is the
1063     // context's: the scroll box reads it.
1064     pub fn set_cursor_pos(&mut self, x: f32, y: f32) {
1065         self.cursor_pos = (x, y);
1066     }
1067 
1068 
1069     // NOTE: the animated hover-highlight for this context previously lived here as
1070     // `tick_hover` / `get_hover_quad`, duplicating the live thread-local implementation in
1071     // widget/core.rs (`hover_animation`). Both were dead (zero callers workspace-wide) and were
1072     // removed; the single source of truth is `hover_animation`. This will be folded into the
1073     // Animated<T> primitive in the core rebuild (see cce-ui/docs/rfc-core-rebuild.md, Phase 4).
1074 
1075     // --- Context Menu ---
1076     pub fn is_context_menu_visible(&self) -> bool {
1077         crate::widget::context_menu::is_visible()
1078     }
1079 
1080     /// Open the shared context menu on the widget `target`, which its actions go to.
1081     pub fn show_context_menu(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
1082         crate::widget::context_menu::show(x, y, options, header_count, target);
1083     }
1084 
1085     /// [`show_context_menu`](Self::show_context_menu) with each row's action beside its
1086     /// label (`None` for a header, or a row the host handles itself), so the label is only
1087     /// what is shown and a translated menu still does what it did.
1088     pub fn show_context_menu_rows(&mut self, x: f32, y: f32, rows: Vec<(String, Option<crate::widget::ContextAction>)>, header_count: usize, target: WidgetId) {
1089         let (options, actions): (Vec<String>, Vec<Option<crate::widget::ContextAction>>) = rows.into_iter().unzip();
1090         self.show_context_menu(x, y, options, header_count, target);
1091         crate::widget::context_menu::set_row_actions(actions);
1092     }
1093 
1094     /// Open the shared config context menu for a right-click on `target` — the widget
1095     /// itself, which the context has out on loan while it handles the press, so it is handed
1096     /// over rather than looked up.
1097     pub fn handle_right_click(&mut self, target: &dyn WidgetHost, px: f32, py: f32) {
1098         let name = target.type_name();
1099         let label = if name == "Breadcrumb" {
1100             target.as_any().downcast_ref::<crate::widget::container::Breadcrumb>().map(|bc| {
1101                 let idx = bc.right_clicked_seg.unwrap_or(bc.path.len());
1102                 bc.path_to_seg(idx)
1103             })
1104         } else {
1105             target.label()
1106         };
1107         let header = if let Some(lbl) = label {
1108             format!("[{}]: {}", name, lbl)
1109         } else {
1110             format!("[{}]", name)
1111         };
1112 
1113         let mut config_info = None;
1114         {
1115             let b = target.base();
1116             if let (Some(ref file), Some(ref key)) = (&b.config_file, &b.config_key) {
1117                 config_info = Some((file.clone(), key.clone()));
1118             }
1119         }
1120 
1121         use crate::widget::ContextAction as CA;
1122         use crate::l10n::{tr, tr_args};
1123         // Each row's label in the user's language; what it does is its action, not its words.
1124         let row = |label: String, action: CA| (label, Some(action));
1125         let mut rows: Vec<(String, Option<CA>)> = vec![(header, None)];
1126         let mut header_count = 1;
1127 
1128         if let Some((file, key)) = config_info {
1129             rows.push((tr_args("menu-config-file", &[("file", &file)]), None));
1130             rows.push((tr_args("menu-config-key", &[("key", &key)]), None));
1131             header_count = 3;
1132         }
1133 
1134         if name == "TextBox" {
1135             let is_password = target
1136                 .as_any()
1137                 .downcast_ref::<crate::widget::input::TextBox>()
1138                 .is_some_and(|tb| tb.is_password);
1139             if !is_password {
1140                 rows.extend([row(tr("menu-cut"), CA::Cut), row(tr("menu-copy"), CA::Copy)]);
1141             }
1142             rows.extend([row(tr("menu-paste"), CA::Paste), row(tr("menu-select-all"), CA::SelectAll)]);
1143             // A search box says so (`with_search`); an English "Search..." placeholder is the
1144             // older sign, still read for the apps that set one themselves.
1145             let is_search = target
1146                 .as_any()
1147                 .downcast_ref::<crate::widget::input::TextBox>()
1148                 .is_some_and(|tb| tb.is_search || tb.placeholder.as_deref() == Some("Search..."));
1149             if is_search {
1150                 rows.push(row(tr("menu-clear"), CA::ClearText));
1151             }
1152         } else if name == "Breadcrumb" {
1153             rows.push(row(tr("menu-copy-path"), CA::CopyPath));
1154         } else if name == "Ramp" {
1155             let collapsed = target
1156                 .as_any()
1157                 .downcast_ref::<crate::widget::input::Ramp>()
1158                 .is_some_and(|r| r.controls_collapsed);
1159             let label = tr("menu-collapse-controls");
1160             let label = if collapsed { format!("{}{label}", crate::widget::context_menu::MARK_CHECK) } else { label };
1161             rows.push((label, Some(CA::ToggleRampControls)));
1162             rows.extend([row(tr("menu-copy"), CA::Copy), row(tr("menu-paste"), CA::Paste)]);
1163         } else {
1164             rows.extend([row(tr("menu-copy"), CA::Copy), row(tr("menu-paste"), CA::Paste)]);
1165         }
1166 
1167         let scroll_y = crate::widget::hover_animation::get_scroll_offset();
1168         let adjusted_py = py - scroll_y;
1169         self.show_context_menu_rows(px, adjusted_py, rows, header_count, target.base().id());
1170     }
1171 
1172     pub fn hide_context_menu(&mut self) {
1173         crate::widget::context_menu::hide();
1174     }
1175 
1176     pub fn hit_test_context_menu(&self, px: f32, py: f32) -> bool {
1177         crate::widget::context_menu::hit_test(px, py)
1178     }
1179 
1180     pub fn cursor_moved_context_menu(&mut self, px: f32, py: f32) -> bool {
1181         crate::widget::context_menu::cursor_moved(px, py)
1182     }
1183 
1184     pub fn mouse_input_context_menu(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
1185         crate::widget::context_menu::mouse_input(button, state, px, py, Some(self))
1186     }
1187 
1188     pub fn context_menu_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1189         crate::widget::context_menu::extra_quads()
1190     }
1191 
1192     pub fn context_menu_labels(&self) -> Vec<crate::widget::display::TextLabel> {
1193         crate::widget::context_menu::text_labels()
1194     }
1195 
1196     /// Whether the point lies inside an OPEN popover's plate. Popovers are drawn on top of
1197     /// everything and are interactive UI, but they are not spatial-grid widgets — a press
1198     /// there must never start a window move (the widgets beneath may not block dragging,
1199     /// e.g. Graph's edge-exclusive canvas hit test).
1200     fn point_in_active_popover(&self, px: f32, py: f32) -> bool {
1201         // The shared context menu is a popover too — a thread-local one,
1202         // with no widget id to register — and a press on one of its rows
1203         // used to start a window move in any app whose background does
1204         // not block dragging (cce-graph, cce-data-editor: the row never
1205         // fired, the window slid).
1206         if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(px, py) {
1207             return true;
1208         }
1209         for &pop_id in &self.active_popovers {
1210             if let Some(ptr) = self.tree.get_ptr(pop_id) {
1211                 unsafe {
1212                     if let Some((x, y, w, h)) = (*ptr).popover_rect() {
1213                         if px >= x && px <= x + w && py >= y && py <= y + h {
1214                             return true;
1215                         }
1216                     }
1217                 }
1218             }
1219         }
1220         false
1221     }
1222 
1223     /// The window-drag question (Phase 6: every root plate container is dissolved, so the surface
1224     /// itself is the movable plate): a drag may start anywhere no drag-blocking widget sits
1225     /// under the cursor.
1226     pub fn drag_allowed_at(&self, px: f32, py: f32) -> bool {
1227         if self.point_in_active_popover(px, py) {
1228             return false;
1229         }
1230         let scroll_y = crate::widget::hover_animation::get_scroll_offset();
1231         let mut candidate_ids = self.spatial_grid.query(px, py).to_vec();
1232         if scroll_y != 0.0 {
1233             candidate_ids.extend_from_slice(self.spatial_grid.query(px, py + scroll_y));
1234             candidate_ids.sort_unstable();
1235             candidate_ids.dedup();
1236         }
1237         for &id in &candidate_ids {
1238             if let Some(ptr) = self.tree.get_ptr(id) {
1239                 unsafe {
1240                     if !ptr.is_null() {
1241                         let w = &*ptr;
1242                         let is_hit = w.hit_test(px, py, self)
1243                             || (scroll_y != 0.0 && w.hit_test(px, py + scroll_y, self));
1244                         if is_hit && w.blocks_root_plate_drag() {
1245                             return false;
1246                         }
1247                     }
1248                 }
1249             }
1250         }
1251         true
1252     }
1253 
1254     pub fn is_widget_at(&self, px: f32, py: f32) -> bool {
1255         let scroll_y = crate::widget::hover_animation::get_scroll_offset();
1256         let mut candidate_ids = self.spatial_grid.query(px, py).to_vec();
1257         if scroll_y != 0.0 {
1258             candidate_ids.extend_from_slice(self.spatial_grid.query(px, py + scroll_y));
1259             candidate_ids.sort_unstable();
1260             candidate_ids.dedup();
1261         }
1262         for &id in &candidate_ids {
1263             if let Some(ptr) = self.tree.get_ptr(id) {
1264                 unsafe {
1265                     if !ptr.is_null() {
1266                         let w = &*ptr;
1267                         let is_hit = w.hit_test(px, py, self) || (scroll_y != 0.0 && w.hit_test(px, py + scroll_y, self));
1268                         if is_hit && w.blocks_root_plate_drag() {
1269                             return true;
1270                         }
1271                     }
1272                 }
1273             }
1274         }
1275         false
1276     }
1277 
1278     fn find_hovered_scrollable(&self, root: WidgetId, cx: f32, cy: f32) -> Option<WidgetId> {
1279         let w = self.get_widget(root)?;
1280         if !w.visible() || !w.hit_test(cx, cy, self) {
1281             return None;
1282         }
1283         for child in self.tree.child_ids(root).into_iter().rev() {
1284             if let Some(scrollable) = self.find_hovered_scrollable(child, cx, cy) {
1285                 return Some(scrollable);
1286             }
1287         }
1288         w.is_scrollable().then_some(root)
1289     }
1290 }
1291 
1292 /// `ctx[h]`: the widget `h` names. Panics if it was removed or is out on loan — use
1293 /// [`UiContext::get`] where either can happen.
1294 impl<W: WidgetHost + 'static> std::ops::Index<Handle<W>> for UiContext {
1295     type Output = W;
1296     fn index(&self, h: Handle<W>) -> &W {
1297         self.get(h).unwrap_or_else(|| panic!("{h:?} is not in the context (removed, or out on loan)"))
1298     }
1299 }
1300 
1301 impl<W: WidgetHost + 'static> std::ops::IndexMut<Handle<W>> for UiContext {
1302     fn index_mut(&mut self, h: Handle<W>) -> &mut W {
1303         self.get_mut(h).unwrap_or_else(|| panic!("{h:?} is not in the context (removed, or out on loan)"))
1304     }
1305 }
1306 
1307 #[cfg(test)]
1308 mod tests {
1309     use super::*;
1310     use crate::widget::{WidgetHost, Widget};
1311 
1312     /// The router's drag lifecycle drives the Input drag hooks end-to-end: a routed press
1313     /// records the drag target, the first >3px move synthesizes DragStart, further moves
1314     /// deliver DragUpdate (the slider value follows), and the release delivers DragEnd.
1315     /// Regression test for the silent-drop gap: `Input::on_event` defaults ignore Drag*
1316     /// events, so `Adapted::handle_event` must map them onto the hooks itself.
1317     #[test]
1318     fn routed_drag_reaches_input_drag_hooks() {
1319         use crate::widget::{ElementState, Event, MouseButton, Slider};
1320 
1321         let mut ctx = UiContext::new();
1322         let slider = ctx.insert(Slider::new());
1323         WidgetHost::set_rect(&mut ctx[slider], 0.0, 0.0, 200.0, 30.0);
1324         let id = slider.id();
1325 
1326         let press = Event::MouseButton {
1327             button: MouseButton::Left,
1328             state: ElementState::Pressed,
1329             x: 100.0,
1330             y: 15.0,
1331             local_x: 100.0,
1332             local_y: 15.0,
1333         };
1334         assert!(ctx.propagate_event(&press, id), "press in the track arms the drag");
1335         assert!(ctx[slider].is_dragging());
1336         let v0 = ctx[slider].value;
1337 
1338         // First move past the 3px threshold starts the drag; the next one updates it.
1339         let mv = |x: f32| Event::PointerMove { x, y: 15.0, local_x: x, local_y: 15.0 };
1340         ctx.propagate_event(&mv(110.0), id);
1341         assert!(ctx.is_dragging, "router crossed the drag threshold");
1342         ctx.propagate_event(&mv(140.0), id);
1343         assert!(
1344             ctx[slider].value > v0 + 0.05,
1345             "DragUpdate reached Input::drag_update (value {} -> {})",
1346             v0,
1347             ctx[slider].value
1348         );
1349 
1350         let release = Event::MouseButton {
1351             button: MouseButton::Left,
1352             state: ElementState::Released,
1353             x: 140.0,
1354             y: 15.0,
1355             local_x: 140.0,
1356             local_y: 15.0,
1357         };
1358         ctx.propagate_event(&release, id);
1359         assert!(!ctx[slider].is_dragging(), "DragEnd reached Input::drag_end");
1360         assert!(!ctx.is_dragging);
1361     }
1362 
1363     /// The multi-root press dispatch (how apps actually loop: one press propagated to
1364     /// EVERY top-level root, no break): a later root's propagate call must not wipe the
1365     /// drag target an earlier root just armed. This was live-broken in every plain-loop
1366     /// app (the demo, colors) while the single-root test above passed — found the first
1367     /// time a held drag could be driven headlessly (ccectl pointer-press).
1368     #[test]
1369     fn multi_root_press_dispatch_keeps_the_drag_target() {
1370         let mut ctx = UiContext::new();
1371         let slider = ctx.insert(crate::widget::Slider::new().with_value(0.5));
1372         let id = slider.id();
1373         ctx[slider].set_rect(0.0, 0.0, 200.0, 30.0);
1374         let other_id = ctx.insert(Block { base: Widget::new_rect(300.0, 300.0, 50.0, 50.0) }).id();
1375 
1376         let press = Event::MouseButton {
1377             button: MouseButton::Left,
1378             state: ElementState::Pressed,
1379             x: 100.0,
1380             y: 15.0,
1381             local_x: 100.0,
1382             local_y: 15.0,
1383         };
1384         // The app loop: same press to both roots, the slider first.
1385         assert!(ctx.propagate_event(&press, id));
1386         ctx.propagate_event(&press, other_id);
1387         assert_eq!(ctx.drag_target, Some(id), "the second root's call must not wipe the armed target");
1388 
1389         let v0 = ctx[slider].value;
1390         let mv = |x: f32| Event::PointerMove { x, y: 15.0, local_x: x, local_y: 15.0 };
1391         for root in [id, other_id] {
1392             ctx.propagate_event(&mv(110.0), root);
1393         }
1394         for root in [id, other_id] {
1395             ctx.propagate_event(&mv(140.0), root);
1396         }
1397         assert!(ctx.is_dragging, "threshold crossed despite multi-root dispatch");
1398         assert!(ctx[slider].value > v0 + 0.05, "DragUpdate drove the slider ({} -> {})", v0, ctx[slider].value);
1399 
1400         let release = Event::MouseButton {
1401             button: MouseButton::Left,
1402             state: ElementState::Released,
1403             x: 140.0,
1404             y: 15.0,
1405             local_x: 140.0,
1406             local_y: 15.0,
1407         };
1408         for root in [id, other_id] {
1409             ctx.propagate_event(&release, root);
1410         }
1411         assert!(!ctx[slider].is_dragging());
1412         assert!(!ctx.is_dragging);
1413     }
1414 
1415     /// A plain drag-blocking widget (the `WidgetHost` default) at a fixed rect.
1416     struct Block {
1417         base: Widget,
1418     }
1419     impl WidgetHost for Block {
1420         crate::impl_widget_base!(Block);
1421     }
1422 
1423     /// `drag_allowed_at` — the window-drag question: allowed on empty surface, denied over a
1424     /// drag-blocking widget.
1425     #[test]
1426     fn drag_allowed_everywhere_except_blocking_widgets() {
1427         let mut ctx = UiContext::new();
1428         ctx.insert(Block { base: Widget::new_rect(10.0, 10.0, 50.0, 50.0) });
1429         ctx.rebuild_spatial_grid();
1430 
1431         assert!(ctx.drag_allowed_at(200.0, 200.0), "empty surface is draggable");
1432         assert!(!ctx.drag_allowed_at(20.0, 20.0), "a drag-blocking widget denies the drag");
1433     }
1434 }
1435 
1436 #[cfg(test)]
1437 mod focus_step_tests {
1438     use super::*;
1439     use crate::widget::{Button, TextBox, WidgetHost};
1440 
1441     /// Tab walks plates and wells in reading order (row, then x), wraps, and
1442     /// Shift+Tab walks back; a focused well opened for typing on the way.
1443     #[test]
1444     fn focus_step_walks_plates_and_wells_in_reading_order() {
1445         let mut ctx = UiContext::new();
1446         let mut a = Button::new(0.0, 0.0, 80.0, 24.0).with_label("A");
1447         let mut b = Button::new(0.0, 0.0, 80.0, 24.0).with_label("B");
1448         let mut t = TextBox::new("well".to_string());
1449         // Placed out of registration order: b is right of a on the first row (and a
1450         // few px lower — a shorter control centred on the row, still the same row), t below.
1451         WidgetHost::set_rect(&mut b, 100.0, 16.0, 80.0, 12.0);
1452         WidgetHost::set_rect(&mut a, 10.0, 10.0, 80.0, 24.0);
1453         WidgetHost::set_rect(&mut t, 10.0, 50.0, 200.0, 24.0);
1454         let (ib, ia) = (ctx.insert(b).id(), ctx.insert(a).id());
1455         let t = ctx.insert(t);
1456         let it = t.id();
1457 
1458         assert!(ctx.focus_step(false));
1459         assert!(ctx.is_focused_id(ia), "first stop: the top-left plate");
1460         assert!(ctx.focus_step(false));
1461         assert!(ctx.is_focused_id(ib), "then the plate to its right");
1462         assert!(ctx.focus_step(false));
1463         assert!(ctx.is_focused_id(it), "then the well on the next row");
1464         assert!(ctx[t].editing, "a well opens for typing when focused");
1465         assert!(ctx.focus_step(false));
1466         assert!(ctx.is_focused_id(ia), "wraps to the first stop");
1467         assert!(ctx.focus_step(true));
1468         assert!(ctx.is_focused_id(it), "Shift+Tab wraps back to the last");
1469 
1470         // A widget with no role is not a stop.
1471         let mut sep = crate::widget::Separator::new(0.0, 0.0, 10.0, 1.0, [1.0; 4]);
1472         WidgetHost::set_rect(&mut sep, 300.0, 10.0, 10.0, 1.0);
1473         assert_eq!(crate::widget::WidgetHostExt::focus_role(&sep), crate::widget::FocusRole::None);
1474 
1475         // A group's members walk together, where the group's first member falls:
1476         // grouping a and t (skipping b, which sits between them in reading order)
1477         // makes the walk a, t, b — and the group chord jumps a -> b -> a.
1478         let g = ctx.insert(crate::widget::Group::new(vec![ia, it]));
1479         assert_eq!(ctx.focus_clusters(), vec![vec![ia, it], vec![ib]]);
1480         ctx.set_focused_id(ia);
1481         assert!(ctx.focus_step(false));
1482         assert!(ctx.is_focused_id(it), "the group's second member before the ungrouped stop");
1483         assert!(ctx.focus_step(false));
1484         assert!(ctx.is_focused_id(ib));
1485         assert!(ctx.focus_step_group(false));
1486         assert!(ctx.is_focused_id(ia), "the group chord wraps to the group's first stop");
1487         assert!(ctx.focus_step_group(false));
1488         assert!(ctx.is_focused_id(ib), "then to the next run");
1489         ctx.remove(g);
1490 
1491         // A plate parked off-screen (the hidden-editor idiom) is not a stop either.
1492         let mut parked = Button::new(0.0, 0.0, 1.0, 1.0).with_label("parked");
1493         WidgetHost::set_rect(&mut parked, -1000.0, -1000.0, 1.0, 1.0);
1494         let pid = ctx.insert(parked).id();
1495         for _ in 0..4 {
1496             ctx.focus_step(false);
1497             assert!(!ctx.is_focused_id(pid), "the parked plate never takes focus");
1498         }
1499     }
1500 }