Skip to main content

fission_shell_winit/
lib.rs

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