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 }