GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);