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