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

commit4eeca3eb04024385a186541c2787b29ed7ac4933
parent8f73c62beb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 23:01
feat(runner): a layer app can have no surface while it is empty

Application::wants_surface (default true) lets a layer-shell app that is
usually empty -- the notifier between notifications -- say it has
nothing to show. The runner then drops the renderer and the layer
surface, and on the turn it says true builds a fresh wl_surface, the
same layer role (attach_layer_role, now shared with session start) and a
new renderer; the first configure makes it presentable as at startup.
The app keeps running throughout. surface_hidden tells it so: ids it
uploads from then on are queued for the next renderer, which must not
re-upload them as after a lost connection.

Why: an always-mapped transparent overlay made the compositor blur
behind it whenever anything under it changed, and kept a fullscreen
client off direct scanout.

Checked in a scale-2 shadow with cce-notifier on a private session
bus: unmapped at start; a card maps the surface, its expiry unmaps it,
the next maps it again; thumbnail cards draw after the rebuild; a card
after an empty spell shows ~45 ms after Notify. xdg windows and layer
apps that do not override it are unchanged.

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

 CLAUDE.md                    |  18 ++++++
 src/backend/app.rs           |  21 +++++++
 src/backend/window_runner.rs | 127 ++++++++++++++++++++++++++++++++++++-------
 3 files changed, 147 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f8189df..f725ccd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -483,6 +483,24 @@ its modules exited, the launcher's backoff grew while nobody was logged in, and
 StatusNotifierWatcher came back seconds after the next login — Dropbox, starting into the
 gap, reported no tray.
 
+### A layer app can have no surface while it is empty (`Application::wants_surface`, 2026-10-05)
+
+A layer-shell app that is usually empty — the notifier, between notifications — returns
+false from `wants_surface` while it has nothing to show. On that turn the runner drops the
+renderer and then the layer surface (SCTK destroys the role, then the `wl_surface`); on the
+turn it says true again it builds a fresh `wl_surface`, re-attaches the same layer role and
+a new renderer, and the first configure makes it presentable as at session start. The app
+keeps running throughout — its calloop sources, D-Bus thread and state are untouched; only
+the surface goes. Why bother: an always-mapped transparent overlay still made the
+compositor blur behind it whenever anything under it changed, and it kept a fullscreen
+client off direct scanout (scenefx scans out only a one-entry render list).
+
+`surface_hidden` tells the app it happened. Image ids it uploads from then on are queued
+for the NEXT renderer, so that renderer's `renderer_init` must not re-upload them the way
+it would after a lost connection (the notifier keeps a flag for this). Rebuilding costs a
+renderer: a card after an empty spell appeared ~45 ms after `Notify` in a scale-2 shadow.
+Default true; xdg windows ignore it.
+
 ### `renderer_init` — GPU handles do not survive a reconnect
 
 A connection is one **session**. A Wayland transport cannot be repaired once it breaks,
diff --git a/src/backend/app.rs b/src/backend/app.rs
index e543d3c..6caa3d8 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -237,6 +237,27 @@ pub trait Application: Sized + 'static {
     fn layer(&self) -> Option<LayerSettings> {
         None
     }
+    /// For a [`layer`](Self::layer) app that is usually empty — a
+    /// notification stack — whether there is anything to show right now.
+    /// While this is `false` the Wayland runner destroys the layer surface
+    /// and its renderer, and it builds both again (a new renderer, so
+    /// [`renderer_init`](Self::renderer_init) runs) on the turn it turns
+    /// `true`. An always-mapped transparent overlay is not free: the
+    /// compositor blurs behind it whenever anything under it changes, and it
+    /// keeps a fullscreen client off direct scanout. Asked once a loop turn;
+    /// ignored for xdg windows. Default `true`: always mapped.
+    #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+    fn wants_surface(&self) -> bool {
+        true
+    }
+    /// The runner has just destroyed the surface and renderer because
+    /// [`wants_surface`](Self::wants_surface) said `false`. Image ids
+    /// uploaded from here on are queued for the NEXT renderer, so its
+    /// `renderer_init` has nothing of theirs to re-upload — unlike the
+    /// `renderer_init` after a lost connection, whose ids died with the old
+    /// renderer.
+    #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+    fn surface_hidden(&mut self) {}
     /// Declare the window a UTILITY window: a tool whose shape is decided by
     /// its contents. The compositor then never dictates a size to it (every
     /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 2215776..8260d95 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -76,6 +76,12 @@ pub struct EngineState<A: Application> {
 
     pub window: Option<XdgWindow>,
     pub layer_surface: Option<LayerSurface>,
+    /// This session's surface is a layer surface ([`Application::layer`]),
+    /// which is what makes [`Application::wants_surface`] apply.
+    pub is_layer_app: bool,
+    /// The app said [`Application::wants_surface`] = false and the layer
+    /// surface and renderer are gone until it says true.
+    pub layer_hidden: bool,
     pub surface: Option<wl_surface::WlSurface>,
     
     pub inner: Option<A>,
@@ -421,6 +427,11 @@ impl<A: Application> EngineState<A> {
                 tl.ack_grid_patch(serial);
             }
         }
+        // Nothing to present on: a layer surface the app has hidden whose
+        // renderer could not be rebuilt (`show_layer_surface`).
+        if self.renderer.is_none() {
+            return;
+        }
         let logical_w = self.logical_width;
         let logical_h = self.logical_height;
         let scale_factor = self.scale_factor;
@@ -615,6 +626,7 @@ impl<A: Application> Shell for EngineState<A> {
     }
 
     fn sync(&mut self) {
+        self.sync_surface_wanted();
         // Overflow-margin drift (configure-sized apps): the rim can change at
         // runtime — a popover overhanging the window frame — so re-derive the
         // surface from the stored frame whenever the app's answer moves. While
@@ -703,6 +715,98 @@ impl<A: Application> Shell for EngineState<A> {
 }
 
 impl<A: Application> EngineState<A> {
+    /// Give `surface` its layer-shell role from `ls` at `width` x `height`
+    /// and commit, which asks the compositor for the first configure. The
+    /// session start and [`Self::show_layer_surface`] share this.
+    fn attach_layer_role(&mut self, surface: &wl_surface::WlSurface, ls: &LayerSettings, width: u32, height: u32) {
+        let layer_shell = self
+            .layer_shell_state
+            .as_ref()
+            .expect("compositor does not support wlr-layer-shell");
+        let layer_surface = layer_shell.create_layer_surface(
+            &self.qh,
+            surface.clone(),
+            ls.layer,
+            Some(ls.namespace.clone()),
+            None,
+        );
+        layer_surface.set_anchor(ls.anchor);
+        layer_surface.set_exclusive_zone(ls.exclusive_zone);
+        layer_surface.set_keyboard_interactivity(ls.keyboard_interactivity);
+        let (t, r, b, l) = ls.margin;
+        layer_surface.set_margin(t, r, b, l);
+        layer_surface.set_size(width, height);
+        layer_surface.commit();
+        self.layer_surface = Some(layer_surface);
+    }
+
+    /// Follow [`Application::wants_surface`]: tear the layer surface down
+    /// when the app has nothing to show, build it again when it does. Once a
+    /// loop turn, before the present decision.
+    fn sync_surface_wanted(&mut self) {
+        if !self.is_layer_app {
+            return;
+        }
+        let want = self.inner.as_ref().unwrap().wants_surface();
+        if !want && !self.layer_hidden {
+            self.hide_layer_surface();
+        } else if want && self.layer_hidden {
+            self.show_layer_surface();
+        }
+    }
+
+    /// Destroy the renderer (its swapchain first, as at session end), then
+    /// the layer surface — SCTK destroys the role and then the `wl_surface`.
+    fn hide_layer_surface(&mut self) {
+        self.renderer = None;
+        self.layer_surface = None;
+        self.surface = None;
+        self.layer_hidden = true;
+        self.first_configure_received = false;
+        self.frame_callback_pending = false;
+        self.keepalive_pending = false;
+        self.redraw = false;
+        self.entered_outputs.clear();
+        self.applied_input_regions = None;
+        log::info!("[window_runner] nothing to show; layer surface unmapped");
+        self.inner.as_mut().unwrap().surface_hidden();
+    }
+
+    /// A fresh `wl_surface` with the app's layer role and a renderer on it.
+    /// The first configure then makes it presentable, exactly as at session
+    /// start. A renderer that cannot be made leaves the surface hidden.
+    fn show_layer_surface(&mut self) {
+        let app = self.inner.as_ref().unwrap();
+        let settings = app.settings();
+        let Some(ls) = app.layer() else { return };
+        let surface = self.compositor_state.create_surface(&self.qh);
+        let buffer_scale = if crate::scale::forced_scale().is_some() { 1 } else { self.scale_factor as i32 };
+        surface.set_buffer_scale(buffer_scale);
+        self.committed_buffer_scale = buffer_scale;
+        self.attach_layer_role(&surface, &ls, settings.width, settings.height);
+
+        let s = self.scale_factor as f32;
+        let (pw, ph) = ((settings.width as f32 * s) as u32, (settings.height as f32 * s) as u32);
+        let display_ptr = self.display_ptr as *mut std::ffi::c_void;
+        let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
+        self.surface = Some(surface);
+        match unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) } {
+            Ok(renderer) => self.renderer = Some(renderer),
+            Err(lost) => {
+                log::error!("[window_runner] cannot rebuild the renderer, staying unmapped: {lost}");
+                self.layer_surface = None;
+                self.surface = None;
+                return;
+            }
+        }
+        self.logical_width = settings.width as f32;
+        self.logical_height = settings.height as f32;
+        self.layer_hidden = false;
+        self.redraw = true;
+        log::info!("[window_runner] layer surface mapped again");
+        self.inner.as_mut().unwrap().renderer_init(self.renderer.as_mut().unwrap());
+    }
+
     /// One warm-down step: a frame callback and a commit with no buffer, so
     /// the compositor keeps servicing this surface's callbacks at vsync
     /// (sparse commits were measured getting theirs 22-128 ms late) while
@@ -1926,6 +2030,8 @@ fn run_session<'l, A: Application>(
         keyboard: None,
         window: None,
         layer_surface: None,
+        is_layer_app: false,
+        layer_hidden: false,
         surface: None,
         inner: None,
         renderer: None,
@@ -2028,25 +2134,8 @@ fn run_session<'l, A: Application>(
 
     let layer_settings = engine_state.inner.as_ref().unwrap().layer();
     if let Some(ls) = layer_settings {
-        let layer_shell = engine_state
-            .layer_shell_state
-            .as_ref()
-            .expect("compositor does not support wlr-layer-shell");
-        let layer_surface = layer_shell.create_layer_surface(
-            &qh,
-            surface.clone(),
-            ls.layer,
-            Some(ls.namespace.clone()),
-            None,
-        );
-        layer_surface.set_anchor(ls.anchor);
-        layer_surface.set_exclusive_zone(ls.exclusive_zone);
-        layer_surface.set_keyboard_interactivity(ls.keyboard_interactivity);
-        let (t, r, b, l) = ls.margin;
-        layer_surface.set_margin(t, r, b, l);
-        layer_surface.set_size(settings.width, settings.height);
-        layer_surface.commit();
-        engine_state.layer_surface = Some(layer_surface);
+        engine_state.is_layer_app = true;
+        engine_state.attach_layer_role(&surface, &ls, settings.width, settings.height);
     } else {
         let window = engine_state.xdg_shell_state.create_window(surface.clone(), WindowDecorations::None, &qh);
         window.set_title(&settings.title);