GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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, "");