git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

commitec8920a58cec0c9cd50ab2ec5e144364463a3f3d
parent02c211ef30
authorLucas Galante <[email protected]>
date2026-07-17 14:01
fix: draw registered popovers and the config context menu (invisible-menu bug)

The dissolved paint path registered open dropdown popovers for occlusion and
routing but never emitted their geometry — the walk only paints the trigger, so
File/Edit/View menus opened invisibly (and the dl-text occlusion clamp erased
graph text beneath the phantom rect). display_list now draws active_popovers
last via render_popover/PopoverCollector, then the global context menu, with
label bounds equal to the overlay rect (the data-editor reference pattern).
The context menu also gets its routing: exclusive pointer/click dispatch while
open, hide on key press.

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

 src/main.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 76 insertions(+)

diff --git a/src/main.rs b/src/main.rs
index 6a5fdc4..dd1f2ba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1314,6 +1314,55 @@ impl Application for GraphApp {
             }
         }
 
+        // Open dropdown popovers — geometry and labels last, on top of everything, exactly
+        // where they hit-test (the ui_context registration above is occlusion/routing only;
+        // nothing else paints them). Labels carry bounds equal to the popover rect, which
+        // clips them to the plate and exempts them from the dl-text occlusion clamp.
+        {
+            use cce_ui::scene::layout::Rect;
+            for &pop_id in &self.ui_context.active_popovers {
+                let Some(pop_ptr) = self.ui_context.tree.get_ptr(pop_id) else { continue };
+                let popover = unsafe { &*pop_ptr };
+                let Some((px, py, pw, ph)) = popover.popover_rect() else { continue };
+                let mut coll = cce_ui::layout::PopoverCollector::new();
+                popover.render_popover(&mut coll);
+                for &(c, x, y, qw, qh) in &coll.rects {
+                    pc.quad(Rect { x, y, width: qw, height: qh }, c);
+                }
+                let pop_bounds = Some([px, py, px + pw, py + ph]);
+                for (content, size, tx, ty, color, font, _bounds) in coll.texts {
+                    let color_u8 = [
+                        (color[0] * 255.0).clamp(0.0, 255.0) as u8,
+                        (color[1] * 255.0).clamp(0.0, 255.0) as u8,
+                        (color[2] * 255.0).clamp(0.0, 255.0) as u8,
+                    ];
+                    pc.text_with(content, tx, ty, size, color_u8, font, pop_bounds);
+                }
+            }
+            if cce_ui::widget::context_menu::is_visible() {
+                let menu_bounds = Some([
+                    cce_ui::widget::context_menu::x(),
+                    cce_ui::widget::context_menu::y(),
+                    cce_ui::widget::context_menu::x() + cce_ui::widget::context_menu::w(),
+                    cce_ui::widget::context_menu::y() + cce_ui::widget::context_menu::h(),
+                ]);
+                for (qx, qy, qw, qh, qc) in cce_ui::widget::context_menu::extra_quads() {
+                    pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
+                }
+                for label in cce_ui::widget::context_menu::text_labels() {
+                    pc.text_with(
+                        label.text.clone(),
+                        label.x,
+                        label.y,
+                        label.font_size,
+                        label.color,
+                        None,
+                        menu_bounds,
+                    );
+                }
+            }
+        }
+
         Some(pc.finish())
     }
 
@@ -1324,6 +1373,16 @@ impl Application for GraphApp {
     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
         let mut changed = false;
 
+        // The global config context menu (right-click on a dropdown) gets the pointer
+        // exclusively while open — same priority the popovers get below.
+        if cce_ui::widget::context_menu::is_visible() {
+            if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+            return;
+        }
+
         let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
             || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
             || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
@@ -1420,6 +1479,16 @@ impl Application for GraphApp {
         let mut changed = false;
         let mut msg_out = None;
 
+        // An open config context menu swallows the click (select or dismiss) before any
+        // widget routing.
+        if cce_ui::widget::context_menu::is_visible() {
+            if cce_ui::widget::context_menu::mouse_input(button, state, pos.x, pos.y, Some(&mut self.ui_context)) {
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+            return None;
+        }
+
         let over_menu = self.dropdown_file.open || self.dropdown_edit.open || self.dropdown_view.open
             || self.dropdown_file.hit_test(pos.x, pos.y, &self.ui_context)
             || self.dropdown_edit.hit_test(pos.x, pos.y, &self.ui_context)
@@ -1584,6 +1653,13 @@ impl Application for GraphApp {
     }
 
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        if event.state == ElementState::Pressed && cce_ui::widget::context_menu::is_visible() {
+            cce_ui::widget::context_menu::hide();
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return None;
+        }
+
         if event.state == ElementState::Pressed {
             // input.kdl `cce-graph.delete_node`, falling back to the legacy
             // config.kdl graph `delete` prop.