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