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

commit6fd7419e8234dc62fe68d7f8728f7f179c960015
parent94c6a309a0
authorLucas Galante <[email protected]>
date2026-07-08 08:08
feat(widget): Input concern trait + adapter event forwarding (Phase 5b)

Third narrow trait of the Element split: Input (hit / on_event against
the laid-out rect). Adapted::handle_event centralizes what legacy
widgets each re-implement: pointer-positioned events are hit-gated once
before reaching on_event, and unconsumed PointerMove falls back to the
legacy hover bookkeeping so base.hovered and MouseEnter/MouseLeave
synthesis keep working. hit_test preserves the occlusion check while
delegating geometry to Input::hit.

Tested through the real UiContext::propagate_event router: in-rect
click consumed, out-of-rect click gated out, hover transitions observed
on both the narrow widget and the base flag.

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

 docs/rfc-core-rebuild.md |  14 ++++-
 src/widget/mod.rs        |   2 +-
 src/widget/model.rs      | 134 ++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 146 insertions(+), 4 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index f03661b..5343ba8 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -357,7 +357,19 @@ Constraint respected: **each crate still builds standalone** — the new core is
     computed rects and the painted quads. Purely additive: no existing widget or app changes, all
     137 cce-ui tests pass. Runtime verification is N/A until a real widget is migrated onto the
     adapter (nothing in a running app uses it yet).
-  - **Still to do:** the `Input` (event) concern trait + adapter forwarding; migrate real widgets
+  - **5b — Input concern: DONE (compile + tests; no runtime surface yet).** `widget/model.rs` —
+    the `Input` trait (`hit` / `on_event`, both against the laid-out rect) plus adapter
+    forwarding with the RFC's centralizations: `Adapted::handle_event` hit-gates pointer-
+    positioned events (`MouseButton`/`MouseWheel`) once, so narrow widgets never carry the
+    per-widget "check hit_test first" boilerplate every legacy `mouse_input` override does;
+    unconsumed `PointerMove` falls back to the legacy hover bookkeeping, so `base.hovered` and
+    the synthesized `MouseEnter`/`MouseLeave` (which re-enter `handle_event` and reach
+    `on_event`) keep working; `hit_test` keeps the occlusion (`is_coordinate_covered`) check
+    while delegating the geometric test to `Input::hit`. A headless test drives a narrow
+    `Clicker` through the *real* `UiContext::propagate_event` router: in-rect click consumed +
+    counted, out-of-rect click gated out, hover enter/leave transitions observed on both the
+    narrow widget and the base flag. 138 cce-ui tests pass.
+  - **Still to do:** migrate real widgets
     off `impl Element` onto the narrow traits (per-widget, Phase 6 flavour); replace the 8 live
     `as_*_controller` downcast pairs (called by `cce-designer`, `cce-test-interface`, and ~10
     cce-ui widgets) with a typed message/command channel; delete `Element` + `Adapted` once the
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 1d6e542..4bc1ad4 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -823,7 +823,7 @@ pub mod model;
 // Re-exports
 pub use self::editor::TextEditorState;
 pub use self::layout_helper::{ColumnLayout, RowLayout};
-pub use self::model::{Adapted, Layout, Paint};
+pub use self::model::{Adapted, Input, Layout, Paint};
 pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
 pub use self::input::{
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 0f01962..1cbe300 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -28,7 +28,7 @@
 
 use crate::scene::layout::{Rect, Size, Style};
 use crate::scene::paint::PaintCtx;
-use crate::widget::{Element, UiContext, Widget, WidgetId};
+use crate::widget::{Element, Event, UiContext, Widget, WidgetId};
 
 /// Layout inputs for the scene layout engine — the RFC's `Widget` concern, named `Layout` here to
 /// avoid the existing [`Widget`] base struct. Mirrors the opt-in `Element::layout_style` /
@@ -79,6 +79,29 @@ pub trait Paint {
     }
 }
 
+/// The input concern — hit-testing and event handling against the laid-out rect. Mirrors the
+/// legacy `Element::hit_test` / `handle_event` pair, but with the RFC's centralizations: the
+/// default hit is plain rect containment (no per-widget address hacks), and pointer-positioned
+/// events are hit-gated by the adapter *before* they reach [`on_event`](Input::on_event), so a
+/// narrow widget never re-implements the "am I actually under the cursor?" boilerplate that every
+/// legacy `mouse_input` override carries.
+pub trait Input {
+    /// Whether the point `(x, y)` hits this widget, given its laid-out `rect`. Override for
+    /// non-rectangular hit shapes. Default: containment (edges inclusive, matching the legacy
+    /// `hit_test`).
+    fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+        x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
+    }
+
+    /// React to `event`, given the laid-out `rect`. Return `true` to consume it (the router marks
+    /// the widget dirty and stops propagation). `MouseButton` / `MouseWheel` events arrive only
+    /// when [`hit`](Input::hit) passed; `MouseEnter` / `MouseLeave` are synthesized by the hover
+    /// machinery. Default: ignore everything.
+    fn on_event(&mut self, _event: &Event, _rect: Rect) -> bool {
+        false
+    }
+}
+
 /// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
 /// [`Widget`] base that `Element`'s rect / id / dirty machinery needs, and forwards the concern
 /// methods to `W`. See the module docs for why this bridge exists rather than a supertrait split.
@@ -109,7 +132,7 @@ impl<W> Adapted<W> {
     }
 }
 
-impl<W: Layout + Paint + 'static> Element for Adapted<W> {
+impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn base(&self) -> Option<&Widget> {
         Some(&self.base)
     }
@@ -151,6 +174,43 @@ impl<W: Layout + Paint + 'static> Element for Adapted<W> {
         let (x, y, w, h) = self.rect();
         Paint::paint(&self.inner, Rect { x, y, width: w, height: h }, ctx);
     }
+
+    // --- Input concern -> `Input` ---
+    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+        // Preserve the legacy occlusion check (a covering layer swallows the hit), then delegate
+        // the geometric test to the narrow trait instead of the row/label-offset machinery.
+        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+            return false;
+        }
+        let (x, y, w, h) = self.rect();
+        Input::hit(&self.inner, Rect { x, y, width: w, height: h }, px, py)
+    }
+
+    fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
+        let (x, y, w, h) = self.rect();
+        let rect = Rect { x, y, width: w, height: h };
+        match event {
+            // Hit-gate pointer-positioned events once, here, so narrow widgets never carry the
+            // per-widget "check hit_test first" boilerplate legacy `mouse_input` overrides do.
+            Event::MouseButton { x: px, y: py, .. } | Event::MouseWheel { x: px, y: py, .. } => {
+                self.hit_test(*px, *py, ctx) && Input::on_event(&mut self.inner, event, rect)
+            }
+            // Offer the raw move to the widget; if unconsumed, run the legacy hover bookkeeping
+            // (base.hovered + MouseEnter/MouseLeave synthesis, which re-enters this method and
+            // reaches `on_event` through the arm below).
+            Event::PointerMove { x: px, y: py, .. } => {
+                if Input::on_event(&mut self.inner, event, rect) {
+                    return true;
+                }
+                let (px, py) = (*px, *py);
+                self.cursor_moved(px, py, ctx)
+            }
+            // Everything else (KeyInput, Tick, Enter/Leave, Drag*, 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, rect),
+        }
+    }
 }
 
 #[cfg(test)]
@@ -178,6 +238,7 @@ mod tests {
             self.color
         }
     }
+    impl Input for Dot {}
 
     /// A narrow container: it drives a column layout ([`Layout`]) and paints nothing.
     struct Col;
@@ -191,6 +252,7 @@ mod tests {
             [0.0, 0.0, 0.0, 0.0]
         }
     }
+    impl Input for Col {}
 
     fn rect_of(ptr: *mut (dyn Element + 'static)) -> Rect {
         let (x, y, w, h) = unsafe { (*ptr).rect() };
@@ -240,4 +302,72 @@ mod tests {
             "green Dot painted at its laid-out rect: {quads:?}",
         );
     }
+
+    /// A narrow interactive widget: counts left-clicks and records hover transitions — all
+    /// through [`Input::on_event`], never touching `Element`.
+    struct Clicker {
+        clicks: u32,
+        entered: u32,
+        left: u32,
+    }
+    impl Layout for Clicker {}
+    impl Paint for Clicker {
+        fn color(&self) -> [f32; 4] {
+            [0.5, 0.5, 0.5, 1.0]
+        }
+    }
+    impl Input for Clicker {
+        fn on_event(&mut self, event: &Event, _rect: Rect) -> bool {
+            use crate::widget::{ElementState, MouseButton};
+            match event {
+                Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
+                    self.clicks += 1;
+                    true
+                }
+                Event::MouseEnter => {
+                    self.entered += 1;
+                    false
+                }
+                Event::MouseLeave => {
+                    self.left += 1;
+                    false
+                }
+                _ => false,
+            }
+        }
+    }
+
+    #[test]
+    fn narrow_widget_receives_routed_events_through_the_adapter() {
+        use crate::widget::{ElementState, MouseButton};
+        let mut ctx = UiContext::new();
+        let mut w = Box::new(Adapted::new(Clicker { clicks: 0, entered: 0, left: 0 }));
+        let (id, ptr) = (w.id(), w.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        unsafe { (*ptr).set_rect(10.0, 10.0, 40.0, 20.0) };
+
+        let click_at = |x: f32, y: f32| Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x,
+            y,
+            local_x: x,
+            local_y: y,
+        };
+
+        // A click inside the rect is hit-gated in, consumed, and counted.
+        assert!(ctx.propagate_event(&click_at(20.0, 15.0), ptr), "in-rect click is consumed");
+        // A click outside never reaches on_event (the adapter's hit gate rejects it).
+        assert!(!ctx.propagate_event(&click_at(200.0, 200.0), ptr), "out-of-rect click passes through");
+        assert_eq!(w.inner().clicks, 1, "only the in-rect click was counted");
+
+        // Hover: moving inside synthesizes MouseEnter (via the legacy bookkeeping the adapter
+        // preserves) and sets the base hover flag; moving away synthesizes MouseLeave.
+        ctx.propagate_event(&Event::PointerMove { x: 20.0, y: 15.0, local_x: 20.0, local_y: 15.0 }, ptr);
+        assert_eq!(w.inner().entered, 1, "MouseEnter reached on_event");
+        assert!(unsafe { (*ptr).hovered() }, "base hover flag set through the adapter");
+        ctx.propagate_event(&Event::PointerMove { x: 200.0, y: 200.0, local_x: 200.0, local_y: 200.0 }, ptr);
+        assert_eq!(w.inner().left, 1, "MouseLeave reached on_event");
+        assert!(!unsafe { (*ptr).hovered() }, "base hover flag cleared");
+    }
 }