Skip to main content

fission_shell_winit/
lib.rs

1#![allow(unexpected_cfgs)]
2#![cfg_attr(
3    target_arch = "wasm32",
4    allow(dead_code, unused_imports, unused_variables)
5)]
6
7use anyhow::Result;
8use base64::Engine;
9use fission_core::internal::BuildCtx;
10use std::collections::{HashMap, VecDeque};
11use std::sync::mpsc;
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14#[cfg(target_arch = "wasm32")]
15use std::{cell::RefCell, rc::Rc};
16#[cfg(feature = "tray")]
17use winit::event::StartCause;
18#[cfg(target_os = "android")]
19use winit::platform::android::{activity::AndroidApp, EventLoopBuilderExtAndroid};
20#[cfg(target_os = "ios")]
21use winit::platform::ios::WindowAttributesExtIOS;
22#[cfg(target_os = "macos")]
23use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
24#[cfg(target_arch = "wasm32")]
25use winit::platform::web::{EventLoopExtWebSys, WindowAttributesExtWebSys, WindowExtWebSys};
26use winit::{
27    dpi::{PhysicalPosition, PhysicalSize},
28    event::{Event, Ime, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
29    event_loop::{
30        ActiveEventLoop as EventLoopWindowTarget, ControlFlow, EventLoop, EventLoopProxy,
31    },
32    window::{CursorIcon, Window, WindowAttributes, WindowId},
33};
34
35use fission_core::env::{VideoStatus, WindowInsets};
36use fission_core::internal::downcast_render_object;
37use fission_core::internal::InternalLoweringCx;
38use fission_core::{
39    Action, ActionId, ActionRegistry, DeepLink, DeepLinkConfig, DeepLinkReceived, Env, GlobalState,
40    InputEvent, KeyCode, KeyEvent as FissionKeyEvent, NotificationResponse,
41    NotificationResponseReceived, OpenUrlRequest, PointerButton, PointerEvent, Runtime,
42    RuntimeEffect, ServiceBindings, View, Widget, WidgetIdExt, OPEN_URL,
43};
44use fission_core::{ActionInput, CapabilityInvocationPayload, Effect};
45use fission_diagnostics::prelude as diag;
46use fission_ir::semantics::MouseCursor;
47use fission_ir::{CoreIR, Op, WidgetId};
48use fission_layout::{LayoutEngine, LayoutSize};
49use fission_render::{LayoutPoint, LayoutRect, Renderer as _};
50use fission_render_vello::parley::FontContext;
51use fission_render_vello::{
52    workload_profile_for_scene, RetainedSceneCache, VelloRenderer, VelloTextMeasurer,
53};
54use fission_shell::async_host::{
55    AsyncMessage, AsyncRegistry, RunningServiceHandle, ServiceControlMessage,
56};
57use fission_shell::{VideoBackend, VideoEvent, VideoPlayer};
58use fission_theme::fonts;
59use fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, SourceCache};
60
61use fission_test_driver::TestEvent;
62
63// Vello / WGPU
64#[cfg(not(target_arch = "wasm32"))]
65use pollster::block_on;
66#[cfg(not(target_arch = "wasm32"))]
67use std::time::Instant;
68use vello::util::{RenderContext, RenderSurface};
69use vello::wgpu;
70use vello::{AaSupport, Renderer as VelloSceneRenderer, RendererOptions, Scene};
71#[cfg(target_arch = "wasm32")]
72use wasm_bindgen::{Clamped, JsCast};
73#[cfg(target_arch = "wasm32")]
74use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
75#[cfg(target_arch = "wasm32")]
76use web_time::Instant;
77
78mod compositor;
79use compositor::TextureLayerCompositor;
80mod pipeline;
81pub use pipeline::{InvalidationSet, Pipeline};
82mod renderer_diagnostics;
83#[cfg(target_arch = "wasm32")]
84use renderer_diagnostics::renderer_request_from_value;
85use renderer_diagnostics::{emit_renderer_report, RendererReport, RendererRequest};
86mod software_renderer;
87use software_renderer::SoftwareRenderer;
88mod video_backend;
89#[cfg(target_os = "macos")]
90use video_backend::MacVideoBackend;
91#[cfg(not(target_os = "macos"))]
92use video_backend::MockVideoBackend;
93mod web_backend;
94#[cfg(target_os = "macos")]
95use web_backend::MacWebBackend;
96#[cfg(not(target_os = "macos"))]
97use web_backend::MockWebBackend;
98
99mod clipboard;
100use clipboard::DesktopClipboard;
101pub use clipboard::{ClipboardHost, MemoryClipboardHost};
102mod geolocation;
103pub use geolocation::{GeolocationHost, MemoryGeolocationHost, UnsupportedGeolocationHost};
104mod haptics;
105pub use haptics::{HapticHost, MemoryHapticHost, UnsupportedHapticHost};
106mod barcode;
107#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))]
108mod barcode_decode;
109pub use barcode::{BarcodeScannerHost, MemoryBarcodeScannerHost, UnsupportedBarcodeScannerHost};
110mod biometric;
111pub use biometric::{BiometricHost, MemoryBiometricHost, UnsupportedBiometricHost};
112mod bluetooth;
113pub use bluetooth::{BluetoothHost, MemoryBluetoothHost, UnsupportedBluetoothHost};
114mod camera;
115pub use camera::{CameraHost, MemoryCameraHost, UnsupportedCameraHost};
116mod ime;
117use ime::{DesktopImeHandler, TextInputConfig};
118mod microphone;
119pub use microphone::{MemoryMicrophoneHost, MicrophoneHost, UnsupportedMicrophoneHost};
120mod notifications;
121pub use notifications::{MemoryNotificationHost, NotificationHost, UnsupportedNotificationHost};
122mod nfc;
123pub use nfc::{MemoryNfcHost, NfcHost, UnsupportedNfcHost};
124mod passkey;
125pub use passkey::{MemoryPasskeyHost, PasskeyHost, UnsupportedPasskeyHost};
126#[cfg(feature = "tray")]
127pub mod tray;
128#[cfg(feature = "tray")]
129pub use tray::{
130    TrayActivateBehavior, TrayConfig, TrayHostAction, TrayIconSource, TrayMenu, TrayMenuAction,
131    TrayMenuBuilder, TrayMenuEntry, TrayMenuItem, WindowCloseBehavior,
132};
133pub mod test_control;
134mod wifi;
135pub use wifi::{MemoryWifiHost, UnsupportedWifiHost, WifiHost};
136mod volume;
137pub use volume::{MemoryVolumeHost, UnsupportedVolumeHost, VolumeHost};
138#[cfg(target_os = "android")]
139mod android_capabilities;
140#[cfg(target_os = "ios")]
141mod ios_capabilities;
142#[cfg(target_os = "macos")]
143mod macos_capabilities;
144#[cfg(target_arch = "wasm32")]
145mod web_capabilities;
146
147use fission_core::action::ActionEnvelope;
148
149type EffectResult = AsyncMessage;
150
151type ServiceKey = (String, String);
152type ServiceBindingKey = (String, String, u64);
153
154struct ActiveServiceHandle {
155    runtime: RunningServiceHandle,
156}
157
158#[cfg(not(target_arch = "wasm32"))]
159fn open_host_url(url: &str, _in_app: bool) -> Result<(), String> {
160    if cfg!(target_os = "macos") {
161        std::process::Command::new("open")
162            .arg(url)
163            .spawn()
164            .map(|_| ())
165            .map_err(|error| error.to_string())
166    } else if cfg!(target_os = "windows") {
167        std::process::Command::new("cmd")
168            .args(["/C", "start", url])
169            .spawn()
170            .map(|_| ())
171            .map_err(|error| error.to_string())
172    } else {
173        std::process::Command::new("xdg-open")
174            .arg(url)
175            .spawn()
176            .map(|_| ())
177            .map_err(|error| error.to_string())
178    }
179}
180
181#[cfg(target_arch = "wasm32")]
182fn open_host_url(url: &str, in_app: bool) -> Result<(), String> {
183    let window = web_sys::window().ok_or_else(|| "browser window is not available".to_string())?;
184    if in_app {
185        window.location().set_href(url).map_err(js_error_to_string)
186    } else {
187        window
188            .open_with_url_and_target(url, "_blank")
189            .map_err(js_error_to_string)?
190            .ok_or_else(|| format!("browser blocked opening url `{url}`"))?;
191        Ok(())
192    }
193}
194
195fn register_builtin_operation_capabilities(async_registry: &mut AsyncRegistry) {
196    async_registry.register_operation_capability(
197        OPEN_URL,
198        |request: OpenUrlRequest, _| async move {
199            open_host_url(&request.url, request.in_app)?;
200            Ok(())
201        },
202    );
203    #[cfg(target_arch = "wasm32")]
204    {
205        web_capabilities::register_web_operation_capabilities(async_registry);
206    }
207
208    #[cfg(not(target_arch = "wasm32"))]
209    {
210        notifications::register_notification_capabilities(
211            async_registry,
212            Arc::new(notifications::native_notification_host()),
213        );
214        nfc::register_nfc_capabilities(async_registry, Arc::new(UnsupportedNfcHost));
215        biometric::register_biometric_capabilities(
216            async_registry,
217            Arc::new(UnsupportedBiometricHost),
218        );
219        passkey::register_passkey_capabilities(async_registry, Arc::new(UnsupportedPasskeyHost));
220        bluetooth::register_bluetooth_capabilities(
221            async_registry,
222            Arc::new(UnsupportedBluetoothHost),
223        );
224        barcode::register_barcode_scanner_capabilities(
225            async_registry,
226            Arc::new(UnsupportedBarcodeScannerHost),
227        );
228        camera::register_camera_capabilities(async_registry, Arc::new(UnsupportedCameraHost));
229        clipboard::register_clipboard_capabilities(
230            async_registry,
231            Arc::new(DesktopClipboard::new()),
232        );
233        geolocation::register_geolocation_capabilities(
234            async_registry,
235            Arc::new(UnsupportedGeolocationHost),
236        );
237        haptics::register_haptic_capabilities(
238            async_registry,
239            Arc::new(haptics::native_haptic_host()),
240        );
241        microphone::register_microphone_capabilities(
242            async_registry,
243            Arc::new(UnsupportedMicrophoneHost),
244        );
245        wifi::register_wifi_capabilities(async_registry, Arc::new(UnsupportedWifiHost));
246        volume::register_volume_capabilities(
247            async_registry,
248            Arc::new(volume::native_volume_host()),
249        );
250        #[cfg(target_os = "macos")]
251        macos_capabilities::register_macos_operation_capabilities(async_registry);
252        #[cfg(target_os = "ios")]
253        ios_capabilities::register_ios_operation_capabilities(async_registry);
254    }
255}
256
257fn collect_startup_deep_links(config: &DeepLinkConfig) -> Vec<DeepLink> {
258    let args = std::env::args().skip(1).collect::<Vec<_>>();
259    let mut env_values = Vec::new();
260    if let Ok(value) = std::env::var("FISSION_DEEP_LINK_URL") {
261        env_values.push(value);
262    }
263    if let Ok(value) = std::env::var("FISSION_DEEP_LINKS") {
264        env_values.extend(
265            value
266                .split('\n')
267                .map(str::trim)
268                .filter(|value| !value.is_empty())
269                .map(ToOwned::to_owned),
270        );
271    }
272
273    #[cfg(target_arch = "wasm32")]
274    if let Some(window) = web_sys::window() {
275        if let Ok(href) = window.location().href() {
276            env_values.push(href);
277        }
278    }
279
280    collect_startup_deep_links_from(config, args, env_values)
281}
282
283fn collect_startup_deep_links_from(
284    config: &DeepLinkConfig,
285    args: impl IntoIterator<Item = String>,
286    env_values: impl IntoIterator<Item = String>,
287) -> Vec<DeepLink> {
288    let mut links = Vec::new();
289    for url in env_values.into_iter().chain(args) {
290        if config.matches(&url) {
291            links.push(
292                DeepLink::new(url.clone())
293                    .cold_start(true)
294                    .source(config.source_for(&url)),
295            );
296        }
297    }
298    links
299}
300
301#[cfg(target_arch = "wasm32")]
302fn js_error_to_string(error: wasm_bindgen::JsValue) -> String {
303    error
304        .as_string()
305        .unwrap_or_else(|| format!("JavaScript error: {error:?}"))
306}
307
308struct ActivePlayer {
309    player: Box<dyn VideoPlayer>,
310    last_status: Option<VideoStatus>,
311    last_rate: Option<f32>,
312    last_volume: Option<f32>,
313    last_muted: Option<bool>,
314}
315
316struct RenderState<'w> {
317    surface: RenderSurface<'w>,
318    target_texture_size: (u32, u32),
319    #[cfg(feature = "three-d")]
320    scene3d_renderer: fission_3d::render::Scene3DRenderer,
321    main_renderer: MainRenderer,
322    renderer_report: RendererReport,
323}
324
325enum MainRenderer {
326    Vello {
327        renderer: VelloSceneRenderer,
328        texture_compositor: TextureLayerCompositor,
329    },
330    Software,
331}
332
333#[cfg(target_arch = "wasm32")]
334struct WebCanvasPresenter {
335    canvas: HtmlCanvasElement,
336    context: CanvasRenderingContext2d,
337    report: RendererReport,
338}
339
340#[cfg(target_arch = "wasm32")]
341impl WebCanvasPresenter {
342    fn new(window: &Window) -> anyhow::Result<Self> {
343        let canvas = window
344            .canvas()
345            .ok_or_else(|| anyhow::anyhow!("winit web window did not expose a canvas"))?;
346        let context = canvas
347            .get_context("2d")
348            .map_err(|error| anyhow::anyhow!(js_error_to_string(error)))?
349            .ok_or_else(|| anyhow::anyhow!("2D canvas context is unavailable"))?
350            .dyn_into::<CanvasRenderingContext2d>()
351            .map_err(|error| anyhow::anyhow!(js_error_to_string(error.into())))?;
352        Ok(Self {
353            canvas,
354            context,
355            report: RendererReport::new(
356                "canvas2d-software",
357                web_renderer_request(),
358                None,
359                None,
360                None,
361                0,
362                0,
363                1.0,
364            ),
365        })
366    }
367
368    fn present(
369        &mut self,
370        rgba: &[u8],
371        width: u32,
372        height: u32,
373        scale_factor: f64,
374    ) -> anyhow::Result<()> {
375        self.canvas.set_width(width.max(1));
376        self.canvas.set_height(height.max(1));
377        self.report.width = width.max(1);
378        self.report.height = height.max(1);
379        self.report.scale_factor = scale_factor;
380        let image =
381            ImageData::new_with_u8_clamped_array_and_sh(Clamped(rgba), width.max(1), height.max(1))
382                .map_err(|error| anyhow::anyhow!(js_error_to_string(error)))?;
383        self.context
384            .put_image_data(&image, 0.0, 0.0)
385            .map_err(|error| anyhow::anyhow!(js_error_to_string(error)))?;
386        Ok(())
387    }
388}
389
390#[cfg(target_arch = "wasm32")]
391struct WebGpuPresenter {
392    render_cx: RenderContext,
393    render_state: RenderState<'static>,
394    scene: Scene,
395    retained_scene_cache: RetainedSceneCache,
396}
397
398#[cfg(target_arch = "wasm32")]
399enum WebRenderer {
400    WebGpu(WebGpuPresenter),
401    Canvas2d(WebCanvasPresenter),
402}
403
404#[cfg(target_arch = "wasm32")]
405impl WebRenderer {
406    fn report(&self) -> &RendererReport {
407        match self {
408            Self::WebGpu(presenter) => &presenter.render_state.renderer_report,
409            Self::Canvas2d(presenter) => &presenter.report,
410        }
411    }
412
413    fn active_name(&self) -> &str {
414        self.report().active.as_str()
415    }
416}
417
418#[cfg(target_arch = "wasm32")]
419type PendingWebGpuInit = Rc<RefCell<Option<Result<WebGpuPresenter, String>>>>;
420
421#[derive(Debug, Clone, Copy, PartialEq)]
422struct WindowViewportState {
423    physical_size: PhysicalSize<u32>,
424    scale_factor: f64,
425}
426
427impl WindowViewportState {
428    fn from_window(window: &Window) -> Self {
429        #[cfg(target_arch = "wasm32")]
430        if let Some(viewport) = web_browser_viewport_state() {
431            return viewport;
432        }
433
434        let reported_scale_factor = normalize_scale_factor(window.scale_factor());
435        #[cfg(target_os = "ios")]
436        {
437            // Winit's iOS `inner_size` is the safe-area rectangle. The renderer
438            // presents into the full view, so the viewport must use the outer
439            // bounds and expose the safe-area separately through `Env`.
440            let mut physical_size = window.outer_size();
441            let effective_scale_factor = ios_effective_scale_factor(reported_scale_factor);
442            if effective_scale_factor > reported_scale_factor && reported_scale_factor <= 1.0 {
443                physical_size = logical_viewport_to_physical_size(
444                    LayoutSize::new(physical_size.width as f32, physical_size.height as f32),
445                    effective_scale_factor,
446                );
447            }
448            return Self {
449                physical_size,
450                scale_factor: effective_scale_factor,
451            };
452        }
453
454        #[cfg(not(target_os = "ios"))]
455        {
456            Self {
457                physical_size: window.inner_size(),
458                scale_factor: reported_scale_factor,
459            }
460        }
461    }
462
463    fn logical_size(self) -> LayoutSize {
464        physical_size_to_layout_size(self.physical_size, self.scale_factor)
465    }
466
467    fn with_physical_size(self, physical_size: PhysicalSize<u32>) -> Self {
468        Self {
469            physical_size,
470            ..self
471        }
472    }
473
474    fn with_logical_size(self, logical_size: LayoutSize) -> Self {
475        self.with_physical_size(logical_viewport_to_physical_size(
476            logical_size,
477            self.scale_factor,
478        ))
479    }
480
481    #[cfg(any(test, not(target_os = "ios")))]
482    fn with_scale_factor(self, scale_factor: f64) -> Self {
483        let scale_factor = normalize_scale_factor(scale_factor);
484        let logical_size = self.logical_size();
485        Self {
486            physical_size: logical_viewport_to_physical_size(logical_size, scale_factor),
487            scale_factor,
488        }
489    }
490}
491
492#[cfg(any(test, target_os = "ios"))]
493fn window_insets_from_safe_area_frames(
494    inner_position: PhysicalPosition<i32>,
495    outer_position: PhysicalPosition<i32>,
496    inner_size: PhysicalSize<u32>,
497    outer_size: PhysicalSize<u32>,
498    scale_factor: f64,
499) -> WindowInsets {
500    let scale_factor = normalize_scale_factor(scale_factor) as f32;
501    let left_px = (inner_position.x - outer_position.x).max(0) as i64;
502    let top_px = (inner_position.y - outer_position.y).max(0) as i64;
503    let right_px = (outer_size.width as i64 - inner_size.width as i64 - left_px).max(0);
504    let bottom_px = (outer_size.height as i64 - inner_size.height as i64 - top_px).max(0);
505
506    WindowInsets {
507        top: top_px as f32 / scale_factor,
508        bottom: bottom_px as f32 / scale_factor,
509        left: left_px as f32 / scale_factor,
510        right: right_px as f32 / scale_factor,
511    }
512}
513
514fn window_safe_area_insets(window: &Window, scale_factor: f64) -> WindowInsets {
515    #[cfg(target_os = "ios")]
516    {
517        if let (Ok(inner_position), Ok(outer_position)) =
518            (window.inner_position(), window.outer_position())
519        {
520            return window_insets_from_safe_area_frames(
521                inner_position,
522                outer_position,
523                window.inner_size(),
524                window.outer_size(),
525                scale_factor,
526            );
527        }
528    }
529
530    let _ = (window, scale_factor);
531    WindowInsets::default()
532}
533
534#[cfg(not(target_arch = "wasm32"))]
535fn create_render_state<'w>(
536    render_cx: &mut RenderContext,
537    window: Arc<Window>,
538    viewport: WindowViewportState,
539) -> anyhow::Result<RenderState<'w>> {
540    let mut surface = block_on(render_cx.create_surface(
541        window.clone(),
542        viewport.physical_size.width,
543        viewport.physical_size.height,
544        wgpu::PresentMode::AutoVsync,
545    ))
546    .map_err(|error| anyhow::anyhow!("failed to create render surface: {error}"))?;
547
548    let device_handle = &render_cx.devices[surface.dev_id];
549    #[cfg(target_os = "ios")]
550    device_handle.device.on_uncaptured_error(Box::new(|error| {
551        eprintln!("wgpu uncaptured error: {error}");
552    }));
553    let surface_caps = surface.surface.get_capabilities(device_handle.adapter());
554    surface.config.alpha_mode = surface_caps
555        .alpha_modes
556        .iter()
557        .copied()
558        .find(|mode| *mode == wgpu::CompositeAlphaMode::PostMultiplied)
559        .unwrap_or_else(|| {
560            surface_caps
561                .alpha_modes
562                .first()
563                .copied()
564                .unwrap_or(wgpu::CompositeAlphaMode::Opaque)
565        });
566    surface
567        .surface
568        .configure(&device_handle.device, &surface.config);
569
570    let target_texture_size = (surface.config.width, surface.config.height);
571    recreate_target_texture(
572        &mut surface,
573        render_cx,
574        target_texture_size.0,
575        target_texture_size.1,
576    );
577
578    #[cfg(feature = "three-d")]
579    let scene3d_renderer = fission_3d::render::Scene3DRenderer::new(
580        &device_handle.device,
581        viewport.physical_size.width,
582        viewport.physical_size.height,
583        wgpu::TextureFormat::Rgba8Unorm,
584    );
585
586    let request = native_renderer_request();
587    let supports_indirect_execution = device_handle
588        .adapter()
589        .get_downlevel_capabilities()
590        .flags
591        .contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
592    let (main_renderer, renderer_report) = create_native_main_renderer(
593        device_handle,
594        request,
595        supports_indirect_execution,
596        viewport.physical_size.width,
597        viewport.physical_size.height,
598        viewport.scale_factor,
599    )?;
600    emit_renderer_report(&renderer_report);
601
602    Ok(RenderState {
603        surface,
604        target_texture_size,
605        #[cfg(feature = "three-d")]
606        scene3d_renderer,
607        main_renderer,
608        renderer_report,
609    })
610}
611
612#[cfg(not(target_arch = "wasm32"))]
613fn present_startup_clear_frame(
614    render_state: &mut RenderState<'_>,
615    render_cx: &RenderContext,
616    clear_color: wgpu::Color,
617) -> anyhow::Result<()> {
618    let surface_texture = render_state
619        .surface
620        .surface
621        .get_current_texture()
622        .map_err(|error| anyhow::anyhow!("failed to get startup surface texture: {error}"))?;
623    let target_view = surface_texture
624        .texture
625        .create_view(&wgpu::TextureViewDescriptor::default());
626    let device_handle = &render_cx.devices[render_state.surface.dev_id];
627    let mut encoder =
628        device_handle
629            .device
630            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
631                label: Some("Fission startup clear encoder"),
632            });
633    {
634        let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
635            label: Some("Fission startup clear pass"),
636            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
637                view: &target_view,
638                resolve_target: None,
639                depth_slice: None,
640                ops: wgpu::Operations {
641                    load: wgpu::LoadOp::Clear(clear_color),
642                    store: wgpu::StoreOp::Store,
643                },
644            })],
645            depth_stencil_attachment: None,
646            timestamp_writes: None,
647            occlusion_query_set: None,
648        });
649    }
650    device_handle.queue.submit(Some(encoder.finish()));
651    surface_texture.present();
652    Ok(())
653}
654
655#[cfg(not(target_arch = "wasm32"))]
656fn theme_background_wgpu_color(env: &Env) -> wgpu::Color {
657    wgpu::Color {
658        r: f64::from(env.theme.tokens.colors.background.r) / 255.0,
659        g: f64::from(env.theme.tokens.colors.background.g) / 255.0,
660        b: f64::from(env.theme.tokens.colors.background.b) / 255.0,
661        a: f64::from(env.theme.tokens.colors.background.a) / 255.0,
662    }
663}
664
665#[cfg(not(target_arch = "wasm32"))]
666fn native_renderer_request() -> RendererRequest {
667    let request = RendererRequest::from_env();
668    let force_cpu_vello = std::env::var("FISSION_VELLO_USE_CPU")
669        .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
670        .unwrap_or(false);
671    if force_cpu_vello {
672        RendererRequest::NativeVelloCpu
673    } else {
674        request
675    }
676}
677
678#[cfg(target_arch = "wasm32")]
679fn web_renderer_request() -> RendererRequest {
680    if let Some(window) = web_sys::window() {
681        if let Ok(search) = window.location().search() {
682            if let Some(value) = query_param(&search, "fission_renderer") {
683                return renderer_request_from_value(Some(&value));
684            }
685        }
686        let global = js_sys::global();
687        if let Ok(value) = js_sys::Reflect::get(
688            &global,
689            &wasm_bindgen::JsValue::from_str("FISSION_RENDERER"),
690        ) {
691            if let Some(value) = value.as_string() {
692                return renderer_request_from_value(Some(&value));
693            }
694        }
695    }
696    RendererRequest::Auto
697}
698
699#[cfg(target_arch = "wasm32")]
700fn query_param(search: &str, name: &str) -> Option<String> {
701    let search = search.strip_prefix('?').unwrap_or(search);
702    search.split('&').find_map(|part| {
703        let mut pieces = part.splitn(2, '=');
704        let key = pieces.next()?;
705        if key == name {
706            pieces.next().map(|value| value.replace('+', " "))
707        } else {
708            None
709        }
710    })
711}
712
713#[cfg(not(target_arch = "wasm32"))]
714fn create_native_main_renderer(
715    device_handle: &vello::util::DeviceHandle,
716    request: RendererRequest,
717    supports_indirect_execution: bool,
718    width: u32,
719    height: u32,
720    scale_factor: f64,
721) -> anyhow::Result<(MainRenderer, RendererReport)> {
722    let (backend, adapter) = adapter_labels(device_handle.adapter());
723    if matches!(request, RendererRequest::NativeSoftware) {
724        return Ok((
725            MainRenderer::Software,
726            RendererReport::new(
727                "native-software-upload",
728                request,
729                backend,
730                adapter,
731                Some("forced_by_renderer_request".to_string()),
732                width,
733                height,
734                scale_factor,
735            ),
736        ));
737    }
738
739    let cpu_requested = matches!(request, RendererRequest::NativeVelloCpu);
740    match create_vello_main_renderer(device_handle, cpu_requested, supports_indirect_execution) {
741        Ok(renderer) => {
742            let active = if cpu_requested {
743                "native-vello-cpu"
744            } else if cfg!(target_os = "ios") || cfg!(target_os = "macos") {
745                "metal-vello"
746            } else {
747                "native-vello"
748            };
749            Ok((
750                renderer,
751                RendererReport::new(
752                    active,
753                    request,
754                    backend,
755                    adapter,
756                    if matches!(request, RendererRequest::NativeVelloCpu) {
757                        Some("forced_cpu_vello".to_string())
758                    } else if !supports_indirect_execution {
759                        Some("direct_dispatch_fallback".to_string())
760                    } else if cpu_requested {
761                        Some("missing_indirect_execution".to_string())
762                    } else {
763                        None
764                    },
765                    width,
766                    height,
767                    scale_factor,
768                ),
769            ))
770        }
771        Err(gpu_error) if request.is_explicit_gpu() => Err(anyhow::anyhow!(
772            "requested native Vello GPU renderer but initialization failed: {gpu_error}"
773        )),
774        Err(gpu_error) => match create_vello_main_renderer(device_handle, true, true) {
775            Ok(renderer) => Ok((
776                renderer,
777                RendererReport::new(
778                    "native-vello-cpu",
779                    request,
780                    backend,
781                    adapter,
782                    Some(format!("gpu_vello_init_failed:{gpu_error}")),
783                    width,
784                    height,
785                    scale_factor,
786                ),
787            )),
788            Err(cpu_error) => Ok((
789                MainRenderer::Software,
790                RendererReport::new(
791                    "native-software-upload",
792                    request,
793                    backend,
794                    adapter,
795                    Some(format!(
796                        "gpu_vello_init_failed:{gpu_error};cpu_vello_init_failed:{cpu_error}"
797                    )),
798                    width,
799                    height,
800                    scale_factor,
801                ),
802            )),
803        },
804    }
805}
806
807#[cfg(not(target_arch = "wasm32"))]
808fn create_vello_main_renderer(
809    device_handle: &vello::util::DeviceHandle,
810    use_cpu: bool,
811    use_indirect_dispatch: bool,
812) -> anyhow::Result<MainRenderer> {
813    let renderer = VelloSceneRenderer::new(
814        &device_handle.device,
815        RendererOptions {
816            use_cpu,
817            use_indirect_dispatch,
818            antialiasing_support: AaSupport::all(),
819            num_init_threads: None,
820            pipeline_cache: None,
821        },
822    )
823    .map_err(|error| anyhow::anyhow!("failed to create vello renderer: {error}"))?;
824
825    let texture_compositor =
826        TextureLayerCompositor::new(&device_handle.device, wgpu::TextureFormat::Rgba8Unorm);
827    Ok(MainRenderer::Vello {
828        renderer,
829        texture_compositor,
830    })
831}
832
833fn adapter_labels(adapter: &wgpu::Adapter) -> (Option<String>, Option<String>) {
834    let info = adapter.get_info();
835    let backend = Some(format!("{:?}", info.backend));
836    let adapter = (!info.name.trim().is_empty()).then_some(info.name);
837    (backend, adapter)
838}
839
840#[cfg(target_arch = "wasm32")]
841async fn create_webgpu_presenter(
842    canvas: HtmlCanvasElement,
843    viewport: WindowViewportState,
844    request: RendererRequest,
845) -> anyhow::Result<WebGpuPresenter> {
846    canvas.set_width(viewport.physical_size.width.max(1));
847    canvas.set_height(viewport.physical_size.height.max(1));
848    let mut render_cx = RenderContext::new();
849    let surface = render_cx
850        .instance
851        .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
852        .map_err(|error| anyhow::anyhow!("failed to create webgpu canvas surface: {error}"))?;
853    let mut surface = render_cx
854        .create_render_surface(
855            surface,
856            viewport.physical_size.width,
857            viewport.physical_size.height,
858            wgpu::PresentMode::AutoVsync,
859        )
860        .await
861        .map_err(|error| anyhow::anyhow!("failed to create webgpu render surface: {error}"))?;
862
863    let device_handle = &render_cx.devices[surface.dev_id];
864    let surface_caps = surface.surface.get_capabilities(device_handle.adapter());
865    surface.config.alpha_mode = surface_caps
866        .alpha_modes
867        .iter()
868        .copied()
869        .find(|mode| *mode == wgpu::CompositeAlphaMode::PostMultiplied)
870        .unwrap_or_else(|| {
871            surface_caps
872                .alpha_modes
873                .first()
874                .copied()
875                .unwrap_or(wgpu::CompositeAlphaMode::Opaque)
876        });
877    surface
878        .surface
879        .configure(&device_handle.device, &surface.config);
880
881    let target_texture_size = (surface.config.width, surface.config.height);
882    recreate_target_texture(
883        &mut surface,
884        &render_cx,
885        target_texture_size.0,
886        target_texture_size.1,
887    );
888    let main_renderer = create_webgpu_main_renderer(device_handle, request)?;
889    let active_renderer = match &main_renderer {
890        MainRenderer::Vello { .. } => "webgpu-vello",
891        MainRenderer::Software => "webgpu-software",
892    };
893    let (backend, adapter) = adapter_labels(device_handle.adapter());
894    let renderer_report = RendererReport::new(
895        active_renderer,
896        request,
897        backend,
898        adapter,
899        None,
900        viewport.physical_size.width,
901        viewport.physical_size.height,
902        viewport.scale_factor,
903    );
904    let render_state = RenderState {
905        surface,
906        target_texture_size,
907        #[cfg(feature = "three-d")]
908        scene3d_renderer: fission_3d::render::Scene3DRenderer::new(
909            &device_handle.device,
910            viewport.physical_size.width,
911            viewport.physical_size.height,
912            wgpu::TextureFormat::Rgba8Unorm,
913        ),
914        main_renderer,
915        renderer_report,
916    };
917    Ok(WebGpuPresenter {
918        render_cx,
919        render_state,
920        scene: Scene::new(),
921        retained_scene_cache: RetainedSceneCache::default(),
922    })
923}
924
925#[cfg(target_arch = "wasm32")]
926fn create_webgpu_main_renderer(
927    device_handle: &vello::util::DeviceHandle,
928    request: RendererRequest,
929) -> anyhow::Result<MainRenderer> {
930    if matches!(request, RendererRequest::Canvas2dSoftware) {
931        return Err(anyhow::anyhow!(
932            "webgpu renderer disabled by renderer request"
933        ));
934    }
935    let renderer = VelloSceneRenderer::new(
936        &device_handle.device,
937        RendererOptions {
938            use_cpu: false,
939            use_indirect_dispatch: true,
940            antialiasing_support: AaSupport::all(),
941            num_init_threads: None,
942            pipeline_cache: None,
943        },
944    )
945    .map_err(|error| anyhow::anyhow!("failed to create webgpu Vello renderer: {error}"))?;
946    let texture_compositor =
947        TextureLayerCompositor::new(&device_handle.device, wgpu::TextureFormat::Rgba8Unorm);
948    Ok(MainRenderer::Vello {
949        renderer,
950        texture_compositor,
951    })
952}
953
954#[cfg(target_arch = "wasm32")]
955fn publish_web_renderer_report(report: &RendererReport) {
956    let line = report.concise_line();
957    web_sys::console::info_1(&wasm_bindgen::JsValue::from_str(&format!(
958        "fission-shell-winit: {line}"
959    )));
960    set_web_global_json("__FISSION_RENDERER_INFO", report);
961    post_web_runtime_event("/__fission/renderer", report);
962}
963
964#[cfg(target_arch = "wasm32")]
965#[derive(serde::Serialize)]
966struct WebFramePerf<'a> {
967    renderer: &'a str,
968    total_ms: f64,
969}
970
971#[cfg(target_arch = "wasm32")]
972#[derive(serde::Serialize)]
973struct WebInputLatency<'a> {
974    renderer: &'a str,
975    latency_ms: f64,
976}
977
978#[cfg(target_arch = "wasm32")]
979fn publish_web_frame_perf(renderer: &str, total_ms: f64) {
980    let perf = WebFramePerf { renderer, total_ms };
981    append_web_perf_sample("frames", total_ms);
982    diag::emit(
983        diag::DiagCategory::Frame,
984        diag::DiagLevel::Debug,
985        diag::DiagEventKind::FramePerformance {
986            renderer: renderer.to_string(),
987            total_ms,
988        },
989    );
990    set_web_global_json("__FISSION_LAST_FRAME_PERF", &perf);
991}
992
993#[cfg(target_arch = "wasm32")]
994fn publish_web_input_latency(renderer: &str, latency_ms: f64) {
995    let latency = WebInputLatency {
996        renderer,
997        latency_ms,
998    };
999    append_web_perf_sample("inputLatencies", latency_ms);
1000    diag::emit(
1001        diag::DiagCategory::Input,
1002        diag::DiagLevel::Debug,
1003        diag::DiagEventKind::InputLatency {
1004            renderer: renderer.to_string(),
1005            latency_ms,
1006        },
1007    );
1008    set_web_global_json("__FISSION_LAST_INPUT_LATENCY", &latency);
1009}
1010
1011#[cfg(target_arch = "wasm32")]
1012fn set_web_global_json<T: serde::Serialize>(name: &str, value: &T) {
1013    let Ok(json) = serde_json::to_string(value) else {
1014        return;
1015    };
1016    let Ok(js_value) = js_sys::JSON::parse(&json) else {
1017        return;
1018    };
1019    let _ = js_sys::Reflect::set(
1020        &js_sys::global(),
1021        &wasm_bindgen::JsValue::from_str(name),
1022        &js_value,
1023    );
1024}
1025
1026#[cfg(target_arch = "wasm32")]
1027fn append_web_perf_sample(name: &str, value: f64) {
1028    let global = js_sys::global();
1029    let key = wasm_bindgen::JsValue::from_str("__FISSION_PERF");
1030    let perf = js_sys::Reflect::get(&global, &key)
1031        .ok()
1032        .filter(|value| value.is_object())
1033        .unwrap_or_else(|| {
1034            let object = js_sys::Object::new();
1035            let _ = js_sys::Reflect::set(&global, &key, &object);
1036            object.into()
1037        });
1038    let sample_key = wasm_bindgen::JsValue::from_str(name);
1039    let samples = js_sys::Reflect::get(&perf, &sample_key)
1040        .ok()
1041        .and_then(|value| value.dyn_into::<js_sys::Array>().ok())
1042        .unwrap_or_else(|| {
1043            let array = js_sys::Array::new();
1044            let _ = js_sys::Reflect::set(&perf, &sample_key, &array);
1045            array
1046        });
1047    samples.push(&wasm_bindgen::JsValue::from_f64(value));
1048    while samples.length() > 240 {
1049        samples.shift();
1050    }
1051}
1052
1053#[cfg(target_arch = "wasm32")]
1054fn post_web_runtime_event<T: serde::Serialize>(path: &str, value: &T) {
1055    let Some(window) = web_sys::window() else {
1056        return;
1057    };
1058    let Ok(body) = serde_json::to_string(value) else {
1059        return;
1060    };
1061    let init = web_sys::RequestInit::new();
1062    init.set_method("POST");
1063    init.set_mode(web_sys::RequestMode::SameOrigin);
1064    init.set_body(&wasm_bindgen::JsValue::from_str(&body));
1065    let Ok(request) = web_sys::Request::new_with_str_and_init(path, &init) else {
1066        return;
1067    };
1068    let _ = request.headers().set("content-type", "application/json");
1069    wasm_bindgen_futures::spawn_local(async move {
1070        let _ = wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await;
1071    });
1072}
1073
1074#[cfg(target_arch = "wasm32")]
1075fn web_bool_global(name: &str) -> bool {
1076    js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str(name))
1077        .ok()
1078        .and_then(|value| {
1079            value.as_bool().or_else(|| {
1080                value
1081                    .as_string()
1082                    .map(|s| matches!(s.as_str(), "1" | "true" | "yes"))
1083            })
1084        })
1085        .unwrap_or(false)
1086}
1087
1088#[cfg(target_os = "android")]
1089fn build_window(
1090    title: &str,
1091    background_test_mode: bool,
1092    target: &EventLoopWindowTarget,
1093    _web_mount_selector: Option<&str>,
1094) -> anyhow::Result<Arc<Window>> {
1095    let reported_scale_factor = target
1096        .primary_monitor()
1097        .map(|monitor| monitor.scale_factor());
1098    let window_attributes = build_window_attributes(
1099        title,
1100        background_test_mode,
1101        _web_mount_selector,
1102        reported_scale_factor,
1103    )?;
1104    Ok(Arc::new(target.create_window(window_attributes).map_err(
1105        |e| anyhow::anyhow!("Window build error: {}", e),
1106    )?))
1107}
1108
1109#[cfg(not(target_os = "android"))]
1110fn build_window_before_run(
1111    title: &str,
1112    background_test_mode: bool,
1113    event_loop: &EventLoop<TestEvent>,
1114    _web_mount_selector: Option<&str>,
1115) -> anyhow::Result<Arc<Window>> {
1116    let window_attributes =
1117        build_window_attributes(title, background_test_mode, _web_mount_selector, None)?;
1118    #[allow(deprecated)]
1119    Ok(Arc::new(
1120        event_loop
1121            .create_window(window_attributes)
1122            .map_err(|e| anyhow::anyhow!("Window build error: {}", e))?,
1123    ))
1124}
1125
1126fn build_window_attributes(
1127    title: &str,
1128    background_test_mode: bool,
1129    _web_mount_selector: Option<&str>,
1130    _reported_scale_factor: Option<f64>,
1131) -> anyhow::Result<WindowAttributes> {
1132    let mut window_attributes = WindowAttributes::default().with_title(title);
1133    #[cfg(target_os = "ios")]
1134    {
1135        // Winit leaves UIView.contentScaleFactor at UIKit's default unless the
1136        // app explicitly opts into the device scale. Without this, iOS presents
1137        // a 1x render target scaled up by the simulator/device, which makes the
1138        // shell look visibly soft compared with web and Android.
1139        let reported_scale_factor = _reported_scale_factor.unwrap_or(1.0);
1140        window_attributes = window_attributes.with_scale_factor(ios_effective_scale_factor(
1141            normalize_scale_factor(reported_scale_factor),
1142        ));
1143    }
1144    #[cfg(target_arch = "wasm32")]
1145    {
1146        window_attributes = window_attributes.with_prevent_default(true);
1147        window_attributes = if let Some(selector) = _web_mount_selector {
1148            window_attributes.with_canvas(Some(canvas_for_mount_selector(selector)?))
1149        } else {
1150            window_attributes.with_append(true)
1151        };
1152    }
1153    if background_test_mode {
1154        window_attributes = window_attributes.with_active(false).with_visible(false);
1155    }
1156    Ok(window_attributes)
1157}
1158
1159#[cfg(target_arch = "wasm32")]
1160fn canvas_for_mount_selector(selector: &str) -> anyhow::Result<web_sys::HtmlCanvasElement> {
1161    use wasm_bindgen::JsCast;
1162
1163    let window =
1164        web_sys::window().ok_or_else(|| anyhow::anyhow!("browser window is not available"))?;
1165    let document = window
1166        .document()
1167        .ok_or_else(|| anyhow::anyhow!("browser document is not available"))?;
1168    let element = document
1169        .query_selector(selector)
1170        .map_err(|error| {
1171            anyhow::anyhow!(
1172                "invalid web mount selector `{}`: {}",
1173                selector,
1174                js_error_to_string(error)
1175            )
1176        })?
1177        .ok_or_else(|| {
1178            anyhow::anyhow!(
1179                "web mount selector `{}` did not match any element",
1180                selector
1181            )
1182        })?;
1183
1184    if let Ok(canvas) = element.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
1185        apply_web_canvas_style(&canvas)?;
1186        return Ok(canvas);
1187    }
1188
1189    let canvas = document
1190        .create_element("canvas")
1191        .map_err(|error| {
1192            anyhow::anyhow!("failed to create web canvas: {}", js_error_to_string(error))
1193        })?
1194        .dyn_into::<web_sys::HtmlCanvasElement>()
1195        .map_err(|_| anyhow::anyhow!("browser created a non-canvas element for `<canvas>`"))?;
1196    element.append_child(&canvas).map_err(|error| {
1197        anyhow::anyhow!(
1198            "failed to append web canvas to `{}`: {}",
1199            selector,
1200            js_error_to_string(error)
1201        )
1202    })?;
1203    apply_web_canvas_style(&canvas)?;
1204    Ok(canvas)
1205}
1206
1207#[cfg(target_arch = "wasm32")]
1208fn apply_web_canvas_style(canvas: &web_sys::HtmlCanvasElement) -> anyhow::Result<()> {
1209    let existing = canvas.get_attribute("style").unwrap_or_default();
1210    let suffix = "display:block;width:100%;height:100%;border:0;outline:none;user-select:none;-webkit-user-drag:none;touch-action:none;-webkit-tap-highlight-color:transparent;";
1211    let style = if existing.trim().is_empty() {
1212        suffix.to_string()
1213    } else {
1214        format!("{existing};{suffix}")
1215    };
1216    canvas.set_attribute("style", &style).map_err(|error| {
1217        anyhow::anyhow!("failed to style web canvas: {}", js_error_to_string(error))
1218    })?;
1219    Ok(())
1220}
1221
1222trait PlatformWindow {
1223    fn active_window(&self) -> Option<&Window>;
1224    fn active_window_arc(&self) -> Option<Arc<Window>>;
1225
1226    fn active_window_id(&self) -> Option<WindowId> {
1227        self.active_window().map(Window::id)
1228    }
1229}
1230
1231#[cfg(target_os = "android")]
1232impl PlatformWindow for Option<Arc<Window>> {
1233    fn active_window(&self) -> Option<&Window> {
1234        self.as_deref()
1235    }
1236
1237    fn active_window_arc(&self) -> Option<Arc<Window>> {
1238        self.clone()
1239    }
1240}
1241
1242#[cfg(not(target_os = "android"))]
1243impl PlatformWindow for Arc<Window> {
1244    fn active_window(&self) -> Option<&Window> {
1245        Some(self)
1246    }
1247
1248    fn active_window_arc(&self) -> Option<Arc<Window>> {
1249        Some(self.clone())
1250    }
1251}
1252
1253fn request_redraw_throttled(
1254    window: &Window,
1255    elwt: &EventLoopWindowTarget,
1256    last_redraw_at: &mut Instant,
1257    min_frame: Duration,
1258    redraw_pending: &mut bool,
1259) {
1260    let now = Instant::now();
1261    let next = *last_redraw_at + min_frame;
1262    if now >= next {
1263        *last_redraw_at = now;
1264        *redraw_pending = false;
1265        window.request_redraw();
1266    } else {
1267        *redraw_pending = true;
1268        elwt.set_control_flow(ControlFlow::WaitUntil(next));
1269    }
1270}
1271
1272fn frame_trace_enabled() -> bool {
1273    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1274    *ENABLED.get_or_init(|| {
1275        std::env::var("FISSION_FRAME_TRACE")
1276            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
1277            .unwrap_or(false)
1278    })
1279}
1280
1281#[derive(Default)]
1282struct FrameTraceState {
1283    enabled: bool,
1284    redraw_reasons: Vec<String>,
1285}
1286
1287impl FrameTraceState {
1288    fn new(enabled: bool) -> Self {
1289        Self {
1290            enabled,
1291            redraw_reasons: Vec::new(),
1292        }
1293    }
1294
1295    fn note_redraw_reason(&mut self, reason: impl Into<String>) {
1296        if !self.enabled {
1297            return;
1298        }
1299        let reason = reason.into();
1300        if !self
1301            .redraw_reasons
1302            .iter()
1303            .any(|existing| existing == &reason)
1304        {
1305            self.redraw_reasons.push(reason);
1306        }
1307    }
1308
1309    fn take_redraw_reasons(&mut self) -> Vec<String> {
1310        if !self.enabled {
1311            return Vec::new();
1312        }
1313        std::mem::take(&mut self.redraw_reasons)
1314    }
1315
1316    fn emit(
1317        &self,
1318        phase: &str,
1319        frame: u64,
1320        active_animation_keys: &[String],
1321        invalidations: InvalidationSet,
1322        reasons: &[String],
1323        detail: &str,
1324    ) {
1325        if !self.enabled {
1326            return;
1327        }
1328        let active = if active_animation_keys.is_empty() {
1329            "none".to_string()
1330        } else {
1331            active_animation_keys.join(",")
1332        };
1333        let reasons = if reasons.is_empty() {
1334            "none".to_string()
1335        } else {
1336            reasons.join(",")
1337        };
1338        eprintln!(
1339            "[frame-trace] phase={} frame={} invalidation={} active=[{}] reasons=[{}] {}",
1340            phase,
1341            frame,
1342            invalidations.labels().join("+"),
1343            active,
1344            reasons,
1345            detail,
1346        );
1347    }
1348}
1349
1350fn request_redraw_logged(
1351    window: &Window,
1352    elwt: &EventLoopWindowTarget,
1353    last_redraw_at: &mut Instant,
1354    min_frame: Duration,
1355    redraw_pending: &mut bool,
1356    frame_trace: &mut FrameTraceState,
1357    reason: &str,
1358) {
1359    frame_trace.note_redraw_reason(reason);
1360    request_redraw_throttled(window, elwt, last_redraw_at, min_frame, redraw_pending);
1361}
1362
1363fn apply_authoritative_resize(
1364    window: &Window,
1365    elwt: &EventLoopWindowTarget,
1366    next_viewport: WindowViewportState,
1367    pending_resize: &mut Option<WindowViewportState>,
1368    resize_needs_settled_frame: &mut bool,
1369    pending_capture_settle: &mut bool,
1370    pending_screenshot_path: Option<&str>,
1371    live_resize: &mut LiveResizeController,
1372    invalidations: &mut InvalidationSet,
1373    last_redraw_at: &mut Instant,
1374    resize_frame: Duration,
1375    redraw_pending: &mut bool,
1376    frame_trace: &mut FrameTraceState,
1377    reason: &str,
1378) {
1379    *pending_resize = Some(next_viewport);
1380    *resize_needs_settled_frame = true;
1381    if pending_screenshot_path.is_some() {
1382        *pending_capture_settle = true;
1383    }
1384    live_resize.note_resize(Instant::now());
1385    invalidations.mark_composite();
1386    request_redraw_logged(
1387        window,
1388        elwt,
1389        last_redraw_at,
1390        resize_frame,
1391        redraw_pending,
1392        frame_trace,
1393        reason,
1394    );
1395}
1396
1397fn active_animation_keys(runtime: &Runtime) -> Vec<String> {
1398    let mut keys = runtime
1399        .runtime_state
1400        .motion
1401        .active
1402        .iter()
1403        .map(|((target, property), anim)| {
1404            let repeat = if anim.repeat { "repeat" } else { "finite" };
1405            format!("{}:{:?}:{}", target.as_u128(), property, repeat)
1406        })
1407        .collect::<Vec<_>>();
1408    keys.sort();
1409    keys
1410}
1411
1412fn repeating_animation_redraw_interval(
1413    animation_map: &fission_core::MotionStateMap,
1414    default_repeat_frame: Duration,
1415) -> Option<Duration> {
1416    animation_map
1417        .active
1418        .values()
1419        .filter(|anim| anim.repeat)
1420        .map(|anim| {
1421            anim.frame_interval_ms
1422                .filter(|ms| *ms > 0)
1423                .map(Duration::from_millis)
1424                .unwrap_or(default_repeat_frame)
1425        })
1426        .min()
1427}
1428
1429fn animation_redraw_interval(
1430    has_finite_animation: bool,
1431    repeat_animation_frame: Option<Duration>,
1432    has_playing_video: bool,
1433    min_frame: Duration,
1434) -> Option<Duration> {
1435    if has_finite_animation || has_playing_video {
1436        Some(min_frame)
1437    } else if let Some(repeat_frame) = repeat_animation_frame {
1438        Some(repeat_frame)
1439    } else {
1440        None
1441    }
1442}
1443
1444fn pending_work_redraw_interval(
1445    invalidations: InvalidationSet,
1446    pending_resize: bool,
1447    min_frame: Duration,
1448    resize_frame: Duration,
1449) -> Duration {
1450    if pending_resize && !invalidations.build && !invalidations.paint && !invalidations.composite {
1451        resize_frame
1452    } else {
1453        min_frame
1454    }
1455}
1456
1457fn resize_is_unsettled(pending_resize: bool, needs_settled_frame: bool, live_resize: bool) -> bool {
1458    pending_resize || needs_settled_frame || live_resize
1459}
1460
1461fn resolve_build_viewport(
1462    last_built_viewport: Option<LayoutSize>,
1463    target_viewport: LayoutSize,
1464    has_prev_ir: bool,
1465    invalidations: &mut InvalidationSet,
1466) -> LayoutSize {
1467    let built_viewport = last_built_viewport.unwrap_or(target_viewport);
1468    if built_viewport != target_viewport {
1469        // Viewport-sensitive build output must stay aligned with the layout viewport.
1470        invalidations.mark_build();
1471    }
1472
1473    if invalidations.build || !has_prev_ir || last_built_viewport.is_none() {
1474        target_viewport
1475    } else {
1476        built_viewport
1477    }
1478}
1479
1480#[derive(Debug)]
1481struct LiveResizeController {
1482    active_until: Option<Instant>,
1483    settle_delay: Duration,
1484}
1485
1486impl LiveResizeController {
1487    fn new(settle_delay: Duration) -> Self {
1488        Self {
1489            active_until: None,
1490            settle_delay,
1491        }
1492    }
1493
1494    fn note_resize(&mut self, now: Instant) {
1495        self.active_until = Some(now + self.settle_delay);
1496    }
1497
1498    fn is_live(&self, now: Instant) -> bool {
1499        self.active_until
1500            .map(|deadline| now < deadline)
1501            .unwrap_or(false)
1502    }
1503}
1504
1505/// Drain pending effects from the runtime, delegating capability work to the
1506/// async registry and runtime-control effects to the shell/runtime boundary.
1507///
1508/// Returns `true` if any synchronous callback was dispatched (caller should redraw).
1509fn process_pending_effects(
1510    runtime: &mut Runtime,
1511    effect_tx: &mpsc::Sender<AsyncMessage>,
1512    event_proxy: &EventLoopProxy<TestEvent>,
1513    async_registry: &AsyncRegistry,
1514    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
1515    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
1516    next_service_instance_id: &mut u64,
1517) -> bool {
1518    let pending = std::mem::take(&mut runtime.pending_effects);
1519    if pending.is_empty() {
1520        return false;
1521    }
1522
1523    let dispatched_callback = false;
1524    let wake = {
1525        let proxy = Arc::new(Mutex::new(event_proxy.clone()));
1526        Arc::new(move || {
1527            if let Ok(proxy) = proxy.lock() {
1528                let _ = proxy.send_event(TestEvent::Wake);
1529            }
1530        })
1531    };
1532
1533    for env in pending {
1534        match env.effect {
1535            Effect::Runtime(ref runtime_effect) => {
1536                diag::emit(
1537                    diag::DiagCategory::Input,
1538                    diag::DiagLevel::Debug,
1539                    diag::DiagEventKind::InputEvent {
1540                        kind: format!("runtime_effect:{:?}", runtime_effect),
1541                        target: None,
1542                        position: None,
1543                    },
1544                );
1545                match runtime_effect {
1546                    RuntimeEffect::Cancel { .. } | RuntimeEffect::ReleaseResource { .. } => {}
1547                }
1548            }
1549            Effect::Capability(capability) => match capability {
1550                CapabilityInvocationPayload::Operation(op) => {
1551                    if !async_registry.spawn_capability(
1552                        &op.capability_name,
1553                        env.req_id,
1554                        op.request,
1555                        env.on_ok.clone(),
1556                        env.on_err.clone(),
1557                        env.resource.clone(),
1558                        effect_tx,
1559                        wake.clone(),
1560                    ) {
1561                        let _ = effect_tx.send(AsyncMessage::CapabilityErr {
1562                            capability_name: op.capability_name,
1563                            req_id: env.req_id,
1564                            payload: None,
1565                            on_err: env.on_err.clone(),
1566                            message: Some(
1567                                "no async operation capability handler registered".into(),
1568                            ),
1569                            resource: env.resource.clone(),
1570                        });
1571                        (wake)();
1572                    }
1573                }
1574            },
1575            Effect::Job(job) => {
1576                if !async_registry.spawn_job(
1577                    &job.job_name,
1578                    env.req_id,
1579                    job.payload,
1580                    env.on_ok.clone(),
1581                    env.on_err.clone(),
1582                    env.resource.clone(),
1583                    effect_tx,
1584                    wake.clone(),
1585                ) {
1586                    let _ = effect_tx.send(AsyncMessage::JobErr {
1587                        job_name: job.job_name,
1588                        req_id: env.req_id,
1589                        payload: None,
1590                        on_err: env.on_err.clone(),
1591                        message: Some("no async job handler registered".into()),
1592                        resource: env.resource.clone(),
1593                    });
1594                    (wake)();
1595                }
1596            }
1597            Effect::StartService(start) => {
1598                let key = (start.service_name.clone(), start.slot_key.clone());
1599                if let Some(previous) = active_services.remove(&key) {
1600                    let _ = previous
1601                        .runtime
1602                        .control_tx
1603                        .send(ServiceControlMessage::Stop);
1604                }
1605
1606                let instance_id = *next_service_instance_id;
1607                *next_service_instance_id = next_service_instance_id.saturating_add(1);
1608                let bindings = env.service_bindings.clone().unwrap_or_default();
1609                service_bindings.insert(
1610                    (
1611                        start.service_name.clone(),
1612                        start.slot_key.clone(),
1613                        instance_id,
1614                    ),
1615                    bindings,
1616                );
1617
1618                match async_registry.spawn_service(
1619                    &start.service_name,
1620                    &start.slot_key,
1621                    instance_id,
1622                    start.config,
1623                    env.resource.clone(),
1624                    effect_tx,
1625                    wake.clone(),
1626                ) {
1627                    Some(handle) => {
1628                        active_services.insert(key, ActiveServiceHandle { runtime: handle });
1629                    }
1630                    None => {
1631                        let _ = service_bindings.remove(&(
1632                            start.service_name.clone(),
1633                            start.slot_key.clone(),
1634                            instance_id,
1635                        ));
1636                        let _ = effect_tx.send(AsyncMessage::ServiceStartFailed {
1637                            service_name: start.service_name,
1638                            slot_key: start.slot_key,
1639                            instance_id,
1640                            payload: None,
1641                            message: Some("no async service handler registered".into()),
1642                            resource: env.resource.clone(),
1643                        });
1644                        (wake)();
1645                    }
1646                }
1647            }
1648            Effect::ServiceCommand(command) => {
1649                let key = (command.service_name.clone(), command.slot_key.clone());
1650                if let Some(handle) = active_services.get(&key) {
1651                    let _ = handle
1652                        .runtime
1653                        .control_tx
1654                        .send(ServiceControlMessage::Command {
1655                            req_id: env.req_id,
1656                            payload: command.payload,
1657                            on_ok: env.on_ok.clone(),
1658                            on_err: env.on_err.clone(),
1659                        });
1660                } else {
1661                    let _ = effect_tx.send(AsyncMessage::ServiceCommandErr {
1662                        service_name: command.service_name,
1663                        slot_key: command.slot_key,
1664                        instance_id: 0,
1665                        req_id: env.req_id,
1666                        payload: None,
1667                        on_err: env.on_err.clone(),
1668                        message: Some("service is not running".into()),
1669                        resource: env.resource.clone(),
1670                    });
1671                    (wake)();
1672                }
1673            }
1674            Effect::StopService(stop) => {
1675                let key = (stop.service_name.clone(), stop.slot_key.clone());
1676                if let Some(handle) = active_services.remove(&key) {
1677                    let _ = handle.runtime.control_tx.send(ServiceControlMessage::Stop);
1678                }
1679            }
1680        }
1681    }
1682
1683    dispatched_callback
1684}
1685
1686/// Drain completed background effect results from the channel and dispatch
1687/// their continuations on the main thread.
1688///
1689/// Returns `true` if any continuation was dispatched (caller should redraw).
1690fn drain_effect_results(
1691    runtime: &mut Runtime,
1692    effect_rx: &mpsc::Receiver<AsyncMessage>,
1693    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
1694    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
1695) -> bool {
1696    let mut dispatched = false;
1697
1698    while let Ok(message) = effect_rx.try_recv() {
1699        match message {
1700            AsyncMessage::JobOk {
1701                job_name,
1702                req_id,
1703                payload,
1704                on_ok,
1705                resource,
1706            } => {
1707                if let Some(resource) = resource.as_ref() {
1708                    if !runtime.is_resource_current(resource) {
1709                        continue;
1710                    }
1711                }
1712                if let Some(action) = on_ok {
1713                    let _ = runtime.dispatch_with_input(
1714                        action,
1715                        WidgetId::from_u128(0),
1716                        &ActionInput::JobOk {
1717                            job_name,
1718                            req_id,
1719                            payload,
1720                        },
1721                    );
1722                    dispatched = true;
1723                }
1724            }
1725            AsyncMessage::JobErr {
1726                job_name,
1727                req_id,
1728                payload,
1729                on_err,
1730                message,
1731                resource,
1732            } => {
1733                if let Some(resource) = resource.as_ref() {
1734                    if !runtime.is_resource_current(resource) {
1735                        continue;
1736                    }
1737                }
1738                if let Some(action) = on_err {
1739                    let _ = runtime.dispatch_with_input(
1740                        action,
1741                        WidgetId::from_u128(0),
1742                        &ActionInput::JobErr {
1743                            job_name,
1744                            req_id,
1745                            payload,
1746                            message,
1747                        },
1748                    );
1749                    dispatched = true;
1750                }
1751            }
1752            AsyncMessage::ServiceStarted {
1753                service_name,
1754                slot_key,
1755                instance_id,
1756                resource,
1757            } => {
1758                if let Some(resource) = resource.as_ref() {
1759                    if !runtime.is_resource_current(resource) {
1760                        continue;
1761                    }
1762                }
1763                let key = (service_name.clone(), slot_key.clone());
1764                let Some(current) = active_services.get(&key) else {
1765                    continue;
1766                };
1767                if current.runtime.instance_id != instance_id {
1768                    continue;
1769                }
1770                if let Some(bindings) =
1771                    service_bindings.get(&(service_name.clone(), slot_key.clone(), instance_id))
1772                {
1773                    if let Some(action) = bindings.on_started.clone() {
1774                        let _ = runtime.dispatch_with_input(
1775                            action,
1776                            WidgetId::from_u128(0),
1777                            &ActionInput::ServiceStarted {
1778                                service_name,
1779                                slot_key,
1780                                instance_id,
1781                            },
1782                        );
1783                        dispatched = true;
1784                    }
1785                }
1786            }
1787            AsyncMessage::ServiceStartFailed {
1788                service_name,
1789                slot_key,
1790                instance_id,
1791                payload,
1792                message,
1793                resource,
1794            } => {
1795                if let Some(resource) = resource.as_ref() {
1796                    if !runtime.is_resource_current(resource) {
1797                        service_bindings.remove(&(service_name, slot_key, instance_id));
1798                        continue;
1799                    }
1800                }
1801                let key = (service_name.clone(), slot_key.clone());
1802                let should_dispatch = active_services
1803                    .get(&key)
1804                    .map(|current| current.runtime.instance_id == instance_id)
1805                    .unwrap_or(true);
1806                active_services.remove(&key);
1807                let bindings =
1808                    service_bindings.remove(&(service_name.clone(), slot_key.clone(), instance_id));
1809                if should_dispatch {
1810                    if let Some(action) = bindings.and_then(|bindings| bindings.on_start_failed) {
1811                        let _ = runtime.dispatch_with_input(
1812                            action,
1813                            WidgetId::from_u128(0),
1814                            &ActionInput::ServiceStartFailed {
1815                                service_name,
1816                                slot_key,
1817                                payload,
1818                                message,
1819                            },
1820                        );
1821                        dispatched = true;
1822                    }
1823                }
1824            }
1825            AsyncMessage::ServiceEvent {
1826                service_name,
1827                slot_key,
1828                instance_id,
1829                payload,
1830                resource,
1831            } => {
1832                if let Some(resource) = resource.as_ref() {
1833                    if !runtime.is_resource_current(resource) {
1834                        continue;
1835                    }
1836                }
1837                let key = (service_name.clone(), slot_key.clone());
1838                let Some(current) = active_services.get(&key) else {
1839                    continue;
1840                };
1841                if current.runtime.instance_id != instance_id {
1842                    continue;
1843                }
1844                if let Some(bindings) =
1845                    service_bindings.get(&(service_name.clone(), slot_key.clone(), instance_id))
1846                {
1847                    if let Some(action) = bindings.on_event.clone() {
1848                        let _ = runtime.dispatch_with_input(
1849                            action,
1850                            WidgetId::from_u128(0),
1851                            &ActionInput::ServiceEvent {
1852                                service_name,
1853                                slot_key,
1854                                instance_id,
1855                                payload,
1856                            },
1857                        );
1858                        dispatched = true;
1859                    }
1860                }
1861            }
1862            AsyncMessage::ServiceStopped {
1863                service_name,
1864                slot_key,
1865                instance_id,
1866                resource,
1867            } => {
1868                if let Some(resource) = resource.as_ref() {
1869                    if !runtime.is_resource_current(resource) {
1870                        service_bindings.remove(&(service_name, slot_key, instance_id));
1871                        continue;
1872                    }
1873                }
1874                let key = (service_name.clone(), slot_key.clone());
1875                let should_dispatch = active_services
1876                    .get(&key)
1877                    .map(|current| current.runtime.instance_id == instance_id)
1878                    .unwrap_or(true);
1879                if should_dispatch {
1880                    active_services.remove(&key);
1881                }
1882                let bindings =
1883                    service_bindings.remove(&(service_name.clone(), slot_key.clone(), instance_id));
1884                if should_dispatch {
1885                    if let Some(action) = bindings.and_then(|bindings| bindings.on_stopped) {
1886                        let _ = runtime.dispatch_with_input(
1887                            action,
1888                            WidgetId::from_u128(0),
1889                            &ActionInput::ServiceStopped {
1890                                service_name,
1891                                slot_key,
1892                                instance_id,
1893                            },
1894                        );
1895                        dispatched = true;
1896                    }
1897                }
1898            }
1899            AsyncMessage::ServiceCommandOk {
1900                service_name,
1901                slot_key,
1902                instance_id,
1903                req_id,
1904                payload,
1905                on_ok,
1906                resource,
1907            } => {
1908                if let Some(resource) = resource.as_ref() {
1909                    if !runtime.is_resource_current(resource) {
1910                        continue;
1911                    }
1912                }
1913                let key = (service_name.clone(), slot_key.clone());
1914                let Some(current) = active_services.get(&key) else {
1915                    continue;
1916                };
1917                if current.runtime.instance_id != instance_id {
1918                    continue;
1919                }
1920                if let Some(action) = on_ok {
1921                    let _ = runtime.dispatch_with_input(
1922                        action,
1923                        WidgetId::from_u128(0),
1924                        &ActionInput::ServiceCommandOk {
1925                            service_name,
1926                            slot_key,
1927                            instance_id,
1928                            req_id,
1929                            payload,
1930                        },
1931                    );
1932                    dispatched = true;
1933                }
1934            }
1935            AsyncMessage::ServiceCommandErr {
1936                service_name,
1937                slot_key,
1938                instance_id,
1939                req_id,
1940                payload,
1941                on_err,
1942                message,
1943                resource,
1944            } => {
1945                if let Some(resource) = resource.as_ref() {
1946                    if !runtime.is_resource_current(resource) {
1947                        continue;
1948                    }
1949                }
1950                let key = (service_name.clone(), slot_key.clone());
1951                if instance_id != 0 {
1952                    let Some(current) = active_services.get(&key) else {
1953                        continue;
1954                    };
1955                    if current.runtime.instance_id != instance_id {
1956                        continue;
1957                    }
1958                }
1959                if let Some(action) = on_err {
1960                    let _ = runtime.dispatch_with_input(
1961                        action,
1962                        WidgetId::from_u128(0),
1963                        &ActionInput::ServiceCommandErr {
1964                            service_name,
1965                            slot_key,
1966                            instance_id,
1967                            req_id,
1968                            payload,
1969                            message,
1970                        },
1971                    );
1972                    dispatched = true;
1973                }
1974            }
1975            AsyncMessage::CapabilityOk {
1976                capability_name,
1977                req_id,
1978                payload,
1979                on_ok,
1980                resource,
1981            } => {
1982                if let Some(resource) = resource.as_ref() {
1983                    if !runtime.is_resource_current(resource) {
1984                        continue;
1985                    }
1986                }
1987                if let Some(action) = on_ok {
1988                    let _ = runtime.dispatch_with_input(
1989                        action,
1990                        WidgetId::from_u128(0),
1991                        &ActionInput::CapabilityOk {
1992                            capability: capability_name,
1993                            req_id,
1994                            payload,
1995                        },
1996                    );
1997                    dispatched = true;
1998                }
1999            }
2000            AsyncMessage::CapabilityErr {
2001                capability_name,
2002                req_id,
2003                payload,
2004                on_err,
2005                message,
2006                resource,
2007            } => {
2008                if let Some(resource) = resource.as_ref() {
2009                    if !runtime.is_resource_current(resource) {
2010                        continue;
2011                    }
2012                }
2013                if let Some(action) = on_err {
2014                    let _ = runtime.dispatch_with_input(
2015                        action,
2016                        WidgetId::from_u128(0),
2017                        &ActionInput::CapabilityErr {
2018                            capability: capability_name,
2019                            req_id,
2020                            payload,
2021                            message,
2022                        },
2023                    );
2024                    dispatched = true;
2025                }
2026            }
2027        }
2028    }
2029
2030    dispatched
2031}
2032
2033fn focused_text_input_id(runtime: &Runtime, ir: Option<&CoreIR>) -> Option<WidgetId> {
2034    let focused = runtime.runtime_state.interaction.focused?;
2035    let ir = ir?;
2036    let mut current = Some(focused);
2037    while let Some(id) = current {
2038        let node = ir.nodes.get(&id)?;
2039        if let Op::Semantics(sem) = &node.op {
2040            if sem.role == fission_ir::Role::TextInput {
2041                return Some(id);
2042            }
2043        }
2044        current = node.parent;
2045    }
2046    None
2047}
2048
2049fn focused_text_input_config(runtime: &Runtime, ir: Option<&CoreIR>) -> Option<TextInputConfig> {
2050    let id = focused_text_input_id(runtime, ir)?;
2051    let ir = ir?;
2052    let node = ir.nodes.get(&id)?;
2053    match &node.op {
2054        Op::Semantics(semantics) => Some(TextInputConfig::from_semantics(semantics)),
2055        _ => None,
2056    }
2057}
2058
2059fn focused_custom_text_input(runtime: &Runtime, ir: Option<&CoreIR>) -> bool {
2060    let focused = match runtime.runtime_state.interaction.focused {
2061        Some(id) => id,
2062        None => return false,
2063    };
2064    let ir = match ir {
2065        Some(ir) => ir,
2066        None => return false,
2067    };
2068    let mut current = Some(focused);
2069    while let Some(id) = current {
2070        if let Some(any_ro) = ir.custom_render_objects.get(&id) {
2071            if let Some(render_obj) = downcast_render_object(any_ro) {
2072                if render_obj.accepts_text_input() {
2073                    return true;
2074                }
2075            }
2076        }
2077        current = ir.nodes.get(&id).and_then(|node| node.parent);
2078    }
2079    false
2080}
2081
2082fn reset_text_input_caret(
2083    runtime: &mut Runtime,
2084    ir: Option<&CoreIR>,
2085    last_blink_toggle: &mut Instant,
2086) {
2087    if let Some(id) = focused_text_input_id(runtime, ir) {
2088        runtime.runtime_state.caret_visible.insert(id, true);
2089        *last_blink_toggle = Instant::now();
2090    }
2091}
2092
2093#[derive(Debug, Clone)]
2094struct PendingTextTrace {
2095    seq: u64,
2096    source: String,
2097    target: Option<WidgetId>,
2098    started_at: Instant,
2099    handled_at: Option<Instant>,
2100    effects_at: Option<Instant>,
2101    present_after_frame: u64,
2102}
2103
2104fn start_text_trace(
2105    enabled: bool,
2106    traces: &mut VecDeque<PendingTextTrace>,
2107    next_seq: &mut u64,
2108    source: String,
2109    target: Option<WidgetId>,
2110    presented_frames: u64,
2111) -> Option<u64> {
2112    if !enabled {
2113        return None;
2114    }
2115    *next_seq += 1;
2116    let seq = *next_seq;
2117    traces.push_back(PendingTextTrace {
2118        seq,
2119        source,
2120        target,
2121        started_at: Instant::now(),
2122        handled_at: None,
2123        effects_at: None,
2124        present_after_frame: presented_frames + 1,
2125    });
2126    Some(seq)
2127}
2128
2129fn mark_text_trace_handled(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
2130    if let Some(seq) = seq {
2131        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
2132            trace.handled_at = Some(Instant::now());
2133        }
2134    }
2135}
2136
2137fn mark_text_trace_effects(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
2138    if let Some(seq) = seq {
2139        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
2140            trace.effects_at = Some(Instant::now());
2141        }
2142    }
2143}
2144
2145fn set_text_trace_target(
2146    traces: &mut VecDeque<PendingTextTrace>,
2147    seq: Option<u64>,
2148    target: Option<WidgetId>,
2149) {
2150    if let Some(seq) = seq {
2151        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
2152            trace.target = target;
2153        }
2154    }
2155}
2156
2157fn cancel_text_trace(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
2158    if let Some(seq) = seq {
2159        traces.retain(|trace| trace.seq != seq);
2160    }
2161}
2162
2163fn flush_text_traces(
2164    enabled: bool,
2165    traces: &mut VecDeque<PendingTextTrace>,
2166    presented_frames: u64,
2167) {
2168    if !enabled {
2169        traces.clear();
2170        return;
2171    }
2172
2173    loop {
2174        let should_flush = traces
2175            .front()
2176            .map(|trace| trace.present_after_frame <= presented_frames)
2177            .unwrap_or(false);
2178        if !should_flush {
2179            break;
2180        }
2181
2182        let Some(trace) = traces.pop_front() else {
2183            break;
2184        };
2185        let now = Instant::now();
2186        let handled_at = trace.handled_at.unwrap_or(now);
2187        let effects_at = trace.effects_at.unwrap_or(handled_at);
2188        let total_ms = now.duration_since(trace.started_at).as_secs_f64() * 1000.0;
2189        let handle_ms = handled_at.duration_since(trace.started_at).as_secs_f64() * 1000.0;
2190        let effects_ms = effects_at.duration_since(handled_at).as_secs_f64() * 1000.0;
2191        let queue_ms = now.duration_since(effects_at).as_secs_f64() * 1000.0;
2192
2193        let target_u128 = trace.target.map(|id| id.as_u128());
2194        let msg = format!(
2195            "text_input_latency seq={} src={} handle_ms={:.2} effects_ms={:.2} queue_ms={:.2} total_ms={:.2} frame={}",
2196            trace.seq, trace.source, handle_ms, effects_ms, queue_ms, total_ms, presented_frames
2197        );
2198        eprintln!("[text-trace] {}", msg);
2199        diag::emit(
2200            diag::DiagCategory::Input,
2201            diag::DiagLevel::Info,
2202            diag::DiagEventKind::InputEvent {
2203                kind: msg,
2204                target: target_u128,
2205                position: None,
2206            },
2207        );
2208    }
2209}
2210
2211// ─── Extracted handler functions ─────────────────────────────────────────
2212// These are called by BOTH real WindowEvent handlers AND the TestEvent (UserEvent)
2213// handler, ensuring test infrastructure exercises the exact same code paths.
2214
2215/// Map a test button index (0=left, 1=right, 2=middle) to a `PointerButton`.
2216fn map_test_button(button: u8) -> PointerButton {
2217    match button {
2218        0 => PointerButton::Primary,
2219        1 => PointerButton::Secondary,
2220        2 => PointerButton::Middle,
2221        n => PointerButton::Other(n),
2222    }
2223}
2224
2225fn cursor_icon_for(cursor: MouseCursor) -> CursorIcon {
2226    match cursor {
2227        MouseCursor::Default => CursorIcon::Default,
2228        MouseCursor::Pointer => CursorIcon::Pointer,
2229        MouseCursor::Text => CursorIcon::Text,
2230        MouseCursor::Crosshair => CursorIcon::Crosshair,
2231        MouseCursor::Move => CursorIcon::Move,
2232        MouseCursor::NotAllowed => CursorIcon::NotAllowed,
2233        MouseCursor::Grab => CursorIcon::Grab,
2234        MouseCursor::Grabbing => CursorIcon::Grabbing,
2235        MouseCursor::Wait => CursorIcon::Wait,
2236        MouseCursor::Help => CursorIcon::Help,
2237        MouseCursor::VerticalText => CursorIcon::VerticalText,
2238    }
2239}
2240
2241fn sync_window_cursor(window: &Window, runtime: &Runtime) {
2242    window.set_cursor(cursor_icon_for(runtime.runtime_state.interaction.cursor()));
2243}
2244
2245const LINE_SCROLL_POINTS: f32 = 50.0;
2246
2247fn normalize_winit_scroll_delta(delta: &MouseScrollDelta, scale_factor: f64) -> (f32, f32) {
2248    let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 {
2249        scale_factor
2250    } else {
2251        1.0
2252    };
2253    match delta {
2254        // Fission scroll offsets increase down/right. Winit reports positive
2255        // wheel lines upward/leftward; the OS has already applied any natural
2256        // scrolling preference before the event reaches us.
2257        MouseScrollDelta::LineDelta(x, y) => (-x * LINE_SCROLL_POINTS, -y * LINE_SCROLL_POINTS),
2258        MouseScrollDelta::PixelDelta(p) => {
2259            (-(p.x / scale_factor) as f32, -(p.y / scale_factor) as f32)
2260        }
2261    }
2262}
2263
2264fn physical_position_to_layout_point(
2265    position: PhysicalPosition<f64>,
2266    scale_factor: f64,
2267    content_origin: PhysicalPosition<i32>,
2268) -> LayoutPoint {
2269    let scale_factor = normalize_scale_factor(scale_factor);
2270    LayoutPoint::new(
2271        ((position.x - content_origin.x as f64) / scale_factor) as f32,
2272        ((position.y - content_origin.y as f64) / scale_factor) as f32,
2273    )
2274}
2275
2276fn window_content_origin_physical(window: &Window) -> PhysicalPosition<i32> {
2277    #[cfg(target_os = "ios")]
2278    {
2279        // Layout uses the full iOS view. Safe-area avoidance is exposed through
2280        // `Env.window_insets`, so pointer coordinates stay in full-view space.
2281        let _ = window;
2282        PhysicalPosition::new(0, 0)
2283    }
2284    #[cfg(not(target_os = "ios"))]
2285    {
2286        let _ = window;
2287        PhysicalPosition::new(0, 0)
2288    }
2289}
2290
2291fn window_physical_position_to_layout_point(
2292    window: &Window,
2293    position: PhysicalPosition<f64>,
2294) -> LayoutPoint {
2295    physical_position_to_layout_point(
2296        position,
2297        window.scale_factor(),
2298        window_content_origin_physical(window),
2299    )
2300}
2301
2302/// Handle cursor/mouse move — shared by WindowEvent::CursorMoved and TestEvent::MouseMove.
2303fn handle_cursor_moved(
2304    x: f32,
2305    y: f32,
2306    modifiers: u8,
2307    runtime: &mut Runtime,
2308    pipeline: &Pipeline,
2309    effect_result_tx: &mpsc::Sender<EffectResult>,
2310    event_proxy: &EventLoopProxy<TestEvent>,
2311    async_registry: &AsyncRegistry,
2312    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
2313    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
2314    next_service_instance_id: &mut u64,
2315    window: &Window,
2316    elwt: &EventLoopWindowTarget,
2317    last_redraw_at: &mut Instant,
2318    min_frame: Duration,
2319    redraw_pending: &mut bool,
2320    frame_trace: &mut FrameTraceState,
2321    invalidations: &mut InvalidationSet,
2322) {
2323    if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
2324        let point = LayoutPoint { x, y };
2325        let event = InputEvent::Pointer(PointerEvent::Move { point, modifiers });
2326        if let Err(e) = runtime.handle_input(event, ir, layout) {
2327            eprintln!("Input handling error: {:?}", e);
2328        }
2329        sync_window_cursor(window, runtime);
2330        invalidations.mark_build();
2331        if process_pending_effects(
2332            runtime,
2333            effect_result_tx,
2334            event_proxy,
2335            async_registry,
2336            active_services,
2337            service_bindings,
2338            next_service_instance_id,
2339        ) {
2340            invalidations.mark_build();
2341            request_redraw_logged(
2342                window,
2343                elwt,
2344                last_redraw_at,
2345                min_frame,
2346                redraw_pending,
2347                frame_trace,
2348                "pointer_move:effects",
2349            );
2350        }
2351        request_redraw_logged(
2352            window,
2353            elwt,
2354            last_redraw_at,
2355            min_frame,
2356            redraw_pending,
2357            frame_trace,
2358            "pointer_move",
2359        );
2360    }
2361}
2362
2363/// Handle mouse button press/release — shared by WindowEvent::MouseInput and
2364/// TestEvent::MouseDown / TestEvent::MouseUp.
2365fn handle_mouse_button(
2366    x: f32,
2367    y: f32,
2368    button: PointerButton,
2369    is_pressed: bool,
2370    modifiers: u8,
2371    runtime: &mut Runtime,
2372    pipeline: &Pipeline,
2373    effect_result_tx: &mpsc::Sender<EffectResult>,
2374    event_proxy: &EventLoopProxy<TestEvent>,
2375    async_registry: &AsyncRegistry,
2376    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
2377    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
2378    next_service_instance_id: &mut u64,
2379    window: &Window,
2380    elwt: &EventLoopWindowTarget,
2381    last_redraw_at: &mut Instant,
2382    min_frame: Duration,
2383    redraw_pending: &mut bool,
2384    text_trace_enabled: bool,
2385    pending_text_traces: &mut VecDeque<PendingTextTrace>,
2386    next_text_trace_seq: &mut u64,
2387    presented_frames: u64,
2388    last_blink_toggle: &mut Instant,
2389    frame_trace: &mut FrameTraceState,
2390    invalidations: &mut InvalidationSet,
2391) {
2392    if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
2393        let point = LayoutPoint { x, y };
2394        let pointer_event = if is_pressed {
2395            PointerEvent::Down {
2396                point,
2397                button,
2398                modifiers,
2399            }
2400        } else {
2401            PointerEvent::Up {
2402                point,
2403                button,
2404                modifiers,
2405            }
2406        };
2407        let input_event = InputEvent::Pointer(pointer_event);
2408
2409        let trace_seq = if text_trace_enabled && is_pressed {
2410            start_text_trace(
2411                text_trace_enabled,
2412                pending_text_traces,
2413                next_text_trace_seq,
2414                "pointer_down".to_string(),
2415                None,
2416                presented_frames,
2417            )
2418        } else {
2419            None
2420        };
2421
2422        if let Err(e) = runtime.handle_input(input_event, ir, layout) {
2423            eprintln!("Input handling error: {:?}", e);
2424        }
2425        sync_window_cursor(window, runtime);
2426        invalidations.mark_build();
2427
2428        mark_text_trace_handled(pending_text_traces, trace_seq);
2429        if process_pending_effects(
2430            runtime,
2431            effect_result_tx,
2432            event_proxy,
2433            async_registry,
2434            active_services,
2435            service_bindings,
2436            next_service_instance_id,
2437        ) {
2438            mark_text_trace_effects(pending_text_traces, trace_seq);
2439            invalidations.mark_build();
2440            request_redraw_logged(
2441                window,
2442                elwt,
2443                last_redraw_at,
2444                min_frame,
2445                redraw_pending,
2446                frame_trace,
2447                if is_pressed {
2448                    "pointer_down:effects"
2449                } else {
2450                    "pointer_up:effects"
2451                },
2452            );
2453        }
2454        if is_pressed {
2455            let target = focused_text_input_id(runtime, pipeline.prev_ir.as_ref());
2456            if target.is_some() {
2457                set_text_trace_target(pending_text_traces, trace_seq, target);
2458            } else {
2459                cancel_text_trace(pending_text_traces, trace_seq);
2460            }
2461            reset_text_input_caret(runtime, pipeline.prev_ir.as_ref(), last_blink_toggle);
2462        }
2463        request_redraw_logged(
2464            window,
2465            elwt,
2466            last_redraw_at,
2467            min_frame,
2468            redraw_pending,
2469            frame_trace,
2470            if is_pressed {
2471                "pointer_down"
2472            } else {
2473                "pointer_up"
2474            },
2475        );
2476    }
2477}
2478
2479/// Handle scroll — shared by WindowEvent::MouseWheel and TestEvent::Scroll.
2480fn handle_scroll(
2481    point_x: f32,
2482    point_y: f32,
2483    delta_x: f32,
2484    delta_y: f32,
2485    modifiers: u8,
2486    runtime: &mut Runtime,
2487    pipeline: &Pipeline,
2488    effect_result_tx: &mpsc::Sender<EffectResult>,
2489    event_proxy: &EventLoopProxy<TestEvent>,
2490    async_registry: &AsyncRegistry,
2491    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
2492    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
2493    next_service_instance_id: &mut u64,
2494    window: &Window,
2495    elwt: &EventLoopWindowTarget,
2496    last_redraw_at: &mut Instant,
2497    min_frame: Duration,
2498    redraw_pending: &mut bool,
2499    frame_trace: &mut FrameTraceState,
2500    invalidations: &mut InvalidationSet,
2501) {
2502    if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
2503        let point = LayoutPoint {
2504            x: point_x,
2505            y: point_y,
2506        };
2507        let scroll_delta = LayoutPoint {
2508            x: delta_x,
2509            y: delta_y,
2510        };
2511        let event = InputEvent::Pointer(PointerEvent::Scroll {
2512            point,
2513            delta: scroll_delta,
2514            modifiers,
2515        });
2516        if let Err(e) = runtime.handle_input(event, ir, layout) {
2517            eprintln!("Scroll error: {:?}", e);
2518        }
2519        sync_window_cursor(window, runtime);
2520        // Scroll offsets can affect more than a compositor translation. Virtualized
2521        // lists, scrollbars, and scroll-aware wrappers depend on the updated offset
2522        // during build/lowering, so treat scroll as a build invalidation.
2523        invalidations.mark_build();
2524        if process_pending_effects(
2525            runtime,
2526            effect_result_tx,
2527            event_proxy,
2528            async_registry,
2529            active_services,
2530            service_bindings,
2531            next_service_instance_id,
2532        ) {
2533            invalidations.mark_build();
2534            request_redraw_logged(
2535                window,
2536                elwt,
2537                last_redraw_at,
2538                min_frame,
2539                redraw_pending,
2540                frame_trace,
2541                "scroll:effects",
2542            );
2543        }
2544        request_redraw_logged(
2545            window,
2546            elwt,
2547            last_redraw_at,
2548            min_frame,
2549            redraw_pending,
2550            frame_trace,
2551            "scroll",
2552        );
2553    }
2554}
2555
2556fn handle_cursor_left(
2557    last_cursor_position: Option<PhysicalPosition<f64>>,
2558    runtime: &mut Runtime,
2559    pipeline: &Pipeline,
2560    effect_result_tx: &mpsc::Sender<EffectResult>,
2561    event_proxy: &EventLoopProxy<TestEvent>,
2562    async_registry: &AsyncRegistry,
2563    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
2564    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
2565    next_service_instance_id: &mut u64,
2566    window: &Window,
2567    elwt: &EventLoopWindowTarget,
2568    last_redraw_at: &mut Instant,
2569    min_frame: Duration,
2570    redraw_pending: &mut bool,
2571    frame_trace: &mut FrameTraceState,
2572    invalidations: &mut InvalidationSet,
2573) {
2574    if let Some(ir) = &pipeline.prev_ir {
2575        let point = last_cursor_position
2576            .map(|position| window_physical_position_to_layout_point(window, position));
2577        match runtime.clear_hover_state(ir, point) {
2578            Ok(changed) => {
2579                sync_window_cursor(window, runtime);
2580                if changed {
2581                    invalidations.mark_build();
2582                    if process_pending_effects(
2583                        runtime,
2584                        effect_result_tx,
2585                        event_proxy,
2586                        async_registry,
2587                        active_services,
2588                        service_bindings,
2589                        next_service_instance_id,
2590                    ) {
2591                        invalidations.mark_build();
2592                        request_redraw_logged(
2593                            window,
2594                            elwt,
2595                            last_redraw_at,
2596                            min_frame,
2597                            redraw_pending,
2598                            frame_trace,
2599                            "cursor_left:effects",
2600                        );
2601                    }
2602                    request_redraw_logged(
2603                        window,
2604                        elwt,
2605                        last_redraw_at,
2606                        min_frame,
2607                        redraw_pending,
2608                        frame_trace,
2609                        "cursor_left",
2610                    );
2611                }
2612            }
2613            Err(error) => eprintln!("Cursor-left handling error: {:?}", error),
2614        }
2615    } else {
2616        sync_window_cursor(window, runtime);
2617    }
2618}
2619
2620/// Parse a key name string into a `KeyCode`.
2621fn parse_key_code(key: &str) -> KeyCode {
2622    match key {
2623        "Enter" => KeyCode::Enter,
2624        "Escape" => KeyCode::Escape,
2625        "Tab" => KeyCode::Tab,
2626        "Backspace" => KeyCode::Backspace,
2627        "Delete" => KeyCode::Delete,
2628        "Left" => KeyCode::Left,
2629        "Right" => KeyCode::Right,
2630        "Up" => KeyCode::Up,
2631        "Down" => KeyCode::Down,
2632        "Home" => KeyCode::Home,
2633        "End" => KeyCode::End,
2634        "PageUp" => KeyCode::PageUp,
2635        "PageDown" => KeyCode::PageDown,
2636        "Space" => KeyCode::Space,
2637        s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()),
2638        _ => KeyCode::Space,
2639    }
2640}
2641
2642/// Handle a key-down event — shared by WindowEvent::KeyboardInput and
2643/// TestEvent::KeyDown / TestEvent::TextInput.
2644///
2645/// Returns `true` if the app key handler consumed the event.
2646fn handle_key_down<S: GlobalState>(
2647    code: KeyCode,
2648    modifiers: u8,
2649    runtime: &mut Runtime,
2650    pipeline: &Pipeline,
2651    effect_result_tx: &mpsc::Sender<EffectResult>,
2652    event_proxy: &EventLoopProxy<TestEvent>,
2653    async_registry: &AsyncRegistry,
2654    active_services: &mut HashMap<ServiceKey, ActiveServiceHandle>,
2655    service_bindings: &mut HashMap<ServiceBindingKey, ServiceBindings>,
2656    next_service_instance_id: &mut u64,
2657    window: &Window,
2658    elwt: &EventLoopWindowTarget,
2659    last_redraw_at: &mut Instant,
2660    min_frame: Duration,
2661    redraw_pending: &mut bool,
2662    text_trace_enabled: bool,
2663    pending_text_traces: &mut VecDeque<PendingTextTrace>,
2664    next_text_trace_seq: &mut u64,
2665    presented_frames: u64,
2666    last_blink_toggle: &mut Instant,
2667    key_handler: Option<&KeyHandler<S>>,
2668    frame_trace: &mut FrameTraceState,
2669    invalidations: &mut InvalidationSet,
2670) -> bool {
2671    let ir_and_snap = match (&pipeline.prev_ir, &pipeline.last_snapshot) {
2672        (Some(ir), Some(snap)) => Some((ir, snap)),
2673        _ => None,
2674    };
2675
2676    // App-level key handler intercepts before framework
2677    if let Some(handler) = key_handler {
2678        let handler = handler.clone();
2679        if let Some(state) = runtime.get_global_state_mut::<S>() {
2680            if handler(state, &code, modifiers) {
2681                if process_pending_effects(
2682                    runtime,
2683                    effect_result_tx,
2684                    event_proxy,
2685                    async_registry,
2686                    active_services,
2687                    service_bindings,
2688                    next_service_instance_id,
2689                ) {
2690                    invalidations.mark_build();
2691                    request_redraw_logged(
2692                        window,
2693                        elwt,
2694                        last_redraw_at,
2695                        min_frame,
2696                        redraw_pending,
2697                        frame_trace,
2698                        "key_handler:effects",
2699                    );
2700                }
2701                invalidations.mark_build();
2702                request_redraw_logged(
2703                    window,
2704                    elwt,
2705                    last_redraw_at,
2706                    min_frame,
2707                    redraw_pending,
2708                    frame_trace,
2709                    "key_handler",
2710                );
2711                return true;
2712            }
2713        }
2714    }
2715
2716    if let Some((ir, layout)) = ir_and_snap {
2717        let target = focused_text_input_id(runtime, pipeline.prev_ir.as_ref());
2718        let trace_seq = start_text_trace(
2719            text_trace_enabled && target.is_some(),
2720            pending_text_traces,
2721            next_text_trace_seq,
2722            format!("keyboard:{:?}", code),
2723            target,
2724            presented_frames,
2725        );
2726        let input_event = InputEvent::Keyboard(FissionKeyEvent::Down {
2727            key_code: code,
2728            modifiers,
2729        });
2730        if let Err(e) = runtime.handle_input(input_event, ir, layout) {
2731            eprintln!("Keyboard error: {:?}", e);
2732        }
2733        invalidations.mark_build();
2734        mark_text_trace_handled(pending_text_traces, trace_seq);
2735        if process_pending_effects(
2736            runtime,
2737            effect_result_tx,
2738            event_proxy,
2739            async_registry,
2740            active_services,
2741            service_bindings,
2742            next_service_instance_id,
2743        ) {
2744            mark_text_trace_effects(pending_text_traces, trace_seq);
2745            invalidations.mark_build();
2746            request_redraw_logged(
2747                window,
2748                elwt,
2749                last_redraw_at,
2750                min_frame,
2751                redraw_pending,
2752                frame_trace,
2753                "keyboard:effects",
2754            );
2755        }
2756        reset_text_input_caret(runtime, pipeline.prev_ir.as_ref(), last_blink_toggle);
2757        request_redraw_logged(
2758            window,
2759            elwt,
2760            last_redraw_at,
2761            min_frame,
2762            redraw_pending,
2763            frame_trace,
2764            "keyboard",
2765        );
2766    }
2767
2768    false
2769}
2770
2771fn rects_intersect(a: LayoutRect, b: LayoutRect) -> bool {
2772    a.x() < b.right() && a.right() > b.x() && a.y() < b.bottom() && a.bottom() > b.y()
2773}
2774
2775fn visual_rect_for_node(
2776    ir: &CoreIR,
2777    snap: &fission_layout::LayoutSnapshot,
2778    scroll: &fission_core::ScrollStateMap,
2779    node_id: WidgetId,
2780) -> Option<LayoutRect> {
2781    let mut rect = snap.get_node_rect(node_id)?;
2782    let mut current = ir.nodes.get(&node_id).and_then(|node| node.parent);
2783    while let Some(parent_id) = current {
2784        let Some(parent) = ir.nodes.get(&parent_id) else {
2785            break;
2786        };
2787        if let fission_ir::Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) = &parent.op {
2788            let offset = scroll.get_offset(parent_id);
2789            match direction {
2790                fission_ir::FlexDirection::Row => rect.origin.x -= offset,
2791                fission_ir::FlexDirection::Column => rect.origin.y -= offset,
2792            }
2793        }
2794        current = parent.parent;
2795    }
2796    Some(rect)
2797}
2798
2799fn rect_visible_in_scroll_ancestors(
2800    ir: &CoreIR,
2801    snap: &fission_layout::LayoutSnapshot,
2802    scroll: &fission_core::ScrollStateMap,
2803    node_id: WidgetId,
2804    rect: LayoutRect,
2805) -> bool {
2806    let viewport = LayoutRect::new(
2807        0.0,
2808        0.0,
2809        snap.viewport_size.width,
2810        snap.viewport_size.height,
2811    );
2812    if !rects_intersect(rect, viewport) {
2813        return false;
2814    }
2815
2816    let mut current = ir.nodes.get(&node_id).and_then(|node| node.parent);
2817    while let Some(parent_id) = current {
2818        let Some(parent) = ir.nodes.get(&parent_id) else {
2819            break;
2820        };
2821        if matches!(
2822            parent.op,
2823            fission_ir::Op::Layout(fission_ir::LayoutOp::Scroll { .. })
2824                | fission_ir::Op::Layout(fission_ir::LayoutOp::Clip { .. })
2825        ) {
2826            let Some(parent_rect) = visual_rect_for_node(ir, snap, scroll, parent_id) else {
2827                return false;
2828            };
2829            if !rects_intersect(rect, parent_rect) {
2830                return false;
2831            }
2832        }
2833        current = parent.parent;
2834    }
2835
2836    true
2837}
2838
2839/// Build the response for a GetText query.
2840fn build_get_text_response(
2841    pipeline: &Pipeline,
2842    scroll: &fission_core::ScrollStateMap,
2843) -> fission_test_driver::TestResponse {
2844    use fission_test_driver::{TestResponse, TextItem};
2845    let mut items = Vec::new();
2846    if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
2847        let mut reachable = std::collections::HashSet::new();
2848        let mut stack = ir.root.into_iter().collect::<Vec<_>>();
2849        while let Some(node_id) = stack.pop() {
2850            if !reachable.insert(node_id) {
2851                continue;
2852            }
2853            if let Some(node) = ir.nodes.get(&node_id) {
2854                stack.extend(node.children.iter().copied());
2855            }
2856        }
2857
2858        for id in reachable {
2859            let Some(node) = ir.nodes.get(&id) else {
2860                continue;
2861            };
2862            let text_content = match &node.op {
2863                fission_ir::Op::Paint(fission_ir::PaintOp::DrawText { text, .. }) => {
2864                    Some(text.clone())
2865                }
2866                fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
2867                    Some(runs.iter().map(|r| r.text.clone()).collect::<String>())
2868                }
2869                _ => None,
2870            };
2871            if let Some(text) = text_content {
2872                if text.is_empty() {
2873                    continue;
2874                }
2875                let check_id = node.parent.unwrap_or(id);
2876                let rect = visual_rect_for_node(ir, snap, scroll, check_id)
2877                    .or_else(|| visual_rect_for_node(ir, snap, scroll, id));
2878                let (x, y, w, h) = rect
2879                    .filter(|r| rect_visible_in_scroll_ancestors(ir, snap, scroll, id, *r))
2880                    .map(|r| (r.x(), r.y(), r.width(), r.height()))
2881                    .unwrap_or((0.0, 0.0, 0.0, 0.0));
2882                if w <= 0.0 || h <= 0.0 {
2883                    continue;
2884                }
2885                items.push(TextItem {
2886                    text,
2887                    x,
2888                    y,
2889                    width: w,
2890                    height: h,
2891                });
2892            }
2893        }
2894    }
2895    TestResponse::Text { items }
2896}
2897
2898fn find_visible_text_center(
2899    pipeline: &Pipeline,
2900    scroll: &fission_core::ScrollStateMap,
2901    text: &str,
2902) -> Option<(f32, f32)> {
2903    let fission_test_driver::TestResponse::Text { items } =
2904        build_get_text_response(pipeline, scroll)
2905    else {
2906        return None;
2907    };
2908    items
2909        .into_iter()
2910        .find(|item| item.text.contains(text) && item.width > 0.0 && item.height > 0.0)
2911        .map(|item| (item.x + item.width / 2.0, item.y + item.height / 2.0))
2912}
2913
2914/// Build the response for a GetTree query.
2915fn build_get_tree_response(
2916    pipeline: &Pipeline,
2917    scroll: &fission_core::ScrollStateMap,
2918) -> fission_test_driver::TestResponse {
2919    use fission_test_driver::{SemanticNode, TestResponse};
2920    let mut nodes = Vec::new();
2921    if let (Some(ir), Some(snap)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
2922        for (id, node) in &ir.nodes {
2923            if let fission_ir::Op::Semantics(sem) = &node.op {
2924                let rect = visual_rect_for_node(ir, snap, scroll, *id)
2925                    .filter(|r| rect_visible_in_scroll_ancestors(ir, snap, scroll, *id, *r));
2926                let (x, y, w, h) = rect
2927                    .map(|r| (r.x(), r.y(), r.width(), r.height()))
2928                    .unwrap_or((0.0, 0.0, 0.0, 0.0));
2929                if w <= 0.0 || h <= 0.0 {
2930                    continue;
2931                }
2932                nodes.push(SemanticNode {
2933                    role: format!("{:?}", sem.role),
2934                    label: sem.label.clone(),
2935                    value: sem.value.clone(),
2936                    focusable: sem.focusable,
2937                    x,
2938                    y,
2939                    width: w,
2940                    height: h,
2941                });
2942            }
2943        }
2944    }
2945    TestResponse::Tree { nodes }
2946}
2947
2948/// Handle TapText — find text in the IR, tap at its center.
2949fn handle_tap_text(
2950    text: &str,
2951    runtime: &mut Runtime,
2952    pipeline: &Pipeline,
2953) -> fission_test_driver::TestResponse {
2954    use fission_test_driver::TestResponse;
2955    if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
2956        if let Some((cx, cy)) =
2957            find_visible_text_center(pipeline, &runtime.runtime_state.scroll, text)
2958        {
2959            let point = LayoutPoint::new(cx, cy);
2960            let _ = runtime.handle_input(
2961                InputEvent::Pointer(PointerEvent::Down {
2962                    point,
2963                    button: PointerButton::Primary,
2964                    modifiers: 0,
2965                }),
2966                ir,
2967                snap,
2968            );
2969            let _ = runtime.handle_input(
2970                InputEvent::Pointer(PointerEvent::Up {
2971                    point,
2972                    button: PointerButton::Primary,
2973                    modifiers: 0,
2974                }),
2975                ir,
2976                snap,
2977            );
2978            TestResponse::Ok {}
2979        } else {
2980            TestResponse::Error {
2981                message: format!("text '{}' not found", text),
2982            }
2983        }
2984    } else {
2985        TestResponse::Error {
2986            message: "no frame rendered yet".into(),
2987        }
2988    }
2989}
2990
2991fn wrap_portal_for_viewport(
2992    id: Option<WidgetId>,
2993    node: fission_core::Widget,
2994    env: &Env,
2995) -> fission_core::Widget {
2996    let builder = fission_core::ui::Container::new(node)
2997        .width(env.viewport_size.width)
2998        .height(env.viewport_size.height);
2999    if let Some(id) = id {
3000        builder.id(fission_ir::WidgetId::derived(id.as_u128(), &[0x0000_F001]))
3001    } else {
3002        builder.into()
3003    }
3004}
3005
3006fn texture_plan_fits_device_limits(
3007    plan: &crate::pipeline::CompositorTexturePlan,
3008    scale_factor: f64,
3009    max_texture_dimension_2d: u32,
3010) -> bool {
3011    if plan.scene.is_some() {
3012        let width = ((plan.bounds.size.width as f64 * scale_factor).ceil() as u32).max(1);
3013        let height = ((plan.bounds.size.height as f64 * scale_factor).ceil() as u32).max(1);
3014        if width > max_texture_dimension_2d || height > max_texture_dimension_2d {
3015            return false;
3016        }
3017    }
3018    plan.children
3019        .iter()
3020        .all(|child| texture_plan_fits_device_limits(child, scale_factor, max_texture_dimension_2d))
3021}
3022
3023fn texture_plans_fit_device_limits(
3024    plans: &[crate::pipeline::CompositorTexturePlan],
3025    scale_factor: f64,
3026    max_texture_dimension_2d: u32,
3027) -> bool {
3028    plans
3029        .iter()
3030        .all(|plan| texture_plan_fits_device_limits(plan, scale_factor, max_texture_dimension_2d))
3031}
3032
3033pub type KeyHandler<S> = Arc<dyn Fn(&mut S, &fission_core::KeyCode, u8) -> bool + Send + Sync>;
3034pub type FrameHook<S> = Arc<dyn Fn(&mut S) -> bool + Send + Sync>;
3035
3036pub struct WinitApp<S: GlobalState, W>
3037where
3038    W: Clone + Into<Widget>,
3039{
3040    runtime: Runtime,
3041    layout_engine: LayoutEngine,
3042    root_widget: W,
3043    env: Env,
3044    pipeline: Pipeline,
3045    measurer: Arc<VelloTextMeasurer>,
3046    sync_env: Option<Arc<dyn Fn(&S, &mut Env) + Send + Sync>>,
3047    key_handler: Option<KeyHandler<S>>,
3048    frame_hook: Option<FrameHook<S>>,
3049    title: String,
3050    web_mount_selector: Option<String>,
3051    test_control_port: Option<u16>,
3052    /// Channel pair for receiving completed background effect results.
3053    effect_result_tx: mpsc::Sender<AsyncMessage>,
3054    effect_result_rx: mpsc::Receiver<AsyncMessage>,
3055    async_registry: AsyncRegistry,
3056    startup_action: Option<ActionEnvelope>,
3057    #[cfg(feature = "tray")]
3058    tray_config: Option<tray::TrayConfig<S>>,
3059    deep_link_config: DeepLinkConfig,
3060    startup_deep_links: Vec<DeepLink>,
3061    startup_notification_responses: Vec<NotificationResponse>,
3062    _phantom: std::marker::PhantomData<S>,
3063}
3064
3065impl<S, W> WinitApp<S, W>
3066where
3067    S: GlobalState + Default,
3068    W: Clone + Into<Widget> + 'static,
3069{
3070    pub fn new(root_widget: W) -> Self {
3071        Self::new_with_global_state(root_widget, S::default())
3072    }
3073
3074    pub fn new_with_global_state(root_widget: W, global_state: S) -> Self {
3075        let mut runtime = Runtime::default();
3076        runtime.add_global_state(Box::new(global_state)).unwrap();
3077
3078        const DEFAULT_FONT_FAMILY: &str = "Fission Default";
3079        let font_cx = Arc::new(Mutex::new(build_font_context()));
3080        {
3081            let mut font_cx = font_cx.lock().unwrap();
3082            let font_data = fonts::default_font_bytes().to_vec();
3083            let info_override = FontInfoOverride {
3084                family_name: Some(DEFAULT_FONT_FAMILY),
3085                ..Default::default()
3086            };
3087            font_cx
3088                .collection
3089                .register_fonts(Blob::from(font_data), Some(info_override));
3090        }
3091        let measurer = Arc::new(VelloTextMeasurer::new_with_default_family(
3092            font_cx.clone(),
3093            DEFAULT_FONT_FAMILY,
3094        ));
3095        let env = Env::new(measurer.clone() as Arc<dyn fission_layout::TextMeasurer>);
3096        let clipboard: Arc<dyn fission_core::env::Clipboard> = Arc::new(DesktopClipboard::new());
3097
3098        let layout_engine = LayoutEngine::new().with_measurer(measurer.clone());
3099        let runtime = runtime
3100            .with_measurer(measurer.clone())
3101            .with_clipboard(clipboard);
3102
3103        let (effect_result_tx, effect_result_rx) = mpsc::channel();
3104        let mut async_registry = AsyncRegistry::new();
3105        register_builtin_operation_capabilities(&mut async_registry);
3106
3107        Self {
3108            runtime,
3109            layout_engine,
3110            root_widget,
3111            env,
3112            pipeline: Pipeline::new(),
3113            measurer,
3114            sync_env: None,
3115            key_handler: None,
3116            frame_hook: None,
3117            title: "Fission".into(),
3118            web_mount_selector: None,
3119            test_control_port: None,
3120            effect_result_tx,
3121            effect_result_rx,
3122            async_registry,
3123            startup_action: None,
3124            #[cfg(feature = "tray")]
3125            tray_config: None,
3126            deep_link_config: DeepLinkConfig::default(),
3127            startup_deep_links: Vec::new(),
3128            startup_notification_responses: Vec::new(),
3129            _phantom: std::marker::PhantomData,
3130        }
3131    }
3132
3133    pub fn with_global_state(mut self, global_state: S) -> Self {
3134        *self.runtime.get_global_state_mut::<S>().expect(
3135            "Fission global state must be registered before WinitApp::with_global_state is called",
3136        ) = global_state;
3137        self
3138    }
3139
3140    pub fn with_key_handler<F>(mut self, handler: F) -> Self
3141    where
3142        F: Fn(&mut S, &fission_core::KeyCode, u8) -> bool + Send + Sync + 'static,
3143    {
3144        self.key_handler = Some(Arc::new(handler));
3145        self
3146    }
3147
3148    pub fn with_title(mut self, title: impl Into<String>) -> Self {
3149        self.title = title.into();
3150        self.env.window.title = fission_core::WindowTitle::plain(self.title.clone());
3151        self
3152    }
3153
3154    pub fn with_test_control_port(mut self, port: u16) -> Self {
3155        self.test_control_port = Some(port);
3156        self
3157    }
3158
3159    pub fn with_mount_selector(mut self, selector: impl Into<String>) -> Self {
3160        self.web_mount_selector = Some(selector.into());
3161        self
3162    }
3163
3164    /// Mutate the initial application state before the first frame.
3165    pub fn with_state_init<F>(mut self, init: F) -> Self
3166    where
3167        F: FnOnce(&mut S),
3168    {
3169        if let Some(state) = self.runtime.get_global_state_mut::<S>() {
3170            init(state);
3171        }
3172        self
3173    }
3174
3175    pub fn with_env(mut self, env: Env) -> Self {
3176        self.env = env;
3177        self
3178    }
3179
3180    pub fn with_design_system<D: fission_theme::DesignSystem>(
3181        mut self,
3182        mode: fission_theme::DesignMode,
3183    ) -> Self {
3184        self.env.theme = D::theme(mode);
3185        self
3186    }
3187
3188    pub fn with_sync_env<F>(mut self, f: F) -> Self
3189    where
3190        F: Fn(&S, &mut Env) + Send + Sync + 'static,
3191    {
3192        self.sync_env = Some(Arc::new(f));
3193        self
3194    }
3195
3196    /// Register a hook that runs on every `AboutToWait` event with mutable
3197    /// access to the application state.  Return `true` to request a redraw.
3198    /// Useful for polling background services (e.g. LSP) between key events.
3199    pub fn with_frame_hook<F>(mut self, f: F) -> Self
3200    where
3201        F: Fn(&mut S) -> bool + Send + Sync + 'static,
3202    {
3203        self.frame_hook = Some(Arc::new(f));
3204        self
3205    }
3206
3207    pub fn with_async<F>(mut self, configure: F) -> Self
3208    where
3209        F: FnOnce(&mut AsyncRegistry),
3210    {
3211        configure(&mut self.async_registry);
3212        self
3213    }
3214
3215    /// Registers the host implementation used for notification effects.
3216    ///
3217    /// `host` receives requests emitted by `ctx.effects.notifications()`. Use
3218    /// this to install a real OS/browser notification provider in a shell, or a
3219    /// deterministic memory provider in tests.
3220    pub fn with_notification_host<H>(mut self, host: H) -> Self
3221    where
3222        H: NotificationHost,
3223    {
3224        notifications::register_notification_capabilities(&mut self.async_registry, Arc::new(host));
3225        self
3226    }
3227
3228    /// Registers the host implementation used for NFC effects.
3229    ///
3230    /// `host` owns scanning, writing, emulation, and cancellation. Install a
3231    /// provider only for targets or attached reader hardware that can satisfy the
3232    /// NFC contract.
3233    pub fn with_nfc_host<H>(mut self, host: H) -> Self
3234    where
3235        H: NfcHost,
3236    {
3237        nfc::register_nfc_capabilities(&mut self.async_registry, Arc::new(host));
3238        self
3239    }
3240
3241    /// Registers the host implementation used for biometric authentication effects.
3242    ///
3243    /// `host` should map Fission requests to the platform local-authentication
3244    /// system and return typed errors for missing enrollment, cancellation, or
3245    /// unsupported hardware.
3246    pub fn with_biometric_host<H>(mut self, host: H) -> Self
3247    where
3248        H: BiometricHost,
3249    {
3250        biometric::register_biometric_capabilities(&mut self.async_registry, Arc::new(host));
3251        self
3252    }
3253
3254    /// Registers the host implementation used for passkey/WebAuthn effects.
3255    ///
3256    /// `host` should map Fission registration and authentication requests to
3257    /// the platform credential APIs and return WebAuthn data for server-side
3258    /// verification. It should not treat local biometric unlock as proof of
3259    /// identity without server verification.
3260    pub fn with_passkey_host<H>(mut self, host: H) -> Self
3261    where
3262        H: PasskeyHost,
3263    {
3264        passkey::register_passkey_capabilities(&mut self.async_registry, Arc::new(host));
3265        self
3266    }
3267
3268    /// Registers the host implementation used for Bluetooth effects.
3269    ///
3270    /// `host` owns adapter state, permission, scanning, connecting, reads, writes,
3271    /// and advertising. Use this boundary to keep platform Bluetooth APIs out of
3272    /// shared app reducers.
3273    pub fn with_bluetooth_host<H>(mut self, host: H) -> Self
3274    where
3275        H: BluetoothHost,
3276    {
3277        bluetooth::register_bluetooth_capabilities(&mut self.async_registry, Arc::new(host));
3278        self
3279    }
3280
3281    /// Registers the host implementation used for barcode scanner effects.
3282    ///
3283    /// `host` may run live camera scanning, decode supplied image bytes, or both.
3284    /// Reducers should rely on this provider instead of depending on a specific
3285    /// camera or decoder library.
3286    pub fn with_barcode_scanner_host<H>(mut self, host: H) -> Self
3287    where
3288        H: BarcodeScannerHost,
3289    {
3290        barcode::register_barcode_scanner_capabilities(&mut self.async_registry, Arc::new(host));
3291        self
3292    }
3293
3294    /// Registers the host implementation used for camera and flashlight effects.
3295    ///
3296    /// `host` owns camera availability, permission, photo capture, torch control,
3297    /// and cancellation. Use memory hosts for tests and real OS providers for
3298    /// production shells.
3299    pub fn with_camera_host<H>(mut self, host: H) -> Self
3300    where
3301        H: CameraHost,
3302    {
3303        camera::register_camera_capabilities(&mut self.async_registry, Arc::new(host));
3304        self
3305    }
3306
3307    /// Registers the host implementation used for clipboard effects.
3308    ///
3309    /// `host` owns text and typed clipboard access. This is useful for tests,
3310    /// custom shells, or platforms where clipboard behavior differs from the
3311    /// default desktop provider.
3312    pub fn with_clipboard_host<H>(mut self, host: H) -> Self
3313    where
3314        H: ClipboardHost,
3315    {
3316        clipboard::register_clipboard_capabilities(&mut self.async_registry, Arc::new(host));
3317        self
3318    }
3319
3320    /// Registers the host implementation used for geolocation effects.
3321    ///
3322    /// `host` owns permission checks and current-position requests. It should map
3323    /// Fission accuracy and cache controls to the platform location service where
3324    /// available.
3325    pub fn with_geolocation_host<H>(mut self, host: H) -> Self
3326    where
3327        H: GeolocationHost,
3328    {
3329        geolocation::register_geolocation_capabilities(&mut self.async_registry, Arc::new(host));
3330        self
3331    }
3332
3333    /// Registers the host implementation used for haptic feedback effects.
3334    ///
3335    /// `host` owns impact, notification, selection, and pattern playback. It
3336    /// should return unsupported errors on devices without tactile hardware.
3337    pub fn with_haptic_host<H>(mut self, host: H) -> Self
3338    where
3339        H: HapticHost,
3340    {
3341        haptics::register_haptic_capabilities(&mut self.async_registry, Arc::new(host));
3342        self
3343    }
3344
3345    /// Registers the host implementation used for microphone effects.
3346    ///
3347    /// `host` owns input-device availability, permission, bounded recording, and
3348    /// cancellation. Keep recording code behind this provider boundary.
3349    pub fn with_microphone_host<H>(mut self, host: H) -> Self
3350    where
3351        H: MicrophoneHost,
3352    {
3353        microphone::register_microphone_capabilities(&mut self.async_registry, Arc::new(host));
3354        self
3355    }
3356
3357    /// Registers the host implementation used for Wi-Fi effects.
3358    ///
3359    /// `host` owns adapter availability, permission, scanning, connection, and
3360    /// disconnection. Platform Wi-Fi APIs are permission-sensitive, so unsupported
3361    /// and denied states should be reported explicitly.
3362    pub fn with_wifi_host<H>(mut self, host: H) -> Self
3363    where
3364        H: WifiHost,
3365    {
3366        wifi::register_wifi_capabilities(&mut self.async_registry, Arc::new(host));
3367        self
3368    }
3369
3370    /// Registers the host implementation used for volume-control effects.
3371    ///
3372    /// `host` maps Fission volume streams to the platform mixer or media control
3373    /// model. It should return unsupported errors when the target cannot expose
3374    /// system volume control to apps.
3375    pub fn with_volume_host<H>(mut self, host: H) -> Self
3376    where
3377        H: VolumeHost,
3378    {
3379        volume::register_volume_capabilities(&mut self.async_registry, Arc::new(host));
3380        self
3381    }
3382
3383    pub fn with_startup_action<A: Action>(mut self, action: A) -> Self {
3384        self.startup_action = Some(action.into());
3385        self
3386    }
3387
3388    #[cfg(feature = "tray")]
3389    pub fn with_tray(mut self, config: tray::TrayConfig<S>) -> Self {
3390        self.tray_config = Some(config);
3391        self
3392    }
3393
3394    /// Installs the deep-link filter used by this shell.
3395    ///
3396    /// `config` declares accepted schemes, domains, and path prefixes. The shell
3397    /// uses it to classify inbound links before dispatching `DeepLinkReceived`
3398    /// actions into the app.
3399    pub fn with_deep_link_config(mut self, config: DeepLinkConfig) -> Self {
3400        self.deep_link_config = config;
3401        self
3402    }
3403
3404    /// Adds one accepted custom deep-link scheme.
3405    ///
3406    /// `scheme` is normalized by `DeepLinkConfig`. Use this for app-specific
3407    /// routes such as `myapp://item/123`.
3408    pub fn with_deep_link_scheme(mut self, scheme: impl Into<String>) -> Self {
3409        self.deep_link_config = self.deep_link_config.scheme(scheme);
3410        self
3411    }
3412
3413    /// Adds one accepted HTTP or HTTPS deep-link domain.
3414    ///
3415    /// `domain` is normalized by `DeepLinkConfig`. Use this for verified app
3416    /// links, universal links, or web URLs that should enter the app.
3417    pub fn with_deep_link_domain(mut self, domain: impl Into<String>) -> Self {
3418        self.deep_link_config = self.deep_link_config.domain(domain);
3419        self
3420    }
3421
3422    /// Queues a deep link to dispatch after the app starts.
3423    ///
3424    /// Use this from host startup code when the platform launched the app because
3425    /// of an external URL. The link is delivered through the normal action path.
3426    pub fn with_startup_deep_link(mut self, link: DeepLink) -> Self {
3427        self.startup_deep_links.push(link);
3428        self
3429    }
3430
3431    /// Queues a notification response to dispatch after the app starts.
3432    ///
3433    /// Use this when a notification action or tap launched the app. The response
3434    /// is delivered as `NotificationResponseReceived` through the normal reducer
3435    /// path.
3436    pub fn with_startup_notification_response(mut self, response: NotificationResponse) -> Self {
3437        self.startup_notification_responses.push(response);
3438        self
3439    }
3440
3441    /// Registers a reducer handler for inbound deep links.
3442    ///
3443    /// `handler` receives `DeepLinkReceived` actions from startup links and
3444    /// runtime host events. Use it to update routing state rather than parsing
3445    /// deep links inside widgets.
3446    pub fn on_deep_link<H>(mut self, handler: H) -> Self
3447    where
3448        H: fission_core::registry::IntoHandler<S, DeepLinkReceived> + Send + Sync + 'static,
3449    {
3450        let mut registry = ActionRegistry::<S>::new();
3451        registry.register(handler);
3452        self.runtime.absorb_persistent_registry(registry);
3453        self
3454    }
3455
3456    /// Registers a reducer handler for notification responses.
3457    ///
3458    /// `handler` receives `NotificationResponseReceived` actions when the user
3459    /// taps or acts on a notification. Use it to route the user or process action
3460    /// ids in normal app state.
3461    pub fn on_notification_response<H>(mut self, handler: H) -> Self
3462    where
3463        H: fission_core::registry::IntoHandler<S, NotificationResponseReceived>
3464            + Send
3465            + Sync
3466            + 'static,
3467    {
3468        let mut registry = ActionRegistry::<S>::new();
3469        registry.register(handler);
3470        self.runtime.absorb_persistent_registry(registry);
3471        self
3472    }
3473
3474    /// Register a reducer for host/shell route changes.
3475    ///
3476    /// The host dispatches [`fission_core::ShellRouteChanged`] when navigation
3477    /// updates. Applications should store route data in state and render the
3478    /// corresponding screen.
3479    pub fn with_route_handler(
3480        mut self,
3481        handler: fission_core::registry::Handler<S, fission_core::ShellRouteChanged>,
3482    ) -> Self {
3483        let mut registry = ActionRegistry::<S>::new();
3484        registry.register::<fission_core::ShellRouteChanged, _>(handler);
3485        self.runtime.absorb_persistent_registry(registry);
3486        self
3487    }
3488
3489    pub fn register_reducer(
3490        &mut self,
3491        action_id: ActionId,
3492        reducer: fission_core::action::Reducer<S>,
3493    ) -> Result<()> {
3494        self.runtime.register_reducer::<S>(action_id, reducer)
3495    }
3496
3497    pub fn absorb_registry(&mut self, registry: fission_core::ActionRegistry<S>) {
3498        self.runtime.absorb_persistent_registry(registry);
3499    }
3500
3501    pub fn run(self) -> Result<()> {
3502        self.run_inner(
3503            #[cfg(target_os = "android")]
3504            None,
3505        )
3506    }
3507
3508    #[cfg(target_os = "android")]
3509    pub fn run_with_android_app(self, android_app: AndroidApp) -> Result<()> {
3510        self.run_inner(Some(android_app))
3511    }
3512
3513    fn run_inner(
3514        mut self,
3515        #[cfg(target_os = "android")] android_app: Option<AndroidApp>,
3516    ) -> Result<()> {
3517        diag::emit(
3518            diag::DiagCategory::Frame,
3519            diag::DiagLevel::Info,
3520            diag::DiagEventKind::FrameStart { root: None },
3521        );
3522        diag::init_from_env();
3523
3524        // Build event loop with TestEvent as the user event type.
3525        // This allows the test control server to inject events via EventLoopProxy.
3526        let background_test_mode = std::env::var_os("FISSION_BACKGROUND_TEST").is_some();
3527        let mut event_loop_builder = EventLoop::<TestEvent>::with_user_event();
3528        #[cfg(target_os = "android")]
3529        if let Some(app) = android_app.as_ref() {
3530            android_capabilities::register_android_operation_capabilities(
3531                &mut self.async_registry,
3532                app,
3533            );
3534        }
3535        #[cfg(target_os = "android")]
3536        if let Some(app) = android_app {
3537            event_loop_builder.with_android_app(app);
3538        }
3539        #[cfg(target_os = "macos")]
3540        if background_test_mode {
3541            event_loop_builder.with_activation_policy(ActivationPolicy::Accessory);
3542            event_loop_builder.with_activate_ignoring_other_apps(false);
3543            event_loop_builder.with_default_menu(false);
3544        }
3545        let event_loop = event_loop_builder
3546            .build()
3547            .map_err(|e| anyhow::anyhow!("Event loop error: {}", e))?;
3548        let event_proxy = event_loop.create_proxy();
3549        #[cfg(feature = "tray")]
3550        let tray_event_rx = self
3551            .tray_config
3552            .as_ref()
3553            .map(|_| tray::install_event_forwarders(event_proxy.clone()));
3554        #[cfg(feature = "tray")]
3555        let tray_config = self.tray_config.clone();
3556        let window_title = self.title.clone();
3557        let web_mount_selector = self.web_mount_selector;
3558        let ime_handler = Arc::new(DesktopImeHandler::default());
3559        self.runtime = self.runtime.with_ime_handler(ime_handler.clone());
3560
3561        #[cfg(not(target_os = "android"))]
3562        let platform_window = build_window_before_run(
3563            &window_title,
3564            background_test_mode,
3565            &event_loop,
3566            web_mount_selector.as_deref(),
3567        )?;
3568        #[cfg(not(target_os = "android"))]
3569        ime_handler.set_window(Some(platform_window.clone()));
3570        #[cfg(target_os = "android")]
3571        let mut platform_window: Option<Arc<Window>> = None;
3572
3573        // Rendering state is created lazily so Android can wait for a valid
3574        // native surface after the first resume event.
3575        #[cfg(target_os = "android")]
3576        if std::env::var_os("WGPU_BACKEND").is_none() {
3577            eprintln!("fission-shell-winit: forcing WGPU_BACKEND=gl on Android");
3578            std::env::set_var("WGPU_BACKEND", "gl");
3579        }
3580        #[cfg(not(target_arch = "wasm32"))]
3581        let mut render_cx = RenderContext::new();
3582        #[cfg(not(target_arch = "wasm32"))]
3583        let mut render_state: Option<RenderState<'_>> = None;
3584        #[cfg(target_arch = "wasm32")]
3585        let mut web_renderer: Option<WebRenderer> = None;
3586        #[cfg(target_arch = "wasm32")]
3587        let pending_webgpu_init: PendingWebGpuInit = Rc::new(RefCell::new(None));
3588        #[cfg(target_arch = "wasm32")]
3589        let mut webgpu_init_in_flight = false;
3590        #[cfg(target_arch = "wasm32")]
3591        let mut web_renderer_reported = false;
3592        #[cfg(not(target_arch = "wasm32"))]
3593        let mut scene = Scene::new();
3594        #[cfg(not(target_arch = "wasm32"))]
3595        let mut retained_scene_cache = RetainedSceneCache::default();
3596
3597        #[cfg(not(target_os = "android"))]
3598        platform_window.request_redraw();
3599
3600        let mut startup_deep_links = self.startup_deep_links.clone();
3601        startup_deep_links.extend(collect_startup_deep_links(&self.deep_link_config));
3602        let startup_notification_responses = self.startup_notification_responses.clone();
3603
3604        let mut runtime = self.runtime;
3605        for link in startup_deep_links {
3606            runtime.dispatch(DeepLinkReceived { link }.into(), WidgetId::from_u128(0))?;
3607        }
3608        for response in startup_notification_responses {
3609            runtime.dispatch(
3610                NotificationResponseReceived { response }.into(),
3611                WidgetId::from_u128(0),
3612            )?;
3613        }
3614        let mut layout_engine = self.layout_engine;
3615        let root_widget = self.root_widget;
3616        let mut env = self.env;
3617        env.window.title = fission_core::WindowTitle::plain(window_title.clone());
3618        let mut applied_window_title = window_title.clone();
3619        let mut pipeline = self.pipeline;
3620        let measurer = self.measurer;
3621        let effect_result_tx = self.effect_result_tx;
3622        let effect_result_rx = self.effect_result_rx;
3623        let async_registry = self.async_registry;
3624        let startup_action = self.startup_action;
3625        let mut startup_dispatched = false;
3626        let mut next_service_instance_id = 1_u64;
3627        let mut active_services: HashMap<ServiceKey, ActiveServiceHandle> = HashMap::new();
3628        let mut service_bindings: HashMap<ServiceBindingKey, ServiceBindings> = HashMap::new();
3629
3630        #[cfg(target_os = "macos")]
3631        let video_backend: Arc<dyn VideoBackend> = Arc::new(MacVideoBackend::new(&platform_window));
3632        #[cfg(not(target_os = "macos"))]
3633        let video_backend: Arc<dyn VideoBackend> = Arc::new(MockVideoBackend::new());
3634        #[cfg(target_os = "macos")]
3635        let web_backend = MacWebBackend::new(&platform_window);
3636        #[cfg(not(target_os = "macos"))]
3637        let web_backend = MockWebBackend::new();
3638        let mut players: HashMap<WidgetId, ActivePlayer> = HashMap::new();
3639
3640        let mut last_cursor_position: Option<PhysicalPosition<f64>> = None;
3641        let mut active_primary_touch: Option<u64> = None;
3642        let mut touch_positions: HashMap<u64, PhysicalPosition<f64>> = HashMap::new();
3643        let max_fps = std::env::var("FISSION_MAX_FPS")
3644            .ok()
3645            .and_then(|v| v.parse::<u32>().ok())
3646            .filter(|v| *v > 0)
3647            .unwrap_or(60);
3648        let min_frame = Duration::from_secs_f32(1.0 / max_fps as f32);
3649        let repeat_animation_fps = std::env::var("FISSION_REPEAT_ANIMATION_FPS")
3650            .ok()
3651            .and_then(|v| v.parse::<u32>().ok())
3652            .filter(|v| *v > 0)
3653            .map(|v| v.min(max_fps))
3654            .unwrap_or(10);
3655        let repeat_animation_frame = Duration::from_secs_f32(1.0 / repeat_animation_fps as f32);
3656        let resize_fps = std::env::var("FISSION_RESIZE_FPS")
3657            .ok()
3658            .and_then(|v| v.parse::<u32>().ok())
3659            .filter(|v| *v > 0)
3660            .map(|v| v.min(max_fps))
3661            .unwrap_or(60);
3662        let resize_frame = Duration::from_secs_f32(1.0 / resize_fps as f32);
3663        let resize_settle_delay = Duration::from_millis(
3664            std::env::var("FISSION_RESIZE_SETTLE_MS")
3665                .ok()
3666                .and_then(|v| v.parse::<u64>().ok())
3667                .filter(|v| *v > 0)
3668                .unwrap_or(90),
3669        );
3670        let mut last_redraw_at = Instant::now()
3671            .checked_sub(min_frame)
3672            .unwrap_or_else(Instant::now);
3673        let mut redraw_pending = false;
3674        let mut last_frame_time = Instant::now();
3675        let blink_enabled = std::env::var("FISSION_TEXTINPUT_BLINK")
3676            .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
3677            .unwrap_or(true);
3678        let blink_period = Duration::from_millis(
3679            std::env::var("FISSION_TEXTINPUT_BLINK_MS")
3680                .ok()
3681                .and_then(|v| v.parse::<u64>().ok())
3682                .filter(|v| *v > 0)
3683                .unwrap_or(530),
3684        );
3685        let mut last_blink_toggle = Instant::now();
3686        let mut blink_focus_id: Option<WidgetId> = None;
3687        let text_trace_enabled = std::env::var("FISSION_TEXT_TRACE")
3688            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
3689            .unwrap_or(false);
3690        let mut frame_trace = FrameTraceState::new(frame_trace_enabled());
3691        let mut presented_frames: u64 = 0;
3692        let mut next_text_trace_seq: u64 = 0;
3693        let mut pending_text_traces: VecDeque<PendingTextTrace> = VecDeque::new();
3694        #[cfg(target_arch = "wasm32")]
3695        let mut pending_web_input_at: Option<Instant> = None;
3696        let mut current_mods: u8 = 0;
3697
3698        // Test control (enabled via FISSION_TEST_CONTROL_PORT env var).
3699        // The TCP server injects TestEvents via the EventLoopProxy. Query
3700        // events carry per-command response channels, so a timed-out command
3701        // cannot poison the next command with a stale response.
3702        #[cfg(not(target_arch = "wasm32"))]
3703        let test_control_port = self.test_control_port.or_else(|| {
3704            std::env::var("FISSION_TEST_CONTROL_PORT")
3705                .ok()
3706                .and_then(|v| v.parse::<u16>().ok())
3707        });
3708        #[cfg(all(target_os = "android", not(target_arch = "wasm32")))]
3709        let pending_test_events = test_control::create_pending_event_queue();
3710        #[cfg(not(target_arch = "wasm32"))]
3711        let test_control_enabled = test_control_port
3712            .map(|port| {
3713                #[cfg(target_os = "android")]
3714                let injector = test_control::EventInjector::Queue {
3715                    queue: pending_test_events.clone(),
3716                    wake_proxy: Some(event_proxy.clone()),
3717                };
3718                #[cfg(not(target_os = "android"))]
3719                let injector = test_control::EventInjector::Proxy(event_proxy.clone());
3720                test_control::spawn_server(port, injector);
3721                true
3722            })
3723            .unwrap_or(false);
3724        #[cfg(target_arch = "wasm32")]
3725        let test_control_enabled = false;
3726        #[cfg(not(target_os = "android"))]
3727        let _ = test_control_enabled;
3728        // Pending screenshot/pump: path + whether it needs a screenshot (vs pump).
3729        let mut pending_screenshot_path: Option<String> = None;
3730        let mut pending_screenshot_response_tx: Option<test_control::ResponseSender> = None;
3731        #[cfg(not(target_os = "android"))]
3732        let mut window_viewport = WindowViewportState::from_window(&platform_window);
3733        #[cfg(target_os = "android")]
3734        let mut window_viewport: Option<WindowViewportState> = None;
3735        #[cfg(not(target_os = "android"))]
3736        let mut pending_resize = Some(window_viewport);
3737        #[cfg(target_os = "android")]
3738        let mut pending_resize = None;
3739        let mut resize_needs_settled_frame = pending_resize.is_some();
3740        let mut pending_capture_settle = false;
3741        let mut last_built_viewport: Option<LayoutSize> = None;
3742        let mut live_resize = LiveResizeController::new(resize_settle_delay);
3743        #[cfg(feature = "tray")]
3744        let mut active_tray: Option<tray::ActiveTray<S>> = None;
3745        let mut invalidations = InvalidationSet {
3746            build: true,
3747            layout: true,
3748            paint: true,
3749            composite: true,
3750        };
3751        let mut vello_image_cache_generation = fission_render_vello::image_cache_generation();
3752        let mut software_image_cache_generation = software_renderer::image_cache_generation();
3753
3754        let event_handler = move |event: Event<TestEvent>, elwt: &EventLoopWindowTarget| {
3755            elwt.set_control_flow(ControlFlow::Wait);
3756            let debug_android_events = cfg!(target_os = "android")
3757                && std::env::var_os("FISSION_DEBUG_ANDROID_EVENTS").is_some();
3758
3759            let mut handle_test_event = |test_event: TestEvent| {
3760                if debug_android_events {
3761                    eprintln!("[android-events] user_event={test_event:?}");
3762                }
3763                match test_event {
3764                    TestEvent::MouseMove { x, y } => {
3765                        let Some(window) = platform_window.active_window() else {
3766                            return;
3767                        };
3768                        let scale_factor = window.scale_factor();
3769                        last_cursor_position = Some(PhysicalPosition::new(
3770                            (x as f64) * scale_factor,
3771                            (y as f64) * scale_factor,
3772                        ));
3773                        handle_cursor_moved(
3774                            x,
3775                            y,
3776                            0,
3777                            &mut runtime,
3778                            &pipeline,
3779                            &effect_result_tx,
3780                            &event_proxy,
3781                            &async_registry,
3782                            &mut active_services,
3783                            &mut service_bindings,
3784                            &mut next_service_instance_id,
3785                            window,
3786                            elwt,
3787                            &mut last_redraw_at,
3788                            min_frame,
3789                            &mut redraw_pending,
3790                            &mut frame_trace,
3791                            &mut invalidations,
3792                        );
3793                    }
3794                    TestEvent::MouseDown { x, y, button } => {
3795                        let Some(window) = platform_window.active_window() else {
3796                            return;
3797                        };
3798                        let btn = map_test_button(button);
3799                        handle_mouse_button(
3800                            x,
3801                            y,
3802                            btn,
3803                            true,
3804                            0,
3805                            &mut runtime,
3806                            &pipeline,
3807                            &effect_result_tx,
3808                            &event_proxy,
3809                            &async_registry,
3810                            &mut active_services,
3811                            &mut service_bindings,
3812                            &mut next_service_instance_id,
3813                            window,
3814                            elwt,
3815                            &mut last_redraw_at,
3816                            min_frame,
3817                            &mut redraw_pending,
3818                            text_trace_enabled,
3819                            &mut pending_text_traces,
3820                            &mut next_text_trace_seq,
3821                            presented_frames,
3822                            &mut last_blink_toggle,
3823                            &mut frame_trace,
3824                            &mut invalidations,
3825                        );
3826                    }
3827                    TestEvent::MouseUp { x, y, button } => {
3828                        let Some(window) = platform_window.active_window() else {
3829                            return;
3830                        };
3831                        let btn = map_test_button(button);
3832                        handle_mouse_button(
3833                            x,
3834                            y,
3835                            btn,
3836                            false,
3837                            0,
3838                            &mut runtime,
3839                            &pipeline,
3840                            &effect_result_tx,
3841                            &event_proxy,
3842                            &async_registry,
3843                            &mut active_services,
3844                            &mut service_bindings,
3845                            &mut next_service_instance_id,
3846                            window,
3847                            elwt,
3848                            &mut last_redraw_at,
3849                            min_frame,
3850                            &mut redraw_pending,
3851                            text_trace_enabled,
3852                            &mut pending_text_traces,
3853                            &mut next_text_trace_seq,
3854                            presented_frames,
3855                            &mut last_blink_toggle,
3856                            &mut frame_trace,
3857                            &mut invalidations,
3858                        );
3859                    }
3860                    TestEvent::KeyDown {
3861                        key_code,
3862                        modifiers,
3863                    } => {
3864                        let Some(window) = platform_window.active_window() else {
3865                            return;
3866                        };
3867                        let code = parse_key_code(&key_code);
3868                        handle_key_down::<S>(
3869                            code,
3870                            modifiers,
3871                            &mut runtime,
3872                            &pipeline,
3873                            &effect_result_tx,
3874                            &event_proxy,
3875                            &async_registry,
3876                            &mut active_services,
3877                            &mut service_bindings,
3878                            &mut next_service_instance_id,
3879                            window,
3880                            elwt,
3881                            &mut last_redraw_at,
3882                            min_frame,
3883                            &mut redraw_pending,
3884                            text_trace_enabled,
3885                            &mut pending_text_traces,
3886                            &mut next_text_trace_seq,
3887                            presented_frames,
3888                            &mut last_blink_toggle,
3889                            self.key_handler.as_ref(),
3890                            &mut frame_trace,
3891                            &mut invalidations,
3892                        );
3893                    }
3894                    TestEvent::KeyUp { .. } => {
3895                        let Some(window) = platform_window.active_window() else {
3896                            return;
3897                        };
3898                        request_redraw_logged(
3899                            window,
3900                            elwt,
3901                            &mut last_redraw_at,
3902                            min_frame,
3903                            &mut redraw_pending,
3904                            &mut frame_trace,
3905                            "test_key_up",
3906                        );
3907                    }
3908                    TestEvent::TextInput { text } => {
3909                        let Some(window) = platform_window.active_window() else {
3910                            return;
3911                        };
3912                        if let (Some(ir), Some(layout)) =
3913                            (&pipeline.prev_ir, &pipeline.last_snapshot)
3914                        {
3915                            let target = focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
3916                            if target.is_some()
3917                                || focused_custom_text_input(&runtime, pipeline.prev_ir.as_ref())
3918                            {
3919                                let trace_seq = start_text_trace(
3920                                    text_trace_enabled && target.is_some(),
3921                                    &mut pending_text_traces,
3922                                    &mut next_text_trace_seq,
3923                                    format!("test_text_input:{}", text.chars().count()),
3924                                    target,
3925                                    presented_frames,
3926                                );
3927                                runtime
3928                                    .handle_input(
3929                                        InputEvent::Ime(fission_core::event::ImeEvent::Commit {
3930                                            text: text.clone(),
3931                                        }),
3932                                        ir,
3933                                        layout,
3934                                    )
3935                                    .ok();
3936                                invalidations.mark_build();
3937                                mark_text_trace_handled(&mut pending_text_traces, trace_seq);
3938                                if process_pending_effects(
3939                                    &mut runtime,
3940                                    &effect_result_tx,
3941                                    &event_proxy,
3942                                    &async_registry,
3943                                    &mut active_services,
3944                                    &mut service_bindings,
3945                                    &mut next_service_instance_id,
3946                                ) {
3947                                    mark_text_trace_effects(&mut pending_text_traces, trace_seq);
3948                                    invalidations.mark_build();
3949                                }
3950                                request_redraw_logged(
3951                                    window,
3952                                    elwt,
3953                                    &mut last_redraw_at,
3954                                    min_frame,
3955                                    &mut redraw_pending,
3956                                    &mut frame_trace,
3957                                    "test_text_input",
3958                                );
3959                            } else {
3960                                for ch in text.chars() {
3961                                    let key = if ch == ' ' {
3962                                        KeyCode::Space
3963                                    } else if ch == '\n' {
3964                                        KeyCode::Enter
3965                                    } else {
3966                                        KeyCode::Char(ch)
3967                                    };
3968                                    handle_key_down::<S>(
3969                                        key,
3970                                        0,
3971                                        &mut runtime,
3972                                        &pipeline,
3973                                        &effect_result_tx,
3974                                        &event_proxy,
3975                                        &async_registry,
3976                                        &mut active_services,
3977                                        &mut service_bindings,
3978                                        &mut next_service_instance_id,
3979                                        window,
3980                                        elwt,
3981                                        &mut last_redraw_at,
3982                                        min_frame,
3983                                        &mut redraw_pending,
3984                                        text_trace_enabled,
3985                                        &mut pending_text_traces,
3986                                        &mut next_text_trace_seq,
3987                                        presented_frames,
3988                                        &mut last_blink_toggle,
3989                                        self.key_handler.as_ref(),
3990                                        &mut frame_trace,
3991                                        &mut invalidations,
3992                                    );
3993                                }
3994                            }
3995                        }
3996                    }
3997                    TestEvent::Scroll { x, y, dx, dy } => {
3998                        let Some(window) = platform_window.active_window() else {
3999                            return;
4000                        };
4001                        handle_scroll(
4002                            x,
4003                            y,
4004                            dx,
4005                            dy,
4006                            0,
4007                            &mut runtime,
4008                            &pipeline,
4009                            &effect_result_tx,
4010                            &event_proxy,
4011                            &async_registry,
4012                            &mut active_services,
4013                            &mut service_bindings,
4014                            &mut next_service_instance_id,
4015                            window,
4016                            elwt,
4017                            &mut last_redraw_at,
4018                            min_frame,
4019                            &mut redraw_pending,
4020                            &mut frame_trace,
4021                            &mut invalidations,
4022                        );
4023                    }
4024                    TestEvent::Resize { width, height } => {
4025                        let Some(window) = platform_window.active_window() else {
4026                            return;
4027                        };
4028                        if width > 0 && height > 0 {
4029                            let requested_logical_size =
4030                                LayoutSize::new(width as f32, height as f32);
4031                            let current_viewport = pending_resize
4032                                .unwrap_or_else(|| WindowViewportState::from_window(window))
4033                                .with_logical_size(requested_logical_size);
4034                            #[cfg(not(any(target_os = "android", target_os = "ios")))]
4035                            {
4036                                let _ = window.request_inner_size(
4037                                    native_window_size_for_logical_viewport(requested_logical_size),
4038                                );
4039                            }
4040                            #[cfg(not(target_os = "android"))]
4041                            {
4042                                window_viewport = current_viewport;
4043                            }
4044                            #[cfg(target_os = "android")]
4045                            {
4046                                window_viewport = Some(current_viewport);
4047                            }
4048                            apply_authoritative_resize(
4049                                window,
4050                                elwt,
4051                                current_viewport,
4052                                &mut pending_resize,
4053                                &mut resize_needs_settled_frame,
4054                                &mut pending_capture_settle,
4055                                pending_screenshot_path.as_deref(),
4056                                &mut live_resize,
4057                                &mut invalidations,
4058                                &mut last_redraw_at,
4059                                resize_frame,
4060                                &mut redraw_pending,
4061                                &mut frame_trace,
4062                                "test_resize",
4063                            );
4064                        }
4065                    }
4066                    TestEvent::TapText { text, response_tx } => {
4067                        let Some(window) = platform_window.active_window() else {
4068                            let _ = response_tx.send(fission_test_driver::TestResponse::Error {
4069                                message: "window not ready".into(),
4070                            });
4071                            return;
4072                        };
4073                        let resp = handle_tap_text(&text, &mut runtime, &pipeline);
4074                        if matches!(resp, fission_test_driver::TestResponse::Ok { .. }) {
4075                            invalidations.mark_build();
4076                            if process_pending_effects(
4077                                &mut runtime,
4078                                &effect_result_tx,
4079                                &event_proxy,
4080                                &async_registry,
4081                                &mut active_services,
4082                                &mut service_bindings,
4083                                &mut next_service_instance_id,
4084                            ) {
4085                                invalidations.mark_build();
4086                            }
4087                        }
4088                        let _ = response_tx.send(resp);
4089                        request_redraw_logged(
4090                            window,
4091                            elwt,
4092                            &mut last_redraw_at,
4093                            min_frame,
4094                            &mut redraw_pending,
4095                            &mut frame_trace,
4096                            "test_tap_text",
4097                        );
4098                    }
4099                    TestEvent::Screenshot { path, response_tx } => {
4100                        let Some(window) = platform_window.active_window() else {
4101                            let _ = response_tx.send(fission_test_driver::TestResponse::Error {
4102                                message: "window not ready".into(),
4103                            });
4104                            return;
4105                        };
4106                        pending_screenshot_path = Some(path);
4107                        pending_screenshot_response_tx = Some(response_tx);
4108                        pending_capture_settle = resize_is_unsettled(
4109                            pending_resize.is_some(),
4110                            resize_needs_settled_frame,
4111                            live_resize.is_live(Instant::now()),
4112                        );
4113                        window.request_redraw();
4114                    }
4115                    TestEvent::CaptureScreenshot { response_tx } => {
4116                        let Some(window) = platform_window.active_window() else {
4117                            let _ = response_tx.send(fission_test_driver::TestResponse::Error {
4118                                message: "window not ready".into(),
4119                            });
4120                            return;
4121                        };
4122                        pending_screenshot_path = Some("__capture__".into());
4123                        pending_screenshot_response_tx = Some(response_tx);
4124                        pending_capture_settle = resize_is_unsettled(
4125                            pending_resize.is_some(),
4126                            resize_needs_settled_frame,
4127                            live_resize.is_live(Instant::now()),
4128                        );
4129                        window.request_redraw();
4130                    }
4131                    TestEvent::GetText { response_tx } => {
4132                        let resp =
4133                            build_get_text_response(&pipeline, &runtime.runtime_state.scroll);
4134                        let _ = response_tx.send(resp);
4135                    }
4136                    TestEvent::GetTree { response_tx } => {
4137                        let resp =
4138                            build_get_tree_response(&pipeline, &runtime.runtime_state.scroll);
4139                        let _ = response_tx.send(resp);
4140                    }
4141                    TestEvent::Pump { response_tx } => {
4142                        let Some(window) = platform_window.active_window() else {
4143                            let _ = response_tx.send(fission_test_driver::TestResponse::Error {
4144                                message: "window not ready".into(),
4145                            });
4146                            return;
4147                        };
4148                        pending_screenshot_path = Some("__pump__".into());
4149                        pending_screenshot_response_tx = Some(response_tx);
4150                        pending_capture_settle = resize_is_unsettled(
4151                            pending_resize.is_some(),
4152                            resize_needs_settled_frame,
4153                            live_resize.is_live(Instant::now()),
4154                        );
4155                        window.request_redraw();
4156                    }
4157                    TestEvent::Wake => {
4158                        if let Some(window) = platform_window.active_window() {
4159                            request_redraw_logged(
4160                                window,
4161                                elwt,
4162                                &mut last_redraw_at,
4163                                min_frame,
4164                                &mut redraw_pending,
4165                                &mut frame_trace,
4166                                "wake",
4167                            );
4168                        }
4169                    }
4170                    TestEvent::Wait { ms: _, response_tx } => {
4171                        let _ = response_tx.send(fission_test_driver::TestResponse::Ok {});
4172                    }
4173                    TestEvent::Quit => {
4174                        elwt.exit();
4175                    }
4176                }
4177            };
4178
4179            #[cfg(target_os = "android")]
4180            let mut drain_pending_test_events = || loop {
4181                let pending = {
4182                    let mut pending = pending_test_events
4183                        .lock()
4184                        .expect("pending test events lock poisoned");
4185                    pending.pop_front()
4186                };
4187                let Some(test_event) = pending else {
4188                    break;
4189                };
4190                if debug_android_events {
4191                    eprintln!("[android-debug] draining_test_queue");
4192                }
4193                handle_test_event(test_event);
4194            };
4195
4196            match event {
4197                #[cfg(feature = "tray")]
4198                Event::NewEvents(StartCause::Init) => {
4199                    if active_tray.is_none() {
4200                        if let Some(config) = tray_config.clone() {
4201                            match tray::ActiveTray::build(config) {
4202                                Ok(tray) => {
4203                                    active_tray = Some(tray);
4204                                }
4205                                Err(error) => {
4206                                    eprintln!("Fission tray setup error: {error:?}");
4207                                }
4208                            }
4209                        }
4210                    }
4211                }
4212                Event::Resumed => {
4213                    if debug_android_events {
4214                        eprintln!("[android-events] resumed");
4215                    }
4216                    #[cfg(target_os = "android")]
4217                    if platform_window.is_none() {
4218                        match build_window(
4219                            &window_title,
4220                            background_test_mode,
4221                            elwt,
4222                            web_mount_selector.as_deref(),
4223                        ) {
4224                            Ok(new_window) => {
4225                                ime_handler.set_window(Some(new_window.clone()));
4226                                sync_window_cursor(&new_window, &runtime);
4227                                platform_window = Some(new_window);
4228                            }
4229                            Err(err) => {
4230                                eprintln!("window build error: {err}");
4231                                elwt.exit();
4232                                return;
4233                            }
4234                        }
4235                    }
4236                    let Some(window) = platform_window.active_window() else {
4237                        return;
4238                    };
4239                    let current_viewport = WindowViewportState::from_window(window);
4240                    #[cfg(not(target_os = "android"))]
4241                    {
4242                        window_viewport = current_viewport;
4243                    }
4244                    #[cfg(target_os = "android")]
4245                    {
4246                        window_viewport = Some(current_viewport);
4247                    }
4248                    #[cfg(not(target_arch = "wasm32"))]
4249                    if render_state.is_none()
4250                        && current_viewport.physical_size.width > 0
4251                        && current_viewport.physical_size.height > 0
4252                    {
4253                        if let Some(render_window) = platform_window.active_window_arc() {
4254                            match create_render_state(
4255                                &mut render_cx,
4256                                render_window,
4257                                current_viewport,
4258                            ) {
4259                                Ok(mut state) => {
4260                                    if let Err(err) = present_startup_clear_frame(
4261                                        &mut state,
4262                                        &render_cx,
4263                                        theme_background_wgpu_color(&env),
4264                                    ) {
4265                                        eprintln!("startup clear frame failed: {err}");
4266                                    }
4267                                    render_state = Some(state);
4268                                }
4269                                Err(err) => {
4270                                    eprintln!("render surface not ready on resume: {err}");
4271                                }
4272                            }
4273                        }
4274                    }
4275                    pending_resize = Some(current_viewport);
4276                    resize_needs_settled_frame = true;
4277                    if pending_screenshot_path.is_some() {
4278                        pending_capture_settle = true;
4279                    }
4280                    invalidations.mark_composite();
4281                    request_redraw_logged(
4282                        window,
4283                        elwt,
4284                        &mut last_redraw_at,
4285                        min_frame,
4286                        &mut redraw_pending,
4287                        &mut frame_trace,
4288                        "app_resumed",
4289                    );
4290                }
4291                Event::Suspended => {
4292                    #[cfg(not(target_arch = "wasm32"))]
4293                    {
4294                        render_state = None;
4295                    }
4296                    #[cfg(target_arch = "wasm32")]
4297                    {
4298                        web_renderer = None;
4299                        webgpu_init_in_flight = false;
4300                        *pending_webgpu_init.borrow_mut() = None;
4301                        web_renderer_reported = false;
4302                    }
4303                    #[cfg(target_os = "android")]
4304                    {
4305                        ime_handler.set_window(None);
4306                        platform_window = None;
4307                        window_viewport = None;
4308                        pending_resize = None;
4309                        resize_needs_settled_frame = false;
4310                        pending_capture_settle = false;
4311                        last_built_viewport = None;
4312                        last_cursor_position = None;
4313                        active_primary_touch = None;
4314                        touch_positions.clear();
4315                    }
4316                }
4317                // ═══════════════════════════════════════════════════════
4318                // UserEvent — injected by test control server via proxy
4319                // ═══════════════════════════════════════════════════════
4320                Event::UserEvent(test_event) => {
4321                    #[cfg(target_os = "android")]
4322                    if matches!(test_event, TestEvent::Wake) {
4323                        if debug_android_events {
4324                            eprintln!("[android-debug] wake_received");
4325                        }
4326                        drain_pending_test_events();
4327                        return;
4328                    }
4329                    handle_test_event(test_event)
4330                }
4331
4332                // ═══════════════════════════════════════════════════════
4333                // AboutToWait — idle / animation / blink / effects
4334                // ═══════════════════════════════════════════════════════
4335                Event::AboutToWait => {
4336                    let Some(window) = platform_window.active_window() else {
4337                        elwt.set_control_flow(ControlFlow::Wait);
4338                        return;
4339                    };
4340                    #[cfg(target_os = "android")]
4341                    drain_pending_test_events();
4342                    #[cfg(feature = "tray")]
4343                    if let (Some(rx), Some(active)) = (tray_event_rx.as_ref(), active_tray.as_ref())
4344                    {
4345                        while let Ok(event) = rx.try_recv() {
4346                            match active.handle_event(event, window, &mut runtime) {
4347                                Ok(outcome) => {
4348                                    if outcome.quit {
4349                                        elwt.exit();
4350                                        return;
4351                                    }
4352                                    if outcome.redraw {
4353                                        invalidations.mark_build();
4354                                        if process_pending_effects(
4355                                            &mut runtime,
4356                                            &effect_result_tx,
4357                                            &event_proxy,
4358                                            &async_registry,
4359                                            &mut active_services,
4360                                            &mut service_bindings,
4361                                            &mut next_service_instance_id,
4362                                        ) {
4363                                            invalidations.mark_build();
4364                                        }
4365                                        request_redraw_logged(
4366                                            window,
4367                                            elwt,
4368                                            &mut last_redraw_at,
4369                                            min_frame,
4370                                            &mut redraw_pending,
4371                                            &mut frame_trace,
4372                                            "tray_menu_action",
4373                                        );
4374                                    }
4375                                }
4376                                Err(error) => {
4377                                    eprintln!("Fission tray event error: {error:?}");
4378                                }
4379                            }
4380                        }
4381                    }
4382                    let now = Instant::now();
4383
4384                    // Video Logic
4385                    let mut surfaces = pipeline.video_surfaces.clone();
4386                    let mut active_nodes = std::collections::HashSet::new();
4387
4388                    for surface in &mut surfaces {
4389                        active_nodes.insert(surface.widget_id);
4390
4391                        // Create player if missing
4392                        if !players.contains_key(&surface.widget_id) {
4393                            if let Some(state) =
4394                                runtime.runtime_state.video.states.get(&surface.widget_id)
4395                            {
4396                                let source = &state.asset_source;
4397                                if !source.is_empty() {
4398                                    let player = video_backend.create_player(source);
4399                                    surface.surface_id = player.surface_id();
4400                                    if let Some(state) = runtime
4401                                        .runtime_state
4402                                        .video
4403                                        .states
4404                                        .get_mut(&surface.widget_id)
4405                                    {
4406                                        state.surface_id = Some(surface.surface_id);
4407                                    }
4408                                    players.insert(
4409                                        surface.widget_id,
4410                                        ActivePlayer {
4411                                            player,
4412                                            last_status: None,
4413                                            last_rate: None,
4414                                            last_volume: None,
4415                                            last_muted: None,
4416                                        },
4417                                    );
4418                                }
4419                            }
4420                        } else if let Some(active_player) = players.get(&surface.widget_id) {
4421                            surface.surface_id = active_player.player.surface_id();
4422                        }
4423                    }
4424
4425                    // Cleanup inactive players
4426                    players.retain(|id, _| active_nodes.contains(id));
4427
4428                    // Update backend
4429                    video_backend.present_surfaces(&surfaces);
4430                    let web_surfaces = pipeline.web_surfaces.clone();
4431                    web_backend.present_surfaces(&web_surfaces);
4432
4433                    // Video Logic - Process Player Events and Sync State
4434                    for (widget_id, active_player) in players.iter_mut() {
4435                        if let Some(video_state) =
4436                            runtime.runtime_state.video.states.get_mut(widget_id)
4437                        {
4438                            let player = &mut active_player.player;
4439
4440                            // Sync player controls from runtime state
4441                            if active_player.last_status != Some(video_state.status) {
4442                                match video_state.status {
4443                                    VideoStatus::Playing => player.play(),
4444                                    VideoStatus::Paused => player.pause(),
4445                                    VideoStatus::Stopped => player.stop(),
4446                                    _ => {}
4447                                }
4448                                active_player.last_status = Some(video_state.status);
4449                            }
4450
4451                            // Update runtime state from player events
4452                            for event in player.poll_events() {
4453                                match event {
4454                                    VideoEvent::Ready { duration } => {
4455                                        video_state.duration_ms = Some(duration);
4456                                        if video_state.status == VideoStatus::Playing {
4457                                            player.play();
4458                                        }
4459                                    }
4460                                    VideoEvent::Ended => {
4461                                        video_state.status = VideoStatus::Ended;
4462                                        active_player.last_status = Some(VideoStatus::Ended);
4463                                        request_redraw_logged(
4464                                            &window,
4465                                            elwt,
4466                                            &mut last_redraw_at,
4467                                            min_frame,
4468                                            &mut redraw_pending,
4469                                            &mut frame_trace,
4470                                            "video_ended",
4471                                        );
4472                                    }
4473                                    VideoEvent::Error(e) => {
4474                                        eprintln!(
4475                                            "Video playback error for {:?}: {:?}",
4476                                            widget_id, e
4477                                        );
4478                                        video_state.status = VideoStatus::Error;
4479                                        active_player.last_status = Some(VideoStatus::Error);
4480                                        request_redraw_logged(
4481                                            &window,
4482                                            elwt,
4483                                            &mut last_redraw_at,
4484                                            min_frame,
4485                                            &mut redraw_pending,
4486                                            &mut frame_trace,
4487                                            "video_error",
4488                                        );
4489                                    }
4490                                }
4491                            }
4492                            // Sync other properties
4493                            video_state.position_ms = player.position();
4494
4495                            if active_player.last_rate != Some(video_state.rate) {
4496                                player.set_rate(video_state.rate);
4497                                active_player.last_rate = Some(video_state.rate);
4498                            }
4499                            if active_player.last_volume != Some(video_state.volume) {
4500                                player.set_volume(video_state.volume);
4501                                active_player.last_volume = Some(video_state.volume);
4502                            }
4503                            if active_player.last_muted != Some(video_state.muted) {
4504                                player.set_muted(video_state.muted);
4505                                active_player.last_muted = Some(video_state.muted);
4506                            }
4507
4508                            if let Some(seek_pos) = video_state.pending_seek.take() {
4509                                player.seek_to(seek_pos);
4510                            }
4511                        }
4512                    }
4513
4514                    let has_finite_animation = runtime
4515                        .runtime_state
4516                        .motion
4517                        .active
4518                        .values()
4519                        .any(|anim| !anim.repeat);
4520                    let resize_unsettled = resize_is_unsettled(
4521                        pending_resize.is_some(),
4522                        resize_needs_settled_frame,
4523                        live_resize.is_live(now),
4524                    );
4525                    let repeat_animation_interval = if resize_unsettled || pending_capture_settle {
4526                        None
4527                    } else {
4528                        repeating_animation_redraw_interval(
4529                            &runtime.runtime_state.motion,
4530                            repeat_animation_frame,
4531                        )
4532                    };
4533                    let has_playing_video = players.iter().any(|(widget_id, _)| {
4534                        runtime
4535                            .runtime_state
4536                            .video
4537                            .states
4538                            .get(widget_id)
4539                            .map(|state| state.status == VideoStatus::Playing)
4540                            .unwrap_or(false)
4541                    });
4542                    let animation_frame = animation_redraw_interval(
4543                        has_finite_animation,
4544                        repeat_animation_interval,
4545                        has_playing_video,
4546                        min_frame,
4547                    );
4548
4549                    ime_handler.set_text_input_config(focused_text_input_config(
4550                        &runtime,
4551                        pipeline.prev_ir.as_ref(),
4552                    ));
4553                    let focused_text_input =
4554                        focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
4555                    if focused_text_input != blink_focus_id {
4556                        if let Some(prev) = blink_focus_id {
4557                            runtime.runtime_state.caret_visible.remove(&prev);
4558                        }
4559                        blink_focus_id = focused_text_input;
4560                        if let Some(id) = blink_focus_id {
4561                            runtime.runtime_state.caret_visible.insert(id, true);
4562                            last_blink_toggle = now;
4563                            invalidations.mark_build();
4564                            request_redraw_logged(
4565                                &window,
4566                                elwt,
4567                                &mut last_redraw_at,
4568                                min_frame,
4569                                &mut redraw_pending,
4570                                &mut frame_trace,
4571                                "caret_focus_changed",
4572                            );
4573                        }
4574                    }
4575
4576                    // Cursor blink: toggle visibility and request a redraw.
4577                    if blink_enabled {
4578                        if let Some(id) = blink_focus_id {
4579                            if now.duration_since(last_blink_toggle) >= blink_period {
4580                                let visible = runtime
4581                                    .runtime_state
4582                                    .caret_visible
4583                                    .get(&id)
4584                                    .copied()
4585                                    .unwrap_or(true);
4586                                runtime.runtime_state.caret_visible.insert(id, !visible);
4587                                last_blink_toggle = now;
4588                                invalidations.mark_build();
4589                                request_redraw_logged(
4590                                    &window,
4591                                    elwt,
4592                                    &mut last_redraw_at,
4593                                    min_frame,
4594                                    &mut redraw_pending,
4595                                    &mut frame_trace,
4596                                    "caret_blink",
4597                                );
4598                            }
4599                        }
4600                    }
4601
4602                    let blink_wake_at = if blink_enabled && blink_focus_id.is_some() {
4603                        Some(last_blink_toggle + blink_period)
4604                    } else {
4605                        None
4606                    };
4607
4608                    // Drain completed background effect results and dispatch
4609                    // their continuations back into the runtime on the main thread.
4610                    let effect_results_dispatched = drain_effect_results(
4611                        &mut runtime,
4612                        &effect_result_rx,
4613                        &mut active_services,
4614                        &mut service_bindings,
4615                    );
4616                    if effect_results_dispatched {
4617                        invalidations.mark_build();
4618                        // Background work completed — process any new effects
4619                        // the continuation reducers may have emitted.
4620                        if process_pending_effects(
4621                            &mut runtime,
4622                            &effect_result_tx,
4623                            &event_proxy,
4624                            &async_registry,
4625                            &mut active_services,
4626                            &mut service_bindings,
4627                            &mut next_service_instance_id,
4628                        ) {
4629                            invalidations.mark_build();
4630                            request_redraw_logged(
4631                                &window,
4632                                elwt,
4633                                &mut last_redraw_at,
4634                                min_frame,
4635                                &mut redraw_pending,
4636                                &mut frame_trace,
4637                                "effect_continuation",
4638                            );
4639                        }
4640                        request_redraw_logged(
4641                            &window,
4642                            elwt,
4643                            &mut last_redraw_at,
4644                            min_frame,
4645                            &mut redraw_pending,
4646                            &mut frame_trace,
4647                            "effect_result",
4648                        );
4649                    }
4650
4651                    // Application frame hook (e.g. LSP polling).
4652                    let frame_hook_wants_redraw = if let Some(ref hook) = self.frame_hook {
4653                        let hook = hook.clone();
4654                        if let Some(state) = runtime.get_global_state_mut::<S>() {
4655                            hook(state)
4656                        } else {
4657                            false
4658                        }
4659                    } else {
4660                        false
4661                    };
4662                    if frame_hook_wants_redraw {
4663                        invalidations.mark_build();
4664                        request_redraw_logged(
4665                            &window,
4666                            elwt,
4667                            &mut last_redraw_at,
4668                            min_frame,
4669                            &mut redraw_pending,
4670                            &mut frame_trace,
4671                            "frame_hook",
4672                        );
4673                    }
4674
4675                    let next_vello_image_generation =
4676                        fission_render_vello::image_cache_generation();
4677                    let next_software_image_generation =
4678                        software_renderer::image_cache_generation();
4679                    let image_cache_changed = next_vello_image_generation
4680                        != vello_image_cache_generation
4681                        || next_software_image_generation != software_image_cache_generation;
4682                    if image_cache_changed {
4683                        vello_image_cache_generation = next_vello_image_generation;
4684                        software_image_cache_generation = next_software_image_generation;
4685                        #[cfg(not(target_arch = "wasm32"))]
4686                        retained_scene_cache.clear();
4687                        #[cfg(target_arch = "wasm32")]
4688                        if let Some(WebRenderer::WebGpu(presenter)) = web_renderer.as_mut() {
4689                            presenter.retained_scene_cache.clear();
4690                        }
4691                        invalidations.mark_paint();
4692                        let stats = fission_render_vello::image_cache_stats();
4693                        diag::emit(
4694                            diag::DiagCategory::Raster,
4695                            diag::DiagLevel::Debug,
4696                            diag::DiagEventKind::ImageCacheSummary {
4697                                renderer: "vello".to_string(),
4698                                entries: stats.entries,
4699                                weighted_bytes: stats.weighted_bytes,
4700                                max_bytes: stats.max_bytes,
4701                                pending: stats.pending,
4702                                hits: stats.hits,
4703                                misses: stats.misses,
4704                                loads_started: stats.loads_started,
4705                                loads_completed: stats.loads_completed,
4706                                loads_failed: stats.loads_failed,
4707                                evictions: stats.evictions,
4708                                offscreen_skips: stats.offscreen_skips,
4709                            },
4710                        );
4711                        let stats = software_renderer::image_cache_stats();
4712                        diag::emit(
4713                            diag::DiagCategory::Raster,
4714                            diag::DiagLevel::Debug,
4715                            diag::DiagEventKind::ImageCacheSummary {
4716                                renderer: "software".to_string(),
4717                                entries: stats.entries,
4718                                weighted_bytes: stats.weighted_bytes,
4719                                max_bytes: stats.max_bytes,
4720                                pending: stats.pending,
4721                                hits: stats.hits,
4722                                misses: stats.misses,
4723                                loads_started: stats.loads_started,
4724                                loads_completed: stats.loads_completed,
4725                                loads_failed: stats.loads_failed,
4726                                evictions: stats.evictions,
4727                                offscreen_skips: 0,
4728                            },
4729                        );
4730                        request_redraw_logged(
4731                            &window,
4732                            elwt,
4733                            &mut last_redraw_at,
4734                            min_frame,
4735                            &mut redraw_pending,
4736                            &mut frame_trace,
4737                            "image_cache",
4738                        );
4739                    }
4740                    let image_cache_pending = fission_render_vello::image_cache_has_pending()
4741                        || software_renderer::image_cache_has_pending();
4742
4743                    // When a frame_hook is registered, ensure the event loop
4744                    // wakes at least every 2 seconds so the hook fires even
4745                    // when no user input or animation is happening (e.g. for
4746                    // asynchronous LSP diagnostics).
4747                    let frame_hook_wake_at = if self.frame_hook.is_some() {
4748                        Some(now + Duration::from_secs(2))
4749                    } else {
4750                        None
4751                    };
4752
4753                    let has_pending_work = effect_results_dispatched
4754                        || frame_hook_wants_redraw
4755                        || image_cache_changed
4756                        || invalidations.any()
4757                        || resize_unsettled
4758                        || pending_capture_settle;
4759                    let active_keys = active_animation_keys(&runtime);
4760
4761                    if has_pending_work {
4762                        let pending_frame = pending_work_redraw_interval(
4763                            invalidations,
4764                            resize_unsettled || pending_capture_settle,
4765                            min_frame,
4766                            resize_frame,
4767                        );
4768                        let redraw_reason = if resize_unsettled {
4769                            "pending_resize"
4770                        } else if pending_capture_settle {
4771                            "pending_capture_settle"
4772                        } else if invalidations.build {
4773                            "pending_work:build"
4774                        } else if invalidations.layout {
4775                            "pending_work:layout"
4776                        } else if invalidations.paint {
4777                            "pending_work:paint"
4778                        } else if invalidations.composite {
4779                            "pending_work:composite"
4780                        } else if effect_results_dispatched {
4781                            "pending_work:effects"
4782                        } else if frame_hook_wants_redraw {
4783                            "pending_work:frame_hook"
4784                        } else {
4785                            "pending_work"
4786                        };
4787                        request_redraw_logged(
4788                            &window,
4789                            elwt,
4790                            &mut last_redraw_at,
4791                            pending_frame,
4792                            &mut redraw_pending,
4793                            &mut frame_trace,
4794                            redraw_reason,
4795                        );
4796                        let reasons = frame_trace.take_redraw_reasons();
4797                        frame_trace.emit(
4798                                "about_to_wait",
4799                                presented_frames + 1,
4800                                &active_keys,
4801                                invalidations,
4802                                &reasons,
4803                                &format!(
4804                                    "schedule=pending interval_ms={} pending_resize={} redraw_pending={} highest={}",
4805                                    pending_frame.as_millis(),
4806                                    resize_unsettled || pending_capture_settle,
4807                                    redraw_pending,
4808                                    invalidations.highest_class(),
4809                                ),
4810                            );
4811                        let mut wake_at = last_redraw_at + pending_frame;
4812                        if let Some(blink_at) = blink_wake_at {
4813                            if blink_at < wake_at {
4814                                wake_at = blink_at;
4815                            }
4816                        }
4817                        if let Some(hook_at) = frame_hook_wake_at {
4818                            if hook_at < wake_at {
4819                                wake_at = hook_at;
4820                            }
4821                        }
4822                        elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4823                    } else if let Some(animation_frame) = animation_frame {
4824                        request_redraw_logged(
4825                            &window,
4826                            elwt,
4827                            &mut last_redraw_at,
4828                            animation_frame,
4829                            &mut redraw_pending,
4830                            &mut frame_trace,
4831                            if has_finite_animation {
4832                                "animation:finite"
4833                            } else if has_playing_video {
4834                                "animation:video"
4835                            } else {
4836                                "animation:repeat"
4837                            },
4838                        );
4839                        let reasons = frame_trace.take_redraw_reasons();
4840                        frame_trace.emit(
4841                                "about_to_wait",
4842                                presented_frames + 1,
4843                                &active_keys,
4844                                invalidations,
4845                                &reasons,
4846                                &format!(
4847                                    "schedule=animation interval_ms={} pending_resize={} redraw_pending={} highest={}",
4848                                    animation_frame.as_millis(),
4849                                    resize_unsettled || pending_capture_settle,
4850                                    redraw_pending,
4851                                    invalidations.highest_class(),
4852                                ),
4853                            );
4854                        let mut wake_at = last_redraw_at + animation_frame;
4855                        if let Some(blink_at) = blink_wake_at {
4856                            if blink_at < wake_at {
4857                                wake_at = blink_at;
4858                            }
4859                        }
4860                        if let Some(hook_at) = frame_hook_wake_at {
4861                            if hook_at < wake_at {
4862                                wake_at = hook_at;
4863                            }
4864                        }
4865                        elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4866                    } else if image_cache_pending {
4867                        let wake_at = now + Duration::from_millis(50);
4868                        elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4869                    } else if let Some(blink_at) = blink_wake_at {
4870                        let reasons = frame_trace.take_redraw_reasons();
4871                        frame_trace.emit(
4872                                "about_to_wait",
4873                                presented_frames + 1,
4874                                &active_keys,
4875                                invalidations,
4876                                &reasons,
4877                                "schedule=blink_wait pending_resize=false redraw_pending=false highest=none",
4878                            );
4879                        let mut wake_at = blink_at;
4880                        if let Some(hook_at) = frame_hook_wake_at {
4881                            if hook_at < wake_at {
4882                                wake_at = hook_at;
4883                            }
4884                        }
4885                        elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4886                    } else if let Some(hook_at) = frame_hook_wake_at {
4887                        let reasons = frame_trace.take_redraw_reasons();
4888                        frame_trace.emit(
4889                                "about_to_wait",
4890                                presented_frames + 1,
4891                                &active_keys,
4892                                invalidations,
4893                                &reasons,
4894                                "schedule=hook_wait pending_resize=false redraw_pending=false highest=none",
4895                            );
4896                        elwt.set_control_flow(ControlFlow::WaitUntil(hook_at));
4897                    } else {
4898                        let reasons = frame_trace.take_redraw_reasons();
4899                        frame_trace.emit(
4900                            "about_to_wait",
4901                            presented_frames + 1,
4902                            &active_keys,
4903                            invalidations,
4904                            &reasons,
4905                            "schedule=idle pending_resize=false redraw_pending=false highest=none",
4906                        );
4907                        #[cfg(target_os = "android")]
4908                        if test_control_enabled {
4909                            elwt.set_control_flow(ControlFlow::Poll);
4910                        } else {
4911                            elwt.set_control_flow(ControlFlow::WaitUntil(
4912                                now + Duration::from_millis(16),
4913                            ));
4914                        }
4915                        #[cfg(not(target_os = "android"))]
4916                        elwt.set_control_flow(ControlFlow::Wait);
4917                    }
4918                }
4919
4920                // ═══════════════════════════════════════════════════════
4921                // WindowEvent — real user interaction
4922                // ═══════════════════════════════════════════════════════
4923                Event::WindowEvent { window_id, event }
4924                    if platform_window.active_window_id() == Some(window_id) =>
4925                {
4926                    let Some(window) = platform_window.active_window() else {
4927                        return;
4928                    };
4929                    match event {
4930                        WindowEvent::Resized(size) => {
4931                            if size.width > 0 && size.height > 0 {
4932                                #[cfg(target_os = "ios")]
4933                                let next_viewport = WindowViewportState::from_window(window);
4934                                #[cfg(not(target_os = "ios"))]
4935                                let next_viewport = pending_resize
4936                                    .unwrap_or_else(|| WindowViewportState::from_window(window))
4937                                    .with_physical_size(size);
4938                                #[cfg(not(target_os = "android"))]
4939                                {
4940                                    window_viewport = next_viewport;
4941                                }
4942                                #[cfg(target_os = "android")]
4943                                {
4944                                    window_viewport = Some(next_viewport);
4945                                }
4946                                apply_authoritative_resize(
4947                                    &window,
4948                                    elwt,
4949                                    next_viewport,
4950                                    &mut pending_resize,
4951                                    &mut resize_needs_settled_frame,
4952                                    &mut pending_capture_settle,
4953                                    pending_screenshot_path.as_deref(),
4954                                    &mut live_resize,
4955                                    &mut invalidations,
4956                                    &mut last_redraw_at,
4957                                    resize_frame,
4958                                    &mut redraw_pending,
4959                                    &mut frame_trace,
4960                                    "window_resized",
4961                                );
4962                            }
4963                        }
4964                        WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
4965                            #[cfg(target_os = "ios")]
4966                            let _ = scale_factor;
4967                            #[cfg(target_os = "ios")]
4968                            let next_viewport = WindowViewportState::from_window(window);
4969                            #[cfg(not(target_os = "ios"))]
4970                            let next_viewport = pending_resize
4971                                .unwrap_or_else(|| WindowViewportState::from_window(window))
4972                                .with_scale_factor(scale_factor);
4973                            #[cfg(not(target_os = "android"))]
4974                            {
4975                                window_viewport = next_viewport;
4976                            }
4977                            #[cfg(target_os = "android")]
4978                            {
4979                                window_viewport = Some(next_viewport);
4980                            }
4981                            apply_authoritative_resize(
4982                                &window,
4983                                elwt,
4984                                next_viewport,
4985                                &mut pending_resize,
4986                                &mut resize_needs_settled_frame,
4987                                &mut pending_capture_settle,
4988                                pending_screenshot_path.as_deref(),
4989                                &mut live_resize,
4990                                &mut invalidations,
4991                                &mut last_redraw_at,
4992                                resize_frame,
4993                                &mut redraw_pending,
4994                                &mut frame_trace,
4995                                "scale_factor_changed",
4996                            );
4997                        }
4998                        WindowEvent::RedrawRequested => {
4999                            if debug_android_events {
5000                                eprintln!("[android-events] redraw_requested");
5001                            }
5002                            redraw_pending = false;
5003                            diag::begin_frame(None);
5004                            let now = Instant::now();
5005                            let dt = now.duration_since(last_frame_time);
5006                            last_frame_time = now;
5007                            let dt_ms = dt.as_millis() as u64;
5008                            let pre_tick_active = active_animation_keys(&runtime);
5009                            match runtime.tick(dt_ms) {
5010                                Ok(tick_result) => {
5011                                    let tick_invalidations = pipeline
5012                                        .classify_animation_updates(&tick_result.changed_motions);
5013                                    invalidations.merge(tick_invalidations);
5014                                    let reasons = if tick_result.changed_motions.is_empty() {
5015                                        Vec::new()
5016                                    } else {
5017                                        tick_result
5018                                            .changed_motions
5019                                            .iter()
5020                                            .map(|(target, property)| {
5021                                                format!(
5022                                                    "tick:{}:{:?}:{}",
5023                                                    target.as_u128(),
5024                                                    property,
5025                                                    tick_invalidations.highest_class()
5026                                                )
5027                                            })
5028                                            .collect::<Vec<_>>()
5029                                    };
5030                                    frame_trace.emit(
5031                                        "redraw_requested",
5032                                        presented_frames + 1,
5033                                        &pre_tick_active,
5034                                        tick_invalidations,
5035                                        &reasons,
5036                                        &format!("dt_ms={}", dt_ms),
5037                                    );
5038                                }
5039                                Err(e) => {
5040                                    eprintln!("Runtime tick error: {:?}", e);
5041                                }
5042                            }
5043                            if process_pending_effects(
5044                                &mut runtime,
5045                                &effect_result_tx,
5046                                &event_proxy,
5047                                &async_registry,
5048                                &mut active_services,
5049                                &mut service_bindings,
5050                                &mut next_service_instance_id,
5051                            ) {
5052                                invalidations.mark_build();
5053                                request_redraw_logged(
5054                                    &window,
5055                                    elwt,
5056                                    &mut last_redraw_at,
5057                                    min_frame,
5058                                    &mut redraw_pending,
5059                                    &mut frame_trace,
5060                                    "redraw:effects",
5061                                );
5062                            }
5063                            let viewport_state = pending_resize.unwrap_or_else(|| {
5064                                #[cfg(not(target_os = "android"))]
5065                                {
5066                                    window_viewport
5067                                }
5068                                #[cfg(target_os = "android")]
5069                                {
5070                                    window_viewport
5071                                        .unwrap_or_else(|| WindowViewportState::from_window(window))
5072                                }
5073                            });
5074                            #[cfg(not(target_os = "android"))]
5075                            {
5076                                window_viewport = viewport_state;
5077                            }
5078                            #[cfg(target_os = "android")]
5079                            {
5080                                window_viewport = Some(viewport_state);
5081                            }
5082                            let swapchain_size = viewport_state.physical_size;
5083                            if swapchain_size.width == 0 || swapchain_size.height == 0 {
5084                                diag::end_frame(diag::FrameStats::default());
5085                                return;
5086                            }
5087
5088                            let scale_factor = viewport_state.scale_factor;
5089                            let pending_layout_viewport = viewport_state.logical_size();
5090                            let render_target_size = (swapchain_size.width, swapchain_size.height);
5091
5092                            #[cfg(target_arch = "wasm32")]
5093                            if web_renderer.is_none() {
5094                                let request = web_renderer_request();
5095                                if matches!(request, RendererRequest::Canvas2dSoftware) {
5096                                    match WebCanvasPresenter::new(window) {
5097                                        Ok(mut presenter) => {
5098                                            presenter.report = RendererReport::new(
5099                                                "canvas2d-software",
5100                                                request,
5101                                                None,
5102                                                None,
5103                                                Some("forced_by_renderer_request".to_string()),
5104                                                render_target_size.0,
5105                                                render_target_size.1,
5106                                                scale_factor,
5107                                            );
5108                                            web_renderer = Some(WebRenderer::Canvas2d(presenter));
5109                                        }
5110                                        Err(err) => {
5111                                            eprintln!("web canvas not ready yet: {err}");
5112                                            request_redraw_logged(
5113                                                &window,
5114                                                elwt,
5115                                                &mut last_redraw_at,
5116                                                min_frame,
5117                                                &mut redraw_pending,
5118                                                &mut frame_trace,
5119                                                "web_canvas_pending",
5120                                            );
5121                                            diag::end_frame(diag::FrameStats::default());
5122                                            return;
5123                                        }
5124                                    }
5125                                } else if let Some(result) = pending_webgpu_init.borrow_mut().take()
5126                                {
5127                                    match result {
5128                                        Ok(presenter) => {
5129                                            web_renderer = Some(WebRenderer::WebGpu(presenter));
5130                                        }
5131                                        Err(error) if request.is_explicit_gpu() => {
5132                                            eprintln!(
5133                                                    "webgpu-vello renderer requested but initialization failed: {error}"
5134                                                );
5135                                            diag::end_frame(diag::FrameStats::default());
5136                                            panic!(
5137                                                    "webgpu-vello renderer requested but initialization failed: {error}"
5138                                                );
5139                                        }
5140                                        Err(error) => match WebCanvasPresenter::new(window) {
5141                                            Ok(mut presenter) => {
5142                                                presenter.report = RendererReport::new(
5143                                                    "canvas2d-software",
5144                                                    request,
5145                                                    None,
5146                                                    None,
5147                                                    Some(format!(
5148                                                        "webgpu_vello_init_failed:{error}"
5149                                                    )),
5150                                                    render_target_size.0,
5151                                                    render_target_size.1,
5152                                                    scale_factor,
5153                                                );
5154                                                web_renderer =
5155                                                    Some(WebRenderer::Canvas2d(presenter));
5156                                            }
5157                                            Err(err) => {
5158                                                eprintln!(
5159                                                        "web renderer fallback failed; webgpu error: {error}; canvas error: {err}"
5160                                                    );
5161                                                request_redraw_logged(
5162                                                    &window,
5163                                                    elwt,
5164                                                    &mut last_redraw_at,
5165                                                    min_frame,
5166                                                    &mut redraw_pending,
5167                                                    &mut frame_trace,
5168                                                    "web_canvas_pending",
5169                                                );
5170                                                diag::end_frame(diag::FrameStats::default());
5171                                                return;
5172                                            }
5173                                        },
5174                                    }
5175                                } else {
5176                                    if !webgpu_init_in_flight {
5177                                        match window.canvas() {
5178                                            Some(canvas) => {
5179                                                let pending = pending_webgpu_init.clone();
5180                                                let proxy = event_proxy.clone();
5181                                                let init_viewport = viewport_state;
5182                                                webgpu_init_in_flight = true;
5183                                                wasm_bindgen_futures::spawn_local(async move {
5184                                                    let result = create_webgpu_presenter(
5185                                                        canvas,
5186                                                        init_viewport,
5187                                                        request,
5188                                                    )
5189                                                    .await
5190                                                    .map_err(|error| error.to_string());
5191                                                    *pending.borrow_mut() = Some(result);
5192                                                    let _ = proxy.send_event(TestEvent::Wake);
5193                                                });
5194                                            }
5195                                            None => {
5196                                                eprintln!("web canvas not ready yet");
5197                                            }
5198                                        }
5199                                    }
5200                                    request_redraw_logged(
5201                                        &window,
5202                                        elwt,
5203                                        &mut last_redraw_at,
5204                                        min_frame,
5205                                        &mut redraw_pending,
5206                                        &mut frame_trace,
5207                                        "webgpu_renderer_pending",
5208                                    );
5209                                    diag::end_frame(diag::FrameStats::default());
5210                                    return;
5211                                }
5212
5213                                if !web_renderer_reported {
5214                                    if let Some(renderer) = web_renderer.as_ref() {
5215                                        publish_web_renderer_report(renderer.report());
5216                                        web_renderer_reported = true;
5217                                    }
5218                                }
5219                            }
5220
5221                            #[cfg(not(target_arch = "wasm32"))]
5222                            {
5223                                if render_state.is_none() {
5224                                    let Some(render_window) = platform_window.active_window_arc()
5225                                    else {
5226                                        diag::end_frame(diag::FrameStats::default());
5227                                        return;
5228                                    };
5229                                    match create_render_state(
5230                                        &mut render_cx,
5231                                        render_window,
5232                                        viewport_state,
5233                                    ) {
5234                                        Ok(state) => {
5235                                            render_state = Some(state);
5236                                        }
5237                                        Err(err) => {
5238                                            eprintln!("render surface not ready yet: {err}");
5239                                            request_redraw_logged(
5240                                                &window,
5241                                                elwt,
5242                                                &mut last_redraw_at,
5243                                                min_frame,
5244                                                &mut redraw_pending,
5245                                                &mut frame_trace,
5246                                                "render_surface_pending",
5247                                            );
5248                                            diag::end_frame(diag::FrameStats::default());
5249                                            return;
5250                                        }
5251                                    }
5252                                }
5253                                let render_state = render_state.as_mut().expect("render state");
5254
5255                                let mut surface_target_replaced = false;
5256                                if swapchain_size.width != render_state.surface.config.width
5257                                    || swapchain_size.height != render_state.surface.config.height
5258                                {
5259                                    render_cx.resize_surface(
5260                                        &mut render_state.surface,
5261                                        swapchain_size.width,
5262                                        swapchain_size.height,
5263                                    );
5264                                    let device_handle =
5265                                        &render_cx.devices[render_state.surface.dev_id];
5266                                    render_state.surface.config.alpha_mode =
5267                                        wgpu::CompositeAlphaMode::PostMultiplied;
5268                                    render_state.surface.surface.configure(
5269                                        &device_handle.device,
5270                                        &render_state.surface.config,
5271                                    );
5272                                    sync_tracked_target_texture_size_to_surface(
5273                                        &mut render_state.target_texture_size,
5274                                        swapchain_size,
5275                                    );
5276                                    surface_target_replaced = true;
5277                                }
5278                                if surface_target_replaced
5279                                    || render_target_size != render_state.target_texture_size
5280                                {
5281                                    recreate_target_texture(
5282                                        &mut render_state.surface,
5283                                        &render_cx,
5284                                        render_target_size.0,
5285                                        render_target_size.1,
5286                                    );
5287                                    #[cfg(feature = "three-d")]
5288                                    {
5289                                        let device_handle =
5290                                            &render_cx.devices[render_state.surface.dev_id];
5291                                        // Keep the 3D depth target in lockstep with the shared render target.
5292                                        render_state.scene3d_renderer.resize(
5293                                            &device_handle.device,
5294                                            render_target_size.0,
5295                                            render_target_size.1,
5296                                        );
5297                                    }
5298                                    render_state.target_texture_size = render_target_size;
5299                                }
5300                            }
5301
5302                            let resize_settled =
5303                                resize_needs_settled_frame && !live_resize.is_live(now);
5304                            let target_viewport = pending_layout_viewport;
5305                            let build_viewport = resolve_build_viewport(
5306                                last_built_viewport,
5307                                target_viewport,
5308                                pipeline.prev_ir.is_some(),
5309                                &mut invalidations,
5310                            );
5311                            env.viewport_size = build_viewport;
5312                            env.window_insets =
5313                                window_safe_area_insets(window, viewport_state.scale_factor);
5314
5315                            if let Some(sync) = &self.sync_env {
5316                                let state = runtime.get_global_state::<S>().unwrap();
5317                                sync(state, &mut env);
5318                            }
5319                            let desired_window_title = env.window.title.plain_text();
5320                            if desired_window_title != applied_window_title {
5321                                if let Some(window) = platform_window.active_window() {
5322                                    window.set_title(desired_window_title);
5323                                }
5324                                applied_window_title = desired_window_title.to_string();
5325                            }
5326
5327                            if invalidations.build || pipeline.prev_ir.is_none() {
5328                                let (
5329                                    node_tree,
5330                                    registry,
5331                                    resources,
5332                                    motion_declarations,
5333                                    videos,
5334                                    web_views,
5335                                    portals,
5336                                ) = {
5337                                    let state = runtime.get_global_state::<S>().unwrap();
5338                                    let view = View::new(
5339                                        state,
5340                                        &runtime.runtime_state,
5341                                        &env,
5342                                        pipeline.last_snapshot.as_ref(),
5343                                    );
5344                                    let mut ctx = BuildCtx::new();
5345                                    let node = fission_core::build::enter(&mut ctx, &view, || {
5346                                        root_widget.clone().into()
5347                                    });
5348                                    let resources = ctx.take_resources();
5349                                    let motion_declarations = ctx.take_motion_declarations();
5350                                    let videos = ctx.take_video_registrations();
5351                                    let web_views = ctx.take_web_registrations();
5352                                    let portals_with_ids = ctx.take_portals();
5353
5354                                    let portals = portals_with_ids
5355                                        .into_iter()
5356                                        .map(|(id, node)| wrap_portal_for_viewport(id, node, &env))
5357                                        .collect::<Vec<_>>();
5358
5359                                    diag::emit(
5360                                        diag::DiagCategory::Layout,
5361                                        diag::DiagLevel::Debug,
5362                                        diag::DiagEventKind::PortalsComposed {
5363                                            portal_count: portals.len() as u32,
5364                                        },
5365                                    );
5366                                    (
5367                                        node,
5368                                        ctx.registry,
5369                                        resources,
5370                                        motion_declarations,
5371                                        videos,
5372                                        web_views,
5373                                        portals,
5374                                    )
5375                                };
5376
5377                                #[cfg(feature = "tray")]
5378                                let tray_registry = if let Some(tray) = active_tray.as_mut() {
5379                                    match tray.refresh_menu(&runtime, &env, &pipeline) {
5380                                        Ok(registry) => Some(registry),
5381                                        Err(err) => {
5382                                            eprintln!("Runtime tray menu rebuild error: {:?}", err);
5383                                            None
5384                                        }
5385                                    }
5386                                } else {
5387                                    None
5388                                };
5389
5390                                runtime.clear_reducers();
5391                                runtime.absorb_registry(registry);
5392                                #[cfg(feature = "tray")]
5393                                if let Some(registry) = tray_registry {
5394                                    runtime.absorb_registry(registry);
5395                                }
5396                                if let Err(err) = runtime.reconcile_resources(resources) {
5397                                    eprintln!("Runtime resource reconciliation error: {:?}", err);
5398                                }
5399                                if !startup_dispatched {
5400                                    if let Some(action) = startup_action.clone() {
5401                                        if let Err(err) =
5402                                            runtime.dispatch(action, WidgetId::from_u128(0))
5403                                        {
5404                                            eprintln!("Startup action error: {:?}", err);
5405                                        }
5406                                    }
5407                                    startup_dispatched = true;
5408                                }
5409                                runtime.sync_motion_declarations(
5410                                    &motion_declarations,
5411                                    pipeline.last_snapshot.as_ref(),
5412                                );
5413                                runtime.sync_video_nodes(&videos);
5414                                runtime.sync_web_nodes(&web_views);
5415
5416                                let final_root: fission_core::Widget = fission_core::ui::Overlay {
5417                                    id: None,
5418                                    content: node_tree,
5419                                    overlay: fission_core::ui::ZStack {
5420                                        children: portals,
5421                                        ..Default::default()
5422                                    }
5423                                    .into(),
5424                                }
5425                                .into();
5426
5427                                let mut lower_cx = InternalLoweringCx::new(
5428                                    &env,
5429                                    &runtime.runtime_state,
5430                                    runtime.measurer.as_ref(),
5431                                    pipeline.last_snapshot.as_ref(),
5432                                );
5433                                let root_id = fission_core::internal::lower_widget(
5434                                    &final_root,
5435                                    &mut lower_cx,
5436                                );
5437                                lower_cx.ir.root = Some(root_id);
5438
5439                                let pipeline_invalidations = pipeline.replace_ir(lower_cx.ir, &env);
5440                                invalidations.merge(pipeline_invalidations);
5441                                last_built_viewport = Some(build_viewport);
5442                            }
5443
5444                            let layout_updates = match pipeline.ensure_layout(
5445                                LayoutRect::new(
5446                                    0.0,
5447                                    0.0,
5448                                    target_viewport.width,
5449                                    target_viewport.height,
5450                                ),
5451                                &mut layout_engine,
5452                                &runtime.runtime_state.scroll,
5453                            ) {
5454                                Ok(updates) => updates,
5455                                Err(e) => {
5456                                    eprintln!("Layout error: {:?}", e);
5457                                    diag::end_frame(diag::FrameStats::default());
5458                                    return;
5459                                }
5460                            };
5461
5462                            if layout_updates > 0 {
5463                                if let (Some(ir), Some(layout)) =
5464                                    (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref())
5465                                {
5466                                    runtime.post_layout_hook(ir, layout);
5467                                }
5468                            }
5469
5470                            match pipeline.prepare_current(
5471                                target_viewport,
5472                                target_viewport,
5473                                false,
5474                                &runtime.runtime_state.scroll,
5475                                &runtime.runtime_state.motion,
5476                                &runtime.runtime_state.video,
5477                                &runtime.runtime_state.web,
5478                            ) {
5479                                Ok(_stats) => {
5480                                    #[cfg(target_arch = "wasm32")]
5481                                    {
5482                                        let Some(renderer) = web_renderer.as_mut() else {
5483                                            eprintln!("web renderer is unavailable");
5484                                            diag::end_frame(diag::FrameStats::default());
5485                                            return;
5486                                        };
5487                                        let active_renderer = renderer.active_name().to_string();
5488                                        match renderer {
5489                                            WebRenderer::Canvas2d(presenter) => {
5490                                                let retained_scene = pipeline
5491                                                    .retained_scene()
5492                                                    .expect(
5493                                                    "retained render scene missing before render",
5494                                                );
5495                                                let rgba = SoftwareRenderer::render(
5496                                                    retained_scene,
5497                                                    render_target_size.0,
5498                                                    render_target_size.1,
5499                                                    fission_render::Color {
5500                                                        r: env.theme.tokens.colors.background.r,
5501                                                        g: env.theme.tokens.colors.background.g,
5502                                                        b: env.theme.tokens.colors.background.b,
5503                                                        a: env.theme.tokens.colors.background.a,
5504                                                    },
5505                                                    scale_factor as f32,
5506                                                )
5507                                                .expect("failed to rasterize software web frame");
5508
5509                                                if let Err(err) = presenter.present(
5510                                                    &rgba,
5511                                                    render_target_size.0,
5512                                                    render_target_size.1,
5513                                                    scale_factor,
5514                                                ) {
5515                                                    eprintln!(
5516                                                        "failed to present web canvas frame: {err}"
5517                                                    );
5518                                                    diag::end_frame(diag::FrameStats::default());
5519                                                    return;
5520                                                }
5521                                            }
5522                                            WebRenderer::WebGpu(presenter) => {
5523                                                if swapchain_size.width
5524                                                    != presenter.render_state.surface.config.width
5525                                                    || swapchain_size.height
5526                                                        != presenter
5527                                                            .render_state
5528                                                            .surface
5529                                                            .config
5530                                                            .height
5531                                                {
5532                                                    presenter.render_cx.resize_surface(
5533                                                        &mut presenter.render_state.surface,
5534                                                        swapchain_size.width,
5535                                                        swapchain_size.height,
5536                                                    );
5537                                                    let device_handle =
5538                                                        &presenter.render_cx.devices
5539                                                            [presenter.render_state.surface.dev_id];
5540                                                    presenter
5541                                                        .render_state
5542                                                        .surface
5543                                                        .config
5544                                                        .alpha_mode =
5545                                                        wgpu::CompositeAlphaMode::PostMultiplied;
5546                                                    presenter
5547                                                        .render_state
5548                                                        .surface
5549                                                        .surface
5550                                                        .configure(
5551                                                            &device_handle.device,
5552                                                            &presenter.render_state.surface.config,
5553                                                        );
5554                                                    sync_tracked_target_texture_size_to_surface(
5555                                                        &mut presenter
5556                                                            .render_state
5557                                                            .target_texture_size,
5558                                                        swapchain_size,
5559                                                    );
5560                                                }
5561                                                if render_target_size
5562                                                    != presenter.render_state.target_texture_size
5563                                                {
5564                                                    recreate_target_texture(
5565                                                        &mut presenter.render_state.surface,
5566                                                        &presenter.render_cx,
5567                                                        render_target_size.0,
5568                                                        render_target_size.1,
5569                                                    );
5570                                                    presenter.render_state.target_texture_size =
5571                                                        render_target_size;
5572                                                }
5573
5574                                                let surface_texture = match presenter
5575                                                    .render_state
5576                                                    .surface
5577                                                    .surface
5578                                                    .get_current_texture()
5579                                                {
5580                                                    Ok(texture) => texture,
5581                                                    Err(err) => {
5582                                                        eprintln!(
5583                                                                "failed to get webgpu surface texture: {err}"
5584                                                            );
5585                                                        diag::end_frame(diag::FrameStats::default());
5586                                                        return;
5587                                                    }
5588                                                };
5589                                                let device_handle = &presenter.render_cx.devices
5590                                                    [presenter.render_state.surface.dev_id];
5591
5592                                                let clear_color = vello::wgpu::Color {
5593                                                    r: env.theme.tokens.colors.background.r as f64
5594                                                        / 255.0,
5595                                                    g: env.theme.tokens.colors.background.g as f64
5596                                                        / 255.0,
5597                                                    b: env.theme.tokens.colors.background.b as f64
5598                                                        / 255.0,
5599                                                    a: env.theme.tokens.colors.background.a as f64
5600                                                        / 255.0,
5601                                                };
5602                                                match &mut presenter.render_state.main_renderer {
5603                                                    MainRenderer::Vello {
5604                                                        renderer,
5605                                                        texture_compositor,
5606                                                    } => {
5607                                                        let texture_plans =
5608                                                            pipeline.texture_compositor_plans();
5609                                                        let texture_plans_fit_limits =
5610                                                            texture_plans_fit_device_limits(
5611                                                                texture_plans,
5612                                                                scale_factor,
5613                                                                device_handle
5614                                                                    .device
5615                                                                    .limits()
5616                                                                    .max_texture_dimension_2d,
5617                                                            );
5618                                                        let has_active_scroll_offsets = runtime
5619                                                            .runtime_state
5620                                                            .scroll
5621                                                            .offsets
5622                                                            .values()
5623                                                            .any(|offset| offset.abs() > 0.5);
5624                                                        let enable_texture_compositor =
5625                                                            web_bool_global(
5626                                                                "FISSION_ENABLE_TEXTURE_COMPOSITOR",
5627                                                            );
5628                                                        if !enable_texture_compositor
5629                                                            || texture_plans.is_empty()
5630                                                            || !texture_plans_fit_limits
5631                                                            || has_active_scroll_offsets
5632                                                        {
5633                                                            let render_params =
5634                                                                    vello::RenderParams {
5635                                                                        base_color:
5636                                                                            vello::peniko::Color::from_rgba8(
5637                                                                                env.theme
5638                                                                                    .tokens
5639                                                                                    .colors
5640                                                                                    .background
5641                                                                                    .r,
5642                                                                                env.theme
5643                                                                                    .tokens
5644                                                                                    .colors
5645                                                                                    .background
5646                                                                                    .g,
5647                                                                                env.theme
5648                                                                                    .tokens
5649                                                                                    .colors
5650                                                                                    .background
5651                                                                                    .b,
5652                                                                                env.theme
5653                                                                                    .tokens
5654                                                                                    .colors
5655                                                                                    .background
5656                                                                                    .a,
5657                                                                            ),
5658                                                                        width: render_target_size.0,
5659                                                                        height: render_target_size.1,
5660                                                                        antialiasing_method:
5661                                                                            vello::AaConfig::Area,
5662                                                                    };
5663
5664                                                            presenter.scene.reset();
5665                                                            let retained_scene = pipeline
5666                                                                    .retained_scene()
5667                                                                    .expect(
5668                                                                        "retained render scene missing before render",
5669                                                                    );
5670                                                            let workload_profile =
5671                                                                workload_profile_for_scene(
5672                                                                    retained_scene,
5673                                                                    render_target_size.0,
5674                                                                    render_target_size.1,
5675                                                                    scale_factor,
5676                                                                );
5677                                                            let mut renderer_wrapper =
5678                                                                VelloRenderer::new(
5679                                                                    &mut presenter.scene,
5680                                                                    measurer.clone(),
5681                                                                    &mut presenter
5682                                                                        .retained_scene_cache,
5683                                                                    scale_factor,
5684                                                                );
5685                                                            renderer_wrapper
5686                                                                    .render_scene(retained_scene)
5687                                                                    .expect(
5688                                                                        "failed to encode retained scene",
5689                                                                    );
5690                                                            renderer
5691                                                                    .render_to_texture_with_workload_profile(
5692                                                                        &device_handle.device,
5693                                                                        &device_handle.queue,
5694                                                                        &presenter.scene,
5695                                                                        &presenter
5696                                                                            .render_state
5697                                                                            .surface
5698                                                                            .target_view,
5699                                                                        &render_params,
5700                                                                        Some(&workload_profile),
5701                                                                    )
5702                                                                    .expect(
5703                                                                        "failed to render webgpu frame",
5704                                                                    );
5705                                                        } else {
5706                                                            let force_full_compositor_redraw =
5707                                                                invalidations.build
5708                                                                    || invalidations.layout
5709                                                                    || invalidations.paint;
5710                                                            let _compositor_stats =
5711                                                                    texture_compositor
5712                                                                        .render_layers(
5713                                                                            &device_handle.device,
5714                                                                            &device_handle.queue,
5715                                                                            renderer,
5716                                                                            &mut presenter
5717                                                                                .retained_scene_cache,
5718                                                                            measurer.clone(),
5719                                                                            scale_factor,
5720                                                                            render_target_size.0,
5721                                                                            render_target_size.1,
5722                                                                            pipeline
5723                                                                                .texture_compositor_root_transform(),
5724                                                                            texture_plans,
5725                                                                            force_full_compositor_redraw,
5726                                                                            clear_color,
5727                                                                            &presenter
5728                                                                                .render_state
5729                                                                                .surface
5730                                                                                .target_view,
5731                                                                        )
5732                                                                        .expect(
5733                                                                            "failed to composite webgpu texture layers",
5734                                                                        );
5735                                                        }
5736                                                    }
5737                                                    MainRenderer::Software => {}
5738                                                }
5739
5740                                                let surface_view =
5741                                                    surface_texture.texture.create_view(
5742                                                        &wgpu::TextureViewDescriptor::default(),
5743                                                    );
5744                                                let mut encoder =
5745                                                    device_handle.device.create_command_encoder(
5746                                                        &wgpu::CommandEncoderDescriptor {
5747                                                            label: Some("WebGPU Surface Blit"),
5748                                                        },
5749                                                    );
5750                                                presenter.render_state.surface.blitter.copy(
5751                                                    &device_handle.device,
5752                                                    &mut encoder,
5753                                                    &presenter.render_state.surface.target_view,
5754                                                    &surface_view,
5755                                                );
5756                                                device_handle.queue.submit(Some(encoder.finish()));
5757                                                surface_texture.present();
5758                                            }
5759                                        }
5760
5761                                        let capture_ready =
5762                                            !pending_capture_settle || resize_settled;
5763                                        if capture_ready {
5764                                            pending_capture_settle = false;
5765                                            let _ = pending_screenshot_path.take();
5766                                            let _ = pending_screenshot_response_tx.take();
5767                                        }
5768
5769                                        pending_resize = None;
5770                                        if resize_settled {
5771                                            resize_needs_settled_frame = false;
5772                                        }
5773                                        invalidations = InvalidationSet::default();
5774
5775                                        presented_frames = presented_frames.saturating_add(1);
5776                                        flush_text_traces(
5777                                            text_trace_enabled,
5778                                            &mut pending_text_traces,
5779                                            presented_frames,
5780                                        );
5781
5782                                        let total_ms = now.elapsed().as_secs_f64() * 1000.0;
5783                                        publish_web_frame_perf(&active_renderer, total_ms);
5784                                        if let Some(input_at) = pending_web_input_at.take() {
5785                                            publish_web_input_latency(
5786                                                &active_renderer,
5787                                                input_at.elapsed().as_secs_f64() * 1000.0,
5788                                            );
5789                                        }
5790
5791                                        diag::end_frame(diag::FrameStats::default());
5792                                    }
5793                                    #[cfg(not(target_arch = "wasm32"))]
5794                                    {
5795                                        let render_state =
5796                                            render_state.as_mut().expect("render state");
5797                                        let surface_texture = render_state
5798                                            .surface
5799                                            .surface
5800                                            .get_current_texture()
5801                                            .expect("failed to get texture");
5802                                        let device_handle =
5803                                            &render_cx.devices[render_state.surface.dev_id];
5804
5805                                        let clear_color = vello::wgpu::Color {
5806                                            r: env.theme.tokens.colors.background.r as f64 / 255.0,
5807                                            g: env.theme.tokens.colors.background.g as f64 / 255.0,
5808                                            b: env.theme.tokens.colors.background.b as f64 / 255.0,
5809                                            a: env.theme.tokens.colors.background.a as f64 / 255.0,
5810                                        };
5811                                        match &mut render_state.main_renderer {
5812                                            MainRenderer::Vello {
5813                                                renderer,
5814                                                texture_compositor,
5815                                            } => {
5816                                                let texture_plans =
5817                                                    pipeline.texture_compositor_plans();
5818                                                let texture_plans_fit_limits =
5819                                                    texture_plans_fit_device_limits(
5820                                                        texture_plans,
5821                                                        scale_factor,
5822                                                        device_handle
5823                                                            .device
5824                                                            .limits()
5825                                                            .max_texture_dimension_2d,
5826                                                    );
5827                                                let has_active_scroll_offsets = runtime
5828                                                    .runtime_state
5829                                                    .scroll
5830                                                    .offsets
5831                                                    .values()
5832                                                    .any(|offset| offset.abs() > 0.5);
5833                                                let enable_texture_compositor = std::env::var(
5834                                                    "FISSION_ENABLE_TEXTURE_COMPOSITOR",
5835                                                )
5836                                                .ok()
5837                                                .as_deref()
5838                                                    == Some("1");
5839                                                if !enable_texture_compositor
5840                                                    || texture_plans.is_empty()
5841                                                    || !texture_plans_fit_limits
5842                                                    || has_active_scroll_offsets
5843                                                {
5844                                                    let render_params = vello::RenderParams {
5845                                                        base_color:
5846                                                            vello::peniko::Color::from_rgba8(
5847                                                                env.theme
5848                                                                    .tokens
5849                                                                    .colors
5850                                                                    .background
5851                                                                    .r,
5852                                                                env.theme
5853                                                                    .tokens
5854                                                                    .colors
5855                                                                    .background
5856                                                                    .g,
5857                                                                env.theme
5858                                                                    .tokens
5859                                                                    .colors
5860                                                                    .background
5861                                                                    .b,
5862                                                                env.theme
5863                                                                    .tokens
5864                                                                    .colors
5865                                                                    .background
5866                                                                    .a,
5867                                                            ),
5868                                                        width: render_target_size.0,
5869                                                        height: render_target_size.1,
5870                                                        antialiasing_method: vello::AaConfig::Area,
5871                                                    };
5872
5873                                                    scene.reset();
5874                                                    let retained_scene = pipeline
5875                                                        .retained_scene()
5876                                                        .expect(
5877                                                            "retained render scene missing before render",
5878                                                        );
5879                                                    let workload_profile =
5880                                                        workload_profile_for_scene(
5881                                                            retained_scene,
5882                                                            render_target_size.0,
5883                                                            render_target_size.1,
5884                                                            scale_factor,
5885                                                        );
5886                                                    let mut renderer_wrapper = VelloRenderer::new(
5887                                                        &mut scene,
5888                                                        measurer.clone(),
5889                                                        &mut retained_scene_cache,
5890                                                        scale_factor,
5891                                                    );
5892                                                    renderer_wrapper
5893                                                        .render_scene(retained_scene)
5894                                                        .expect("failed to encode retained scene");
5895                                                    renderer
5896                                                        .render_to_texture_with_workload_profile(
5897                                                            &device_handle.device,
5898                                                            &device_handle.queue,
5899                                                            &scene,
5900                                                            &render_state.surface.target_view,
5901                                                            &render_params,
5902                                                            Some(&workload_profile),
5903                                                        )
5904                                                        .expect("failed to render");
5905                                                } else {
5906                                                    let force_full_compositor_redraw = invalidations
5907                                                        .build
5908                                                        || invalidations.layout
5909                                                        || invalidations.paint;
5910                                                    let _compositor_stats = texture_compositor
5911                                                        .render_layers(
5912                                                            &device_handle.device,
5913                                                            &device_handle.queue,
5914                                                            renderer,
5915                                                            &mut retained_scene_cache,
5916                                                            measurer.clone(),
5917                                                            scale_factor,
5918                                                            render_target_size.0,
5919                                                            render_target_size.1,
5920                                                            pipeline
5921                                                                .texture_compositor_root_transform(
5922                                                                ),
5923                                                            texture_plans,
5924                                                            force_full_compositor_redraw,
5925                                                            clear_color,
5926                                                            &render_state.surface.target_view,
5927                                                        )
5928                                                        .expect(
5929                                                            "failed to composite texture layers",
5930                                                        );
5931                                                }
5932                                            }
5933                                            MainRenderer::Software => {
5934                                                let retained_scene = pipeline
5935                                                    .retained_scene()
5936                                                    .expect(
5937                                                    "retained render scene missing before render",
5938                                                );
5939                                                let rgba = SoftwareRenderer::render(
5940                                                    retained_scene,
5941                                                    render_target_size.0,
5942                                                    render_target_size.1,
5943                                                    fission_render::Color {
5944                                                        r: env.theme.tokens.colors.background.r,
5945                                                        g: env.theme.tokens.colors.background.g,
5946                                                        b: env.theme.tokens.colors.background.b,
5947                                                        a: env.theme.tokens.colors.background.a,
5948                                                    },
5949                                                    scale_factor as f32,
5950                                                )
5951                                                .expect("failed to rasterize software frame");
5952                                                device_handle.queue.write_texture(
5953                                                    wgpu::TexelCopyTextureInfo {
5954                                                        texture: &render_state
5955                                                            .surface
5956                                                            .target_texture,
5957                                                        mip_level: 0,
5958                                                        origin: wgpu::Origin3d::ZERO,
5959                                                        aspect: wgpu::TextureAspect::All,
5960                                                    },
5961                                                    &rgba,
5962                                                    wgpu::TexelCopyBufferLayout {
5963                                                        offset: 0,
5964                                                        bytes_per_row: Some(
5965                                                            render_target_size.0 * 4,
5966                                                        ),
5967                                                        rows_per_image: Some(render_target_size.1),
5968                                                    },
5969                                                    wgpu::Extent3d {
5970                                                        width: render_target_size.0,
5971                                                        height: render_target_size.1,
5972                                                        depth_or_array_layers: 1,
5973                                                    },
5974                                                );
5975                                            }
5976                                        }
5977
5978                                        #[cfg(feature = "three-d")]
5979                                        {
5980                                            for (_, rect, payload) in &pipeline.scene_3d_surfaces {
5981                                                if let Ok(primitives) = bincode::deserialize::<
5982                                                    Vec<fission_3d::Primitive3D>,
5983                                                >(
5984                                                    payload
5985                                                ) {
5986                                                    let scene3d = fission_3d::Scene3D {
5987                                                        width: Some(rect.size.width),
5988                                                        height: Some(rect.size.height),
5989                                                        primitives,
5990                                                    };
5991                                                    let scale = scale_factor as f32;
5992                                                    render_state.scene3d_renderer.render_in_rect(
5993                                                        &device_handle.device,
5994                                                        &device_handle.queue,
5995                                                        &render_state.surface.target_view,
5996                                                        &scene3d,
5997                                                        fission_3d::render::Scene3DViewport {
5998                                                            x: rect.origin.x * scale,
5999                                                            y: rect.origin.y * scale,
6000                                                            width: rect.size.width * scale,
6001                                                            height: rect.size.height * scale,
6002                                                        },
6003                                                    );
6004                                                }
6005                                            }
6006                                        }
6007
6008                                        let surface_view = surface_texture
6009                                            .texture
6010                                            .create_view(&wgpu::TextureViewDescriptor::default());
6011
6012                                        let mut encoder =
6013                                            device_handle.device.create_command_encoder(
6014                                                &wgpu::CommandEncoderDescriptor {
6015                                                    label: Some("Surface Blit"),
6016                                                },
6017                                            );
6018
6019                                        render_state.surface.blitter.copy(
6020                                            &device_handle.device,
6021                                            &mut encoder,
6022                                            &render_state.surface.target_view,
6023                                            &surface_view,
6024                                        );
6025
6026                                        device_handle.queue.submit(Some(encoder.finish()));
6027
6028                                        let capture_ready =
6029                                            !pending_capture_settle || resize_settled;
6030                                        if capture_ready {
6031                                            pending_capture_settle = false;
6032                                        }
6033                                        if capture_ready {
6034                                            if let Some(path) = pending_screenshot_path.take() {
6035                                                let screenshot_dimensions =
6036                                                    layout_size_to_image_dimensions(
6037                                                        target_viewport,
6038                                                    );
6039                                                if let Some(tx) =
6040                                                    pending_screenshot_response_tx.take()
6041                                                {
6042                                                    if path == "__pump__" {
6043                                                        let _ = tx.send(
6044                                                            fission_test_driver::TestResponse::Ok {},
6045                                                        );
6046                                                    } else if path == "__capture__" {
6047                                                        let resp = gpu_screenshot(
6048                                                            &device_handle.device,
6049                                                            &device_handle.queue,
6050                                                            &render_state.surface.target_texture,
6051                                                            render_target_size.0,
6052                                                            render_target_size.1,
6053                                                            screenshot_dimensions.0,
6054                                                            screenshot_dimensions.1,
6055                                                            None,
6056                                                        );
6057                                                        let _ = tx.send(resp);
6058                                                    } else {
6059                                                        let resp = gpu_screenshot(
6060                                                            &device_handle.device,
6061                                                            &device_handle.queue,
6062                                                            &render_state.surface.target_texture,
6063                                                            render_target_size.0,
6064                                                            render_target_size.1,
6065                                                            screenshot_dimensions.0,
6066                                                            screenshot_dimensions.1,
6067                                                            Some(&path),
6068                                                        );
6069                                                        let _ = tx.send(resp);
6070                                                    }
6071                                                }
6072                                            }
6073                                        }
6074
6075                                        surface_texture.present();
6076                                        pending_resize = None;
6077                                        if resize_settled {
6078                                            resize_needs_settled_frame = false;
6079                                        }
6080                                        invalidations = InvalidationSet::default();
6081
6082                                        presented_frames = presented_frames.saturating_add(1);
6083                                        flush_text_traces(
6084                                            text_trace_enabled,
6085                                            &mut pending_text_traces,
6086                                            presented_frames,
6087                                        );
6088
6089                                        diag::emit(
6090                                            diag::DiagCategory::Frame,
6091                                            diag::DiagLevel::Debug,
6092                                            diag::DiagEventKind::FramePerformance {
6093                                                renderer: render_state
6094                                                    .renderer_report
6095                                                    .active
6096                                                    .clone(),
6097                                                total_ms: now.elapsed().as_secs_f64() * 1000.0,
6098                                            },
6099                                        );
6100                                        diag::end_frame(diag::FrameStats::default());
6101                                    }
6102                                }
6103                                Err(e) => {
6104                                    eprintln!("Pipeline error: {:?}", e);
6105                                    diag::end_frame(diag::FrameStats::default());
6106                                }
6107                            }
6108                        }
6109                        WindowEvent::CloseRequested => {
6110                            #[cfg(feature = "tray")]
6111                            if active_tray
6112                                .as_ref()
6113                                .map(|tray| {
6114                                    tray.close_behavior() == tray::WindowCloseBehavior::HideToTray
6115                                })
6116                                .unwrap_or(false)
6117                            {
6118                                tray::hide_window_to_tray(window);
6119                                return;
6120                            }
6121                            elwt.exit();
6122                        }
6123                        // Input Handling — delegates to the same extracted functions
6124                        // that TestEvent handlers use.
6125                        WindowEvent::CursorMoved { position, .. } => {
6126                            last_cursor_position = Some(position);
6127                            let point = window_physical_position_to_layout_point(window, position);
6128                            handle_cursor_moved(
6129                                point.x,
6130                                point.y,
6131                                current_mods,
6132                                &mut runtime,
6133                                &pipeline,
6134                                &effect_result_tx,
6135                                &event_proxy,
6136                                &async_registry,
6137                                &mut active_services,
6138                                &mut service_bindings,
6139                                &mut next_service_instance_id,
6140                                &window,
6141                                elwt,
6142                                &mut last_redraw_at,
6143                                min_frame,
6144                                &mut redraw_pending,
6145                                &mut frame_trace,
6146                                &mut invalidations,
6147                            );
6148                        }
6149                        WindowEvent::CursorLeft { .. } => {
6150                            handle_cursor_left(
6151                                last_cursor_position,
6152                                &mut runtime,
6153                                &pipeline,
6154                                &effect_result_tx,
6155                                &event_proxy,
6156                                &async_registry,
6157                                &mut active_services,
6158                                &mut service_bindings,
6159                                &mut next_service_instance_id,
6160                                &window,
6161                                elwt,
6162                                &mut last_redraw_at,
6163                                min_frame,
6164                                &mut redraw_pending,
6165                                &mut frame_trace,
6166                                &mut invalidations,
6167                            );
6168                            last_cursor_position = None;
6169                        }
6170                        WindowEvent::MouseInput { state, button, .. } => {
6171                            #[cfg(target_arch = "wasm32")]
6172                            pending_web_input_at.get_or_insert_with(Instant::now);
6173                            if let Some(position) = last_cursor_position {
6174                                let point =
6175                                    window_physical_position_to_layout_point(window, position);
6176                                if let Some(btn) = map_mouse_button(button) {
6177                                    let is_pressed = state.is_pressed();
6178                                    handle_mouse_button(
6179                                        point.x,
6180                                        point.y,
6181                                        btn,
6182                                        is_pressed,
6183                                        current_mods,
6184                                        &mut runtime,
6185                                        &pipeline,
6186                                        &effect_result_tx,
6187                                        &event_proxy,
6188                                        &async_registry,
6189                                        &mut active_services,
6190                                        &mut service_bindings,
6191                                        &mut next_service_instance_id,
6192                                        &window,
6193                                        elwt,
6194                                        &mut last_redraw_at,
6195                                        min_frame,
6196                                        &mut redraw_pending,
6197                                        text_trace_enabled,
6198                                        &mut pending_text_traces,
6199                                        &mut next_text_trace_seq,
6200                                        presented_frames,
6201                                        &mut last_blink_toggle,
6202                                        &mut frame_trace,
6203                                        &mut invalidations,
6204                                    );
6205                                }
6206                            }
6207                        }
6208                        WindowEvent::MouseWheel { delta, .. } => {
6209                            #[cfg(target_arch = "wasm32")]
6210                            pending_web_input_at.get_or_insert_with(Instant::now);
6211                            if let Some(position) = last_cursor_position {
6212                                let scale_factor = window.scale_factor();
6213                                let point =
6214                                    window_physical_position_to_layout_point(window, position);
6215
6216                                let (dx, dy) = normalize_winit_scroll_delta(&delta, scale_factor);
6217
6218                                if std::env::var("FISSION_SCROLL_TRACE").ok().as_deref()
6219                                    == Some("1")
6220                                {
6221                                    eprintln!(
6222                                            "[scroll-trace] mousewheel raw={:?} point=({:.1},{:.1}) delta=({:.1},{:.1})",
6223                                            delta, point.x, point.y, dx, dy
6224                                        );
6225                                }
6226                                handle_scroll(
6227                                    point.x,
6228                                    point.y,
6229                                    dx,
6230                                    dy,
6231                                    current_mods,
6232                                    &mut runtime,
6233                                    &pipeline,
6234                                    &effect_result_tx,
6235                                    &event_proxy,
6236                                    &async_registry,
6237                                    &mut active_services,
6238                                    &mut service_bindings,
6239                                    &mut next_service_instance_id,
6240                                    &window,
6241                                    elwt,
6242                                    &mut last_redraw_at,
6243                                    min_frame,
6244                                    &mut redraw_pending,
6245                                    &mut frame_trace,
6246                                    &mut invalidations,
6247                                );
6248                            }
6249                        }
6250                        WindowEvent::Touch(touch) => {
6251                            #[cfg(target_arch = "wasm32")]
6252                            pending_web_input_at.get_or_insert_with(Instant::now);
6253                            let current_position = touch.location;
6254                            // Some mobile backends report the end/cancel location after the
6255                            // contact has already been cleared. Keep the last active touch
6256                            // position so a normal tap releases over the same hit target.
6257                            let position = match touch.phase {
6258                                TouchPhase::Ended | TouchPhase::Cancelled => touch_positions
6259                                    .get(&touch.id)
6260                                    .copied()
6261                                    .unwrap_or(current_position),
6262                                TouchPhase::Started | TouchPhase::Moved => current_position,
6263                            };
6264                            last_cursor_position = Some(position);
6265
6266                            let point = window_physical_position_to_layout_point(window, position);
6267
6268                            match touch.phase {
6269                                TouchPhase::Started => {
6270                                    touch_positions.insert(touch.id, position);
6271                                    if active_primary_touch.is_none() {
6272                                        active_primary_touch = Some(touch.id);
6273                                    }
6274                                    if active_primary_touch == Some(touch.id) {
6275                                        handle_cursor_moved(
6276                                            point.x,
6277                                            point.y,
6278                                            current_mods,
6279                                            &mut runtime,
6280                                            &pipeline,
6281                                            &effect_result_tx,
6282                                            &event_proxy,
6283                                            &async_registry,
6284                                            &mut active_services,
6285                                            &mut service_bindings,
6286                                            &mut next_service_instance_id,
6287                                            &window,
6288                                            elwt,
6289                                            &mut last_redraw_at,
6290                                            min_frame,
6291                                            &mut redraw_pending,
6292                                            &mut frame_trace,
6293                                            &mut invalidations,
6294                                        );
6295                                        handle_mouse_button(
6296                                            point.x,
6297                                            point.y,
6298                                            PointerButton::Primary,
6299                                            true,
6300                                            current_mods,
6301                                            &mut runtime,
6302                                            &pipeline,
6303                                            &effect_result_tx,
6304                                            &event_proxy,
6305                                            &async_registry,
6306                                            &mut active_services,
6307                                            &mut service_bindings,
6308                                            &mut next_service_instance_id,
6309                                            &window,
6310                                            elwt,
6311                                            &mut last_redraw_at,
6312                                            min_frame,
6313                                            &mut redraw_pending,
6314                                            text_trace_enabled,
6315                                            &mut pending_text_traces,
6316                                            &mut next_text_trace_seq,
6317                                            presented_frames,
6318                                            &mut last_blink_toggle,
6319                                            &mut frame_trace,
6320                                            &mut invalidations,
6321                                        );
6322                                    }
6323                                }
6324                                TouchPhase::Moved => {
6325                                    touch_positions.insert(touch.id, position);
6326                                    if active_primary_touch == Some(touch.id) {
6327                                        handle_cursor_moved(
6328                                            point.x,
6329                                            point.y,
6330                                            current_mods,
6331                                            &mut runtime,
6332                                            &pipeline,
6333                                            &effect_result_tx,
6334                                            &event_proxy,
6335                                            &async_registry,
6336                                            &mut active_services,
6337                                            &mut service_bindings,
6338                                            &mut next_service_instance_id,
6339                                            &window,
6340                                            elwt,
6341                                            &mut last_redraw_at,
6342                                            min_frame,
6343                                            &mut redraw_pending,
6344                                            &mut frame_trace,
6345                                            &mut invalidations,
6346                                        );
6347                                    }
6348                                }
6349                                TouchPhase::Ended | TouchPhase::Cancelled => {
6350                                    if active_primary_touch == Some(touch.id) {
6351                                        handle_cursor_moved(
6352                                            point.x,
6353                                            point.y,
6354                                            current_mods,
6355                                            &mut runtime,
6356                                            &pipeline,
6357                                            &effect_result_tx,
6358                                            &event_proxy,
6359                                            &async_registry,
6360                                            &mut active_services,
6361                                            &mut service_bindings,
6362                                            &mut next_service_instance_id,
6363                                            &window,
6364                                            elwt,
6365                                            &mut last_redraw_at,
6366                                            min_frame,
6367                                            &mut redraw_pending,
6368                                            &mut frame_trace,
6369                                            &mut invalidations,
6370                                        );
6371                                        handle_mouse_button(
6372                                            point.x,
6373                                            point.y,
6374                                            PointerButton::Primary,
6375                                            false,
6376                                            current_mods,
6377                                            &mut runtime,
6378                                            &pipeline,
6379                                            &effect_result_tx,
6380                                            &event_proxy,
6381                                            &async_registry,
6382                                            &mut active_services,
6383                                            &mut service_bindings,
6384                                            &mut next_service_instance_id,
6385                                            &window,
6386                                            elwt,
6387                                            &mut last_redraw_at,
6388                                            min_frame,
6389                                            &mut redraw_pending,
6390                                            text_trace_enabled,
6391                                            &mut pending_text_traces,
6392                                            &mut next_text_trace_seq,
6393                                            presented_frames,
6394                                            &mut last_blink_toggle,
6395                                            &mut frame_trace,
6396                                            &mut invalidations,
6397                                        );
6398                                        active_primary_touch = None;
6399                                    }
6400                                    touch_positions.remove(&touch.id);
6401                                }
6402                            }
6403                        }
6404                        WindowEvent::ModifiersChanged(modifiers) => {
6405                            current_mods = 0;
6406                            if modifiers.state().shift_key() {
6407                                current_mods |= 1;
6408                            }
6409                            if modifiers.state().alt_key() {
6410                                current_mods |= 2;
6411                            }
6412                            if modifiers.state().control_key() {
6413                                current_mods |= 4;
6414                            }
6415                            if modifiers.state().super_key() {
6416                                current_mods |= 8;
6417                            }
6418                        }
6419                        WindowEvent::KeyboardInput { event, .. } => {
6420                            #[cfg(target_arch = "wasm32")]
6421                            pending_web_input_at.get_or_insert_with(Instant::now);
6422                            if event.state.is_pressed() {
6423                                use winit::keyboard::{Key, NamedKey};
6424                                let key_code = match event.logical_key {
6425                                    Key::Named(NamedKey::Space) => Some(KeyCode::Space),
6426                                    Key::Named(NamedKey::Enter) => Some(KeyCode::Enter),
6427                                    Key::Named(NamedKey::Escape) => Some(KeyCode::Escape),
6428                                    Key::Named(NamedKey::Backspace) => Some(KeyCode::Backspace),
6429                                    Key::Named(NamedKey::Delete) => Some(KeyCode::Delete),
6430                                    Key::Named(NamedKey::Tab) => Some(KeyCode::Tab),
6431                                    Key::Named(NamedKey::ArrowLeft) => Some(KeyCode::Left),
6432                                    Key::Named(NamedKey::ArrowRight) => Some(KeyCode::Right),
6433                                    Key::Named(NamedKey::ArrowUp) => Some(KeyCode::Up),
6434                                    Key::Named(NamedKey::ArrowDown) => Some(KeyCode::Down),
6435                                    Key::Named(NamedKey::Home) => Some(KeyCode::Home),
6436                                    Key::Named(NamedKey::End) => Some(KeyCode::End),
6437                                    Key::Named(NamedKey::PageUp) => Some(KeyCode::PageUp),
6438                                    Key::Named(NamedKey::PageDown) => Some(KeyCode::PageDown),
6439                                    _ => {
6440                                        if let Some(text) = &event.text {
6441                                            text.chars().next().map(KeyCode::Char)
6442                                        } else {
6443                                            None
6444                                        }
6445                                    }
6446                                };
6447
6448                                if let Some(code) = key_code {
6449                                    handle_key_down::<S>(
6450                                        code,
6451                                        current_mods,
6452                                        &mut runtime,
6453                                        &pipeline,
6454                                        &effect_result_tx,
6455                                        &event_proxy,
6456                                        &async_registry,
6457                                        &mut active_services,
6458                                        &mut service_bindings,
6459                                        &mut next_service_instance_id,
6460                                        &window,
6461                                        elwt,
6462                                        &mut last_redraw_at,
6463                                        min_frame,
6464                                        &mut redraw_pending,
6465                                        text_trace_enabled,
6466                                        &mut pending_text_traces,
6467                                        &mut next_text_trace_seq,
6468                                        presented_frames,
6469                                        &mut last_blink_toggle,
6470                                        self.key_handler.as_ref(),
6471                                        &mut frame_trace,
6472                                        &mut invalidations,
6473                                    );
6474                                }
6475                            }
6476                        }
6477                        WindowEvent::Ime(ime) => {
6478                            #[cfg(target_arch = "wasm32")]
6479                            pending_web_input_at.get_or_insert_with(Instant::now);
6480                            if let (Some(ir), Some(layout)) =
6481                                (&pipeline.prev_ir, &pipeline.last_snapshot)
6482                            {
6483                                let (input_event, source) = match ime {
6484                                    Ime::Commit(text) => (
6485                                        Some(InputEvent::Ime(
6486                                            fission_core::event::ImeEvent::Commit {
6487                                                text: text.clone(),
6488                                            },
6489                                        )),
6490                                        Some(format!("ime_commit:{}", text.chars().count())),
6491                                    ),
6492                                    Ime::Preedit(text, _) => (
6493                                        Some(InputEvent::Ime(
6494                                            fission_core::event::ImeEvent::Preedit {
6495                                                text: text.clone(),
6496                                            },
6497                                        )),
6498                                        Some(format!("ime_preedit:{}", text.chars().count())),
6499                                    ),
6500                                    _ => (None, None),
6501                                };
6502
6503                                if let Some(e) = input_event {
6504                                    let target =
6505                                        focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
6506                                    let trace_seq = start_text_trace(
6507                                        text_trace_enabled && target.is_some(),
6508                                        &mut pending_text_traces,
6509                                        &mut next_text_trace_seq,
6510                                        source.unwrap_or_else(|| "ime".to_string()),
6511                                        target,
6512                                        presented_frames,
6513                                    );
6514                                    runtime.handle_input(e, ir, layout).ok();
6515                                    invalidations.mark_build();
6516                                    mark_text_trace_handled(&mut pending_text_traces, trace_seq);
6517                                    if process_pending_effects(
6518                                        &mut runtime,
6519                                        &effect_result_tx,
6520                                        &event_proxy,
6521                                        &async_registry,
6522                                        &mut active_services,
6523                                        &mut service_bindings,
6524                                        &mut next_service_instance_id,
6525                                    ) {
6526                                        mark_text_trace_effects(
6527                                            &mut pending_text_traces,
6528                                            trace_seq,
6529                                        );
6530                                        invalidations.mark_build();
6531                                        request_redraw_logged(
6532                                            &window,
6533                                            elwt,
6534                                            &mut last_redraw_at,
6535                                            min_frame,
6536                                            &mut redraw_pending,
6537                                            &mut frame_trace,
6538                                            "ime:effects",
6539                                        );
6540                                    }
6541                                    reset_text_input_caret(
6542                                        &mut runtime,
6543                                        pipeline.prev_ir.as_ref(),
6544                                        &mut last_blink_toggle,
6545                                    );
6546                                    request_redraw_logged(
6547                                        &window,
6548                                        elwt,
6549                                        &mut last_redraw_at,
6550                                        min_frame,
6551                                        &mut redraw_pending,
6552                                        &mut frame_trace,
6553                                        "ime",
6554                                    );
6555                                }
6556                            }
6557                        }
6558                        _ => {}
6559                    }
6560                }
6561                _ => {}
6562            }
6563        };
6564
6565        #[cfg(target_arch = "wasm32")]
6566        {
6567            #[allow(deprecated)]
6568            event_loop.spawn(event_handler);
6569            Ok(())
6570        }
6571        #[cfg(not(target_arch = "wasm32"))]
6572        {
6573            #[allow(deprecated)]
6574            event_loop
6575                .run(event_handler)
6576                .map_err(|e| anyhow::anyhow!("Event loop error: {}", e))
6577        }
6578    }
6579}
6580
6581fn build_font_context() -> FontContext {
6582    let use_system_fonts = std::env::var("FISSION_USE_SYSTEM_FONTS")
6583        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
6584        .unwrap_or(false);
6585    let options = CollectionOptions {
6586        shared: false,
6587        system_fonts: use_system_fonts,
6588    };
6589    FontContext {
6590        collection: Collection::new(options),
6591        source_cache: SourceCache::default(),
6592    }
6593}
6594
6595// Helpers...
6596fn map_mouse_button(button: MouseButton) -> Option<PointerButton> {
6597    match button {
6598        MouseButton::Left => Some(PointerButton::Primary),
6599        MouseButton::Right => Some(PointerButton::Secondary),
6600        MouseButton::Middle => Some(PointerButton::Middle),
6601        MouseButton::Other(id) => Some(PointerButton::Other(id as u8)),
6602        _ => None,
6603    }
6604}
6605
6606fn clamp_copy_extent_to_texture(
6607    requested_width: u32,
6608    requested_height: u32,
6609    actual_width: u32,
6610    actual_height: u32,
6611) -> (u32, u32) {
6612    (
6613        requested_width.min(actual_width).max(1),
6614        requested_height.min(actual_height).max(1),
6615    )
6616}
6617
6618fn gpu_screenshot(
6619    device: &wgpu::Device,
6620    queue: &wgpu::Queue,
6621    texture: &wgpu::Texture,
6622    texture_width: u32,
6623    texture_height: u32,
6624    output_width: u32,
6625    output_height: u32,
6626    path: Option<&str>,
6627) -> fission_test_driver::TestResponse {
6628    let actual_texture_width = texture.width();
6629    let actual_texture_height = texture.height();
6630    let (texture_width, texture_height) = clamp_copy_extent_to_texture(
6631        texture_width,
6632        texture_height,
6633        actual_texture_width,
6634        actual_texture_height,
6635    );
6636    if output_width == 0 || output_height == 0 {
6637        return fission_test_driver::TestResponse::Error {
6638            message: "zero-size viewport".into(),
6639        };
6640    }
6641
6642    let bytes_per_pixel = 4u32;
6643    let unpadded_bytes_per_row = texture_width * bytes_per_pixel;
6644    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
6645    let padded_bytes_per_row = (unpadded_bytes_per_row + align - 1) / align * align;
6646    let buffer_size = (padded_bytes_per_row * texture_height) as u64;
6647
6648    let staging = device.create_buffer(&wgpu::BufferDescriptor {
6649        label: Some("screenshot staging"),
6650        size: buffer_size,
6651        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
6652        mapped_at_creation: false,
6653    });
6654
6655    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
6656        label: Some("screenshot copy"),
6657    });
6658
6659    encoder.copy_texture_to_buffer(
6660        wgpu::TexelCopyTextureInfo {
6661            texture,
6662            mip_level: 0,
6663            origin: wgpu::Origin3d::ZERO,
6664            aspect: wgpu::TextureAspect::All,
6665        },
6666        wgpu::TexelCopyBufferInfo {
6667            buffer: &staging,
6668            layout: wgpu::TexelCopyBufferLayout {
6669                offset: 0,
6670                bytes_per_row: Some(padded_bytes_per_row),
6671                rows_per_image: Some(texture_height),
6672            },
6673        },
6674        wgpu::Extent3d {
6675            width: texture_width,
6676            height: texture_height,
6677            depth_or_array_layers: 1,
6678        },
6679    );
6680
6681    queue.submit(Some(encoder.finish()));
6682
6683    let (tx, rx) = std::sync::mpsc::channel();
6684    staging
6685        .slice(..)
6686        .map_async(wgpu::MapMode::Read, move |result| {
6687            let _ = tx.send(result);
6688        });
6689    let _ = device.poll(wgpu::PollType::Wait);
6690
6691    match rx.recv() {
6692        Ok(Ok(())) => {}
6693        Ok(Err(e)) => {
6694            return fission_test_driver::TestResponse::Error {
6695                message: format!("buffer map failed: {:?}", e),
6696            };
6697        }
6698        Err(e) => {
6699            return fission_test_driver::TestResponse::Error {
6700                message: format!("buffer map channel error: {}", e),
6701            };
6702        }
6703    }
6704
6705    let data = staging.slice(..).get_mapped_range();
6706
6707    // Remove row padding (texture is Rgba8Unorm, no swizzle needed)
6708    let mut rgba = Vec::with_capacity((texture_width * texture_height * 4) as usize);
6709    for row in 0..texture_height {
6710        let start = (row * padded_bytes_per_row) as usize;
6711        let end = start + (texture_width * bytes_per_pixel) as usize;
6712        rgba.extend_from_slice(&data[start..end]);
6713    }
6714
6715    drop(data);
6716    staging.unmap();
6717
6718    let (rgba, width, height) = if texture_width == output_width && texture_height == output_height
6719    {
6720        (rgba, texture_width, texture_height)
6721    } else if let Some(resized) = downscale_rgba_box(
6722        &rgba,
6723        texture_width,
6724        texture_height,
6725        output_width,
6726        output_height,
6727    ) {
6728        (resized, output_width, output_height)
6729    } else {
6730        let Some(image) = image::RgbaImage::from_raw(texture_width, texture_height, rgba) else {
6731            return fission_test_driver::TestResponse::Error {
6732                message: "failed to decode screenshot RGBA buffer".into(),
6733            };
6734        };
6735        let resized = image::imageops::resize(
6736            &image,
6737            output_width,
6738            output_height,
6739            image::imageops::FilterType::Triangle,
6740        );
6741        (resized.into_raw(), output_width, output_height)
6742    };
6743
6744    let mut png = Vec::new();
6745    {
6746        use image::ImageEncoder;
6747        let encoder = image::codecs::png::PngEncoder::new(&mut png);
6748        if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) {
6749            return fission_test_driver::TestResponse::Error {
6750                message: format!("PNG encode failed: {}", e),
6751            };
6752        }
6753    }
6754
6755    if let Some(path) = path {
6756        match std::fs::write(path, &png) {
6757            Ok(()) => fission_test_driver::TestResponse::Ok {},
6758            Err(e) => fission_test_driver::TestResponse::Error {
6759                message: format!("PNG save failed: {}", e),
6760            },
6761        }
6762    } else {
6763        fission_test_driver::TestResponse::Screenshot {
6764            png_base64: base64::engine::general_purpose::STANDARD.encode(png),
6765            width,
6766            height,
6767        }
6768    }
6769}
6770
6771fn downscale_rgba_box(
6772    rgba: &[u8],
6773    input_width: u32,
6774    input_height: u32,
6775    output_width: u32,
6776    output_height: u32,
6777) -> Option<Vec<u8>> {
6778    if output_width == 0
6779        || output_height == 0
6780        || input_width % output_width != 0
6781        || input_height % output_height != 0
6782    {
6783        return None;
6784    }
6785
6786    let scale_x = input_width / output_width;
6787    let scale_y = input_height / output_height;
6788    if scale_x <= 1 && scale_y <= 1 {
6789        return None;
6790    }
6791
6792    let samples_per_pixel = scale_x.checked_mul(scale_y)?;
6793    let mut out = vec![0u8; (output_width * output_height * 4) as usize];
6794
6795    for out_y in 0..output_height {
6796        let src_y0 = out_y * scale_y;
6797        for out_x in 0..output_width {
6798            let src_x0 = out_x * scale_x;
6799            let mut sum = [0u32; 4];
6800            for dy in 0..scale_y {
6801                let src_y = src_y0 + dy;
6802                let row_offset = ((src_y * input_width) * 4) as usize;
6803                for dx in 0..scale_x {
6804                    let src_x = src_x0 + dx;
6805                    let src_index = row_offset + (src_x * 4) as usize;
6806                    sum[0] += rgba[src_index] as u32;
6807                    sum[1] += rgba[src_index + 1] as u32;
6808                    sum[2] += rgba[src_index + 2] as u32;
6809                    sum[3] += rgba[src_index + 3] as u32;
6810                }
6811            }
6812
6813            let dst_index = (((out_y * output_width) + out_x) * 4) as usize;
6814            out[dst_index] = (sum[0] / samples_per_pixel) as u8;
6815            out[dst_index + 1] = (sum[1] / samples_per_pixel) as u8;
6816            out[dst_index + 2] = (sum[2] / samples_per_pixel) as u8;
6817            out[dst_index + 3] = (sum[3] / samples_per_pixel) as u8;
6818        }
6819    }
6820
6821    Some(out)
6822}
6823
6824fn layout_size_to_image_dimensions(size: LayoutSize) -> (u32, u32) {
6825    let width = size.width.max(1.0).round() as u32;
6826    let height = size.height.max(1.0).round() as u32;
6827    (width.max(1), height.max(1))
6828}
6829
6830fn normalize_scale_factor(scale_factor: f64) -> f64 {
6831    if scale_factor.is_finite() && scale_factor > 0.0 {
6832        scale_factor
6833    } else {
6834        1.0
6835    }
6836}
6837
6838#[cfg(target_os = "ios")]
6839fn ios_effective_scale_factor(reported_scale_factor: f64) -> f64 {
6840    std::env::var("FISSION_IOS_SCALE_FACTOR")
6841        .ok()
6842        .and_then(|value| value.parse::<f64>().ok())
6843        .filter(|scale| scale.is_finite() && *scale > 0.0)
6844        .unwrap_or_else(|| {
6845            if reported_scale_factor >= 2.0 {
6846                reported_scale_factor
6847            } else {
6848                3.0
6849            }
6850        })
6851}
6852
6853#[cfg(target_arch = "wasm32")]
6854fn web_browser_viewport_state() -> Option<WindowViewportState> {
6855    let window = web_sys::window()?;
6856    let width = window.inner_width().ok()?.as_f64()? as f32;
6857    let height = window.inner_height().ok()?.as_f64()? as f32;
6858    if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
6859        return None;
6860    }
6861    let scale_factor = normalize_scale_factor(window.device_pixel_ratio());
6862    Some(WindowViewportState {
6863        physical_size: logical_viewport_to_physical_size(
6864            LayoutSize::new(width, height),
6865            scale_factor,
6866        ),
6867        scale_factor,
6868    })
6869}
6870
6871fn physical_size_to_layout_size(size: PhysicalSize<u32>, scale_factor: f64) -> LayoutSize {
6872    let scale_factor = normalize_scale_factor(scale_factor);
6873    LayoutSize {
6874        width: (size.width as f64 / scale_factor) as f32,
6875        height: (size.height as f64 / scale_factor) as f32,
6876    }
6877}
6878
6879fn logical_viewport_to_render_target_size(size: LayoutSize, scale_factor: f64) -> (u32, u32) {
6880    let scale_factor = normalize_scale_factor(scale_factor);
6881    let width = (size.width.max(1.0) as f64 * scale_factor).ceil() as u32;
6882    let height = (size.height.max(1.0) as f64 * scale_factor).ceil() as u32;
6883    (width.max(1), height.max(1))
6884}
6885
6886fn logical_viewport_to_physical_size(size: LayoutSize, scale_factor: f64) -> PhysicalSize<u32> {
6887    let (width, height) = logical_viewport_to_render_target_size(size, scale_factor);
6888    PhysicalSize::new(width, height)
6889}
6890
6891fn recreate_target_texture(
6892    surface: &mut RenderSurface,
6893    render_cx: &RenderContext,
6894    width: u32,
6895    height: u32,
6896) {
6897    let device = &render_cx.devices[surface.dev_id].device;
6898    let size = wgpu::Extent3d {
6899        width: width.max(1),
6900        height: height.max(1),
6901        depth_or_array_layers: 1,
6902    };
6903    let new_texture = device.create_texture(&wgpu::TextureDescriptor {
6904        label: Some("fission_target_with_copy"),
6905        size,
6906        mip_level_count: 1,
6907        sample_count: 1,
6908        dimension: wgpu::TextureDimension::D2,
6909        format: wgpu::TextureFormat::Rgba8Unorm, // Must match Vello's internal format
6910        usage: wgpu::TextureUsages::STORAGE_BINDING
6911            | wgpu::TextureUsages::TEXTURE_BINDING
6912            | wgpu::TextureUsages::RENDER_ATTACHMENT
6913            | wgpu::TextureUsages::COPY_SRC
6914            | wgpu::TextureUsages::COPY_DST,
6915        view_formats: &[],
6916    });
6917    let new_view = new_texture.create_view(&wgpu::TextureViewDescriptor::default());
6918    surface.target_texture = new_texture;
6919    surface.target_view = new_view;
6920}
6921
6922fn sync_tracked_target_texture_size_to_surface(
6923    target_texture_size: &mut (u32, u32),
6924    surface_size: PhysicalSize<u32>,
6925) {
6926    *target_texture_size = (surface_size.width.max(1), surface_size.height.max(1));
6927}
6928
6929#[cfg(any(test, not(any(target_os = "android", target_os = "ios"))))]
6930fn native_window_size_for_logical_viewport(size: LayoutSize) -> winit::dpi::LogicalSize<f64> {
6931    winit::dpi::LogicalSize::new(size.width as f64, size.height as f64)
6932}
6933
6934#[cfg(test)]
6935mod tests {
6936    use super::{
6937        animation_redraw_interval, clamp_copy_extent_to_texture, collect_startup_deep_links_from,
6938        cursor_icon_for, downscale_rgba_box, layout_size_to_image_dimensions,
6939        logical_viewport_to_physical_size, logical_viewport_to_render_target_size,
6940        native_window_size_for_logical_viewport, normalize_scale_factor,
6941        normalize_winit_scroll_delta, physical_position_to_layout_point,
6942        physical_size_to_layout_size, rect_visible_in_scroll_ancestors,
6943        repeating_animation_redraw_interval, resize_is_unsettled, resolve_build_viewport,
6944        sync_tracked_target_texture_size_to_surface, texture_plans_fit_device_limits,
6945        visual_rect_for_node, window_insets_from_safe_area_frames, LiveResizeController,
6946        WindowViewportState,
6947    };
6948    use crate::pipeline::CompositorTexturePlan;
6949    use crate::InvalidationSet;
6950    use fission_core::{ActiveMotion, MotionEasing, MotionStateMap, MotionValue, ScrollStateMap};
6951    use fission_core::{DeepLinkConfig, MotionPropertyId, WidgetId};
6952    use fission_ir::semantics::MouseCursor;
6953    use fission_ir::{CoreIR, FlexDirection, LayoutOp, Op};
6954    use fission_layout::{LayoutNodeGeometry, LayoutRect, LayoutSize, LayoutSnapshot};
6955    use std::collections::HashMap;
6956    use std::time::Duration;
6957    use winit::dpi::{PhysicalPosition, PhysicalSize};
6958    use winit::event::MouseScrollDelta;
6959    use winit::window::CursorIcon;
6960
6961    #[test]
6962    fn semantic_cursor_icons_map_to_winit_icons() {
6963        assert_eq!(cursor_icon_for(MouseCursor::Default), CursorIcon::Default);
6964        assert_eq!(cursor_icon_for(MouseCursor::Pointer), CursorIcon::Pointer);
6965        assert_eq!(cursor_icon_for(MouseCursor::Text), CursorIcon::Text);
6966        assert_eq!(
6967            cursor_icon_for(MouseCursor::NotAllowed),
6968            CursorIcon::NotAllowed
6969        );
6970        assert_eq!(
6971            cursor_icon_for(MouseCursor::VerticalText),
6972            CursorIcon::VerticalText
6973        );
6974    }
6975
6976    #[test]
6977    fn winit_scroll_delta_normalizes_to_positive_down_and_right() {
6978        assert_eq!(
6979            normalize_winit_scroll_delta(&MouseScrollDelta::LineDelta(-1.0, -2.0), 1.0),
6980            (50.0, 100.0)
6981        );
6982        assert_eq!(
6983            normalize_winit_scroll_delta(
6984                &MouseScrollDelta::PixelDelta(PhysicalPosition::new(-20.0, -40.0)),
6985                2.0,
6986            ),
6987            (10.0, 20.0)
6988        );
6989    }
6990
6991    #[test]
6992    fn physical_input_position_maps_into_layout_space() {
6993        let point = physical_position_to_layout_point(
6994            PhysicalPosition::new(240.0, 360.0),
6995            2.0,
6996            PhysicalPosition::new(0, 0),
6997        );
6998        assert_eq!(point, fission_render::LayoutPoint::new(120.0, 180.0));
6999    }
7000
7001    #[test]
7002    fn physical_input_position_subtracts_content_origin_before_scaling() {
7003        let point = physical_position_to_layout_point(
7004            PhysicalPosition::new(240.0, 460.0),
7005            2.0,
7006            PhysicalPosition::new(0, 100),
7007        );
7008        assert_eq!(point, fission_render::LayoutPoint::new(120.0, 180.0));
7009    }
7010
7011    #[test]
7012    fn safe_area_frames_convert_to_logical_window_insets() {
7013        let insets = window_insets_from_safe_area_frames(
7014            PhysicalPosition::new(0, 177),
7015            PhysicalPosition::new(0, 0),
7016            PhysicalSize::new(1206, 2343),
7017            PhysicalSize::new(1206, 2622),
7018            3.0,
7019        );
7020
7021        assert_eq!(insets.left, 0.0);
7022        assert_eq!(insets.right, 0.0);
7023        assert_eq!(insets.top, 59.0);
7024        assert_eq!(insets.bottom, 34.0);
7025    }
7026
7027    #[test]
7028    fn visual_rect_subtracts_ancestor_scroll_offset() {
7029        let scroll = WidgetId::from_u128(1);
7030        let child = WidgetId::from_u128(2);
7031        let mut ir = CoreIR::new();
7032        ir.add_node(
7033            child,
7034            Op::Paint(fission_ir::PaintOp::DrawRect {
7035                fill: None,
7036                stroke: None,
7037                corner_radius: 0.0,
7038                shadow: None,
7039            }),
7040            Vec::new(),
7041        );
7042        ir.add_node(
7043            scroll,
7044            Op::Layout(LayoutOp::Scroll {
7045                direction: FlexDirection::Column,
7046                show_scrollbar: true,
7047                width: None,
7048                height: None,
7049                min_width: None,
7050                max_width: None,
7051                min_height: None,
7052                max_height: None,
7053                padding: [0.0; 4],
7054                flex_grow: 0.0,
7055                flex_shrink: 1.0,
7056            }),
7057            vec![child],
7058        );
7059        ir.set_root(scroll);
7060
7061        let mut snapshot = LayoutSnapshot::new(LayoutSize::new(100.0, 100.0));
7062        snapshot.nodes.insert(
7063            scroll,
7064            LayoutNodeGeometry {
7065                rect: LayoutRect::new(0.0, 0.0, 100.0, 100.0),
7066                content_size: LayoutSize::new(100.0, 400.0),
7067            },
7068        );
7069        snapshot.nodes.insert(
7070            child,
7071            LayoutNodeGeometry {
7072                rect: LayoutRect::new(0.0, 150.0, 80.0, 20.0),
7073                content_size: LayoutSize::new(80.0, 20.0),
7074            },
7075        );
7076        let mut scroll_map = ScrollStateMap::default();
7077        scroll_map.set_offset(scroll, 120.0);
7078
7079        let visual = visual_rect_for_node(&ir, &snapshot, &scroll_map, child).unwrap();
7080        assert_eq!(visual, LayoutRect::new(0.0, 30.0, 80.0, 20.0));
7081        assert!(rect_visible_in_scroll_ancestors(
7082            &ir,
7083            &snapshot,
7084            &scroll_map,
7085            child,
7086            visual
7087        ));
7088    }
7089
7090    #[test]
7091    fn repeating_animation_uses_reduced_frame_rate() {
7092        let min_frame = Duration::from_millis(16);
7093        let repeat_frame = Duration::from_millis(66);
7094        assert_eq!(
7095            animation_redraw_interval(false, Some(repeat_frame), false, min_frame),
7096            Some(repeat_frame)
7097        );
7098    }
7099
7100    #[test]
7101    fn finite_animation_keeps_full_frame_rate() {
7102        let min_frame = Duration::from_millis(16);
7103        assert_eq!(
7104            animation_redraw_interval(true, None, false, min_frame),
7105            Some(min_frame)
7106        );
7107        assert_eq!(
7108            animation_redraw_interval(false, None, true, min_frame),
7109            Some(min_frame)
7110        );
7111    }
7112
7113    #[test]
7114    fn idle_video_does_not_force_full_frame_rate() {
7115        let min_frame = Duration::from_millis(16);
7116        let repeat_frame = Duration::from_millis(66);
7117        assert_eq!(
7118            animation_redraw_interval(false, Some(repeat_frame), false, min_frame),
7119            Some(repeat_frame)
7120        );
7121    }
7122
7123    #[test]
7124    fn no_repeat_interval_means_no_idle_animation_redraw() {
7125        let min_frame = Duration::from_millis(16);
7126        assert_eq!(
7127            animation_redraw_interval(false, None, false, min_frame),
7128            None
7129        );
7130    }
7131
7132    #[test]
7133    fn repeat_animation_interval_uses_low_priority_hint() {
7134        let mut animation = MotionStateMap::default();
7135        animation.active.insert(
7136            (WidgetId::explicit("spinner"), MotionPropertyId::opacity()),
7137            ActiveMotion {
7138                target: WidgetId::explicit("spinner"),
7139                property: MotionPropertyId::opacity(),
7140                start_value: MotionValue::Scalar(0.3),
7141                end_value: MotionValue::Scalar(1.0),
7142                start_time: 0,
7143                duration: 600,
7144                repeat: true,
7145                frame_interval_ms: Some(166),
7146                easing: MotionEasing::Linear,
7147            },
7148        );
7149        assert_eq!(
7150            repeating_animation_redraw_interval(&animation, Duration::from_millis(66)),
7151            Some(Duration::from_millis(166))
7152        );
7153    }
7154
7155    #[test]
7156    fn repeat_animation_interval_chooses_fastest_active_repeat() {
7157        let mut animation = MotionStateMap {
7158            values: HashMap::new(),
7159            active: HashMap::new(),
7160            ..Default::default()
7161        };
7162        animation.active.insert(
7163            (WidgetId::explicit("slow"), MotionPropertyId::opacity()),
7164            ActiveMotion {
7165                target: WidgetId::explicit("slow"),
7166                property: MotionPropertyId::opacity(),
7167                start_value: MotionValue::Scalar(0.3),
7168                end_value: MotionValue::Scalar(1.0),
7169                start_time: 0,
7170                duration: 600,
7171                repeat: true,
7172                frame_interval_ms: Some(200),
7173                easing: MotionEasing::Linear,
7174            },
7175        );
7176        animation.active.insert(
7177            (WidgetId::explicit("fast"), MotionPropertyId::opacity()),
7178            ActiveMotion {
7179                target: WidgetId::explicit("fast"),
7180                property: MotionPropertyId::opacity(),
7181                start_value: MotionValue::Scalar(0.3),
7182                end_value: MotionValue::Scalar(1.0),
7183                start_time: 0,
7184                duration: 600,
7185                repeat: true,
7186                frame_interval_ms: Some(100),
7187                easing: MotionEasing::Linear,
7188            },
7189        );
7190        assert_eq!(
7191            repeating_animation_redraw_interval(&animation, Duration::from_millis(66)),
7192            Some(Duration::from_millis(100))
7193        );
7194    }
7195
7196    #[test]
7197    fn live_resize_reports_unsettled_until_deadline() {
7198        let settle = Duration::from_millis(90);
7199        let mut resize = LiveResizeController::new(settle);
7200        let now = std::time::Instant::now();
7201        resize.note_resize(now);
7202
7203        assert!(resize.is_live(now + Duration::from_millis(30)));
7204        assert!(resize_is_unsettled(
7205            false,
7206            false,
7207            resize.is_live(now + Duration::from_millis(30))
7208        ));
7209        assert!(!resize.is_live(now + Duration::from_millis(95)));
7210    }
7211
7212    #[test]
7213    fn viewport_resize_forces_build_viewport_refresh() {
7214        let target = LayoutSize::new(1440.0, 900.0);
7215        let mut invalidations = InvalidationSet::default();
7216
7217        let build_viewport = resolve_build_viewport(
7218            Some(LayoutSize::new(1024.0, 768.0)),
7219            target,
7220            true,
7221            &mut invalidations,
7222        );
7223
7224        assert!(invalidations.build);
7225        assert_eq!(build_viewport, target);
7226    }
7227
7228    #[test]
7229    fn stable_viewport_preserves_existing_build_viewport() {
7230        let target = LayoutSize::new(1024.0, 768.0);
7231        let mut invalidations = InvalidationSet::default();
7232
7233        let build_viewport = resolve_build_viewport(Some(target), target, true, &mut invalidations);
7234
7235        assert!(!invalidations.build);
7236        assert_eq!(build_viewport, target);
7237    }
7238
7239    #[test]
7240    fn oversized_texture_plan_forces_scene_fallback() {
7241        let plans = vec![CompositorTexturePlan {
7242            key: 1,
7243            bounds: LayoutRect::new(0.0, 0.0, 320.0, 9000.0),
7244            scene: Some(fission_render::RenderScene::new(LayoutRect::new(
7245                0.0, 0.0, 320.0, 9000.0,
7246            ))),
7247            scene_cache_key: Some(1),
7248            content_key: 1,
7249            local_dynamic: false,
7250            composite_dynamic: false,
7251            opacity: 1.0,
7252            transform: None,
7253            transform_clip: false,
7254            clip: None,
7255            children: Vec::new(),
7256            source_layer_path: None,
7257        }];
7258        assert!(!texture_plans_fit_device_limits(&plans, 1.0, 8192));
7259    }
7260
7261    #[test]
7262    fn nested_texture_plans_must_all_fit_device_limits() {
7263        let child = CompositorTexturePlan {
7264            key: 2,
7265            bounds: LayoutRect::new(0.0, 0.0, 400.0, 8400.0),
7266            scene: Some(fission_render::RenderScene::new(LayoutRect::new(
7267                0.0, 0.0, 400.0, 8400.0,
7268            ))),
7269            scene_cache_key: Some(2),
7270            content_key: 2,
7271            local_dynamic: false,
7272            composite_dynamic: false,
7273            opacity: 1.0,
7274            transform: None,
7275            transform_clip: false,
7276            clip: None,
7277            children: Vec::new(),
7278            source_layer_path: None,
7279        };
7280        let plans = vec![CompositorTexturePlan {
7281            key: 1,
7282            bounds: LayoutRect::new(0.0, 0.0, 800.0, 600.0),
7283            scene: None,
7284            scene_cache_key: None,
7285            content_key: 3,
7286            local_dynamic: false,
7287            composite_dynamic: false,
7288            opacity: 1.0,
7289            transform: None,
7290            transform_clip: false,
7291            clip: None,
7292            children: vec![child],
7293            source_layer_path: None,
7294        }];
7295        assert!(!texture_plans_fit_device_limits(&plans, 1.0, 8192));
7296    }
7297
7298    #[test]
7299    fn screenshot_dimensions_follow_logical_viewport() {
7300        let dims = layout_size_to_image_dimensions(fission_layout::LayoutSize::new(1600.0, 1200.0));
7301        assert_eq!(dims, (1600, 1200));
7302
7303        let rounded =
7304            layout_size_to_image_dimensions(fission_layout::LayoutSize::new(999.6, 700.4));
7305        assert_eq!(rounded, (1000, 700));
7306    }
7307
7308    #[test]
7309    fn simulated_resize_uses_physical_render_target_size() {
7310        let dims = logical_viewport_to_render_target_size(
7311            fission_layout::LayoutSize::new(1600.0, 1200.0),
7312            2.0,
7313        );
7314        assert_eq!(dims, (3200, 2400));
7315
7316        let fractional = logical_viewport_to_render_target_size(
7317            fission_layout::LayoutSize::new(430.0, 900.0),
7318            1.5,
7319        );
7320        assert_eq!(fractional, (645, 1350));
7321    }
7322
7323    #[test]
7324    fn physical_viewport_maps_to_logical_size_with_scale_factor() {
7325        let logical = physical_size_to_layout_size(PhysicalSize::new(1728, 1117), 1.5);
7326        assert_eq!(logical.width, 1152.0);
7327        assert!((logical.height - 744.6667).abs() < 0.001);
7328    }
7329
7330    #[test]
7331    fn scale_factor_change_preserves_logical_viewport_until_resize_arrives() {
7332        let viewport = WindowViewportState {
7333            physical_size: PhysicalSize::new(1600, 1200),
7334            scale_factor: 1.0,
7335        }
7336        .with_scale_factor(2.0);
7337
7338        assert_eq!(viewport.physical_size, PhysicalSize::new(3200, 2400));
7339        assert_eq!(
7340            viewport.logical_size(),
7341            fission_layout::LayoutSize::new(1600.0, 1200.0)
7342        );
7343    }
7344
7345    #[test]
7346    fn resized_event_overrides_scale_factor_prediction_authoritatively() {
7347        let viewport = WindowViewportState {
7348            physical_size: PhysicalSize::new(1600, 1200),
7349            scale_factor: 1.0,
7350        }
7351        .with_scale_factor(1.5)
7352        .with_physical_size(PhysicalSize::new(2412, 1809));
7353
7354        assert_eq!(viewport.physical_size, PhysicalSize::new(2412, 1809));
7355        assert_eq!(
7356            viewport.logical_size(),
7357            fission_layout::LayoutSize::new(1608.0, 1206.0)
7358        );
7359    }
7360
7361    #[test]
7362    fn fractional_logical_viewports_round_up_for_render_targets() {
7363        let physical =
7364            logical_viewport_to_physical_size(fission_layout::LayoutSize::new(430.2, 900.1), 1.5);
7365        assert_eq!(physical, PhysicalSize::new(646, 1351));
7366    }
7367
7368    #[test]
7369    fn scale_factor_prediction_never_undershoots_fractional_viewports() {
7370        let initial = WindowViewportState {
7371            physical_size: PhysicalSize::new(1728, 1117),
7372            scale_factor: 1.5,
7373        };
7374        let predicted = initial.with_scale_factor(2.0);
7375
7376        assert_eq!(predicted.physical_size, PhysicalSize::new(2304, 1490));
7377        assert!(predicted.logical_size().width >= initial.logical_size().width);
7378        assert!(predicted.logical_size().height >= initial.logical_size().height);
7379    }
7380
7381    #[test]
7382    fn logical_resize_updates_native_viewport_prediction() {
7383        let initial = WindowViewportState {
7384            physical_size: PhysicalSize::new(800, 632),
7385            scale_factor: 2.0,
7386        };
7387        let resized = initial.with_logical_size(fission_layout::LayoutSize::new(1600.0, 1200.0));
7388
7389        assert_eq!(resized.physical_size, PhysicalSize::new(3200, 2400));
7390        assert_eq!(
7391            resized.logical_size(),
7392            fission_layout::LayoutSize::new(1600.0, 1200.0)
7393        );
7394    }
7395
7396    #[test]
7397    fn logical_resize_requests_logical_window_dimensions() {
7398        let requested = native_window_size_for_logical_viewport(fission_layout::LayoutSize::new(
7399            1600.0, 2200.0,
7400        ));
7401
7402        assert_eq!(requested.width, 1600.0);
7403        assert_eq!(requested.height, 2200.0);
7404    }
7405
7406    #[test]
7407    fn invalid_scale_factors_fall_back_to_unit_scale() {
7408        assert_eq!(normalize_scale_factor(0.0), 1.0);
7409        assert_eq!(normalize_scale_factor(-2.0), 1.0);
7410        assert_eq!(normalize_scale_factor(f64::NAN), 1.0);
7411        assert_eq!(normalize_scale_factor(f64::INFINITY), 1.0);
7412        assert_eq!(normalize_scale_factor(1.5), 1.5);
7413    }
7414
7415    #[test]
7416    fn invalid_scale_factor_does_not_shrink_viewport_math() {
7417        let logical = physical_size_to_layout_size(PhysicalSize::new(1600, 1200), 0.0);
7418        assert_eq!(logical, fission_layout::LayoutSize::new(1600.0, 1200.0));
7419
7420        let render_target = logical_viewport_to_render_target_size(
7421            fission_layout::LayoutSize::new(1600.0, 1200.0),
7422            0.0,
7423        );
7424        assert_eq!(render_target, (1600, 1200));
7425    }
7426
7427    #[test]
7428    fn surface_resize_resets_custom_target_texture_tracking() {
7429        let mut tracked_target_texture_size = (1600, 1200);
7430
7431        sync_tracked_target_texture_size_to_surface(
7432            &mut tracked_target_texture_size,
7433            PhysicalSize::new(1055, 791),
7434        );
7435
7436        assert_eq!(tracked_target_texture_size, (1055, 791));
7437        assert_ne!(
7438            tracked_target_texture_size,
7439            logical_viewport_to_render_target_size(
7440                fission_layout::LayoutSize::new(1600.0, 1200.0),
7441                1.0,
7442            )
7443        );
7444    }
7445
7446    #[test]
7447    fn resize_settle_signal_tracks_real_resize_state() {
7448        assert!(resize_is_unsettled(true, false, false));
7449        assert!(resize_is_unsettled(false, true, false));
7450        assert!(resize_is_unsettled(false, false, true));
7451        assert!(!resize_is_unsettled(false, false, false));
7452    }
7453
7454    #[test]
7455    fn screenshot_copy_extent_never_exceeds_texture_bounds() {
7456        assert_eq!(
7457            clamp_copy_extent_to_texture(1600, 1200, 1055, 791),
7458            (1055, 791)
7459        );
7460        assert_eq!(clamp_copy_extent_to_texture(0, 0, 1055, 791), (1, 1));
7461        assert_eq!(
7462            clamp_copy_extent_to_texture(640, 480, 1055, 791),
7463            (640, 480)
7464        );
7465    }
7466
7467    #[test]
7468    fn integer_downscale_uses_fast_box_path() {
7469        let rgba = vec![
7470            10, 20, 30, 255, 30, 40, 50, 255, 50, 60, 70, 255, 70, 80, 90, 255,
7471        ];
7472        let downscaled = downscale_rgba_box(&rgba, 2, 2, 1, 1).expect("downscale");
7473        assert_eq!(downscaled, vec![40, 50, 60, 255]);
7474    }
7475
7476    #[test]
7477    fn startup_deep_link_collection_filters_to_declared_config() {
7478        let config = DeepLinkConfig::new()
7479            .scheme("fission")
7480            .domain("example.com")
7481            .path_prefix("/tasks");
7482
7483        let links = collect_startup_deep_links_from(
7484            &config,
7485            vec![
7486                "--ignored".to_string(),
7487                "fission://open/tasks/1".to_string(),
7488                "other://open/tasks/1".to_string(),
7489            ],
7490            vec!["https://example.com/tasks/2?source=email".to_string()],
7491        );
7492
7493        assert_eq!(links.len(), 2);
7494        assert_eq!(links[0].url, "https://example.com/tasks/2?source=email");
7495        assert!(links[0].cold_start);
7496        assert_eq!(links[1].url, "fission://open/tasks/1");
7497        assert!(links[1].cold_start);
7498    }
7499}