Skip to main content

fission_shell_winit/
lib.rs

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