Skip to main content

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}