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

commit4bbd40a647df1f3afd608a165f773a730e2fcb62
parent4ae950d1dc
authorLucas Galante <[email protected]>
date2026-09-21 12:38
Context menu: an even pad on every side

The plate's labels sat 8px from the left edge with 16px spare on the
right (width = widest label + 24), and the rows ran flush to the top
and bottom — the only vertical room was each row's own slack. One PAD
(8px) now applies on every side: height = rows + 2·PAD, width = widest
shaped label + 2·PAD (never narrower than before), labels one pad in.
Labels, hover tablets and separator grooves place themselves from one
row-origin helper; the hover fill rounds all four corners as its own
tablet, since the rows no longer meet the plate's edge.

The padding is plate, not row: a new row_at treats it as no row, so a
press or hover in the top or bottom pad hits nothing and a pointer a
row below the top pad is on row 1 — where the old (py - y) / ROW_H
would have called the top pad row 0. Both pointer paths and the
legacy quad view use it. Tested: height, label positions, pad hits
nothing, separators never hover.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/widget/core.rs | 102 +++++++++++++++++++++++++++++++++++++++++------------
 1 file changed, 80 insertions(+), 22 deletions(-)

diff --git a/src/widget/core.rs b/src/widget/core.rs
index 9316235..a8800ab 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -336,6 +336,12 @@ pub mod context_menu {
     /// `24.0`, and a menu whose rows are measured differently from where they
     /// are drawn selects the entry above the one under the cursor.
     pub const ROW_H: f32 = 24.0;
+    /// The plate's padding, the same on every side: the rows start this far
+    /// below the top edge and end this far above the bottom, and the labels
+    /// sit this far in from the left, with the widest label this far from
+    /// the right. Before 2026-09-21 the rows ran flush to the top and bottom
+    /// and the labels had 8px on the left against 16px on the right.
+    pub const PAD: f32 = 8.0;
 
     /// The face a menu label is drawn in: the DE's menu font, family and
     /// size — the same `menubar_font` the menubar's own drop-downs use
@@ -393,7 +399,7 @@ pub mod context_menu {
             self.x = x;
             self.y = y;
             self.options = options;
-            self.h = self.options.len() as f32 * ROW_H;
+            self.h = self.options.len() as f32 * ROW_H + 2.0 * PAD;
             // Width from the SHAPED widest label, not `bytes * 7.5`: that
             // estimate is a different face's advance (and counts bytes, so a
             // non-ASCII label over-measures), which is how a menu ends up
@@ -404,7 +410,7 @@ pub mod context_menu {
                 .iter()
                 .map(|s| crate::widget::display::measure_text_width(s, &family, size))
                 .fold(0.0f32, f32::max);
-            self.w = (widest + 24.0).max(120.0);
+            self.w = (widest + 2.0 * PAD).max(120.0);
             self.visible = true;
             self.hovered_item = None;
             self.target = Some(target);
@@ -421,16 +427,34 @@ pub mod context_menu {
             px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
         }
 
+        /// The top of row `idx`.
+        pub fn row_y(&self, idx: usize) -> f32 {
+            self.y + PAD + idx as f32 * ROW_H
+        }
+
+        /// The row under `(px, py)`, or `None` outside the plate or in its
+        /// padding — the padding is plate, not a row, so a press there
+        /// neither hovers nor fires row 0.
+        pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
+            if px < self.x || px > self.x + self.w {
+                return None;
+            }
+            let rel = py - self.y - PAD;
+            if rel < 0.0 {
+                return None;
+            }
+            let idx = (rel / ROW_H) as usize;
+            (idx < self.options.len()).then_some(idx)
+        }
+
         pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
             if !self.visible { return false; }
             let was_hovered = self.hovered_item;
             self.hovered_item = None;
-            if px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h {
-                let idx = ((py - self.y) / ROW_H) as usize;
+            if let Some(idx) = self.row_at(px, py) {
                 // A "-" row is a SEPARATOR (the dropdown's convention):
                 // engraved, never hovered, never an action.
-                if idx < self.options.len() && idx >= self.header_count && self.options[idx] != "-"
-                {
+                if idx >= self.header_count && self.options[idx] != "-" {
                     self.hovered_item = Some(idx);
                 }
             }
@@ -447,9 +471,8 @@ pub mod context_menu {
                 return false;
             }
 
-            if px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h {
-                let idx = ((py - self.y) / ROW_H) as usize;
-                if idx < self.options.len() {
+            if self.hit_test(px, py) {
+                if let Some(idx) = self.row_at(px, py) {
                     if idx >= self.header_count {
                         let opt = self.options[idx].clone();
                         if let (Some(target_id), Some(ctx)) = (self.target, ctx) {
@@ -533,10 +556,11 @@ pub mod context_menu {
                 // the first and last rows touch the plate's, and a header row is
                 // never hovered, so the top pair only rounds when there is no
                 // header above.
-                let iy = self.y + h_idx as f32 * ROW_H;
-                let inset = (depth * 0.5).max(2.0);
-                let first = h_idx == self.header_count && self.header_count == 0;
-                let last = h_idx + 1 == self.options.len();
+                // Inside the padding on every side — the rows no longer
+                // touch the plate's edge, so the fill is its own rounded
+                // tablet on the face rather than a band that meets the roll.
+                let iy = self.row_y(h_idx);
+                let inset = (depth * 0.5).max(2.0).max(PAD * 0.5);
                 ctx.rounded_rect(
                     crate::scene::layout::Rect {
                         x: self.x + inset,
@@ -545,7 +569,7 @@ pub mod context_menu {
                         height: ROW_H - 4.0,
                     },
                     (r - inset).max(0.0),
-                    (first, first, last, last),
+                    (true, true, true, true),
                     [0.20, 0.40, 0.65, 0.6],
                 );
             }
@@ -555,8 +579,8 @@ pub mod context_menu {
             // into the menu plate instead of a printed dash.
             for (idx, opt) in self.options.iter().enumerate() {
                 if opt == "-" {
-                    let cy = self.y + idx as f32 * ROW_H + ROW_H * 0.5;
-                    let inset = (depth * 0.5).max(6.0);
+                    let cy = self.row_y(idx) + ROW_H * 0.5;
+                    let inset = (depth * 0.5).max(PAD);
                     ctx.groove(
                         (self.x + inset, cy),
                         (self.x + self.w - inset, cy),
@@ -582,16 +606,16 @@ pub mod context_menu {
             quads.push((self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, [0.06, 0.06, 0.09, 1.0]));
 
             if let Some(h_idx) = self.hovered_item {
-                let iy = self.y + h_idx as f32 * ROW_H;
-                quads.push((self.x + 2.0, iy + 2.0, self.w - 4.0, ROW_H - 4.0, [0.20, 0.40, 0.65, 0.6]));
+                let iy = self.row_y(h_idx);
+                quads.push((self.x + PAD * 0.5, iy + 2.0, self.w - PAD, ROW_H - 4.0, [0.20, 0.40, 0.65, 0.6]));
             }
 
             // Separator rows ("-"): a hairline in place of the engraved
             // groove the plate path cuts.
             for (idx, opt) in self.options.iter().enumerate() {
                 if opt == "-" {
-                    let cy = self.y + idx as f32 * ROW_H + ROW_H * 0.5;
-                    quads.push((self.x + 6.0, cy, self.w - 12.0, 1.0, [0.22, 0.22, 0.28, 1.0]));
+                    let cy = self.row_y(idx) + ROW_H * 0.5;
+                    quads.push((self.x + PAD, cy, self.w - 2.0 * PAD, 1.0, [0.22, 0.22, 0.28, 1.0]));
                 }
             }
 
@@ -638,7 +662,7 @@ pub mod context_menu {
                     continue;
                 }
                 let (_, label_size) = label_font();
-                let iy = self.y + idx as f32 * ROW_H + (ROW_H - label_size) / 2.0;
+                let iy = self.row_y(idx) + (ROW_H - label_size) / 2.0;
                 // The toolkit's semantic colors rather than greys hand-mixed
                 // against the old near-black fill: on the plate's mid-slate the
                 // header's 0x70 was a step above its background and read as
@@ -653,7 +677,7 @@ pub mod context_menu {
 
                 labels.push(TextLabel {
                     text: opt.clone(),
-                    x: self.x + 8.0,
+                    x: self.x + PAD,
                     y: iy,
                     font_size: label_size,
                     color: text_color,
@@ -819,3 +843,37 @@ macro_rules! impl_widget_base {
         fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
     };
 }
+
+#[cfg(test)]
+mod context_menu_padding_tests {
+    use super::context_menu::{ContextMenuState, PAD, ROW_H};
+    use crate::widget::WidgetId;
+
+    /// The plate pads its rows evenly: the height is the rows plus a pad
+    /// above and below, the labels sit one pad in from the left with the
+    /// widest one a pad from the right, and the padding is plate — a pointer
+    /// in it hovers no row, and a pointer a row down from the top pad is on
+    /// row 1, not row 0 plus a fraction.
+    #[test]
+    fn rows_sit_inside_an_even_pad() {
+        let mut m = ContextMenuState::new();
+        m.show(100.0, 200.0, vec!["Hide Geometry".into(), "-".into(), "Delete".into()], 0, WidgetId(1));
+        assert_eq!(m.h, 3.0 * ROW_H + 2.0 * PAD);
+        assert!(m.w >= 2.0 * PAD);
+        let labels = m.text_labels();
+        assert!(labels.iter().all(|l| l.x == 100.0 + PAD), "labels start one pad in");
+        assert_eq!(labels[0].y, 200.0 + PAD + (ROW_H - labels[0].font_size) / 2.0, "row 0 starts under the top pad");
+
+        assert_eq!(m.row_at(110.0, 200.0 + PAD * 0.5), None, "the top pad is no row");
+        assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 0.5), Some(0));
+        assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 2.5), Some(2));
+        assert_eq!(m.row_at(110.0, 200.0 + m.h - PAD * 0.5), None, "the bottom pad is no row");
+
+        m.cursor_moved(110.0, 200.0 + PAD * 0.5);
+        assert_eq!(m.hovered_item, None);
+        m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 1.5);
+        assert_eq!(m.hovered_item, None, "a separator row never hovers");
+        m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 2.5);
+        assert_eq!(m.hovered_item, Some(2));
+    }
+}