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

commit7a10f27af173f9c7e1ea62d164b6d68bb01c753c
parent143a77c1f4
authorClaude <noreply@anthropic.com>
date2026-10-03 23:57
feat(app): Application::create(AppSender), a constructor with no Wayland in it

Application::new took the session's Wayland QueueHandle and a calloop
Sender, so the client contract itself named the window system: no other
shell could construct an app. No client ever used the queue handle (all
26 sibling impls ignore it), and the sender is only ever cloned into
worker threads and sent on.

AppSender<M> is cce-ui's own handle for that: send, Clone, Send, Debug,
and From both ways with calloop::channel::Sender for a client that still
stores calloop's type. Application::create(sender) builds the app from
it.

Clients move one at a time: the runner still calls new, whose default
now forwards to create, so an app implements either. Implementing
neither panics at startup naming the app. new goes once nothing
implements it, as VkRenderer::new did. register_sources stays as it is,
a calloop-only hook of the Wayland shell.

The demo, cce-ramp and cce-relief move to create, so the reference app
new clients copy shows the portable form. Verified as before: same test
results plus the new AppSender test, the plate golden byte-identical,
the demo identical to the pixel over the 18 scripted sway steps (now
through new -> create), and both bins launch and draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md             |  16 ++++++--
 src/backend/app.rs    | 106 +++++++++++++++++++++++++++++++++++++++++++++++++-
 src/backend/mod.rs    |   2 +-
 src/bin/cce-ramp.rs   |   8 +---
 src/bin/cce-relief.rs |   8 +---
 src/engine.rs         |   2 +-
 src/main.rs           |   8 +---
 7 files changed, 126 insertions(+), 24 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 511be16..9d65066 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -69,19 +69,29 @@ Every client implements `Application` (`src/backend/app.rs`, re-exported from
 (e.g. `cce-status-interface`) — do not invent a new structure.
 
 Key methods (see the trait def in `backend/app.rs`):
-- `new`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
+- `create(sender)`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
   layer-shell surfaces like the status bar), `update(msg, needs_rebuild, exit)`, `tick(dt, …)`.
   **`tick` is not a clock.** Since 2026-09-11 the runner sleeps between ticks while the
   window is idle (no redraw pending, no animation, no key held, no warm-down) — up to
   `IDLE_DISPATCH` (1 s, `CCE_UI_IDLE_MS` overrides) — and is woken by Wayland events and
-  by messages on the calloop `Sender` handed to `new`. It used to tick a flat 16 ms
+  by messages on the `AppSender` handed to `create`. It used to tick a flat 16 ms
   forever: every client awake 60×/s doing nothing. So: deliver background results
-  through that `Sender`, never by draining a `std::sync::mpsc` in `tick`; if a widget
+  through that sender, never by draining a `std::sync::mpsc` in `tick`; if a widget
   or app must poll something the loop cannot see, say so — a widget returns `true`
   from `tick` while the session is live (ColorSelector's picker), an app overrides
   `Application::idle_poll_interval` (cce-authenticator, cce-system-interface,
   cce-designer while a pane is detached). Any animation keeps the frame cadence by
   itself because it reports a change.
+- **Construction is `create(sender: AppSender<Self::Message>)`** (since 2026-10-03).
+  `AppSender` is cce-ui's own handle — `send`, `Clone`, `Send`, and `From` both ways
+  with `calloop::channel::Sender` for a client that still stores calloop's type — so
+  the constructor names no window system, which is what lets a second shell (macOS,
+  the browser) run the same `Application`. The legacy `new(qh, sender)` still works:
+  the runner calls `new`, whose default forwards to `create`, so a client implements
+  ONE of the two and moves when it likes (no client ever used `qh`). Implementing
+  neither panics at startup naming the app. `new` is removed once no client
+  implements it, the way `VkRenderer::new` went. `register_sources` stays a
+  calloop-only hook: it is the Wayland shell's, not part of the portable contract.
 - **Draw**: `view` / `view_rounded_quads` / `view_vectors` / `overlay_quads` push legacy
   primitive tuples; `text_items()` returns text; `custom_vertices()` appends raw vertices (e.g.
   graph geometry). `display_list()` is the new opt-in path (see below).
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 7c16a93..2720ad7 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -77,10 +77,78 @@ pub struct RenderContext<'a> {
     pub font_system: &'a mut FontSystem,
 }
 
+/// The app's handle for posting a message to itself: from a worker thread, a
+/// callback, a timer the app runs itself. Each message reaches
+/// [`Application::update`] on the UI thread and wakes an idle loop, so
+/// background results arrive without polling (see "`tick` is not a clock"
+/// in CLAUDE.md).
+///
+/// It names no window system. Handed to [`Application::create`], it is what
+/// a client written against it can be run by any shell with; the Wayland
+/// runner backs it with its calloop channel, and `From` converts both ways
+/// for a client that still keeps a `calloop::channel::Sender` somewhere.
+pub struct AppSender<M> {
+    inner: calloop::channel::Sender<M>,
+}
+
+impl<M> AppSender<M> {
+    /// Post `msg` to the app. Fails, handing it back, only once the loop is
+    /// gone: the app has exited.
+    pub fn send(&self, msg: M) -> Result<(), std::sync::mpsc::SendError<M>> {
+        self.inner.send(msg)
+    }
+}
+
+impl<M> Clone for AppSender<M> {
+    fn clone(&self) -> Self {
+        Self { inner: self.inner.clone() }
+    }
+}
+
+impl<M> std::fmt::Debug for AppSender<M> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("AppSender")
+    }
+}
+
+impl<M> From<calloop::channel::Sender<M>> for AppSender<M> {
+    fn from(inner: calloop::channel::Sender<M>) -> Self {
+        Self { inner }
+    }
+}
+
+impl<M> From<AppSender<M>> for calloop::channel::Sender<M> {
+    fn from(sender: AppSender<M>) -> Self {
+        sender.inner
+    }
+}
+
 pub trait Application: Sized + 'static {
     type Message: Send + Clone + 'static;
 
-    fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
+    /// Build the app. Implement this or the legacy [`new`](Self::new), not
+    /// both: the runner calls `new`, whose default forwards here. `create`
+    /// takes no Wayland type, so it is the constructor another shell can
+    /// call; `new` goes once no client implements it.
+    ///
+    /// Implementing neither panics at startup, naming the app: the price of
+    /// letting clients move one at a time.
+    fn create(sender: AppSender<Self::Message>) -> Self {
+        let _ = sender;
+        panic!(
+            "{}: implement Application::create (or the legacy Application::new)",
+            std::any::type_name::<Self>()
+        )
+    }
+
+    /// The legacy constructor, from before the runner had a second shell in
+    /// view: the session's Wayland queue handle (no client ever used it) and
+    /// its calloop sender. Prefer [`create`](Self::create); the default here
+    /// forwards to it.
+    fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        let _ = qh;
+        Self::create(AppSender::from(sender))
+    }
     fn settings(&self) -> WindowSettings;
     /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
     /// instead of an xdg toplevel. Defaults to `None` (a normal window).
@@ -425,3 +493,39 @@ pub trait Application: Sized + 'static {
     fn on_exit(&mut self) {}
 }
 
+
+#[cfg(test)]
+mod app_sender_tests {
+    use super::AppSender;
+    use calloop::channel::{channel, Event};
+
+    #[test]
+    fn an_app_sender_delivers_through_the_loop_and_fails_once_it_is_gone() {
+        let (tx, rx) = channel::<u32>();
+        let sender = AppSender::from(tx);
+        let worker = sender.clone();
+        std::thread::spawn(move || worker.send(7).unwrap()).join().unwrap();
+        sender.send(8).unwrap();
+
+        let mut event_loop = calloop::EventLoop::<Vec<u32>>::try_new().unwrap();
+        let token = event_loop
+            .handle()
+            .insert_source(rx, |event, _, got: &mut Vec<u32>| {
+                if let Event::Msg(m) = event {
+                    got.push(m);
+                }
+            })
+            .unwrap();
+        let mut got = Vec::new();
+        event_loop.dispatch(Some(std::time::Duration::ZERO), &mut got).unwrap();
+        assert_eq!(got, vec![7, 8]);
+
+        // The loop dropping its end is the app having exited: the message
+        // comes back to the sender rather than vanishing.
+        event_loop.handle().remove(token);
+        assert_eq!(sender.send(9).unwrap_err().0, 9);
+
+        // And the escape hatch for a client still holding calloop's type.
+        let _raw: calloop::channel::Sender<u32> = sender.into();
+    }
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 1160cfd..81b1861 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -6,6 +6,6 @@ pub mod text;
 pub mod window_runner;
 
 pub use window_runner::{
-    EngineState, WindowSettings, LogicalPosition, LogicalSize, Application, run,
+    EngineState, WindowSettings, LogicalPosition, LogicalSize, Application, AppSender, run,
     Vertex, LineCap, PressedKey, get_text_buffer,
 };
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index 3772a68..4c9a08f 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -13,14 +13,13 @@
 //! Architecture mirrors the reference `DemoApp` (`src/main.rs`): display-list
 //! frame, routed events, in-frame popovers.
 
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
     Adapted, Button, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Ramp,
     WidgetHost,
 };
-use wayland_client::QueueHandle;
 
 /// Transparent rim between the surface edge and the plate: room for the ramp's
 /// key pegs (r=28, +45 selected halo) to render outside the window frame
@@ -92,10 +91,7 @@ impl RampPopup {
 impl Application for RampPopup {
     type Message = RampMsg;
 
-    fn new(
-        _qh: &QueueHandle<EngineState<Self>>,
-        _sender: calloop::channel::Sender<Self::Message>,
-    ) -> Self {
+    fn create(_sender: AppSender<Self::Message>) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
         let mut ramp = Ramp::new();
 
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 0600861..6f7c369 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -64,7 +64,7 @@
 //! DE-wide loader are unchanged — free-form specs from cce-designer or a
 //! hand-edited config still load everywhere.
 
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
@@ -73,7 +73,6 @@ use cce_ui::widget::{
     Adapted, Button, Dropdown, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta,
     Slider, WidgetHost, WidgetId,
 };
-use wayland_client::QueueHandle;
 
 const HEADER_FONT_SIZE: f32 = 13.0;
 const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
@@ -1383,10 +1382,7 @@ impl BevelPopup {
 impl Application for BevelPopup {
     type Message = BevelMsg;
 
-    fn new(
-        _qh: &QueueHandle<EngineState<Self>>,
-        _sender: calloop::channel::Sender<Self::Message>,
-    ) -> Self {
+    fn create(_sender: AppSender<Self::Message>) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
         // Force the lazy config load BEFORE reading the registry: the knob
         // strings are read directly (no getter wraps them), so nothing else
diff --git a/src/engine.rs b/src/engine.rs
index 106fe9d..68a3a8d 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -1,6 +1,6 @@
 pub use crate::backend::window_runner::{
     Vertex, LineCap, WindowSettings, LogicalPosition, LogicalSize,
-    RenderContext, Application, PressedKey, EngineState, run,
+    RenderContext, Application, AppSender, PressedKey, EngineState, run,
     WindowAction, xdg_toplevel, PointerCursorIcon as CursorIcon,
     LayerSettings, LayerKind, LayerAnchor, LayerKeyboardInteractivity,
     quad_vertices, quad_vertices_with_clip, quad_vertices_clipped, line_vertices,
diff --git a/src/main.rs b/src/main.rs
index c72407d..9c5cf5e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,7 +19,7 @@
 //!    plate is prims, not a root plate container; popovers draw INTO the frame (there is no popup
 //!    surface); app state — not any widget tree — is the source of truth.
 
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::arena::Arena;
 use cce_ui::scene::layout::{
     compute_layout, FitMode, LayoutBox, Length, Rect, Size as LSize, Style,
@@ -29,7 +29,6 @@ use cce_ui::widget::{
     Adapted, Button, Dropdown, ImageView, WidgetHost, WidgetId, ElementState, Event, KeyEvent,
     MouseButton, MouseScrollDelta, Slider, TextBox, Toggle,
 };
-use wayland_client::QueueHandle;
 
 #[derive(Debug, Clone)]
 enum DemoMessage {
@@ -143,10 +142,7 @@ impl DemoApp {
 impl Application for DemoApp {
     type Message = DemoMessage;
 
-    fn new(
-        _qh: &QueueHandle<EngineState<Self>>,
-        _sender: calloop::channel::Sender<Self::Message>,
-    ) -> Self {
+    fn create(_sender: AppSender<Self::Message>) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
         // One procedurally generated gradient (no asset dependency), uploaded
         // once and SHARED by both ImageViews — the widget borrows ids;