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

commit8c262c063353c5a3248f38b95aef94d1eff40cbc
parentcf9a579341
authorLucas Galante <[email protected]>
date2026-06-27 15:56
feat: Route scroll navigation keys to hovered scrollable widgets if they are not focused

 src/context.rs                      | 59 ++++++++++++++++++++++++++++++++++++-
 src/widget/container/page.rs        |  1 +
 src/widget/container/scroll_box.rs  | 30 ++++++++++++++++++-
 src/widget/container/spreadsheet.rs |  1 +
 src/widget/json_layout.rs           |  1 +
 src/widget/mod.rs                   |  1 +
 6 files changed, 91 insertions(+), 2 deletions(-)

diff --git a/src/context.rs b/src/context.rs
index 277652b..60638b6 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,5 +1,5 @@
 use std::collections::HashMap;
-use crate::widget::{Element, WidgetId, LayoutTree, Key, MouseButton, ElementState, Event};
+use crate::widget::{Element, WidgetId, LayoutTree, Key, NamedKey, MouseButton, ElementState, Event};
 use crate::widget::core::hover_animation::HoverState;
 use crate::widget::core::context_menu::ContextMenuState;
 
@@ -121,6 +121,40 @@ impl UiContext {
                 self.last_scroll_time = Some(now);
             }
         }
+        if let Event::KeyInput(ref key_event) = event {
+            let is_scroll_key = match &key_event.logical_key {
+                Key::Named(NamedKey::PageUp)
+                | Key::Named(NamedKey::PageDown)
+                | Key::Named(NamedKey::Home)
+                | Key::Named(NamedKey::End)
+                | Key::Named(NamedKey::ArrowUp)
+                | Key::Named(NamedKey::ArrowDown) => true,
+                _ => false,
+            };
+            if is_scroll_key {
+                let mut handled = false;
+                if let Some(focused) = self.focused_widget {
+                    unsafe {
+                        if (*focused).handle_event(event, self) {
+                            (*focused).mark_dirty(self);
+                            handled = true;
+                        }
+                    }
+                }
+                if handled {
+                    return true;
+                }
+                let (cx, cy) = self.cursor_pos;
+                if let Some(scrollable) = self.find_hovered_scrollable(root, cx, cy) {
+                    unsafe {
+                        if (*scrollable).handle_event(event, self) {
+                            (*scrollable).mark_dirty(self);
+                            return true;
+                        }
+                    }
+                }
+            }
+        }
         self.propagate_event_impl(event, root)
     }
 
@@ -811,4 +845,27 @@ impl UiContext {
         }
         hit_backplate
     }
+
+    fn find_hovered_scrollable(&self, root: *mut (dyn Element + 'static), cx: f32, cy: f32) -> Option<*mut (dyn Element + 'static)> {
+        unsafe {
+            if root.is_null() {
+                return None;
+            }
+            if !(*root).visible() {
+                return None;
+            }
+            if !(*root).hit_test(cx, cy, self) {
+                return None;
+            }
+            for child in (*root).children(self).into_iter().rev() {
+                if let Some(scrollable) = self.find_hovered_scrollable(child, cx, cy) {
+                    return Some(scrollable);
+                }
+            }
+            if (*root).is_scrollable() {
+                return Some(root);
+            }
+        }
+        None
+    }
 }
diff --git a/src/widget/container/page.rs b/src/widget/container/page.rs
index ef8394a..3996ae2 100644
--- a/src/widget/container/page.rs
+++ b/src/widget/container/page.rs
@@ -168,6 +168,7 @@ fn clip_quad(
 impl Element for Page {
     fn base(&self) -> Option<&Widget> { Some(&self.base.base) }
     fn is_page(&self) -> bool { true }
+    fn is_scrollable(&self) -> bool { true }
     fn blocks_backplate_drag(&self) -> bool { false }
 
     fn check_out_of_bounds(&self, event: &Event, _ctx: &UiContext) -> bool {
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 06ff7c3..9f2d1b9 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -52,6 +52,7 @@ impl ScrollBox {
 
 impl Element for ScrollBox {
     crate::impl_widget_base!(ScrollBox);
+    fn is_scrollable(&self) -> bool { true }
 
     fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
         self.base.x = x;
@@ -162,7 +163,8 @@ impl Element for ScrollBox {
             found
         };
 
-        if !has_focus {
+        let is_hovered = self.hit_test(ctx.cursor_pos.0, ctx.cursor_pos.1, ctx);
+        if !has_focus && !is_hovered {
             return false;
         }
         if event.state != ElementState::Pressed {
@@ -348,4 +350,30 @@ mod tests {
         assert!(sb.keyboard_input(&event_home, &mut ctx));
         assert_eq!(sb.scroll_y, 0.0);
     }
+
+    #[test]
+    fn test_scroll_box_non_focused_hovered_scrolling() {
+        let mut sb = ScrollBox::new();
+        sb.set_rect(10.0, 20.0, 100.0, 100.0);
+        sb.update_bounds(300.0, 20.0, 100.0); // max_scroll = 200.0
+
+        let mut ctx = UiContext::new();
+        ctx.register_widget(sb.base.id(), &mut sb);
+        
+        // Set cursor position over the scroll box
+        ctx.set_cursor_pos(50.0, 50.0);
+
+        let event_down = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::ArrowDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+
+        let root_ptr = &mut sb as *mut ScrollBox as *mut (dyn Element + 'static);
+        assert!(ctx.propagate_event(&Event::KeyInput(event_down), root_ptr));
+        assert_eq!(sb.scroll_y, 24.0);
+    }
 }
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index a33d1e7..b39e155 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -41,6 +41,7 @@ impl Spreadsheet {
 }
 
 impl Element for Spreadsheet {
+    fn is_scrollable(&self) -> bool { true }
     fn rounded_corners(&self) -> (bool, bool, bool, bool) { (true, true, true, true) }
     fn as_ptr(&self) -> *mut (dyn Element + 'static) {
         self as *const Self as *mut Self as *mut (dyn Element + 'static)
diff --git a/src/widget/json_layout.rs b/src/widget/json_layout.rs
index 39f81f0..d2b08e2 100644
--- a/src/widget/json_layout.rs
+++ b/src/widget/json_layout.rs
@@ -273,6 +273,7 @@ impl JsonLayoutWidget {
 }
 
 impl Element for JsonLayoutWidget {
+    fn is_scrollable(&self) -> bool { true }
     fn as_any(&self) -> &dyn std::any::Any { self }
     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
     fn as_ptr(&self) -> *mut (dyn Element + 'static) {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 7762722..1886f9f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -581,6 +581,7 @@ pub trait Element {
     fn is_layer(&self) -> bool { false }
     fn is_backplate(&self) -> bool { false }
     fn is_movable_backplate(&self) -> bool { false }
+    fn is_scrollable(&self) -> bool { false }
     fn blocks_backplate_drag(&self) -> bool { true }
     fn rounded_corners(&self) -> (bool, bool, bool, bool) { (false, false, false, false) }