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

commit8f7e9c5288f3c6b4cb8125a3749068c802779d4b
parent99d07dbc5d
authorLucas Galante <[email protected]>
date2026-06-27 15:48
feat: Add Page Up, Page Down, Home, and End keys to scrolling interfaces

 src/widget/container/page.rs        | 114 +++++++++++++++++++++++++++++++++++
 src/widget/container/scroll_box.rs  | 115 ++++++++++++++++++++++++++++++++++--
 src/widget/container/spreadsheet.rs |  40 +++++++++++++
 src/widget/json_layout.rs           |  39 ++++++++++++
 4 files changed, 302 insertions(+), 6 deletions(-)

diff --git a/src/widget/container/page.rs b/src/widget/container/page.rs
index a430380..ef8394a 100644
--- a/src/widget/container/page.rs
+++ b/src/widget/container/page.rs
@@ -429,6 +429,52 @@ impl Element for Page {
         }
         false
     }
+
+    fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+        if !self.visible {
+            return false;
+        }
+        if event.state != ElementState::Pressed {
+            return false;
+        }
+        let (x, y, w, h) = self.rect();
+        let max_scroll = (self.content_h - h).max(0.0);
+        if max_scroll <= 0.0 {
+            return false;
+        }
+
+        let old_scroll = self.scroll_y;
+        match &event.logical_key {
+            Key::Named(NamedKey::ArrowDown) => {
+                self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
+            }
+            Key::Named(NamedKey::ArrowUp) => {
+                self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
+            }
+            Key::Named(NamedKey::PageDown) => {
+                self.scroll_y = (self.scroll_y + h).clamp(0.0, max_scroll);
+            }
+            Key::Named(NamedKey::PageUp) => {
+                self.scroll_y = (self.scroll_y - h).clamp(0.0, max_scroll);
+            }
+            Key::Named(NamedKey::Home) => {
+                self.scroll_y = 0.0;
+            }
+            Key::Named(NamedKey::End) => {
+                self.scroll_y = max_scroll;
+            }
+            _ => return false,
+        }
+
+        self.scroll_bar.scroll_y = self.scroll_y;
+        if (self.scroll_y - old_scroll).abs() > 0.01 {
+            self.set_rect(x, y, w, h);
+            self.mark_dirty(ctx);
+            true
+        } else {
+            false
+        }
+    }
 }
 
 #[cfg(test)]
@@ -494,5 +540,73 @@ mod tests {
         assert!(page.check_out_of_bounds(&event_scrolled_out, &ctx));
         assert!(!page.hit_test(100.0, 1000.0, &ctx));
     }
+
+    #[test]
+    fn test_page_keyboard_input() {
+        let mut page = Page::new(0.0, 0.0, 800.0, 600.0);
+        page.content_h = 1000.0; // max_scroll = 400.0
+        
+        let mut ctx = UiContext::new();
+        
+        // 1. ArrowDown
+        let event_down = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::ArrowDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(page.keyboard_input(&event_down, &mut ctx));
+        assert_eq!(page.scroll_y, 24.0);
+
+        // 2. PageDown
+        let event_pgdown = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::PageDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(page.keyboard_input(&event_pgdown, &mut ctx));
+        assert_eq!(page.scroll_y, 400.0);
+
+        // 3. PageUp
+        let event_pgup = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::PageUp),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(page.keyboard_input(&event_pgup, &mut ctx));
+        assert_eq!(page.scroll_y, 0.0);
+
+        // 4. End
+        let event_end = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::End),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(page.keyboard_input(&event_end, &mut ctx));
+        assert_eq!(page.scroll_y, 400.0);
+
+        // 5. Home
+        let event_home = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::Home),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(page.keyboard_input(&event_home, &mut ctx));
+        assert_eq!(page.scroll_y, 0.0);
+    }
 }
 
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index a948fc1..06ff7c3 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -146,24 +146,38 @@ impl Element for ScrollBox {
         quads
     }
 
-    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
-        if !focus::is_focused(self) {
+    fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+        let self_addr = self as *const Self as *const () as usize;
+        let has_focus = ctx.is_focused_addr(self_addr) || {
+            let mut current = ctx.focused_widget;
+            let mut found = false;
+            while let Some(ptr) = current {
+                let ptr_addr = ptr as *const () as usize;
+                if ptr_addr == self_addr {
+                    found = true;
+                    break;
+                }
+                current = unsafe { (*ptr).parent(ctx) };
+            }
+            found
+        };
+
+        if !has_focus {
             return false;
         }
         if event.state != ElementState::Pressed {
             return false;
         }
+        let max_scroll = (self.content_h - self.viewport_h).max(0.0);
         if event.ctrl {
             match &event.logical_key {
                 Key::Character(c) if c == "n" || c == "N" => {
                     let old_scroll = self.scroll_y;
-                    let max_scroll = (self.content_h - self.viewport_h).max(0.0);
                     self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
                     (self.scroll_y - old_scroll).abs() > 0.01
                 }
                 Key::Character(c) if c == "p" || c == "P" => {
                     let old_scroll = self.scroll_y;
-                    let max_scroll = (self.content_h - self.viewport_h).max(0.0);
                     self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
                     (self.scroll_y - old_scroll).abs() > 0.01
                 }
@@ -173,16 +187,34 @@ impl Element for ScrollBox {
             match &event.logical_key {
                 Key::Named(NamedKey::ArrowDown) => {
                     let old_scroll = self.scroll_y;
-                    let max_scroll = (self.content_h - self.viewport_h).max(0.0);
                     self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll);
                     (self.scroll_y - old_scroll).abs() > 0.01
                 }
                 Key::Named(NamedKey::ArrowUp) => {
                     let old_scroll = self.scroll_y;
-                    let max_scroll = (self.content_h - self.viewport_h).max(0.0);
                     self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll);
                     (self.scroll_y - old_scroll).abs() > 0.01
                 }
+                Key::Named(NamedKey::PageDown) => {
+                    let old_scroll = self.scroll_y;
+                    self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max_scroll);
+                    (self.scroll_y - old_scroll).abs() > 0.01
+                }
+                Key::Named(NamedKey::PageUp) => {
+                    let old_scroll = self.scroll_y;
+                    self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max_scroll);
+                    (self.scroll_y - old_scroll).abs() > 0.01
+                }
+                Key::Named(NamedKey::Home) => {
+                    let old_scroll = self.scroll_y;
+                    self.scroll_y = 0.0;
+                    (self.scroll_y - old_scroll).abs() > 0.01
+                }
+                Key::Named(NamedKey::End) => {
+                    let old_scroll = self.scroll_y;
+                    self.scroll_y = max_scroll;
+                    (self.scroll_y - old_scroll).abs() > 0.01
+                }
                 _ => false,
             }
         }
@@ -245,4 +277,75 @@ mod tests {
         // 30 >= 22.0 and 30 + 24 <= 118.0, so it should return Some(30.0)
         assert_eq!(sb.get_item_draw_y(60.0, 24.0), Some(30.0));
     }
+
+    #[test]
+    fn test_scroll_box_keyboard_input() {
+        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();
+        // Focus the scroll box
+        ctx.set_focused(&mut sb);
+        
+        // 1. ArrowDown key
+        let event_down = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::ArrowDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(sb.keyboard_input(&event_down, &mut ctx));
+        assert_eq!(sb.scroll_y, 24.0);
+
+        // 2. PageDown key
+        let event_pgdown = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::PageDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(sb.keyboard_input(&event_pgdown, &mut ctx));
+        assert_eq!(sb.scroll_y, 124.0);
+
+        // 3. End key
+        let event_end = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::End),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(sb.keyboard_input(&event_end, &mut ctx));
+        assert_eq!(sb.scroll_y, 200.0); // clamps at max_scroll = 200.0
+
+        // 4. PageUp key
+        let event_pgup = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::PageUp),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(sb.keyboard_input(&event_pgup, &mut ctx));
+        assert_eq!(sb.scroll_y, 100.0);
+
+        // 5. Home key
+        let event_home = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::Home),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(sb.keyboard_input(&event_home, &mut ctx));
+        assert_eq!(sb.scroll_y, 0.0);
+    }
 }
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index 8f1b3bb..a33d1e7 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -398,6 +398,46 @@ impl Element for Spreadsheet {
         }
         labels
     }
+
+    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
+        if !self.visible {
+            return false;
+        }
+        if event.state != ElementState::Pressed {
+            return false;
+        }
+        let content_h = self.rows.len() as f32 * 24.0;
+        let visible_h = (self.h - 24.0).max(0.0);
+        if visible_h <= 0.0 || content_h <= visible_h {
+            return false;
+        }
+
+        let max_scroll_y = content_h - visible_h;
+        let old_scroll_y = self.scroll_y;
+        match &event.logical_key {
+            Key::Named(NamedKey::ArrowDown) => {
+                self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max_scroll_y);
+            }
+            Key::Named(NamedKey::ArrowUp) => {
+                self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max_scroll_y);
+            }
+            Key::Named(NamedKey::PageDown) => {
+                self.scroll_y = (self.scroll_y + visible_h).clamp(0.0, max_scroll_y);
+            }
+            Key::Named(NamedKey::PageUp) => {
+                self.scroll_y = (self.scroll_y - visible_h).clamp(0.0, max_scroll_y);
+            }
+            Key::Named(NamedKey::Home) => {
+                self.scroll_y = 0.0;
+            }
+            Key::Named(NamedKey::End) => {
+                self.scroll_y = max_scroll_y;
+            }
+            _ => return false,
+        }
+
+        (self.scroll_y - old_scroll_y).abs() > 0.01
+    }
 }
 
 impl SpreadsheetController for Spreadsheet {
diff --git a/src/widget/json_layout.rs b/src/widget/json_layout.rs
index 38fb55e..39f81f0 100644
--- a/src/widget/json_layout.rs
+++ b/src/widget/json_layout.rs
@@ -1,5 +1,6 @@
 use crate::widget::{
     Element, Widget, Checkbox, Button, Label, Spinbox, ColorSelector, TextLabel, MouseButton, ElementState, Slider, Event, UiContext,
+    Key, NamedKey,
 };
 use serde::Deserialize;
 
@@ -516,6 +517,44 @@ impl Element for JsonLayoutWidget {
             }
         }
 
+        if let Event::KeyInput(key_event) = event {
+            if key_event.state == ElementState::Pressed {
+                if active_page < self.page_total_heights.len() {
+                    let total_height = self.page_total_heights[active_page];
+                    let (_, _, _, bh) = self.rect();
+                    let max_scroll_y = (total_height - bh).max(0.0);
+                    if max_scroll_y > 0.0 {
+                        let old_scroll = self.page_scroll_y[active_page];
+                        match &key_event.logical_key {
+                            Key::Named(NamedKey::PageDown) => {
+                                self.page_scroll_y[active_page] = (old_scroll + bh).clamp(0.0, max_scroll_y);
+                            }
+                            Key::Named(NamedKey::PageUp) => {
+                                self.page_scroll_y[active_page] = (old_scroll - bh).clamp(0.0, max_scroll_y);
+                            }
+                            Key::Named(NamedKey::Home) => {
+                                self.page_scroll_y[active_page] = 0.0;
+                            }
+                            Key::Named(NamedKey::End) => {
+                                self.page_scroll_y[active_page] = max_scroll_y;
+                            }
+                            Key::Named(NamedKey::ArrowDown) => {
+                                self.page_scroll_y[active_page] = (old_scroll + 24.0).clamp(0.0, max_scroll_y);
+                            }
+                            Key::Named(NamedKey::ArrowUp) => {
+                                self.page_scroll_y[active_page] = (old_scroll - 24.0).clamp(0.0, max_scroll_y);
+                            }
+                            _ => {}
+                        }
+                        if (self.page_scroll_y[active_page] - old_scroll).abs() > 0.01 {
+                            self.layout_children();
+                            changed = true;
+                        }
+                    }
+                }
+            }
+        }
+
         if let Event::Tick(dt) = event {
             for w in &mut self.widgets {
                 if w.page_idx != active_page {