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

commitac218aa5837fcb0d507b92e673e57599c11446e0
parent851a352125
authorLucas Galante <[email protected]>
date2026-07-12 20:12
fix(widget): routed drags reach the Input drag hooks — the silent-drop gap

The router's drag lifecycle (DragStart/DragUpdate/DragEnd to the recorded
drag target) arrived at Adapted::handle_event's catch-all, which forwards to
Input::on_event — whose default drops them, and NO widget consumes Drag*
there. So every ROUTED drag was silently dead; the reason each app
historically kept an app-held drag index and called drag_update directly.
The 6bd routed-events conversions (colors, settings audio) removed those
indices and exposed the gap: their thumb drags — the one interaction not
headlessly drivable — were broken.

Adapted::handle_event now maps the three drag events onto the Input drag
hooks, mirroring the direct WidgetHost::drag_* entry points exactly
(including drag_reposition for self-moving widgets); on_event is offered
first so a widget may still consume them as events.

New regression test drives a full press -> threshold -> DragUpdate ->
release cycle through propagate_event on a real Slider (164 tests).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018u7qTwzX95dd5ysAkaSCLk

 src/context.rs      | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/widget/model.rs | 33 ++++++++++++++++++++++++++++++++-
 2 files changed, 84 insertions(+), 1 deletion(-)

diff --git a/src/context.rs b/src/context.rs
index d19a078..30cef24 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -892,6 +892,58 @@ mod tests {
     use super::*;
     use crate::widget::{WidgetHost, Widget};
 
+    /// The router's drag lifecycle drives the Input drag hooks end-to-end: a routed press
+    /// records the drag target, the first >3px move synthesizes DragStart, further moves
+    /// deliver DragUpdate (the slider value follows), and the release delivers DragEnd.
+    /// Regression test for the silent-drop gap: `Input::on_event` defaults ignore Drag*
+    /// events, so `Adapted::handle_event` must map them onto the hooks itself.
+    #[test]
+    fn routed_drag_reaches_input_drag_hooks() {
+        use crate::widget::{ElementState, Event, MouseButton, Slider};
+
+        let mut ctx = UiContext::new();
+        let mut slider = Slider::new();
+        WidgetHost::set_rect(&mut slider, 0.0, 0.0, 200.0, 30.0);
+        let ptr = slider.as_ptr_mut();
+        ctx.register_widget(slider.base().id(), 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,
+        };
+        assert!(ctx.propagate_event(&press, ptr), "press in the track arms the drag");
+        assert!(WidgetHost::is_dragging(&slider));
+        let v0 = slider.value;
+
+        // First move past the 3px threshold starts the drag; the next one updates it.
+        let mv = |x: f32| Event::PointerMove { x, y: 15.0, local_x: x, local_y: 15.0 };
+        ctx.propagate_event(&mv(110.0), ptr);
+        assert!(ctx.is_dragging, "router crossed the drag threshold");
+        ctx.propagate_event(&mv(140.0), ptr);
+        assert!(
+            slider.value > v0 + 0.05,
+            "DragUpdate reached Input::drag_update (value {} -> {})",
+            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,
+        };
+        ctx.propagate_event(&release, ptr);
+        assert!(!WidgetHost::is_dragging(&slider), "DragEnd reached Input::drag_end");
+        assert!(!ctx.is_dragging);
+    }
+
     /// A plain drag-blocking widget (the `WidgetHost` default) at a fixed rect.
     struct Block {
         base: Widget,
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 22a14d7..c63db56 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1529,7 +1529,38 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 let (px, py) = (*px, *py);
                 self.cursor_moved(px, py, ctx)
             }
-            // Everything else (KeyInput, Tick, Enter/Leave, Drag*, Focus*) forwards directly —
+            // The router's drag lifecycle (recorded drag target → DragStart/DragUpdate/
+            // DragEnd) maps to the Input drag hooks, exactly like the direct
+            // `WidgetHost::drag_*` entry points below — `on_event` is offered first, but no
+            // widget consumes Drag* there today; without these arms the events fell into the
+            // on_event default and every ROUTED drag was silently dead (the reason each app
+            // historically kept its own held-drag index and called drag_update directly).
+            Event::DragStart { start_x, start_y } => {
+                if Input::on_event(&mut self.inner, event, &mut ectx!()) {
+                    return true;
+                }
+                Input::drag_begin(&mut self.inner, *start_x, *start_y, rect);
+                true
+            }
+            Event::DragUpdate { x, y, .. } => {
+                if Input::on_event(&mut self.inner, event, &mut ectx!()) {
+                    return true;
+                }
+                if let Some((nx, ny)) = Input::drag_reposition(&mut self.inner, *x, *y, rect) {
+                    self.base.x = nx;
+                    self.base.y = ny;
+                    return true;
+                }
+                Input::drag_update(&mut self.inner, *x, *y, rect)
+            }
+            Event::DragEnd => {
+                if Input::on_event(&mut self.inner, event, &mut ectx!()) {
+                    return true;
+                }
+                Input::drag_end(&mut self.inner);
+                true
+            }
+            // Everything else (KeyInput, Tick, Enter/Leave, Focus*) forwards directly —
             // the legacy default dispatch would route these to leaf handlers Adapted never
             // overrides, so there is no behavior to fall back to.
             _ => Input::on_event(&mut self.inner, event, &mut ectx!()),