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

commit22213f9ac93c4e7f5c07ad8d3a58fa4c15966576
parent4d4851b9c9
authorLucas Galante <[email protected]>
date2026-09-22 13:05
Context menu: PAD-aware row_at / row_y, the drag veto knows the menu, Clear

Findings of the 2026-09-22 context-menu audit, the toolkit's share:

- `context_menu::row_at` / `row_y` free functions expose the PAD-aware row
  geometry `ContextMenuState` already had. Six call sites across five apps
  divided `(py - y()) / ROW_H` from the plate's top instead, which names
  the row below over the bottom third of every row and runs off the end on
  the last; they move onto this in their own crates.
- `point_in_active_popover` — the window-drag veto — now sees the shared
  menu, which has no widget id to register: a press on a menu row over
  drag-free background started a window move in cce-graph and
  cce-data-editor instead of firing the row.
- The search box's "Cear" row is "Clear", at both ends of the label map.
- cce-ramp and cce-relief paint the menu through `paint_with_labels`: the
  lit plate in the menu font, instead of flat quads in the default sans.

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

 src/bin/cce-ramp.rs          | 19 ++++---------------
 src/bin/cce-relief.rs        | 19 ++++---------------
 src/context.rs               | 10 +++++++++-
 src/widget/core.rs           | 34 ++++++++++++++++++++++++++++++++--
 src/widget/input/text_box.rs |  8 ++++----
 5 files changed, 53 insertions(+), 37 deletions(-)

diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index 397769a..3772a68 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -302,21 +302,10 @@ impl Application for RampPopup {
         // The shared context menu (right-click on the graph), drawn last, on
         // top of everything. Its labels carry the menu rect as bounds — the
         // runner exempts them from the menu's own text occlusion that way.
-        if self.ui_context.is_context_menu_visible() {
-            for (qx, qy, qw, qh, c) in self.ui_context.context_menu_quads() {
-                pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, c);
-            }
-            let (mx, my, mw, mh) = (
-                cce_ui::widget::context_menu::x(),
-                cce_ui::widget::context_menu::y(),
-                cce_ui::widget::context_menu::w(),
-                cce_ui::widget::context_menu::h(),
-            );
-            let bounds = Some([mx, my, mx + mw, my + mh]);
-            for l in self.ui_context.context_menu_labels() {
-                pc.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bounds);
-            }
-        }
+        // The lit plate and the menu font, in one call — the flat quads and
+        // a family-less label loop drew this menu square, opaque and in the
+        // default sans, unlike every app's.
+        cce_ui::widget::context_menu::paint_with_labels(&mut pc);
 
         Some(pc.finish())
     }
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index e90263d..9e92714 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -1840,21 +1840,10 @@ impl Application for BevelPopup {
         }
 
         // The shared context menu (slider Copy/Paste), last, on top.
-        if self.ui_context.is_context_menu_visible() {
-            for (qx, qy, qw, qh, c) in self.ui_context.context_menu_quads() {
-                pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, c);
-            }
-            let (mx, my, mw, mh) = (
-                cce_ui::widget::context_menu::x(),
-                cce_ui::widget::context_menu::y(),
-                cce_ui::widget::context_menu::w(),
-                cce_ui::widget::context_menu::h(),
-            );
-            let bounds = Some([mx, my, mx + mw, my + mh]);
-            for l in self.ui_context.context_menu_labels() {
-                pc.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bounds);
-            }
-        }
+        // The lit plate and the menu font, in one call — the flat quads and
+        // a family-less label loop drew this menu square, opaque and in the
+        // default sans, unlike every app's.
+        cce_ui::widget::context_menu::paint_with_labels(&mut pc);
 
         Some(pc.finish())
     }
diff --git a/src/context.rs b/src/context.rs
index f5d6578..6da6713 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1043,7 +1043,7 @@ impl UiContext {
                 }
             };
             if is_search {
-                options.push("Cear".to_string());
+                options.push("Clear".to_string());
             }
         } else if name == "Breadcrumb" {
             options.push("Copy Path".to_string());
@@ -1097,6 +1097,14 @@ impl UiContext {
     /// there must never start a window move (the widgets beneath may not block dragging,
     /// e.g. Graph's edge-exclusive canvas hit test).
     fn point_in_active_popover(&self, px: f32, py: f32) -> bool {
+        // The shared context menu is a popover too — a thread-local one,
+        // with no widget id to register — and a press on one of its rows
+        // used to start a window move in any app whose background does
+        // not block dragging (cce-graph, cce-data-editor: the row never
+        // fired, the window slid).
+        if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(px, py) {
+            return true;
+        }
         for &pop_id in &self.active_popovers {
             if let Some(ptr) = self.tree.get_ptr(pop_id) {
                 unsafe {
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 6594432..0ab8067 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -506,7 +506,7 @@ pub mod context_menu {
                                         "Select All" => Some(CA::SelectAll),
                                         "Undo" => Some(CA::Undo),
                                         "Redo" => Some(CA::Redo),
-                                        "Cear" => Some(CA::ClearText),
+                                        "Clear" => Some(CA::ClearText),
                                         "Copy Key" => Some(CA::CopyKey),
                                         "Copy Value" => Some(CA::CopyValue),
                                         "Delete" => Some(CA::DeleteKey),
@@ -740,6 +740,19 @@ pub mod context_menu {
     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()) }
 
+    /// 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
+    /// `(py - y()) / ROW_H`: the rows start `PAD` below the plate's top, so
+    /// that division names the row below over the bottom third of every
+    /// row, and runs off the end on the last one (2026-09-22 audit: six
+    /// call sites across five apps had it).
+    pub fn row_at(px: f32, py: f32) -> Option<usize> {
+        CONTEXT_MENU.with(|m| m.borrow().row_at(px, py))
+    }
+    /// A row's top, PAD-aware — for a host painting the rows itself.
+    pub fn row_y(idx: usize) -> f32 {
+        CONTEXT_MENU.with(|m| m.borrow().row_y(idx))
+    }
     pub fn hit_test(px: f32, py: f32) -> bool {
         CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py))
     }
@@ -865,9 +878,26 @@ macro_rules! impl_widget_base {
 
 #[cfg(test)]
 mod context_menu_padding_tests {
-    use super::context_menu::{ContextMenuState, PAD, ROW_H};
+    use super::context_menu::{self, ContextMenuState, PAD, ROW_H};
     use crate::widget::WidgetId;
 
+    /// The shared menu is a popover for the window-drag question too: a
+    /// press on one of its rows must never start a window move, whatever
+    /// sits under the menu. It has no widget id to register, so the veto
+    /// asks the thread-local directly. And the free `row_at` is the
+    /// PAD-aware row hit test hosts dispatch by.
+    #[test]
+    fn an_open_menu_vetoes_window_drags_under_it() {
+        let ctx = crate::context::UiContext::new();
+        context_menu::show(100.0, 200.0, vec!["Copy".into(), "Paste".into()], 0, WidgetId(1));
+        assert!(!ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "a press on a row is not a drag");
+        assert_eq!(context_menu::row_at(110.0, 200.0 + PAD + ROW_H * 1.5), Some(1));
+        assert_eq!(context_menu::row_y(1), 200.0 + PAD + ROW_H);
+        assert!(ctx.drag_allowed_at(10.0, 10.0), "away from the menu the drag question is the widgets'");
+        context_menu::hide();
+        assert!(ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "hidden, it vetoes nothing");
+    }
+
     /// 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
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 78c6115..72146ed 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -2253,7 +2253,7 @@ mod tests {
     }
 
     #[test]
-    fn test_search_textbox_cear_option() {
+    fn test_search_textbox_clear_option() {
         let mut dummy = crate::context::UiContext::new();
         let mut tb = TextBox::new("Some Search query".to_string()).with_placeholder("Search...");
         tb.set_rect(10.0, 10.0, 200.0, 30.0);
@@ -2263,11 +2263,11 @@ mod tests {
         assert!(clicked);
         assert!(dummy.is_context_menu_visible());
 
-        // Verify "Cear" option is in options
+        // Verify "Clear" option is in options
         let opts = crate::widget::context_menu::options();
-        assert!(opts.contains(&"Cear".to_string()));
+        assert!(opts.contains(&"Clear".to_string()));
 
-        // Simulate choosing the "Cear" option
+        // Simulate choosing the "Clear" option
         WidgetHost::context_action(&mut tb, crate::widget::ContextAction::ClearText);
         assert_eq!(tb.text, "");
         assert_eq!(tb.edit_buffer, "");