Skip to main content

unlit3d_examples/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(unsafe_code)]
3
4pub mod scenes;
5
6mod cli;
7
8#[cfg(target_os = "android")]
9pub mod android;
10
11/// The fullscreen button, and the landscape lock that only a browser permits.
12mod fullscreen;
13
14use std::collections::VecDeque;
15use std::io::Write;
16use std::process::ExitCode;
17use unlit_ecs::{Bundle, DynamicBundle, bundle};
18
19use cli::{Args, Parsed};
20use scenes::{Advance, SceneControl};
21use unlit_wgpu::resources::ResourceGraph;
22use unlit_wgpu::scene::ViewportRect;
23use unlit3d::frame::Frame;
24use unlit3d::input::winit::WinitInput;
25use unlit3d::prelude::*;
26use unlit3d::ui::{UiSource, egui};
27use unlit3d::winit::builtin::{
28    DisplayHandle, ExitOn, ExitRequest, ExitTrigger, Resumed, WindowSetup, WinitProxy,
29    request_redraw_while_foreground, window, winit_input_behaviour,
30};
31#[cfg(target_os = "android")]
32use unlit3d::winit::event::OnSuspended;
33use unlit3d::winit::event::{OnUserEvent, OnWindowEvent, WinitHost};
34use unlit3d::winit::presenter::SCENE_PASS_ORDER;
35use winit::event::WindowEvent;
36use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
37use winit::window::Window;
38
39/// The number of samples the windowed loop presents every frame with.
40const SAMPLE_COUNT: u32 = scenes::spin_cube::SAMPLE_COUNT;
41
42/// The timestep a scene is stepped by when it is drawn without a display.
43///
44/// A windowed run advances by how long its last frame took, so its pace depends
45/// on the machine. Drawing to compare against a stored frame has to be
46/// reproducible instead, and this is the fixed step that makes it so.
47pub const FIXED_STEP: f32 = 1.0 / 60.0;
48
49/// The order the scene and its presentation path come up at.
50///
51/// It is the one that sets the frame's skip flag, so it comes before every
52/// reader of the flag: a behaviour that reads it states a later order rather
53/// than leaving the two to chance.
54const READY_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-40);
55
56/// The order the controls run at: after `ensure_presented`, which may have
57/// skipped the frame, and first of the readers that act on the flag, since
58/// switching a scene skips the frame for the two after them.
59const CONTROLS_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-30);
60
61/// The order the scene advances at: after the controls, which may have skipped
62/// the frame, and before the present, which draws what the advance produced.
63const ADVANCE_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-20);
64
65/// The order the scene's region is restated at: after the advance, and before
66/// the library's present, which draws the pass the region belongs to.
67const VIEWPORT_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(0);
68
69/// The order the scene world's queued commands are applied at: after the
70/// library's present, whose render is what queued them.
71const FLUSH_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(20);
72
73/// The order the scene world advances its scene at: after `step_sequence`,
74/// which decides whether this frame advances and records the decision for the
75/// behaviour that runs it. The value shares nothing with the host world's
76/// orders: the two worlds dispatch separately.
77const SCENE_ADVANCE_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(20);
78
79/// Whether this build can serve the Model Context Protocol.
80///
81/// It needs the stdio transport and a tokio runtime, neither of which a
82/// browser or an activity has; the switch is parsed everywhere but only acts
83/// where this is true.
84const MCP_SUPPORTED: bool = cfg!(all(not(target_arch = "wasm32"), not(target_os = "android")));
85
86/// Write `text` to standard output.
87///
88/// Through [`std::io::Write`] rather than `print!`, which the workspace lints
89/// against: a command line that prints its usage is not a reason to relax them.
90fn stdout(text: &str) {
91    let _ = std::io::stdout().write_all(text.as_bytes());
92}
93
94/// Write `text` to standard error.
95fn stderr(text: &str) {
96    let _ = std::io::stderr().write_all(text.as_bytes());
97}
98
99/// Run `future` concurrently: on a worker thread on native, in the browser's
100/// task queue on the web (where a thread cannot be blocked).
101#[cfg(not(target_arch = "wasm32"))]
102fn spawn(future: impl core::future::Future<Output = ()> + Send + 'static) {
103    std::thread::spawn(move || pollster::block_on(future));
104}
105
106/// Run `future` concurrently: on a worker thread on native, in the browser's
107/// task queue on the web (where a thread cannot be blocked).
108#[cfg(target_arch = "wasm32")]
109fn spawn(future: impl core::future::Future<Output = ()> + 'static) {
110    wasm_bindgen_futures::spawn_local(future);
111}
112
113/// Install the logger backend and the panic hook the example reports through.
114///
115/// The backend follows the terminal the platform has: `env_logger` writes to
116/// the standard error a command line owns, logcat takes an activity's, and the
117/// browser console takes a page's. `RUST_LOG` selects the level where there is
118/// an environment to read it from, and `info` is the default, so the example's
119/// own startup messages are visible without a variable. A panic surfaces as an
120/// opaque `unreachable executed` on the web unless the hook logs it with its
121/// stack first.
122fn init_logging() {
123    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
124    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
125        .try_init()
126        .ok();
127    #[cfg(target_os = "android")]
128    // The system starts an activity rather than a shell, so there is no
129    // environment to read a level from and the desktop default stands.
130    android_logger::init_once(
131        android_logger::Config::default().with_max_level(log::LevelFilter::Info),
132    );
133    #[cfg(target_arch = "wasm32")]
134    {
135        console_log::init_with_level(log::Level::Info).ok();
136        std::panic::set_hook(Box::new(console_error_panic_hook::hook));
137    }
138}
139
140/// Run the example from the command line.
141///
142/// Returns the process exit code: the snapshot tests report their result
143/// through it, so a CI run sees a mismatched snapshot as a failed command.
144///
145/// Android never comes through here — its activity has no command line and
146/// enters at the module that entry point lives in instead — but the function
147/// stays part of the library so the two entry points differ in as little as
148/// possible.
149pub fn run() -> ExitCode {
150    init_logging();
151
152    let args = match cli::parse(std::env::args().skip(1)) {
153        Ok(Parsed::Help(help)) => {
154            stdout(&help);
155            return ExitCode::SUCCESS;
156        }
157        Ok(Parsed::Run(args)) => args,
158        Err(error) => {
159            stderr(&format!("error: {error}\n\n{}", cli::usage()));
160            return ExitCode::from(2);
161        }
162    };
163
164    if args.list_scenes {
165        stdout(&scenes::list_text());
166        return ExitCode::SUCCESS;
167    }
168
169    let event_loop = EventLoop::<UserEvent>::with_user_event()
170        .build()
171        .expect("an event loop");
172    windowed(args, event_loop);
173    ExitCode::SUCCESS
174}
175
176/// Drive the windowed example on `event_loop` until it exits.
177///
178/// The loop is built by the caller, because that is the one thing the entry
179/// points differ on: Android's loop has to be handed the activity it runs in,
180/// which only the activity's own entry point has.
181fn windowed(args: Args, event_loop: EventLoop<UserEvent>) {
182    // Poll rather than wait: the scene animates every frame.
183    event_loop.set_control_flow(ControlFlow::Poll);
184    let scene = scenes::by_id(&args.scene).expect("validated by the CLI");
185    let proxy = event_loop.create_proxy();
186
187    let mut host = WinitHost::<UserEvent>::new();
188    let world = host.world_mut();
189
190    // One writer, shared: the host world carries it and the adapter that
191    // feeds it, and every nested world the host reaches gets a clone of it.
192    // Each world then builds its own buffer from the same stream.
193    let input = InputWriter::new();
194    // The writer is read by every source, so it lives in the shared subtree.
195    let shared = world.shared();
196    world
197        .scope_mut(shared)
198        .expect("the shared scope exists")
199        .spawn(bundle![input.clone()]);
200    world
201        .root_scope_mut()
202        .spawn(bundle![winit_input_behaviour(WinitInput::new(input))]);
203
204    // The window is requested at the first resume, not created here: a
205    // platform may not allow a window before then.
206    let initial_size = args.size.unwrap_or(scene.size);
207    #[cfg_attr(
208        not(target_arch = "wasm32"),
209        expect(unused_mut, reason = "wasm32 reassigns to append the canvas")
210    )]
211    let mut attributes = Window::default_attributes()
212        .with_title("unlit3d + winit")
213        .with_inner_size(winit::dpi::LogicalSize::new(initial_size.0, initial_size.1));
214    #[cfg(target_arch = "wasm32")]
215    {
216        use winit::platform::web::WindowAttributesExtWebSys;
217        attributes = attributes.with_append(true);
218    }
219    world.root_scope_mut().spawn(WindowSetup { attributes });
220
221    world
222        .root_scope_mut()
223        .spawn(ExitOn(ExitTrigger::CloseRequested));
224    world.root_scope_mut().spawn(ExitOn(ExitTrigger::Escape));
225    world
226        .root_scope_mut()
227        .spawn(bundle![request_redraw_while_foreground()]);
228
229    // The application's state and its per-callback behaviours share one
230    // entity: every component is its own cell, so a callback writing a sibling
231    // state component never conflicts with the behaviour being run.
232    let app = world.root_scope_mut().spawn(App {
233        scene,
234        proxy,
235        mcp: args.mcp && MCP_SUPPORTED,
236    });
237    // The frame's own behaviours. Requesting the GPU states no order: nothing
238    // reads what it writes in the same frame. The rest state one, chained from
239    // the behaviour that brings the scene up and decides whether the frame runs
240    // at all, through the readers of that decision, to the library's present
241    // and the flush that follows it.
242    world.root_scope_mut().spawn(bundle![request_gpu(app)]);
243    world.root_scope_mut().spawn(bundle![ensure_presented(app)]);
244    world
245        .root_scope_mut()
246        .spawn(bundle![serve_scene_controls(app)]);
247    world.root_scope_mut().spawn(bundle![advance_scene(app)]);
248    world.root_scope_mut().spawn(bundle![restate_viewport(app)]);
249    world.root_scope_mut().spawn(bundle![present_frames()]);
250    world.root_scope_mut().spawn(bundle![flush_scene(app)]);
251    let (pump, redraw) = pump_presented();
252    world.root_scope_mut().spawn(bundle![pump]);
253    world.root_scope_mut().spawn(bundle![redraw]);
254    // A resize is a second `OnWindowEvent` behaviour, so it sits on its own
255    // entity: two of one family on an entity would be two borrows of one cell.
256    world.root_scope_mut().spawn(bundle![resize_scene(app)]);
257    // Serving a user event needs the proxy and the app's state, so it captures
258    // the app entity; it is a family of its own, so it sits on its own entity.
259    world
260        .root_scope_mut()
261        .spawn(bundle![handle_user_event(app)]);
262    #[cfg(target_os = "android")]
263    world
264        .root_scope_mut()
265        .spawn(bundle![release_on_suspend(app)]);
266
267    host.run(event_loop).expect("the event loop runs");
268}
269
270/// Events delivered to the winit loop from outside a `WindowEvent`.
271enum UserEvent {
272    /// The async GPU setup finished; the scene is built from it here, on the
273    /// main thread.
274    Ready(Gpu),
275    /// The async GPU setup failed; the message is reported and the app exits.
276    Failed(String),
277    /// A command from the MCP transport, to run against a world of the host.
278    ///
279    /// It arrives on this thread because a world is not `Send`; the app
280    /// resolves the command's world handle and dispatches it here, between
281    /// frames, so no command ever runs while a frame is being drawn.
282    ///
283    /// The command is wrapped in an `Option` because a user event reaches its
284    /// callbacks mutably and the one that runs it takes it out.
285    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
286    Command(Option<unlit_ecs::mcp::McpCommand>),
287}
288
289/// The GPU context a scene draws with, requested asynchronously.
290///
291/// Everything here is a `Send + Sync` wgpu handle, so the request can run off
292/// the event loop's thread and the context can cross back to it. The scene
293/// itself cannot cross: its ECS world is single-threaded, so it is built on
294/// the main thread once this arrives.
295///
296/// The context and its device outlive a suspension: rebuilding them would drop
297/// every pipeline, buffer and texture the scene holds, for a change the
298/// platform only makes to the native window.
299#[derive(Clone)]
300struct Gpu {
301    instance: wgpu::Instance,
302    adapter: wgpu::Adapter,
303    device: wgpu::Device,
304    queue: wgpu::Queue,
305    /// What the device can do beyond the WebGPU baseline, captured while the
306    /// adapter is still here to report it.
307    ///
308    /// `wgpu::Device` exposes its limits and features but not the downlevel
309    /// flags, and the adapter is not kept once the device exists, so this is
310    /// read once and carried; see [`DeviceCapabilities`].
311    capabilities: DeviceCapabilities,
312}
313
314impl Gpu {
315    /// Request the adapter and device that can present to `surface`.
316    ///
317    /// `surface` is a *probe*: it exists only so the adapter is required to be
318    /// able to present to the window, and is dropped again before the device
319    /// is requested. The context deliberately ends up owning no surface,
320    /// because a surface is tied to the native window it was created from —
321    /// Android replaces that window across a suspension — while the adapter
322    /// and device are not. Whoever presents builds one for the window it has
323    /// at that moment.
324    ///
325    /// Async because both requests resolve on the browser's task queue on the
326    /// web; blocking on them there would hang the page.
327    async fn request(
328        instance: wgpu::Instance,
329        surface: wgpu::Surface<'static>,
330    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
331        let adapter = instance
332            .request_adapter(&wgpu::RequestAdapterOptions {
333                power_preference: wgpu::PowerPreference::HighPerformance,
334                compatible_surface: Some(&surface),
335                ..Default::default()
336            })
337            .await?;
338        drop(surface);
339
340        // The tier is what decides how much of the WebGPU baseline the device
341        // is asked for, and so which paths it takes. `UNLIT3D_DEVICE_TIER`
342        // selects it — the WebGPU baseline by default, WebGL2's shape when
343        // asked for; see [`DeviceTier`].
344        let tier = DeviceTier::from_env();
345        let capabilities = tier.capabilities_of(&adapter);
346        let (device, queue) = adapter
347            .request_device(&wgpu::DeviceDescriptor {
348                required_limits: tier.limits(&adapter.limits()),
349                ..Default::default()
350            })
351            .await?;
352        Ok(Self {
353            instance,
354            adapter,
355            device,
356            queue,
357            capabilities,
358        })
359    }
360}
361
362/// Where the one-time GPU setup stands.
363///
364/// The request is asynchronous, so a resume that arrives before it lands must
365/// not start a second one — two devices and two adapters would be built, and
366/// only one of them could present. The `Requested` state is what remembers
367/// that the setup is already in flight.
368#[derive(Default)]
369enum GpuState {
370    /// No request has been made: the loop is before its first resume, or the
371    /// first one failed and the app is exiting.
372    #[default]
373    Idle,
374    /// The request is in flight; `user_event` carries its result.
375    Requested,
376    /// The context is here and the scene can draw with it.
377    Ready(Gpu),
378}
379
380/// The scene's world and the frame loop that drives it.
381///
382/// Deliberately knows nothing about how a frame reaches a display: it advances
383/// the scene's behaviour and renders into whatever target is bound, so the
384/// windowed run and the snapshot tests differ only in what they bind and what
385/// with the result. The scene itself is a [`scenes::SceneDef`] whose build
386/// populated the world and returned the [`SceneControl`] this drives.
387///
388/// Public because the snapshot tests drive the same scenes offscreen: the web
389/// renders them and compares each frame against its snapshot, which is the
390/// comparison they make.
391///
392/// The world is *not* owned here: a scene is the driving state of a world the
393/// caller owns, and every frame method takes that world back as `&mut World`.
394/// A windowed run stores the world as a component of its host world and pulls
395/// it out per frame; the snapshot tests keep a local one. Either way one world
396/// has exactly one scene driving it, and the scene never reaches across to
397/// another.
398pub struct Scene {
399    /// The renderer resource entity: the handle every renderer access goes
400    /// through.
401    pub renderer: Entity,
402    /// The size the scene's content is drawn at, in pixels: the target's own,
403    /// or the letterboxed part of it when [`Self::viewport`] is set.
404    ///
405    /// This is what the scene's camera is built for and what its behaviour is
406    /// handed, so the projection matches what the viewport scales into place.
407    size: (u32, u32),
408    /// The region of the target the 3D content is drawn into, or `None` to draw
409    /// into the whole of it.
410    ///
411    /// Set when the scene declares an aspect of its own — see
412    /// [`scenes::SceneDef::baseline`] — so a target of any shape shows the same
413    /// picture at a different scale rather than showing more of it.
414    pub viewport: Option<ViewportRect>,
415    /// The frame pass the scene's own sources record into.
416    ///
417    /// Everything a scene builds hangs under it, which is what puts the scene
418    /// in the frame: a pass records the sources in its own scope, so the pass a
419    /// source's entity sits under is the pass its scene is drawn in.
420    pub pass: Entity,
421    /// The snapshot the scene's test compares each frame against, if any.
422    ///
423    /// The scene's per-frame behaviour is not here: it was moved into the
424    /// scene's own world as a frame behaviour at build time, so a frame
425    /// advances it without `&mut Scene`.
426    pub snapshot: scenes::Snapshot,
427    /// The frame-rate measurement the windowed shell's readout displays.
428    ///
429    /// Unused by the snapshot tests, which have no display to report a rate on.
430    frame_rate: Entity,
431    /// The index of the next frame the scene's behaviour advances from.
432    ///
433    /// Carried across calls because [`Self::advance`] hands it to the frame
434    /// behaviours through the frame's context, and a fixed sequence may skip a
435    /// frame without advancing it.
436    frame: u32,
437    /// The scene-selector's switch component, read once per frame.
438    switch: Entity,
439    /// The fullscreen button's request component, read once per frame.
440    fullscreen: Entity,
441    /// The window's fullscreen state, written once per frame for the button to
442    /// label itself from: a panel runs with no window to ask.
443    fullscreen_active: Entity,
444    /// A scene switch requested by the selector, consumed by the frame loop.
445    pending: Option<&'static scenes::SceneDef>,
446}
447
448/// Set by the windowed shell's scene-selector panel, read once by the frame
449/// loop.
450struct SceneSwitch(Option<&'static scenes::SceneDef>);
451
452/// Set by the windowed shell's fullscreen button, read once by the frame loop.
453///
454/// A flag rather than the state to move to: the button asks for the window to
455/// change, and which way it changes is read from the window itself at the
456/// moment the request is served. A panel runs while the world is borrowed to
457/// lay the UI out, so it can only record the click; the frame loop owns the
458/// window the request is served on. See [`fullscreen`].
459struct FullscreenRequest(bool);
460
461/// Whether the window is fullscreen, written once per frame by the frame loop.
462///
463/// The button labels itself for the action it would take, and a panel has no
464/// window to ask, so the frame loop reads the window's state and leaves it here
465/// for the panel to draw from.
466struct FullscreenActive(bool);
467
468/// The host entity carrying the scene's world and its [`Scene`].
469///
470/// `None` until the GPU context arrives. The world itself outlives a
471/// suspension — only the swap chain it is presented through does not — so the
472/// scene's state survives one.
473struct SceneEntity(Option<Entity>);
474
475/// The render world an application presents through, under the name it reports
476/// to the MCP tools.
477///
478/// A world is reachable from the host only as a component of an entity, and the
479/// name is what the report lists it by: spawning the two together is what makes
480/// the scene findable at all.
481struct RenderWorld {
482    /// The world the scene draws in.
483    world: World,
484    /// The scene driving it.
485    scene: Scene,
486    /// The name the MCP tools list it under.
487    name: &'static str,
488}
489
490impl Bundle for RenderWorld {
491    fn name(&self) -> &'static str {
492        self.name
493    }
494
495    fn into_bundle(self) -> DynamicBundle {
496        bundle![self.world, self.scene, WorldName(self.name)]
497    }
498}
499
500/// The name this example reports a host world under to the MCP tools.
501///
502/// The example keeps its own component rather than borrowing the core's: it
503/// does not depend on unlit3dx, and a name is all the report needs. Targets
504/// without the MCP dependency never read it back.
505#[cfg_attr(
506    any(target_arch = "wasm32", target_os = "android"),
507    expect(
508        dead_code,
509        reason = "only the MCP bridge reads the name back, and it is not built for these targets"
510    )
511)]
512struct WorldName(&'static str);
513
514/// The entity carrying the presentation path, absent until the scene and
515/// surface are ready.
516///
517/// The path is its own entity rather than a component of the app because the
518/// library's frame behaviours find it by querying for a [Presenter]; dropping
519/// the entity is how a scene switch gives the path up.
520struct PresenterEntity(Option<Entity>);
521
522/// The scene the example starts with; a switch replaces it.
523struct InitialScene(&'static scenes::SceneDef);
524
525/// The selector window's rectangle the last time a scene was live, so a scene
526/// switch can rebuild it in the same place and at the same size.
527struct SelectorPos(Option<egui::Rect>);
528
529/// The GPU-info panel's rectangle the last time a scene was live, so a scene
530/// switch can rebuild it where the user left it.
531struct GpuInfoPos(Option<egui::Rect>);
532
533/// The application's state, spawned as one object.
534///
535/// Every component is its own cell, so a callback writing a sibling state
536/// component never conflicts with the behaviour being run. The behaviours are
537/// spawned beside this entity rather than in it, each handed the handle back, so
538/// a callback reaches the state it reads and the proxy it posts its own events
539/// through from one place.
540struct App {
541    /// The scene the application starts with.
542    scene: &'static scenes::SceneDef,
543    /// The proxy a callback posts an event to the loop it runs on with.
544    proxy: EventLoopProxy<UserEvent>,
545    /// Whether to serve the world over the Model Context Protocol.
546    mcp: bool,
547}
548
549impl Bundle for App {
550    fn into_bundle(self) -> DynamicBundle {
551        bundle![
552            GpuState::Idle,
553            SceneEntity(None),
554            PresenterEntity(None),
555            InitialScene(self.scene),
556            SelectorPos(None),
557            GpuInfoPos(None),
558            McpEnabled(self.mcp),
559            ExitRequest(false),
560            WinitProxy(self.proxy),
561            FrameSkip::default(),
562        ]
563    }
564}
565
566/// Where the scene's draggable panels opened, carried from the scene a switch
567/// replaced.
568///
569/// The two rectangles travel together because they are one idea: each is the
570/// position egui remembered for a panel, read from the dying scene's context
571/// and handed to the new scene's. A scene built without a carried position
572/// opens its panels in their default corners.
573#[derive(Clone, Copy, Default)]
574pub struct PanelRects {
575    /// The scene-selector window's last rectangle.
576    pub selector: Option<egui::Rect>,
577    /// The GPU-info panel's last rectangle.
578    pub gpu_info: Option<egui::Rect>,
579}
580
581impl PanelRects {
582    /// The panels' rectangles as the app entity remembers them.
583    fn read(world: &World, app: Entity) -> Self {
584        Self {
585            selector: world
586                .root_scope()
587                .query_one::<&SelectorPos>(app)
588                .ok()
589                .and_then(|mut pos| pos.get().0),
590            gpu_info: world
591                .root_scope()
592                .query_one::<&GpuInfoPos>(app)
593                .ok()
594                .and_then(|mut pos| pos.get().0),
595        }
596    }
597}
598
599/// Whether to serve the world over the Model Context Protocol rather than
600/// drive a scene on this thread.
601///
602/// False on the web and on Android, which have no stdio transport; see
603/// [`MCP_SUPPORTED`].
604struct McpEnabled(bool);
605
606/// The interval a scene's index advances at, in seconds, or `None` to advance
607/// it once per drawn frame.
608struct SequenceStep(Option<f32>);
609
610/// Seconds accumulated towards the next advance of a scene's index.
611struct SequenceClock(f32);
612
613/// Whether this frame advanced the scene's index, decided by the sequence step
614/// and read by the behaviour that runs the scene's own advance.
615struct SequenceStepped(bool);
616
617/// A scene's own per-frame behaviour, stored in its world so the frame
618/// behaviour that drives it can call it with the world it mutates.
619///
620/// `None` only while it is being called: the closure needs `&mut World`, and a
621/// component cannot be borrowed mutably and handed the world it lives in at the
622/// same time, so it is taken out, called, and put back.
623struct SceneAdvance(Option<Advance>);
624
625/// The scene's per-frame state, spawned with its own advance.
626///
627/// The state and the behaviour that moves it live on one entity so a frame
628/// advances the scene without `&mut Scene`: the index the sequence step keeps
629/// and the clock it accumulates into are read by the advance beside them, and
630/// neither half means anything without the other.
631struct SceneFrame {
632    /// The scene's own per-frame behaviour.
633    advance: Advance,
634    /// The interval the scene's index advances at, or `None` to advance it once
635    /// per drawn frame.
636    step: Option<f32>,
637}
638
639impl Bundle for SceneFrame {
640    fn into_bundle(self) -> DynamicBundle {
641        bundle![
642            SceneAdvance(Some(self.advance)),
643            SequenceStep(self.step),
644            SequenceClock(0.0),
645            SequenceStepped(true),
646        ]
647    }
648}
649
650/// Run the scene's own per-frame behaviour, which needs `&mut World`.
651struct RunSceneAdvance {
652    /// The entity carrying the scene's [`SceneAdvance`] and sequence state.
653    entity: Entity,
654    /// The frame the behaviour is advanced by.
655    frame: Frame,
656}
657
658impl Command for RunSceneAdvance {
659    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
660        let Self { entity, frame } = *self;
661        let advance = world
662            .root_scope()
663            .query_one::<&mut SceneAdvance>(entity)
664            .ok()
665            .and_then(|mut slot| slot.get().0.take());
666        let Some(mut advance) = advance else {
667            return Ok(());
668        };
669        advance(world, frame.index, frame.delta_time, frame.size);
670        let _ = world
671            .root_scope()
672            .query_one::<&mut SceneAdvance>(entity)
673            .map(|mut slot| slot.get().0 = Some(advance));
674        Ok(())
675    }
676}
677
678/// Run one MCP command against a nested world of the host.
679#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
680struct RunInWorld {
681    /// The host entity carrying the world the command names.
682    entity: Entity,
683    /// The command to run.
684    command: unlit_ecs::mcp::McpCommand,
685}
686
687#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
688impl Command for RunInWorld {
689    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
690        let Self { entity, command } = *self;
691        let Ok(mut nested) = world.root_scope().query_one::<&mut World>(entity) else {
692            command.fail(format!("host entity {entity:?} carries no world"));
693            return Ok(());
694        };
695        command.dispatch(&mut *nested.get());
696        Ok(())
697    }
698}
699
700/// Run one MCP command against the host world itself.
701#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
702struct RunInHost {
703    /// The command to run.
704    command: unlit_ecs::mcp::McpCommand,
705}
706
707#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
708impl Command for RunInHost {
709    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
710        self.command.dispatch(world);
711        Ok(())
712    }
713}
714
715/// The GPU context, once the asynchronous setup has landed.
716fn gpu(world: &World, app: Entity) -> Option<Gpu> {
717    let mut state = world.root_scope().query_one::<&GpuState>(app).ok()?;
718    match state.get() {
719        GpuState::Ready(gpu) => Some(gpu.clone()),
720        GpuState::Idle | GpuState::Requested => None,
721    }
722}
723
724/// Build the presentation path once the scene's queued world has landed.
725///
726/// The window is created before the GPU request, so the raw surface can travel
727/// through the asynchronous setup. `BuildPresenter` creates the library surface
728/// from that raw handle on the main thread, after the scene world exists.
729struct BuildPresenter {
730    app: Entity,
731    scene: Entity,
732    renderer: Entity,
733    pass: Entity,
734    gpu: Gpu,
735    size: (u32, u32),
736    surface: wgpu::Surface<'static>,
737    path: PresentationPath,
738}
739
740impl Command for BuildPresenter {
741    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
742        let presenter = match build_presenter(
743            world,
744            Some(self.scene),
745            self.renderer,
746            self.pass,
747            self.surface,
748            &self.gpu.instance,
749            &self.gpu.adapter,
750            self.size,
751            self.path,
752        ) {
753            Ok(presenter) => presenter,
754            Err(error) => {
755                log::error!("failed to build the presentation path: {error}");
756                return Ok(());
757            }
758        };
759        let entity = world.root_scope_mut().spawn(bundle![presenter]);
760        world
761            .root_scope()
762            .query_one::<&mut PresenterEntity>(self.app)?
763            .get()
764            .0 = Some(entity);
765        Ok(())
766    }
767}
768
769/// Bring the scene and its presentation path up, as far as the state allows.
770///
771/// Spawning the scene world and building its surface both need `&mut World`,
772/// so they are queued. The next frame finishes whichever stage has landed.
773fn present(world: &World, app: Entity) {
774    let Some(gpu) = gpu(world, app) else {
775        return;
776    };
777    let Some(window) = window(world) else {
778        return;
779    };
780    let window_size = window.inner_size();
781    let size = (window_size.width.max(1), window_size.height.max(1));
782    let def = world
783        .root_scope()
784        .query_one::<&InitialScene>(app)
785        .expect("the app entity carries its initial scene")
786        .get()
787        .0;
788    let mcp = world
789        .root_scope()
790        .query_one::<&McpEnabled>(app)
791        .is_ok_and(|mut mcp| mcp.get().0);
792    let scene_entity = match world
793        .root_scope()
794        .query_one::<&SceneEntity>(app)
795        .ok()
796        .and_then(|mut slot| slot.get().0)
797    {
798        Some(entity) => entity,
799        None => {
800            let (scene_world, scene) = Scene::new(
801                gpu.device.clone(),
802                gpu.queue.clone(),
803                gpu.capabilities,
804                size,
805                scenes::SceneOptions {
806                    ui: def.ui,
807                    selector: true,
808                    reproducible: false,
809                    letterbox: true,
810                    sequence_step: def.step_seconds,
811                },
812                def,
813                PanelRects::read(world, app),
814            );
815            let entity = world.root_scope().queue().spawn(RenderWorld {
816                world: scene_world,
817                scene,
818                name: "render",
819            });
820            let _ = world
821                .root_scope()
822                .query_one::<&mut SceneEntity>(app)
823                .map(|mut slot| slot.get().0 = Some(entity));
824            window.set_title(&format!(
825                "unlit3d{} — {}",
826                if mcp { " (MCP)" } else { "" },
827                def.title
828            ));
829            return;
830        }
831    };
832    if world
833        .root_scope()
834        .query_one::<&World>(scene_entity)
835        .is_err()
836    {
837        return;
838    }
839    let (renderer, pass) = {
840        let mut scene = world
841            .root_scope()
842            .query_one::<&mut Scene>(scene_entity)
843            .expect("the scene entity carries a scene");
844        scene.get().resize(size, def.baseline);
845        (scene.get().renderer, scene.get().pass)
846    };
847    if presenter_entity(world, app).is_some() {
848        return;
849    }
850    let surface = match gpu.instance.create_surface(window.clone()) {
851        Ok(surface) => surface,
852        Err(error) => {
853            log::error!("failed to build the presentation surface: {error}");
854            return;
855        }
856    };
857    let path = if mcp {
858        PresentationPath::Intermediate(TargetConfig {
859            samples: def.samples,
860            with_depth: def.depth,
861            ..TargetConfig::default()
862        })
863    } else {
864        PresentationPath::Surface {
865            samples: SAMPLE_COUNT,
866            with_depth: true,
867        }
868    };
869    world.root_scope().queue().push(BuildPresenter {
870        app,
871        scene: scene_entity,
872        renderer,
873        pass,
874        gpu,
875        size,
876        surface,
877        path,
878    });
879    window.request_redraw();
880}
881
882/// The entity carrying the presentation path, while one is up.
883fn presenter_entity(world: &World, app: Entity) -> Option<Entity> {
884    world
885        .root_scope()
886        .query_one::<&PresenterEntity>(app)
887        .ok()
888        .and_then(|mut slot| slot.get().0)
889}
890
891/// Release the swap chain, keeping the presentation path for a later resume.
892fn release_surface(world: &World, app: Entity) {
893    let Some(entity) = presenter_entity(world, app) else {
894        return;
895    };
896    let released = world
897        .root_scope()
898        .query_one::<&mut Presenter>(entity)
899        .map(|mut presenter| presenter.get().release(world));
900    if let Ok(Err(error)) = released {
901        log::error!("failed to release the presentation path: {error}");
902    }
903}
904
905/// Release the presentation path and drop it, before its scene goes.
906fn release_presenter(world: &World, app: Entity) {
907    release_surface(world, app);
908    let entity = world
909        .root_scope()
910        .query_one::<&mut PresenterEntity>(app)
911        .ok()
912        .and_then(|mut slot| slot.get().0.take());
913    if let Some(entity) = entity {
914        world.root_scope().queue().despawn(entity);
915    }
916}
917
918#[cfg(target_os = "android")]
919fn release(world: &World, app: Entity) {
920    release_surface(world, app);
921}
922
923/// Rebuild the app around a different scene.
924fn switch_scene(world: &World, app: Entity, def: &'static scenes::SceneDef) {
925    // The target and blit belong to the scene world, so both go with it.
926    // The switching frame skips drawing before the replacement is built.
927    release_presenter(world, app);
928    if let Some(entity) = world
929        .root_scope()
930        .query_one::<&mut SceneEntity>(app)
931        .ok()
932        .and_then(|mut slot| slot.get().0.take())
933    {
934        world.root_scope().queue().despawn(entity);
935    }
936    if let Ok(mut initial) = world.root_scope().query_one::<&mut InitialScene>(app) {
937        initial.get().0 = def;
938    }
939    present(world, app);
940}
941/// Run the scene world's input dispatch for the frame.
942fn dispatch_input_on_frame() -> OnFrame {
943    OnFrame::new(|world, _entity, _frame| {
944        dispatch_input(world);
945    })
946}
947
948/// Decide whether this frame advances the scene's sequence.
949///
950/// A scene with a fixed step advances once its clock has accumulated one; one
951/// without advances every frame. The decision is recorded in `SequenceStepped`
952/// for the behaviour that runs the advance.
953fn step_sequence(entity: Entity) -> OnFrame {
954    OnFrame::new(move |world, _entity, frame| {
955        let stepped = match world
956            .root_scope()
957            .query_one::<&SequenceStep>(entity)
958            .ok()
959            .and_then(|mut step| step.get().0)
960        {
961            Some(step) => world
962                .root_scope()
963                .query_one::<&mut SequenceClock>(entity)
964                .map(|mut clock| tick(&mut clock.get().0, step, frame.delta_time))
965                .expect("the scene frame entity carries a sequence clock"),
966            None => true,
967        };
968        let _ = world
969            .root_scope()
970            .query_one::<&mut SequenceStepped>(entity)
971            .map(|mut flag| flag.get().0 = stepped);
972    })
973}
974
975/// Run the scene's own per-frame behaviour when the sequence advanced, and
976/// count the frame.
977///
978/// The behaviour needs `&mut World`, which a frame callback does not have, so
979/// it is queued; the index is captured before it is counted on, so the
980/// behaviour is handed the frame it advances from.
981fn run_scene_advance(entity: Entity) -> OnFrame {
982    OnFrame::with_order(SCENE_ADVANCE_ORDER, move |world, _entity, frame| {
983        let stepped = world
984            .root_scope()
985            .query_one::<&SequenceStepped>(entity)
986            .is_ok_and(|mut flag| flag.get().0);
987        if !stepped {
988            return;
989        }
990        world.root_scope().queue().push(RunSceneAdvance {
991            entity,
992            frame: *frame,
993        });
994        frame.index = frame.index.wrapping_add(1);
995    })
996}
997
998/// Build the scene and its surface, and short-circuit the frame until both are
999/// ready.
1000///
1001/// The scene's world is spawned through the queue, so it lands on a later
1002/// `apply`; the surface is built out of that world. Each frame retries, and
1003/// the frames that cannot yet draw are skipped.
1004fn ensure_presented(app: Entity) -> OnFrame {
1005    OnFrame::with_order(READY_ORDER, move |world, _entity, _frame| {
1006        let resumed = world
1007            .root_scope()
1008            .query::<(Entity, &Resumed)>()
1009            .iter()
1010            .next()
1011            .is_some_and(|(_, state)| state.0);
1012        if !resumed {
1013            FrameSkip::skip(world);
1014            return;
1015        }
1016        if world
1017            .root_scope()
1018            .query_one::<&SceneEntity>(app)
1019            .map_or(true, |mut slot| slot.get().0.is_none())
1020        {
1021            present(world, app);
1022            FrameSkip::skip(world);
1023            return;
1024        }
1025        if presenter_entity(world, app).is_some() {
1026            return;
1027        }
1028        present(world, app);
1029        if presenter_entity(world, app).is_none() {
1030            FrameSkip::skip(world);
1031        }
1032    })
1033}
1034
1035/// Open a URL the UI asked for, in a new tab when it asked for one.
1036///
1037/// The UI cannot open it itself: a panel runs while the world is borrowed and
1038/// has no page to reach, so it publishes the request in the world and the frame
1039/// loop serves it here — the same split the fullscreen request follows. One
1040/// call covers every platform the example builds for.
1041fn open_url(url: &OpenUrl) {
1042    // The target hint only means anything in a browser, where it decides
1043    // between a new tab and the page the app is already on; elsewhere it is
1044    // ignored.
1045    let target = if url.new_tab { "_blank" } else { "_self" };
1046    let mut options = webbrowser::BrowserOptions::new();
1047    options.with_target_hint(target);
1048    if let Err(error) =
1049        webbrowser::open_browser_with_options(webbrowser::Browser::Default, &url.url, &options)
1050    {
1051        log::debug!("the URL {} could not be opened: {error}", url.url);
1052    }
1053}
1054
1055/// Serve the scene selector, the fullscreen button and the UI's URL requests.
1056///
1057/// A scene switch drops the scene's world, so nothing may still be borrowed
1058/// from it when the switch is served: the control state is read in one block
1059/// and acted on after.
1060fn serve_scene_controls(app: Entity) -> OnFrame {
1061    OnFrame::with_order(CONTROLS_ORDER, move |world, _entity, _frame| {
1062        if FrameSkip::is_skipped(world) {
1063            return;
1064        }
1065        let Some(scene_entity) = world
1066            .root_scope()
1067            .query_one::<&SceneEntity>(app)
1068            .ok()
1069            .and_then(|mut slot| slot.get().0)
1070        else {
1071            return;
1072        };
1073        let active = window(world).is_some_and(|window| fullscreen::active(&window));
1074        let (selector_pos, gpu_info_pos, switch, fullscreen) = {
1075            let Ok(mut nested) = world.root_scope().query_one::<&World>(scene_entity) else {
1076                return;
1077            };
1078            let (selector_pos, gpu_info_pos) = {
1079                let mut scene = world
1080                    .root_scope()
1081                    .query_one::<&Scene>(scene_entity)
1082                    .expect("the scene component exists");
1083                (
1084                    scene.get().selector_rect(nested.get()),
1085                    scene.get().gpu_info_rect(nested.get()),
1086                )
1087            };
1088            let switch = world
1089                .root_scope()
1090                .query_one::<&mut Scene>(scene_entity)
1091                .ok()
1092                .and_then(|mut scene| scene.get().pending.take());
1093            let fullscreen = world
1094                .root_scope()
1095                .query_one::<&mut Scene>(scene_entity)
1096                .is_ok_and(|mut scene| scene.get().fullscreen_request(nested.get(), active));
1097            (selector_pos, gpu_info_pos, switch, fullscreen)
1098        };
1099        let _ = world
1100            .root_scope()
1101            .query_one::<&mut SelectorPos>(app)
1102            .map(|mut slot| slot.get().0 = selector_pos);
1103        let _ = world
1104            .root_scope()
1105            .query_one::<&mut GpuInfoPos>(app)
1106            .map(|mut slot| slot.get().0 = gpu_info_pos);
1107        if let Some(def) = switch {
1108            switch_scene(world, app, def);
1109            FrameSkip::skip(world);
1110            return;
1111        }
1112        if fullscreen && let Some(window) = window(world) {
1113            fullscreen::toggle(&window);
1114        }
1115        // The UI publishes the URLs it was asked to open in the scene's own
1116        // world, so it is drained there rather than here in the host.
1117        if let Ok(mut nested) = world.root_scope().query_one::<&World>(scene_entity) {
1118            for url in drain_open_urls(nested.get()) {
1119                open_url(&url);
1120            }
1121        }
1122    })
1123}
1124
1125/// Advance the scene by the frame's delta.
1126fn advance_scene(app: Entity) -> OnFrame {
1127    OnFrame::with_order(ADVANCE_ORDER, move |world, _entity, frame| {
1128        if FrameSkip::is_skipped(world) {
1129            return;
1130        }
1131        let Some(scene_entity) = world
1132            .root_scope()
1133            .query_one::<&SceneEntity>(app)
1134            .ok()
1135            .and_then(|mut slot| slot.get().0)
1136        else {
1137            return;
1138        };
1139        let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1140            return;
1141        };
1142        let _ = world
1143            .root_scope()
1144            .query_one::<&mut Scene>(scene_entity)
1145            .map(|mut scene| {
1146                scene.get().advance(&mut *nested.get(), frame.delta_time);
1147            });
1148    })
1149}
1150
1151/// Restate the scene's letterboxed region on the pass that draws it.
1152///
1153/// A window resize changes the region, and the library's present renders the
1154/// pass this frame, so this runs before it. The blit pass, when there is one,
1155/// keeps its full-target viewport. A skipped frame still restates the region:
1156/// keeping the pass's viewport in step with the window costs nothing and
1157/// leaves the pass right for the frame that draws again.
1158fn restate_viewport(app: Entity) -> OnFrame {
1159    OnFrame::with_order(VIEWPORT_ORDER, move |world, _entity, _frame| {
1160        let Some(scene_entity) = world
1161            .root_scope()
1162            .query_one::<&SceneEntity>(app)
1163            .ok()
1164            .and_then(|mut slot| slot.get().0)
1165        else {
1166            return;
1167        };
1168        let (scene_pass, viewport) = {
1169            let mut scene = world
1170                .root_scope()
1171                .query_one::<&Scene>(scene_entity)
1172                .expect("the scene entity carries a scene");
1173            (scene.get().pass, scene.get().viewport)
1174        };
1175        let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1176            return;
1177        };
1178        nested
1179            .get()
1180            .root_scope()
1181            .query_one::<&mut FramePass>(scene_pass)
1182            .map(|mut pass| pass.get().viewport = viewport)
1183            .expect("the scene's own pass exists");
1184    })
1185}
1186
1187/// Apply the commands the scene world queued while presenting.
1188///
1189/// The library's present renders the scene world, so its commands are applied
1190/// after it -- a later order -- rather than by the presenter itself. A skipped
1191/// frame still flushes: the commands queue up on the scene world either way,
1192/// and dropping none of them keeps the scene's own record intact.
1193fn flush_scene(app: Entity) -> OnFrame {
1194    OnFrame::with_order(FLUSH_ORDER, move |world, _entity, _frame| {
1195        let Some(scene_entity) = world
1196            .root_scope()
1197            .query_one::<&SceneEntity>(app)
1198            .ok()
1199            .and_then(|mut slot| slot.get().0)
1200        else {
1201            return;
1202        };
1203        let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1204            return;
1205        };
1206        let _ = world
1207            .root_scope()
1208            .query_one::<&mut Scene>(scene_entity)
1209            .map(|mut scene| {
1210                if let Err(error) = scene.get().end_frame(&mut *nested.get()) {
1211                    log::error!("a command queued by a scene failed: {error}");
1212                }
1213            });
1214    })
1215}
1216
1217/// Request the GPU context once a window exists to present through.
1218///
1219/// The window is created by the host at the end of the resume callback, so the
1220/// request waits for a later frame; a `GpuState` that is no longer idle keeps
1221/// a later frame from starting it twice.
1222fn request_gpu(app: Entity) -> OnFrame {
1223    OnFrame::new(move |world, _entity, _frame| {
1224        if !world
1225            .root_scope()
1226            .query_one::<&GpuState>(app)
1227            .is_ok_and(|mut state| matches!(state.get(), GpuState::Idle))
1228        {
1229            return;
1230        }
1231        let Some(window) = window(world) else {
1232            return;
1233        };
1234        let Some(display) = world
1235            .root_scope()
1236            .query::<(Entity, &DisplayHandle)>()
1237            .iter()
1238            .next()
1239            .map(|(_, handle)| handle.0.clone())
1240        else {
1241            return;
1242        };
1243        let Ok(proxy) = world
1244            .root_scope()
1245            .query_one::<&WinitProxy<UserEvent>>(app)
1246            .map(|mut proxy| proxy.get().0.clone())
1247        else {
1248            return;
1249        };
1250        let _ = world
1251            .root_scope()
1252            .query_one::<&mut GpuState>(app)
1253            .map(|mut state| *state.get() = GpuState::Requested);
1254        // The display handle is owned, not borrowed: the instance it builds has
1255        // to outlive this callback. The descriptor is the environment-aware one
1256        // so `WGPU_BACKEND` still selects a backend.
1257        spawn(async move {
1258            let instance = wgpu::util::new_instance_with_webgpu_detection(
1259                wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(display)),
1260            )
1261            .await;
1262            let surface = instance
1263                .create_surface(window)
1264                .expect("the window presents to a surface");
1265            match Gpu::request(instance, surface).await {
1266                Ok(gpu) => {
1267                    let _ = proxy.send_event(UserEvent::Ready(gpu));
1268                }
1269                Err(error) => {
1270                    let _ = proxy.send_event(UserEvent::Failed(error.to_string()));
1271                }
1272            }
1273        });
1274    })
1275}
1276
1277/// Serve the events the app posts to itself.
1278fn handle_user_event(app: Entity) -> OnUserEvent<UserEvent> {
1279    OnUserEvent::new(move |world, _entity, event| match event {
1280        UserEvent::Ready(gpu) => {
1281            let _ = world
1282                .root_scope()
1283                .query_one::<&mut GpuState>(app)
1284                .map(|mut state| {
1285                    *state.get() = GpuState::Ready(gpu.clone());
1286                });
1287            #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1288            if world
1289                .root_scope()
1290                .query_one::<&McpEnabled>(app)
1291                .is_ok_and(|mut mcp| mcp.get().0)
1292            {
1293                start_mcp(world, app);
1294            }
1295            present(world, app);
1296        }
1297        #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1298        UserEvent::Command(command) => {
1299            if let Some(command) = command.take() {
1300                dispatch_command(world, command);
1301            }
1302        }
1303        UserEvent::Failed(error) => {
1304            log::error!("failed to start the renderer: {error}");
1305            let _ = world
1306                .root_scope()
1307                .query_one::<&mut ExitRequest>(app)
1308                .map(|mut request| request.get().0 = true);
1309        }
1310    })
1311}
1312
1313/// Re-state the scene and the surface for a new window size.
1314fn resize_scene(app: Entity) -> OnWindowEvent {
1315    OnWindowEvent::new(move |world, _entity, payload| {
1316        let WindowEvent::Resized(size) = &payload.event else {
1317            return;
1318        };
1319        if size.width == 0 || size.height == 0 {
1320            return;
1321        }
1322        let size = (size.width, size.height);
1323        let Some(scene_entity) = world
1324            .root_scope()
1325            .query_one::<&SceneEntity>(app)
1326            .ok()
1327            .and_then(|mut slot| slot.get().0)
1328        else {
1329            return;
1330        };
1331        let baseline = world
1332            .root_scope()
1333            .query_one::<&InitialScene>(app)
1334            .expect("the app entity carries its initial scene")
1335            .get()
1336            .0
1337            .baseline;
1338        let _ = world
1339            .root_scope()
1340            .query_one::<&mut Scene>(scene_entity)
1341            .map(|mut scene| scene.get().resize(size, baseline));
1342        let Some(presenter) = presenter_entity(world, app) else {
1343            return;
1344        };
1345        let resized = world
1346            .root_scope()
1347            .query_one::<&mut Presenter>(presenter)
1348            .map(|mut presenter| presenter.get().resize(world, size));
1349        if let Ok(Err(error)) = resized {
1350            log::error!("failed to resize the presentation path: {error}");
1351        }
1352    })
1353}
1354
1355/// Drop the swap chain the platform is about to invalidate.
1356#[cfg(target_os = "android")]
1357fn release_on_suspend(app: Entity) -> OnSuspended {
1358    OnSuspended::new(move |world, _entity, ()| {
1359        release(world, app);
1360    })
1361}
1362
1363/// Start the MCP stdio transport, which posts each command back to the loop.
1364///
1365/// It runs on its own thread because serving the protocol blocks; the proxy is
1366/// what carries a command back, because a world is not `Send`.
1367#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1368fn start_mcp(world: &World, app: Entity) {
1369    let Ok(proxy) = world
1370        .root_scope()
1371        .query_one::<&WinitProxy<UserEvent>>(app)
1372        .map(|mut proxy| proxy.get().0.clone())
1373    else {
1374        return;
1375    };
1376    std::thread::Builder::new()
1377        .name("unlit3d-mcp-transport".to_owned())
1378        .spawn(move || {
1379            let sink = move |command| proxy.send_event(UserEvent::Command(Some(command))).is_ok();
1380            if let Err(error) = unlit_ecs::mcp::serve_stdio_with(sink) {
1381                log::error!("the MCP server stopped: {error}");
1382            }
1383        })
1384        .expect("the MCP transport thread starts");
1385}
1386
1387/// Queue one MCP command against the world it names.
1388///
1389/// Running it needs `&mut World`, so it is queued as a command and applied
1390/// between two event callbacks. The host answers `list_worlds` itself: it is
1391/// the only one that knows which entities carry a world and what they are
1392/// called.
1393#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1394fn dispatch_command(world: &World, command: unlit_ecs::mcp::McpCommand) {
1395    use unlit_ecs::mcp::{WorldEntry, WorldId};
1396    if command.wants_worlds() {
1397        let mut worlds = vec![WorldEntry {
1398            world: WorldId::Host,
1399            name: "host",
1400        }];
1401        for (entity, _) in world.root_scope().query::<(Entity, &World)>().iter() {
1402            let Ok(mut name) = world.root_scope().query_one::<&WorldName>(entity) else {
1403                continue;
1404            };
1405            let name = name.get();
1406            worlds.push(WorldEntry {
1407                world: WorldId::handle(entity),
1408                name: name.0,
1409            });
1410        }
1411        command.answer_worlds(worlds);
1412        return;
1413    }
1414    match command.world().entity() {
1415        Some(entity) => world
1416            .root_scope()
1417            .queue()
1418            .push(RunInWorld { entity, command }),
1419        None => world.root_scope().queue().push(RunInHost { command }),
1420    }
1421}
1422
1423impl Scene {
1424    /// Build the scene `def` into a fresh world.
1425    ///
1426    /// Sync, and on the main thread: the ECS world is single-threaded, so it
1427    /// lives on the thread the event loop runs on. The world starts with the
1428    /// frame's GPU context and the renderer resource entity; the scene's build
1429    /// adds its sources, meshes, camera and entities, and returns the
1430    /// [`SceneControl`] that drives them.
1431    pub fn new(
1432        device: wgpu::Device,
1433        queue: wgpu::Queue,
1434        capabilities: DeviceCapabilities,
1435        size: (u32, u32),
1436        options: scenes::SceneOptions,
1437        def: &'static scenes::SceneDef,
1438        panels: PanelRects,
1439    ) -> (World, Self) {
1440        let mut world = World::new();
1441        let context = spawn_context(
1442            &mut world,
1443            device,
1444            queue,
1445            ResourceGraph::new(),
1446            capabilities,
1447        );
1448        let renderer = world
1449            .root_scope_mut()
1450            .spawn(bundle![Renderer::new(context)]);
1451        // The pass the scene draws in. Everything the scene builds hangs under
1452        // it — its sources, and the UI source mounted below — because a pass
1453        // records the sources in its own scope: the pass a source sits under is
1454        // the pass its drawing lands in. Its order is the first of the frame,
1455        // so a presenting pass mounted later draws after it.
1456        let load = RenderLoadOps {
1457            color: match def.clear {
1458                Some(color) => wgpu::LoadOp::Clear(color),
1459                None => color_clear(),
1460            },
1461            ..RenderLoadOps::default()
1462        };
1463        let pass = world.root_scope_mut().spawn(Pass {
1464            load,
1465            ..Pass::new(SCENE_PASS_ORDER)
1466        });
1467        // The scene's content is built for the region it will be drawn into,
1468        // not for the whole target: a camera built for the target's aspect
1469        // would be sheared back out to it by the viewport. A run that does not
1470        // letterbox — every capture — draws into the whole target, so its
1471        // content size is the target's own and nothing about it changes.
1472        let (viewport, content) = letterbox(def.baseline, size, options.letterbox);
1473        // The pass carries the region as well as the scene: the driver asks the
1474        // pass where its sources draw, so a viewport kept only on this struct
1475        // would never reach the renderer and the content would stretch to the
1476        // whole target.
1477        world
1478            .root_scope()
1479            .query_one::<&mut FramePass>(pass)
1480            .map(|mut pass| pass.get().viewport = viewport)
1481            .expect("the pass was just spawned");
1482
1483        // The UI source is mounted before the scene is built, so a scene's own
1484        // panels hang in the scope it draws: a source renders its own subtree,
1485        // and a panel spawned beside it would never be shown.
1486        let ui = (options.ui || options.selector).then(|| {
1487            let mut source_ui = UiSource::new();
1488            if options.reproducible && def.reproducible_ui {
1489                // egui fades a window in over the first frames and animates
1490                // widget transitions, all measured against the clock it is
1491                // handed. With no animation time every one of them is already
1492                // over, so the first frame is fully drawn and does not depend
1493                // on when it was taken.
1494                source_ui
1495                    .context_mut()
1496                    .all_styles_mut(|style| style.animation_time = 0.0);
1497            }
1498            world
1499                .scope_mut(pass)
1500                .expect("the pass was just spawned")
1501                .spawn(bundle![Source::new(source_ui)])
1502        });
1503        let control = (def.build)(&mut world, context, pass, ui, content, options);
1504        let SceneControl { advance, snapshot } = control;
1505
1506        // The shell's own state — the selector's switch, the fullscreen
1507        // request and its state, the frame-rate reading — lives in the shared
1508        // subtree. A panel reads and writes it from the UI source's scope,
1509        // which reaches the shared subtree and nothing beside it.
1510        let shared = world.shared();
1511        let (switch, fullscreen, fullscreen_active, frame_rate) = {
1512            let mut shared_view = world.scope_mut(shared).expect("the shared scope exists");
1513            (
1514                shared_view.spawn(bundle![SceneSwitch(None)]),
1515                shared_view.spawn(bundle![FullscreenRequest(false)]),
1516                shared_view.spawn(bundle![FullscreenActive(false)]),
1517                shared_view.spawn(bundle![FrameRate::default()]),
1518            )
1519        };
1520        // The scene's per-frame state and its own behaviour, as components: the
1521        // frame's behaviours advance them without `&mut Scene`. One entity
1522        // carries them so the behaviour that advances the scene and the state
1523        // it reads are reached together.
1524        let frame_entity = world.root_scope_mut().spawn(SceneFrame {
1525            advance,
1526            step: options.sequence_step,
1527        });
1528        // The frame's own dispatch: the input runs first and states no order,
1529        // since nothing else here reads what it dispatched. Stepping the index
1530        // and advancing by the frame it names are the one dependency: the
1531        // advance states an order after the step that records it.
1532        world
1533            .root_scope_mut()
1534            .spawn(bundle![dispatch_input_on_frame()]);
1535        world
1536            .root_scope_mut()
1537            .spawn(bundle![step_sequence(frame_entity)]);
1538        world
1539            .root_scope_mut()
1540            .spawn(bundle![run_scene_advance(frame_entity)]);
1541        // A scene world has no window: it starts whole and nothing short-
1542        // circuits it. Spawning the flag lets a scene behaviour do so.
1543        world.root_scope_mut().spawn(bundle![FrameSkip::default()]);
1544
1545        if let Some(ui) = ui
1546            && options.selector
1547        {
1548            mount_selector(&mut world, ui, switch, def, panels.selector);
1549            mount_frame_rate(&mut world, ui, frame_rate);
1550            let device = context
1551                .device(&world.root_scope())
1552                .expect("the device resource");
1553            mount_gpu_info(&mut world, ui, device, panels.gpu_info);
1554            // Every platform has a window the button can take fullscreen, so
1555            // it is drawn everywhere. A platform whose backend cannot — Android
1556            // already takes the whole screen for its activity — draws the
1557            // button and ignores the press.
1558            mount_fullscreen(&mut world, ui, fullscreen, fullscreen_active);
1559            // The API docs are a page every platform can open, so the link is
1560            // drawn everywhere. Mounted after the fullscreen button, which it
1561            // is placed under.
1562            mount_api_docs(&mut world, ui);
1563        }
1564
1565        (
1566            world,
1567            Self {
1568                renderer,
1569                size: content,
1570                viewport,
1571                pass,
1572                snapshot,
1573                frame_rate,
1574                frame: 0,
1575                switch,
1576                fullscreen,
1577                fullscreen_active,
1578                pending: None,
1579            },
1580        )
1581    }
1582
1583    /// Re-state the target's size, re-letterboxing the content for its shape.
1584    ///
1585    /// Called on every resize and on the first frame that knows the window's
1586    /// size, so the projection and the size the scene's behaviour is handed
1587    /// follow the target together. The region is the scene's own state here and
1588    /// reaches the pass that draws it from [`restate_viewport`], which is the one
1589    /// place holding both that pass and this scene.
1590    fn resize(&mut self, target: (u32, u32), baseline: Option<(u32, u32)>) {
1591        let (viewport, content) = letterbox(baseline, target, true);
1592        self.size = content;
1593        self.viewport = viewport;
1594    }
1595
1596    /// Advance the scene by `delta_time` seconds.
1597    ///
1598    /// A frame is the dispatch of the scene world's `OnFrame` behaviours. The
1599    /// input and the index state no order; the scene's own behaviour states one
1600    /// after the index, since it advances by the frame the index names. The
1601    /// index and the content size travel in the frame's context, so a behaviour
1602    /// reads them without a component of its own.
1603    pub fn advance(&mut self, world: &mut World, delta_time: f32) {
1604        // Measured before anything else, so the reading covers the whole frame
1605        // — input, behaviour, render and present — and not just the part
1606        // between here and the next call.
1607        world
1608            .root_scope()
1609            .query_one::<&mut FrameRate>(self.frame_rate)
1610            .map(|mut rate| rate.get().push(delta_time))
1611            .expect("the frame-rate component exists");
1612
1613        let mut frame = Frame {
1614            delta_time,
1615            index: self.frame,
1616            size: self.size,
1617        };
1618        dispatch_frame(world, &mut frame);
1619        self.frame = frame.index;
1620
1621        // The selector's request, read once so the frame loop can act on it.
1622        self.pending = world
1623            .root_scope()
1624            .query_one::<&mut SceneSwitch>(self.switch)
1625            .map(|mut s| s.get().0.take())
1626            .expect("the switch component exists");
1627    }
1628
1629    /// The selector window's rectangle the last time it was laid out, or
1630    /// `None` before it has been shown once.
1631    ///
1632    /// The window's rectangle is remembered by egui, in this scene's context;
1633    /// the shell reads it from here so a scene switch — which rebuilds that
1634    /// context — can rebuild the window in the same place and at the same size.
1635    fn selector_rect(&self, world: &World) -> Option<egui::Rect> {
1636        self.panel_rect(world, SELECTOR_WINDOW)
1637    }
1638
1639    /// The GPU-info panel's rectangle the last time it was laid out, or `None`
1640    /// before it has been shown once.
1641    ///
1642    /// Carried across a scene switch exactly like [`Self::selector_rect`]: the
1643    /// panel is a window the user may drag, and the switch rebuilds the context
1644    /// that remembers where it was left.
1645    fn gpu_info_rect(&self, world: &World) -> Option<egui::Rect> {
1646        self.panel_rect(world, GPU_INFO_AREA)
1647    }
1648
1649    /// The rectangle egui remembers for the window with `id`.
1650    ///
1651    /// Several sources share the world; only the UI one holds egui's window
1652    /// memory, so skip every other source's entity.
1653    fn panel_rect(&self, world: &World, id: &str) -> Option<egui::Rect> {
1654        world
1655            .root_scope()
1656            .query::<(Entity, &Source)>()
1657            .iter()
1658            .find_map(|(_, source)| {
1659                let ui = source.as_ref::<UiSource>().ok()?;
1660                ui.context()
1661                    .memory(|memory| memory.area_rect(egui::Id::unique(id)))
1662            })
1663    }
1664
1665    /// Record whether the window is fullscreen and report whether the button
1666    /// asked for a change, clearing the request.
1667    ///
1668    /// Both are read and written once per frame by the windowed loop, which is
1669    /// what owns the window; the scene keeps no opinion of its own about
1670    /// fullscreen.
1671    fn fullscreen_request(&mut self, world: &World, active: bool) -> bool {
1672        let _ = world
1673            .root_scope()
1674            .query_one::<&mut FullscreenActive>(self.fullscreen_active)
1675            .map(|mut state| state.get().0 = active);
1676        world
1677            .root_scope()
1678            .query_one::<&mut FullscreenRequest>(self.fullscreen)
1679            .map(|mut request| std::mem::take(&mut request.get().0))
1680            .expect("the fullscreen request component exists")
1681    }
1682
1683    /// Render one frame into whatever target the caller bound.
1684    ///
1685    /// Only the snapshot tests draw through this: the windowed run acquires
1686    /// the swap chain's image and renders between the acquire and the present,
1687    /// so it needs both under one borrow.
1688    pub fn render(&mut self, world: &World) {
1689        world
1690            .root_scope()
1691            .query_one::<&mut Renderer>(self.renderer)
1692            .map(|mut renderer| renderer.get().render(world))
1693            .expect("the renderer is a resource entity");
1694    }
1695
1696    /// Finish the frame: apply whatever a behaviour component or panel queued.
1697    ///
1698    /// Nothing is cleared: every consumer reads the events from its own
1699    /// subscription, and the dispatch that reads them takes them.
1700    pub fn end_frame(&mut self, world: &mut World) -> Result<(), EntityAccessError> {
1701        world.apply()
1702    }
1703}
1704
1705/// The scene-selector window's title, which is also its egui area id: the
1706/// shell reads the window's remembered rectangle by this same id.
1707const SELECTOR_WINDOW: &str = "scenes";
1708
1709/// The width the selector window opens at, in points.
1710///
1711/// Narrow enough that the list leaves the scene visible behind it: the
1712/// description wraps onto a few lines instead of the window stretching across
1713/// the frame. A window a user has dragged keeps the width it was dragged to.
1714const SELECTOR_DEFAULT_WIDTH: f32 = 220.0;
1715
1716/// Shift a movable window by however far the screen corner it belongs to moved
1717/// when the screen changed shape.
1718///
1719/// egui applies a window's opening position on the pass that first shows it and
1720/// afterwards only clamps the remembered rectangle into the screen, so a window
1721/// seeded into a corner keeps its old spot when the display rotates and the
1722/// frame becomes a different shape. Shifting it by the corner's movement keeps
1723/// its distance from that corner — including a distance the user dragged it to —
1724/// instead of leaving it stranded off its edge.
1725///
1726/// The window is anchored to the corner `pivot` names, so its pivot position
1727/// (the point of the window sitting on that corner) is what moves. The screen
1728/// the last call saw is remembered under `id`, so the shift is computed once
1729/// per change and the returned position is applied with
1730/// [`egui::Window::current_pos`] for that one pass; every other pass returns
1731/// `None` and the window keeps the position egui remembers for it.
1732fn follow_screen_corner(
1733    ctx: &egui::Context,
1734    id: egui::Id,
1735    pivot: egui::Align2,
1736) -> Option<egui::Pos2> {
1737    let screen = ctx.content_rect();
1738    let screen_key = id.with("screen");
1739    let previous = ctx.data(|data| data.get_temp::<egui::Rect>(screen_key));
1740    ctx.data_mut(|data| {
1741        data.insert_temp(screen_key, screen);
1742    });
1743    let previous = previous?;
1744    if previous == screen {
1745        return None;
1746    }
1747    // The window has to have been laid out for its position to be known; on its
1748    // first pass it is seeded by the corner it belongs to, which is where this
1749    // pass would move it anyway.
1750    let rect = ctx.memory(|memory| memory.area_rect(id))?;
1751    let factor = pivot_factor(pivot);
1752    let moved = screen.lerp_inside(factor) - previous.lerp_inside(factor);
1753    Some(rect.lerp_inside(factor) + moved)
1754}
1755
1756/// The fraction of a window's size at which its pivot sits, per axis.
1757///
1758/// The pivot of a window seeded into a corner is that corner, so this is the
1759/// point of the window — and the matching point of the screen — that stays
1760/// fixed as the window's size changes.
1761fn pivot_factor(pivot: egui::Align2) -> egui::Vec2 {
1762    egui::vec2(pivot.x().to_factor(), pivot.y().to_factor())
1763}
1764
1765/// Where the selector window opens when no position was carried over — the
1766/// first build of a session.
1767///
1768/// The window's bottom-right corner sits one overlay margin from the screen's,
1769/// so the list opens out of the way of the frame-rate readout and the
1770/// fullscreen button in the top corners. The window is seeded by its
1771/// bottom-right pivot, so that corner stays put as the content decides the
1772/// window's size; a window a user has dragged is carried by its whole
1773/// rectangle, which a rebuild restores exactly.
1774fn selector_default_pos(ctx: &egui::Context) -> egui::Pos2 {
1775    ctx.content_rect().right_bottom() - egui::Vec2::splat(OVERLAY_MARGIN)
1776}
1777
1778/// Mount the windowed shell's scene-selector panel.
1779///
1780/// The panel lists every scene and writes its choice into the [`SceneSwitch`]
1781/// component, which the frame loop reads once after the frame's advance.
1782fn mount_selector(
1783    world: &mut World,
1784    ui: Entity,
1785    switch: Entity,
1786    current: &'static scenes::SceneDef,
1787    initial: Option<egui::Rect>,
1788) {
1789    world
1790        .scope_mut(ui)
1791        .expect("the UI source is alive")
1792        .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
1793            // The id is set explicitly: `Window::new` derives it from the title's
1794            // `Atoms` text, whose hash differs from a plain string id, so the
1795            // shell's read of the remembered rect would never match.
1796            let id = egui::Id::unique(SELECTOR_WINDOW);
1797            // The window belongs to the bottom-right corner. A fresh one is seeded
1798            // by that corner, so the corner stays put while the first layout
1799            // measures the content; a carried rectangle is seeded by its own
1800            // bottom-right and given its size, so the first frame lays it out
1801            // exactly where the previous scene left it. `follow_screen_corner`
1802            // keeps the corner when the screen changes shape.
1803            let pivot = egui::Align2::RIGHT_BOTTOM;
1804            let window = egui::Window::new(SELECTOR_WINDOW).id(id).pivot(pivot);
1805            let window = match initial {
1806                Some(rect) => window
1807                    .default_pos(rect.lerp_inside(pivot_factor(pivot)))
1808                    .default_size(rect.size()),
1809                None => window
1810                    .default_width(SELECTOR_DEFAULT_WIDTH)
1811                    .default_pos(selector_default_pos(ui.ctx())),
1812            };
1813            // A titled window that is movable discards `current_pos`: egui
1814            // re-applies the pivot it had stored before the area began, so the
1815            // position given here would not stick. The reposition pass therefore
1816            // also turns the window immovable; every other pass is movable again
1817            // and a user's drag works as before.
1818            let window = match follow_screen_corner(ui.ctx(), id, pivot) {
1819                Some(pos) => window.current_pos(pos).movable(false),
1820                None => window,
1821            };
1822            window.show(ui.ctx(), |ui| {
1823                ui.label(format!("{} — {}", current.title, current.description));
1824                ui.separator();
1825                for scene in scenes::SCENES.iter().copied() {
1826                    let selected = scene.id == current.id;
1827                    if ui.selectable_label(selected, scene.title).clicked() {
1828                        let _ = world
1829                            .query_one::<&mut SceneSwitch>(switch)
1830                            .map(|mut s| s.get().0 = Some(scene));
1831                    }
1832                }
1833            });
1834        },),]);
1835}
1836
1837/// The frame rate the windowed shell reports, as a component so the panel that
1838/// displays it and the loop that measures it share one value.
1839///
1840/// It lives in the world like any other behaviour state rather than being
1841/// captured by the panel's closure: a panel may run several times in one frame
1842/// when egui redoes a layout, and a panel must not reach for its own
1843/// [`UiPanel`], so the measurement belongs in a sibling component.
1844#[derive(Default)]
1845struct FrameRate {
1846    /// The frames measured so far, capped at [`FrameRate::WINDOW`].
1847    ///
1848    /// A sliding window rather than a running mean: a scene that stalls once
1849    /// should not drag the reading down for the rest of the session, and a rate
1850    /// that has just changed should show up within a few frames.
1851    samples: VecDeque<f32>,
1852}
1853
1854impl FrameRate {
1855    /// How many frames the reading averages over.
1856    ///
1857    /// About a second at 60 Hz: long enough that the number does not flicker
1858    /// with every frame, short enough that a change is visible promptly.
1859    const WINDOW: usize = 60;
1860
1861    /// Record one frame's duration, in seconds.
1862    fn push(&mut self, delta: f32) {
1863        if self.samples.len() == Self::WINDOW {
1864            self.samples.pop_front();
1865        }
1866        self.samples.push_back(delta);
1867    }
1868
1869    /// The mean frame time over the window, in seconds, or `None` before any
1870    /// frame was measured.
1871    fn mean_seconds(&self) -> Option<f32> {
1872        if self.samples.is_empty() {
1873            return None;
1874        }
1875        let total: f32 = self.samples.iter().sum();
1876        Some(total / self.samples.len() as f32)
1877    }
1878
1879    /// The reading as `"60.0 fps · 16.7 ms"`.
1880    ///
1881    /// The frame time is shown next to the rate because the two answer
1882    /// different questions: the rate is what a display is judged against, while
1883    /// the milliseconds are what a budget is spent in.
1884    fn text(&self) -> String {
1885        match self.mean_seconds() {
1886            Some(seconds) if seconds > 0.0 => {
1887                format!("{:.1} fps · {:.1} ms", 1.0 / seconds, seconds * 1e3)
1888            }
1889            // The first frame has no previous one to measure against.
1890            _ => "— fps".to_owned(),
1891        }
1892    }
1893}
1894
1895/// The distance every overlay keeps from the edge of the screen.
1896///
1897/// Each corner overlay is anchored to its edge with this offset, so none of
1898/// them is drawn flush against the boundary — a readout or a button touching
1899/// the very edge of a phone's display reads as clipped, and the safe-area
1900/// insets the page already applies to the canvas are not visible to egui.
1901const OVERLAY_MARGIN: f32 = 8.0;
1902
1903/// The gap between the frame-rate readout and the fullscreen button when a
1904/// window is too narrow to hold them on the same row.
1905const OVERLAY_GAP: f32 = 8.0;
1906
1907/// The smallest a corner button is drawn, in points.
1908///
1909/// A corner button is an overlay on the scene, so it is kept small enough to
1910/// read as one — a control as tall as a panel's row competes with the frame it
1911/// annotates — while still being a comfortable target to click.
1912const OVERLAY_BUTTON_MIN_SIZE: egui::Vec2 = egui::Vec2::new(110.0, 32.0);
1913
1914/// A corner button's inner padding, symmetric so the label is centred within
1915/// the button it is grown to.
1916///
1917/// A bare [`egui::Button::min_size`] would leave the label sitting in a corner:
1918/// the button's atoms are aligned by the surrounding layout, which is not
1919/// centred. Growing the padding instead — the same on every side — is what
1920/// pushes the label to the middle of the larger button.
1921const OVERLAY_BUTTON_PADDING: egui::Vec2 = egui::Vec2::new(10.0, 7.0);
1922
1923/// The API docs the Pages workflow publishes beside the example page.
1924///
1925/// Absolute, because the browser and Android only open an `http(s)` URL — a
1926/// path relative to the page is not something either hands to a browser. The
1927/// site is the repository's, so a fork that publishes its own should point this
1928/// at its own Pages URL.
1929const API_DOCS_URL: &str = "https://beicause.github.io/unlit3d/docs/index.html";
1930
1931/// The label the API-docs button carries.
1932const API_DOCS_LABEL: &str = "API docs";
1933
1934/// The id of the frame-rate readout's area.
1935const FRAME_RATE_AREA: &str = "unlit3d::frame-rate";
1936
1937/// The id of the fullscreen button's area.
1938const FULLSCREEN_AREA: &str = "unlit3d::fullscreen";
1939
1940/// The id of the API-docs button's area.
1941const API_DOCS_AREA: &str = "unlit3d::api-docs";
1942
1943/// The GPU-info panel's title, shown in its title bar.
1944const GPU_INFO_TITLE: &str = "GPU";
1945
1946/// The id of the GPU-info panel's area, which is also the id egui remembers
1947/// the panel's rectangle under.
1948const GPU_INFO_AREA: &str = "unlit3d::gpu-info";
1949
1950/// The narrowest the GPU-info panel may be dragged, in points.
1951///
1952/// A fresh panel opens as wide as its widest fact needs to stay on one line,
1953/// so this floor is only reached by dragging. Without it a drag could squeeze
1954/// the panel down to a column of one letter: the facts are wrapped text in a
1955/// scroll area, so they follow the panel all the way down.
1956const GPU_INFO_MIN_WIDTH: f32 = 160.0;
1957
1958/// Slack added to the GPU-info panel's measured width.
1959///
1960/// A label wraps on a strict comparison against the width it is handed, and a
1961/// window rounds its size to a whole pixel, so a width that fits a fact exactly
1962/// can still come out a hair too narrow. A couple of points keep the longest
1963/// fact on its line when the rounding would otherwise break it.
1964const GPU_INFO_WIDTH_SLACK: f32 = 2.0;
1965
1966/// The share of the window's width the GPU-info panel may take, at most.
1967///
1968/// The panel takes its opening width from its content, and a device can name a
1969/// limit that is longer than the window is wide. This keeps such a panel from
1970/// hanging off the edge and leaves the scene visible beside it; a name that
1971/// still does not fit wraps, and the panel may be dragged wider by hand.
1972const GPU_INFO_MAX_WIDTH_FRACTION: f32 = 0.85;
1973
1974/// The share of the window's height the GPU-info panel may take.
1975///
1976/// The panel is an overlay on the scene, not a replacement for it: the limits
1977/// alone are several dozen rows, so the body scrolls within this share of the
1978/// window instead of covering it.
1979const GPU_INFO_HEIGHT_FRACTION: f32 = 0.6;
1980
1981/// Mount the frame-rate readout at the top of the window, centred.
1982///
1983/// An [`egui::Area`] rather than a window: it is pinned, not something the user
1984/// can drag away or accidentally resize, and `interactable(false)` keeps it
1985/// from swallowing a click meant for the scene underneath it.
1986fn mount_frame_rate(world: &mut World, ui: Entity, rate: Entity) {
1987    world
1988        .scope_mut(ui)
1989        .expect("the UI source is alive")
1990        .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
1991            let text = world
1992                .query_one::<&FrameRate>(rate)
1993                .map(|mut rate| rate.get().text())
1994                .expect("the frame-rate component exists");
1995            frame_rate_readout(ui, &text);
1996        },),]);
1997}
1998
1999/// Draw the frame-rate readout, showing `text`, centred along the top edge of
2000/// `ui`'s screen.
2001///
2002/// The label is set to [`egui::Label::extend`], so the readout grows to fit its
2003/// text instead of wrapping it. An anchored area lays its contents out against
2004/// the size it remembered from the previous pass, so a reading that is wider
2005/// than the last one — the first real reading after the empty placeholder, or
2006/// any reading after the rate changes — would otherwise be broken across lines
2007/// in a narrow window.
2008///
2009/// A free function rather than the body of the panel's closure so a test can
2010/// drive it against a bare [`egui::Context`]: what it has to get right — one
2011/// line, whatever the previous reading was — is a property of the widget tree,
2012/// not of the world.
2013fn frame_rate_readout(ui: &mut egui::Ui, text: &str) {
2014    egui::Area::new(egui::Id::unique(FRAME_RATE_AREA))
2015        .anchor(egui::Align2::CENTER_TOP, [0.0, OVERLAY_MARGIN])
2016        .interactable(false)
2017        // The readout is an overlay, not a window: a scene panel dragged over
2018        // the same corner must not cover it, so it sits in the layer above the
2019        // one windows are drawn in.
2020        .order(egui::Order::Foreground)
2021        .show(ui.ctx(), |ui| {
2022            egui::Frame::popup(ui.style()).show(ui, |ui| {
2023                ui.add(egui::Label::new(egui::RichText::new(text).monospace()).extend());
2024            });
2025        });
2026}
2027
2028/// The device facts the GPU-info panel shows, read once when it is mounted.
2029///
2030/// Reading them once rather than every frame keeps the panel's per-frame pass
2031/// free of work the device cannot change: an adapter's name, the features it
2032/// was created with and the limits it reports are fixed for the lifetime of
2033/// the device, so the strings are built where the device is in hand and the
2034/// pass only lays them out.
2035struct GpuInfo {
2036    /// The adapter's name, shown as the panel's title.
2037    name: String,
2038    /// One line naming the backend, the kind of device and the driver.
2039    summary: String,
2040    /// The rest of the adapter's description, as label/value pairs.
2041    rows: Vec<(String, String)>,
2042    /// The names of the features the device was created with.
2043    features: Vec<&'static str>,
2044    /// The limits the device reports, as field-name/value pairs.
2045    limits: Vec<(String, String)>,
2046}
2047
2048impl GpuInfo {
2049    /// Read `device`'s adapter description, enabled features and limits.
2050    fn read(device: &wgpu::Device) -> Self {
2051        let info = device.adapter_info();
2052        let features = device.features();
2053        let limits = device.limits();
2054
2055        // The field names are the panel's labels, so they are spelled once and
2056        // `stringify!`d rather than repeated as string literals that could drift
2057        // from the fields they name.
2058        Self {
2059            name: info.name.clone(),
2060            summary: format!(
2061                "{} · {} · {}",
2062                info.backend.to_str(),
2063                device_type_name(info.device_type),
2064                info.driver,
2065            ),
2066            rows: vec![
2067                ("driver info".to_owned(), info.driver_info.clone()),
2068                ("vendor".to_owned(), format!("{:#06x}", info.vendor)),
2069                ("device".to_owned(), format!("{:#06x}", info.device)),
2070                ("pci bus".to_owned(), info.device_pci_bus_id.clone()),
2071                (
2072                    "subgroup".to_owned(),
2073                    format!("{}..{}", info.subgroup_min_size, info.subgroup_max_size),
2074                ),
2075                (
2076                    "transient saves memory".to_owned(),
2077                    match info.transient_saves_memory {
2078                        Some(saves) => saves.to_string(),
2079                        None => "unknown".to_owned(),
2080                    },
2081                ),
2082            ],
2083            features: features.iter_names().map(|(name, _)| name).collect(),
2084            limits: limit_rows(&limits),
2085        }
2086    }
2087}
2088
2089/// The device's limits as label/value pairs, in the order the device reports
2090/// them.
2091///
2092/// A [`wgpu::Limits`] is serialisable, so the labels and values come from its
2093/// own serde description: the field names are the JSON object's keys and the
2094/// values are its entries. Nothing here names a limit, so a field wgpu adds is
2095/// shown without a change, and a field it renames is not silently dropped.
2096///
2097/// The keys come out in the JSON object's order, which serde_json keeps sorted
2098/// rather than in declaration order; the list is stable and complete either
2099/// way, and a reader looking a limit up benefits from the order.
2100///
2101/// Falls back to an empty list rather than failing: a panel that loses its
2102/// limits is a cosmetic loss, and serialising a plain struct of integers has
2103/// no way to fail.
2104fn limit_rows(limits: &wgpu::Limits) -> Vec<(String, String)> {
2105    let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(limits) else {
2106        return Vec::new();
2107    };
2108    fields
2109        .into_iter()
2110        .map(|(name, value)| (name, json_scalar(&value)))
2111        .collect()
2112}
2113
2114/// One limit's value as text.
2115///
2116/// Every field of a [`wgpu::Limits`] is an integer, so a scalar is all there
2117/// is to render; the fallback covers a future field of another shape without
2118/// hiding it.
2119fn json_scalar(value: &serde_json::Value) -> String {
2120    match value {
2121        serde_json::Value::Number(number) => number.to_string(),
2122        other => other.to_string(),
2123    }
2124}
2125
2126/// The name of a [`wgpu::DeviceType`], for the panel's summary line.
2127///
2128/// [`wgpu::DeviceType`] has no `Display` impl, so its names are spelled here.
2129fn device_type_name(device_type: wgpu::DeviceType) -> &'static str {
2130    match device_type {
2131        wgpu::DeviceType::Other => "other",
2132        wgpu::DeviceType::IntegratedGpu => "integrated GPU",
2133        wgpu::DeviceType::DiscreteGpu => "discrete GPU",
2134        wgpu::DeviceType::VirtualGpu => "virtual GPU",
2135        wgpu::DeviceType::Cpu => "CPU",
2136    }
2137}
2138
2139/// Where the GPU-info panel opens when no position was carried over.
2140///
2141/// Like the selector, it is seeded by the corner it belongs in — here the
2142/// bottom-left, one overlay margin in — so that corner stays put as the
2143/// content decides the panel's size; a panel the user has dragged is carried
2144/// by its whole rectangle, which a rebuild restores exactly.
2145fn gpu_info_default_pos(ctx: &egui::Context) -> egui::Pos2 {
2146    ctx.content_rect().left_bottom() + egui::Vec2::new(OVERLAY_MARGIN, -OVERLAY_MARGIN)
2147}
2148
2149/// Mount the GPU-info panel, a draggable window in the bottom-left corner.
2150///
2151/// A window rather than an anchored area so the user can move it out of the
2152/// way. It is an ordinary window, in the layer the scene's own windows are
2153/// drawn in: the title bar then highlights exactly when it is the window on
2154/// top, the same as the selector's, and a scene panel dragged over it covers
2155/// it the same way.
2156///
2157/// The device is read here, once, where it is in hand; the panel then draws
2158/// from the strings that read produced.
2159fn mount_gpu_info(
2160    world: &mut World,
2161    ui: Entity,
2162    device: wgpu::Device,
2163    initial: Option<egui::Rect>,
2164) {
2165    let info = GpuInfo::read(&device);
2166    world
2167        .scope_mut(ui)
2168        .expect("the UI source is alive")
2169        .spawn(bundle![UiPanel::new(move |_world, _entity, ui| {
2170            gpu_info_panel(ui, &info, initial);
2171        },),]);
2172}
2173
2174/// One line of the GPU-info panel.
2175///
2176/// The panel sizes itself from these lines and then draws the same values, so a
2177/// line's wording and the font it is drawn in are chosen once: a width measured
2178/// from a differently-styled string would not be the width the panel draws with.
2179enum GpuInfoLine {
2180    /// The adapter's name or its one-line description, above the separator.
2181    Header(egui::WidgetText),
2182    /// A heading that opens a group of facts, set off by a gap above it.
2183    Heading(egui::WidgetText),
2184    /// One fact.
2185    Fact(egui::WidgetText),
2186}
2187
2188impl GpuInfoLine {
2189    /// The line's text and styling, as it is both measured and drawn.
2190    fn text(&self) -> &egui::WidgetText {
2191        match self {
2192            Self::Header(text) | Self::Heading(text) | Self::Fact(text) => text,
2193        }
2194    }
2195
2196    /// Whether the line is part of the header, above the separating rule.
2197    fn is_header(&self) -> bool {
2198        matches!(self, Self::Header(_))
2199    }
2200
2201    /// Whether the line opens a section, which is set off by a gap above it.
2202    fn is_heading(&self) -> bool {
2203        matches!(self, Self::Heading(_))
2204    }
2205}
2206
2207/// The lines the GPU-info panel shows, top to bottom.
2208///
2209/// The wording lives here and nowhere else: the width measurement and the
2210/// drawing both walk this list, so a fact cannot be measured as one string and
2211/// drawn as another, nor measured in one font and drawn in another.
2212fn gpu_info_lines(info: &GpuInfo) -> Vec<GpuInfoLine> {
2213    let mut lines = Vec::new();
2214    lines.push(GpuInfoLine::Header(
2215        egui::RichText::new(&info.name).strong().into(),
2216    ));
2217    lines.push(GpuInfoLine::Header(
2218        egui::RichText::new(&info.summary).monospace().into(),
2219    ));
2220    lines.push(GpuInfoLine::Heading(
2221        egui::RichText::new("Adapter").strong().into(),
2222    ));
2223    for (label, value) in &info.rows {
2224        lines.push(GpuInfoLine::Fact(format!("{label}: {value}").into()));
2225    }
2226    lines.push(GpuInfoLine::Heading(
2227        egui::RichText::new(format!("Features ({})", info.features.len()))
2228            .strong()
2229            .into(),
2230    ));
2231    if info.features.is_empty() {
2232        lines.push(GpuInfoLine::Fact("none".into()));
2233    } else {
2234        for feature in &info.features {
2235            lines.push(GpuInfoLine::Fact((*feature).into()));
2236        }
2237    }
2238    lines.push(GpuInfoLine::Heading(
2239        egui::RichText::new(format!("Limits ({})", info.limits.len()))
2240            .strong()
2241            .into(),
2242    ));
2243    for (name, value) in &info.limits {
2244        lines.push(GpuInfoLine::Fact(format!("{name} = {value}").into()));
2245    }
2246    lines
2247}
2248
2249/// How wide the GPU-info panel has to be to keep every line on one row.
2250///
2251/// The facts are strings the device chose, and its own names for its limits run
2252/// past fifty characters, so a fixed opening width would wrap them. Each line is
2253/// laid out unwrapped as it will be drawn, the widest one sets the content
2254/// width, and the window's frame and the body's scroll bar are added back on:
2255/// the panel's size, unlike the text's, includes both.
2256///
2257/// A free function rather than part of the panel so a test can ask what width a
2258/// given set of lines needs without drawing a pass.
2259fn gpu_info_width(ui: &egui::Ui, lines: &[GpuInfoLine]) -> f32 {
2260    let mut widest: f32 = 0.0;
2261    for line in lines {
2262        let width = line
2263            .text()
2264            .clone()
2265            .into_galley(
2266                ui,
2267                Some(egui::TextWrapMode::Extend),
2268                0.0,
2269                egui::FontSelection::Default,
2270            )
2271            .size()
2272            .x;
2273        widest = widest.max(width);
2274    }
2275    let style = ui.style();
2276    let frame = egui::Frame::window(style).total_margin().sum().x;
2277    let scroll_bar = style.spacing.scroll.allocated_width();
2278    widest + frame + scroll_bar + GPU_INFO_WIDTH_SLACK
2279}
2280
2281/// Draw the GPU-info panel for `info`, seeded at `initial` or the bottom-left
2282/// corner of `ui`'s screen.
2283///
2284/// A free function rather than the body of the panel's closure so a test can
2285/// drive it against a bare [`egui::Context`]: what it has to get right — where
2286/// it opens, the width it takes from its facts and the facts it prints — is a
2287/// property of the widget tree, not of the world.
2288fn gpu_info_panel(ui: &mut egui::Ui, info: &GpuInfo, initial: Option<egui::Rect>) {
2289    // The id is set explicitly, as in the selector: `Window::new` would derive
2290    // it from the title text, and the shell reads the remembered rectangle back
2291    // under this id.
2292    //
2293    // The panel is resizable in both directions, like the selector and the
2294    // scene's own windows: the limits alone are several dozen rows, and how
2295    // much of the scene the panel covers while they are read is the reader's
2296    // to decide. It opens as wide as its facts need, and dragging it narrower
2297    // is what wraps them.
2298    let id = egui::Id::unique(GPU_INFO_AREA);
2299    // The panel belongs to the bottom-left corner, so it is seeded and kept by
2300    // that corner: a fresh one opens there, a carried rectangle is seeded by its
2301    // own bottom-left and given its size, and `follow_screen_corner` moves it
2302    // when the screen changes shape.
2303    let pivot = egui::Align2::LEFT_BOTTOM;
2304    let window = egui::Window::new(GPU_INFO_TITLE)
2305        .id(id)
2306        .pivot(pivot)
2307        .min_width(GPU_INFO_MIN_WIDTH)
2308        .max_width(ui.ctx().content_rect().width() * GPU_INFO_MAX_WIDTH_FRACTION);
2309    let lines = gpu_info_lines(info);
2310    // The lines above the rule are the header; the rest are the facts, which
2311    // scroll, so the two runs are drawn into different places from the one
2312    // list.
2313    let header = lines.iter().take_while(|line| line.is_header()).count();
2314    let window = match initial {
2315        Some(rect) => window
2316            .default_pos(rect.lerp_inside(pivot_factor(pivot)))
2317            .default_size(rect.size()),
2318        None => window
2319            // The size is given as well as the position: an area is laid out
2320            // against the size it had before the first pass measures it, and
2321            // egui's default area size is wider than a narrow window, which
2322            // would push the seeded corner inward when the area is constrained
2323            // to the screen.
2324            .default_size(egui::Vec2::new(
2325                gpu_info_width(ui, &lines),
2326                ui.ctx().content_rect().height() * GPU_INFO_HEIGHT_FRACTION,
2327            ))
2328            .default_pos(gpu_info_default_pos(ui.ctx())),
2329    };
2330    // A titled window that is movable discards `current_pos`: egui re-applies
2331    // the pivot it had stored before the area began, so the position given here
2332    // would not stick. The reposition pass therefore also turns the window
2333    // immovable; every other pass is movable again and a user's drag works as
2334    // before.
2335    let window = match follow_screen_corner(ui.ctx(), id, pivot) {
2336        Some(pos) => window.current_pos(pos).movable(false),
2337        None => window,
2338    };
2339    window.show(ui.ctx(), |ui| {
2340        let (header_lines, body_lines) = lines.split_at(header);
2341        ui.vertical(|ui| {
2342            for line in header_lines {
2343                ui.label(line.text().clone());
2344            }
2345            ui.separator();
2346            egui::ScrollArea::vertical()
2347                .max_height(ui.ctx().content_rect().height() * GPU_INFO_HEIGHT_FRACTION)
2348                // Fill the panel's width so the facts wrap within it instead of
2349                // the panel sizing itself to the widest one; shrink to the
2350                // content's height so a device with few limits gets a short
2351                // panel, and dragging it taller then does not pad it out with
2352                // empty space.
2353                .auto_shrink([false, true])
2354                .show(ui, |ui| {
2355                    for line in body_lines {
2356                        if line.is_heading() {
2357                            ui.add_space(OVERLAY_GAP);
2358                        }
2359                        ui.label(line.text().clone());
2360                    }
2361                });
2362        });
2363    });
2364}
2365
2366/// Mount the fullscreen button in the window's top-right corner.
2367///
2368/// A button rather than the old "the first press asks for fullscreen": a press
2369/// on the canvas is not discoverable — a phone shows no hint that it would
2370/// work, and nothing tells a user who left fullscreen that they may ask again —
2371/// whereas a button is visible on every platform and keeps working after the
2372/// browser has left fullscreen. Nothing about the request is latched, so the
2373/// display can be entered and left as often as the button is pressed.
2374///
2375/// The click is recorded rather than served here: a panel runs while the world
2376/// is borrowed, and it has no window to ask; the frame loop reads the request
2377/// and calls [`fullscreen::toggle`], which is where a browser accepts it from.
2378fn mount_fullscreen(world: &mut World, ui: Entity, request: Entity, state: Entity) {
2379    world
2380        .scope_mut(ui)
2381        .expect("the UI source is alive")
2382        .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
2383            let active = world
2384                .query_one::<&FullscreenActive>(state)
2385                .is_ok_and(|mut state| state.get().0);
2386            if fullscreen_button(ui, active) {
2387                let _ = world
2388                    .query_one::<&mut FullscreenRequest>(request)
2389                    .map(|mut request| request.get().0 = true);
2390            }
2391        },),]);
2392}
2393
2394/// Mount the link to the API docs in the window's top-right corner.
2395///
2396/// The link lives in the UI rather than in the page so the two corner overlays
2397/// are laid out by the same code and cannot collide: the page's own link knew
2398/// nothing about the button, which is what put the two on top of each other.
2399/// Mounted after [`mount_fullscreen`], because the link is placed under the
2400/// button it measures.
2401///
2402/// The click is recorded rather than served here: a panel runs while the world
2403/// is borrowed and has no page to reach; the frame loop reads the request and
2404/// opens it with [`webbrowser`], which covers every platform the example runs
2405/// on.
2406fn mount_api_docs(world: &mut World, ui: Entity) {
2407    world
2408        .scope_mut(ui)
2409        .expect("the UI source is alive")
2410        .spawn(bundle![UiPanel::new(|_world, _entity, ui| {
2411            api_docs_button(ui);
2412        }),]);
2413}
2414
2415/// The vertical offset a corner button's anchor is drawn at, given the area
2416/// above it in the same corner.
2417///
2418/// `above` and the button share the top row when the window is wide enough for
2419/// both to sit side by side. When it is not — the frame-rate readout is
2420/// centred, so on a portrait phone it reaches into the corner the fullscreen
2421/// button anchors to — the button drops to the row under `above` instead:
2422/// hiding the end of the reading under the button that is meant to sit beside
2423/// it on a roomier window does not help. The fallback keeps both legible and
2424/// separated at any width.
2425///
2426/// Both areas are anchored, and an anchored area is laid out against the
2427/// rectangle the previous pass remembered for it — which is what is compared
2428/// here, so the decision is made about the very positions egui will use.
2429fn overlay_button_top(ctx: &egui::Context, above: &str, id: &str) -> f32 {
2430    let above = ctx.memory(|memory| memory.area_rect(egui::Id::unique(above)));
2431    let button = ctx.memory(|memory| memory.area_rect(egui::Id::unique(id)));
2432    match (above, button) {
2433        // The area above stops clear of the button's column.
2434        (Some(above), Some(button)) if above.right() + OVERLAY_GAP < button.left() => {
2435            OVERLAY_MARGIN
2436        }
2437        // The area above reaches into the button's corner: stack beneath it.
2438        (Some(above), _) => above.bottom() + OVERLAY_GAP,
2439        // The button is the first thing drawn; the top row is where it belongs.
2440        (None, _) => OVERLAY_MARGIN,
2441    }
2442}
2443
2444/// Draw a button that owns a corner, and report whether it was pressed.
2445///
2446/// `id` names the area, so the button can be read back — the docs link is
2447/// placed under the fullscreen button by measuring it — and so two corners can
2448/// be tested apart. A free function rather than the body of a panel's closure
2449/// so a test can drive it against a bare [`egui::Context`].
2450fn corner_button(ui: &mut egui::Ui, id: &str, top: f32, label: egui::RichText) -> bool {
2451    egui::Area::new(egui::Id::unique(id))
2452        .anchor(egui::Align2::RIGHT_TOP, [-OVERLAY_MARGIN, top])
2453        // A corner button is an overlay, not a window: a scene panel dragged
2454        // over the corner must not cover it, so it sits in the layer above the
2455        // one windows are drawn in.
2456        .order(egui::Order::Foreground)
2457        .show(ui.ctx(), |ui| {
2458            egui::Frame::popup(ui.style())
2459                .show(ui, |ui| {
2460                    ui.style_mut().spacing.button_padding = OVERLAY_BUTTON_PADDING;
2461                    ui.add(
2462                        egui::Button::new(label)
2463                            .wrap_mode(egui::TextWrapMode::Extend)
2464                            .min_size(OVERLAY_BUTTON_MIN_SIZE),
2465                    )
2466                    .clicked()
2467                })
2468                .inner
2469        })
2470        .inner
2471}
2472
2473/// Draw the fullscreen button, labelled for what pressing it does, and report
2474/// whether it was pressed.
2475///
2476/// The label follows the document rather than a local flag: the user can leave
2477/// fullscreen with the browser's own control — Escape, or the back gesture on a
2478/// phone — and the button has to offer to go back in afterwards. That is also
2479/// why it owns its corner rather than sharing a row with the readout: each
2480/// anchored area keeps to itself, and the button is only drawn on top of the
2481/// readout's line when the two would otherwise collide.
2482///
2483/// The glyphs are drawn from the default font — ⛶ to enter and ⏏ to leave —
2484/// with a word beside each so the meaning does not rest on a symbol alone. Both
2485/// labels are laid out to extend rather than wrap: the exit label is the wider
2486/// of the two, and an anchored area lays its contents out against the size it
2487/// remembered from the previous pass, so switching to it would otherwise break
2488/// it across two lines in the narrower area the enter label had established.
2489fn fullscreen_button(ui: &mut egui::Ui, active: bool) -> bool {
2490    let label = if active {
2491        "⏏ Exit fullscreen"
2492    } else {
2493        "⛶ Fullscreen"
2494    };
2495    let top = overlay_button_top(ui.ctx(), FRAME_RATE_AREA, FULLSCREEN_AREA);
2496    corner_button(
2497        ui,
2498        FULLSCREEN_AREA,
2499        top,
2500        egui::RichText::new(label).monospace(),
2501    )
2502}
2503
2504/// Draw the API-docs link under the fullscreen button, and report whether it
2505/// was pressed.
2506///
2507/// The click becomes egui's own URL command, which the UI source publishes in
2508/// the world for the frame loop to serve, rather than opening the page here: a
2509/// panel runs while the world is borrowed and cannot wait on one.
2510fn api_docs_button(ui: &mut egui::Ui) -> bool {
2511    let top = overlay_button_top(ui.ctx(), FULLSCREEN_AREA, API_DOCS_AREA);
2512    let label = egui::RichText::new(API_DOCS_LABEL).monospace();
2513    let clicked = corner_button(ui, API_DOCS_AREA, top, label);
2514    if clicked {
2515        ui.ctx().open_url(egui::OpenUrl::same_tab(API_DOCS_URL));
2516    }
2517    clicked
2518}
2519
2520/// The region a scene's content is drawn into and the size its camera is built
2521/// for, for a `target`-pixel render target.
2522///
2523/// `baseline` is the aspect to keep, or `None` for a scene with no aspect of
2524/// its own — a UI-only one — which draws into the whole target. `letterbox`
2525/// turns the whole mechanism off, which is what a capture wants: its
2526/// captures are the scene's own size and must stay comparable with the stored
2527/// snapshots.
2528fn letterbox(
2529    baseline: Option<(u32, u32)>,
2530    target: (u32, u32),
2531    letterbox: bool,
2532) -> (Option<ViewportRect>, (u32, u32)) {
2533    match baseline.filter(|_| letterbox) {
2534        Some(baseline) => (
2535            Some(scenes::baseline_viewport(baseline, target)),
2536            scenes::content_size(baseline, target),
2537        ),
2538        None => (None, target),
2539    }
2540}
2541
2542/// Accumulate `delta_time` into `clock` and report whether a `step`-long
2543/// interval has passed, subtracting it from the clock if so.
2544///
2545/// At most one interval is consumed per call, so a long stall advances a
2546/// fixed-sequence scene by one frame rather than skipping several. The
2547/// leftover stays in the clock, so the sequence keeps pace over time.
2548fn tick(clock: &mut f32, step: f32, delta_time: f32) -> bool {
2549    *clock += delta_time;
2550    if *clock >= step {
2551        *clock -= step;
2552        true
2553    } else {
2554        false
2555    }
2556}
2557
2558#[cfg(test)]
2559mod tests {
2560    use super::{
2561        API_DOCS_AREA, API_DOCS_URL, FRAME_RATE_AREA, FULLSCREEN_AREA, FrameRate, GPU_INFO_AREA,
2562        GPU_INFO_MIN_WIDTH, GpuInfo, OVERLAY_BUTTON_MIN_SIZE, OVERLAY_MARGIN, api_docs_button,
2563        egui, frame_rate_readout, fullscreen_button, gpu_info_panel, letterbox, limit_rows, tick,
2564    };
2565    // The scene-switch test below drives the app's own slots, so it is the only
2566    // part of this module that needs these names.
2567    #[cfg(not(target_arch = "wasm32"))]
2568    use super::{InitialScene, PresenterEntity, SceneEntity, scenes, switch_scene};
2569    #[cfg(not(target_arch = "wasm32"))]
2570    use unlit_ecs::bundle;
2571    #[cfg(not(target_arch = "wasm32"))]
2572    use unlit3d::prelude::World;
2573
2574    /// A scene switch drops the presentation path, which was built around the
2575    /// scene it replaced.
2576    ///
2577    /// The path's surface and the pass that presents through it belong to the
2578    /// dying scene's world, so the rebuilt scene builds a fresh one instead of
2579    /// presenting through a pass that went with the world it drew.
2580    #[cfg(not(target_arch = "wasm32"))]
2581    #[test]
2582    fn a_scene_switch_drops_the_presentation_path() {
2583        let mut world = World::new();
2584
2585        let old_scene = world.root_scope_mut().spawn(bundle![]);
2586        let old_presenter = world.root_scope_mut().spawn(bundle![]);
2587        let app = world.root_scope_mut().spawn(bundle![
2588            InitialScene(scenes::default()),
2589            PresenterEntity(Some(old_presenter)),
2590            SceneEntity(Some(old_scene)),
2591        ]);
2592
2593        let next = scenes::by_id("ui_only").expect("the example ships a UI-only scene");
2594        switch_scene(&world, app, next);
2595        world.apply().unwrap();
2596
2597        assert!(
2598            world
2599                .root_scope()
2600                .query_one::<&PresenterEntity>(app)
2601                .expect("the app keeps its presentation slot")
2602                .get()
2603                .0
2604                .is_none(),
2605            "the presentation path went with the scene it presented"
2606        );
2607        assert!(
2608            !world.root_scope().contains(old_presenter),
2609            "the presentation path's entity was despawned"
2610        );
2611        assert!(
2612            !world.root_scope().contains(old_scene),
2613            "the old scene was despawned"
2614        );
2615        assert_eq!(
2616            world
2617                .root_scope()
2618                .query_one::<&InitialScene>(app)
2619                .expect("the app keeps its initial scene")
2620                .get()
2621                .0
2622                .id,
2623            "ui_only"
2624        );
2625    }
2626
2627    /// An egui input for a `screen`-point screen carrying `events`.
2628    fn raw_input(screen: (f32, f32), events: Vec<egui::Event>) -> egui::RawInput {
2629        egui::RawInput {
2630            screen_rect: Some(egui::Rect::from_min_size(
2631                egui::Pos2::ZERO,
2632                egui::Vec2::new(screen.0, screen.1),
2633            )),
2634            events,
2635            ..Default::default()
2636        }
2637    }
2638
2639    /// Run one egui pass over a `screen`-point screen with `events`, drawing the
2640    /// fullscreen button in the state `active`, and report what it drew.
2641    ///
2642    /// What the pass drew is both what pressing the button did — the returned
2643    /// flag — and every galley it laid out, which is how a test reads the
2644    /// label without reaching into egui's internals.
2645    fn fullscreen_pass(
2646        ctx: &egui::Context,
2647        screen: (f32, f32),
2648        active: bool,
2649        events: Vec<egui::Event>,
2650    ) -> (bool, Vec<String>) {
2651        let input = egui::RawInput {
2652            screen_rect: Some(egui::Rect::from_min_size(
2653                egui::Pos2::ZERO,
2654                egui::Vec2::new(screen.0, screen.1),
2655            )),
2656            events,
2657            ..Default::default()
2658        };
2659        let mut clicked = false;
2660        let mut output = ctx.run_ui(input, |ui| {
2661            clicked = fullscreen_button(ui, active);
2662        });
2663        // egui panics if a texture delta is dropped unapplied; a real frame
2664        // hands it to the integration, and this test discards it.
2665        output.textures_delta.clear();
2666        let labels = output
2667            .shapes
2668            .iter()
2669            .filter_map(|clipped| match &clipped.shape {
2670                egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
2671                _ => None,
2672            })
2673            .collect();
2674        (clicked, labels)
2675    }
2676
2677    /// The centre of the area with `id`, which a previous pass has to have
2678    /// drawn for this to answer.
2679    fn area_centre(ctx: &egui::Context, id: &str) -> egui::Pos2 {
2680        area_rect(ctx, id).center()
2681    }
2682
2683    /// The rectangle of the area with `id`, which a previous pass has to have
2684    /// drawn for this to answer.
2685    fn area_rect(ctx: &egui::Context, id: &str) -> egui::Rect {
2686        ctx.memory(|memory| {
2687            memory
2688                .area_rect(egui::Id::unique(id))
2689                .expect("the area was drawn")
2690        })
2691    }
2692
2693    /// The pointer events that begin a drag at `pos`: the pointer arrives and
2694    /// the button goes down.
2695    fn drag_start(pos: egui::Pos2) -> Vec<egui::Event> {
2696        vec![
2697            egui::Event::PointerMoved(pos),
2698            egui::Event::PointerButton {
2699                pos,
2700                button: egui::PointerButton::Primary,
2701                pressed: true,
2702                modifiers: egui::Modifiers::default(),
2703            },
2704        ]
2705    }
2706
2707    /// The pointer events that move a held pointer to `to`. The movement has
2708    /// to be a pass of its own: egui measures a drag as the delta between two
2709    /// passes, not between two events of one.
2710    fn drag_move(to: egui::Pos2) -> Vec<egui::Event> {
2711        vec![egui::Event::PointerMoved(to)]
2712    }
2713
2714    /// The pointer events that release a held pointer at `pos`.
2715    fn drag_release(pos: egui::Pos2) -> Vec<egui::Event> {
2716        vec![egui::Event::PointerButton {
2717            pos,
2718            button: egui::PointerButton::Primary,
2719            pressed: false,
2720            modifiers: egui::Modifiers::default(),
2721        }]
2722    }
2723
2724    /// The pointer events that tap `pos`: a press and, on a later pass, the
2725    /// release that egui reports as a click.
2726    fn tap(pos: egui::Pos2) -> (Vec<egui::Event>, Vec<egui::Event>) {
2727        let button = |pressed| egui::Event::PointerButton {
2728            pos,
2729            button: egui::PointerButton::Primary,
2730            pressed,
2731            modifiers: egui::Modifiers::default(),
2732        };
2733        (
2734            vec![egui::Event::PointerMoved(pos), button(true)],
2735            vec![button(false)],
2736        )
2737    }
2738
2739    /// Run one egui pass over a `screen`-point screen with `events`, drawing
2740    /// the GPU-info panel for `info` where `initial` says, and report every
2741    /// galley it laid out.
2742    fn gpu_info_pass(
2743        ctx: &egui::Context,
2744        screen: (f32, f32),
2745        info: &GpuInfo,
2746        initial: Option<egui::Rect>,
2747        events: Vec<egui::Event>,
2748    ) -> Vec<String> {
2749        let input = egui::RawInput {
2750            screen_rect: Some(egui::Rect::from_min_size(
2751                egui::Pos2::ZERO,
2752                egui::Vec2::new(screen.0, screen.1),
2753            )),
2754            events,
2755            ..Default::default()
2756        };
2757        let mut output = ctx.run_ui(input, |ui| {
2758            gpu_info_panel(ui, info, initial);
2759        });
2760        // egui panics if a texture delta is dropped unapplied; a real frame
2761        // hands it to the integration, and this test discards it.
2762        output.textures_delta.clear();
2763        output
2764            .shapes
2765            .iter()
2766            .filter_map(|clipped| match &clipped.shape {
2767                egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
2768                _ => None,
2769            })
2770            .collect()
2771    }
2772
2773    /// Run one egui pass over a `screen`-point screen, drawing the GPU-info
2774    /// panel for `info`, and report every galley it laid out with the number of
2775    /// rows it was broken into. A fact on a single row is a fact the panel was
2776    /// wide enough to keep on one line.
2777    fn gpu_info_rows(
2778        ctx: &egui::Context,
2779        screen: (f32, f32),
2780        info: &GpuInfo,
2781        initial: Option<egui::Rect>,
2782    ) -> Vec<(String, usize)> {
2783        let mut output = ctx.run_ui(raw_input(screen, Vec::new()), |ui| {
2784            gpu_info_panel(ui, info, initial);
2785        });
2786        // egui panics if a texture delta is dropped unapplied; a real frame
2787        // hands it to the integration, and this test discards it.
2788        output.textures_delta.clear();
2789        output
2790            .shapes
2791            .iter()
2792            .filter_map(|clipped| match &clipped.shape {
2793                egui::Shape::Text(text) => {
2794                    Some((text.galley.text().to_owned(), text.galley.rows.len()))
2795                }
2796                _ => None,
2797            })
2798            .collect()
2799    }
2800
2801    #[test]
2802    fn the_limit_list_comes_from_the_limits_own_description() {
2803        // The labels are wgpu's own field names, so the test names the ones the
2804        // type is known to carry rather than a copy of the whole list.
2805        let rows = limit_rows(&wgpu::Limits::defaults());
2806        assert!(
2807            rows.len() > 50,
2808            "a limits struct has dozens of fields, got {}",
2809            rows.len()
2810        );
2811        for expected in [
2812            ("maxBindGroups", "4"),
2813            ("maxTextureDimension1D", "8192"),
2814            ("maxBufferSize", "268435456"),
2815        ] {
2816            assert!(
2817                rows.iter()
2818                    .any(|(name, value)| name == expected.0 && value == expected.1),
2819                "the limits do not show {expected:?}"
2820            );
2821        }
2822    }
2823
2824    /// The facts a GPU-info panel is asked to show, small enough to read.
2825    fn sample_gpu_info() -> GpuInfo {
2826        GpuInfo {
2827            name: "Test Adapter".to_owned(),
2828            summary: "vulkan · discrete GPU · test driver".to_owned(),
2829            rows: vec![("driver info".to_owned(), "1.2.3".to_owned())],
2830            features: vec!["DEPTH_CLIP_CONTROL", "TEXTURE_COMPRESSION_BC"],
2831            limits: vec![("max_bind_groups".to_owned(), "4".to_owned())],
2832        }
2833    }
2834
2835    #[test]
2836    fn the_gpu_info_panel_opens_in_the_bottom_left_corner() {
2837        // The panel is an overlay on the scene, so the first build has to put
2838        // it in its corner and keep it inside the window.
2839        for screen in [(1280.0, 577.0), (800.0, 600.0), (390.0, 844.0)] {
2840            let ctx = egui::Context::default();
2841            let info = sample_gpu_info();
2842            // Two passes: the first measures the content, the second lays the
2843            // window out against that measurement.
2844            let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2845            let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2846
2847            let panel = area_rect(&ctx, GPU_INFO_AREA);
2848            assert!(
2849                (panel.left() - OVERLAY_MARGIN).abs() < 1.0,
2850                "at {screen:?} the panel {panel:?} does not keep the left margin"
2851            );
2852            assert!(
2853                (screen.1 - panel.bottom() - OVERLAY_MARGIN).abs() < 1.0,
2854                "at {screen:?} the panel {panel:?} does not keep the bottom margin"
2855            );
2856            assert!(
2857                panel.right() <= screen.0,
2858                "at {screen:?} the panel {panel:?} runs off the right"
2859            );
2860            assert!(
2861                panel.width() >= GPU_INFO_MIN_WIDTH,
2862                "at {screen:?} the panel {panel:?} is narrower than its floor"
2863            );
2864        }
2865    }
2866
2867    #[test]
2868    fn the_gpu_info_panel_follows_its_corner_when_the_screen_changes_shape() {
2869        // The panel opens against the bottom-left corner. When the display
2870        // rotates, the frame becomes a different shape and egui only clamps the
2871        // remembered rectangle, so the panel has to be moved by how far that
2872        // corner moved — otherwise it stays where the old, now-rotated screen
2873        // put it.
2874        let ctx = egui::Context::default();
2875        let info = sample_gpu_info();
2876        for (from, to) in [
2877            ((1280.0, 577.0), (577.0, 1280.0)),
2878            ((800.0, 600.0), (600.0, 800.0)),
2879        ] {
2880            // Two passes: the first measures the content, the second lays the
2881            // window out against that measurement.
2882            let _ = gpu_info_pass(&ctx, from, &info, None, Vec::new());
2883            let _ = gpu_info_pass(&ctx, from, &info, None, Vec::new());
2884
2885            let _ = gpu_info_pass(&ctx, to, &info, None, Vec::new());
2886            let panel = area_rect(&ctx, GPU_INFO_AREA);
2887            assert!(
2888                (panel.left() - OVERLAY_MARGIN).abs() < 1.0
2889                    && (to.1 - panel.bottom() - OVERLAY_MARGIN).abs() < 1.0,
2890                "after {from:?} -> {to:?} the panel {panel:?} left its corner"
2891            );
2892        }
2893    }
2894
2895    #[test]
2896    fn the_gpu_info_panel_opens_wide_enough_for_its_facts() {
2897        // A limit name is the device's own, and some run past fifty characters.
2898        // A panel that opened at a fixed width would wrap them, so every fact
2899        // has to come out on one row.
2900        let info = sample_gpu_info();
2901        for screen in [(1280.0, 577.0), (800.0, 600.0), (390.0, 844.0)] {
2902            let ctx = egui::Context::default();
2903            // Two passes: the first measures the content, the second lays the
2904            // window out against that measurement.
2905            let _ = gpu_info_rows(&ctx, screen, &info, None);
2906            for (text, rows) in gpu_info_rows(&ctx, screen, &info, None) {
2907                assert_eq!(
2908                    rows, 1,
2909                    "at {screen:?} the panel wrapped {text:?} onto {rows} rows"
2910                );
2911            }
2912        }
2913    }
2914
2915    #[test]
2916    fn the_gpu_info_panel_wraps_a_fact_wider_than_the_window() {
2917        // The panel's opening width comes from its facts, so a fact wider than
2918        // the window cannot be kept on one row: it is wrapped inside the window
2919        // instead of the panel hanging off the edge.
2920        let mut info = sample_gpu_info();
2921        info.limits
2922            .push(("max_long_limit_name".to_owned(), "x".repeat(200)));
2923        let screen = (800.0, 600.0);
2924        let ctx = egui::Context::default();
2925        let _ = gpu_info_rows(&ctx, screen, &info, None);
2926        let lines = gpu_info_rows(&ctx, screen, &info, None);
2927
2928        let panel = area_rect(&ctx, GPU_INFO_AREA);
2929        assert!(
2930            panel.right() <= screen.0,
2931            "the panel {panel:?} runs off the right of the {screen:?} screen"
2932        );
2933        assert!(
2934            lines
2935                .iter()
2936                .any(|(text, rows)| text.starts_with("max_long_limit_name") && *rows > 1),
2937            "the oversized fact was not wrapped inside the window"
2938        );
2939    }
2940
2941    #[test]
2942    fn the_gpu_info_panel_can_be_dragged_by_its_title() {
2943        // The panel is a window, not a pinned overlay: a user has to be able to
2944        // move it off whatever it covers. The drag is a gesture spread over
2945        // several passes, as egui sees a real one.
2946        let ctx = egui::Context::default();
2947        let info = sample_gpu_info();
2948        let screen = (800.0, 600.0);
2949        let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2950        let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2951        let opened = area_rect(&ctx, GPU_INFO_AREA);
2952
2953        // Grab the title bar and pull the window up and to the right.
2954        let from = egui::pos2(opened.center().x, opened.top() + 8.0);
2955        let to = from + egui::vec2(60.0, -40.0);
2956        let _ = gpu_info_pass(&ctx, screen, &info, None, drag_start(from));
2957        let _ = gpu_info_pass(&ctx, screen, &info, None, drag_move(to));
2958        let _ = gpu_info_pass(&ctx, screen, &info, None, drag_release(to));
2959
2960        let moved = area_rect(&ctx, GPU_INFO_AREA);
2961        assert!(
2962            (moved.min - opened.min - (to - from)).length() < 1.0,
2963            "the panel did not follow the drag: opened {opened:?}, moved {moved:?}"
2964        );
2965    }
2966
2967    #[test]
2968    fn the_gpu_info_panel_prints_the_adapter_features_and_limits() {
2969        let ctx = egui::Context::default();
2970        let info = sample_gpu_info();
2971        let _ = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new());
2972        let text = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new()).join("\n");
2973
2974        for expected in [
2975            "Test Adapter",
2976            "vulkan · discrete GPU · test driver",
2977            "driver info: 1.2.3",
2978            "Features (2)",
2979            "DEPTH_CLIP_CONTROL",
2980            "TEXTURE_COMPRESSION_BC",
2981            "Limits (1)",
2982            "max_bind_groups = 4",
2983        ] {
2984            assert!(
2985                text.contains(expected),
2986                "the panel does not show {expected:?}"
2987            );
2988        }
2989    }
2990
2991    #[test]
2992    fn the_gpu_info_panel_says_none_when_no_feature_is_enabled() {
2993        let ctx = egui::Context::default();
2994        let mut info = sample_gpu_info();
2995        info.features.clear();
2996        let _ = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new());
2997        let text = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new()).join("\n");
2998        assert!(text.contains("Features (0)"), "the count is still stated");
2999        assert!(text.contains("none"), "an empty feature list is stated");
3000    }
3001
3002    #[test]
3003    fn the_fullscreen_button_offers_the_action_that_is_not_in_effect() {
3004        let ctx = egui::Context::default();
3005        // Two passes, because an anchored area is laid out against the size the
3006        // previous pass left it.
3007        for active in [false, true] {
3008            let _ = fullscreen_pass(&ctx, (800.0, 600.0), active, Vec::new());
3009            let (clicked, labels) = fullscreen_pass(&ctx, (800.0, 600.0), active, Vec::new());
3010            assert!(!clicked, "nothing was pressed");
3011            let label = labels.join(" ");
3012            if active {
3013                assert!(
3014                    label.contains("Exit fullscreen"),
3015                    "leaving is offered while fullscreen, got {label:?}"
3016                );
3017            } else {
3018                assert!(
3019                    label.contains("Fullscreen") && !label.contains("Exit"),
3020                    "entering is offered while windowed, got {label:?}"
3021                );
3022            }
3023        }
3024    }
3025
3026    #[test]
3027    fn the_fullscreen_button_reports_a_press() {
3028        let ctx = egui::Context::default();
3029        let screen = (800.0, 600.0);
3030        // An anchored area is laid out at the position the previous pass gave
3031        // it, and it is only pinned to its corner from the pass after the
3032        // first, so the tap is aimed at where two passes have put it.
3033        let _ = fullscreen_pass(&ctx, screen, false, Vec::new());
3034        let _ = fullscreen_pass(&ctx, screen, false, Vec::new());
3035        let (press, release) = tap(area_centre(&ctx, FULLSCREEN_AREA));
3036
3037        // A release with no press before it is not a click, so the request is
3038        // not raised by a stray event.
3039        let (clicked, _) = fullscreen_pass(&ctx, screen, false, release.clone());
3040        assert!(!clicked, "a release alone is not a click");
3041        // Neither is a press the user has not let go of yet.
3042        let (clicked, _) = fullscreen_pass(&ctx, screen, false, press);
3043        assert!(!clicked, "a press alone is not a click");
3044        let (clicked, _) = fullscreen_pass(&ctx, screen, false, release);
3045        assert!(clicked, "the release inside the button is a click");
3046    }
3047
3048    #[test]
3049    fn the_fullscreen_button_grows_to_its_wider_label() {
3050        let ctx = egui::Context::default();
3051        // The area remembers the size of the narrower enter label, which is
3052        // what it is laid out against when the exit label is first drawn.
3053        let _ = fullscreen_pass(&ctx, (800.0, 600.0), false, Vec::new());
3054        let (_, enter_labels) = fullscreen_pass(&ctx, (800.0, 600.0), false, Vec::new());
3055        assert_eq!(enter_labels.len(), 1, "the enter label is one line");
3056        let enter = area_rect(&ctx, FULLSCREEN_AREA);
3057        let _ = fullscreen_pass(&ctx, (800.0, 600.0), true, Vec::new());
3058        let leaving = area_rect(&ctx, FULLSCREEN_AREA);
3059        // A wrapped label makes the button a row taller, which is the bug: the
3060        // exit label is wider than the enter one, not taller than it.
3061        assert!(
3062            (leaving.height() - enter.height()).abs() < 1.0,
3063            "the exit label wrapped: {enter:?} became {leaving:?}"
3064        );
3065        assert!(
3066            leaving.width() > enter.width(),
3067            "the exit label is the wider of the two: {enter:?} to {leaving:?}"
3068        );
3069    }
3070
3071    /// The screens the corner overlays are laid out on: wide enough for all
3072    /// three to share a row, and narrow ones — a portrait phone — where they
3073    /// have to stack.
3074    const OVERLAY_SCREENS: [(f32, f32); 4] = [
3075        (1280.0, 577.0),
3076        (800.0, 600.0),
3077        (390.0, 844.0),
3078        (320.0, 480.0),
3079    ];
3080
3081    /// Lay the three corner overlays out on `screen` until their anchored
3082    /// positions settle, and return their rectangles.
3083    ///
3084    /// Each of them anchors against what the previous pass remembered, so one
3085    /// pass proves nothing: the layout is only settled once every area has a
3086    /// geometry for the next one to read.
3087    fn corner_rects(
3088        ctx: &egui::Context,
3089        screen: (f32, f32),
3090    ) -> (egui::Rect, egui::Rect, egui::Rect) {
3091        let text = "60.0 fps · 16.7 ms";
3092        for _ in 0..3 {
3093            let input = egui::RawInput {
3094                screen_rect: Some(egui::Rect::from_min_size(
3095                    egui::Pos2::ZERO,
3096                    egui::Vec2::new(screen.0, screen.1),
3097                )),
3098                ..Default::default()
3099            };
3100            let mut output = ctx.run_ui(input, |ui| {
3101                frame_rate_readout(ui, text);
3102                fullscreen_button(ui, false);
3103                api_docs_button(ui);
3104            });
3105            output.textures_delta.clear();
3106        }
3107        (
3108            area_rect(ctx, FRAME_RATE_AREA),
3109            area_rect(ctx, FULLSCREEN_AREA),
3110            area_rect(ctx, API_DOCS_AREA),
3111        )
3112    }
3113
3114    #[test]
3115    fn the_corner_overlays_do_not_cover_each_other() {
3116        // The readout sits centred at the top, the fullscreen button anchors to
3117        // the top-right corner and the docs link under it. On a window wide
3118        // enough for them they share the top row, and on a narrow one — a
3119        // portrait phone — the button drops under the readout rather than hide
3120        // the end of the reading. The link then follows the button down.
3121        for screen in OVERLAY_SCREENS {
3122            let ctx = egui::Context::default();
3123            let (readout, button, docs) = corner_rects(&ctx, screen);
3124            for (first, second) in [(readout, button), (readout, docs), (button, docs)] {
3125                assert!(
3126                    !first.intersects(second),
3127                    "at {screen:?} {first:?} and {second:?} share screen space"
3128                );
3129            }
3130
3131            // The readout is centred, its top a margin from the window's top.
3132            assert!(
3133                (readout.center().x - screen.0 / 2.0).abs() < 1.0,
3134                "at {screen:?} the readout {readout:?} is not centred"
3135            );
3136            assert!(
3137                (readout.top() - OVERLAY_MARGIN).abs() < 1.0,
3138                "at {screen:?} the readout {readout:?} does not keep the top margin"
3139            );
3140
3141            // Both corner buttons keep the margin from the right edge and stay
3142            // on screen, the link under the button.
3143            for (name, rect) in [("button", button), ("docs", docs)] {
3144                assert!(
3145                    (screen.0 - rect.right() - OVERLAY_MARGIN).abs() < 1.0,
3146                    "at {screen:?} the {name} {rect:?} does not keep the right margin"
3147                );
3148                assert!(
3149                    rect.bottom() <= screen.1,
3150                    "at {screen:?} the {name} {rect:?} runs off the bottom"
3151                );
3152            }
3153            assert!(
3154                docs.top() >= button.bottom(),
3155                "at {screen:?} the docs link {docs:?} is not under the button {button:?}"
3156            );
3157
3158            // Wide windows hold the readout and the button on the top row;
3159            // narrow ones stack the button under the readout.
3160            if screen.0 >= 800.0 {
3161                assert!(
3162                    (button.top() - OVERLAY_MARGIN).abs() < 1.0,
3163                    "at {screen:?} the button {button:?} should share the top row"
3164                );
3165            } else {
3166                assert!(
3167                    button.top() >= readout.bottom(),
3168                    "at {screen:?} the button {button:?} should drop under the readout {readout:?}"
3169                );
3170            }
3171        }
3172    }
3173
3174    #[test]
3175    fn the_docs_button_asks_for_the_api_docs() {
3176        // The docs link goes through egui's own URL command, which the UI
3177        // source publishes in the world; nothing here opens a page.
3178        let ctx = egui::Context::default();
3179        let screen = (800.0, 600.0);
3180        // The link is only under the button once a pass has settled both.
3181        for _ in 0..2 {
3182            let _ = corner_rects(&ctx, screen);
3183        }
3184        let (press, release) = tap(area_centre(&ctx, API_DOCS_AREA));
3185
3186        let mut output = ctx.run_ui(raw_input(screen, press), |ui| {
3187            api_docs_button(ui);
3188        });
3189        assert!(
3190            output.platform_output.commands.is_empty(),
3191            "a press alone asks for nothing"
3192        );
3193        output.textures_delta.clear();
3194
3195        let mut output = ctx.run_ui(raw_input(screen, release), |ui| {
3196            api_docs_button(ui);
3197        });
3198        let commands = std::mem::take(&mut output.platform_output.commands);
3199        output.textures_delta.clear();
3200        let opened: Vec<(String, bool)> = commands
3201            .iter()
3202            .filter_map(|command| match command {
3203                egui::OutputCommand::OpenUrl(open) => Some((open.url.clone(), open.new_tab)),
3204                _ => None,
3205            })
3206            .collect();
3207        assert_eq!(
3208            opened,
3209            vec![(API_DOCS_URL.to_owned(), false)],
3210            "the release asks for the API docs in this tab"
3211        );
3212    }
3213
3214    #[test]
3215    fn the_fullscreen_button_is_at_least_a_touch_target() {
3216        // A control a phone has to be hit with a finger must not shrink to a
3217        // row of text: the button is grown to the declared minimum, including
3218        // the padding that centres the label, on every state and width.
3219        for screen in [(1280.0, 577.0), (390.0, 844.0)] {
3220            for active in [false, true] {
3221                let ctx = egui::Context::default();
3222                for _ in 0..2 {
3223                    let _ = fullscreen_pass(&ctx, screen, active, Vec::new());
3224                }
3225                let button = area_rect(&ctx, FULLSCREEN_AREA);
3226                assert!(
3227                    button.width() >= OVERLAY_BUTTON_MIN_SIZE.x
3228                        && button.height() >= OVERLAY_BUTTON_MIN_SIZE.y,
3229                    "at {screen:?} {active} the button {button:?} is below the touch-target size"
3230                );
3231            }
3232        }
3233    }
3234
3235    /// Every line count the readout laid a text galley out with, for a screen
3236    /// of `screen` points showing each of `texts` in turn.
3237    ///
3238    /// The readout is anchored, and an anchored area lays its contents out
3239    /// against the size it remembered from the previous pass, so one pass
3240    /// proves nothing: the failure it is prone to only shows once the text
3241    /// outgrows what the last pass left room for. `texts` is therefore a
3242    /// sequence of readings, one per pass, and the first pass — egui's
3243    /// invisible sizing pass — draws no text at all and contributes nothing.
3244    fn readout_lines(screen: (f32, f32), texts: &[&str]) -> Vec<usize> {
3245        let ctx = egui::Context::default();
3246        let mut lines = Vec::new();
3247        for text in texts {
3248            let input = egui::RawInput {
3249                screen_rect: Some(egui::Rect::from_min_size(
3250                    egui::Pos2::ZERO,
3251                    egui::Vec2::new(screen.0, screen.1),
3252                )),
3253                ..Default::default()
3254            };
3255            let mut output = ctx.run_ui(input, |ui| frame_rate_readout(ui, text));
3256            // egui panics if a texture delta is dropped unapplied; a real frame
3257            // hands it to the integration, and this test discards it.
3258            output.textures_delta.clear();
3259            for clipped in &output.shapes {
3260                if let egui::Shape::Text(text) = &clipped.shape {
3261                    lines.push(text.galley.rows.len());
3262                }
3263            }
3264        }
3265        lines
3266    }
3267
3268    #[test]
3269    fn the_frame_rate_readout_stays_on_one_line() {
3270        // The reading is re-laid out every frame, starting from the empty
3271        // placeholder, and it grows a digit as the average settles: the readout
3272        // must hold one line through all of it, at any window width.
3273        for screen in [(1600.0, 900.0), (640.0, 480.0), (320.0, 240.0)] {
3274            for texts in [
3275                vec!["60.0 fps · 16.7 ms"; 4],
3276                vec!["— fps"; 4],
3277                vec![
3278                    "— fps",
3279                    "60.0 fps · 16.7 ms",
3280                    "60.0 fps · 16.7 ms",
3281                    "100.0 fps · 10.0 ms",
3282                ],
3283            ] {
3284                let lines = readout_lines(screen, &texts);
3285                assert!(
3286                    !lines.is_empty(),
3287                    "the readout drew nothing at {screen:?} {texts:?}"
3288                );
3289                assert_eq!(
3290                    lines,
3291                    vec![1; lines.len()],
3292                    "screen {screen:?} text {texts:?}"
3293                );
3294            }
3295        }
3296    }
3297
3298    #[test]
3299    fn a_clock_reaches_its_step_only_after_enough_time() {
3300        let mut clock = 0.0;
3301        let step = 0.5;
3302
3303        // Half the interval is not enough.
3304        assert!(!tick(&mut clock, step, 0.25));
3305        // The interval is reached exactly.
3306        assert!(tick(&mut clock, step, 0.25));
3307        // The clock keeps the leftover rather than resetting, so the next
3308        // interval is reached in the time that is left.
3309        assert!(!tick(&mut clock, step, 0.4));
3310        assert!(tick(&mut clock, step, 0.1));
3311    }
3312
3313    #[test]
3314    fn a_long_stall_advances_one_step_and_keeps_the_rest() {
3315        let mut clock = 0.0;
3316        // A two-second stall over a half-second step consumes one step and
3317        // leaves the surplus for later, rather than skipping frames.
3318        assert!(tick(&mut clock, 0.5, 2.0));
3319        assert_eq!(clock, 1.5, "the surplus stays in the clock");
3320
3321        // The surplus is spent over the next three calls, one step each.
3322        assert!(tick(&mut clock, 0.5, 0.0));
3323        assert!(tick(&mut clock, 0.5, 0.0));
3324        assert!(tick(&mut clock, 0.5, 0.0));
3325        assert_eq!(clock, 0.0, "the surplus is used up");
3326        assert!(!tick(&mut clock, 0.5, 0.0));
3327    }
3328
3329    #[test]
3330    fn the_frame_rate_reads_nothing_until_a_frame_was_measured() {
3331        let rate = FrameRate::default();
3332        assert_eq!(rate.mean_seconds(), None);
3333        assert_eq!(rate.text(), "— fps");
3334    }
3335
3336    #[test]
3337    fn the_frame_rate_averages_its_window_and_reports_both_units() {
3338        let mut rate = FrameRate::default();
3339        // A steady 16 ms a frame is a little over 60 fps.
3340        for _ in 0..FrameRate::WINDOW {
3341            rate.push(0.016);
3342        }
3343        let mean = rate.mean_seconds().expect("a measured frame");
3344        assert!((mean - 0.016).abs() < 1e-6);
3345        assert_eq!(rate.text(), "62.5 fps · 16.0 ms");
3346    }
3347
3348    #[test]
3349    fn the_frame_rate_window_slides_rather_than_averaging_the_session() {
3350        let mut rate = FrameRate::default();
3351        // A long slow stretch, then enough fast frames to fill the window
3352        // again: the reading has to follow, or a scene that stalled once would
3353        // read slow for the rest of the session.
3354        for _ in 0..FrameRate::WINDOW * 2 {
3355            rate.push(0.1);
3356        }
3357        for _ in 0..FrameRate::WINDOW {
3358            rate.push(0.01);
3359        }
3360        assert_eq!(rate.samples.len(), FrameRate::WINDOW);
3361        let mean = rate.mean_seconds().expect("a measured frame");
3362        assert!((mean - 0.01).abs() < 1e-6, "got {mean}");
3363    }
3364
3365    #[test]
3366    fn only_a_scene_with_a_baseline_and_a_window_letterboxes() {
3367        // The windowed path keeps a declared baseline's aspect.
3368        let (viewport, content) = letterbox(Some((960, 720)), (1600, 600), true);
3369        assert_eq!(content, (800, 600), "the camera is built for the region");
3370        let viewport = viewport.expect("a letterbox is stated");
3371        assert_eq!((viewport.width, viewport.height), (800.0, 600.0));
3372        assert_eq!(viewport.x, 400.0, "centred horizontally");
3373
3374        // A capture never does: it draws at the scene's own size.
3375        assert_eq!(
3376            letterbox(Some((960, 720)), (1600, 600), false),
3377            (None, (1600, 600))
3378        );
3379
3380        // A scene with no aspect of its own draws into the whole target, which
3381        // is what a UI-only scene wants at any size.
3382        assert_eq!(letterbox(None, (1600, 600), true), (None, (1600, 600)));
3383
3384        // A target of the baseline's own shape needs no bars.
3385        let (viewport, content) = letterbox(Some((960, 720)), (960, 720), true);
3386        assert_eq!(content, (960, 720));
3387        let viewport = viewport.expect("a letterbox is stated");
3388        assert_eq!((viewport.x, viewport.y), (0.0, 0.0));
3389    }
3390}