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

commitae50154ceef7bdd0e3358b37d5195247b3ef4bcc
parent544be80911
authorLucas Galante <[email protected]>
date2026-07-12 21:52
refactor(widget)!: the direct-dispatch block collapses into handle_event (6bd)

WidgetHost sheds eight methods (67 -> 59 series count): cursor_moved,
on_cursor_moved, mouse_input, mouse_wheel, keyboard_input, drag_begin,
drag_update, drag_end. Every event delivery now goes through handle_event —
the entry points live on as inherent Adapted<W> methods (in-crate composite
forwards to CONCRETE embedded children resolve unchanged: ramp,
parameters_bg, treelist, dropdown, menu, paginator, breadcrumb,
scroll_box); dyn callers rewrote as handle_event(&Event::...), identical by
construction since the entry points were handle_event forwards and the
drag-fix maps Event::Drag* onto the Input drag hooks.

Also landed, per the census design: keyboard's !visible() gate moved from
the entry point into handle_event's KeyInput arm — hidden focused widgets
now drop keys on the ROUTED path too (previously a hole); the deleted trait
cursor_moved default (coverage-gated hover) became the inherent
Adapted::cursor_moved; the trait's default handle_event now serves test
shims only (base hover on moves + tick).

Verified: 164 tests; workspace builds; settings audio render stream
byte-identical; TI gallery four-state interactive A/B all empty 8% masks;
designer /state identical before and after a canvas click, pixels AE=14.

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

 src/widget/container/spreadsheet.rs |  12 +--
 src/widget/display/float3.rs        |   4 +-
 src/widget/display/graph.rs         |   4 +-
 src/widget/display/node.rs          |   2 +-
 src/widget/input/slider.rs          |  34 ++++----
 src/widget/input/spinbox.rs         |   4 +-
 src/widget/mod.rs                   |  58 +++----------
 src/widget/model.rs                 | 159 +++++++++++++++++++-----------------
 8 files changed, 127 insertions(+), 150 deletions(-)

diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 3bab1c2..b63f04a 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -420,16 +420,16 @@ mod tests {
         assert!(WidgetHost::draggable(&s));
 
         // Press on the scrollbar track (x >= 200-6-2-4): thumb jumps, drag engages.
-        WidgetHost::drag_begin(&mut s, 195.0, 80.0);
+        s.drag_begin(195.0, 80.0);
         assert!(WidgetHost::is_dragging(&s));
-        assert!(WidgetHost::drag_update(&mut s, 195.0, 110.0), "thumb drag scrolls");
+        assert!(s.drag_update(195.0, 110.0), "thumb drag scrolls");
         let dragged_to = s.inner().geom(rect).unwrap().scroll;
         assert!(dragged_to > 0.0);
-        WidgetHost::drag_end(&mut s);
+        s.drag_end();
         assert!(!WidgetHost::is_dragging(&s));
 
         // A body press (left of the scrollbar) engages no drag.
-        WidgetHost::drag_begin(&mut s, 50.0, 60.0);
+        s.drag_begin(50.0, 60.0);
         assert!(!WidgetHost::is_dragging(&s), "body press is not a scrollbar drag");
 
         // End key jumps to max; Home returns to zero. (Keys route via keyboard_input.)
@@ -441,12 +441,12 @@ mod tests {
             ctrl: false,
             shift: false,
         };
-        assert!(WidgetHost::keyboard_input(&mut s, &end, &mut ctx));
+        assert!(s.keyboard_input(&end, &mut ctx));
         let g = s.inner().geom(rect).unwrap();
         assert_eq!(g.scroll, g.max_scroll);
 
         // Hidden: the focused-widget keyboard path must not consume keys.
         s.set_visible(false);
-        assert!(!WidgetHost::keyboard_input(&mut s, &end, &mut ctx), "hidden widget ignores keys");
+        assert!(!s.keyboard_input(&end, &mut ctx), "hidden widget ignores keys");
     }
 }
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 7a74186..2a95a01 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -353,9 +353,9 @@ mod tests {
         let track_y = rows[0].1 + 8.0;
         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, 150.0, track_y, &mut ctx));
         assert!(WidgetHost::is_dragging(&f));
-        WidgetHost::drag_update(&mut f, 260.0, track_y);
+        f.drag_update(260.0, track_y);
         assert!(f.values[0] > 0.5, "drag right raises the value");
-        WidgetHost::drag_end(&mut f);
+        f.drag_end();
         assert!(!WidgetHost::is_dragging(&f));
     }
 }
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index dbe2d90..030078c 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -1056,8 +1056,8 @@ mod tests {
 
         // Drag one grid step right (step_x = 100): snap puts the node at column 1, but cell
         // (1, 0) is free so it lands there.
-        WidgetHost::drag_begin(&mut g, 110.0, 120.0);
-        assert!(WidgetHost::drag_update(&mut g, 210.0, 120.0));
+        g.drag_begin(110.0, 120.0);
+        assert!(g.drag_update(210.0, 120.0));
         assert!(g.mouse_input(MouseButton::Left, ElementState::Released, 210.0, 120.0, &mut ctx));
         assert!(!WidgetHost::is_dragging(&g));
         assert_eq!(g.get_nodes()[0].position, (1.0, 0.0));
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index d86d644..183032b 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -268,7 +268,7 @@ mod tests {
         // A press outside the toggle starts a drag; reposition snaps to the drag origin.
         assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 110.0, &mut ctx));
         assert!(WidgetHost::is_dragging(&node));
-        assert!(WidgetHost::drag_update(&mut node, 150.0, 130.0));
+        assert!(node.drag_update(150.0, 130.0));
         assert_eq!(WidgetHost::rect(&node), (140.0, 120.0, 120.0, 40.0), "moved by the pointer delta");
         assert!(node.mouse_input(MouseButton::Left, ElementState::Released, 150.0, 130.0, &mut ctx));
         assert!(!WidgetHost::is_dragging(&node));
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 99b4c1c..7235c6e 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -699,20 +699,20 @@ mod tests {
         assert_eq!(rs.values(), (0.2, 0.8));
 
         // Thumb size 18, range 182; low center = 55.4.
-        WidgetHost::drag_begin(&mut rs, 55.4, 20.0);
+        rs.drag_begin(55.4, 20.0);
         assert_eq!(rs.active_thumb, Some(ActiveThumb::Low));
-        assert!(WidgetHost::drag_update(&mut rs, 100.9, 20.0));
+        assert!(rs.drag_update(100.9, 20.0));
         assert!((rs.values().0 - 0.45).abs() < 0.01);
         assert_eq!(rs.values().1, 0.8);
-        WidgetHost::drag_end(&mut rs);
+        rs.drag_end();
         assert_eq!(rs.active_thumb, None);
 
         // High thumb 0.8 -> 0.6.
-        WidgetHost::drag_begin(&mut rs, 164.6, 20.0);
+        rs.drag_begin(164.6, 20.0);
         assert_eq!(rs.active_thumb, Some(ActiveThumb::High));
-        assert!(WidgetHost::drag_update(&mut rs, 128.2, 20.0));
+        assert!(rs.drag_update(128.2, 20.0));
         assert!((rs.values().1 - 0.6).abs() < 0.01);
-        WidgetHost::drag_end(&mut rs);
+        rs.drag_end();
     }
 
     #[test]
@@ -720,18 +720,18 @@ mod tests {
         let mut rs = RangeSlider::new().with_values(0.5, 0.5);
         WidgetHost::set_rect(&mut rs, 10.0, 10.0, 200.0, 20.0);
 
-        WidgetHost::drag_begin(&mut rs, 109.0, 20.0);
+        rs.drag_begin(109.0, 20.0);
         assert_eq!(rs.active_thumb, Some(ActiveThumb::Low));
-        WidgetHost::drag_end(&mut rs);
+        rs.drag_end();
 
-        WidgetHost::drag_begin(&mut rs, 111.0, 20.0);
+        rs.drag_begin(111.0, 20.0);
         assert_eq!(rs.active_thumb, Some(ActiveThumb::High));
-        WidgetHost::drag_end(&mut rs);
+        rs.drag_end();
 
-        WidgetHost::drag_begin(&mut rs, 110.0, 20.0);
-        WidgetHost::drag_update(&mut rs, 150.0, 20.0);
+        rs.drag_begin(110.0, 20.0);
+        rs.drag_update(150.0, 20.0);
         assert_eq!(rs.values().0, 0.5, "low constrained to high");
-        WidgetHost::drag_end(&mut rs);
+        rs.drag_end();
     }
 
 #[test]
@@ -763,16 +763,14 @@ fn probe_slider_bridge() {
             ptr,
         ));
         assert!(WidgetHost::is_dragging(&sl));
-        assert!(WidgetHost::drag_update(&mut sl, 80.0, 10.0));
+        assert!(sl.drag_update(80.0, 10.0));
         assert!(sl.inner().value() > 0.5);
-        WidgetHost::drag_end(&mut sl);
+        sl.drag_end();
 
         // Wheel adjusts value when the gesture starts fresh.
         ctx.scroll_gesture_new = true;
         let before = sl.inner().value();
-        assert!(WidgetHost::mouse_wheel(
-            &mut sl,
-            &MouseScrollDelta::LineDelta(0.0, 1.0),
+        assert!(sl.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0),
             50.0,
             10.0,
             &mut ctx,
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 023c254..a80de47 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -451,12 +451,12 @@ mod tests {
         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
 
         // Legacy test: click at (75, 33) lands in the decrement zone.
-        assert!(WidgetHost::mouse_input(&mut sb, MouseButton::Left, ElementState::Pressed, 75.0, 33.0, &mut ctx));
+        assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 75.0, 33.0, &mut ctx));
         assert_eq!(sb.value, -1);
         assert!(WidgetHost::take_change(&mut sb));
 
         // Increment zone (past 77.5% of the width).
-        assert!(WidgetHost::mouse_input(&mut sb, MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
+        assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
         assert_eq!(sb.value, 0);
     }
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 9484c36..ba26f8d 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -224,18 +224,17 @@ pub trait WidgetHost {
     fn as_ptr_mut(&mut self) -> *mut (dyn WidgetHost + 'static);
 
     fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
+        // The default serves test shims only (Adapted overrides this): base hover
+        // bookkeeping on moves, tick forwarding, everything else inert — the old
+        // per-method dispatch died with the direct-dispatch entry points (6bd collapse).
         match event {
             Event::PointerMove { x, y, .. } => {
-                self.cursor_moved(*x, *y, ctx)
-            }
-            Event::MouseButton { button, state, x, y, .. } => {
-                self.mouse_input(*button, *state, *x, *y, ctx)
-            }
-            Event::MouseWheel { delta, x, y, .. } => {
-                self.mouse_wheel(delta, *x, *y, ctx)
-            }
-            Event::KeyInput(key_event) => {
-                self.keyboard_input(key_event, ctx)
+                let (px, py) = (*x, *y);
+                ctx.set_cursor_pos(px, py);
+                let was = self.base().hovered;
+                let is_hit = self.hit_test(px, py, ctx);
+                self.base_mut().hovered = is_hit;
+                was != is_hit
             }
             Event::Tick(dt) => {
                 self.tick(*dt, ctx)
@@ -309,37 +308,10 @@ pub trait WidgetHost {
         px >= hx && px <= hx + hw && py >= y && py <= y + h
     }
 
-    fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        ctx.set_cursor_pos(px, py);
-        if ctx.is_coordinate_covered(self.base().id(), px, py) {
-            let was = self.base().hovered;
-            if was {
-                self.base_mut().hovered = false;
-                self.handle_event(&Event::MouseLeave, ctx);
-            }
-            return was;
-        }
-        self.on_cursor_moved(px, py, ctx)
-    }
-
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was = self.base().hovered;
-        let is_hit = self.hit_test(px, py, ctx);
-        self.base_mut().hovered = is_hit;
-        if was != is_hit {
-            if is_hit {
-                self.handle_event(&Event::MouseEnter, ctx);
-            } else {
-                self.handle_event(&Event::MouseLeave, ctx);
-            }
-            true
-        } else {
-            false
-        }
-    }
-
-    fn mouse_input(&mut self, _button: MouseButton, _state: ElementState, _px: f32, _py: f32, _ctx: &mut UiContext) -> bool { false }
-    fn mouse_wheel(&mut self, _delta: &MouseScrollDelta, _px: f32, _py: f32, _ctx: &mut UiContext) -> bool { false }
+    // The direct-dispatch entry points (`cursor_moved`, `on_cursor_moved`, `mouse_input`,
+    // `mouse_wheel`, `keyboard_input`, `drag_begin`/`drag_update`/`drag_end`) are GONE from
+    // the trait (6bd collapse): every event delivery goes through `handle_event` — the entry
+    // points live on as inherent `Adapted<W>` methods for concrete in-crate forwards.
 
     // `hovered`/`set_hovered` are GONE from the trait (6bd batch 2): the state is the base
     // `Widget::hovered` flag, read/written directly by the defaults above; Button/Checkbox
@@ -367,9 +339,6 @@ pub trait WidgetHost {
     fn plate_bevel(&self) -> Option<f32> { None }
 
     fn is_dragging(&self) -> bool { false }
-    fn drag_update(&mut self, _px: f32, _py: f32) -> bool { false }
-    fn drag_begin(&mut self, _px: f32, _py: f32) {}
-    fn drag_end(&mut self) {}
     fn take_click(&mut self) -> bool { false }
     fn draggable(&self) -> bool { false }
 
@@ -515,7 +484,6 @@ pub trait WidgetHost {
     }
     fn prepare_text(&mut self, _fs: &mut glyphon::FontSystem) {}
     fn set_selected(&mut self, _selected: bool) {}
-    fn keyboard_input(&mut self, _event: &KeyEvent, _ctx: &mut UiContext) -> bool { false }
 
     fn set_visible(&mut self, _visible: bool) {}
     fn visible(&self) -> bool { true }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index c63db56..a45551b 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -689,6 +689,86 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         ctx.clear_children_ids(self.base.id());
     }
 
+    // --- The direct-dispatch entry points, inherent since the 6bd collapse. In-crate
+    // composites forward to their CONCRETE embedded children through these; dyn callers
+    // and the router go through `handle_event`, which these forward to (the two paths
+    // are identical by construction — including Drag*, which handle_event maps onto the
+    // Input drag hooks).
+
+    pub fn mouse_input(&mut self, button: crate::widget::MouseButton, state: crate::widget::ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        self.handle_event(
+            &Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py },
+            ctx,
+        )
+    }
+    pub fn mouse_wheel(&mut self, delta: &crate::widget::MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        self.handle_event(
+            &Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py },
+            ctx,
+        )
+    }
+    pub fn keyboard_input(&mut self, event: &crate::widget::KeyEvent, ctx: &mut UiContext) -> bool {
+        self.handle_event(&Event::KeyInput(event.clone()), ctx)
+    }
+    pub fn drag_begin(&mut self, px: f32, py: f32) {
+        let rect = self.content_rect();
+        Input::drag_begin(&mut self.inner, px, py, rect)
+    }
+    pub fn drag_update(&mut self, px: f32, py: f32) -> bool {
+        let rect = self.content_rect();
+        if let Some((nx, ny)) = Input::drag_reposition(&mut self.inner, px, py, rect) {
+            self.base.x = nx;
+            self.base.y = ny;
+            return true;
+        }
+        Input::drag_update(&mut self.inner, px, py, rect)
+    }
+    pub fn drag_end(&mut self) {
+        Input::drag_end(&mut self.inner)
+    }
+
+    /// The deleted trait default: coverage-gated hover dispatch (an open popover covering
+    /// the point clears the hover instead of recomputing it).
+    pub fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        ctx.set_cursor_pos(px, py);
+        if ctx.is_coordinate_covered(self.base.id(), px, py) {
+            let was = self.base.hovered;
+            if was {
+                self.base.hovered = false;
+                self.handle_event(&Event::MouseLeave, ctx);
+            }
+            return was;
+        }
+        self.on_cursor_moved(px, py, ctx)
+    }
+
+    /// Ungated pointer moves (no popover-coverage check): 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).
+    pub 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;
+        }
+        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
+        }
+    }
+
     /// Register + link a child under this widget (off `WidgetHost` in 6bd batch 4; dyn callers
     /// went to `focus::link_parent_child`/tree ops).
     pub fn add_child(&mut self, child: *mut (dyn WidgetHost + 'static), ctx: &mut UiContext) {
@@ -1340,80 +1420,6 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
     fn is_scrollable(&self) -> bool {
         Input::scrollable(&self.inner)
     }
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        let rect = self.content_rect();
-        Input::drag_begin(&mut self.inner, px, py, rect)
-    }
-    fn drag_update(&mut self, px: f32, py: f32) -> bool {
-        let rect = self.content_rect();
-        if let Some((nx, ny)) = Input::drag_reposition(&mut self.inner, px, py, rect) {
-            self.base.x = nx;
-            self.base.y = ny;
-            return true;
-        }
-        Input::drag_update(&mut self.inner, px, py, rect)
-    }
-    fn drag_end(&mut self) {
-        Input::drag_end(&mut 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 WidgetHost defaults and the
-    // widget would go deaf on those paths. Route them into `handle_event` so the hit-gating /
-    // context-menu / on_event pipeline applies identically on both paths.
-
-    fn mouse_input(&mut self, button: crate::widget::MouseButton, state: crate::widget::ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        self.handle_event(
-            &Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py },
-            ctx,
-        )
-    }
-    fn mouse_wheel(&mut self, delta: &crate::widget::MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        self.handle_event(
-            &Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py },
-            ctx,
-        )
-    }
-    fn keyboard_input(&mut self, event: &crate::widget::KeyEvent, ctx: &mut UiContext) -> bool {
-        // A widget hidden while still holding focus (the designer keys into `focused_widget`;
-        // hiding a pane doesn't unfocus it) must not consume keys — the legacy visibility-toggled
-        // widgets gated their keyboard_input overrides on `visible` themselves.
-        if !self.visible() {
-            return false;
-        }
-        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 `WidgetHost::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
     /// (unless the widget opts out — [`Input::tracks_base_focus`], TextBox's legacy `focus`
     /// never set it) and tell the widget via the same `FocusIn`/`FocusOut` events the router
@@ -1560,6 +1566,11 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 Input::drag_end(&mut self.inner);
                 true
             }
+            // A widget hidden while still holding focus (the designer keys into
+            // `focused_widget`; hiding a pane doesn't unfocus it) must not consume keys —
+            // formerly the `keyboard_input` entry point's gate, now on the one funnel
+            // (which also closes the routed path's missing-gate hole).
+            Event::KeyInput(_) if !self.visible() => false,
             // 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.