lumen_core/app.rs
1//! App builder and [`Plugin`] trait. Wraps [`bevy_ecs::World`] and [`Schedule`] directly without depending on `bevy_app`.
2//!
3//! ## Two-world architecture
4//!
5//! [`App`] holds a **main world** for app/UI state and a **render world** for per-frame extracted draw data and GPU resources.
6//! Cross-world flow is documented in [`crate::render_world`].
7//!
8//! ## Tick
9//!
10//! Each call to [`App::tick`] runs:
11//!
12//! 1. [`Tick`](crate::tick::Tick) `advance()`, bumping the frame counter and `dt`.
13//! 2. The main schedule, ordered `Input -> CommandDrain -> Systems -> LayoutSync -> A11ySync`.
14//!
15//! Steps 3 to 5 run only when [`FrameDirty`] is set; an idle tick stops after step 2.
16//!
17//! 3. [`clear_extracted`] on the render world to remove transient `Extracted*` entities.
18//! 4. Every registered [`ExtractFn`] against `(&mut main, &mut render)`. The main world is taken mutably because
19//! [`bevy_ecs::world::World::query`] mutates the world's query cache; extract does not change main-world state.
20//! 5. The render schedule on the render world, ordered `Prepare -> Render`.
21
22use crate::command::{Command, CommandQueue, CommandReceiver, CommandRegistry};
23use crate::input::{
24 ClickEvent, CloseRequest, DoubleClickEvent, DragEndEvent, DragMoveEvent, DragStartEvent,
25 FileDropped, FileHoverCancelled, FileHovered, FocusTracker, FocusedKey, ImeEvent, ImeRequest,
26 KeyPressed, KeyReleased, LongPressEvent, MenuClicked, ModifiersState, MouseWheel,
27 PendingFileDrops, PointerLeft, PointerMoved, PointerPressed, PointerReleased, PointerState,
28 ShowContextMenu, TextInputCommitted,
29};
30use crate::node_ir::{PreviousScene, RetainedScene};
31use crate::property_store::PropertyStore;
32use crate::render_world::{
33 ExtractFn, ExtractSchedule, ExtractSet, FrameDirty, HiddenExtracts, Render, RenderStage,
34 Viewport, clear_extracted,
35};
36use crate::tick::TickStage;
37use crate::time::Instant;
38use bevy_ecs::error::{BevyError, ErrorContext};
39use bevy_ecs::message::{Message, MessageRegistry};
40use bevy_ecs::prelude::*;
41use bevy_ecs::schedule::{
42 FixedBitSet, ScheduleLabel, SystemExecutor, SystemSchedule, default_executor,
43};
44use bevy_ecs::system::ScheduleSystem;
45use std::any::{Any, TypeId};
46use std::collections::{HashMap, HashSet};
47use std::sync::OnceLock;
48
49/// Schedule label for the main tick.
50#[derive(ScheduleLabel, Clone, Copy, Debug, Hash, PartialEq, Eq)]
51pub struct Tick;
52
53/// Cross-thread handle used to wake a parked platform event loop after
54/// something is pushed onto a resource the tick loop doesn't otherwise
55/// observe until the next OS event - e.g. `lumen-mcp`'s `SimulateQueue`
56/// filling from the MCP server thread while `lumen-window-winit`'s winit
57/// loop sits parked in `about_to_wait`. Without a wakeup, injected input
58/// is invisible until an unrelated OS event (mouse move, resize, ...)
59/// happens to tick the app.
60///
61/// Backends that run a real OS event loop insert this as a main-world
62/// resource once they have a way to interrupt their own park/wait call
63/// (the winit window backend does it via a `winit::event_loop::EventLoopProxy`).
64/// Headless/test contexts simply never insert it, so callers must treat
65/// its absence as "no loop to wake" and no-op.
66#[derive(Clone, Resource)]
67pub struct EventLoopWaker(pub std::sync::Arc<dyn Fn() + Send + Sync>);
68
69impl EventLoopWaker {
70 /// Invoke the wakeup callback.
71 pub fn wake(&self) {
72 (self.0)()
73 }
74}
75
76impl std::fmt::Debug for EventLoopWaker {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_tuple("EventLoopWaker").finish()
79 }
80}
81
82/// How this app is being run. `headless` is true when there is no interactive
83/// session: `lumenc run --headless`, an automation driver, a test, a
84/// build-time instantiation that only wants what the plugins register. A
85/// plugin whose work only makes sense for a person at a window - a thread
86/// that wakes the loop, a device, a daemon - installs its idle shape instead.
87///
88/// Inserted before the first plugin is installed. An embedder that builds its
89/// own [`App`] may never insert it, so absence reads as an ordinary
90/// interactive run. Read it through [`App::is_headless`], so every plugin
91/// spells the absence rule the same way.
92#[derive(Resource, Clone, Copy, Debug, Default)]
93pub struct RunMode {
94 /// No interactive session.
95 pub headless: bool,
96}
97
98/// Process-start reference instant for startup instrumentation.
99///
100/// Set once by the binary entry point ([`lumenc`'s `main`]) as early as
101/// reachable; read by the windowed backend to time exec->first-frame for
102/// the `LUMEN_BOOT_TRACE` startup marker (the same measurement the
103/// headless boot-trace prints). Absent in embedders that never call
104/// [`mark_process_start`], in which case first-frame timing is simply not
105/// reported and the marker carries no `startup_ms:`.
106static PROCESS_START: OnceLock<Instant> = OnceLock::new();
107
108/// Record the process-start instant. Idempotent - only the first call
109/// wins, so calling it as the first statement of `main` captures the
110/// earliest reachable moment. A no-op if already set.
111pub fn mark_process_start() {
112 let _ = PROCESS_START.set(Instant::now());
113}
114
115/// The process-start instant recorded by [`mark_process_start`], if any.
116pub fn process_start() -> Option<Instant> {
117 PROCESS_START.get().copied()
118}
119
120/// Plugin trait registered via [`App::add_plugin`].
121///
122/// `build` consumes `self` so the implementor can move non-clone payloads (text shapers, async runtimes, sockets) into the world.
123pub trait Plugin: Sized {
124 /// Returns the plugin's name for diagnostics; defaults to the type name.
125 fn name(&self) -> &'static str {
126 std::any::type_name::<Self>()
127 }
128
129 /// Returns the list of plugin names this plugin depends on. Default: empty.
130 ///
131 /// [`App::add_plugin`] checks each name against the already-installed set and prints a warning for any name it
132 /// does not find, then builds the plugin anyway. Ordering is the caller's responsibility; the topological sort
133 /// over a deferred plugin queue is not written yet.
134 fn depends_on(&self) -> &'static [&'static str] {
135 &[]
136 }
137
138 /// Registers systems and resources on `app`, consuming the plugin.
139 fn build(self, app: &mut App);
140
141 /// Optional teardown hook. Default no-op.
142 ///
143 /// The intent is for async-tokio to join workers here and for render backends to release GPU resources.
144 /// [`App`] has no `Drop` impl and nothing calls this yet, so implementing it has no effect today.
145 fn cleanup(&mut self, _app: &mut App) {}
146}
147
148/// Type-erased plugin metadata recorded by [`App::add_plugin`].
149///
150/// Carries the plugin's `name()` and `depends_on()` so `is_plugin_added` / topological queries can answer without re-
151/// running the build closure.
152pub trait PluginMetadata: Send + Sync {
153 /// Plugin name (matches the corresponding [`Plugin::name`] return).
154 fn name(&self) -> &'static str;
155 /// Declared dependencies (matches [`Plugin::depends_on`]).
156 fn depends_on(&self) -> &'static [&'static str];
157 /// Concrete plugin type id, for [`App::is_plugin_added`].
158 fn type_id(&self) -> TypeId;
159}
160
161struct PluginInfo {
162 name: &'static str,
163 deps: &'static [&'static str],
164 type_id: TypeId,
165}
166
167impl PluginMetadata for PluginInfo {
168 fn name(&self) -> &'static str {
169 self.name
170 }
171 fn depends_on(&self) -> &'static [&'static str] {
172 self.deps
173 }
174 fn type_id(&self) -> TypeId {
175 self.type_id
176 }
177}
178
179/// Errors returned by builder methods that can fail.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum AppError {
182 /// A plugin's `depends_on()` listed a plugin that has not been added yet.
183 MissingDependency {
184 /// Plugin attempting to install.
185 plugin: &'static str,
186 /// Missing dependency name.
187 missing: &'static str,
188 },
189 /// Topological sort detected a cycle among installed plugins.
190 PluginCycle {
191 /// One plugin participating in the detected cycle.
192 plugin: &'static str,
193 },
194}
195
196impl std::fmt::Display for AppError {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 match self {
199 AppError::MissingDependency { plugin, missing } => {
200 write!(
201 f,
202 "plugin `{plugin}` depends on `{missing}`, which has not been added"
203 )
204 }
205 AppError::PluginCycle { plugin } => {
206 write!(f, "plugin dependency cycle detected at `{plugin}`")
207 }
208 }
209 }
210}
211
212impl std::error::Error for AppError {}
213
214/// Lumen application holding the main world, render world, their schedules, and the extract-fn list.
215pub struct App {
216 /// Main world carrying app/UI state, layout, and scripts.
217 pub world: World,
218 /// Render world carrying per-frame extracted draw data and GPU resources.
219 pub render_world: World,
220 /// Extract fns invoked in registration order each tick, after the main schedule and before the render schedule.
221 /// Empty until a render backend installs the built-in chain with
222 /// [`crate::render_world::install_extract_pipeline`]; [`App::add_extract_fn`] appends to it. The list is public so
223 /// a plugin can also replace or reorder an entry; nothing in the workspace does that today.
224 pub extract_fns: Vec<ExtractFn>,
225 /// Worker-thread budget for the `bevy_ecs` multithreaded executor.
226 ///
227 /// - Defaults to [`LUMEN_DEFAULT_THREADS`] (4).
228 /// - Plugins raise it monotonically via [`Self::request_threads_at_least`].
229 /// - The `LUMEN_THREADS` environment variable overrides any plugin request.
230 /// - Read at the first [`Self::tick`]; the task pool is initialised once via `ComputeTaskPool::get_or_init` and subsequent updates have no effect.
231 pub desired_threads: usize,
232 /// Plugin name -> metadata. Populated by [`Self::add_plugin`] in installation order.
233 pub installed_plugins: HashMap<&'static str, Box<dyn PluginMetadata>>,
234 /// Concrete plugin type ids that have been added. Consulted by [`Self::is_plugin_added`].
235 installed_plugin_types: HashSet<TypeId>,
236}
237
238/// Upper bound on the default worker count for the bevy_ecs task pool when
239/// no plugin or env var raises it. One worker per main-stage band
240/// (input / systems / layout / render). The effective default is
241/// [`default_thread_budget`] = `min(available_parallelism, LUMEN_DEFAULT_THREADS)`,
242/// so a 24-core box does not spawn a 24-wide pool for a UI that never
243/// saturates four workers.
244pub const LUMEN_DEFAULT_THREADS: usize = 4;
245
246/// Effective default worker budget: [`LUMEN_DEFAULT_THREADS`] capped to the
247/// machine's available parallelism. Falls back to 1 when parallelism is
248/// unknown. The `LUMEN_THREADS` env var (read in [`ensure_task_pool`]) and
249/// `lumen.toml [runtime] threads` still override this.
250pub fn default_thread_budget() -> usize {
251 let cores = std::thread::available_parallelism()
252 .map(|n| n.get())
253 .unwrap_or(1);
254 LUMEN_DEFAULT_THREADS.min(cores).max(1)
255}
256
257/// Marks whether the global `bevy_tasks` compute pool has been initialised and short-circuits the env-var lookup on subsequent calls.
258static TASK_POOL_INITIALISED: OnceLock<usize> = OnceLock::new();
259
260/// Initialises the global `bevy_tasks` compute pool with `desired` workers (or the `LUMEN_THREADS` env var override).
261/// No-ops after the first call; the pool is global and single-init.
262fn ensure_task_pool(desired: usize) {
263 TASK_POOL_INITIALISED.get_or_init(|| {
264 let n = std::env::var("LUMEN_THREADS")
265 .ok()
266 .and_then(|v| v.parse::<usize>().ok())
267 .filter(|n| *n > 0)
268 .unwrap_or(desired)
269 .max(1);
270 bevy_tasks::ComputeTaskPool::get_or_init(|| {
271 bevy_tasks::TaskPoolBuilder::new()
272 .num_threads(n)
273 .thread_name("lumen-worker".into())
274 .build()
275 });
276 n
277 });
278}
279
280impl Default for App {
281 fn default() -> Self {
282 Self::new()
283 }
284}
285
286impl App {
287 /// Constructs a fresh app with both worlds, both schedules and the command queue.
288 ///
289 /// The extract list starts empty and the render schedule holds no systems: what turns the main world into a
290 /// paintable scene is installed by the render backend, through
291 /// [`crate::render_world::install_extract_pipeline`], so an app that is never painted never carries it.
292 pub fn new() -> Self {
293 let mut world = World::new();
294
295 // Install the command queue resources on the main world.
296 let (queue, receiver) = CommandQueue::new();
297 world.insert_resource(queue);
298 world.insert_resource(receiver);
299 // Strongly-typed custom command registry (see `App::register_command`).
300 world.insert_resource(CommandRegistry::default());
301 // Foundation property store; legacy `Signals` writes mirror here via `mirror_signals_to_property_store`.
302 world.insert_resource(PropertyStore::default());
303 // Per-tick frame clock; updated by `App::tick` before each main schedule run.
304 world.insert_resource(crate::tick::Tick::default());
305 // Insert `Viewport` into the main world; the window plugin mirrors writes into the render world on resize.
306 world.insert_resource(Viewport::default());
307 // Insert `FrameDirty`; defaults to dirty so the first frame paints. The window backend reads and clears it per presented frame.
308 world.insert_resource(FrameDirty::default());
309 // Per-tick "an animation is still moving" flag. Reset at the top
310 // of every tick (below) and re-raised by animation drivers while
311 // they still have motion, so the window backend can self-schedule
312 // follow-up frames without spinning at idle.
313 world.insert_resource(crate::render_world::AnimationsActive::default());
314 // Per-tick "tick again no later than" request, reset alongside
315 // `AnimationsActive` and read by the loop to sleep until it.
316 world.insert_resource(crate::tick::WakeDeadline::default());
317 // Per-extract-phase memo of the hierarchy-derived maps the extract
318 // fns would otherwise each rebuild identically (parent map, scroll
319 // offsets, opacities, hidden set, clip rects). Populated by the
320 // first extractor of a frame and reused by the rest; see
321 // [`crate::render_world::ExtractContextCache`].
322 world.insert_resource(crate::render_world::ExtractContextCache::default());
323 world.insert_resource(PointerState::default());
324 world.insert_resource(crate::input::ScrollbarInteraction::default());
325 world.insert_resource(ModifiersState::default());
326 world.insert_resource(FocusTracker::default());
327 world.insert_resource(ImeRequest::default());
328 world.insert_resource(PendingFileDrops::default());
329
330 // Register pointer, keyboard, drag, IME, file-drop, and system messages so producers and consumers can use `MessageWriter` and `MessageReader`.
331 MessageRegistry::register_message::<PointerMoved>(&mut world);
332 MessageRegistry::register_message::<PointerPressed>(&mut world);
333 MessageRegistry::register_message::<PointerReleased>(&mut world);
334 MessageRegistry::register_message::<PointerLeft>(&mut world);
335 MessageRegistry::register_message::<ClickEvent>(&mut world);
336 MessageRegistry::register_message::<KeyPressed>(&mut world);
337 MessageRegistry::register_message::<KeyReleased>(&mut world);
338 MessageRegistry::register_message::<FocusedKey>(&mut world);
339 MessageRegistry::register_message::<MouseWheel>(&mut world);
340 MessageRegistry::register_message::<LongPressEvent>(&mut world);
341 MessageRegistry::register_message::<DoubleClickEvent>(&mut world);
342 MessageRegistry::register_message::<DragStartEvent>(&mut world);
343 MessageRegistry::register_message::<DragMoveEvent>(&mut world);
344 MessageRegistry::register_message::<DragEndEvent>(&mut world);
345 MessageRegistry::register_message::<ImeEvent>(&mut world);
346 MessageRegistry::register_message::<TextInputCommitted>(&mut world);
347 MessageRegistry::register_message::<FileHovered>(&mut world);
348 MessageRegistry::register_message::<FileHoverCancelled>(&mut world);
349 MessageRegistry::register_message::<FileDropped>(&mut world);
350 MessageRegistry::register_message::<MenuClicked>(&mut world);
351 MessageRegistry::register_message::<crate::input::DialogClosed>(&mut world);
352 MessageRegistry::register_message::<ShowContextMenu>(&mut world);
353 // Close-request bus. Registered here (not only by the window
354 // backend plugin) so app-level close hooks - the script host's
355 // `on_close` dispatcher, the C-ABI `lumen_app_on_close` router,
356 // and SDK systems - can rely on the resource existing in every
357 // context, including headless runs that never install a window
358 // backend. Window backends write `CloseRequest { vetoed: false }`
359 // on an OS close request (window button, SIGINT/SIGTERM); a
360 // system that wants to keep the window open writes a fresh
361 // `CloseRequest { vetoed: true }` on the same tick.
362 MessageRegistry::register_message::<CloseRequest>(&mut world);
363
364 // Install the main `Tick` schedule with a fixed five-stage ordering.
365 let mut schedule = Schedule::new(Tick);
366 schedule.configure_sets(
367 (
368 TickStage::Input,
369 TickStage::CommandDrain,
370 TickStage::Systems,
371 TickStage::LayoutSync,
372 TickStage::A11ySync,
373 )
374 .chain(),
375 );
376 // Where the layout engine writes this tick's boxes; see
377 // [`crate::layout_backend::LayoutSolve`].
378 schedule.configure_sets(crate::layout_backend::LayoutSolve.in_set(TickStage::LayoutSync));
379 world.add_schedule(schedule);
380
381 // Build the render world.
382 let mut render_world = World::new();
383 render_world.insert_resource(Viewport::default());
384 // Insert the persistent main->render entity registry consulted by upserting extract fns.
385 // [`clear_extracted`] skips entities present in this map so their identities survive across frames.
386 render_world.insert_resource(crate::render_world::RenderEntityMap::default());
387 // W2.1 retained Node IR - produced by `transform_extracted_to_nodes` in `RenderStage::Prepare`,
388 // consumed by the back-end walker in `RenderStage::Render`.
389 render_world.insert_resource(RetainedScene::default());
390 render_world.insert_resource(PreviousScene::default());
391 // Snapshot of hidden main entities, refreshed each extract phase by
392 // `stash_hidden_entities` and consumed by the `cull_hidden` guard.
393 render_world.insert_resource(HiddenExtracts::default());
394 // Painter registry for plugin-contributed `ExtractedNative` leaves; see
395 // [`Self::register_native_painter`].
396 render_world.insert_resource(crate::native::NativePainters::default());
397
398 // Install the render schedule ordered `Prepare -> Render`.
399 let mut render_schedule = Schedule::new(Render);
400 render_schedule.configure_sets((RenderStage::Prepare, RenderStage::Render).chain());
401 render_world.add_schedule(render_schedule);
402
403 // Install the dedicated extract schedule with a single `Extract` set. Wave 2 migrates the legacy
404 // `extract_fns` list onto this schedule.
405 let mut extract_schedule = Schedule::new(ExtractSchedule);
406 extract_schedule.configure_sets((ExtractSet::Extract,));
407 render_world.add_schedule(extract_schedule);
408
409 let mut s = Self {
410 world,
411 render_world,
412 extract_fns: Vec::new(),
413 desired_threads: default_thread_budget(),
414 installed_plugins: HashMap::new(),
415 installed_plugin_types: HashSet::new(),
416 };
417 // Cycle message buffers at the start of `Input` each tick.
418 s.add_systems(TickStage::Input, bevy_ecs::message::message_update_system);
419 // Clear the per-tick `AnimationsActive` flag before any animation
420 // driver runs (Input is chained before Systems). Drivers re-raise
421 // it while they still have motion; the window backend reads it
422 // after the tick to re-arm the redraw for the next frame.
423 s.add_systems(
424 TickStage::Input,
425 crate::render_world::reset_animations_active,
426 );
427 s.add_systems(TickStage::Input, crate::tick::reset_wake_deadline);
428 // `window.size()` / `window.dpr()` read a process-wide cache that
429 // scripts reach without a world; keep it on the live viewport.
430 s.add_systems(TickStage::Input, crate::window_state::publish_viewport);
431 // Wave-D dirty-queue lifecycle. `clear_signal_dirty` keeps the legacy
432 // `Signals::dirty` set tidy for embedders that still hold a `Res<Signals>`
433 // reference; `clear_property_store_dirty` runs against the canonical
434 // typed queue so derivation systems and the theme propagation consumer
435 // observe in-tick `set()` calls before the next tick starts with a
436 // clean dirty set.
437 s.add_systems(TickStage::A11ySync, crate::signals::clear_signal_dirty);
438 s.add_systems(
439 TickStage::A11ySync,
440 crate::property_store::clear_property_store_dirty,
441 );
442 s.add_systems(
443 TickStage::A11ySync,
444 crate::signals::clear_array_signal_changes,
445 );
446 // Wave-D back-mirror: pre wave-D systems wrote into `Signals` which
447 // mirrored forward into `PropertyStore`. Post wave-D internal systems
448 // write directly to `PropertyStore`, so we run the mirror in the
449 // reverse direction - copy every dirty global `Str` cell into the
450 // legacy `Signals` map so embedders that still call
451 // `Res<Signals>.get(...)` keep observing the latest value. Registered
452 // in `Systems` after the property bus drain so this tick's writes
453 // are observable.
454 crate::property_store::init_external_properties();
455 s.add_systems(
456 TickStage::CommandDrain,
457 crate::property_store::drain_external_properties,
458 );
459 s.add_systems(
460 TickStage::Systems,
461 crate::signals::mirror_property_store_globals_to_signals,
462 );
463 // Insert [`crate::components::StyleManager`] (the W4.6 rename of the legacy
464 // `OsTheme` resource - now exposing the 5-state AdwColorScheme model:
465 // Default / ForceLight / ForceDark / PreferLight / PreferDark) and register
466 // the W1.6 split: [`crate::signals::style_manager_to_signal`] (producer)
467 // writes `"dark"`/`"light"` into `Signals["__theme__"]` from
468 // `StyleManager::effective_dark`, the existing
469 // [`mirror_signals_to_property_store`] pushes the write into [`PropertyStore`]
470 // keyed on `PropertyKey::Global("__theme__")`, and
471 // [`apply_theme_signal_to_root_classes`] (consumer) updates root
472 // [`crate::components::LumenClasses`] only when the notify queue carries a
473 // `__theme__` write. Replaces the legacy [`apply_theme_class_to_root`]
474 // mutex-dance system.
475 s.world
476 .insert_resource(crate::components::StyleManager::default());
477 s.add_systems(TickStage::Systems, crate::signals::style_manager_to_signal);
478 // Explicitly after the producer: the dirty queue is cleared at end of
479 // tick, so a consumer scheduled ahead of the producer would observe
480 // the `__theme__` write on no tick at all, and whether it was ahead
481 // used to depend on the incidental topological order of the whole
482 // stage.
483 s.add_systems(
484 TickStage::Systems,
485 crate::signals::apply_theme_signal_to_root_classes
486 .after(crate::signals::style_manager_to_signal),
487 );
488 // W5.4 - install the [`DefaultLayoutDirection`] resource (Ltr
489 // by default; the translation registration in `lumen-runtime`
490 // overwrites it from the resolved locale) and register
491 // [`resolve_layout_direction`] in
492 // `LayoutSync` so every entity has a fresh [`ResolvedDirection`]
493 // before the layout backend reads it.
494 s.world
495 .insert_resource(crate::components::DefaultLayoutDirection::default());
496 s.add_systems(
497 TickStage::LayoutSync,
498 crate::components::resolve_layout_direction,
499 );
500 s
501 }
502
503 /// Registers a message type with [`MessageRegistry`] so [`bevy_ecs::message::MessageWriter`] and [`bevy_ecs::message::MessageReader`] can be used for `M`.
504 pub fn add_message<M: Message>(&mut self) -> &mut Self {
505 MessageRegistry::register_message::<M>(&mut self.world);
506 self
507 }
508
509 /// Calls `plugin.build(self)`, consuming the plugin.
510 ///
511 /// Records the plugin's metadata in [`Self::installed_plugins`] so [`Self::is_plugin_added`] and other queries can
512 /// answer without re-running the build closure. Logs (but does not panic) when a declared `depends_on` entry has
513 /// not been installed yet - wave 1 wires the topological sort that would defer the build instead.
514 pub fn add_plugin<P: Plugin + 'static>(&mut self, plugin: P) -> &mut Self {
515 let name = plugin.name();
516 let deps = plugin.depends_on();
517 for dep in deps {
518 if !self.installed_plugins.contains_key(*dep) {
519 // Foundation only logs - wave 1 will fold this into the topo-sort + AppError::MissingDependency error
520 // surface. Today's plugin chains add in correct order already, so missing deps are real bugs.
521 eprintln!(
522 "[lumen-core] plugin `{name}` declares dependency on `{dep}` which has not been added yet"
523 );
524 }
525 }
526 let type_id = TypeId::of::<P>();
527 plugin.build(self);
528 self.installed_plugin_types.insert(type_id);
529 self.installed_plugins.insert(
530 name,
531 Box::new(PluginInfo {
532 name,
533 deps,
534 type_id,
535 }),
536 );
537 self
538 }
539
540 /// Whether this app runs with no interactive session. See [`RunMode`];
541 /// a world that declares no mode reads as interactive.
542 #[must_use]
543 pub fn is_headless(&self) -> bool {
544 self.world
545 .get_resource::<RunMode>()
546 .is_some_and(|mode| mode.headless)
547 }
548
549 /// Returns `true` when a plugin of type `P` has been installed via [`Self::add_plugin`].
550 pub fn is_plugin_added<P: Plugin + 'static>(&self) -> bool {
551 self.installed_plugin_types.contains(&TypeId::of::<P>())
552 }
553
554 /// Returns `true` when a plugin with the supplied [`Plugin::name`] has been installed.
555 pub fn plugin_added(&self, name: &str) -> bool {
556 self.installed_plugins.contains_key(name)
557 }
558
559 /// Registers a strongly-typed handler invoked when a [`Command::Typed`] payload of type `T` is drained.
560 ///
561 /// Replaces the legacy blind `Command::Custom(Box<dyn Any>)` downcast pattern: producers build
562 /// `Command::Typed { type_id: TypeId::of::<T>(), payload: Box::new(value) }`, the drain looks up the handler by
563 /// type id and invokes it with the typed payload.
564 pub fn register_command<T, F>(&mut self, handler: F) -> &mut Self
565 where
566 T: Any + Send,
567 F: Fn(&mut World, Box<T>) + Send + Sync + 'static,
568 {
569 let mut registry = self.world.resource_mut::<CommandRegistry>();
570 registry.register::<T, F>(handler);
571 self
572 }
573
574 /// Adds main-world systems into the `Tick` schedule under the given [`TickStage`] set.
575 pub fn add_systems<M>(
576 &mut self,
577 stage: TickStage,
578 systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
579 ) -> &mut Self {
580 let mut schedules = self.world.resource_mut::<Schedules>();
581 let schedule = schedules
582 .get_mut(Tick)
583 .expect("Tick schedule should be installed by App::new");
584 schedule.add_systems(systems.in_set(stage));
585 self
586 }
587
588 /// Adds render-world systems into the `Render` schedule under the given [`RenderStage`] set.
589 pub fn add_render_systems<M>(
590 &mut self,
591 stage: RenderStage,
592 systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
593 ) -> &mut Self {
594 let mut schedules = self.render_world.resource_mut::<Schedules>();
595 let schedule = schedules
596 .get_mut(Render)
597 .expect("Render schedule should be installed by App::new");
598 schedule.add_systems(systems.in_set(stage));
599 self
600 }
601
602 /// Adds render-world systems into the dedicated [`ExtractSchedule`] under the given [`ExtractSet`].
603 ///
604 /// Foundation only installs the schedule; the legacy [`Self::extract_fns`] list keeps providing cross-world data.
605 /// Wave 2 migrates the existing extractors onto this schedule.
606 pub fn add_extract_systems<M>(
607 &mut self,
608 set: ExtractSet,
609 systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
610 ) -> &mut Self {
611 let mut schedules = self.render_world.resource_mut::<Schedules>();
612 let schedule = schedules
613 .get_mut(ExtractSchedule)
614 .expect("ExtractSchedule should be installed by App::new");
615 schedule.add_systems(systems.in_set(set));
616 self
617 }
618
619 /// Appends an extract fn to [`Self::extract_fns`].
620 pub fn add_extract_fn(&mut self, f: ExtractFn) -> &mut Self {
621 self.extract_fns.push(f);
622 self
623 }
624
625 /// Registers the painter that draws every [`crate::native::ExtractedNative`] leaf carrying
626 /// `extension_id`. Registering the same id twice keeps the later painter.
627 ///
628 /// The backend calls it when the leaf's turn comes in paint order; a backend with no painter
629 /// for an id skips the leaf. See [`crate::native`] for the seam's contracts.
630 ///
631 /// Registering during a run repaints the next frame. Painters are not part of the scene, so
632 /// the frame diff cannot see one arrive; without forcing the repaint, leaves already in the
633 /// tree would stay unpainted until something else changed.
634 pub fn register_native_painter<P: crate::native::NativePainter>(
635 &mut self,
636 extension_id: impl Into<std::sync::Arc<str>>,
637 painter: P,
638 ) -> &mut Self {
639 self.render_world
640 .get_resource_or_insert_with(crate::native::NativePainters::default)
641 .register(extension_id, painter);
642 if let Some(mut dirty) = self.world.get_resource_mut::<FrameDirty>() {
643 dirty.dirty = true;
644 }
645 // Dropping the last painted tree makes the next frame a first frame, which is what gets
646 // past the damage gate: the leaves themselves did not change, only who paints them.
647 if let Some(mut previous) = self.render_world.get_resource_mut::<PreviousScene>() {
648 previous.root = None;
649 }
650 self
651 }
652
653 /// Raises [`Self::desired_threads`] to `n` if it is currently lower (monotonic max across plugins).
654 /// Takes effect at the first [`Self::tick`]; the `LUMEN_THREADS` env var overrides the value.
655 pub fn request_threads_at_least(&mut self, n: usize) -> &mut Self {
656 if n > self.desired_threads {
657 self.desired_threads = n;
658 }
659 self
660 }
661
662 /// Runs one tick: advance the [`crate::tick::Tick`] resource, run the main schedule, then (when [`FrameDirty`] is
663 /// set) `clear_extracted`, every extract fn, the extract schedule, and the render schedule.
664 /// When [`FrameDirty`] is unset, returns after the main schedule; the previous frame's extracted entities remain in
665 /// the render world for the backend's next `RedrawRequested`.
666 pub fn tick(&mut self) {
667 ensure_task_pool(self.desired_threads);
668 if let Some(mut tick) = self.world.get_resource_mut::<crate::tick::Tick>() {
669 tick.advance();
670 }
671 run_schedule_resilient(&mut self.world, Tick);
672
673 // Rotate the main world's removal/despawn event buffers once per tick.
674 // Standalone bevy_ecs (no bevy_app) never rotates `RemovedComponentEvents`
675 // on its own; without this every `Hovered`/`Pressed`/`Focused`/`ChildOf`/
676 // `Style` removal accumulates forever. Runs every tick (the main world
677 // advances regardless of `FrameDirty`) and AFTER `run_schedule(Tick)`, so
678 // all main-world `RemovedComponents` readers - `roll_up_frame_dirty`
679 // (A11ySync), the taffy free-node sweeps, `sync_removed_direction`,
680 // `BindScroll` cleanup - have already observed this tick's removals inside
681 // the schedule. bevy's double-buffered `Events` still retains this tick's
682 // and last tick's removals after `update()`, so nothing a same-tick reader
683 // needed is dropped. Change detection is untouched: schedule systems track
684 // their own per-system `last_run`, not `world.last_change_tick`.
685 self.world.clear_trackers();
686
687 let dirty = self
688 .world
689 .get_resource::<FrameDirty>()
690 .map(|f| f.dirty)
691 .unwrap_or(true);
692 if !dirty {
693 return;
694 }
695
696 clear_extracted(&mut self.render_world);
697 // Open the extract phase so the hierarchy-derived maps
698 // ([`build_parent_map`] & friends) are computed once by the first
699 // extract fn and cloned back by the rest, instead of each of the
700 // six extractors rebuilding them. Strictly scoped to this loop:
701 // `end_phase` disables reuse before the render schedules run and
702 // before the next tick's Systems-stage callers (hover hit-testing)
703 // reach the same helpers, so no stale hierarchy can leak out.
704 if let Some(mut c) = self
705 .world
706 .get_resource_mut::<crate::render_world::ExtractContextCache>()
707 {
708 c.begin_phase();
709 }
710 // Clone the fn-pointer vec to release the immutable borrow on `self.extract_fns` before re-borrowing `self.world` mutably.
711 let fns = self.extract_fns.clone();
712 for f in fns {
713 f(&mut self.world, &mut self.render_world);
714 }
715 if let Some(mut c) = self
716 .world
717 .get_resource_mut::<crate::render_world::ExtractContextCache>()
718 {
719 c.end_phase();
720 }
721
722 // Run extract systems registered via `add_extract_systems`. These read already-extracted render-world state
723 // (e.g. `Changed<ExtractedText>` filters) and queue further render-world work. Foundation ships the schedule
724 // empty; wave 2 wires migrations.
725 run_schedule_resilient(&mut self.render_world, ExtractSchedule);
726
727 run_schedule_resilient(&mut self.render_world, Render);
728
729 // Rotate the render world's removal buffers. `clear_extracted` despawns
730 // the entire transient `Extracted*` set every dirty frame, recording a
731 // removal event for every component on each despawned entity; nothing
732 // frees these without `update()`. The render world holds no
733 // `RemovedComponents` readers, so there is no same-tick observation to
734 // preserve here. Only reachable on dirty ticks (the render pass runs only
735 // when dirty), which is exactly when the despawn churn happens.
736 self.render_world.clear_trackers();
737 }
738
739 /// Borrows the [`CommandReceiver`] resource mutably from the main world.
740 pub fn commands(&mut self) -> Mut<'_, CommandReceiver> {
741 self.world.resource_mut::<CommandReceiver>()
742 }
743
744 /// Constructs a typed [`crate::property_store::Property`] handle bound to
745 /// the global namespace. The cell is created lazily on the first `set` -
746 /// this helper only mints the typed key wrapper, no allocations besides
747 /// the shared `Arc<str>` for the name.
748 ///
749 /// Equivalent to `Property::<T>::new(name)`; lives on [`App`] so app-init
750 /// code can read more like `let count = app.property::<i64>("count");`
751 /// without a separate `use` for the prelude.
752 pub fn property<T>(
753 &self,
754 name: impl Into<std::sync::Arc<str>>,
755 ) -> crate::property_store::Property<T>
756 where
757 T: TryFrom<crate::property_store::PropertyValue>
758 + Into<crate::property_store::PropertyValue>
759 + Clone,
760 {
761 crate::property_store::Property::<T>::new(name)
762 }
763}
764
765/// [`World::run_schedule`], surviving an unwind.
766///
767/// bevy's `schedule_scope` takes the schedule out of the [`Schedules`]
768/// resource for the duration of the run and reinserts it on the way out - but
769/// not on the way out of a panic. A system panic caught above (a module tick,
770/// an embedder's `catch_unwind`) would then leave the schedule missing, and
771/// every later tick would die on "schedule not found" long after the panic
772/// itself was survived. This twin reinserts the schedule whether the run
773/// returned or unwound, then lets the unwind continue.
774fn run_schedule_resilient(world: &mut World, label: impl ScheduleLabel) {
775 use bevy_ecs::schedule::Schedules;
776 use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
777
778 let label = label.intern();
779 let Some(mut schedule) = world
780 .get_resource_mut::<Schedules>()
781 .and_then(|mut schedules| schedules.remove(label))
782 else {
783 // Parity with `World::run_schedule`, which panics on a label no
784 // schedule carries.
785 panic!("The schedule with the label {label:?} was not found.");
786 };
787 let outcome = catch_unwind(AssertUnwindSafe(|| schedule.run(world)));
788 if outcome.is_err() {
789 // The executor unwound mid-run, so its progress state (completed /
790 // ready bitsets) still describes the dead run; left in place it makes
791 // the next run a silent no-op. A fresh executor re-initializes from
792 // the schedule on that next run. The replacement is the same platform
793 // default a new `Schedule` is born with, so a recovered schedule keeps
794 // the executor kind (and its parallelism) it had before the panic.
795 schedule.set_executor(PlatformDefaultExecutor::new());
796 }
797 world
798 .get_resource_or_insert_with(Schedules::default)
799 .insert(schedule);
800 if let Err(payload) = outcome {
801 resume_unwind(payload);
802 }
803}
804
805/// The executor a fresh [`Schedule`](bevy_ecs::schedule::Schedule) starts
806/// with, in a shape [`Schedule::set_executor`](bevy_ecs::schedule::Schedule::set_executor)
807/// accepts.
808///
809/// `bevy_ecs::schedule::default_executor()` picks the executor for the
810/// compiled feature set - multi-threaded where `std` + `multi_threaded` are
811/// on, single-threaded on Wasm and without them - but returns it boxed, and
812/// `set_executor` wants an unboxed `impl SystemExecutor`. This forwards the
813/// box, so panic recovery installs exactly the kind the schedule was built
814/// with instead of hardcoding one.
815struct PlatformDefaultExecutor(Box<dyn SystemExecutor>);
816
817impl PlatformDefaultExecutor {
818 fn new() -> Self {
819 Self(default_executor())
820 }
821}
822
823impl SystemExecutor for PlatformDefaultExecutor {
824 fn init(&mut self, schedule: &SystemSchedule) {
825 self.0.init(schedule);
826 }
827
828 fn run(
829 &mut self,
830 schedule: &mut SystemSchedule,
831 world: &mut World,
832 skip_systems: Option<&FixedBitSet>,
833 error_handler: fn(BevyError, ErrorContext),
834 ) {
835 self.0.run(schedule, world, skip_systems, error_handler);
836 }
837
838 fn set_apply_final_deferred(&mut self, value: bool) {
839 self.0.set_apply_final_deferred(value);
840 }
841}
842
843// Suppress unused-import warnings while the foundation's new types are wired downstream by wave 1.
844#[allow(dead_code)]
845fn _suppress_unused() {
846 let _ = TypeId::of::<Command>();
847}