git.lucas.co / cce-ui
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>);