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