GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Every app holds its window open for the compositor's close dissolve
window_runner's exit path now asks the compositor to fade this client out
and waits for it before leaving the session loop, so every Application gets
a close dissolve with no per-app code. request_close_fade sends `fade-out`
on the control socket and returns the duration the compositor answers with;
the loop keeps dispatching (rather than sleeping) for that long, which
keeps the connection pumped and lets a last animation finish on screen
while the window dissolves.
The fade itself is the compositor's — it ramps this client's scene-node
opacity, which takes the backdrop blur, shadow and bevel down with the
window. A client fading its own pixels cannot do that: its surface stays
fully present however transparent it draws itself. Hence the wait rather
than an animation here.
The duration is read back instead of assumed, so the client and
`surface { fade out_ms }` cannot drift apart; the visible failure of a
drift — the window vanishing partway through its own dissolve — would read
as a rendering bug rather than as a disagreement about a number.
request_close_fade does its own socket call rather than going through
send_command, which reads to EOF with no deadline. This runs on the exit
path of every cce-ui app, and a compositor wedged mid-frame would otherwise
hang the quit forever. A connect failure, a timeout, or a reply that is not
a number all yield zero, which means exit now — so an old compositor
answering "unknown command" leaves quit behaviour exactly as it was.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/backend/window_runner.rs | 21 +++++++++++++++++++++
src/ipc.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 66 insertions(+)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index e1a6edf..d28a263 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -5950,6 +5950,27 @@ fn run_session<'l, A: Application>(
}
}
if engine_state.exit {
+ // The close dissolve. It is the COMPOSITOR that fades us — it
+ // ramps our scene subtree's opacity, which takes the backdrop
+ // blur, drop shadow and bevel down with the window; all this side
+ // has to do is not vanish before it finishes. So keep the surface
+ // mapped and the loop turning for exactly as long as the
+ // compositor asked for, then leave. Dispatching (rather than
+ // sleeping) keeps the connection pumped and lets any last
+ // animation finish on screen while the window dissolves.
+ let fade = crate::ipc::request_close_fade();
+ if !fade.is_zero() {
+ let until = std::time::Instant::now() + fade;
+ loop {
+ let left = until.saturating_duration_since(std::time::Instant::now());
+ if left.is_zero() {
+ break;
+ }
+ if event_loop.dispatch(left.min(ACTIVE_DISPATCH), &mut engine_state).is_err() {
+ break;
+ }
+ }
+ }
break;
}
diff --git a/src/ipc.rs b/src/ipc.rs
index ac2d678..03da669 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -27,3 +27,48 @@ pub fn send_command(prefix: &str, command: &str) -> std::io::Result<String> {
stream.read_to_string(&mut reply)?;
Ok(reply)
}
+
+/// Ask the compositor to dissolve this client's surfaces out, and return how
+/// long it says that will take.
+///
+/// The fade is the compositor's, not the app's: it ramps the opacity of the
+/// scene subtree, which carries the backdrop blur, the drop shadow and the
+/// bevel down with the window. A client fading its own pixels instead leaves
+/// its surface fully present, so the blur behind it hangs at full strength
+/// over a dissolving window — and any part of its drawing that is not plain
+/// vertex alpha (shader-lit plate rims, specular) does not fade at all.
+///
+/// The contract is that the caller keeps its surfaces mapped and its process
+/// alive for the returned duration and only then exits. `window_runner` does
+/// that for every [`Application`](crate::backend::window_runner::Application);
+/// an app driving its own event loop calls this itself. Zero — no compositor,
+/// nothing of ours on screen, or fading configured off — means exit now.
+pub fn request_close_fade() -> std::time::Duration {
+ // This runs on the exit path of every cce-ui app, so it does its own
+ // socket call rather than `send_command`: that one reads to EOF with no
+ // deadline, and a compositor wedged mid-frame would hang the quit
+ // forever. A second is far longer than an IPC round trip and short
+ // enough that a user who hit Close still sees the window go.
+ const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
+ let Ok(mut stream) = UnixStream::connect(socket_path("cce")) else {
+ return std::time::Duration::ZERO;
+ };
+ let _ = stream.set_write_timeout(Some(REPLY_TIMEOUT));
+ let _ = stream.set_read_timeout(Some(REPLY_TIMEOUT));
+ if stream.write_all(b"fade-out\n").is_err() {
+ return std::time::Duration::ZERO;
+ }
+ // The duration is the compositor's to decide (`surface { fade out_ms }`),
+ // so it is read back rather than assumed: the two sides would otherwise
+ // drift apart the moment the config changed, and the visible failure —
+ // the window vanishing partway through its own dissolve — reads as a
+ // rendering bug rather than a disagreement about a number.
+ let mut reply = String::new();
+ if stream.read_to_string(&mut reply).is_err() {
+ return std::time::Duration::ZERO;
+ }
+ let ms: u64 = reply.trim().parse().unwrap_or(0);
+ // The compositor clamps this already; clamped again here because a
+ // client must never be made to hang on a number from the other side.
+ std::time::Duration::from_millis(ms.min(2000))
+}