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

commitbe1a4c40337c21c2111e79e68661af2b1c05ef9b
parent7776a2bcf9
authorLucas Galante <[email protected]>
date2026-07-08 12:28
feat(widget): controller-capability forwarding on the adapter (Phase 5k)

Unblocks the controller-widget tier: Element's as_*_controller downcast
pairs are implemented exactly once (for Adapted<W>), so a migrated widget
could not re-expose its MenuController/GraphController/… impl. Input now
carries transitional capability hooks (menu/graph/spreadsheet/path/param/
geom _controller[_mut], default None) that the adapter forwards the legacy
downcasts to; a controller widget returns Some(self).

Also:
- EventCtx::open_context_menu — for widgets that must do work before the
  shared menu opens (Breadcrumb records the right-clicked segment first),
  which Input::opens_context_menu cannot express.
- Input::copy_path forward (the context menu's "Copy Path" action calls
  Element::copy_path on its target).
- Adapted::on_cursor_moved override — another direct-dispatch entry
  (cce-files drives its breadcrumbs through it); routes the raw move to
  on_event, then falls back to the base hover bookkeeping.
- Drop ScrollController + its Element pair: zero implementors and zero
  live callers workspace-wide (only cce-designer's dead scroll_mut helper,
  removed there).

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

 src/widget/mod.rs   |   7 --
 src/widget/model.rs | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 186 insertions(+), 8 deletions(-)

diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index df66b68..6de7ce2 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -694,8 +694,6 @@ pub trait Element {
     fn as_param_controller_mut(&mut self) -> Option<&mut dyn ParamController> { None }
     fn as_geom_controller(&self) -> Option<&dyn GeomController> { None }
     fn as_geom_controller_mut(&mut self) -> Option<&mut dyn GeomController> { None }
-    fn as_scroll_controller(&self) -> Option<&dyn ScrollController> { None }
-    fn as_scroll_controller_mut(&mut self) -> Option<&mut dyn ScrollController> { None }
 
     fn parent(&self, ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> {
         let base = self.base()?;
@@ -921,11 +919,6 @@ pub trait GeomController {
     fn take_geom_toggle(&mut self) -> bool;
 }
 
-pub trait ScrollController {
-    fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32);
-    fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32>;
-}
-
 pub fn label_offset(w: &dyn Element) -> f32 {
     let name = w.type_name();
     if name == "Label" || name == "Button" || name == "Checkbox" || name == "Toggle" {
diff --git a/src/widget/model.rs b/src/widget/model.rs
index d1f27db..a69935c 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -28,7 +28,10 @@
 
 use crate::scene::layout::{Rect, Size, Style};
 use crate::scene::paint::{PaintCtx, Prim};
-use crate::widget::{Element, Event, TextLabel, UiContext, Widget, WidgetId};
+use crate::widget::{
+    Element, Event, GeomController, GraphController, MenuController, ParamController,
+    PathController, SpreadsheetController, TextLabel, 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` /
@@ -160,6 +163,17 @@ impl EventCtx<'_> {
             unsafe { crate::widget::focus::set_focused(&mut *ptr) };
         }
     }
+
+    /// Open the shared context menu on this widget (legacy `ctx.handle_right_click(self, …)`),
+    /// for widgets that must do work *before* the menu opens — Breadcrumb records which segment
+    /// was right-clicked first, so the menu header can show that segment's path.
+    /// [`Input::opens_context_menu`] can't express that: the adapter's gate runs instead of
+    /// `on_event`, not after it. No-op outside a routed path (no ctx or no self pointer).
+    pub fn open_context_menu(&mut self, px: f32, py: f32) {
+        if let (Some(ptr), Some(ui)) = (self.self_ptr, self.ui.as_deref_mut()) {
+            ui.handle_right_click(ptr, px, py);
+        }
+    }
 }
 
 /// The input concern — hit-testing and event handling against the laid-out rect. Mirrors the
@@ -254,6 +268,54 @@ pub trait Input {
     fn drag_end(&mut self) {}
     /// Movement bounds pushed in by hosts (legacy `Element::set_drag_bounds`).
     fn set_drag_bounds(&mut self, _bx: f32, _by: f32, _bw: f32, _bh: f32) {}
+
+    // --- Controller capabilities (transitional, like the polling surface above). The legacy
+    // tree reaches a widget's typed API through the `Element::as_*_controller` downcast pairs;
+    // `Element` is implemented exactly once (for `Adapted<W>`), so a migrated controller widget
+    // re-exposes its controller impl through these hooks instead — `Some(self)` when `W`
+    // implements the trait. Dies with `Element`: the end state reaches a controller through the
+    // concrete `Adapted<W>` (or a `&dyn XController` held directly), per RFC §3.5.
+
+    fn menu_controller(&self) -> Option<&dyn MenuController> {
+        None
+    }
+    fn menu_controller_mut(&mut self) -> Option<&mut dyn MenuController> {
+        None
+    }
+    fn graph_controller(&self) -> Option<&dyn GraphController> {
+        None
+    }
+    fn graph_controller_mut(&mut self) -> Option<&mut dyn GraphController> {
+        None
+    }
+    fn spreadsheet_controller(&self) -> Option<&dyn SpreadsheetController> {
+        None
+    }
+    fn spreadsheet_controller_mut(&mut self) -> Option<&mut dyn SpreadsheetController> {
+        None
+    }
+    fn path_controller(&self) -> Option<&dyn PathController> {
+        None
+    }
+    fn path_controller_mut(&mut self) -> Option<&mut dyn PathController> {
+        None
+    }
+    fn param_controller(&self) -> Option<&dyn ParamController> {
+        None
+    }
+    fn param_controller_mut(&mut self) -> Option<&mut dyn ParamController> {
+        None
+    }
+    fn geom_controller(&self) -> Option<&dyn GeomController> {
+        None
+    }
+    fn geom_controller_mut(&mut self) -> Option<&mut dyn GeomController> {
+        None
+    }
+
+    /// Copy this widget's path/content to the clipboard — the context menu's "Copy Path" action
+    /// calls `Element::copy_path` on its target (Breadcrumb is the only implementor).
+    fn copy_path(&self) {}
 }
 
 /// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
@@ -630,6 +692,47 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         Input::set_drag_bounds(&mut self.inner, bx, by, bw, bh)
     }
 
+    // --- Controller downcasts -> the `Input` capability hooks ---
+    fn as_menu_controller(&self) -> Option<&dyn MenuController> {
+        Input::menu_controller(&self.inner)
+    }
+    fn as_menu_controller_mut(&mut self) -> Option<&mut dyn MenuController> {
+        Input::menu_controller_mut(&mut self.inner)
+    }
+    fn as_graph_controller(&self) -> Option<&dyn GraphController> {
+        Input::graph_controller(&self.inner)
+    }
+    fn as_graph_controller_mut(&mut self) -> Option<&mut dyn GraphController> {
+        Input::graph_controller_mut(&mut self.inner)
+    }
+    fn as_spreadsheet_controller(&self) -> Option<&dyn SpreadsheetController> {
+        Input::spreadsheet_controller(&self.inner)
+    }
+    fn as_spreadsheet_controller_mut(&mut self) -> Option<&mut dyn SpreadsheetController> {
+        Input::spreadsheet_controller_mut(&mut self.inner)
+    }
+    fn as_path_controller(&self) -> Option<&dyn PathController> {
+        Input::path_controller(&self.inner)
+    }
+    fn as_path_controller_mut(&mut self) -> Option<&mut dyn PathController> {
+        Input::path_controller_mut(&mut self.inner)
+    }
+    fn as_param_controller(&self) -> Option<&dyn ParamController> {
+        Input::param_controller(&self.inner)
+    }
+    fn as_param_controller_mut(&mut self) -> Option<&mut dyn ParamController> {
+        Input::param_controller_mut(&mut self.inner)
+    }
+    fn as_geom_controller(&self) -> Option<&dyn GeomController> {
+        Input::geom_controller(&self.inner)
+    }
+    fn as_geom_controller_mut(&mut self) -> Option<&mut dyn GeomController> {
+        Input::geom_controller_mut(&mut self.inner)
+    }
+    fn copy_path(&self) {
+        Input::copy_path(&self.inner)
+    }
+
     // --- Legacy direct-dispatch entry points. Hosts (treelist's add-key button, parameters_bg's
     // checkboxes, app pages) call these ON the widget instead of routing an Event through
     // `propagate_event`; without these overrides they'd hit the inert Element defaults and the
@@ -652,6 +755,36 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         self.handle_event(&Event::KeyInput(event.clone()), ctx)
     }
 
+    /// Direct-dispatch pointer moves (hosts call `w.on_cursor_moved(..)` instead of routing a
+    /// `PointerMove` — cce-files drives its breadcrumbs this way): offer the raw move to the
+    /// widget, then fall back to the base hover bookkeeping, mirroring `handle_event`'s
+    /// `PointerMove` arm. Not routed *through* `handle_event`, because the routed path reaches
+    /// this method too (via `cursor_moved`) and would recurse; on that path `on_event` sees the
+    /// same unconsumed move twice, which is fine — a hover recompute is idempotent (anything
+    /// that changed on the first call consumed it there).
+    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        let rect = self.content_rect();
+        let id = self.base.id();
+        let self_ptr = self.as_ptr_mut();
+        let mut ectx = EventCtx { rect, id, ui: Some(ctx), self_ptr: Some(self_ptr) };
+        let event = Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
+        if Input::on_event(&mut self.inner, &event, &mut ectx) {
+            return true;
+        }
+        // The legacy default `Element::on_cursor_moved` body: base hover flag + synthesized
+        // MouseEnter/MouseLeave (which re-enter `handle_event` and reach `on_event`).
+        let was = self.base.hovered;
+        let is_hit = self.hit_test(px, py, ctx);
+        self.base.hovered = is_hit;
+        if was != is_hit {
+            let transition = if is_hit { Event::MouseEnter } else { Event::MouseLeave };
+            self.handle_event(&transition, ctx);
+            true
+        } else {
+            false
+        }
+    }
+
     /// Focus set/cleared directly (hosts call `w.focus()`/`w.unfocus()`): keep the base flag and
     /// tell the widget via the same `FocusIn`/`FocusOut` events the router would send.
     fn focus(&mut self) {
@@ -888,4 +1021,56 @@ mod tests {
         assert_eq!(w.inner().left, 1, "MouseLeave reached on_event");
         assert!(!unsafe { (*ptr).hovered() }, "base hover flag cleared");
     }
+
+    /// A narrow widget that is also a controller: it re-exposes its [`PathController`] impl
+    /// through the `Input` capability hooks, and the adapter forwards the legacy
+    /// `Element::as_path_controller` downcasts to them.
+    struct Crumbs {
+        segs: Vec<String>,
+        clicked: Option<usize>,
+    }
+    impl Layout for Crumbs {}
+    impl Paint for Crumbs {
+        fn color(&self) -> [f32; 4] {
+            [0.0; 4]
+        }
+    }
+    impl Input for Crumbs {
+        fn path_controller(&self) -> Option<&dyn PathController> {
+            Some(self)
+        }
+        fn path_controller_mut(&mut self) -> Option<&mut dyn PathController> {
+            Some(self)
+        }
+    }
+    impl PathController for Crumbs {
+        fn set_path(&mut self, segments: &[String]) {
+            self.segs = segments.to_vec();
+        }
+        fn path_click(&mut self) -> Option<usize> {
+            self.clicked.take()
+        }
+    }
+
+    #[test]
+    fn controller_capability_forwards_through_the_element_downcast() {
+        let mut w = Box::new(Adapted::new(Crumbs { segs: Vec::new(), clicked: Some(2) }));
+        let elem: &mut dyn Element = w.as_mut();
+
+        // The legacy downcast pair reaches the narrow widget's controller impl…
+        elem.as_path_controller_mut()
+            .expect("Adapted forwards as_path_controller_mut")
+            .set_path(&["home".to_string(), "user".to_string()]);
+        assert!(elem.as_path_controller().is_some(), "shared-ref downcast forwards too");
+        assert_eq!(elem.as_path_controller_mut().unwrap().path_click(), Some(2));
+
+        // …and lands on the same state the concrete widget sees.
+        assert_eq!(w.inner().segs, vec!["home".to_string(), "user".to_string()]);
+        assert_eq!(w.inner().clicked, None, "path_click drained through the forward");
+
+        // Capabilities the widget does not expose stay None (the Element defaults).
+        let elem: &dyn Element = w.as_ref();
+        assert!(elem.as_menu_controller().is_none());
+        assert!(elem.as_graph_controller().is_none());
+    }
 }