GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/backend/menu_popup.rs (18.7K)
1 //! The global context menu in its own `xdg_popup` surface.
2 //!
3 //! Every app paints [`context_menu`] into its own display list and routes the
4 //! pointer to it by window coordinates. Drawn in the window, a menu opened
5 //! near an edge is cut off at the window's edge — and the window is the only
6 //! room it has. Here the runner mirrors the open menu into a popup surface
7 //! parented to the window, which the compositor may place anywhere on the
8 //! output and, through the positioner's constraint adjustment, keeps ON the
9 //! output: it flips the menu to open upward, slides it in from an edge, or
10 //! cuts it short, in which case the rows scroll.
11 //!
12 //! Two rules make this safe, and they are the two things the previous popup
13 //! path (deleted in July 2026 as Phase 6x) got wrong:
14 //!
15 //! - **The compositor's placement is written back into the menu.** The popup
16 //! lands where the configure says, not where the menu asked, and
17 //! `context_menu::place` moves the menu's rect there. The rect every app
18 //! hit-tests against is therefore the rect on screen — the anchor-mismatch
19 //! bugs the old path had (a menu drawn in one place and clicked in another)
20 //! cannot happen.
21 //! - **The popup takes its own input, translated into window coordinates.**
22 //! The old popup had an empty input region and let clicks fall through to
23 //! the window beneath, which only works where there IS window beneath. A
24 //! pointer event on the popup is offset by the popup's position and handed
25 //! to the app as if it had landed on the window, so apps need no change:
26 //! their menu dispatch already works in window coordinates, which may now
27 //! lie outside the window.
28 //!
29 //! While the popup is up the menu is `hosted`, which turns the apps' own
30 //! in-window paint calls into no-ops. The popup has no keyboard grab: the
31 //! keyboard stays with the window, whose Escape and press-outside handling
32 //! close the menu as before.
33 //!
34 //! **A page turn is a new popup at the old one's corner** (2026-10-02,
35 //! where until then a row's submenu was a second popup, the CHILD of this
36 //! one, flying out beside it). `context_menu::show_page` puts the page's
37 //! top-left where the menu's was and marks the menu `turned`; its generation
38 //! moves, and the popup that is up is REPOSITIONED (`xdg_popup.reposition`)
39 //! to the page's size, anchored at that corner, rather than replaced: a new
40 //! surface under a pointer that has not moved gets no pointer focus until it
41 //! moves, so a swipe that turned the page, and the swipe back, would land on
42 //! nothing. Its positioner slides the page on screen rather than flipping it
43 //! to open up from the corner — a page that jumped above the plate it
44 //! replaced would not read as that plate turned.
45 //!
46 //! Only xdg toplevels get a popup. A layer surface, or any app with
47 //! `CCE_UI_MENU_POPUP=0`, keeps the in-window menu, constrained to the window
48 //! by `context_menu::constrain_to` with the same flip / slide / shorten rules.
49
50 use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_positioner::{
51 Anchor, ConstraintAdjustment, Gravity,
52 };
53 use smithay_client_toolkit::shell::xdg::popup::{Popup, PopupConfigure, PopupHandler};
54 use smithay_client_toolkit::shell::xdg::{XdgPositioner, XdgSurface as _};
55 use wayland_client::{protocol::wl_surface, Connection, Proxy, QueueHandle};
56
57 use super::window_runner::{
58 collect_dl_text, dl_batches_2d, dl_text_spans, tessellate_display_list, Application,
59 EngineState, TextBounds,
60 };
61 use crate::vk::{Frame2D, VkRenderer};
62 use crate::widget::context_menu;
63
64 pub struct MenuPopup {
65 popup: Popup,
66 /// What the popup was opened for: the menu's generation and its natural
67 /// size, rounded up. A re-show or a change of size opens a new popup.
68 key: (u64, u32, u32),
69 /// Where the compositor put it, in app-logical px relative to the
70 /// window's geometry: `(x, y, w, h)`. `None` until the first configure,
71 /// before which nothing may be attached to the surface.
72 placed: Option<(f32, f32, f32, f32)>,
73 /// The buffer scale last sent on the popup's surface.
74 committed_scale: i32,
75 /// The menu is still drawn in the window until this popup is placed —
76 /// see `open_menu_popup`.
77 handoff: bool,
78 /// Placed after a hand-off and not drawn yet: the next frame draws it
79 /// AHEAD of the window's (`take_menu_popup_lead`).
80 lead: bool,
81 }
82
83 impl MenuPopup {
84 /// The popup's offset from the window, if `surface` is its surface and it
85 /// has been placed — what a pointer event on it is translated by.
86 pub fn offset_for(&self, surface: &wl_surface::WlSurface) -> Option<(f32, f32)> {
87 if self.popup.wl_surface() != surface {
88 return None;
89 }
90 self.placed.map(|(x, y, _, _)| (x, y))
91 }
92 }
93
94 fn popup_enabled() -> bool {
95 std::env::var("CCE_UI_MENU_POPUP").map_or(true, |v| v != "0")
96 }
97
98 /// App-logical px to the compositor's surface coordinates: the same thing
99 /// except in forced-scale mode, where the compositor believes scale 1 and
100 /// the app's logical px are `forced` of its own.
101 fn forced() -> f32 {
102 crate::scale::forced_scale().unwrap_or(1.0)
103 }
104
105 impl<A: Application> EngineState<A> {
106 /// The window frame's logical size: the surface minus the overflow rim.
107 fn frame_size(&self) -> (f32, f32) {
108 (
109 (self.logical_width - self.applied_margin).max(1.0),
110 (self.logical_height - self.applied_margin).max(1.0),
111 )
112 }
113
114 /// Bring the popup in line with the menu: open one for a newly shown
115 /// menu, close it for a hidden one. Runs
116 /// once per loop, after input, so a host that shows the menu and then
117 /// sets its slider rows (which widen it) has done both before the popup
118 /// is sized.
119 pub(crate) fn sync_menu_popup(&mut self) {
120 let visible = context_menu::is_visible();
121 // A page turn is animated: frames until it lands, and the popup is
122 // sized to hold both plates meanwhile (`natural_geometry`), then to
123 // the page's own once it has.
124 if visible && context_menu::is_turning() {
125 self.redraw = true;
126 }
127 if !(visible && self.window.is_some() && popup_enabled()) {
128 if self.menu_popup.is_some() {
129 self.close_menu_popup();
130 }
131 context_menu::set_hosted(false);
132 if visible {
133 let (fw, fh) = self.frame_size();
134 context_menu::constrain_to(0.0, 0.0, fw, fh);
135 }
136 return;
137 }
138 let (anchor, w, content_h) = context_menu::natural_geometry();
139 let key = (context_menu::generation(), w.ceil() as u32, content_h.ceil() as u32);
140 if self.menu_popup.as_ref().is_some_and(|p| p.key == key) {
141 return;
142 }
143 // A page turned to in place of the menu MOVES the popup that is up
144 // rather than replacing it: a new surface under a pointer that has
145 // not moved gets no pointer focus until it does, so the rest of a
146 // swipe — and the swipe back — would land on nothing.
147 if context_menu::is_turned() && self.reposition_menu_popup(anchor, key) {
148 return;
149 }
150 self.close_menu_popup();
151 self.open_menu_popup(anchor, key);
152 }
153
154 /// Move and resize the popup that is up to show a turned page, through
155 /// `xdg_popup.reposition` (version 3). `false` where it cannot be: no
156 /// popup placed yet, an older protocol, no positioner.
157 fn reposition_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) -> bool {
158 let Some(mp) = self.menu_popup.as_ref() else { return false };
159 if mp.placed.is_none() || mp.popup.xdg_popup().version() < 3 {
160 return false;
161 }
162 let Some(positioner) = self.menu_positioner(anchor, key) else { return false };
163 // Configures as the popup is moved, so the token is not read back.
164 let mp = self.menu_popup.as_mut().unwrap();
165 mp.popup.reposition(&positioner, key.0 as u32);
166 mp.key = key;
167 self.redraw = true;
168 true
169 }
170
171 /// The positioner the menu's popup is placed by, sized to `key`'s width
172 /// and height and anchored at `anchor`.
173 fn menu_positioner(&self, anchor: (f32, f32), key: (u64, u32, u32)) -> Option<XdgPositioner> {
174 let positioner = match XdgPositioner::new(&self.xdg_shell_state) {
175 Ok(p) => p,
176 Err(e) => {
177 log::warn!("[menu_popup] no positioner ({e}); drawing the menu in the window");
178 return None;
179 }
180 };
181 let f = forced();
182 positioner.set_size(
183 ((key.1 as f32) * f).round().max(1.0) as i32,
184 ((key.2 as f32) * f).round().max(1.0) as i32,
185 );
186 // A 1x1 anchor at the point the menu was opened at, held inside the
187 // window's geometry (the rect the positioner is relative to).
188 let (fw, fh) = self.frame_size();
189 let ax = (anchor.0.clamp(0.0, fw - 1.0) * f).round() as i32;
190 let ay = (anchor.1.clamp(0.0, fh - 1.0) * f).round() as i32;
191 positioner.set_anchor_rect(ax, ay, 1, 1);
192 positioner.set_anchor(Anchor::TopLeft);
193 positioner.set_gravity(Gravity::BottomRight);
194 // Open down and right from the pointer. Short of room below, open UP
195 // from it (flip); short either way, slide in from the edge; taller
196 // than the output, cut it down (resize) — the menu scrolls.
197 // A page keeps the corner of the plate it turned from: no flip.
198 let flip = if context_menu::is_turned() { ConstraintAdjustment::empty() } else { ConstraintAdjustment::FlipY };
199 positioner.set_constraint_adjustment(
200 flip | ConstraintAdjustment::SlideX | ConstraintAdjustment::SlideY | ConstraintAdjustment::ResizeY,
201 );
202 Some(positioner)
203 }
204
205 fn open_menu_popup(&mut self, anchor: (f32, f32), key: (u64, u32, u32)) {
206 let Some(positioner) = self.menu_positioner(anchor, key) else { return };
207 let Some(window) = self.window.as_ref() else { return };
208 let popup = match Popup::new(
209 window.xdg_surface(),
210 &positioner,
211 &self.qh,
212 &self.compositor_state,
213 &self.xdg_shell_state,
214 ) {
215 Ok(p) => p,
216 Err(e) => {
217 log::warn!("[menu_popup] cannot create popup ({e}); drawing the menu in the window");
218 return;
219 }
220 };
221 // A menu opened at the pointer is hosted from now: until the first
222 // configure places it, it is drawn nowhere — a few milliseconds,
223 // against a copy in the window that would blink out when the popup
224 // appears somewhere else.
225 //
226 // A TURNED page is not: it takes the place of a plate that was just
227 // on screen (the designer's dialog, which closed in the same
228 // dispatch), and drawn nowhere it was a frame of nothing between the
229 // two. Its place is known — the corner — so the window keeps drawing
230 // it until the popup is placed, and the frame after the configure
231 // commits the popup FIRST and then the window without it: for the
232 // time the window's frame takes to draw, the two stand one over the
233 // other at one place, where the other order left neither.
234 let handoff = context_menu::is_turned();
235 context_menu::set_hosted(!handoff);
236 self.menu_popup = Some(MenuPopup { popup, key, placed: None, committed_scale: 0, handoff, lead: false });
237 }
238
239 /// Close the popup. The renderer lets go
240 /// of the surface FIRST: dropping the popup destroys the `wl_surface`,
241 /// and a swapchain must never outlive the surface it presents to.
242 pub(crate) fn close_menu_popup(&mut self) {
243 if let Some(renderer) = self.menu_renderer.as_mut() {
244 if renderer.has_surface() {
245 renderer.detach_surface();
246 }
247 }
248 self.menu_popup = None;
249 context_menu::set_hosted(false);
250 }
251
252 /// Whether the next frame draws the popup ahead of the window's own: once,
253 /// for the first frame after a hand-off (see `open_menu_popup`).
254 pub(crate) fn take_menu_popup_lead(&mut self) -> bool {
255 self.menu_popup.as_mut().is_some_and(|p| std::mem::take(&mut p.lead))
256 }
257
258 /// The offset from the window of the menu popup, if `surface` is it.
259 pub(crate) fn menu_popup_offset(&self, surface: &wl_surface::WlSurface) -> Option<(f32, f32)> {
260 self.menu_popup.as_ref().and_then(|p| p.offset_for(surface))
261 }
262
263 /// Draw the menu into its popup. Called after the window's own frame,
264 /// and after a configure; a no-op for a popup not yet placed.
265 pub(crate) fn render_menu_popup(&mut self) {
266 let (mp, renderer) = (self.menu_popup.as_mut(), self.menu_renderer.as_mut());
267 let Some(mp) = mp else { return };
268 let Some((_, _, w, h)) = mp.placed else { return };
269 let Some(renderer) = renderer else { return };
270 if !renderer.has_surface() {
271 return;
272 }
273 let scale = self.scale_factor as f32;
274 let (s, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
275
276 let mut pc = crate::scene::paint::PaintCtx::new();
277 context_menu::paint_hosted(&mut pc);
278 let dl = pc.finish();
279
280 let mut items = Vec::new();
281 collect_dl_text(self.font_system.as_mut().unwrap(), &dl, &mut items);
282 let (verts, dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, w, h, scale);
283 let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
284 let spans = dl_text_spans(&items, scale, bounds, &[]);
285 renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
286 let batches = dl_batches_2d(&dl_batches, scale);
287 // The menu's glyphs (its marks, its page and back chevrons) were
288 // painted with the WINDOW renderer's ids; this renderer keeps images
289 // of its own, so each is drawn by its copy here, uploaded once.
290 let icon_ids = &mut self.menu_icon_ids;
291 let images: Vec<crate::vk::ImageQuad> = dl_images
292 .iter()
293 .filter_map(|di| {
294 let own = *icon_ids.entry(di.image).or_insert_with(|| {
295 let (name, px, tint) = crate::icon_source(di.image)?;
296 let (rgba, iw, ih) = crate::icon_pixels(&name, px, tint)?;
297 Some(renderer.upload_rgba_now(&rgba, iw, ih))
298 });
299 Some(crate::vk::ImageQuad {
300 image: own?,
301 rect: (di.rect.x * scale, di.rect.y * scale, di.rect.width * scale, di.rect.height * scale),
302 alpha: di.alpha,
303 z_before: di.at,
304 clip: di.clip.map(|c| {
305 ((c.x * scale).max(0.0) as u32, (c.y * scale).max(0.0) as u32, (c.width * scale) as u32, (c.height * scale) as u32)
306 }),
307 })
308 })
309 .collect();
310
311 let e = renderer.pending_extent();
312 if e.width != pw || e.height != ph {
313 renderer.resize(pw, ph);
314 }
315 if s != mp.committed_scale {
316 mp.popup.wl_surface().set_buffer_scale(s);
317 mp.committed_scale = s;
318 }
319 renderer.draw_frame_2d(Frame2D {
320 verts: &verts,
321 batches: &batches,
322 overlay_verts: &[],
323 images: &images,
324 plate_features: &plate_features,
325 clear_color: [0.0; 4],
326 damage: None,
327 });
328 }
329
330 fn is_menu_popup(&self, surface: &wl_surface::WlSurface) -> bool {
331 self.menu_popup.as_ref().is_some_and(|p| p.popup.wl_surface() == surface)
332 }
333 }
334
335 impl<A: Application> PopupHandler for EngineState<A> {
336 fn configure(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup, config: PopupConfigure) {
337 if !self.is_menu_popup(popup.wl_surface()) {
338 return;
339 }
340 let f = forced();
341 let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
342 let (w, h) = (config.width.max(1) as f32 / f, config.height.max(1) as f32 / f);
343 let (scale_factor, display) = (self.scale_factor, self.display_ptr);
344 let (mp, slot, icon_ids) = (self.menu_popup.as_mut(), &mut self.menu_renderer, &mut self.menu_icon_ids);
345 let Some(mp) = mp else { return };
346 mp.placed = Some((x, y, w, h));
347 let handoff = std::mem::take(&mut mp.handoff);
348 // Where it landed IS where the menu is — see the module docs.
349 context_menu::place(x, y, h);
350 if handoff {
351 context_menu::set_hosted(true);
352 mp.lead = true;
353 }
354
355 let (_, pw, ph) = Self::buffer_geometry(scale_factor, w, h);
356 let surface_ptr = mp.popup.wl_surface().id().as_ptr() as *mut std::ffi::c_void;
357 let display_ptr = display as *mut std::ffi::c_void;
358 let attached = match slot.as_mut() {
359 Some(r) if r.has_surface() => {
360 r.resize(pw, ph);
361 Ok(())
362 }
363 Some(r) => unsafe { r.attach_surface(display_ptr, surface_ptr, pw, ph) },
364 None => {
365 let t = std::time::Instant::now();
366 let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) };
367 log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
368 // A new renderer holds none of the old one's copies.
369 icon_ids.clear();
370 made.map(|mut r| {
371 // Its images are its own (the menu's glyphs, copied in
372 // `render_menu_popup`); the shared queue is the window's.
373 r.set_shared_uploads(false);
374 *slot = Some(r)
375 })
376 }
377 };
378 // A lost surface is the connection dying under the menu; the window's
379 // own event loop ends the session on it. Just drop the menu.
380 if let Err(lost) = attached {
381 log::warn!("[menu_popup] {lost}; closing the menu");
382 context_menu::hide();
383 self.close_menu_popup();
384 self.redraw = true;
385 return;
386 }
387 self.redraw = true;
388 // A handed-off menu is drawn on the next frame, after the window's
389 // own without it (see `open_menu_popup`); drawn here it would stand
390 // over the window's copy until that frame.
391 if !handoff {
392 self.render_menu_popup();
393 }
394 }
395
396 fn done(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, popup: &Popup) {
397 // The compositor dismissed it (its parent went away, say). The menu
398 // closes with it; an app that watches `is_visible` sees that.
399 if self.is_menu_popup(popup.wl_surface()) {
400 context_menu::hide();
401 self.close_menu_popup();
402 self.redraw = true;
403 }
404 }
405 }
406
407 smithay_client_toolkit::delegate_xdg_popup!(@<A: Application> EngineState<A>);