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

src/context.rs (58.9K)

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