GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix(context): multi-root press dispatch no longer wipes the armed drag target
Apps re-dispatch the same press to every top-level root (a plain loop);
the router's press arm reset drag_start_pos/drag_target on EVERY call, so
a later root's propagate wiped the target an earlier root had just armed —
routed drags were live-dead in every plain-loop app (the demo, colors)
while the single-root unit test passed. The reset now runs only for a new
press (drag_start_pos is cleared on release, so an equal position means
"same press, next root"). Regression test drives a two-root press loop
through a full drag.
Found the first time a held drag could be driven headlessly (the new
ccectl pointer-press/release): the WAYLAND_DEBUG wire log showed a perfect
press/motion/frame/release sequence reaching the client while the slider
never moved.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N4ajhvVZtyEEEus9bodsj3
src/context.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 67 insertions(+), 3 deletions(-)
diff --git a/src/context.rs b/src/context.rs
index d3458ee..b183a80 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -187,9 +187,17 @@ impl UiContext {
match event {
Event::MouseButton { button, state, x, y, .. } if *button == MouseButton::Left => {
if *state == ElementState::Pressed {
- self.drag_start_pos = Some((*x, *y));
- self.is_dragging = false;
- self.drag_target = None;
+ // Apps re-dispatch the SAME press to several roots (a plain loop
+ // over their top-level widgets); only the first call for a given
+ // press may reset the drag bookkeeping — a later call would wipe
+ // the target an earlier root just armed, killing the drag before
+ // its first move. drag_start_pos is cleared on release, so an
+ // equal position here means "same press, next root".
+ if self.drag_start_pos != Some((*x, *y)) {
+ self.drag_start_pos = Some((*x, *y));
+ self.is_dragging = false;
+ self.drag_target = None;
+ }
} else if *state == ElementState::Released {
if self.is_dragging {
if let Some(target_id) = self.drag_target {
@@ -881,6 +889,62 @@ mod tests {
assert!(!ctx.is_dragging);
}
+ /// The multi-root press dispatch (how apps actually loop: one press propagated to
+ /// EVERY top-level root, no break): a later root's propagate call must not wipe the
+ /// drag target an earlier root just armed. This was live-broken in every plain-loop
+ /// app (the demo, colors) while the single-root test above passed — found the first
+ /// time a held drag could be driven headlessly (ccectl pointer-press).
+ #[test]
+ fn multi_root_press_dispatch_keeps_the_drag_target() {
+ let mut ctx = UiContext::new();
+ let mut slider = crate::widget::Slider::new().with_value(0.5);
+ let (id, ptr) = (slider.id(), slider.as_ptr_mut());
+ ctx.register_widget(id, ptr);
+ slider.set_rect(0.0, 0.0, 200.0, 30.0);
+ let mut other = Block { base: Widget::new_rect(300.0, 300.0, 50.0, 50.0) };
+ let other_ptr = &mut other as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
+ let other_id = other.base.id();
+ ctx.register_widget(other_id, other_ptr);
+
+ let press = Event::MouseButton {
+ button: MouseButton::Left,
+ state: ElementState::Pressed,
+ x: 100.0,
+ y: 15.0,
+ local_x: 100.0,
+ local_y: 15.0,
+ };
+ // The app loop: same press to both roots, slider first.
+ assert!(ctx.propagate_event(&press, id));
+ ctx.propagate_event(&press, other_id);
+ assert_eq!(ctx.drag_target, Some(id), "the second root's call must not wipe the armed target");
+
+ let v0 = slider.value;
+ let mv = |x: f32| Event::PointerMove { x, y: 15.0, local_x: x, local_y: 15.0 };
+ for root in [id, other_id] {
+ ctx.propagate_event(&mv(110.0), root);
+ }
+ for root in [id, other_id] {
+ ctx.propagate_event(&mv(140.0), root);
+ }
+ assert!(ctx.is_dragging, "threshold crossed despite multi-root dispatch");
+ assert!(slider.value > v0 + 0.05, "DragUpdate drove the slider ({} -> {})", v0, slider.value);
+
+ let release = Event::MouseButton {
+ button: MouseButton::Left,
+ state: ElementState::Released,
+ x: 140.0,
+ y: 15.0,
+ local_x: 140.0,
+ local_y: 15.0,
+ };
+ for root in [id, other_id] {
+ ctx.propagate_event(&release, root);
+ }
+ assert!(!slider.is_dragging());
+ assert!(!ctx.is_dragging);
+ }
+
/// A plain drag-blocking widget (the `WidgetHost` default) at a fixed rect.
struct Block {
base: Widget,