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

commit3da1f26668151c10cbed2e79b3acc6585c40dd18
parent4b9cf38805
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 19:07
fix(menu_popup): a turned page is handed over from the window, no frame of nothing

A turned page that needs a new popup (the designer's dialog turning back
into its menu) was hosted at once, so until the popup's first configure it
was drawn nowhere, and the dialog was already gone: a frame with neither.
It now stays drawn in the window until the popup is placed, and the next
frame commits the popup ahead of the window's own, so the two overlap at
one place for a frame's draw instead of leaving a gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    |  8 +++++++-
 src/backend/menu_popup.rs    | 45 ++++++++++++++++++++++++++++++++++++++------
 src/backend/window_runner.rs | 10 +++++++++-
 3 files changed, 55 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 75707a6..afd7ded 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -588,7 +588,13 @@ there:
   runs `natural_geometry` is the larger of the two sizes, so the popup is repositioned
   to hold both and again to the page's own when it lands, and the runner asks for
   frames (`is_turning`). Only labels fade (`text_faded`); the leaving rows' geometry
-  is not drawn. `CCE_UI_TURN_MS` slows it down, to capture a turn frame by frame in a
+  is not drawn. **A turned page that needs a NEW popup is handed over from the
+  window** (`MenuPopup::handoff`): it stays drawn in the window until the popup's
+  first configure, and the frame after commits the popup ahead of the window
+  (`take_menu_popup_lead`). Hosted at once, as a menu opened at the pointer is, a
+  swipe back from the designer's dialog left a frame with neither plate — the dialog
+  gone, the popup not placed — and, drawn after the window, the popup arrived up to a
+  window frame's draw late. `CCE_UI_TURN_MS` slows it down, to capture a turn frame by frame in a
   shadow session. `a_page_turn_grows_the_plate_from_the_one_it_replaced` is the test.
 
 **Until this a row could open a SUBMENU** (2026-09-29 to 2026-10-02): a second
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index 110dc9c..fb2798f 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -72,6 +72,12 @@ pub struct MenuPopup {
     placed: Option<(f32, f32, f32, f32)>,
     /// The buffer scale last sent on the popup's surface.
     committed_scale: i32,
+    /// The menu is still drawn in the window until this popup is placed —
+    /// see `open_menu_popup`.
+    handoff: bool,
+    /// Placed after a hand-off and not drawn yet: the next frame draws it
+    /// AHEAD of the window's (`take_menu_popup_lead`).
+    lead: bool,
 }
 
 impl MenuPopup {
@@ -212,11 +218,22 @@ impl<A: Application> EngineState<A> {
                 return;
             }
         };
-        // Hosted from now: until the first configure places it, the menu is
-        // drawn nowhere — a few milliseconds, against a copy in the window
-        // that would blink out when the popup appears somewhere else.
-        context_menu::set_hosted(true);
-        self.menu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0 });
+        // A menu opened at the pointer is hosted from now: until the first
+        // configure places it, it is drawn nowhere — a few milliseconds,
+        // against a copy in the window that would blink out when the popup
+        // appears somewhere else.
+        //
+        // A TURNED page is not: it takes the place of a plate that was just
+        // on screen (the designer's dialog, which closed in the same
+        // dispatch), and drawn nowhere it was a frame of nothing between the
+        // two. Its place is known — the corner — so the window keeps drawing
+        // it until the popup is placed, and the frame after the configure
+        // commits the popup FIRST and then the window without it: for the
+        // time the window's frame takes to draw, the two stand one over the
+        // other at one place, where the other order left neither.
+        let handoff = context_menu::is_turned();
+        context_menu::set_hosted(!handoff);
+        self.menu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0, handoff, lead: false });
     }
 
     /// Close the popup. The renderer lets go
@@ -232,6 +249,12 @@ impl<A: Application> EngineState<A> {
         context_menu::set_hosted(false);
     }
 
+    /// Whether the next frame draws the popup ahead of the window's own: once,
+    /// for the first frame after a hand-off (see `open_menu_popup`).
+    pub(crate) fn take_menu_popup_lead(&mut self) -> bool {
+        self.menu_popup.as_mut().is_some_and(|p| std::mem::take(&mut p.lead))
+    }
+
     /// The offset from the window of the menu popup, if `surface` is it.
     pub(crate) fn menu_popup_offset(&self, surface: &wl_surface::WlSurface) -> Option<(f32, f32)> {
         self.menu_popup.as_ref().and_then(|p| p.offset_for(surface))
@@ -298,8 +321,13 @@ impl<A: Application> PopupHandler for EngineState<A> {
         let (mp, slot) = (self.menu_popup.as_mut(), &mut self.menu_renderer);
         let Some(mp) = mp else { return };
         mp.placed = Some((x, y, w, h));
+        let handoff = std::mem::take(&mut mp.handoff);
         // Where it landed IS where the menu is — see the module docs.
         context_menu::place(x, y, h);
+        if handoff {
+            context_menu::set_hosted(true);
+            mp.lead = true;
+        }
 
         let (_, pw, ph) = Self::buffer_geometry(scale_factor, w, h);
         let surface_ptr = mp.popup.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
@@ -327,7 +355,12 @@ impl<A: Application> PopupHandler for EngineState<A> {
             return;
         }
         self.redraw = true;
-        self.render_menu_popup();
+        // A handed-off menu is drawn on the next frame, after the window's
+        // own without it (see `open_menu_popup`); drawn here it would stand
+        // over the window's copy until that frame.
+        if !handoff {
+            self.render_menu_popup();
+        }
     }
 
     fn done(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup) {
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 9c3110e..32114af 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -6613,8 +6613,16 @@ fn run_session<'l, A: Application>(
         if engine_state.redraw && !engine_state.frame_callback_pending {
             engine_state.redraw = false;
             if engine_state.first_configure_received {
+                // A menu handed over from the window commits first, so
+                // there is no moment with neither (`take_menu_popup_lead`).
+                let lead = engine_state.take_menu_popup_lead();
+                if lead {
+                    engine_state.render_menu_popup();
+                }
                 engine_state.render();
-                engine_state.render_menu_popup();
+                if !lead {
+                    engine_state.render_menu_popup();
+                }
                 rendered = true;
             }
         } else if !engine_state.redraw