Skip to main content

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/// The no-op extension set: every hook keeps this core's own behavior.
236///
237/// Installed by [`run_desktop`](crate::run_desktop), so the zero-config dev
238/// preview behaves exactly as it did before the seam existed.
239#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
240pub struct NoExtensions;
241
242impl DesktopExtensions for NoExtensions {}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    /// A spy over every hook that can be driven without a live event loop
249    /// (i.e. all but the two that take a `&Arc<Window>`, which needs a real
250    /// `winit::Window` — see the module docs). Records the call order so a caller
251    /// can assert both *that* a hook ran and *when*.
252    #[derive(Debug, Default)]
253    struct SpyExtensions {
254        calls: Vec<String>,
255        close_action: Option<CloseAction>,
256    }
257
258    impl DesktopExtensions for SpyExtensions {
259        fn on_window_attributes(&mut self, attributes: WindowAttributes) -> WindowAttributes {
260            self.calls.push("on_window_attributes".to_string());
261            attributes.with_title("spied")
262        }
263
264        fn pump(&mut self) {
265            self.calls.push("pump".to_string());
266        }
267
268        fn on_theme_brightness_changed(&mut self, brightness: Brightness) {
269            self.calls.push(format!("brightness:{brightness:?}"));
270        }
271
272        fn on_platform_views_suspended(&mut self) {
273            self.calls.push("on_platform_views_suspended".to_string());
274        }
275
276        fn on_close_requested(&mut self) -> CloseAction {
277            self.calls.push("on_close_requested".to_string());
278            self.close_action.unwrap_or(CloseAction::Exit)
279        }
280    }
281
282    #[test]
283    fn no_extensions_leaves_window_attributes_untouched() {
284        let attributes = WindowAttributes::default().with_title("Frust");
285        let result = NoExtensions.on_window_attributes(attributes.clone());
286        assert_eq!(result.title, attributes.title);
287        assert_eq!(result.visible, attributes.visible);
288    }
289
290    #[test]
291    fn no_extensions_keeps_the_historical_close_behavior() {
292        // The zero-config preview must still exit on a close request.
293        assert_eq!(NoExtensions.on_close_requested(), CloseAction::Exit);
294    }
295
296    #[test]
297    fn no_extensions_hooks_are_all_no_ops() {
298        // Nothing to observe — this pins that each hook exists with a default
299        // body an implementation may leave unwritten (a compile-time assertion
300        // as much as a runtime one).
301        let mut ext = NoExtensions;
302        ext.pump();
303        ext.on_theme_brightness_changed(Brightness::Dark);
304        ext.on_platform_views_suspended();
305    }
306
307    #[test]
308    fn a_suspend_notification_is_observable_beside_the_other_hooks() {
309        // The platform-view seam's window-free half: a host that tracks nothing
310        // else still has to hear about the hierarchy going away.
311        let mut spy = SpyExtensions::default();
312        spy.pump();
313        spy.on_platform_views_suspended();
314        assert_eq!(spy.calls, vec!["pump", "on_platform_views_suspended"]);
315    }
316
317    #[test]
318    fn an_extension_can_override_the_attributes_the_core_assembled() {
319        let mut spy = SpyExtensions::default();
320        let result = spy.on_window_attributes(WindowAttributes::default().with_title("Frust"));
321        assert_eq!(result.title, "spied");
322        assert_eq!(spy.calls, vec!["on_window_attributes"]);
323    }
324
325    #[test]
326    fn an_extension_can_refuse_a_close_request() {
327        let mut spy = SpyExtensions {
328            close_action: Some(CloseAction::KeepRunning),
329            ..SpyExtensions::default()
330        };
331        assert_eq!(spy.on_close_requested(), CloseAction::KeepRunning);
332        assert_eq!(spy.calls, vec!["on_close_requested"]);
333    }
334
335    #[test]
336    fn hook_calls_are_observable_in_order() {
337        let mut spy = SpyExtensions::default();
338        spy.pump();
339        spy.on_theme_brightness_changed(Brightness::Dark);
340        spy.pump();
341        assert_eq!(spy.calls, vec!["pump", "brightness:Dark", "pump"]);
342    }
343}