git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit416d8349fba1ec5caed92e85fd9661511521847b
parentb510956422
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 15:36
fix: a captured pointer hovers no pane, and the release hands it back

A control hovered at a press stayed lit for the length of an orbit, a
node drag or a pane resize: the pointer broadcast ran only while no
widget or app drag was live, and the cursor arm's early returns (a held
menu slider, a handle grab, an orbit, a grid expansion) skipped it for
every other gesture. The dialog, a modal, let the panes beside its plate
keep hovering.

`State::broadcast_pointer` is the one broadcast now, and it knows about
capture: `pointer_captured` names every gesture that owns the pointer,
and while one does every pane gets an off-screen position (the slot
driving a widget drag is left to its DragUpdate stream). It runs from the
cursor arm ahead of the early returns, from the release once the
captures are down so what is under the pointer hovers without a motion,
and from the dialog's open and close — while the dialog is up only its
own slot sees the pointer.

`a_captured_pointer_hovers_no_pane_and_the_release_hands_it_back` drives
an orbit, an app drag and the dialog through the event loop against the
network plate's hover flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md     |  12 +++++++
 src/app.rs    | 111 ++++++++++++++++++++++++++++++++++++++++++++--------------
 src/dialog.rs |   4 +++
 src/main.rs   |  50 ++++++++++++++++++++++++++
 4 files changed, 151 insertions(+), 26 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6359777..8a31457 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2061,6 +2061,18 @@ dispatch into the dialog and puts it back, invalidating the coverage memo on bot
 edges (the engine queries it on every left press, so lowering the flag alone
 leaves a stale cached answer).
 
+**A captured pointer hovers no pane.** `State::broadcast_pointer` hands
+every slot the pointer's position, or an off-screen one while
+`pointer_captured` says a gesture or the dialog owns it — a widget or app
+drag, an orbit, a pan, a grid expansion, a handle grab, a held menu
+slider. It runs from the cursor arm (ahead of the early returns those
+gestures take, which is where a pane hovered at the press used to stay lit
+for the whole drag), from the release once the captures are down, and
+from the dialog's open and close, since a modal that let the panes beside
+its plate keep hovering was a modal in name.
+`a_captured_pointer_hovers_no_pane_and_the_release_hands_it_back` is the
+test.
+
 Input is intercepted whole, at the top of `handle_event`'s keyboard and mouse
 branches: `dialog_key_input` is TOTAL rather than a layer, because the network
 pane's bare-letter family is ungated and typing "frame" into the filter would
diff --git a/src/app.rs b/src/app.rs
index 4a4919e..13baf8b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2690,6 +2690,74 @@ impl State {
     /// into `pending_yaw`/`pending_pitch` for the node to pick up. Doing it any
     /// other way would give a dragged camera a different meaning from a
     /// scrolled one.
+    /// Is the pointer CAPTURED — owned by a gesture or a modal rather than
+    /// free to hover whatever it is over? A widget drag, an app drag (pane
+    /// edges, the dock), a camera orbit, a network pan, a grid expansion
+    /// drag, a viewer-tool handle grab, a held viewport-menu slider, or the
+    /// dialog. While it is, no pane hovers (`broadcast_pointer`).
+    pub(crate) fn pointer_captured(&self) -> bool {
+        self.drag_widget.is_some()
+            || self.app_drag.is_some()
+            || self.orbit_drag.is_some()
+            || self.is_panning
+            || self.grid_cursor_drag.is_some()
+            || self.viewer_tool.as_ref().map_or(false, |t| t.drag.is_some())
+            || (self.viewport_menu_open() && cce_ui::widget::context_menu::slider_dragging())
+            || self.dialog_visible()
+    }
+
+    /// Hand every pane the pointer: its real position when the pane is
+    /// under it and the pointer is free, an off-screen one otherwise, so a
+    /// control's hover tracks the pointer exactly while the pointer can
+    /// reach it. Returns whether any pane changed.
+    ///
+    /// Until 2026-09-28 this ran only while no widget or app drag was live,
+    /// and the arm's early returns skipped it for the other gestures — so a
+    /// control hovered at a press stayed lit for the length of an orbit, a
+    /// node drag or a pane resize, and lit again only when the pointer next
+    /// crossed it. A captured pointer now clears every pane, and the
+    /// release re-broadcasts (`MouseInput`) so what is under the pointer
+    /// hovers at once. The slot driving a widget drag is left alone: its
+    /// `DragUpdate` stream is its motion. The dialog is modal, so while it
+    /// is up only its own slot sees the pointer, and `close_dialog` hands
+    /// it back.
+    pub(crate) fn broadcast_pointer(&mut self) -> bool {
+        let captured = self.pointer_captured();
+        let dialog = self.dialog_visible();
+        let mut changed = false;
+        for i in 0..WIDGET_COUNT {
+            if self.drag_widget == Some(i) {
+                continue;
+            }
+            let (cx, cy) = (self.cursor_x, self.cursor_y);
+            let free = if dialog { i == crate::slots::DIALOG_IDX } else { !captured };
+            let is_network_part = i == CONTENT_IDX || i == LEFT_MENUBAR_IDX || i == BREADCRUMB_IDX || i == NETWORK_PANEL_IDX;
+            let inside = free
+                && if self.circular_network_pane && is_network_part {
+                    if i == CONTENT_IDX {
+                        self.circular_network_layout.hit_test_content(cx, cy, 0.0, BREADCRUMB_H)
+                    } else if i == LEFT_MENUBAR_IDX {
+                        false
+                    } else if i == BREADCRUMB_IDX {
+                        self.circular_network_layout.hit_test_breadcrumb(cx, cy, 0.0, BREADCRUMB_H)
+                    } else if i == NETWORK_PANEL_IDX {
+                        self.slots.network_panel.hit_test(cx, cy, &self.ui_context)
+                    } else {
+                        false
+                    }
+                } else {
+                    self.slots.get_dyn_mut(i).hit_test(cx, cy, &self.ui_context)
+                };
+            let (tx, ty) = if inside { (cx, cy) } else { (-9999.0, -9999.0) };
+            let mv = cce_ui::widget::Event::PointerMove { x: tx, y: ty, local_x: tx, local_y: ty };
+            let ptr = self.slots.get_dyn_mut(i) as *mut (dyn WidgetHost + 'static);
+            if unsafe { (*ptr).handle_event(&mv, &mut self.ui_context) } {
+                changed = true;
+            }
+        }
+        changed
+    }
+
     pub(crate) fn orbit_camera_by(&mut self, dx_px: f32, dy_px: f32) {
         let dx = dx_px * Self::ORBIT_RADIANS_PER_PX;
         let dy = dy_px * Self::ORBIT_RADIANS_PER_PX;
@@ -8233,6 +8301,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 self.cursor_y = position.y as f32;
                 let mut changed = false;
 
+                // A captured pointer reaches no pane: the returns below
+                // (a menu slider, a handle grab, an orbit, a grid drag) each
+                // leave before the broadcast at the end of this arm, and a
+                // pane hovered at the press stayed lit for the whole gesture.
+                if self.pointer_captured() && self.broadcast_pointer() {
+                    changed = true;
+                }
+
                 // Track hover on the node/viewport/network context menus so
                 // the highlight follows.
                 if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open())
@@ -8430,32 +8506,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         }
                     }
 
-                    if self.drag_widget.is_none() && self.app_drag.is_none() {
-                        for i in 0..WIDGET_COUNT {
-                            let (cx, cy) = (self.cursor_x, self.cursor_y);
-                            let is_network_part = i == CONTENT_IDX || i == LEFT_MENUBAR_IDX || i == BREADCRUMB_IDX || i == NETWORK_PANEL_IDX;
-                            let inside = if self.circular_network_pane && is_network_part {
-                                if i == CONTENT_IDX {
-                                    self.circular_network_layout.hit_test_content(cx, cy, 0.0, BREADCRUMB_H)
-                                } else if i == LEFT_MENUBAR_IDX {
-                                    false
-                                } else if i == BREADCRUMB_IDX {
-                                    self.circular_network_layout.hit_test_breadcrumb(cx, cy, 0.0, BREADCRUMB_H)
-                                } else if i == NETWORK_PANEL_IDX {
-                                    self.slots.network_panel.hit_test(cx, cy, &self.ui_context)
-                                } else {
-                                    false
-                                }
-                            } else {
-                                self.slots.get_dyn_mut(i).hit_test(cx, cy, &self.ui_context)
-                            };
-                            let (tx, ty) = if inside { (cx, cy) } else { (-9999.0, -9999.0) };
-                            let mv = cce_ui::widget::Event::PointerMove { x: tx, y: ty, local_x: tx, local_y: ty };
-                            let ptr = self.slots.get_dyn_mut(i) as *mut (dyn WidgetHost + 'static);
-                            if unsafe { (*ptr).handle_event(&mv, &mut self.ui_context) } {
-                                changed = true;
-                            }
-                        }
+                    if self.broadcast_pointer() {
+                        changed = true;
                     }
                 }
                 changed
@@ -9083,6 +9135,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             changed = true;
                         }
                         if self.orbit_drag.take().is_some() {
+                            self.broadcast_pointer();
                             return true;
                         }
                         if self.viewer_tool_release() {
@@ -9212,6 +9265,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             self.drag_press_cursor = None;
                             changed = true;
                         }
+                        // Every capture the release ends is down by here:
+                        // the pane under the pointer hovers again without
+                        // waiting for it to move.
+                        if self.broadcast_pointer() {
+                            changed = true;
+                        }
                         let mut sync_params = false;
                         {
                             let ctx = &mut self.ui_context;
diff --git a/src/dialog.rs b/src/dialog.rs
index 5a6f548..17236e3 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1360,6 +1360,8 @@ impl State {
         self.refresh_dialog_rows();
         self.rebuild_positions();
         self.apply_layout();
+        // Modal: the panes lose the pointer while it is up.
+        self.broadcast_pointer();
         self.update_status_text(match mode {
             Mode::Commands => "Dialog: type to filter commands and settings, Escape closes.",
             Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
@@ -1376,6 +1378,8 @@ impl State {
         }
         self.rebuild_positions();
         self.apply_layout();
+        // The pane under the pointer hovers again without a motion.
+        self.broadcast_pointer();
     }
 
     /// Re-rank the row list against the current query, for whichever mode is
diff --git a/src/main.rs b/src/main.rs
index 8ca38c7..14aca60 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10892,6 +10892,56 @@ mod tests {
         assert_eq!(d.take_slider_change(), None);
     }
 
+    /// A captured pointer hovers no pane. A control lit at the press used to
+    /// stay lit for the length of an orbit, a node drag or a pane resize —
+    /// the broadcast ran only while no widget or app drag was live, and the
+    /// cursor arm's early returns skipped it for every other gesture — and
+    /// the dialog, a modal, let the panes beside its plate keep hovering.
+    /// Now a capture clears every pane, the release hands the pointer back
+    /// without a motion, and the dialog's open and close do the same.
+    #[test]
+    fn a_captured_pointer_hovers_no_pane_and_the_release_hands_it_back() {
+        use crate::slots::NETWORK_PANEL_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
+        let mut state = State::new(false);
+        let (x, y, w, h) = state.positions[NETWORK_PANEL_IDX];
+        assert!(w > 0.0 && h > 0.0, "the network plate is laid out");
+        let (cx, cy) = (x + w * 0.5, y + h * 0.5);
+        let hovered = |state: &State| state.slots.get_dyn(NETWORK_PANEL_IDX).base().hovered;
+        let moved = |state: &mut State, x: f32, y: f32| {
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        };
+        moved(&mut state, cx, cy);
+        assert!(hovered(&state), "the plate under a free pointer hovers");
+
+        // An orbit captures the pointer: the next motion clears the plate,
+        // and the release hands the pointer back where it stands.
+        state.orbit_drag = Some((cx, cy));
+        moved(&mut state, cx + 1.0, cy);
+        assert!(!hovered(&state), "a captured pointer hovers no pane");
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert!(state.orbit_drag.is_none());
+        assert!(hovered(&state), "the release re-hovers without a motion");
+
+        // An app drag (a pane edge) captures it the same way.
+        state.app_drag = Some(crate::app::AppDrag::ParamResize { start_w: 100.0, start_mouse_x: cx });
+        moved(&mut state, cx + 2.0, cy);
+        assert!(!hovered(&state));
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert!(state.app_drag.is_none());
+        assert!(hovered(&state));
+
+        // The dialog is modal: open, the panes lose the pointer, motion does
+        // not give it back, and closing does.
+        state.open_dialog();
+        assert!(!hovered(&state), "a pane beside the dialog does not hover");
+        moved(&mut state, cx + 3.0, cy);
+        assert!(!hovered(&state));
+        state.close_dialog();
+        assert!(hovered(&state), "closing hands the pointer back");
+    }
+
     /// A dialog slider is worked by the pointer, and every motion of a drag
     /// lands its value. Until 2026-09-28 each landing ran `apply_setting`'s
     /// whole regenerate pass — a graph evaluation (and two more keyed on the