git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit8fff0391d6cb2e6ba24c21a7d6bcc541627e399f
parent01b21abaf1
authorLucas Galante <[email protected]>
date2026-08-04 10:10
fix: don't broadcast MouseEnter to json children — every button lit up on hover

When a PointerMove changes no child hover, route_event reports it unconsumed
and the adapter falls through to the container's base-hover bookkeeping,
which synthesizes a MouseEnter for the panel. route_event forwarded that
Enter to every child, and Button's on_event trusts the router contract that
Enter only reaches the widget under the cursor — so one stationary wiggle
marked every button hovered (glaring once the paint walk started rendering
the hover plates). Drop MouseEnter at the container; the next PointerMove
re-derives child hover. MouseLeave still broadcasts — clearing every child
on panel exit is correct.

Regression test drives the routed path with the exact two-move sequence.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/json_layout.rs | 11 ++++++++
 src/main.rs        | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 90 insertions(+)

diff --git a/src/json_layout.rs b/src/json_layout.rs
index 67443be..1f41b8c 100644
--- a/src/json_layout.rs
+++ b/src/json_layout.rs
@@ -362,6 +362,17 @@ impl JsonLayoutWidget {
     /// ungated legacy direct-dispatch path — the trailing focus-clear on a missed press
     /// depends on that.
     fn route_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
+        // MouseEnter targets THIS container (the adapter's base-hover bookkeeping
+        // synthesizes it when a move goes unconsumed). Broadcasting it to every
+        // child marks them all hovered — Button's on_event trusts the router
+        // contract that Enter only reaches the widget under the cursor (the
+        // desktop-menu every-button-lit bug). The next PointerMove re-derives
+        // child hover, so dropping it loses nothing. MouseLeave still broadcasts
+        // below: clearing every child's hover is exactly what leaving the panel
+        // means.
+        if matches!(event, Event::MouseEnter) {
+            return false;
+        }
         let mut changed = false;
 
         match event {
diff --git a/src/main.rs b/src/main.rs
index d8c20ff..723c1e4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3157,6 +3157,85 @@ mod tests {
         assert!(layout.widgets[2].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Button>().unwrap().hovered());
     }
 
+    /// Hover must be exclusive: a pointer over one button leaves the others
+    /// unhovered (the desktop context menu regression — every button lit up
+    /// once the paint walk started rendering the Button model's hover state).
+    #[test]
+    fn test_json_button_hover_is_exclusive() {
+        let mk_btn = |id: &str, text: &str| JsonWidgetConfig {
+            widget_type: "button".to_string(),
+            text: text.to_string(),
+            id: Some(id.to_string()),
+            checked: None,
+            value: None,
+            min: None,
+            max: None,
+            step: None,
+            decimals: None,
+            color: None,
+            value_f32: None,
+            min_f32: None,
+            max_f32: None,
+            target_page: None,
+        };
+        let config = JsonLayoutConfig {
+            width: Some(300),
+            height: Some(400),
+            widgets: Some(vec![mk_btn("a", "Alpha"), mk_btn("b", "Beta"), mk_btn("c", "Gamma")]),
+            pages: None,
+            justify: None,
+        };
+        let mut layout = JsonLayoutWidget::new(&config);
+        layout.set_rect(0.0, 0.0, 300.0, 400.0);
+        let mut ctx = cce_ui::context::UiContext::new();
+
+        let hovered = |layout: &cce_ui::widget::Adapted<JsonLayoutWidget>, i: usize| {
+            layout.widgets[i]
+                .widget
+                .as_dyn()
+                .as_any()
+                .downcast_ref::<cce_ui::widget::Button>()
+                .unwrap()
+                .hovered()
+        };
+
+        // Pointer over the first button only.
+        let (x0, y0) = (layout.widgets[0].x, layout.widgets[0].y);
+        layout.on_cursor_moved(x0 + 10.0, y0 + 5.0, &mut ctx);
+        assert!(hovered(&layout, 0), "hovered button must be hovered");
+        assert!(!hovered(&layout, 1), "second button must not be hovered");
+        assert!(!hovered(&layout, 2), "third button must not be hovered");
+
+        // Move to the third button: hover follows, first clears.
+        let (x2, y2) = (layout.widgets[2].x, layout.widgets[2].y);
+        layout.on_cursor_moved(x2 + 10.0, y2 + 5.0, &mut ctx);
+        assert!(!hovered(&layout, 0), "old hover must clear");
+        assert!(!hovered(&layout, 1));
+        assert!(hovered(&layout, 2), "new hover must set");
+
+        // Pointer inside the panel but on no button: everything clears.
+        layout.on_cursor_moved(150.0, 395.0, &mut ctx);
+        assert!(!hovered(&layout, 0));
+        assert!(!hovered(&layout, 1));
+        assert!(!hovered(&layout, 2));
+
+        // The live path: routed dispatch through the UiContext, and crucially a
+        // SECOND move that changes no child hover. The first (consumed) move
+        // skips the adapter's base-hover bookkeeping; the unconsumed second one
+        // runs it, synthesizing a MouseEnter for the panel — which route_event
+        // must NOT broadcast to the children (the desktop-menu regression: every
+        // button lit up on the first stationary wiggle).
+        let (x1, y1) = (layout.widgets[1].x, layout.widgets[1].y);
+        let root = layout.id();
+        ctx.register_widget(root, layout.as_ptr_mut());
+        let mv = |x: f32, y: f32| cce_ui::widget::Event::PointerMove { x, y, local_x: x, local_y: y };
+        ctx.propagate_event(&mv(x1 + 10.0, y1 + 5.0), root);
+        ctx.propagate_event(&mv(x1 + 12.0, y1 + 5.0), root);
+        assert!(!hovered(&layout, 0), "unconsumed move must not hover-broadcast");
+        assert!(hovered(&layout, 1));
+        assert!(!hovered(&layout, 2), "unconsumed move must not hover-broadcast");
+    }
+
     #[test]
     fn test_app_history_sorting() {
         let temp_dir = std::path::PathBuf::from("/tmp/cce-cloud-test-cache-dir");