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

commit2039d70111ef120f4f62773c5aa7a566459e6caa
parent537197c68e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:02
feat: the keyboard can walk a context menu

context_menu::set_hovered_item highlights a row as the pointer would and
step_hovered moves the highlight to the next row that can run, skipping
headers and separators and stopping at the ends; both scroll a shortened
menu to the row without re-hovering the one under the pointer. For hosts
that open a menu as a dropdown and route Up/Down/Enter to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md          | 11 +++++++
 src/widget/core.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 102 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7711f42..f5b2280 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -240,6 +240,17 @@ Layer surfaces keep the in-window menu, placed by `context_menu::constrain_to` w
 same flip / slide / shorten rules inside the window; so does any app run with
 `CCE_UI_MENU_POPUP=0`.
 
+### The keyboard can walk a menu (since 2026-10-01)
+
+`context_menu::set_hovered_item(Some(idx))` highlights a row as the pointer would, and
+`context_menu::step_hovered(dir)` moves the highlight to the next row that can run
+(down for `dir > 0`), skipping header rows and `-` separators, stopping at either end
+rather than wrapping. Both scroll a shortened menu to the row, WITHOUT re-hovering the
+row under the pointer the way `scroll_by` does: the keyboard put the highlight there.
+The menu itself still reads no keys — a host that wants a walkable menu (the designer's
+dialog dropdowns) routes Up/Down to `step_hovered` and runs `hovered_item()` on Enter.
+`the_keyboard_steps_the_highlight_over_what_cannot_run` is the test.
+
 ### A row can open a submenu (since 2026-09-29)
 
 `context_menu::set_row_submenu(idx, SubmenuSpec { options, header_count, sliders })`,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 9185e5d..11a1287 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -796,6 +796,59 @@ pub mod context_menu {
             (self.content_h - self.h).max(0.0)
         }
 
+        /// Whether a press on row `idx` could run it: not a header, not a
+        /// separator.
+        fn actionable(&self, idx: usize) -> bool {
+            idx >= self.header_count && self.options.get(idx).is_some_and(|o| o != "-")
+        }
+
+        /// Highlight row `idx` as the pointer would, scrolled into view —
+        /// the keyboard's hover. `None`, or a row that cannot run, clears it.
+        pub fn set_hovered_item(&mut self, idx: Option<usize>) {
+            self.hovered_item = idx.filter(|&i| self.actionable(i));
+            if let Some(i) = self.hovered_item {
+                self.scroll_into_view(i);
+            }
+        }
+
+        /// Move the highlight to the next row that can run, `dir` > 0 down
+        /// and < 0 up, skipping headers and separators; from no highlight,
+        /// the first (or last) such row. Stops at either end rather than
+        /// wrapping. Returns the highlighted row.
+        pub fn step_hovered(&mut self, dir: i32) -> Option<usize> {
+            let n = self.options.len() as i32;
+            let step = if dir < 0 { -1 } else { 1 };
+            let mut i = match self.hovered_item {
+                Some(h) => h as i32,
+                None if step > 0 => -1,
+                None => n,
+            };
+            loop {
+                i += step;
+                if i < 0 || i >= n {
+                    return self.hovered_item;
+                }
+                if self.actionable(i as usize) {
+                    self.set_hovered_item(Some(i as usize));
+                    return self.hovered_item;
+                }
+            }
+        }
+
+        /// Scroll so row `idx` is wholly inside the plate. Unlike
+        /// [`Self::scroll_by`] this does not re-hover the row under the
+        /// pointer: the keyboard put the highlight where it is.
+        fn scroll_into_view(&mut self, idx: usize) {
+            let top = idx as f32 * ROW_H;
+            let bottom = top + ROW_H + 2.0 * PAD;
+            if top < self.scroll {
+                self.scroll = top;
+            } else if bottom > self.scroll + self.h {
+                self.scroll = bottom - self.h;
+            }
+            self.scroll = self.scroll.clamp(0.0, self.max_scroll());
+        }
+
         /// Scroll the rows by `dy` px (positive shows rows further down),
         /// clamped; re-hovers whatever row the pointer now sits on. `true`
         /// when anything moved.
@@ -1502,6 +1555,17 @@ pub mod context_menu {
     pub fn h() -> f32 { CONTEXT_MENU.with(|m| m.borrow().h) }
     pub fn hovered_item() -> Option<usize> { CONTEXT_MENU.with(|m| m.borrow().hovered_item) }
     pub fn options() -> Vec<String> { CONTEXT_MENU.with(|m| m.borrow().options.clone()) }
+    /// Highlight a row from the keyboard — see
+    /// [`ContextMenuState::set_hovered_item`]. A host that walks a menu
+    /// with the arrow keys (a dropdown) sets the open row with this and
+    /// moves with [`step_hovered`], and runs [`hovered_item`] on Enter.
+    pub fn set_hovered_item(idx: Option<usize>) {
+        CONTEXT_MENU.with(|m| m.borrow_mut().set_hovered_item(idx));
+    }
+    /// See [`ContextMenuState::step_hovered`].
+    pub fn step_hovered(dir: i32) -> Option<usize> {
+        CONTEXT_MENU.with(|m| m.borrow_mut().step_hovered(dir))
+    }
 
     /// The row under a point, PAD-aware — the ONE row hit test. Every host
     /// that dispatches the menu itself should ask this rather than divide
@@ -1729,6 +1793,33 @@ mod context_menu_slider_tests {
         m
     }
 
+    /// The keyboard walks a menu: a step skips the header and the
+    /// separators, stops at both ends rather than wrapping, and scrolls a
+    /// shortened menu to the row it lands on.
+    #[test]
+    fn the_keyboard_steps_the_highlight_over_what_cannot_run() {
+        let mut m = ContextMenuState::new();
+        let rows = ["Header", "A", "-", "B", "C"].map(String::from).to_vec();
+        m.show(100.0, 50.0, rows, 1, WidgetId(7));
+        assert_eq!(m.step_hovered(1), Some(1), "the header is skipped");
+        assert_eq!(m.step_hovered(1), Some(3), "and the separator");
+        assert_eq!(m.step_hovered(1), Some(4));
+        assert_eq!(m.step_hovered(1), Some(4), "the last row stays");
+        assert_eq!(m.step_hovered(-1), Some(3));
+        assert_eq!(m.step_hovered(-1), Some(1));
+        assert_eq!(m.step_hovered(-1), Some(1), "the header is not reached");
+        m.set_hovered_item(Some(2));
+        assert_eq!(m.hovered_item, None, "a separator cannot be highlighted");
+        assert_eq!(m.step_hovered(-1), Some(4), "from nothing, up starts at the bottom");
+
+        let mut long = long_menu();
+        long.place(100.0, 50.0, 200.0);
+        long.set_hovered_item(Some(15));
+        assert!(long.row_y(15) >= long.y && long.row_y(15) + ROW_H <= long.y + long.h, "scrolled into view");
+        long.set_hovered_item(Some(0));
+        assert_eq!(long.scroll, 0.0);
+    }
+
     /// Placed shorter than its rows, the menu scrolls: the wheel moves the
     /// rows a row a notch, up shows the rows above, it stops at both ends,
     /// and the row under the pointer — hover, press — is the one DRAWN