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}