frust_shell_desktop/extensions.rs
1//! The per-OS extension seam: the hooks a native desktop shell crate
2//! (`frust-shell-macos`/`-windows`/`-linux`) plugs into this shared winit core.
3//!
4//! This crate owns the cross-platform half of a desktop app — the event loop,
5//! the frame pipeline, input/IME/theme translation, accessibility — and knows
6//! nothing about menu bars, Dock reopen semantics, taskbar identity, or
7//! `WM_CLASS`. [`DesktopExtensions`] is where that native half attaches: a
8//! trait of eight hooks, each with a no-op default, invoked at the points in the
9//! loop where a platform integration has something to say. [`NoExtensions`]
10//! is the whole-set no-op, and is what [`run_desktop`](crate::run_desktop) —
11//! the zero-config dev preview — installs, so the preview path pays nothing for
12//! a seam it doesn't use.
13//!
14//! # Static dispatch, not `dyn`
15//!
16//! [`run_desktop_with`](crate::run_desktop_with) is generic over
17//! `E: DesktopExtensions` rather than taking a `Box<dyn DesktopExtensions>`:
18//! [`DesktopExtensions::on_event_loop_builder`] hands out a
19//! [`DesktopEventLoopBuilder`], which a platform crate extends through winit's
20//! own `EventLoopBuilderExt*` traits — object safety would be a live constraint
21//! there, and there is exactly one extension per binary anyway (chosen by
22//! `cfg(target_os)` at the facade), so a vtable would buy nothing.
23//!
24//! # Which hooks receive the window
25//!
26//! Two do. [`DesktopExtensions::on_window_created`] hands it over as an
27//! `&Arc<Window>` an extension is expected to **clone and retain** if it needs
28//! one later, and [`DesktopExtensions::on_platform_view_commands`] hands over
29//! the same handle again because it fires from inside the frame path, where this
30//! core is already holding a live window and a hosted native view must be
31//! parented into that exact one.
32//!
33//! Every other hook is window-free, deliberately: the hooks that need a window
34//! (hide-on-close, titlebar theming) need it at a moment this core cannot always
35//! guarantee one exists, and threading an `Option<&Window>` through the rest
36//! would make every implementation handle a case its own retained handle already
37//! answers. It also keeps those hooks unit-testable without a live event loop —
38//! a `winit::Window` cannot be constructed without one, which is why the two
39//! window-taking hooks have no spy coverage here (the command hook's *payload*
40//! is covered instead, in `crate::platform_view`'s own tests).
41
42use std::sync::Arc;
43
44use frust_theme::Brightness;
45use winit::event_loop::EventLoopBuilder;
46use winit::window::{Window, WindowAttributes};
47
48use crate::app_handler::ShellUserEvent;
49
50/// The winit event-loop builder this shell builds its loop from, named so a
51/// per-OS crate can take it in [`DesktopExtensions::on_event_loop_builder`]
52/// without spelling the shell's own (doc-hidden) user-event type.
53///
54/// Windows' menu accelerators are the motivating consumer:
55/// `EventLoopBuilderExtWindows::with_msg_hook` is implemented on exactly this
56/// type, and must be installed before the loop is built.
57pub type DesktopEventLoopBuilder = EventLoopBuilder<ShellUserEvent>;
58
59/// What the shell should do about a window-close request (see
60/// [`DesktopExtensions::on_close_requested`]).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum CloseAction {
63 /// Exit the event loop — this shell's historical unconditional behavior and
64 /// the default. Returning from `run_app` is what drops the frame executor,
65 /// which is what persists the pipeline cache, so this is also the only
66 /// action that runs the real shutdown path.
67 Exit,
68 /// Leave the loop running: the extension has already handled the request
69 /// (macOS hiding the window instead of quitting, per
70 /// `DesktopConfig::quit_on_last_window_closed`). The extension then owns
71 /// the eventual real exit — nothing else in this core will call
72 /// `event_loop.exit()` on its behalf, so a shell that returns this and
73 /// never quits leaves the app running with no window, which is precisely
74 /// the macOS convention it exists for.
75 KeepRunning,
76}
77
78/// The hooks a per-OS desktop shell implements to add native behavior to this
79/// shared winit core.
80///
81/// Every method has a no-op default, so an implementation writes only the hooks
82/// it uses; [`NoExtensions`] implements none at all. The eight hooks, in the
83/// order a running app meets them:
84///
85/// 1. [`on_event_loop_builder`](Self::on_event_loop_builder) — before the loop
86/// is built.
87/// 2. [`on_window_attributes`](Self::on_window_attributes) — before the window
88/// is created.
89/// 3. [`on_window_created`](Self::on_window_created) — after it is created,
90/// before it is shown.
91/// 4. [`pump`](Self::pump) — once per frame, at the top.
92/// 5. [`on_theme_brightness_changed`](Self::on_theme_brightness_changed) —
93/// whenever the resolved theme brightness changes.
94/// 6. [`on_platform_view_commands`](Self::on_platform_view_commands) — once per
95/// frame, after the frame is submitted, when a hosted native view needs
96/// creating, placing or tearing down.
97/// 7. [`on_platform_views_suspended`](Self::on_platform_views_suspended) — when
98/// the window or its surface goes away and every hosted view must go with it.
99/// 8. [`on_close_requested`](Self::on_close_requested) — on a close request.
100pub trait DesktopExtensions {
101 /// Called with the winit event-loop builder, before `build()`.
102 ///
103 /// The one hook that runs before anything else exists — no runtime, no
104 /// window, no frame. Its purpose is the platform-specific builder
105 /// extensions winit only accepts here, notably
106 /// `EventLoopBuilderExtWindows::with_msg_hook` (the message hook a Windows
107 /// shell needs so `TranslateAcceleratorW` can turn a menu accelerator into
108 /// a menu event before winit consumes the key).
109 fn on_event_loop_builder(&mut self, builder: &mut DesktopEventLoopBuilder) {
110 let _ = builder;
111 }
112
113 /// Called with the window attributes the core assembled from
114 /// [`DesktopConfig`](crate::DesktopConfig), returning the attributes to
115 /// actually create the window with.
116 ///
117 /// Runs **after** the core's own defaults (title, initial size, and the
118 /// `visible(false)` the accessibility adapter's creation contract requires),
119 /// so an extension can override any of them — a Linux shell attaching
120 /// `with_name` (Wayland `app_id`/X11 `WM_CLASS`) or a window icon does it
121 /// here, since winit accepts neither after creation.
122 ///
123 /// Leaving `visible` alone matters: the adapter must be constructed before
124 /// the window is ever shown, and this core makes it visible itself once
125 /// that is done.
126 fn on_window_attributes(&mut self, attributes: WindowAttributes) -> WindowAttributes {
127 attributes
128 }
129
130 /// Called once, immediately after the window is created and its
131 /// accessibility adapter constructed, but **before** the window is shown.
132 ///
133 /// This is where a native menu is attached to the live window handle (muda's
134 /// `init_for_nsapp`/`init_for_hwnd`) and where a platform identity call that
135 /// needs the window (a Windows AppUserModelID, a taskbar icon) belongs.
136 ///
137 /// It is also the **only** hook that receives the window: clone the `Arc` if
138 /// a later hook needs it (see the module docs).
139 fn on_window_created(&mut self, window: &Arc<Window>) {
140 let _ = window;
141 }
142
143 /// Called once per frame, at the top of the redraw pass — before the theme
144 /// and font polls, and before the rebuild.
145 ///
146 /// This is the per-OS half of the signal-poll seam idiom: a shell hands over
147 /// at most *one* queued activation per frame through
148 /// `frust_reactive::push_menu_event` (the seam is a single-slot signal, so a
149 /// batch pushed in one pass would coalesce to its last entry) and requests
150 /// another frame while more remain queued. The position guarantees the
151 /// activation delivered on this frame is visible to *this* frame's rebuild
152 /// rather than waiting for the next one.
153 fn pump(&mut self) {}
154
155 /// Called whenever the shell's resolved theme brightness actually changes —
156 /// its first resolution in `resumed`, a platform appearance change
157 /// (`WindowEvent::ThemeChanged`), and an app-forced override arriving or
158 /// clearing through the per-frame `set_app_theme`/`clear_app_theme` poll.
159 ///
160 /// "Actually changes" is enforced by the core, which tracks the last value
161 /// it reported: a re-push at unchanged brightness (an override swapping one
162 /// dark theme for another) fires nothing, and the hook never fires
163 /// per-frame.
164 ///
165 /// The motivating consumer is the Windows titlebar: winit themes it from
166 /// the *system* preference on its own, so only an app-forced theme needs the
167 /// shell to call `Window::set_theme` — which is why the hook must fire on
168 /// the override path and not merely on the platform event.
169 fn on_theme_brightness_changed(&mut self, brightness: Brightness) {
170 let _ = brightness;
171 }
172
173 /// Called on the UI thread right after a frame is submitted, with the
174 /// platform-view differ's pending command batch in generation order — the
175 /// per-OS half of the desktop platform-view host (this crate owns the
176 /// OS-neutral half; see `crate::platform_view`).
177 ///
178 /// A host creates, places, shows/hides, re-parameterizes and disposes its
179 /// native sibling views from this batch, parented into `window`. Contract:
180 ///
181 /// - Rects and clips are **logical points** (winit logical units, absolute
182 /// window coordinates) — deliberately *not* converted to physical px the
183 /// way the mobile shells convert at their FFI boundary, because AppKit (the
184 /// first consumer) places `NSView`s in logical points. `scale` is the
185 /// window's current `scale_factor()`, for a host that does need physical px
186 /// — it scales at its own boundary.
187 /// - Apply the batch **idempotently, per `ViewCommand` semantics**: the whole
188 /// not-yet-applied backlog is re-served if a batch is ever missed, so an
189 /// already-applied prefix may arrive twice, an `Update` for an unknown slot
190 /// must be ignored rather than treated as a create, and a `Dispose` for a
191 /// slot already gone must be a no-op.
192 /// - **Do not block.** This runs between submitting a frame and the next
193 /// event-loop turn on the thread that owns the window; a blocking call here
194 /// stalls the frame loop directly.
195 ///
196 /// Called only when there is something to apply — an unchanged frame (the
197 /// common case: nothing hosted, or a static hosted view) never reaches the
198 /// hook at all.
199 fn on_platform_view_commands(
200 &mut self,
201 window: &Arc<Window>,
202 scale: f64,
203 commands: &[frust_shell_common::platform_view::ViewCommand],
204 ) {
205 let _ = (window, scale, commands);
206 }
207
208 /// Called when the window is destroyed or its surface lost: remove **every**
209 /// hosted native view.
210 ///
211 /// Not a hide — the view hierarchy this core was placing views into is
212 /// going away, so a host that merely hides them leaks them. The commands
213 /// queued at this moment are discarded rather than delivered (they describe
214 /// views that no longer exist); on the way back in, the next
215 /// [`on_platform_view_commands`](Self::on_platform_view_commands) batch is a
216 /// full `Create` + `Update` replay of every live slot, so a host rebuilds
217 /// from that batch alone and needs to retain nothing across the gap.
218 fn on_platform_views_suspended(&mut self) {}
219
220 /// Called on `WindowEvent::CloseRequested`, deciding whether the loop exits.
221 ///
222 /// The default is [`CloseAction::Exit`] — this shell's historical
223 /// unconditional behavior, and therefore what the dev preview still does.
224 /// A macOS shell returns [`CloseAction::KeepRunning`] after hiding its
225 /// retained window when `DesktopConfig::quit_on_last_window_closed` is
226 /// `false`, and lets the platform Quit item exit later; the pipeline-cache
227 /// persistence path runs on that real exit either way, since it hangs off
228 /// the frame executor's drop when `run_app` returns rather than off this
229 /// event.
230 fn on_close_requested(&mut self) -> CloseAction {
231 CloseAction::Exit
232 }
233}
234
235/// Whether overlay surfaces are on: `FRUST_OVERLAYS` (runtime env, then a
236/// compile-time `--define`, then the `FRUST_OVERLAY_SPIKE` alias; `0`, `off`
237/// or `false` is off, any other value on) — and only on macOS, the one desktop
238/// host with an overlay view. Read once per process.
239///
240/// On, the core feeds the platform-view differ each painted scene, so a batch
241/// handed to [`DesktopExtensions::on_platform_view_commands`] also carries
242/// `ViewCommand::Overlay`/`DisposeOverlay`: frust paint recorded above a hosted
243/// view, for the host to give a surface stacked directly above that view —
244/// see [`publish_overlay_layer`]. Off (the default), no overlay command is
245/// ever produced and the frame path is the single-surface one.
246pub fn overlays_enabled() -> bool {
247 crate::render::overlays_enabled()
248}
249
250/// Hand the render side overlay `overlay_id`'s `CAMetalLayer*` at
251/// `width` x `height` physical px — a new layer, or the same one resized —
252/// from inside [`DesktopExtensions::on_platform_view_commands`] (the platform
253/// main thread). The render side builds an overlay surface on it before its
254/// next frame and draws the overlay's command range there. Answers whether
255/// something changed, in which case the host requests a frame
256/// (`Window::request_redraw`) so the surface is drawn at once rather than on
257/// the next unrelated repaint.
258///
259/// The host must keep `layer` alive until [`withdraw_overlay_layer`] for the
260/// same id has returned.
261#[doc(hidden)]
262pub fn publish_overlay_layer(
263 overlay_id: u64,
264 layer: std::ptr::NonNull<std::ffi::c_void>,
265 width: u32,
266 height: u32,
267) -> bool {
268 if !overlays_enabled() {
269 return false;
270 }
271 crate::render::publish_overlay_layer(
272 overlay_id,
273 layer.as_ptr().expose_provenance(),
274 (width, height),
275 )
276}
277
278/// Withdraw overlay `overlay_id`'s layer **before** the host releases it:
279/// waits out a frame that is drawing the overlay's surface, then drops the
280/// surface. On return the layer may go. A no-op for an id never published.
281#[doc(hidden)]
282pub fn withdraw_overlay_layer(overlay_id: u64) {
283 if overlays_enabled() {
284 crate::render::withdraw_overlay_layer(overlay_id);
285 }
286}
287
288/// The no-op extension set: every hook keeps this core's own behavior.
289///
290/// Installed by [`run_desktop`](crate::run_desktop), so the zero-config dev
291/// preview behaves exactly as it did before the seam existed.
292#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
293pub struct NoExtensions;
294
295impl DesktopExtensions for NoExtensions {}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 /// A spy over every hook that can be driven without a live event loop
302 /// (i.e. all but the two that take a `&Arc<Window>`, which needs a real
303 /// `winit::Window` — see the module docs). Records the call order so a caller
304 /// can assert both *that* a hook ran and *when*.
305 #[derive(Debug, Default)]
306 struct SpyExtensions {
307 calls: Vec<String>,
308 close_action: Option<CloseAction>,
309 }
310
311 impl DesktopExtensions for SpyExtensions {
312 fn on_window_attributes(&mut self, attributes: WindowAttributes) -> WindowAttributes {
313 self.calls.push("on_window_attributes".to_string());
314 attributes.with_title("spied")
315 }
316
317 fn pump(&mut self) {
318 self.calls.push("pump".to_string());
319 }
320
321 fn on_theme_brightness_changed(&mut self, brightness: Brightness) {
322 self.calls.push(format!("brightness:{brightness:?}"));
323 }
324
325 fn on_platform_views_suspended(&mut self) {
326 self.calls.push("on_platform_views_suspended".to_string());
327 }
328
329 fn on_close_requested(&mut self) -> CloseAction {
330 self.calls.push("on_close_requested".to_string());
331 self.close_action.unwrap_or(CloseAction::Exit)
332 }
333 }
334
335 #[test]
336 fn no_extensions_leaves_window_attributes_untouched() {
337 let attributes = WindowAttributes::default().with_title("Frust");
338 let result = NoExtensions.on_window_attributes(attributes.clone());
339 assert_eq!(result.title, attributes.title);
340 assert_eq!(result.visible, attributes.visible);
341 }
342
343 #[test]
344 fn no_extensions_keeps_the_historical_close_behavior() {
345 // The zero-config preview must still exit on a close request.
346 assert_eq!(NoExtensions.on_close_requested(), CloseAction::Exit);
347 }
348
349 #[test]
350 fn no_extensions_hooks_are_all_no_ops() {
351 // Nothing to observe — this pins that each hook exists with a default
352 // body an implementation may leave unwritten (a compile-time assertion
353 // as much as a runtime one).
354 let mut ext = NoExtensions;
355 ext.pump();
356 ext.on_theme_brightness_changed(Brightness::Dark);
357 ext.on_platform_views_suspended();
358 }
359
360 #[test]
361 fn a_suspend_notification_is_observable_beside_the_other_hooks() {
362 // The platform-view seam's window-free half: a host that tracks nothing
363 // else still has to hear about the hierarchy going away.
364 let mut spy = SpyExtensions::default();
365 spy.pump();
366 spy.on_platform_views_suspended();
367 assert_eq!(spy.calls, vec!["pump", "on_platform_views_suspended"]);
368 }
369
370 #[test]
371 fn an_extension_can_override_the_attributes_the_core_assembled() {
372 let mut spy = SpyExtensions::default();
373 let result = spy.on_window_attributes(WindowAttributes::default().with_title("Frust"));
374 assert_eq!(result.title, "spied");
375 assert_eq!(spy.calls, vec!["on_window_attributes"]);
376 }
377
378 #[test]
379 fn an_extension_can_refuse_a_close_request() {
380 let mut spy = SpyExtensions {
381 close_action: Some(CloseAction::KeepRunning),
382 ..SpyExtensions::default()
383 };
384 assert_eq!(spy.on_close_requested(), CloseAction::KeepRunning);
385 assert_eq!(spy.calls, vec!["on_close_requested"]);
386 }
387
388 #[test]
389 fn hook_calls_are_observable_in_order() {
390 let mut spy = SpyExtensions::default();
391 spy.pump();
392 spy.on_theme_brightness_changed(Brightness::Dark);
393 spy.pump();
394 assert_eq!(spy.calls, vec!["pump", "brightness:Dark", "pump"]);
395 }
396}