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

src/backend/app.rs (31.3K)

  1 //! The client contract: the `Application` trait and the plain types it
  2 //! speaks in (`WindowSettings`, `LayerSettings`, `LogicalPosition`, …).
  3 //! Moved out of `window_runner` unchanged; still re-exported from there
  4 //! (and from `engine`) at the old paths.
  5 
  6 use cosmic_text::FontSystem;
  7 use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
  8 #[cfg(not(target_arch = "wasm32"))]
  9 use crate::vk::VkRenderer;
 10 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 11 use super::window_runner::EngineState;
 12 use cursor_icon::CursorIcon;
 13 use super::tessellate::Vertex;
 14 pub use crate::draw::scene::Stage3D;
 15 
 16 #[derive(Debug, Clone)]
 17 pub struct WindowSettings {
 18     pub title: String,
 19     pub app_id: String,
 20     pub width: u32,
 21     pub height: u32,
 22     pub fullscreen: bool,
 23     pub min_size: Option<(u32, u32)>,
 24 }
 25 
 26 /// The edge a [`WindowAction::Resize`] grabs. On Wayland it is
 27 /// `xdg_toplevel`'s own enum, as it always was; elsewhere the driver's.
 28 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 29 pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge as WindowEdge;
 30 #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
 31 pub use super::driver::ResizeEdge as WindowEdge;
 32 
 33 /// A compositor-side window operation requested by the app: an interactive
 34 /// move or resize grab. Returned from [`Application::take_window_action`];
 35 /// the runner executes it with the serial of the most recent pointer press.
 36 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 37 pub enum WindowAction {
 38     Move,
 39     Resize(WindowEdge),
 40 }
 41 
 42 // Re-export the wlr-layer-shell types apps need to describe a layer surface.
 43 // Layer surfaces are a Wayland (wlr) notion: native only, like `layer()`.
 44 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 45 pub use smithay_client_toolkit::shell::wlr_layer::{
 46     Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
 47 };
 48 
 49 /// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
 50 /// surface (panels, overlays, notifications) instead of an xdg toplevel.
 51 /// Return one from [`Application::layer`] to select layer-shell.
 52 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 53 #[derive(Debug, Clone)]
 54 pub struct LayerSettings {
 55     pub layer: LayerKind,
 56     pub anchor: LayerAnchor,
 57     pub exclusive_zone: i32,
 58     pub keyboard_interactivity: LayerKeyboardInteractivity,
 59     /// (top, right, bottom, left) margins in logical pixels.
 60     pub margin: (i32, i32, i32, i32),
 61     pub namespace: String,
 62 }
 63 
 64 #[derive(Debug, Clone, Copy, PartialEq)]
 65 pub struct LogicalPosition {
 66     pub x: f32,
 67     pub y: f32,
 68 }
 69 
 70 impl LogicalPosition {
 71     pub fn new(x: f32, y: f32) -> Self {
 72         Self { x, y }
 73     }
 74 }
 75 
 76 #[derive(Debug, Clone, Copy, PartialEq)]
 77 pub struct LogicalSize {
 78     pub width: f32,
 79     pub height: f32,
 80 }
 81 
 82 impl LogicalSize {
 83     pub fn new(width: f32, height: f32) -> Self {
 84         Self { width, height }
 85     }
 86 }
 87 
 88 pub struct RenderContext<'a> {
 89     pub font_system: &'a mut FontSystem,
 90 }
 91 
 92 /// The app's handle for posting a message to itself: from a worker thread, a
 93 /// callback, a timer the app runs itself. Each message reaches
 94 /// [`Application::update`] on the UI thread and wakes an idle loop, so
 95 /// background results arrive without polling (see "`tick` is not a clock"
 96 /// in CLAUDE.md).
 97 ///
 98 /// It names no window system. Handed to [`Application::create`], it is what
 99 /// a client written against it can be run by any shell with; the Wayland
100 /// runner backs it with its calloop channel, and `From` converts both ways
101 /// for a client that still keeps a `calloop::channel::Sender` somewhere.
102 /// Elsewhere (the browser, macOS) it is a `std::sync::mpsc` sender whose
103 /// receiver the shell drains every turn — still `Send`, so app code that
104 /// hands it to a worker compiles unchanged — and a send wakes the shell's
105 /// loop through `set_wake`, as a calloop channel wakes the Wayland one.
106 pub struct AppSender<M> {
107     #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
108     inner: calloop::channel::Sender<M>,
109     #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
110     inner: std::sync::mpsc::Sender<M>,
111 }
112 
113 impl<M> AppSender<M> {
114     /// Post `msg` to the app. Fails, handing it back, only once the loop is
115     /// gone: the app has exited.
116     pub fn send(&self, msg: M) -> Result<(), std::sync::mpsc::SendError<M>> {
117         self.inner.send(msg)?;
118         #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
119         wake();
120         Ok(())
121     }
122 }
123 
124 /// Call the hook [`set_wake`] installed, if any.
125 #[cfg(target_arch = "wasm32")]
126 pub(crate) fn wake() {
127     WAKE.with(|w| {
128         if let Some(wake) = w.borrow().as_ref() {
129             wake();
130         }
131     });
132 }
133 
134 #[cfg(target_arch = "wasm32")]
135 thread_local! {
136     static WAKE: std::cell::RefCell<Option<Box<dyn Fn()>>> = const { std::cell::RefCell::new(None) };
137 }
138 
139 /// What an [`AppSender::send`] on this thread calls after posting: the
140 /// browser shell's "turn the loop soon". Per thread, and the page's app runs
141 /// on the one thread a page has; a send from a worker thread posts without
142 /// waking, and is drained at the next turn the page takes.
143 #[cfg(target_arch = "wasm32")]
144 pub fn set_wake(wake: Option<Box<dyn Fn()>>) {
145     WAKE.with(|w| *w.borrow_mut() = wake);
146 }
147 
148 /// Call the hook [`set_wake`] installed, if any, from whatever thread sent.
149 #[cfg(target_os = "macos")]
150 pub(crate) fn wake() {
151     let hook = WAKE.read().ok().and_then(|w| w.clone());
152     if let Some(wake) = hook {
153         wake();
154     }
155 }
156 
157 #[cfg(target_os = "macos")]
158 static WAKE: std::sync::RwLock<Option<std::sync::Arc<dyn Fn() + Send + Sync>>> = std::sync::RwLock::new(None);
159 
160 /// What an [`AppSender::send`] calls after posting: the AppKit shell's "turn
161 /// the loop soon". Process-wide, unlike the browser's: an AppKit app's
162 /// workers send from their own threads, and the hook hops to the main thread
163 /// itself, as a calloop channel's ping wakes the Wayland loop from anywhere.
164 #[cfg(target_os = "macos")]
165 pub fn set_wake(wake: Option<std::sync::Arc<dyn Fn() + Send + Sync>>) {
166     if let Ok(mut w) = WAKE.write() {
167         *w = wake;
168     }
169 }
170 
171 impl<M> Clone for AppSender<M> {
172     fn clone(&self) -> Self {
173         Self { inner: self.inner.clone() }
174     }
175 }
176 
177 impl<M> std::fmt::Debug for AppSender<M> {
178     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179         f.write_str("AppSender")
180     }
181 }
182 
183 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
184 impl<M> From<calloop::channel::Sender<M>> for AppSender<M> {
185     fn from(inner: calloop::channel::Sender<M>) -> Self {
186         Self { inner }
187     }
188 }
189 
190 #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
191 impl<M> From<std::sync::mpsc::Sender<M>> for AppSender<M> {
192     fn from(inner: std::sync::mpsc::Sender<M>) -> Self {
193         Self { inner }
194     }
195 }
196 
197 #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
198 impl<M> From<AppSender<M>> for calloop::channel::Sender<M> {
199     fn from(sender: AppSender<M>) -> Self {
200         sender.inner
201     }
202 }
203 
204 pub trait Application: Sized + 'static {
205     type Message: Send + Clone + 'static;
206 
207     /// Build the app. `sender` posts messages to [`update`](Self::update) from any thread, and
208     /// waking the loop if it sleeps; it converts into calloop's `Sender` for an app that keeps
209     /// that type (`let tx: calloop::channel::Sender<_> = sender.into();`). It names no window
210     /// system, so every shell (Wayland, AppKit, the browser) builds the app the same way.
211     ///
212     /// Required since 2026-10-07: the legacy `new(qh, sender)` — whose Wayland queue handle no
213     /// client ever used — and the default that panicked when neither was implemented are gone,
214     /// so an app without a constructor is a compile error rather than a crash at startup.
215     fn create(sender: AppSender<Self::Message>) -> Self;
216     fn settings(&self) -> WindowSettings;
217     /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
218     /// instead of an xdg toplevel. Defaults to `None` (a normal window).
219     #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
220     fn layer(&self) -> Option<LayerSettings> {
221         None
222     }
223     /// For a [`layer`](Self::layer) app that is usually empty — a
224     /// notification stack — whether there is anything to show right now.
225     /// While this is `false` the Wayland runner destroys the layer surface,
226     /// keeping its renderer detached, and on the turn it turns `true` builds a
227     /// new surface and moves the same renderer onto it — so image ids stay
228     /// good and [`renderer_init`](Self::renderer_init) does not run. An always-mapped transparent overlay is not free: the
229     /// compositor blurs behind it whenever anything under it changes, and it
230     /// keeps a fullscreen client off direct scanout. Asked once a loop turn;
231     /// ignored for xdg windows. Default `true`: always mapped.
232     #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
233     fn wants_surface(&self) -> bool {
234         true
235     }
236     /// Declare the window a UTILITY window: a tool whose shape is decided by
237     /// its contents. The compositor then never dictates a size to it (every
238     /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
239     /// become the surface's own initial size), offers no resize affordance
240     /// (the whole border band moves the window), and never saves geometry
241     /// for it, so a stale remembered size can't be restored over what the
242     /// app asks for. Declared over the cce window-management protocol at
243     /// window creation; on a compositor too old to know the request this is
244     /// silently a plain floating window. Defaults to `false`.
245     fn utility(&self) -> bool {
246         false
247     }
248     /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
249     /// compositor world-anchors the surface to the virtual desktop and
250     /// pans/zooms it per frame like window content; the app renders only
251     /// when handed a patch (see [`Application::grid_patch`]). The surface
252     /// becomes input-transparent and lives behind all windows. Needs
253     /// manager v6; on an older compositor the declaration is skipped.
254     /// Defaults to `false`.
255     fn grid(&self) -> bool {
256         false
257     }
258     /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
259     /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
260     /// Called right before the frame that must show it; the runner has
261     /// already resized the surface to `(w*scale, h*scale)` and acks the
262     /// patch so the coming commit is latched at the new anchor.
263     fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
264     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
265     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
266     /// How long the runner may sleep between `tick`s while the window is
267     /// idle — nothing to draw, no animation, no key held, no frame callback
268     /// outstanding. `None` (the default) lets it sleep until a Wayland
269     /// event or a message on the app's calloop `Sender` arrives, bounded by
270     /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
271     /// something the loop cannot see — a `std::sync::mpsc` receiver drained
272     /// in `tick`, say — because with the default that poll waits for the
273     /// next unrelated event. The better fix is to send through the calloop
274     /// `Sender` handed to `new`, which wakes the loop by itself.
275     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
276         None
277     }
278     /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
279     /// tray-hover highlights). Deliberately separate from the single paint path.
280     fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
281     /// The part of the surface that changed since the last frame this app
282     /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
283     /// rendered frame right after `display_list`. `None` — the default —
284     /// means all of it. Returning a rect makes the frame a partial one: only
285     /// that rect is repainted and only it is reported to the compositor as
286     /// damage, which is what keeps a small change on a very large surface
287     /// (an image dragged across the desktop grid) from costing a full
288     /// repaint on both sides. The app vouches for the rect: anything that
289     /// changed outside it keeps its old pixels. A frame that was skipped is
290     /// the runner's to make up — the next one is painted in full.
291     fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
292         None
293     }
294     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
295         None
296     }
297 
298     /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
299     /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
300     /// runner sizes the surface `margin` wider/taller than the configured
301     /// window size, publishes the top-left rect as the xdg window geometry
302     /// (what the compositor tiles, borders, and snaps) and an input region of
303     /// the frame plus any open popover rects — an overhanging menu stays
304     /// clickable while empty rim falls through to whatever is behind.
305     ///
306     /// Right/bottom ONLY, deliberately: the surface grows away from its
307     /// origin, so the frame never moves relative to the surface and pointer
308     /// coordinates stay valid across the resize (a leading rim shifts the
309     /// surface under an unmoved cursor, and the compositor's stale pointer
310     /// state then drops the very next click). Frame coords == surface coords:
311     /// no input translation, no paint shift — the app's only obligation is to
312     /// lay out against the frame (`display_list`'s `size` minus the margin);
313     /// content emitted past the frame edge renders in the rim instead of
314     /// clipping at the buffer edge.
315     ///
316     /// The value may change at runtime (return the popover overhang while a
317     /// menu is open, 0 otherwise): the engine re-derives the surface from the
318     /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
319     /// steps) so an animating popover doesn't resize the surface per frame.
320     /// xdg toplevels only (layer surfaces ignore it).
321     fn overflow_margin(&self) -> u32 {
322         0
323     }
324 
325     fn desired_size(&self) -> Option<(u32, u32)> {
326         None
327     }
328     
329     /// Publish this app's accessibility tree to screen readers (AT-SPI on Wayland), with
330     /// cce-ui built with its `a11y` feature. Default false while the adapter is proven
331     /// (`docs/rfc-accessibility-locale.md`, phase 2); `CCE_A11Y=1` turns it on for any app.
332     fn publishes_accessibility(&self) -> bool {
333         false
334     }
335 
336     /// What the app shows that its [`ui_context`](Self::ui_context) does not: the nodes of
337     /// an accessibility tree, for an app that draws without widgets (a status bar module, a
338     /// terminal, a map) or draws parts of its window itself. Pushed as AccessKit nodes into
339     /// `nodes` (`crate::a11y::AppNodes`); the widgets and an open context menu are added
340     /// around them (`crate::a11y::app_tree`). Default: nothing.
341     fn accessibility(&mut self, nodes: &mut crate::a11y::AppNodes) {
342         let _ = nodes;
343     }
344 
345     /// An assistive tool asked something of one of the app's own nodes
346     /// ([`accessibility`](Self::accessibility)), named by the app's own number `n`
347     /// (`AppNodes::id(n)`): a reader's edit of a field the app draws
348     /// (`AppAction::SetText`, for one published with `AppNodes::text_field`), a request
349     /// for the keyboard, a press. Do what the user doing it would, and answer whether
350     /// anything changed (the window is then redrawn and the tree republished). Default:
351     /// nothing.
352     fn accessibility_action(&mut self, n: u64, action: crate::a11y::AppAction) -> bool {
353         let _ = (n, action);
354         false
355     }
356 
357     fn ui_context(&self) -> Option<&crate::context::UiContext> {
358         None
359     }
360 
361     fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
362         None
363     }
364 
365     /// Where a widget's open popover is DRAWN, as an offset from the rect it
366     /// reports (`popover_rect`). A widget reports in the coordinates it was
367     /// laid out in; an app that lays its page out unscrolled and shifts what
368     /// it emits draws the popover `scroll` px away from there, and returns
369     /// `(0.0, -scroll_y)` here for the page's widgets. Everything the engine
370     /// derives from a popover rect reads it through this: the text-occlusion
371     /// clamp, the overflow input region, and the region sent to the
372     /// compositor. Without it a menu opened on a scrolled page had the page's
373     /// text drawn over it, and cut a menu-shaped hole in the text one scroll
374     /// offset away.
375     fn popover_offset(&self, _id: crate::widget::WidgetId) -> (f32, f32) {
376         (0.0, 0.0)
377     }
378 
379     /// Whether a left-press at (px, py) should start a compositor window drag. Every root
380     /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
381     /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
382     fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
383         false
384     }
385     
386     fn clear_color(&self) -> [f32; 4] {
387         [0.0, 0.0, 0.0, 0.0]
388     }
389 
390     #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
391     fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
392 
393     fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
394         (width, height)
395     }
396     
397     /// Mime types this app accepts from a drag, in the app's own preference
398     /// order (the source's order is ignored — a browser lists `text/html`
399     /// before `text/uri-list` and which is more useful is the app's call).
400     /// The default is empty: the app accepts nothing and drags over it read
401     /// as "can't drop here", which is what every client did before drops
402     /// existed. Opting in also requires [`Application::handle_drop`].
403     fn drop_mimes(&self) -> &'static [&'static str] {
404         &[]
405     }
406 
407     /// A completed drop: `data` is everything the source wrote for `mime`,
408     /// and `pos` is where it was released in the app's logical coordinates.
409     /// Runs on the main loop, after the transfer finished — this is not the
410     /// place to block, since the compositor is waiting on the next frame.
411     fn handle_drop(
412         &mut self,
413         _mime: &str,
414         _data: &[u8],
415         _pos: LogicalPosition,
416         _needs_rebuild: &mut bool,
417     ) {
418     }
419 
420     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
421     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
422     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
423     /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
424     /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
425     /// so direct-manipulation zoom is `content_scale *= factor`. Return true
426     /// to consume; returning false falls back to the engine's legacy
427     /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
428     /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
429     /// without implementing this.
430     fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
431         false
432     }
433     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
434 
435     /// Undo, after the focused widget declined the chord (a text box that is
436     /// editing takes it for its own typing). Return true when something was
437     /// undone; false lets the key fall through to `handle_key_input` like any
438     /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
439     /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
440     /// the history on `cce_ui::history::History`.
441     fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
442         false
443     }
444 
445     /// Redo — see [`undo`](Self::undo).
446     fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
447         false
448     }
449 
450     /// The toolkit's keyboard navigation in plate terms: Tab and Shift+Tab move
451     /// focus to the next / previous plate or well in reading order
452     /// (`UiContext::focus_step`), a press (Enter / Space) acts on the focused
453     /// plate, a well opens for typing when focused. **On by default** (since
454     /// 2026-10-08, `docs/rfc-accessibility-locale.md` phase 3): the keyboard is
455     /// how a person who cannot use a pointer reaches anything. It needs the
456     /// app's `ui_context_mut`, so an app without a widget tree is untouched
457     /// (Tab reaches its `handle_key_input` as before); a focused widget that
458     /// takes Tab itself keeps it (`WidgetHost::keeps_tab`). An app that routes
459     /// Tab itself (its own field order, a completion popup) returns false.
460     fn plate_navigation(&self) -> bool {
461         true
462     }
463 
464     /// Wait for the NEXT compositor when this one goes away, instead of
465     /// exiting. Default false, which is right for any window the compositor
466     /// saves and restores: its successor respawns the app itself, and a
467     /// client that rejoined too came up beside its own copy (see
468     /// [`after_session`]). Return true from a process the compositor does NOT
469     /// restore and that must outlive it — a systemd user service like the
470     /// status bar or the notifier, whose D-Bus names (the tray's
471     /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
472     /// depend on. Exiting took those names down at every logout and
473     /// compositor restart, and Dropbox, starting into the gap, found no tray.
474     fn outlives_compositor(&self) -> bool {
475         false
476     }
477 
478     /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
479     /// caches its geometry until its own rebuild flag (relief carves collected
480     /// in a view pass, widget lists built on layout) raises that flag here, so
481     /// the new ring is drawn; an app that paints fresh every frame needs
482     /// nothing. Default: nothing.
483     fn focus_stepped(&mut self) {}
484     /// Keyboard focus entered/left the window (the compositor keyboard-focuses
485     /// the focused window, so this is the "am I the focused window" signal —
486     /// e.g. for focus-dependent chrome). Default: ignore.
487     fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
488 
489     fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
490 
491     /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
492     /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
493     /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
494     /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
495     /// text renders from the list when [`display_list_text`](Application::display_list_text)
496     /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
497     /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
498     fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
499         None
500     }
501 
502     /// Opt in to render the display list's `Prim::Text` items through the glyph pass
503     /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
504     /// app's ENTIRE frame — geometry and text — is then one
505     /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
506     /// only draws geometry, or none at all).
507     ///
508     /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
509     /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
510     /// open popover's plate clips list text beneath it on both paths.
511     fn display_list_text(&self) -> bool {
512         false
513     }
514 
515     /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
516     /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
517     /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
518     /// exists only among installed system fonts is silently invisible — buffers shaped
519     /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
520     /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
521     /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
522     /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
523     /// so both databases load identically. Consulted once, at GPU init.
524     fn load_system_fonts(&self) -> bool {
525         false
526     }
527 
528     /// Called once per renderer, right after it is created and before its
529     /// first frame (a reconnect's replacement too — see "`renderer_init`"
530     /// in CLAUDE.md): create persistent renderer resources here (3D meshes
531     /// via [`VkRenderer::create_mesh`]). Most 2D apps never need this. The
532     /// default hands the renderer to the portable [`init_3d`](Self::init_3d),
533     /// so an app written against that runs here unchanged.
534     #[cfg(not(target_arch = "wasm32"))]
535     fn renderer_init(&mut self, renderer: &mut VkRenderer) {
536         self.init_3d(renderer);
537     }
538 
539     /// Direct renderer staging, called every frame after the engine's own text
540     /// prep and immediately before the frame is drawn: stage 3D scene panes
541     /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
542     /// prepare app-shaped text (`prepare_text` — an app that returns `false`
543     /// from [`display_list_text`](Application::display_list_text) fully owns
544     /// the renderer's text state, the engine never touches it). Return `true`
545     /// to request another frame immediately (e.g. while a path tracer is still
546     /// accumulating samples). The default is the portable
547     /// [`stage_3d`](Self::stage_3d).
548     #[cfg(not(target_arch = "wasm32"))]
549     fn stage_renderer(&mut self, renderer: &mut VkRenderer, size: LogicalSize, scale: f64) -> bool {
550         self.stage_3d(renderer, size, scale)
551     }
552 
553     /// The portable [`renderer_init`](Self::renderer_init): once per
554     /// renderer, before its first frame, through [`Stage3D`] — the 3D half
555     /// every renderer has, the Vulkan one natively and the WebGPU one in a
556     /// browser. Make the app's meshes here. Called by the browser shell, and
557     /// natively by `renderer_init`'s default.
558     fn init_3d(&mut self, _stage: &mut dyn Stage3D) {}
559 
560     /// The portable [`stage_renderer`](Self::stage_renderer): every frame,
561     /// just before it is drawn, stage the scene through [`Stage3D`]. `size`
562     /// is the window's logical size and `scale` its pixel ratio; a scissor is
563     /// physical px. Return `true` to ask for another frame at once.
564     fn stage_3d(&mut self, _stage: &mut dyn Stage3D, _size: LogicalSize, _scale: f64) -> bool {
565         false
566     }
567 
568     /// The surface was resized (or the scale factor changed): `width`/`height`
569     /// are the new logical size. The renderer has already been resized; use
570     /// this for stateful relayout that can't wait for the next paint callback.
571     fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
572 
573     /// Whether the runner's built-in client-side decorations apply: the
574     /// titlebar move band, the movable-root plate drag regions, and — when
575     /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
576     /// the rect-edge resize grabs and their edge cursors. Return `false` for a
577     /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
578     /// drive moves/resizes yourself via
579     /// [`take_window_action`](Application::take_window_action).
580     fn standard_csd(&self) -> bool {
581         true
582     }
583 
584     /// Whether the standard CSD claims the outer 8px of the surface as resize
585     /// grabs (with matching edge cursors). Off by default: under the cce
586     /// compositor the server already provides a resize band just *outside* the
587     /// window, so enabling this gives a window two adjacent 8px gutters driven
588     /// by different code paths — and only the compositor's snaps to the
589     /// desktop grid. It also costs the app clicks, since a press inside the
590     /// band starts a grab and never reaches the widgets underneath.
591     ///
592     /// Turn it on for a window that must be resizable by its own edges under a
593     /// compositor that provides no such affordance. Only consulted when
594     /// [`standard_csd`](Application::standard_csd) is on.
595     fn csd_resize_borders(&self) -> bool {
596         false
597     }
598 
599     /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
600     /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
601     /// through the compositor (or via explicitly-declared handles —
602     /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
603     /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
604     /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
605     /// only consulted when `standard_csd()` is on.
606     fn csd_titlebar_move(&self) -> bool {
607         false
608     }
609 
610     /// Override the pointer cursor at (x, y). `None` falls back to the
611     /// runner's standard CSD edge cursors (or `Default` when
612     /// [`standard_csd`](Application::standard_csd) is off).
613     fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
614         None
615     }
616 
617     /// Polled after each pointer frame is dispatched: return a
618     /// [`WindowAction`] to start an interactive move/resize grab with the
619     /// serial of the most recent pointer press. This is take-semantics — the
620     /// implementation should clear its pending action when returning it.
621     fn take_window_action(&mut self) -> Option<WindowAction> {
622         None
623     }
624 
625     /// Called once when the event loop ends (window closed, app-requested
626     /// exit): last-chance work like autosave. The surface is still alive.
627     fn on_exit(&mut self) {}
628 }
629 
630 
631 #[cfg(all(test, not(any(target_arch = "wasm32", target_os = "macos"))))]
632 mod app_sender_tests {
633     use super::AppSender;
634     use calloop::channel::{channel, Event};
635 
636     #[test]
637     fn an_app_sender_delivers_through_the_loop_and_fails_once_it_is_gone() {
638         let (tx, rx) = channel::<u32>();
639         let sender = AppSender::from(tx);
640         let worker = sender.clone();
641         std::thread::spawn(move || worker.send(7).unwrap()).join().unwrap();
642         sender.send(8).unwrap();
643 
644         let mut event_loop = calloop::EventLoop::<Vec<u32>>::try_new().unwrap();
645         let token = event_loop
646             .handle()
647             .insert_source(rx, |event, _, got: &mut Vec<u32>| {
648                 if let Event::Msg(m) = event {
649                     got.push(m);
650                 }
651             })
652             .unwrap();
653         let mut got = Vec::new();
654         event_loop.dispatch(Some(std::time::Duration::ZERO), &mut got).unwrap();
655         assert_eq!(got, vec![7, 8]);
656 
657         // The loop dropping its end is the app having exited: the message
658         // comes back to the sender rather than vanishing.
659         event_loop.handle().remove(token);
660         assert_eq!(sender.send(9).unwrap_err().0, 9);
661 
662         // And the escape hatch for a client still holding calloop's type.
663         let _raw: calloop::channel::Sender<u32> = sender.into();
664     }
665 }