Skip to main content

fission_shell_winit/
lib.rs

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