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

commitab18ef02c6969a638d7998c9f2541f41453ef806
parente45cd46904
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 12:57
feat(context_menu): draw the menu in its own xdg_popup, kept on screen

The global context menu drew into each app's window, so it was cut off
at the window's edge. On an xdg toplevel the runner now mirrors the open
menu into an xdg_popup (backend/menu_popup.rs) with flip-y, slide and
resize-y, so the compositor keeps it on the output. A menu cut short
scrolls: ContextMenuState keeps content_h apart from the shown h, plus
a scroll offset, and row_at / row_y / hit_test answer for the rows as
drawn. The wheel scrolls it off the slider rows, a thumb shows it.

The July popup path was deleted for drawing in one place and
hit-testing in another. This one writes the configure back into the
menu (context_menu::place) and takes its own pointer input, translated
into window coordinates, so no app changes. While up, the menu is
hosted: the apps' in-window paint calls draw nothing and the runner
paints a copy at the origin, as the surface's root plate: frosted by
the compositor, at an alpha raised to 1 - (1 - a)(1 - k) to stand in
for the in-app pass's luminance compression.

The popup's renderer is kept across opens: VkRenderer::detach_surface /
attach_surface move it between surfaces, a swapchain instead of a
device. Layer surfaces, and CCE_UI_MENU_POPUP=0, keep the in-window
menu, placed by context_menu::constrain_to with the same rules.

The render path's text collection, span building and batch conversion
are shared functions now, so the popup draws text exactly as the
window does.

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

 CLAUDE.md                    |  42 ++++++
 src/backend/menu_popup.rs    | 269 +++++++++++++++++++++++++++++++++
 src/backend/mod.rs           |   1 +
 src/backend/window_runner.rs | 256 +++++++++++++++++++-------------
 src/vk/core.rs               |  36 ++++-
 src/vk/renderer.rs           |  55 +++++++
 src/widget/core.rs           | 344 +++++++++++++++++++++++++++++++++++++++++--
 7 files changed, 892 insertions(+), 111 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index df5bc34..ecc14f5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,6 +169,46 @@ list** (`window_runner.rs` ~1799). Two ways an app feeds it:
 So every app, migrated or not, renders through the same tessellate step. `custom_vertices` is
 appended as a final unclipped batch drawn on top.
 
+## The context menu draws in its own popup surface (since 2026-09-25)
+
+`widget::context_menu` is one global menu that every app shows, paints into its own
+display list and dispatches by window coordinates. Drawn in the window it was cut off
+at the window's edge, and a menu taller than the room left could not be seen at all.
+So on an xdg toplevel the runner mirrors the open menu into an `xdg_popup`
+(`backend/menu_popup.rs`, with the reasoning in its module docs): the compositor may
+put it anywhere on the output, and the positioner's flip-y / slide / resize-y keeps it
+there. A menu cut short scrolls: `ContextMenuState` keeps `content_h` (all the rows)
+apart from `h` (what is shown) and a `scroll`, and `row_at` / `row_y` / `hit_test`
+answer for the rows as DRAWN — every host that dispatches through them scrolls for free.
+
+A popup path existed before and was deleted in July (Phase 6x) for drawing in one
+place and hit-testing in another. Two rules make this one different, and both are
+load-bearing:
+
+- **The configure is written back.** Where the compositor put the popup is where the
+  menu IS (`context_menu::place`), so the rect apps hit-test is the rect on screen.
+- **The popup takes its own pointer input**, translated by its offset into window
+  coordinates (`pointer_frame`), so apps need no change — their menu coordinates may
+  now simply lie outside the window. The CSD move/resize checks are skipped for it.
+
+While the popup is up the menu is `hosted`: the apps' in-window `paint*`,
+`text_labels` and `extra_quads` draw nothing, and the runner paints a copy at the
+origin (`paint_hosted`). There the plate is the surface's ROOT, so its frost is the
+compositor's blur-behind, not the in-app pass — which has nothing to sample inside a
+popup and resolves to flat opaque grey. The compositor's blur cannot compress luminance
+the way the in-app frost does, so the root plate's alpha is raised to
+`1 - (1 - a)(1 - k)` to let the backdrop through by the same amount. The compositor
+blurs popups since the same date (`xdg_popup.rs`'s `update_blur`).
+
+The popup's renderer is kept across opens: `VkRenderer::detach_surface` /
+`attach_surface` move it from one popup's `wl_surface` to the next, so a re-open costs a
+swapchain rather than a device and every pipeline. **Detach before the popup drops** —
+the drop destroys the `wl_surface`, and a swapchain must not outlive it.
+
+Layer surfaces keep the in-window menu, placed by `context_menu::constrain_to` with the
+same flip / slide / shorten rules inside the window; so does any app run with
+`CCE_UI_MENU_POPUP=0`.
+
 ## Plates, wells and seams — the surface vocabulary
 
 Everything cce draws is a lit surface, and the words below name those surfaces
@@ -644,6 +684,8 @@ All opt-in, all read once, all quiet when unset — set one and run any client.
   its grouping window (correctly — the carve's shading is baked into the plate's earlier
   draw).
 - `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
+- `CCE_UI_MENU_POPUP=0` — keep the context menu in the window instead of its popup
+  surface (see "The context menu draws in its own popup surface").
 - `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
 - `CCE_FORCE_SCALE=<f>` — override HiDPI scale detection.
 - `CCE_FORCE_PPI=<f>` — pin the display metric (logical px per inch) regardless of what
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
new file mode 100644
index 0000000..21ca19c
--- /dev/null
+++ b/src/backend/menu_popup.rs
@@ -0,0 +1,269 @@
+//! The global context menu in its own `xdg_popup` surface.
+//!
+//! Every app paints [`context_menu`] into its own display list and routes the
+//! pointer to it by window coordinates. Drawn in the window, a menu opened
+//! near an edge is cut off at the window's edge — and the window is the only
+//! room it has. Here the runner mirrors the open menu into a popup surface
+//! parented to the window, which the compositor may place anywhere on the
+//! output and, through the positioner's constraint adjustment, keeps ON the
+//! output: it flips the menu to open upward, slides it in from an edge, or
+//! cuts it short, in which case the rows scroll.
+//!
+//! Two rules make this safe, and they are the two things the previous popup
+//! path (deleted in July 2026 as Phase 6x) got wrong:
+//!
+//! - **The compositor's placement is written back into the menu.** The popup
+//!   lands where the configure says, not where the menu asked, and
+//!   `context_menu::place` moves the menu's rect there. The rect every app
+//!   hit-tests against is therefore the rect on screen — the anchor-mismatch
+//!   bugs the old path had (a menu drawn in one place and clicked in another)
+//!   cannot happen.
+//! - **The popup takes its own input, translated into window coordinates.**
+//!   The old popup had an empty input region and let clicks fall through to
+//!   the window beneath, which only works where there IS window beneath. A
+//!   pointer event on the popup is offset by the popup's position and handed
+//!   to the app as if it had landed on the window, so apps need no change:
+//!   their menu dispatch already works in window coordinates, which may now
+//!   lie outside the window.
+//!
+//! While the popup is up the menu is `hosted`, which turns the apps' own
+//! in-window paint calls into no-ops. The popup has no keyboard grab: the
+//! keyboard stays with the window, whose Escape and press-outside handling
+//! close the menu as before.
+//!
+//! Only xdg toplevels get a popup. A layer surface, or any app with
+//! `CCE_UI_MENU_POPUP=0`, keeps the in-window menu, constrained to the window
+//! by `context_menu::constrain_to` with the same flip / slide / shorten rules.
+
+use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_positioner::{
+    Anchor, ConstraintAdjustment, Gravity,
+};
+use smithay_client_toolkit::shell::xdg::popup::{Popup, PopupConfigure, PopupHandler};
+use smithay_client_toolkit::shell::xdg::{XdgPositioner, XdgSurface as _};
+use wayland_client::{protocol::wl_surface, Connection, Proxy, QueueHandle};
+
+use super::window_runner::{
+    collect_dl_text, dl_batches_2d, dl_text_spans, tessellate_display_list, Application,
+    EngineState, TextBounds,
+};
+use crate::vk::{Frame2D, VkRenderer};
+use crate::widget::context_menu;
+
+pub struct MenuPopup {
+    popup: Popup,
+    /// What the popup was opened for: the menu's generation and its natural
+    /// size, rounded up. A re-show or a change of size opens a new popup.
+    key: (u64, u32, u32),
+    /// Where the compositor put it, in app-logical px relative to the
+    /// window's geometry: `(x, y, w, h)`. `None` until the first configure,
+    /// before which nothing may be attached to the surface.
+    placed: Option<(f32, f32, f32, f32)>,
+    /// The buffer scale last sent on the popup's surface.
+    committed_scale: i32,
+}
+
+impl MenuPopup {
+    /// The popup's offset from the window, if `surface` is its surface and it
+    /// has been placed — what a pointer event on it is translated by.
+    pub fn offset_for(&self, surface: &wl_surface::WlSurface) -> Option<(f32, f32)> {
+        if self.popup.wl_surface() != surface {
+            return None;
+        }
+        self.placed.map(|(x, y, _, _)| (x, y))
+    }
+}
+
+fn popup_enabled() -> bool {
+    std::env::var("CCE_UI_MENU_POPUP").map_or(true, |v| v != "0")
+}
+
+/// App-logical px to the compositor's surface coordinates: the same thing
+/// except in forced-scale mode, where the compositor believes scale 1 and
+/// the app's logical px are `forced` of its own.
+fn forced() -> f32 {
+    crate::scale::forced_scale().map(|f| f as f32).unwrap_or(1.0)
+}
+
+impl<A: Application> EngineState<A> {
+    /// The window frame's logical size: the surface minus the overflow rim.
+    fn frame_size(&self) -> (f32, f32) {
+        (
+            (self.logical_width - self.applied_margin).max(1.0),
+            (self.logical_height - self.applied_margin).max(1.0),
+        )
+    }
+
+    /// Bring the popup in line with the menu: open one for a newly shown
+    /// menu, close it for a hidden one. Runs once per loop, after input, so
+    /// a host that shows the menu and then sets its slider rows (which widen
+    /// it) has done both before the popup is sized.
+    pub(crate) fn sync_menu_popup(&mut self) {
+        let visible = context_menu::is_visible();
+        if !(visible && self.window.is_some() && popup_enabled()) {
+            if self.menu_popup.is_some() {
+                self.close_menu_popup();
+            }
+            context_menu::set_hosted(false);
+            if visible {
+                let (fw, fh) = self.frame_size();
+                context_menu::constrain_to(0.0, 0.0, fw, fh);
+            }
+            return;
+        }
+        let (anchor, w, content_h) = context_menu::natural_geometry();
+        let key = (context_menu::generation(), w.ceil() as u32, content_h.ceil() as u32);
+        if self.menu_popup.as_ref().is_some_and(|p| p.key == key) {
+            return;
+        }
+        self.close_menu_popup();
+        self.open_menu_popup(anchor, key);
+    }
+
+    fn open_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) {
+        let Some(window) = self.window.as_ref() else { return };
+        let positioner = match XdgPositioner::new(&self.xdg_shell_state) {
+            Ok(p) => p,
+            Err(e) => {
+                log::warn!("[menu_popup] no positioner ({e}); drawing the menu in the window");
+                return;
+            }
+        };
+        let f = forced();
+        positioner.set_size(
+            ((key.1 as f32) * f).round().max(1.0) as i32,
+            ((key.2 as f32) * f).round().max(1.0) as i32,
+        );
+        // A 1x1 anchor at the point the menu was opened at, held inside the
+        // window's geometry (the rect the positioner is relative to).
+        let (fw, fh) = self.frame_size();
+        let ax = (anchor.0.clamp(0.0, fw - 1.0) * f).round() as i32;
+        let ay = (anchor.1.clamp(0.0, fh - 1.0) * f).round() as i32;
+        positioner.set_anchor_rect(ax, ay, 1, 1);
+        positioner.set_anchor(Anchor::TopLeft);
+        positioner.set_gravity(Gravity::BottomRight);
+        // Open down and right from the pointer. Short of room below, open UP
+        // from it (flip); short either way, slide in from the edge; taller
+        // than the output, cut it down (resize) — the menu scrolls.
+        positioner.set_constraint_adjustment(
+            ConstraintAdjustment::FlipY
+                | ConstraintAdjustment::SlideX
+                | ConstraintAdjustment::SlideY
+                | ConstraintAdjustment::ResizeY,
+        );
+        let popup = match Popup::new(
+            window.xdg_surface(),
+            &positioner,
+            &self.qh,
+            &self.compositor_state,
+            &self.xdg_shell_state,
+        ) {
+            Ok(p) => p,
+            Err(e) => {
+                log::warn!("[menu_popup] cannot create popup ({e}); drawing the menu in the window");
+                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 });
+    }
+
+    /// Close the popup. The renderer lets go of the surface FIRST: dropping
+    /// the popup destroys the `wl_surface`, and a swapchain must never
+    /// outlive the surface it presents to.
+    pub(crate) fn close_menu_popup(&mut self) {
+        if let Some(renderer) = self.menu_renderer.as_mut() {
+            if renderer.has_surface() {
+                renderer.detach_surface();
+            }
+        }
+        self.menu_popup = None;
+        context_menu::set_hosted(false);
+    }
+
+    /// Draw the menu into its popup. Called after the window's own frame,
+    /// and after a configure; a no-op until the popup is placed.
+    pub(crate) fn render_menu_popup(&mut self) {
+        let Some(mp) = self.menu_popup.as_mut() else { return };
+        let Some((_, _, w, h)) = mp.placed else { return };
+        let Some(renderer) = self.menu_renderer.as_mut() else { return };
+        if !renderer.has_surface() {
+            return;
+        }
+        let scale = self.scale_factor as f32;
+        let (s, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
+
+        let mut pc = crate::scene::paint::PaintCtx::new();
+        context_menu::paint_hosted(&mut pc);
+        let dl = pc.finish();
+
+        let mut items = Vec::new();
+        collect_dl_text(self.font_system.as_mut().unwrap(), &dl, &mut items);
+        let (verts, dl_batches, _images, plate_features) = tessellate_display_list(&dl, w, h, scale);
+        let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
+        let spans = dl_text_spans(&items, scale, bounds, &[]);
+        renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
+        let batches = dl_batches_2d(&dl_batches, scale);
+
+        let e = renderer.pending_extent();
+        if e.width != pw || e.height != ph {
+            renderer.resize(pw, ph);
+        }
+        if s != mp.committed_scale {
+            mp.popup.wl_surface().set_buffer_scale(s);
+            mp.committed_scale = s;
+        }
+        renderer.draw_frame_2d(Frame2D {
+            verts: &verts,
+            batches: &batches,
+            overlay_verts: &[],
+            images: &[],
+            plate_features: &plate_features,
+            clear_color: [0.0; 4],
+        });
+    }
+}
+
+impl<A: Application> PopupHandler for EngineState<A> {
+    fn configure(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup, config: PopupConfigure) {
+        let Some(mp) = self.menu_popup.as_mut() else { return };
+        if mp.popup.wl_surface() != popup.wl_surface() {
+            return;
+        }
+        let f = forced();
+        let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
+        let (w, h) = (config.width.max(1) as f32 / f, config.height.max(1) as f32 / f);
+        mp.placed = Some((x, y, w, h));
+        // Where it landed IS where the menu is — see the module docs.
+        context_menu::place(x, y, h);
+
+        let (_, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
+        let surface_ptr = mp.popup.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
+        let display_ptr = self.display_ptr as *mut std::ffi::c_void;
+        match self.menu_renderer.as_mut() {
+            Some(r) if r.has_surface() => r.resize(pw, ph),
+            Some(r) => unsafe { r.attach_surface(display_ptr, surface_ptr, pw, ph) },
+            None => {
+                let t = std::time::Instant::now();
+                self.menu_renderer = Some(unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, 0.0) });
+                log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
+            }
+        }
+        self.redraw = true;
+        self.render_menu_popup();
+    }
+
+    fn done(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup) {
+        // The compositor dismissed it (its parent went away, say). The menu
+        // closes with it; an app that watches `is_visible` sees that.
+        if self.menu_popup.as_ref().is_some_and(|mp| mp.popup.wl_surface() == popup.wl_surface()) {
+            context_menu::hide();
+            self.close_menu_popup();
+            self.redraw = true;
+        }
+    }
+}
+
+smithay_client_toolkit::delegate_xdg_popup!(@<A: Application> EngineState<A>);
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 759ba6b..4939ad2 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,4 +1,5 @@
 pub mod dnd;
+pub mod menu_popup;
 pub mod window_runner;
 
 pub use window_runner::{
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index affe2b7..69f5586 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -459,6 +459,124 @@ pub struct TextBounds {
     pub bottom: i32,
 }
 
+/// The display list's Text prims, shaped through the shared buffer cache and
+/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
+/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
+/// logical space. Shared by the window's frame and the context-menu popup's.
+pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
+    for item in &dl.items {
+        if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
+            let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
+            let merged = match (clip, *bounds) {
+                (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
+                (Some(a), None) => Some(a),
+                (None, b) => b,
+            };
+            // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
+            // offset; ordinary labels take the shared cached buffer.
+            let (buffer, y_off) = match layout {
+                Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
+                None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
+            };
+            out.push(TextItem {
+                buffer,
+                x: *x,
+                y: *y + y_off,
+                color: cosmic_text::Color::rgba(
+                    color[0],
+                    color[1],
+                    color[2],
+                    (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
+                ),
+                bounds: merged,
+                clip_circle: item.clip_circle,
+                clip_rrect: item.clip_rrect,
+            });
+        }
+    }
+}
+
+/// The glyph pass's spans for `items`: each clamped to the surface and its
+/// own bounds, then by the popover-occlusion clamp against `overlays`.
+pub(crate) fn dl_text_spans<'a>(
+    items: &'a [TextItem],
+    scale_f32: f32,
+    bounds: TextBounds,
+    overlays: &[(f32, f32, f32, f32)],
+) -> Vec<TextSpan<'a>> {
+    let mut spans: Vec<TextSpan<'a>> = Vec::new();
+    for ti in items {
+        let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
+            TextBounds {
+                left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
+                top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
+                right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
+                bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
+            }
+        } else {
+            bounds
+        };
+        popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
+        spans.push(TextSpan {
+            buffer: &ti.buffer,
+            left: (ti.x * scale_f32).round(),
+            top: (ti.y * scale_f32).round(),
+            // Buffers are shaped at physical size (get_text_buffer_attrs).
+            scale: 1.0,
+            bounds: Some([
+                item_bounds.left,
+                item_bounds.top,
+                item_bounds.right,
+                item_bounds.bottom,
+            ]),
+            default_color: [
+                ti.color.r() as f32 / 255.0,
+                ti.color.g() as f32 / 255.0,
+                ti.color.b() as f32 / 255.0,
+                ti.color.a() as f32 / 255.0,
+            ],
+            rotation: None,
+            // Circle wins when both are set (the circular pane's innermost clip);
+            // otherwise a rounded-rect clip rides as center+radius with extents.
+            clip_circle: match (ti.clip_circle, ti.clip_rrect) {
+                (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
+                (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
+                (None, None) => [0.0; 3],
+            },
+            clip_extents: match (ti.clip_circle, ti.clip_rrect) {
+                (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
+                _ => [0.0; 2],
+            },
+        });
+    }
+    spans
+}
+
+/// The tessellated display list's batches, scissors and rounded clips scaled
+/// to physical px.
+pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
+    dl_batches
+        .iter()
+        .map(|batch| Batch2D {
+            scissor: batch.scissor.map(|clip| {
+                (
+                    (clip.x * scale_f32).max(0.0) as u32,
+                    (clip.y * scale_f32).max(0.0) as u32,
+                    (clip.width * scale_f32) as u32,
+                    (clip.height * scale_f32) as u32,
+                )
+            }),
+            clip_rrect: batch
+                .clip_rrect
+                .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
+            start: batch.start,
+            end: batch.end,
+            plate: batch.plate,
+            blur_behind: batch.blur_behind,
+        })
+        .collect()
+}
+
 /// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
 /// the display-list text path: clip a text item's bounds so it does not bleed through an open
 /// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
@@ -3763,6 +3881,15 @@ pub struct EngineState<A: Application> {
     /// The popover-union rect last sent via zcce set_popover_region, logical
     /// surface px; None once a clear has been sent (or never anything).
     pub sent_popover_region: Option<(i32, i32, i32, i32)>,
+    /// The context menu's popup surface while one is open — see
+    /// `backend::menu_popup`.
+    pub menu_popup: Option<crate::backend::menu_popup::MenuPopup>,
+    /// The popup's renderer, kept across opens and moved from one popup
+    /// surface to the next: a renderer costs a device and every pipeline
+    /// (tens of ms), a re-attach costs one swapchain.
+    pub menu_renderer: Option<VkRenderer>,
+    /// The `wl_display` the renderers were made from, as an address.
+    pub display_ptr: usize,
 
     pub exit: bool,
     pub redraw: bool,
@@ -3856,6 +3983,7 @@ impl<A: Application> EngineState<A> {
 
         let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
         let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
+        self.display_ptr = display_ptr as usize;
 
         let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
         // Corner radius 0: runner apps tessellate their own rounded corners.
@@ -3881,7 +4009,7 @@ impl<A: Application> EngineState<A> {
     /// disagrees with it — a mispaired buffer/scale commit is how the resume
     /// output bounce halved even-sized windows (buffer at the old scale's
     /// size, new scale latched; the compositor reads it as a self-resize).
-    fn buffer_geometry(scale_factor: f64, w: f32, h: f32) -> (i32, u32, u32) {
+    pub(crate) fn buffer_geometry(scale_factor: f64, w: f32, h: f32) -> (i32, u32, u32) {
         let s = if crate::scale::forced_scale().is_some() {
             1
         } else {
@@ -4132,37 +4260,7 @@ impl<A: Application> EngineState<A> {
         // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
         self.dl_text_items.clear();
         if self.inner.as_ref().unwrap().display_list_text() {
-            let fs = self.font_system.as_mut().unwrap();
-            for item in &dl.items {
-                if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
-                    let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
-                    let merged = match (clip, *bounds) {
-                        (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
-                        (Some(a), None) => Some(a),
-                        (None, b) => b,
-                    };
-                    // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
-                    // offset; ordinary labels take the shared cached buffer.
-                    let (buffer, y_off) = match layout {
-                        Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
-                        None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
-                    };
-                    self.dl_text_items.push(TextItem {
-                        buffer,
-                        x: *x,
-                        y: *y + y_off,
-                        color: cosmic_text::Color::rgba(
-                            color[0],
-                            color[1],
-                            color[2],
-                            (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
-                        ),
-                        bounds: merged,
-                        clip_circle: item.clip_circle,
-                        clip_rrect: item.clip_rrect,
-                    });
-                }
-            }
+            collect_dl_text(self.font_system.as_mut().unwrap(), &dl, &mut self.dl_text_items);
         }
 
         let (mut verts, mut dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
@@ -4222,7 +4320,9 @@ impl<A: Application> EngineState<A> {
         // popup is gone), so it gets the same occlusion: the menu rect clamps list text
         // beneath, and the menu's own labels are exempt because they carry bounds equal
         // to the rect.
-        if crate::widget::context_menu::is_visible() {
+        // Hosted in its popup surface, the menu covers the window from above
+        // and nothing of it is in the list.
+        if crate::widget::context_menu::is_visible() && !crate::widget::context_menu::is_hosted() {
             dl_overlay_rects.push((
                 crate::widget::context_menu::x(),
                 crate::widget::context_menu::y(),
@@ -4230,51 +4330,7 @@ impl<A: Application> EngineState<A> {
                 crate::widget::context_menu::h(),
             ));
         }
-        let mut spans: Vec<TextSpan> = Vec::new();
-        for ti in &self.dl_text_items {
-            let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
-                TextBounds {
-                    left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
-                    top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
-                    right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
-                    bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
-                }
-            } else {
-                bounds
-            };
-            popover_occlusion_clamp(&dl_overlay_rects, ti, scale_f32, &mut item_bounds);
-            spans.push(TextSpan {
-                buffer: &ti.buffer,
-                left: (ti.x * scale_f32).round(),
-                top: (ti.y * scale_f32).round(),
-                // Buffers are shaped at physical size (get_text_buffer_attrs).
-                scale: 1.0,
-                bounds: Some([
-                    item_bounds.left,
-                    item_bounds.top,
-                    item_bounds.right,
-                    item_bounds.bottom,
-                ]),
-                default_color: [
-                    ti.color.r() as f32 / 255.0,
-                    ti.color.g() as f32 / 255.0,
-                    ti.color.b() as f32 / 255.0,
-                    ti.color.a() as f32 / 255.0,
-                ],
-                rotation: None,
-                // Circle wins when both are set (the circular pane's innermost clip);
-                // otherwise a rounded-rect clip rides as center+radius with extents.
-                clip_circle: match (ti.clip_circle, ti.clip_rrect) {
-                    (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
-                    (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
-                    (None, None) => [0.0; 3],
-                },
-                clip_extents: match (ti.clip_circle, ti.clip_rrect) {
-                    (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
-                    _ => [0.0; 2],
-                },
-            });
-        }
+        let spans = dl_text_spans(&self.dl_text_items, scale_f32, bounds, &dl_overlay_rects);
 
         // 3. Frame: display-list batches under their physical scissors, then
         // text, then overlays. The renderer owns swapchain rebuild/recovery.
@@ -4308,26 +4364,7 @@ impl<A: Application> EngineState<A> {
             })
             .collect();
 
-        let batches: Vec<Batch2D> = dl_batches
-            .iter()
-            .map(|batch| Batch2D {
-                scissor: batch.scissor.map(|clip| {
-                    (
-                        (clip.x * scale_f32).max(0.0) as u32,
-                        (clip.y * scale_f32).max(0.0) as u32,
-                        (clip.width * scale_f32) as u32,
-                        (clip.height * scale_f32) as u32,
-                    )
-                }),
-                clip_rrect: batch
-                    .clip_rrect
-                    .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
-                start: batch.start,
-                end: batch.end,
-                plate: batch.plate,
-                blur_behind: batch.blur_behind,
-            })
-            .collect();
+        let batches = dl_batches_2d(&dl_batches, scale_f32);
 
         let cc = self.inner.as_ref().unwrap().clear_color();
         let clear_color = [cc[0].powf(2.2), cc[1].powf(2.2), cc[2].powf(2.2), cc[3]];
@@ -4413,6 +4450,10 @@ impl<A: Application> EngineState<A> {
 
 impl<A: Application> Drop for EngineState<A> {
     fn drop(&mut self) {
+        // The popup's renderer lets go of its surface before the popup (and
+        // its wl_surface) drops with the rest of the fields.
+        self.close_menu_popup();
+        self.menu_renderer = None;
         self.renderer = None;
     }
 }
@@ -4718,6 +4759,15 @@ impl<A: Application> PointerHandler for EngineState<A> {
             // right/bottom-only, so frame coords == surface coords.
             let lx = x as f32 / forced;
             let ly = y as f32 / forced;
+            // An event on the context menu's popup surface is the app's too,
+            // at the popup's offset from the window: menu dispatch works in
+            // window coordinates, which now reach outside the window.
+            let popup_offset = self.menu_popup.as_ref().and_then(|p| p.offset_for(&event.surface));
+            let on_popup = popup_offset.is_some();
+            let (lx, ly) = match popup_offset {
+                Some((ox, oy)) => (lx + ox, ly + oy),
+                None => (lx, ly),
+            };
 
             self.cursor_pos = (lx, ly);
             match &event.kind {
@@ -4813,7 +4863,10 @@ impl<A: Application> PointerHandler for EngineState<A> {
 
                     // Client-Side Decorations (CSD) Drag & Resize Handling
                     let is_status_bar = self.inner.as_ref().unwrap().settings().app_id.starts_with("cce-status");
-                    if btn == MouseButton::Left && !is_status_bar && self.inner.as_ref().unwrap().standard_csd() {
+                    // Never on the menu popup: its presses are the menu's, and
+                    // its coordinates, translated into the window's, would
+                    // otherwise read as a resize border or a movable plate.
+                    if btn == MouseButton::Left && !on_popup && !is_status_bar && self.inner.as_ref().unwrap().standard_csd() {
                         let border = 8.0f32;
                         let mut edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::None;
                         if !self.inner.as_ref().unwrap().csd_resize_borders() {
@@ -5807,6 +5860,9 @@ fn run_session<'l, A: Application>(
         applied_margin: 0.0,
         overflow_was_active: false,
         sent_popover_region: None,
+        menu_popup: None,
+        menu_renderer: None,
+        display_ptr: 0,
         exit: false,
         redraw: false,
         frame_callback_pending: false,
@@ -6189,6 +6245,7 @@ fn run_session<'l, A: Application>(
             }
             engine_state.send_popover_region();
         }
+        engine_state.sync_menu_popup();
 
         if let Some(ref mut pk) = engine_state.pressed_key {
             let now = std::time::Instant::now();
@@ -6282,6 +6339,7 @@ fn run_session<'l, A: Application>(
             engine_state.redraw = false;
             if engine_state.first_configure_received {
                 engine_state.render();
+                engine_state.render_menu_popup();
                 rendered = true;
             }
         } else if !engine_state.redraw
diff --git a/src/vk/core.rs b/src/vk/core.rs
index 6becff8..0640939 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -48,7 +48,6 @@ pub struct VkCore {
     pub(crate) allocator: Option<Allocator>,
     pub(crate) command_pool: vk::CommandPool,
     pub(crate) queue: vk::Queue,
-    #[allow(dead_code)] // RT engine / future consumers select by family
     pub(crate) queue_family: u32,
     /// The VK_KHR_acceleration_structure device loader — present exactly when
     /// the ray-query stack (accel structs + ray_query + BDA) was enabled at
@@ -207,6 +206,41 @@ impl VkCore {
         (core, surface.expect("surface requested but not created"))
     }
 
+    /// A new `VkSurfaceKHR` on another Wayland surface, from this core's
+    /// instance — for a renderer moving to a fresh `wl_surface` (a menu popup
+    /// re-opened) without a new device. The caller owns the handle.
+    ///
+    /// # Safety
+    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
+    /// pointers that outlive the returned surface.
+    pub unsafe fn create_wayland_surface(
+        &self,
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+    ) -> vk::SurfaceKHR {
+        let shared = shared_instance();
+        let wayland_loader = ash::khr::wayland_surface::Instance::new(&shared.entry, &self.instance);
+        let surface = wayland_loader
+            .create_wayland_surface(
+                &vk::WaylandSurfaceCreateInfoKHR::default()
+                    .display(display_ptr)
+                    .surface(surface_ptr),
+                None,
+            )
+            .expect("Failed to create Wayland surface");
+        // The device was chosen for the FIRST surface's present support; a
+        // later surface on the same display is presentable from the same
+        // family on every driver this runs on, but say so if not.
+        if !self
+            .surface_loader
+            .get_physical_device_surface_support(self.physical_device, self.queue_family, surface)
+            .unwrap_or(false)
+        {
+            log::warn!("[vk] queue family {} cannot present to the re-attached surface", self.queue_family);
+        }
+        surface
+    }
+
     /// A windowless core: no surface extensions, any graphics-capable device.
     /// For offscreen rendering (thumbnails, previews) and compute.
     pub fn new_headless() -> Self {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index e922b21..c4a28c6 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1446,6 +1446,58 @@ impl VkRenderer {
         self.present_mode == vk::PresentModeKHR::MAILBOX
     }
 
+    /// Let go of the window surface: wait idle, then destroy the swapchain
+    /// and the `VkSurfaceKHR`, keeping the device, pipelines and atlases. The
+    /// `wl_surface` under them may be destroyed after this returns, and must
+    /// not be before — a swapchain presenting to a dead surface is undefined.
+    /// Until [`attach_surface`](Self::attach_surface), `draw_frame_2d` draws
+    /// nothing and returns false.
+    pub fn detach_surface(&mut self) {
+        unsafe {
+            let _ = self.core.device.device_wait_idle();
+            self.destroy_swapchain_resources();
+            if self.swapchain != vk::SwapchainKHR::null() {
+                self.swapchain_loader.destroy_swapchain(self.swapchain, None);
+                self.swapchain = vk::SwapchainKHR::null();
+            }
+            if self.surface != vk::SurfaceKHR::null() {
+                self.core.surface_loader.destroy_surface(self.surface, None);
+                self.surface = vk::SurfaceKHR::null();
+            }
+        }
+        self.extent = vk::Extent2D { width: 0, height: 0 };
+        self.swapchain_dirty = true;
+    }
+
+    /// Present to a different `wl_surface` from now on, at `width` x
+    /// `height` physical px — detaching from the current one first if it is
+    /// still attached. What makes a popup surface cheap to re-open: a new
+    /// renderer costs a device and every pipeline, this costs one swapchain.
+    ///
+    /// # Safety
+    /// Same contract as [`VkRenderer::new`]: live `wl_display` / `wl_surface`
+    /// pointers that outlive the attachment.
+    pub unsafe fn attach_surface(
+        &mut self,
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+        width: u32,
+        height: u32,
+    ) {
+        if self.surface != vk::SurfaceKHR::null() {
+            self.detach_surface();
+        }
+        self.surface = self.core.create_wayland_surface(display_ptr, surface_ptr);
+        self.resize(width, height);
+        self.swapchain_dirty = true;
+    }
+
+    /// Whether a surface is attached — false between
+    /// [`detach_surface`](Self::detach_surface) and the next attach.
+    pub fn has_surface(&self) -> bool {
+        self.surface != vk::SurfaceKHR::null()
+    }
+
     /// The extent the next `draw_frame` will render at: the pending size when a
     /// swapchain rebuild is queued, otherwise the live one.
     pub fn pending_extent(&self) -> vk::Extent2D {
@@ -1515,6 +1567,9 @@ impl VkRenderer {
     /// top. Returns false if the frame was skipped (swapchain rebuild); the
     /// caller just draws again next tick.
     pub fn draw_frame_2d(&mut self, frame2d: Frame2D<'_>) -> bool {
+        if self.surface == vk::SurfaceKHR::null() {
+            return false;
+        }
         if self.swapchain_dirty {
             self.swapchain_dirty = false;
             self.recreate_swapchain();
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 860b53b..584a250 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -431,6 +431,37 @@ pub mod context_menu {
         wheel_accum: f32,
         /// The last value a slider was moved to, drained by the host.
         slider_change: Option<(usize, f32)>,
+        /// The point `show` opened the menu at — the anchor a placement
+        /// flips and slides from. `x`/`y` are where the menu IS.
+        pub anchor: (f32, f32),
+        /// Height of every row plus the padding: the menu's natural height.
+        /// `h` is the height it is SHOWN at, which a placement may cut down
+        /// to fit the screen; the rows then scroll.
+        pub content_h: f32,
+        /// How far the rows are scrolled up, 0..=`content_h - h`.
+        pub scroll: f32,
+        /// Bumped by every `show`, so a host mirroring the menu elsewhere (the
+        /// runner's popup surface) can tell a re-show from a repaint.
+        pub generation: u64,
+        /// The menu is drawn in its own popup surface by the runner, so the
+        /// in-window paint calls ([`paint`](Self::paint), [`text_labels`],
+        /// [`extra_quads`]) draw nothing — every app still makes them, and a
+        /// second copy in the window would show through under the popup.
+        /// Hit testing is unaffected: the popup routes its pointer events
+        /// back into window coordinates, where the rect is.
+        ///
+        /// [`text_labels`]: Self::text_labels
+        /// [`extra_quads`]: Self::extra_quads
+        pub hosted: bool,
+        /// The pointer's last place over the menu, to re-hover after a scroll
+        /// moves a different row under it.
+        last_cursor: Option<(f32, f32)>,
+        /// Being painted into the popup surface, where the plate is the
+        /// surface's ROOT: frosted by the compositor's blur-behind rather
+        /// than the in-app pass, which has no backdrop to sample there — the
+        /// popup's own frame is empty behind the plate, and the in-app frost
+        /// of nothing is a flat opaque grey.
+        in_popup: bool,
     }
 
     impl ContextMenuState {
@@ -449,14 +480,26 @@ pub mod context_menu {
                 slider_drag: None,
                 wheel_accum: 0.0,
                 slider_change: None,
+                anchor: (0.0, 0.0),
+                content_h: 0.0,
+                scroll: 0.0,
+                generation: 0,
+                hosted: false,
+                last_cursor: None,
+                in_popup: false,
             }
         }
 
         pub fn show(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
             self.x = x;
             self.y = y;
+            self.anchor = (x, y);
             self.options = options;
-            self.h = self.options.len() as f32 * ROW_H + 2.0 * PAD;
+            self.content_h = self.options.len() as f32 * ROW_H + 2.0 * PAD;
+            self.h = self.content_h;
+            self.scroll = 0.0;
+            self.last_cursor = None;
+            self.generation = self.generation.wrapping_add(1);
             // Width from the widest label as the RENDERER shapes it —
             // `shaped_cluster_offsets`, the same cosmic-text buffer cache the
             // draw reads — not `measure_text_width`. That one rasterizes an
@@ -514,6 +557,61 @@ pub mod context_menu {
             self.w = self.w.max(need);
         }
 
+        /// Put the menu at `(x, y)`, shown at most `max_h` tall: the rows
+        /// scroll when that cuts them off. Never shorter than one row, so a
+        /// placement that leaves no room still shows something to scroll.
+        /// Where the popup's configure lands the menu, and the in-window
+        /// fallback's [`constrain_to`](Self::constrain_to).
+        pub fn place(&mut self, x: f32, y: f32, max_h: f32) {
+            self.x = x;
+            self.y = y;
+            let floor = self.content_h.min(ROW_H + 2.0 * PAD);
+            self.h = self.content_h.min(max_h).max(floor);
+            self.scroll = self.scroll.clamp(0.0, self.max_scroll());
+        }
+
+        /// Keep the menu inside `(bx, by, bw, bh)` the way an xdg positioner
+        /// with flip-y, slide-x, slide-y and resize-y does, from the anchor
+        /// `show` was given: it opens down and right; if it does not fit
+        /// below, it flips to open UP from the anchor; if it fits neither
+        /// way it slides to the bottom edge, and if it is taller than the
+        /// whole box it is cut to the box and scrolls. Recomputed from the
+        /// anchor every call, so it can run every frame. For hosts with no
+        /// popup surface (a layer surface, or the popup disabled) — there the
+        /// window is the only room there is.
+        pub fn constrain_to(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+            let (ax, ay) = self.anchor;
+            let x = if ax + self.w > bx + bw { (bx + bw - self.w).max(bx) } else { ax.max(bx) };
+            let (y, max_h) = if ay + self.content_h <= by + bh {
+                (ay.max(by), self.content_h)
+            } else if ay - self.content_h >= by {
+                (ay - self.content_h, self.content_h)
+            } else {
+                ((by + bh - self.content_h).max(by), bh)
+            };
+            self.place(x, y, max_h);
+        }
+
+        /// How far the rows can scroll: zero when the menu shows them all.
+        pub fn max_scroll(&self) -> f32 {
+            (self.content_h - self.h).max(0.0)
+        }
+
+        /// Scroll the rows by `dy` px (positive shows rows further down),
+        /// clamped; re-hovers whatever row the pointer now sits on. `true`
+        /// when anything moved.
+        pub fn scroll_by(&mut self, dy: f32) -> bool {
+            let next = (self.scroll + dy).clamp(0.0, self.max_scroll());
+            if (next - self.scroll).abs() < f32::EPSILON {
+                return false;
+            }
+            self.scroll = next;
+            if let Some((px, py)) = self.last_cursor {
+                self.rehover(px, py);
+            }
+            true
+        }
+
         /// The slider on row `idx`, if it is one.
         pub fn slider(&self, idx: usize) -> Option<MenuSlider> {
             self.sliders.get(idx).copied().flatten()
@@ -547,7 +645,15 @@ pub mod context_menu {
                 return false;
             }
             let Some(idx) = self.row_at(px, py) else { return false };
-            let Some(s) = self.slider(idx) else { return false };
+            let Some(s) = self.slider(idx) else {
+                // Not a slider: a menu cut down to fit scrolls its rows.
+                // Up shows the rows above, as every list in the DE does.
+                if self.max_scroll() <= 0.0 {
+                    return false;
+                }
+                self.scroll_by(-delta.notches_y() * ROW_H);
+                return true;
+            };
             self.wheel_accum += delta.notches_y();
             let whole = self.wheel_accum.trunc();
             if whole == 0.0 {
@@ -600,6 +706,7 @@ pub mod context_menu {
         pub fn hide(&mut self) {
             self.visible = false;
             self.target = None;
+            self.last_cursor = None;
         }
 
         pub fn hit_test(&self, px: f32, py: f32) -> bool {
@@ -607,19 +714,20 @@ pub mod context_menu {
             px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
         }
 
-        /// The top of row `idx`.
+        /// The top of row `idx`, where it is drawn: scrolled, so a row above
+        /// the view lies above `y`.
         pub fn row_y(&self, idx: usize) -> f32 {
-            self.y + PAD + idx as f32 * ROW_H
+            self.y + PAD + idx as f32 * ROW_H - self.scroll
         }
 
         /// The row under `(px, py)`, or `None` outside the plate or in its
         /// padding — the padding is plate, not a row, so a press there
         /// neither hovers nor fires row 0.
         pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
-            if px < self.x || px > self.x + self.w {
+            if px < self.x || px > self.x + self.w || py < self.y || py > self.y + self.h {
                 return None;
             }
-            let rel = py - self.y - PAD;
+            let rel = py - self.y - PAD + self.scroll;
             if rel < 0.0 {
                 return None;
             }
@@ -629,9 +737,14 @@ pub mod context_menu {
 
         pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
             if !self.visible { return false; }
+            self.last_cursor = Some((px, py));
             if self.slider_drag.is_some() {
                 return self.slider_drag_to(px);
             }
+            self.rehover(px, py)
+        }
+
+        fn rehover(&mut self, px: f32, py: f32) -> bool {
             let was_hovered = self.hovered_item;
             self.hovered_item = None;
             if let Some(idx) = self.row_at(px, py) {
@@ -717,7 +830,7 @@ pub mod context_menu {
         ///
         /// [`text_labels`]: ContextMenuState::text_labels
         pub fn paint(&self, ctx: &mut crate::scene::paint::PaintCtx) {
-            if !self.visible {
+            if !self.visible || self.hosted {
                 return;
             }
             let rect = crate::scene::layout::Rect {
@@ -733,12 +846,50 @@ pub mod context_menu {
                 // The popover material: the page colour at menu_opacity,
                 // frosted (an opaque page colour would resolve the frost
                 // to a solid tint, invisible).
-                ctx.plate(rect, (r, r, r, r), &crate::scene::material::Material::popover(face), depth);
+                let material = crate::scene::material::Material::popover(face);
+                let material = if self.in_popup {
+                    // The compositor's blur frosts but cannot COMPRESS: the
+                    // in-app pass pulls the backdrop's luminance a fraction
+                    // `k` toward the plate's key, which is what keeps the
+                    // labels legible over a bright scene. Over glass that
+                    // only blurs, hold the same swing with opacity instead —
+                    // the backdrop reaches the eye at (1 - a)(1 - k) either
+                    // way — or a menu opened over something white washes out.
+                    let k = crate::color::menu_compression().clamp(0.0, 1.0);
+                    let mut m = material.for_role(crate::scene::material::PlateRole::Root);
+                    m.tint[3] = 1.0 - (1.0 - m.tint[3]) * (1.0 - k);
+                    m
+                } else {
+                    material
+                };
+                ctx.plate(rect, (r, r, r, r), &material, depth);
             } else {
                 let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
                 ctx.boss(plateau, radii, depth);
             }
 
+            // What the rows draw — hover, separators, slider bands — is cut at
+            // the plate, so a row scrolled half out of a shortened menu stops
+            // at its edge instead of hanging off it.
+            ctx.clip_rounded(rect, r, |ctx| self.paint_rows(ctx, rect, r, depth));
+            if self.max_scroll() > 0.0 {
+                // A scrolled menu says so: a thumb in the right padding, as
+                // long against the plate as the view is against the rows.
+                let track = (rect.y + PAD, rect.height - 2.0 * PAD);
+                let len = (track.1 * self.h / self.content_h).max(12.0).min(track.1);
+                let at = track.0 + (track.1 - len) * (self.scroll / self.max_scroll());
+                let tw = 3.0;
+                let c = crate::color::TEXT_DIM;
+                ctx.rounded_rect(
+                    crate::scene::layout::Rect { x: rect.x + rect.width - PAD * 0.5 - tw * 0.5, y: at, width: tw, height: len },
+                    tw * 0.5,
+                    (true, true, true, true),
+                    [c[0], c[1], c[2], 0.6],
+                );
+            }
+        }
+
+        fn paint_rows(&self, ctx: &mut crate::scene::paint::PaintCtx, rect: crate::scene::layout::Rect, r: f32, depth: f32) {
             if let Some(h_idx) = self.hovered_item {
                 // Inset off the roll so the fill sits on the face instead of
                 // climbing the lit edge, and round the corners it actually meets:
@@ -803,7 +954,7 @@ pub mod context_menu {
         /// for hosts that have not migrated, and renders as it always has.
         pub fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
             let mut quads = Vec::new();
-            if !self.visible { return quads; }
+            if !self.visible || self.hosted { return quads; }
 
             // border
             quads.push((self.x, self.y, self.w, self.h, [0.22, 0.22, 0.28, 1.0]));
@@ -837,7 +988,7 @@ pub mod context_menu {
         /// it; prefer it over the pair.
         pub fn paint_with_labels(&self, ctx: &mut crate::scene::paint::PaintCtx) {
             self.paint(ctx);
-            if !self.visible {
+            if !self.visible || self.hosted {
                 return;
             }
             let (family, _) = label_font();
@@ -860,12 +1011,19 @@ pub mod context_menu {
 
         pub fn text_labels(&self) -> Vec<TextLabel> {
             let mut labels = Vec::new();
-            if !self.visible { return labels; }
+            if !self.visible || self.hosted { return labels; }
 
             for (idx, opt) in self.options.iter().enumerate() {
                 if opt == "-" {
                     continue;
                 }
+                // Scrolled wholly out of a shortened menu: nothing to draw.
+                // A row partly in view is drawn and cut at the plate by the
+                // label's bounds.
+                let top = self.row_y(idx);
+                if top + ROW_H < self.y || top > self.y + self.h {
+                    continue;
+                }
                 let (_, label_size) = label_font();
                 let iy = self.row_y(idx) + (ROW_H - label_size) / 2.0;
                 // The toolkit's semantic colors rather than greys hand-mixed
@@ -938,6 +1096,45 @@ pub mod context_menu {
         });
     }
 
+    /// Whether the runner draws the menu in its own popup surface — see
+    /// [`ContextMenuState::hosted`]. Set by the runner, never by an app.
+    pub fn set_hosted(hosted: bool) {
+        CONTEXT_MENU.with(|m| m.borrow_mut().hosted = hosted);
+    }
+    pub fn is_hosted() -> bool {
+        CONTEXT_MENU.with(|m| m.borrow().hosted)
+    }
+    /// See [`ContextMenuState::generation`].
+    pub fn generation() -> u64 {
+        CONTEXT_MENU.with(|m| m.borrow().generation)
+    }
+    /// See [`ContextMenuState::place`].
+    pub fn place(x: f32, y: f32, max_h: f32) {
+        CONTEXT_MENU.with(|m| m.borrow_mut().place(x, y, max_h));
+    }
+    /// See [`ContextMenuState::constrain_to`].
+    pub fn constrain_to(bx: f32, by: f32, bw: f32, bh: f32) {
+        CONTEXT_MENU.with(|m| m.borrow_mut().constrain_to(bx, by, bw, bh));
+    }
+    /// `(anchor, w, content_h)` — what a popup positioner is built from.
+    pub fn natural_geometry() -> ((f32, f32), f32, f32) {
+        CONTEXT_MENU.with(|m| {
+            let m = m.borrow();
+            (m.anchor, m.w, m.content_h)
+        })
+    }
+    /// Paint the menu with its top-left at the origin, whether or not it is
+    /// [hosted](set_hosted) — the runner's popup surface draws it this way.
+    /// Paints a COPY, so no borrow of the menu is held while the paint runs
+    /// (the slider stamp's drop reaches back into this cell).
+    pub fn paint_hosted(ctx: &mut crate::scene::paint::PaintCtx) {
+        let mut menu = CONTEXT_MENU.with(|m| m.borrow().clone());
+        menu.hosted = false;
+        menu.in_popup = true;
+        let (x, y) = (menu.x, menu.y);
+        ctx.translate(-x, -y, |ctx| menu.paint_with_labels(ctx));
+    }
+
     pub fn x() -> f32 { CONTEXT_MENU.with(|m| m.borrow().x) }
     pub fn y() -> f32 { CONTEXT_MENU.with(|m| m.borrow().y) }
     pub fn w() -> f32 { CONTEXT_MENU.with(|m| m.borrow().w) }
@@ -1126,6 +1323,131 @@ mod context_menu_slider_tests {
         m.row_y(idx) + ROW_H * 0.5
     }
 
+    /// Twenty rows: 20 * ROW_H + 2 * PAD tall, far more than the boxes below.
+    fn long_menu() -> ContextMenuState {
+        let mut m = ContextMenuState::new();
+        let rows: Vec<String> = (0..20).map(|i| format!("Row {i}")).collect();
+        m.show(100.0, 50.0, rows, 0, WidgetId(7));
+        m
+    }
+
+    /// Placed shorter than its rows, the menu scrolls: the wheel moves the
+    /// rows a row a notch, up shows the rows above, it stops at both ends,
+    /// and the row under the pointer — hover, press — is the one DRAWN
+    /// there, scroll included.
+    #[test]
+    fn a_shortened_menu_scrolls_its_rows() {
+        let mut m = long_menu();
+        let full = m.content_h;
+        assert_eq!(full, 20.0 * ROW_H + 2.0 * PAD);
+        m.place(100.0, 50.0, 200.0);
+        assert_eq!(m.h, 200.0);
+        assert_eq!(m.max_scroll(), full - 200.0);
+
+        let (px, py) = (130.0, m.y + PAD + ROW_H * 0.5);
+        m.cursor_moved(px, py);
+        assert_eq!(m.row_at(px, py), Some(0));
+        // Wheel down (negative notches): three rows further on.
+        assert!(m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), px, py));
+        assert_eq!(m.scroll, 3.0 * ROW_H);
+        assert_eq!(m.row_at(px, py), Some(3), "the row under the pointer moved with the scroll");
+        assert_eq!(m.hovered_item, Some(3), "and the hover followed it without a motion event");
+        assert_eq!(m.row_y(3), m.y + PAD, "row 3 is drawn where row 0 was");
+        // Up past the top stops at the top; down past the end stops there.
+        m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 10.0), px, py);
+        assert_eq!(m.scroll, 0.0);
+        m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), px, py);
+        assert_eq!(m.scroll, m.max_scroll());
+        // Nothing to scroll: the wheel is not the menu's.
+        let mut short = menu();
+        assert!(!short.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 110.0, short.row_y(0) + 1.0));
+    }
+
+    /// A row outside the shown plate is not under the pointer, even though
+    /// the rows' arithmetic would reach it — the plate ends at `h`.
+    #[test]
+    fn rows_below_a_shortened_plate_are_not_hit() {
+        let mut m = long_menu();
+        m.place(100.0, 50.0, 200.0);
+        assert!(m.row_at(130.0, m.y + m.h + 5.0).is_none());
+        assert!(!m.hit_test(130.0, m.y + m.h + 5.0));
+    }
+
+    /// The in-window placement, from the anchor: fits below → stays; not
+    /// below but above → flips to open up from the anchor; neither → slides
+    /// to the bottom edge; taller than the box → cut to it, scrolling. And
+    /// the right edge slides the menu left.
+    #[test]
+    fn constrain_flips_slides_and_shortens_like_a_positioner() {
+        // Fits below.
+        let mut m = menu();
+        m.constrain_to(0.0, 0.0, 800.0, 600.0);
+        assert_eq!((m.x, m.y, m.h), (100.0, 50.0, m.content_h));
+
+        // Opened near the bottom: flips up from the anchor.
+        let mut m = ContextMenuState::new();
+        m.show(100.0, 580.0, vec!["A".into(), "B".into(), "C".into()], 0, WidgetId(7));
+        m.constrain_to(0.0, 0.0, 800.0, 600.0);
+        assert_eq!(m.y, 580.0 - m.content_h, "flipped to open upward");
+
+        // No room either way: slides to the bottom edge, whole.
+        let mut m = long_menu(); // 496 tall
+        m.show(100.0, 300.0, (0..20).map(|i| format!("{i}")).collect(), 0, WidgetId(7));
+        m.constrain_to(0.0, 0.0, 800.0, 600.0);
+        assert_eq!(m.y + m.h, 600.0);
+        assert_eq!(m.h, m.content_h);
+
+        // Taller than the box: cut to it, and it scrolls.
+        m.constrain_to(0.0, 0.0, 800.0, 300.0);
+        assert_eq!((m.y, m.h), (0.0, 300.0));
+        assert!(m.max_scroll() > 0.0);
+
+        // The right edge: slides left to fit.
+        let mut m = menu();
+        m.show(790.0, 50.0, vec!["A".into()], 0, WidgetId(7));
+        m.constrain_to(0.0, 0.0, 800.0, 600.0);
+        assert_eq!(m.x + m.w, 800.0);
+
+        // Re-running is stable: it works from the anchor, not from where
+        // the last run put it.
+        let mut m = long_menu();
+        m.constrain_to(0.0, 0.0, 800.0, 300.0);
+        let first = (m.x, m.y, m.h);
+        m.constrain_to(0.0, 0.0, 800.0, 300.0);
+        assert_eq!((m.x, m.y, m.h), first);
+    }
+
+    /// Hosted in the popup, the menu draws nothing into the window's list —
+    /// every app still calls the in-window paint — while the runner's
+    /// `paint_hosted` draws it at the origin. Hit testing is untouched.
+    #[test]
+    fn a_hosted_menu_paints_only_through_the_popup() {
+        use super::context_menu as cm;
+        cm::show(100.0, 50.0, vec!["Frame All".into(), "Opacity".into()], 0, WidgetId(7));
+        cm::set_hosted(true);
+        let mut pc = crate::scene::paint::PaintCtx::new();
+        cm::paint_with_labels(&mut pc);
+        assert!(pc.finish().items.is_empty(), "nothing in the window");
+        assert!(cm::text_labels().is_empty());
+        assert!(cm::hit_test(110.0, 60.0), "still hit-tested where it is");
+
+        let mut pc = crate::scene::paint::PaintCtx::new();
+        cm::paint_hosted(&mut pc);
+        let dl = pc.finish();
+        assert!(!dl.items.is_empty(), "the popup draws it");
+        let texts: Vec<(f32, f32)> = dl
+            .items
+            .iter()
+            .filter_map(|i| match &i.prim {
+                crate::scene::paint::Prim::Text { x, y, .. } => Some((*x, *y)),
+                _ => None,
+            })
+            .collect();
+        assert!(texts.iter().all(|&(x, y)| x < 100.0 && y < 50.0 + 2.0 * ROW_H), "at the popup's origin, not the window's");
+        cm::set_hosted(false);
+        cm::hide();
+    }
+
     /// Painted through the thread-local, as every host paints it: the slider
     /// stamp is dropped while `CONTEXT_MENU` is borrowed, and its drop clears
     /// widget references in that same cell. The tests above paint a bare