git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit50f396e30300c4878d080151d805f0043c5aca20
parent7c85aac751
authorLucas Galante <[email protected]>
date2026-07-16 15:13
fix: popovers draw over widget labels in the display list

The display list draws strictly in order, and popover backgrounds were
emitted in the geometry pass — under the label text pass — so the labels
of buttons beneath the params pane's open dropdown bled through it.
Popovers (background, then option text) now append after
append_frame_text via append_popovers. Regression-tested by opening the
Main node's dropdown programmatically and asserting item order.

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

 src/main.rs   | 43 +++++++++++++++++++++++++++++++++++++++++++
 src/render.rs | 24 ++++++++++++++----------
 2 files changed, 57 insertions(+), 10 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 1656190..c42afbb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -741,6 +741,49 @@ mod tests {
         assert_eq!(mgr.match_action(&mods_none, &key_tab), None);
     }
 
+    #[test]
+    fn test_popover_draws_over_widget_labels() {
+        // Regression: the display list draws strictly in order, so an open
+        // dropdown's popover (background AND option text) must be appended
+        // after the widget-label text pass — popover rects emitted in the
+        // geometry pass sat under every label, and the labels of buttons
+        // beneath the params pane's "Open" dropdown bled through it.
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+        let main_idx = state.fs_root.children.iter().position(|c| c.name == "Main").expect("Main node");
+        state.graph_mut().set_selected_node(Some(main_idx));
+        state.sync_parameters_pane();
+
+        {
+            let dropdown = state
+                .slots
+                .param
+                .inner_mut()
+                .choices
+                .iter_mut()
+                .flatten()
+                .next()
+                .expect("Main's params include a dropdown (Open)");
+            dropdown.open = true;
+        }
+
+        let list = state.collect_display_list();
+        let text_pos = |needle: &str| {
+            list.items.iter().position(|item| {
+                matches!(&item.prim, cce_ui::scene::paint::Prim::Text { text, .. } if text == needle)
+            })
+        };
+        // "New Project" is a button label sitting under the open dropdown;
+        // "Other" only exists inside the popover's option list.
+        let label_idx = text_pos("New Project").expect("button label in display list");
+        let option_idx = text_pos("Other").expect("popover option text in display list");
+        assert!(option_idx > label_idx, "popover text must draw after widget labels");
+        let has_bg_between = list.items[label_idx..option_idx]
+            .iter()
+            .any(|item| matches!(item.prim, cce_ui::scene::paint::Prim::Quad { .. }));
+        assert!(has_bg_between, "popover background must draw after widget labels");
+    }
+
     #[test]
     fn test_mcp_tools_map_to_actions() {
         // Every MCP tool except get_state must dispatch by injecting its name
diff --git a/src/render.rs b/src/render.rs
index 00e1d84..3f73b16 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -107,6 +107,7 @@ impl State {
 
         self.append_context_border(&mut pc);
         self.append_frame_text(&mut pc);
+        self.append_popovers(&mut pc);
 
         pc.finish()
     }
@@ -223,14 +224,6 @@ impl State {
             }
         }
 
-        if w.visible() && (self.focused_widget == Some(idx) || idx == PARAM_IDX) {
-            let mut popover_pc = cce_ui::layout::PopoverCollector::new();
-            w.render_popover(&mut popover_pc);
-            for (color, px, py, pw, ph) in popover_pc.rects {
-                pc.quad(rect(px, py, pw, ph), color);
-            }
-        }
-
         if active_circle.is_some() {
             pc.pop_clip_circle();
         }
@@ -333,7 +326,8 @@ impl State {
     /// The frame's text, as `Prim::Text` items shaped and drawn by the engine
     /// (`display_list_text`): each non-menubar widget's walk-derived labels — the
     /// graph's clamped to the network pane (and distance-filtered against the circular
-    /// pane), network text fading with `network_opacity` — then the open popovers'.
+    /// pane), network text fading with `network_opacity`. Popovers follow in
+    /// `append_popovers`.
     fn append_frame_text(&self, pc: &mut PaintCtx) {
         let circular = self.circular_network_pane;
         let ncx = self.circular_network_layout.x;
@@ -381,7 +375,14 @@ impl State {
             }
         }
 
-        // Popover text, on top of (i.e. after) all widget labels.
+    }
+
+    /// Open popovers (the params pane's expanded dropdowns), background then
+    /// text per widget. Appended after `append_frame_text` so the popover
+    /// occludes the widget labels underneath it — the display list is drawn
+    /// strictly in order, so a popover background emitted in the geometry
+    /// pass would sit under every label.
+    fn append_popovers(&self, pc: &mut PaintCtx) {
         for i in 0..WIDGET_COUNT {
             let w = self.slots.get_dyn(i);
             if !w.visible() {
@@ -394,6 +395,9 @@ impl State {
             if self.focused_widget == Some(i) || i == PARAM_IDX {
                 let mut popover_pc = cce_ui::layout::PopoverCollector::new();
                 w.render_popover(&mut popover_pc);
+                for (color, px, py, pw, ph) in popover_pc.rects {
+                    pc.quad(rect(px, py, pw, ph), color);
+                }
                 for (t, size, x, y, tc, font_opt, label_bounds) in popover_pc.texts {
                     let color = [
                         (tc[0] * 255.0).round().clamp(0.0, 255.0) as u8,