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 }