Skip to main content

lumen_core/
window_backend.rs

1//! The window backends a binary carries, and the window-free state every
2//! window backend shares with the app.
3//!
4//! Each backend registers a [`WindowBackendEntry`] into the app's
5//! [`WindowBackends`] from its own crate, and an interactive launch runs
6//! the app through the highest-priority one's
7//! [`crate::traits::WindowBackend::run`]. The core and the runtime name no
8//! backend.
9//!
10//! [`WindowCorePlugin`] is the half of a window that needs no display: the
11//! messages a window backend writes and the redraw state it reads. A
12//! backend installs it when it runs; a headless run installs it alone, so
13//! systems that read window state behave the same with no window open.
14
15use crate::app::{App, Plugin};
16use crate::backends::{BackendEntry, Backends};
17use crate::input::{WindowFocused, WindowOccluded};
18use crate::render_world::install_extract_pipeline;
19use crate::text_events::{ImeSurroundingRequested, ImeSurroundingResponse, TextEditRequest};
20use crate::traits::WindowBackend;
21use bevy_ecs::prelude::Resource;
22
23/// One window backend: its name, its rank, and how to make it.
24#[derive(Clone, Copy, Debug)]
25pub struct WindowBackendEntry {
26    /// The name the backend is registered under.
27    pub name: &'static str,
28    /// Preference: the highest-priority backend is the one a launch runs.
29    pub priority: i32,
30    /// The backend, not yet running. Construction does no work;
31    /// [`WindowBackend::run`] opens the window.
32    pub backend: fn() -> Box<dyn WindowBackend>,
33}
34
35impl BackendEntry for WindowBackendEntry {
36    const KIND: &'static str = "window";
37
38    fn name(&self) -> &'static str {
39        self.name
40    }
41
42    fn priority(&self) -> i32 {
43        self.priority
44    }
45}
46
47/// Main-world registry of the window backends this binary carries. A
48/// backend's capability install adds itself with
49/// [`crate::backends::register_backend`].
50pub type WindowBackends = Backends<WindowBackendEntry>;
51
52/// Redraw pacing: whether a frame is wanted, and whether the window can
53/// show one.
54///
55/// Other systems request a paint by writing `pending = true`; the window
56/// backend asks the platform for a redraw only while the window is
57/// visible (not occluded). That keeps an idle app from repainting at the
58/// display rate, the way Qt gates `requestUpdate` on `isExposed()` and GTK
59/// drives its frame clock.
60///
61/// The pump gates on visibility, not focus. On tiling window managers
62/// (Hyprland, sway) an unfocused window stays fully on-screen, so it must
63/// keep animating: a module's off-thread position pump, restyle tweens, and
64/// scroll inertia all ride the redraw loop, and freezing them the moment
65/// focus leaves would stop a slider mid-drag. Only an occluded or minimized
66/// window parks.
67///
68/// The window backend owns `focused`, `occluded`, and `paused`, and calls
69/// [`Self::recompute_paused`] when the first two change. Other code reads
70/// them and writes only `pending`.
71#[derive(Resource, Clone, Copy, Debug)]
72pub struct RedrawScheduler {
73    /// Set to `true` to request a paint the next time the event loop goes
74    /// idle. Cleared once the paint is dispatched.
75    pub pending: bool,
76    /// `true` when the window has keyboard focus. Tracked for the
77    /// [`WindowFocused`] message and the focus-return repaint; not part of
78    /// the pump gate (see [`RedrawScheduler::compute_paused`]).
79    pub focused: bool,
80    /// `true` when the window is fully occluded (covered by another window
81    /// or moved off-screen).
82    pub occluded: bool,
83    /// Whether redraw requests are held back: recomputed whenever
84    /// `occluded` or `focused` changes, and true only while the window is
85    /// occluded.
86    pub paused: bool,
87}
88
89impl Default for RedrawScheduler {
90    fn default() -> Self {
91        // Start focused and visible so the first frame paints. The platform
92        // reports focus and visibility shortly after the window opens; until
93        // then the app should render rather than show an empty surface.
94        Self {
95            pending: true,
96            focused: true,
97            occluded: false,
98            paused: false,
99        }
100    }
101}
102
103impl RedrawScheduler {
104    /// Recompute [`Self::paused`] after a change to `focused` or
105    /// `occluded`.
106    pub fn recompute_paused(&mut self) {
107        self.paused = Self::compute_paused(self.focused, self.occluded);
108    }
109
110    /// The pump gate: should the redraw loop park?
111    ///
112    /// Only occlusion parks the loop. `focused` is accepted and ignored, so
113    /// a visible but unfocused window (the tiling window manager case) keeps
114    /// animating.
115    pub fn compute_paused(_focused: bool, occluded: bool) -> bool {
116        occluded
117    }
118
119    /// Whether the backend should ask the platform for a redraw: a paint is
120    /// pending and the window is not parked. Anything that wants a frame
121    /// while unfocused (a worker thread's
122    /// [`crate::app::EventLoopWaker`], a restyle tween, scroll inertia) sets
123    /// `pending`, and this gate lets it through.
124    pub fn should_forward_redraw(&self) -> bool {
125        self.pending && !self.paused
126    }
127}
128
129/// The window-free half of every window backend.
130///
131/// Registers the messages a window backend writes ([`WindowFocused`],
132/// [`WindowOccluded`], the IME surrounding-text pair, and
133/// [`TextEditRequest`] for IME commits), inserts a default
134/// [`RedrawScheduler`], and installs the extract pipeline a window presents
135/// from. A window backend installs it before it opens a window unless the
136/// app already has it; a headless run installs it alone.
137///
138/// `CloseRequest` is not registered here: [`App::new`] registers it, so
139/// close hooks work headless too, and registering a message twice cycles
140/// its buffer twice per tick, dropping writes made before the tick.
141pub struct WindowCorePlugin;
142
143impl Plugin for WindowCorePlugin {
144    fn build(self, app: &mut App) {
145        // A window presents frames, so it is what asks for the pipeline that
146        // produces them; an app that is never shown installs none of it.
147        install_extract_pipeline(app);
148        app.add_message::<WindowFocused>();
149        app.add_message::<WindowOccluded>();
150        app.add_message::<ImeSurroundingRequested>();
151        app.add_message::<ImeSurroundingResponse>();
152        app.add_message::<TextEditRequest>();
153        app.world.insert_resource(RedrawScheduler::default());
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::app::App;
161    use crate::backends::{register_backend, registered};
162    use crate::traits::{A11yBridgeFactory, Renderer, WindowError};
163    use crate::window::WindowOptions;
164
165    /// A backend that runs nothing and reports what it was handed.
166    struct Inert;
167
168    impl WindowBackend for Inert {
169        fn run(
170            self: Box<Self>,
171            _app: App,
172            _options: WindowOptions,
173            renderers: Vec<Box<dyn Renderer>>,
174            _a11y: Option<A11yBridgeFactory>,
175        ) -> Result<(), WindowError> {
176            if renderers.is_empty() {
177                Err(WindowError::NoRenderer)
178            } else {
179                Ok(())
180            }
181        }
182    }
183
184    fn inert() -> Box<dyn WindowBackend> {
185        Box::new(Inert)
186    }
187
188    /// A launch runs the backend the registry ranks first, through the
189    /// trait.
190    #[test]
191    fn the_preferred_backend_runs_the_app() {
192        let mut app = App::new();
193        register_backend(
194            &mut app,
195            WindowBackendEntry {
196                name: "inert",
197                priority: 0,
198                backend: inert,
199            },
200        );
201        let entry = registered::<WindowBackendEntry>(&app)
202            .preferred()
203            .expect("registered");
204        let err = (entry.backend)()
205            .run(App::new(), WindowOptions::default(), Vec::new(), None)
206            .expect_err("no renderer");
207        assert!(matches!(err, WindowError::NoRenderer));
208    }
209
210    /// The window-free half gives a headless app the same window state a
211    /// windowed one starts with.
212    #[test]
213    fn the_core_plugin_installs_the_window_state() {
214        let mut app = App::new();
215        app.add_plugin(WindowCorePlugin);
216        assert!(app.world.resource::<RedrawScheduler>().pending);
217        assert!(
218            app.world
219                .contains_resource::<bevy_ecs::message::Messages<WindowFocused>>()
220        );
221        assert!(
222            app.world
223                .contains_resource::<bevy_ecs::message::Messages<TextEditRequest>>()
224        );
225    }
226
227    /// The pump-gate policy: only occlusion parks the loop. Focus is not a
228    /// factor, so a visible-but-unfocused window (Hyprland/sway, where an
229    /// unfocused window is still fully on-screen) keeps animating. This is
230    /// the "slider freeze while unfocused on a tiling WM" fix.
231    #[test]
232    fn visibility_not_focus_gates_the_pump() {
233        // Visible + focused -> run.
234        assert!(!RedrawScheduler::compute_paused(true, false));
235        // Visible + UNFOCUSED -> still run (the regression this fixes).
236        assert!(!RedrawScheduler::compute_paused(false, false));
237        // Occluded -> park, regardless of focus.
238        assert!(RedrawScheduler::compute_paused(true, true));
239        assert!(RedrawScheduler::compute_paused(false, true));
240    }
241
242    fn scheduler(pending: bool, focused: bool, occluded: bool) -> RedrawScheduler {
243        let mut s = RedrawScheduler {
244            pending,
245            focused,
246            occluded,
247            paused: false,
248        };
249        s.recompute_paused();
250        s
251    }
252
253    /// `about_to_wait` forwards a `request_redraw` iff `should_forward_redraw`.
254    /// A pending paint raised while UNFOCUSED-but-VISIBLE (a worker thread's
255    /// `EventLoopWaker`, a restyle tween, or scroll inertia all set `pending`)
256    /// must forward; an occluded window must not.
257    #[test]
258    fn forward_redraw_wakes_unfocused_visible_but_parks_occluded() {
259        // A pump or tween in flight while unfocused-but-visible: it set
260        // `pending = true`; the gate must forward it.
261        assert!(scheduler(true, false, false).should_forward_redraw());
262        // Focused + pending: unchanged from before the fix.
263        assert!(scheduler(true, true, false).should_forward_redraw());
264        // Nothing pending (idle) while unfocused-visible: stay damage-driven,
265        // do not busy-repaint an unchanging frame.
266        assert!(!scheduler(false, false, false).should_forward_redraw());
267        // Occluded / minimized: park even with work pending (battery win).
268        assert!(!scheduler(true, false, true).should_forward_redraw());
269        assert!(!scheduler(true, true, true).should_forward_redraw());
270    }
271}