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