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        .motion
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::MotionStateMap,
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    /// Register a reducer for host/shell route changes.
3448    ///
3449    /// The host dispatches [`fission_core::ShellRouteChanged`] when navigation
3450    /// updates. Applications should store route data in state and render the
3451    /// corresponding screen.
3452    pub fn with_route_handler(
3453        mut self,
3454        handler: fission_core::registry::Handler<S, fission_core::ShellRouteChanged>,
3455    ) -> Self {
3456        let mut registry = ActionRegistry::<S>::new();
3457        registry.register::<fission_core::ShellRouteChanged, _>(handler);
3458        self.runtime.absorb_persistent_registry(registry);
3459        self
3460    }
3461
3462    pub fn register_reducer(
3463        &mut self,
3464        action_id: ActionId,
3465        reducer: fission_core::action::Reducer<S>,
3466    ) -> Result<()> {
3467        self.runtime.register_reducer::<S>(action_id, reducer)
3468    }
3469
3470    pub fn absorb_registry(&mut self, registry: fission_core::ActionRegistry<S>) {
3471        self.runtime.absorb_persistent_registry(registry);
3472    }
3473
3474    pub fn run(self) -> Result<()> {
3475        self.run_inner(
3476            #[cfg(target_os = "android")]
3477            None,
3478        )
3479    }
3480
3481    #[cfg(target_os = "android")]
3482    pub fn run_with_android_app(self, android_app: AndroidApp) -> Result<()> {
3483        self.run_inner(Some(android_app))
3484    }
3485
3486    fn run_inner(
3487        mut self,
3488        #[cfg(target_os = "android")] android_app: Option<AndroidApp>,
3489    ) -> Result<()> {
3490        diag::emit(
3491            diag::DiagCategory::Frame,
3492            diag::DiagLevel::Info,
3493            diag::DiagEventKind::FrameStart { root: None },
3494        );
3495        diag::init_from_env();
3496
3497        // Build event loop with TestEvent as the user event type.
3498        // This allows the test control server to inject events via EventLoopProxy.
3499        let background_test_mode = std::env::var_os("FISSION_BACKGROUND_TEST").is_some();
3500        let mut event_loop_builder = EventLoopBuilder::<TestEvent>::with_user_event();
3501        #[cfg(target_os = "android")]
3502        if let Some(app) = android_app.as_ref() {
3503            android_capabilities::register_android_operation_capabilities(
3504                &mut self.async_registry,
3505                app,
3506            );
3507        }
3508        #[cfg(target_os = "android")]
3509        if let Some(app) = android_app {
3510            event_loop_builder.with_android_app(app);
3511        }
3512        #[cfg(target_os = "macos")]
3513        if background_test_mode {
3514            event_loop_builder.with_activation_policy(ActivationPolicy::Accessory);
3515            event_loop_builder.with_activate_ignoring_other_apps(false);
3516            event_loop_builder.with_default_menu(false);
3517        }
3518        let event_loop = event_loop_builder
3519            .build()
3520            .map_err(|e| anyhow::anyhow!("Event loop error: {}", e))?;
3521        let event_proxy = event_loop.create_proxy();
3522        #[cfg(feature = "tray")]
3523        let tray_event_rx = self
3524            .tray_config
3525            .as_ref()
3526            .map(|_| tray::install_event_forwarders(event_proxy.clone()));
3527        #[cfg(feature = "tray")]
3528        let tray_config = self.tray_config.clone();
3529        let window_title = self.title.clone();
3530        let web_mount_selector = self.web_mount_selector;
3531        let ime_handler = Arc::new(DesktopImeHandler::default());
3532        self.runtime = self.runtime.with_ime_handler(ime_handler.clone());
3533
3534        #[cfg(not(target_os = "android"))]
3535        let platform_window = build_window(
3536            &window_title,
3537            background_test_mode,
3538            &event_loop,
3539            web_mount_selector.as_deref(),
3540        )?;
3541        #[cfg(not(target_os = "android"))]
3542        ime_handler.set_window(Some(platform_window.clone()));
3543        #[cfg(target_os = "android")]
3544        let mut platform_window: Option<Arc<Window>> = None;
3545
3546        // Rendering state is created lazily so Android can wait for a valid
3547        // native surface after the first resume event.
3548        #[cfg(target_os = "android")]
3549        if std::env::var_os("WGPU_BACKEND").is_none() {
3550            eprintln!("fission-shell-winit: forcing WGPU_BACKEND=gl on Android");
3551            std::env::set_var("WGPU_BACKEND", "gl");
3552        }
3553        #[cfg(not(target_arch = "wasm32"))]
3554        let mut render_cx = RenderContext::new();
3555        #[cfg(not(target_arch = "wasm32"))]
3556        let mut render_state: Option<RenderState<'_>> = None;
3557        #[cfg(target_arch = "wasm32")]
3558        let mut web_renderer: Option<WebRenderer> = None;
3559        #[cfg(target_arch = "wasm32")]
3560        let pending_webgpu_init: PendingWebGpuInit = Rc::new(RefCell::new(None));
3561        #[cfg(target_arch = "wasm32")]
3562        let mut webgpu_init_in_flight = false;
3563        #[cfg(target_arch = "wasm32")]
3564        let mut web_renderer_reported = false;
3565        #[cfg(not(target_arch = "wasm32"))]
3566        let mut scene = Scene::new();
3567        #[cfg(not(target_arch = "wasm32"))]
3568        let mut retained_scene_cache = RetainedSceneCache::default();
3569
3570        #[cfg(not(target_os = "android"))]
3571        platform_window.request_redraw();
3572
3573        let mut startup_deep_links = self.startup_deep_links.clone();
3574        startup_deep_links.extend(collect_startup_deep_links(&self.deep_link_config));
3575        let startup_notification_responses = self.startup_notification_responses.clone();
3576
3577        let mut runtime = self.runtime;
3578        for link in startup_deep_links {
3579            runtime.dispatch(DeepLinkReceived { link }.into(), WidgetId::from_u128(0))?;
3580        }
3581        for response in startup_notification_responses {
3582            runtime.dispatch(
3583                NotificationResponseReceived { response }.into(),
3584                WidgetId::from_u128(0),
3585            )?;
3586        }
3587        let mut layout_engine = self.layout_engine;
3588        let root_widget = self.root_widget;
3589        let mut env = self.env;
3590        env.window.title = fission_core::WindowTitle::plain(window_title.clone());
3591        let mut applied_window_title = window_title.clone();
3592        let mut pipeline = self.pipeline;
3593        let measurer = self.measurer;
3594        let effect_result_tx = self.effect_result_tx;
3595        let effect_result_rx = self.effect_result_rx;
3596        let async_registry = self.async_registry;
3597        let startup_action = self.startup_action;
3598        let mut startup_dispatched = false;
3599        let mut next_service_instance_id = 1_u64;
3600        let mut active_services: HashMap<ServiceKey, ActiveServiceHandle> = HashMap::new();
3601        let mut service_bindings: HashMap<ServiceBindingKey, ServiceBindings> = HashMap::new();
3602
3603        #[cfg(target_os = "macos")]
3604        let video_backend: Arc<dyn VideoBackend> = Arc::new(MacVideoBackend::new(&platform_window));
3605        #[cfg(not(target_os = "macos"))]
3606        let video_backend: Arc<dyn VideoBackend> = Arc::new(MockVideoBackend::new());
3607        #[cfg(target_os = "macos")]
3608        let web_backend = MacWebBackend::new(&platform_window);
3609        #[cfg(not(target_os = "macos"))]
3610        let web_backend = MockWebBackend::new();
3611        let mut players: HashMap<WidgetId, ActivePlayer> = HashMap::new();
3612
3613        let mut last_cursor_position: Option<PhysicalPosition<f64>> = None;
3614        let mut active_primary_touch: Option<u64> = None;
3615        let mut touch_positions: HashMap<u64, PhysicalPosition<f64>> = HashMap::new();
3616        let max_fps = std::env::var("FISSION_MAX_FPS")
3617            .ok()
3618            .and_then(|v| v.parse::<u32>().ok())
3619            .filter(|v| *v > 0)
3620            .unwrap_or(60);
3621        let min_frame = Duration::from_secs_f32(1.0 / max_fps as f32);
3622        let repeat_animation_fps = std::env::var("FISSION_REPEAT_ANIMATION_FPS")
3623            .ok()
3624            .and_then(|v| v.parse::<u32>().ok())
3625            .filter(|v| *v > 0)
3626            .map(|v| v.min(max_fps))
3627            .unwrap_or(10);
3628        let repeat_animation_frame = Duration::from_secs_f32(1.0 / repeat_animation_fps as f32);
3629        let resize_fps = std::env::var("FISSION_RESIZE_FPS")
3630            .ok()
3631            .and_then(|v| v.parse::<u32>().ok())
3632            .filter(|v| *v > 0)
3633            .map(|v| v.min(max_fps))
3634            .unwrap_or(60);
3635        let resize_frame = Duration::from_secs_f32(1.0 / resize_fps as f32);
3636        let resize_settle_delay = Duration::from_millis(
3637            std::env::var("FISSION_RESIZE_SETTLE_MS")
3638                .ok()
3639                .and_then(|v| v.parse::<u64>().ok())
3640                .filter(|v| *v > 0)
3641                .unwrap_or(90),
3642        );
3643        let mut last_redraw_at = Instant::now()
3644            .checked_sub(min_frame)
3645            .unwrap_or_else(Instant::now);
3646        let mut redraw_pending = false;
3647        let mut last_frame_time = Instant::now();
3648        let blink_enabled = std::env::var("FISSION_TEXTINPUT_BLINK")
3649            .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
3650            .unwrap_or(true);
3651        let blink_period = Duration::from_millis(
3652            std::env::var("FISSION_TEXTINPUT_BLINK_MS")
3653                .ok()
3654                .and_then(|v| v.parse::<u64>().ok())
3655                .filter(|v| *v > 0)
3656                .unwrap_or(530),
3657        );
3658        let mut last_blink_toggle = Instant::now();
3659        let mut blink_focus_id: Option<WidgetId> = None;
3660        let text_trace_enabled = std::env::var("FISSION_TEXT_TRACE")
3661            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
3662            .unwrap_or(false);
3663        let mut frame_trace = FrameTraceState::new(frame_trace_enabled());
3664        let mut presented_frames: u64 = 0;
3665        let mut next_text_trace_seq: u64 = 0;
3666        let mut pending_text_traces: VecDeque<PendingTextTrace> = VecDeque::new();
3667        #[cfg(target_arch = "wasm32")]
3668        let mut pending_web_input_at: Option<Instant> = None;
3669        let mut current_mods: u8 = 0;
3670
3671        // Test control (enabled via FISSION_TEST_CONTROL_PORT env var).
3672        // The TCP server injects TestEvents via the EventLoopProxy. Query
3673        // events carry per-command response channels, so a timed-out command
3674        // cannot poison the next command with a stale response.
3675        #[cfg(not(target_arch = "wasm32"))]
3676        let test_control_port = self.test_control_port.or_else(|| {
3677            std::env::var("FISSION_TEST_CONTROL_PORT")
3678                .ok()
3679                .and_then(|v| v.parse::<u16>().ok())
3680        });
3681        #[cfg(all(target_os = "android", not(target_arch = "wasm32")))]
3682        let pending_test_events = test_control::create_pending_event_queue();
3683        #[cfg(not(target_arch = "wasm32"))]
3684        let test_control_enabled = test_control_port
3685            .map(|port| {
3686                #[cfg(target_os = "android")]
3687                let injector = test_control::EventInjector::Queue {
3688                    queue: pending_test_events.clone(),
3689                    wake_proxy: Some(event_proxy.clone()),
3690                };
3691                #[cfg(not(target_os = "android"))]
3692                let injector = test_control::EventInjector::Proxy(event_proxy.clone());
3693                test_control::spawn_server(port, injector);
3694                true
3695            })
3696            .unwrap_or(false);
3697        #[cfg(target_arch = "wasm32")]
3698        let test_control_enabled = false;
3699        #[cfg(not(target_os = "android"))]
3700        let _ = test_control_enabled;
3701        // Pending screenshot/pump: path + whether it needs a screenshot (vs pump).
3702        let mut pending_screenshot_path: Option<String> = None;
3703        let mut pending_screenshot_response_tx: Option<test_control::ResponseSender> = None;
3704        #[cfg(not(target_os = "android"))]
3705        let mut window_viewport = WindowViewportState::from_window(&platform_window);
3706        #[cfg(target_os = "android")]
3707        let mut window_viewport: Option<WindowViewportState> = None;
3708        #[cfg(not(target_os = "android"))]
3709        let mut pending_resize = Some(window_viewport);
3710        #[cfg(target_os = "android")]
3711        let mut pending_resize = None;
3712        let mut resize_needs_settled_frame = pending_resize.is_some();
3713        let mut pending_capture_settle = false;
3714        let mut last_built_viewport: Option<LayoutSize> = None;
3715        let mut live_resize = LiveResizeController::new(resize_settle_delay);
3716        #[cfg(feature = "tray")]
3717        let mut active_tray: Option<tray::ActiveTray<S>> = None;
3718        let mut invalidations = InvalidationSet {
3719            build: true,
3720            layout: true,
3721            paint: true,
3722            composite: true,
3723        };
3724        let mut vello_image_cache_generation = fission_render_vello::image_cache_generation();
3725        let mut software_image_cache_generation = software_renderer::image_cache_generation();
3726
3727        let event_handler =
3728            move |event: Event<TestEvent>, elwt: &EventLoopWindowTarget<TestEvent>| {
3729                elwt.set_control_flow(ControlFlow::Wait);
3730                let debug_android_events = cfg!(target_os = "android")
3731                    && std::env::var_os("FISSION_DEBUG_ANDROID_EVENTS").is_some();
3732
3733                let mut handle_test_event = |test_event: TestEvent| {
3734                    if debug_android_events {
3735                        eprintln!("[android-events] user_event={test_event:?}");
3736                    }
3737                    match test_event {
3738                        TestEvent::MouseMove { x, y } => {
3739                            let Some(window) = platform_window.active_window() else {
3740                                return;
3741                            };
3742                            let scale_factor = window.scale_factor();
3743                            last_cursor_position = Some(PhysicalPosition::new(
3744                                (x as f64) * scale_factor,
3745                                (y as f64) * scale_factor,
3746                            ));
3747                            handle_cursor_moved(
3748                                x,
3749                                y,
3750                                0,
3751                                &mut runtime,
3752                                &pipeline,
3753                                &effect_result_tx,
3754                                &event_proxy,
3755                                &async_registry,
3756                                &mut active_services,
3757                                &mut service_bindings,
3758                                &mut next_service_instance_id,
3759                                window,
3760                                elwt,
3761                                &mut last_redraw_at,
3762                                min_frame,
3763                                &mut redraw_pending,
3764                                &mut frame_trace,
3765                                &mut invalidations,
3766                            );
3767                        }
3768                        TestEvent::MouseDown { x, y, button } => {
3769                            let Some(window) = platform_window.active_window() else {
3770                                return;
3771                            };
3772                            let btn = map_test_button(button);
3773                            handle_mouse_button(
3774                                x,
3775                                y,
3776                                btn,
3777                                true,
3778                                0,
3779                                &mut runtime,
3780                                &pipeline,
3781                                &effect_result_tx,
3782                                &event_proxy,
3783                                &async_registry,
3784                                &mut active_services,
3785                                &mut service_bindings,
3786                                &mut next_service_instance_id,
3787                                window,
3788                                elwt,
3789                                &mut last_redraw_at,
3790                                min_frame,
3791                                &mut redraw_pending,
3792                                text_trace_enabled,
3793                                &mut pending_text_traces,
3794                                &mut next_text_trace_seq,
3795                                presented_frames,
3796                                &mut last_blink_toggle,
3797                                &mut frame_trace,
3798                                &mut invalidations,
3799                            );
3800                        }
3801                        TestEvent::MouseUp { x, y, button } => {
3802                            let Some(window) = platform_window.active_window() else {
3803                                return;
3804                            };
3805                            let btn = map_test_button(button);
3806                            handle_mouse_button(
3807                                x,
3808                                y,
3809                                btn,
3810                                false,
3811                                0,
3812                                &mut runtime,
3813                                &pipeline,
3814                                &effect_result_tx,
3815                                &event_proxy,
3816                                &async_registry,
3817                                &mut active_services,
3818                                &mut service_bindings,
3819                                &mut next_service_instance_id,
3820                                window,
3821                                elwt,
3822                                &mut last_redraw_at,
3823                                min_frame,
3824                                &mut redraw_pending,
3825                                text_trace_enabled,
3826                                &mut pending_text_traces,
3827                                &mut next_text_trace_seq,
3828                                presented_frames,
3829                                &mut last_blink_toggle,
3830                                &mut frame_trace,
3831                                &mut invalidations,
3832                            );
3833                        }
3834                        TestEvent::KeyDown {
3835                            key_code,
3836                            modifiers,
3837                        } => {
3838                            let Some(window) = platform_window.active_window() else {
3839                                return;
3840                            };
3841                            let code = parse_key_code(&key_code);
3842                            handle_key_down::<S>(
3843                                code,
3844                                modifiers,
3845                                &mut runtime,
3846                                &pipeline,
3847                                &effect_result_tx,
3848                                &event_proxy,
3849                                &async_registry,
3850                                &mut active_services,
3851                                &mut service_bindings,
3852                                &mut next_service_instance_id,
3853                                window,
3854                                elwt,
3855                                &mut last_redraw_at,
3856                                min_frame,
3857                                &mut redraw_pending,
3858                                text_trace_enabled,
3859                                &mut pending_text_traces,
3860                                &mut next_text_trace_seq,
3861                                presented_frames,
3862                                &mut last_blink_toggle,
3863                                self.key_handler.as_ref(),
3864                                &mut frame_trace,
3865                                &mut invalidations,
3866                            );
3867                        }
3868                        TestEvent::KeyUp { .. } => {
3869                            let Some(window) = platform_window.active_window() else {
3870                                return;
3871                            };
3872                            request_redraw_logged(
3873                                window,
3874                                elwt,
3875                                &mut last_redraw_at,
3876                                min_frame,
3877                                &mut redraw_pending,
3878                                &mut frame_trace,
3879                                "test_key_up",
3880                            );
3881                        }
3882                        TestEvent::TextInput { text } => {
3883                            let Some(window) = platform_window.active_window() else {
3884                                return;
3885                            };
3886                            if let (Some(ir), Some(layout)) =
3887                                (&pipeline.prev_ir, &pipeline.last_snapshot)
3888                            {
3889                                let target =
3890                                    focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
3891                                if target.is_some()
3892                                    || focused_custom_text_input(
3893                                        &runtime,
3894                                        pipeline.prev_ir.as_ref(),
3895                                    )
3896                                {
3897                                    let trace_seq = start_text_trace(
3898                                        text_trace_enabled && target.is_some(),
3899                                        &mut pending_text_traces,
3900                                        &mut next_text_trace_seq,
3901                                        format!("test_text_input:{}", text.chars().count()),
3902                                        target,
3903                                        presented_frames,
3904                                    );
3905                                    runtime
3906                                        .handle_input(
3907                                            InputEvent::Ime(
3908                                                fission_core::event::ImeEvent::Commit {
3909                                                    text: text.clone(),
3910                                                },
3911                                            ),
3912                                            ir,
3913                                            layout,
3914                                        )
3915                                        .ok();
3916                                    invalidations.mark_build();
3917                                    mark_text_trace_handled(&mut pending_text_traces, trace_seq);
3918                                    if process_pending_effects(
3919                                        &mut runtime,
3920                                        &effect_result_tx,
3921                                        &event_proxy,
3922                                        &async_registry,
3923                                        &mut active_services,
3924                                        &mut service_bindings,
3925                                        &mut next_service_instance_id,
3926                                    ) {
3927                                        mark_text_trace_effects(
3928                                            &mut pending_text_traces,
3929                                            trace_seq,
3930                                        );
3931                                        invalidations.mark_build();
3932                                    }
3933                                    request_redraw_logged(
3934                                        window,
3935                                        elwt,
3936                                        &mut last_redraw_at,
3937                                        min_frame,
3938                                        &mut redraw_pending,
3939                                        &mut frame_trace,
3940                                        "test_text_input",
3941                                    );
3942                                } else {
3943                                    for ch in text.chars() {
3944                                        let key = if ch == ' ' {
3945                                            KeyCode::Space
3946                                        } else if ch == '\n' {
3947                                            KeyCode::Enter
3948                                        } else {
3949                                            KeyCode::Char(ch)
3950                                        };
3951                                        handle_key_down::<S>(
3952                                            key,
3953                                            0,
3954                                            &mut runtime,
3955                                            &pipeline,
3956                                            &effect_result_tx,
3957                                            &event_proxy,
3958                                            &async_registry,
3959                                            &mut active_services,
3960                                            &mut service_bindings,
3961                                            &mut next_service_instance_id,
3962                                            window,
3963                                            elwt,
3964                                            &mut last_redraw_at,
3965                                            min_frame,
3966                                            &mut redraw_pending,
3967                                            text_trace_enabled,
3968                                            &mut pending_text_traces,
3969                                            &mut next_text_trace_seq,
3970                                            presented_frames,
3971                                            &mut last_blink_toggle,
3972                                            self.key_handler.as_ref(),
3973                                            &mut frame_trace,
3974                                            &mut invalidations,
3975                                        );
3976                                    }
3977                                }
3978                            }
3979                        }
3980                        TestEvent::Scroll { x, y, dx, dy } => {
3981                            let Some(window) = platform_window.active_window() else {
3982                                return;
3983                            };
3984                            handle_scroll(
3985                                x,
3986                                y,
3987                                dx,
3988                                dy,
3989                                0,
3990                                &mut runtime,
3991                                &pipeline,
3992                                &effect_result_tx,
3993                                &event_proxy,
3994                                &async_registry,
3995                                &mut active_services,
3996                                &mut service_bindings,
3997                                &mut next_service_instance_id,
3998                                window,
3999                                elwt,
4000                                &mut last_redraw_at,
4001                                min_frame,
4002                                &mut redraw_pending,
4003                                &mut frame_trace,
4004                                &mut invalidations,
4005                            );
4006                        }
4007                        TestEvent::Resize { width, height } => {
4008                            let Some(window) = platform_window.active_window() else {
4009                                return;
4010                            };
4011                            if width > 0 && height > 0 {
4012                                let requested_logical_size =
4013                                    LayoutSize::new(width as f32, height as f32);
4014                                let current_viewport = pending_resize
4015                                    .unwrap_or_else(|| WindowViewportState::from_window(window))
4016                                    .with_logical_size(requested_logical_size);
4017                                #[cfg(not(any(target_os = "android", target_os = "ios")))]
4018                                {
4019                                    let _ = window.request_inner_size(
4020                                        native_window_size_for_logical_viewport(
4021                                            requested_logical_size,
4022                                        ),
4023                                    );
4024                                }
4025                                #[cfg(not(target_os = "android"))]
4026                                {
4027                                    window_viewport = current_viewport;
4028                                }
4029                                #[cfg(target_os = "android")]
4030                                {
4031                                    window_viewport = Some(current_viewport);
4032                                }
4033                                apply_authoritative_resize(
4034                                    window,
4035                                    elwt,
4036                                    current_viewport,
4037                                    &mut pending_resize,
4038                                    &mut resize_needs_settled_frame,
4039                                    &mut pending_capture_settle,
4040                                    pending_screenshot_path.as_deref(),
4041                                    &mut live_resize,
4042                                    &mut invalidations,
4043                                    &mut last_redraw_at,
4044                                    resize_frame,
4045                                    &mut redraw_pending,
4046                                    &mut frame_trace,
4047                                    "test_resize",
4048                                );
4049                            }
4050                        }
4051                        TestEvent::TapText { text, response_tx } => {
4052                            let Some(window) = platform_window.active_window() else {
4053                                let _ =
4054                                    response_tx.send(fission_test_driver::TestResponse::Error {
4055                                        message: "window not ready".into(),
4056                                    });
4057                                return;
4058                            };
4059                            let resp = handle_tap_text(&text, &mut runtime, &pipeline);
4060                            if matches!(resp, fission_test_driver::TestResponse::Ok { .. }) {
4061                                invalidations.mark_build();
4062                                if process_pending_effects(
4063                                    &mut runtime,
4064                                    &effect_result_tx,
4065                                    &event_proxy,
4066                                    &async_registry,
4067                                    &mut active_services,
4068                                    &mut service_bindings,
4069                                    &mut next_service_instance_id,
4070                                ) {
4071                                    invalidations.mark_build();
4072                                }
4073                            }
4074                            let _ = response_tx.send(resp);
4075                            request_redraw_logged(
4076                                window,
4077                                elwt,
4078                                &mut last_redraw_at,
4079                                min_frame,
4080                                &mut redraw_pending,
4081                                &mut frame_trace,
4082                                "test_tap_text",
4083                            );
4084                        }
4085                        TestEvent::Screenshot { path, response_tx } => {
4086                            let Some(window) = platform_window.active_window() else {
4087                                let _ =
4088                                    response_tx.send(fission_test_driver::TestResponse::Error {
4089                                        message: "window not ready".into(),
4090                                    });
4091                                return;
4092                            };
4093                            pending_screenshot_path = Some(path);
4094                            pending_screenshot_response_tx = Some(response_tx);
4095                            pending_capture_settle = resize_is_unsettled(
4096                                pending_resize.is_some(),
4097                                resize_needs_settled_frame,
4098                                live_resize.is_live(Instant::now()),
4099                            );
4100                            window.request_redraw();
4101                        }
4102                        TestEvent::CaptureScreenshot { response_tx } => {
4103                            let Some(window) = platform_window.active_window() else {
4104                                let _ =
4105                                    response_tx.send(fission_test_driver::TestResponse::Error {
4106                                        message: "window not ready".into(),
4107                                    });
4108                                return;
4109                            };
4110                            pending_screenshot_path = Some("__capture__".into());
4111                            pending_screenshot_response_tx = Some(response_tx);
4112                            pending_capture_settle = resize_is_unsettled(
4113                                pending_resize.is_some(),
4114                                resize_needs_settled_frame,
4115                                live_resize.is_live(Instant::now()),
4116                            );
4117                            window.request_redraw();
4118                        }
4119                        TestEvent::GetText { response_tx } => {
4120                            let resp =
4121                                build_get_text_response(&pipeline, &runtime.runtime_state.scroll);
4122                            let _ = response_tx.send(resp);
4123                        }
4124                        TestEvent::GetTree { response_tx } => {
4125                            let resp =
4126                                build_get_tree_response(&pipeline, &runtime.runtime_state.scroll);
4127                            let _ = response_tx.send(resp);
4128                        }
4129                        TestEvent::Pump { response_tx } => {
4130                            let Some(window) = platform_window.active_window() else {
4131                                let _ =
4132                                    response_tx.send(fission_test_driver::TestResponse::Error {
4133                                        message: "window not ready".into(),
4134                                    });
4135                                return;
4136                            };
4137                            pending_screenshot_path = Some("__pump__".into());
4138                            pending_screenshot_response_tx = Some(response_tx);
4139                            pending_capture_settle = resize_is_unsettled(
4140                                pending_resize.is_some(),
4141                                resize_needs_settled_frame,
4142                                live_resize.is_live(Instant::now()),
4143                            );
4144                            window.request_redraw();
4145                        }
4146                        TestEvent::Wake => {
4147                            if let Some(window) = platform_window.active_window() {
4148                                request_redraw_logged(
4149                                    window,
4150                                    elwt,
4151                                    &mut last_redraw_at,
4152                                    min_frame,
4153                                    &mut redraw_pending,
4154                                    &mut frame_trace,
4155                                    "wake",
4156                                );
4157                            }
4158                        }
4159                        TestEvent::Wait { ms: _, response_tx } => {
4160                            let _ = response_tx.send(fission_test_driver::TestResponse::Ok {});
4161                        }
4162                        TestEvent::Quit => {
4163                            elwt.exit();
4164                        }
4165                    }
4166                };
4167
4168                #[cfg(target_os = "android")]
4169                let mut drain_pending_test_events = || loop {
4170                    let pending = {
4171                        let mut pending = pending_test_events
4172                            .lock()
4173                            .expect("pending test events lock poisoned");
4174                        pending.pop_front()
4175                    };
4176                    let Some(test_event) = pending else {
4177                        break;
4178                    };
4179                    if debug_android_events {
4180                        eprintln!("[android-debug] draining_test_queue");
4181                    }
4182                    handle_test_event(test_event);
4183                };
4184
4185                match event {
4186                    #[cfg(feature = "tray")]
4187                    Event::NewEvents(StartCause::Init) => {
4188                        if active_tray.is_none() {
4189                            if let Some(config) = tray_config.clone() {
4190                                match tray::ActiveTray::build(config) {
4191                                    Ok(tray) => {
4192                                        active_tray = Some(tray);
4193                                    }
4194                                    Err(error) => {
4195                                        eprintln!("Fission tray setup error: {error:?}");
4196                                    }
4197                                }
4198                            }
4199                        }
4200                    }
4201                    Event::Resumed => {
4202                        if debug_android_events {
4203                            eprintln!("[android-events] resumed");
4204                        }
4205                        #[cfg(target_os = "android")]
4206                        if platform_window.is_none() {
4207                            match build_window(
4208                                &window_title,
4209                                background_test_mode,
4210                                elwt,
4211                                web_mount_selector.as_deref(),
4212                            ) {
4213                                Ok(new_window) => {
4214                                    ime_handler.set_window(Some(new_window.clone()));
4215                                    sync_window_cursor(&new_window, &runtime);
4216                                    platform_window = Some(new_window);
4217                                }
4218                                Err(err) => {
4219                                    eprintln!("window build error: {err}");
4220                                    elwt.exit();
4221                                    return;
4222                                }
4223                            }
4224                        }
4225                        let Some(window) = platform_window.active_window() else {
4226                            return;
4227                        };
4228                        let current_viewport = WindowViewportState::from_window(window);
4229                        #[cfg(not(target_os = "android"))]
4230                        {
4231                            window_viewport = current_viewport;
4232                        }
4233                        #[cfg(target_os = "android")]
4234                        {
4235                            window_viewport = Some(current_viewport);
4236                        }
4237                        #[cfg(not(target_arch = "wasm32"))]
4238                        if render_state.is_none()
4239                            && current_viewport.physical_size.width > 0
4240                            && current_viewport.physical_size.height > 0
4241                        {
4242                            if let Some(render_window) = platform_window.active_window_arc() {
4243                                match create_render_state(
4244                                    &mut render_cx,
4245                                    render_window,
4246                                    current_viewport,
4247                                ) {
4248                                    Ok(mut state) => {
4249                                        if let Err(err) = present_startup_clear_frame(
4250                                            &mut state,
4251                                            &render_cx,
4252                                            theme_background_wgpu_color(&env),
4253                                        ) {
4254                                            eprintln!("startup clear frame failed: {err}");
4255                                        }
4256                                        render_state = Some(state);
4257                                    }
4258                                    Err(err) => {
4259                                        eprintln!("render surface not ready on resume: {err}");
4260                                    }
4261                                }
4262                            }
4263                        }
4264                        pending_resize = Some(current_viewport);
4265                        resize_needs_settled_frame = true;
4266                        if pending_screenshot_path.is_some() {
4267                            pending_capture_settle = true;
4268                        }
4269                        invalidations.mark_composite();
4270                        request_redraw_logged(
4271                            window,
4272                            elwt,
4273                            &mut last_redraw_at,
4274                            min_frame,
4275                            &mut redraw_pending,
4276                            &mut frame_trace,
4277                            "app_resumed",
4278                        );
4279                    }
4280                    Event::Suspended => {
4281                        #[cfg(not(target_arch = "wasm32"))]
4282                        {
4283                            render_state = None;
4284                        }
4285                        #[cfg(target_arch = "wasm32")]
4286                        {
4287                            web_renderer = None;
4288                            webgpu_init_in_flight = false;
4289                            *pending_webgpu_init.borrow_mut() = None;
4290                            web_renderer_reported = false;
4291                        }
4292                        #[cfg(target_os = "android")]
4293                        {
4294                            ime_handler.set_window(None);
4295                            platform_window = None;
4296                            window_viewport = None;
4297                            pending_resize = None;
4298                            resize_needs_settled_frame = false;
4299                            pending_capture_settle = false;
4300                            last_built_viewport = None;
4301                            last_cursor_position = None;
4302                            active_primary_touch = None;
4303                            touch_positions.clear();
4304                        }
4305                    }
4306                    // ═══════════════════════════════════════════════════════
4307                    // UserEvent — injected by test control server via proxy
4308                    // ═══════════════════════════════════════════════════════
4309                    Event::UserEvent(test_event) => {
4310                        #[cfg(target_os = "android")]
4311                        if matches!(test_event, TestEvent::Wake) {
4312                            if debug_android_events {
4313                                eprintln!("[android-debug] wake_received");
4314                            }
4315                            drain_pending_test_events();
4316                            return;
4317                        }
4318                        handle_test_event(test_event)
4319                    }
4320
4321                    // ═══════════════════════════════════════════════════════
4322                    // AboutToWait — idle / animation / blink / effects
4323                    // ═══════════════════════════════════════════════════════
4324                    Event::AboutToWait => {
4325                        let Some(window) = platform_window.active_window() else {
4326                            elwt.set_control_flow(ControlFlow::Wait);
4327                            return;
4328                        };
4329                        #[cfg(target_os = "android")]
4330                        drain_pending_test_events();
4331                        #[cfg(feature = "tray")]
4332                        if let (Some(rx), Some(active)) =
4333                            (tray_event_rx.as_ref(), active_tray.as_ref())
4334                        {
4335                            while let Ok(event) = rx.try_recv() {
4336                                match active.handle_event(event, window, &mut runtime) {
4337                                    Ok(outcome) => {
4338                                        if outcome.quit {
4339                                            elwt.exit();
4340                                            return;
4341                                        }
4342                                        if outcome.redraw {
4343                                            invalidations.mark_build();
4344                                            if process_pending_effects(
4345                                                &mut runtime,
4346                                                &effect_result_tx,
4347                                                &event_proxy,
4348                                                &async_registry,
4349                                                &mut active_services,
4350                                                &mut service_bindings,
4351                                                &mut next_service_instance_id,
4352                                            ) {
4353                                                invalidations.mark_build();
4354                                            }
4355                                            request_redraw_logged(
4356                                                window,
4357                                                elwt,
4358                                                &mut last_redraw_at,
4359                                                min_frame,
4360                                                &mut redraw_pending,
4361                                                &mut frame_trace,
4362                                                "tray_menu_action",
4363                                            );
4364                                        }
4365                                    }
4366                                    Err(error) => {
4367                                        eprintln!("Fission tray event error: {error:?}");
4368                                    }
4369                                }
4370                            }
4371                        }
4372                        let now = Instant::now();
4373
4374                        // Video Logic
4375                        let mut surfaces = pipeline.video_surfaces.clone();
4376                        let mut active_nodes = std::collections::HashSet::new();
4377
4378                        for surface in &mut surfaces {
4379                            active_nodes.insert(surface.widget_id);
4380
4381                            // Create player if missing
4382                            if !players.contains_key(&surface.widget_id) {
4383                                if let Some(state) =
4384                                    runtime.runtime_state.video.states.get(&surface.widget_id)
4385                                {
4386                                    let source = &state.asset_source;
4387                                    if !source.is_empty() {
4388                                        let player = video_backend.create_player(source);
4389                                        surface.surface_id = player.surface_id();
4390                                        if let Some(state) = runtime
4391                                            .runtime_state
4392                                            .video
4393                                            .states
4394                                            .get_mut(&surface.widget_id)
4395                                        {
4396                                            state.surface_id = Some(surface.surface_id);
4397                                        }
4398                                        players.insert(
4399                                            surface.widget_id,
4400                                            ActivePlayer {
4401                                                player,
4402                                                last_status: None,
4403                                                last_rate: None,
4404                                                last_volume: None,
4405                                                last_muted: None,
4406                                            },
4407                                        );
4408                                    }
4409                                }
4410                            } else if let Some(active_player) = players.get(&surface.widget_id) {
4411                                surface.surface_id = active_player.player.surface_id();
4412                            }
4413                        }
4414
4415                        // Cleanup inactive players
4416                        players.retain(|id, _| active_nodes.contains(id));
4417
4418                        // Update backend
4419                        video_backend.present_surfaces(&surfaces);
4420                        let web_surfaces = pipeline.web_surfaces.clone();
4421                        web_backend.present_surfaces(&web_surfaces);
4422
4423                        // Video Logic - Process Player Events and Sync State
4424                        for (widget_id, active_player) in players.iter_mut() {
4425                            if let Some(video_state) =
4426                                runtime.runtime_state.video.states.get_mut(widget_id)
4427                            {
4428                                let player = &mut active_player.player;
4429
4430                                // Sync player controls from runtime state
4431                                if active_player.last_status != Some(video_state.status) {
4432                                    match video_state.status {
4433                                        VideoStatus::Playing => player.play(),
4434                                        VideoStatus::Paused => player.pause(),
4435                                        VideoStatus::Stopped => player.stop(),
4436                                        _ => {}
4437                                    }
4438                                    active_player.last_status = Some(video_state.status);
4439                                }
4440
4441                                // Update runtime state from player events
4442                                for event in player.poll_events() {
4443                                    match event {
4444                                        VideoEvent::Ready { duration } => {
4445                                            video_state.duration_ms = Some(duration);
4446                                            if video_state.status == VideoStatus::Playing {
4447                                                player.play();
4448                                            }
4449                                        }
4450                                        VideoEvent::Ended => {
4451                                            video_state.status = VideoStatus::Ended;
4452                                            active_player.last_status = Some(VideoStatus::Ended);
4453                                            request_redraw_logged(
4454                                                &window,
4455                                                elwt,
4456                                                &mut last_redraw_at,
4457                                                min_frame,
4458                                                &mut redraw_pending,
4459                                                &mut frame_trace,
4460                                                "video_ended",
4461                                            );
4462                                        }
4463                                        VideoEvent::Error(e) => {
4464                                            eprintln!(
4465                                                "Video playback error for {:?}: {:?}",
4466                                                widget_id, e
4467                                            );
4468                                            video_state.status = VideoStatus::Error;
4469                                            active_player.last_status = Some(VideoStatus::Error);
4470                                            request_redraw_logged(
4471                                                &window,
4472                                                elwt,
4473                                                &mut last_redraw_at,
4474                                                min_frame,
4475                                                &mut redraw_pending,
4476                                                &mut frame_trace,
4477                                                "video_error",
4478                                            );
4479                                        }
4480                                    }
4481                                }
4482                                // Sync other properties
4483                                video_state.position_ms = player.position();
4484
4485                                if active_player.last_rate != Some(video_state.rate) {
4486                                    player.set_rate(video_state.rate);
4487                                    active_player.last_rate = Some(video_state.rate);
4488                                }
4489                                if active_player.last_volume != Some(video_state.volume) {
4490                                    player.set_volume(video_state.volume);
4491                                    active_player.last_volume = Some(video_state.volume);
4492                                }
4493                                if active_player.last_muted != Some(video_state.muted) {
4494                                    player.set_muted(video_state.muted);
4495                                    active_player.last_muted = Some(video_state.muted);
4496                                }
4497
4498                                if let Some(seek_pos) = video_state.pending_seek.take() {
4499                                    player.seek_to(seek_pos);
4500                                }
4501                            }
4502                        }
4503
4504                        let has_finite_animation = runtime
4505                            .runtime_state
4506                            .motion
4507                            .active
4508                            .values()
4509                            .any(|anim| !anim.repeat);
4510                        let resize_unsettled = resize_is_unsettled(
4511                            pending_resize.is_some(),
4512                            resize_needs_settled_frame,
4513                            live_resize.is_live(now),
4514                        );
4515                        let repeat_animation_interval =
4516                            if resize_unsettled || pending_capture_settle {
4517                                None
4518                            } else {
4519                                repeating_animation_redraw_interval(
4520                                    &runtime.runtime_state.motion,
4521                                    repeat_animation_frame,
4522                                )
4523                            };
4524                        let has_playing_video = players.iter().any(|(widget_id, _)| {
4525                            runtime
4526                                .runtime_state
4527                                .video
4528                                .states
4529                                .get(widget_id)
4530                                .map(|state| state.status == VideoStatus::Playing)
4531                                .unwrap_or(false)
4532                        });
4533                        let animation_frame = animation_redraw_interval(
4534                            has_finite_animation,
4535                            repeat_animation_interval,
4536                            has_playing_video,
4537                            min_frame,
4538                        );
4539
4540                        ime_handler.set_text_input_config(focused_text_input_config(
4541                            &runtime,
4542                            pipeline.prev_ir.as_ref(),
4543                        ));
4544                        let focused_text_input =
4545                            focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
4546                        if focused_text_input != blink_focus_id {
4547                            if let Some(prev) = blink_focus_id {
4548                                runtime.runtime_state.caret_visible.remove(&prev);
4549                            }
4550                            blink_focus_id = focused_text_input;
4551                            if let Some(id) = blink_focus_id {
4552                                runtime.runtime_state.caret_visible.insert(id, true);
4553                                last_blink_toggle = now;
4554                                invalidations.mark_build();
4555                                request_redraw_logged(
4556                                    &window,
4557                                    elwt,
4558                                    &mut last_redraw_at,
4559                                    min_frame,
4560                                    &mut redraw_pending,
4561                                    &mut frame_trace,
4562                                    "caret_focus_changed",
4563                                );
4564                            }
4565                        }
4566
4567                        // Cursor blink: toggle visibility and request a redraw.
4568                        if blink_enabled {
4569                            if let Some(id) = blink_focus_id {
4570                                if now.duration_since(last_blink_toggle) >= blink_period {
4571                                    let visible = runtime
4572                                        .runtime_state
4573                                        .caret_visible
4574                                        .get(&id)
4575                                        .copied()
4576                                        .unwrap_or(true);
4577                                    runtime.runtime_state.caret_visible.insert(id, !visible);
4578                                    last_blink_toggle = now;
4579                                    invalidations.mark_build();
4580                                    request_redraw_logged(
4581                                        &window,
4582                                        elwt,
4583                                        &mut last_redraw_at,
4584                                        min_frame,
4585                                        &mut redraw_pending,
4586                                        &mut frame_trace,
4587                                        "caret_blink",
4588                                    );
4589                                }
4590                            }
4591                        }
4592
4593                        let blink_wake_at = if blink_enabled && blink_focus_id.is_some() {
4594                            Some(last_blink_toggle + blink_period)
4595                        } else {
4596                            None
4597                        };
4598
4599                        // Drain completed background effect results and dispatch
4600                        // their continuations back into the runtime on the main thread.
4601                        let effect_results_dispatched = drain_effect_results(
4602                            &mut runtime,
4603                            &effect_result_rx,
4604                            &mut active_services,
4605                            &mut service_bindings,
4606                        );
4607                        if effect_results_dispatched {
4608                            invalidations.mark_build();
4609                            // Background work completed — process any new effects
4610                            // the continuation reducers may have emitted.
4611                            if process_pending_effects(
4612                                &mut runtime,
4613                                &effect_result_tx,
4614                                &event_proxy,
4615                                &async_registry,
4616                                &mut active_services,
4617                                &mut service_bindings,
4618                                &mut next_service_instance_id,
4619                            ) {
4620                                invalidations.mark_build();
4621                                request_redraw_logged(
4622                                    &window,
4623                                    elwt,
4624                                    &mut last_redraw_at,
4625                                    min_frame,
4626                                    &mut redraw_pending,
4627                                    &mut frame_trace,
4628                                    "effect_continuation",
4629                                );
4630                            }
4631                            request_redraw_logged(
4632                                &window,
4633                                elwt,
4634                                &mut last_redraw_at,
4635                                min_frame,
4636                                &mut redraw_pending,
4637                                &mut frame_trace,
4638                                "effect_result",
4639                            );
4640                        }
4641
4642                        // Application frame hook (e.g. LSP polling).
4643                        let frame_hook_wants_redraw = if let Some(ref hook) = self.frame_hook {
4644                            let hook = hook.clone();
4645                            if let Some(state) = runtime.get_global_state_mut::<S>() {
4646                                hook(state)
4647                            } else {
4648                                false
4649                            }
4650                        } else {
4651                            false
4652                        };
4653                        if frame_hook_wants_redraw {
4654                            invalidations.mark_build();
4655                            request_redraw_logged(
4656                                &window,
4657                                elwt,
4658                                &mut last_redraw_at,
4659                                min_frame,
4660                                &mut redraw_pending,
4661                                &mut frame_trace,
4662                                "frame_hook",
4663                            );
4664                        }
4665
4666                        let next_vello_image_generation =
4667                            fission_render_vello::image_cache_generation();
4668                        let next_software_image_generation =
4669                            software_renderer::image_cache_generation();
4670                        let image_cache_changed = next_vello_image_generation
4671                            != vello_image_cache_generation
4672                            || next_software_image_generation != software_image_cache_generation;
4673                        if image_cache_changed {
4674                            vello_image_cache_generation = next_vello_image_generation;
4675                            software_image_cache_generation = next_software_image_generation;
4676                            #[cfg(not(target_arch = "wasm32"))]
4677                            retained_scene_cache.clear();
4678                            #[cfg(target_arch = "wasm32")]
4679                            if let Some(WebRenderer::WebGpu(presenter)) = web_renderer.as_mut() {
4680                                presenter.retained_scene_cache.clear();
4681                            }
4682                            invalidations.mark_paint();
4683                            request_redraw_logged(
4684                                &window,
4685                                elwt,
4686                                &mut last_redraw_at,
4687                                min_frame,
4688                                &mut redraw_pending,
4689                                &mut frame_trace,
4690                                "image_cache",
4691                            );
4692                        }
4693                        let image_cache_pending = fission_render_vello::image_cache_has_pending()
4694                            || software_renderer::image_cache_has_pending();
4695
4696                        // When a frame_hook is registered, ensure the event loop
4697                        // wakes at least every 2 seconds so the hook fires even
4698                        // when no user input or animation is happening (e.g. for
4699                        // asynchronous LSP diagnostics).
4700                        let frame_hook_wake_at = if self.frame_hook.is_some() {
4701                            Some(now + Duration::from_secs(2))
4702                        } else {
4703                            None
4704                        };
4705
4706                        let has_pending_work = effect_results_dispatched
4707                            || frame_hook_wants_redraw
4708                            || image_cache_changed
4709                            || invalidations.any()
4710                            || resize_unsettled
4711                            || pending_capture_settle;
4712                        let active_keys = active_animation_keys(&runtime);
4713
4714                        if has_pending_work {
4715                            let pending_frame = pending_work_redraw_interval(
4716                                invalidations,
4717                                resize_unsettled || pending_capture_settle,
4718                                min_frame,
4719                                resize_frame,
4720                            );
4721                            let redraw_reason = if resize_unsettled {
4722                                "pending_resize"
4723                            } else if pending_capture_settle {
4724                                "pending_capture_settle"
4725                            } else if invalidations.build {
4726                                "pending_work:build"
4727                            } else if invalidations.layout {
4728                                "pending_work:layout"
4729                            } else if invalidations.paint {
4730                                "pending_work:paint"
4731                            } else if invalidations.composite {
4732                                "pending_work:composite"
4733                            } else if effect_results_dispatched {
4734                                "pending_work:effects"
4735                            } else if frame_hook_wants_redraw {
4736                                "pending_work:frame_hook"
4737                            } else {
4738                                "pending_work"
4739                            };
4740                            request_redraw_logged(
4741                                &window,
4742                                elwt,
4743                                &mut last_redraw_at,
4744                                pending_frame,
4745                                &mut redraw_pending,
4746                                &mut frame_trace,
4747                                redraw_reason,
4748                            );
4749                            let reasons = frame_trace.take_redraw_reasons();
4750                            frame_trace.emit(
4751                                "about_to_wait",
4752                                presented_frames + 1,
4753                                &active_keys,
4754                                invalidations,
4755                                &reasons,
4756                                &format!(
4757                                    "schedule=pending interval_ms={} pending_resize={} redraw_pending={} highest={}",
4758                                    pending_frame.as_millis(),
4759                                    resize_unsettled || pending_capture_settle,
4760                                    redraw_pending,
4761                                    invalidations.highest_class(),
4762                                ),
4763                            );
4764                            let mut wake_at = last_redraw_at + pending_frame;
4765                            if let Some(blink_at) = blink_wake_at {
4766                                if blink_at < wake_at {
4767                                    wake_at = blink_at;
4768                                }
4769                            }
4770                            if let Some(hook_at) = frame_hook_wake_at {
4771                                if hook_at < wake_at {
4772                                    wake_at = hook_at;
4773                                }
4774                            }
4775                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4776                        } else if let Some(animation_frame) = animation_frame {
4777                            request_redraw_logged(
4778                                &window,
4779                                elwt,
4780                                &mut last_redraw_at,
4781                                animation_frame,
4782                                &mut redraw_pending,
4783                                &mut frame_trace,
4784                                if has_finite_animation {
4785                                    "animation:finite"
4786                                } else if has_playing_video {
4787                                    "animation:video"
4788                                } else {
4789                                    "animation:repeat"
4790                                },
4791                            );
4792                            let reasons = frame_trace.take_redraw_reasons();
4793                            frame_trace.emit(
4794                                "about_to_wait",
4795                                presented_frames + 1,
4796                                &active_keys,
4797                                invalidations,
4798                                &reasons,
4799                                &format!(
4800                                    "schedule=animation interval_ms={} pending_resize={} redraw_pending={} highest={}",
4801                                    animation_frame.as_millis(),
4802                                    resize_unsettled || pending_capture_settle,
4803                                    redraw_pending,
4804                                    invalidations.highest_class(),
4805                                ),
4806                            );
4807                            let mut wake_at = last_redraw_at + animation_frame;
4808                            if let Some(blink_at) = blink_wake_at {
4809                                if blink_at < wake_at {
4810                                    wake_at = blink_at;
4811                                }
4812                            }
4813                            if let Some(hook_at) = frame_hook_wake_at {
4814                                if hook_at < wake_at {
4815                                    wake_at = hook_at;
4816                                }
4817                            }
4818                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4819                        } else if image_cache_pending {
4820                            let wake_at = now + Duration::from_millis(50);
4821                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4822                        } else if let Some(blink_at) = blink_wake_at {
4823                            let reasons = frame_trace.take_redraw_reasons();
4824                            frame_trace.emit(
4825                                "about_to_wait",
4826                                presented_frames + 1,
4827                                &active_keys,
4828                                invalidations,
4829                                &reasons,
4830                                "schedule=blink_wait pending_resize=false redraw_pending=false highest=none",
4831                            );
4832                            let mut wake_at = blink_at;
4833                            if let Some(hook_at) = frame_hook_wake_at {
4834                                if hook_at < wake_at {
4835                                    wake_at = hook_at;
4836                                }
4837                            }
4838                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
4839                        } else if let Some(hook_at) = frame_hook_wake_at {
4840                            let reasons = frame_trace.take_redraw_reasons();
4841                            frame_trace.emit(
4842                                "about_to_wait",
4843                                presented_frames + 1,
4844                                &active_keys,
4845                                invalidations,
4846                                &reasons,
4847                                "schedule=hook_wait pending_resize=false redraw_pending=false highest=none",
4848                            );
4849                            elwt.set_control_flow(ControlFlow::WaitUntil(hook_at));
4850                        } else {
4851                            let reasons = frame_trace.take_redraw_reasons();
4852                            frame_trace.emit(
4853                                "about_to_wait",
4854                                presented_frames + 1,
4855                                &active_keys,
4856                                invalidations,
4857                                &reasons,
4858                                "schedule=idle pending_resize=false redraw_pending=false highest=none",
4859                            );
4860                            #[cfg(target_os = "android")]
4861                            if test_control_enabled {
4862                                elwt.set_control_flow(ControlFlow::Poll);
4863                            } else {
4864                                elwt.set_control_flow(ControlFlow::WaitUntil(
4865                                    now + Duration::from_millis(16),
4866                                ));
4867                            }
4868                            #[cfg(not(target_os = "android"))]
4869                            elwt.set_control_flow(ControlFlow::Wait);
4870                        }
4871                    }
4872
4873                    // ═══════════════════════════════════════════════════════
4874                    // WindowEvent — real user interaction
4875                    // ═══════════════════════════════════════════════════════
4876                    Event::WindowEvent { window_id, event }
4877                        if platform_window.active_window_id() == Some(window_id) =>
4878                    {
4879                        let Some(window) = platform_window.active_window() else {
4880                            return;
4881                        };
4882                        match event {
4883                            WindowEvent::Resized(size) => {
4884                                if size.width > 0 && size.height > 0 {
4885                                    #[cfg(target_os = "ios")]
4886                                    let next_viewport = WindowViewportState::from_window(window);
4887                                    #[cfg(not(target_os = "ios"))]
4888                                    let next_viewport = pending_resize
4889                                        .unwrap_or_else(|| WindowViewportState::from_window(window))
4890                                        .with_physical_size(size);
4891                                    #[cfg(not(target_os = "android"))]
4892                                    {
4893                                        window_viewport = next_viewport;
4894                                    }
4895                                    #[cfg(target_os = "android")]
4896                                    {
4897                                        window_viewport = Some(next_viewport);
4898                                    }
4899                                    apply_authoritative_resize(
4900                                        &window,
4901                                        elwt,
4902                                        next_viewport,
4903                                        &mut pending_resize,
4904                                        &mut resize_needs_settled_frame,
4905                                        &mut pending_capture_settle,
4906                                        pending_screenshot_path.as_deref(),
4907                                        &mut live_resize,
4908                                        &mut invalidations,
4909                                        &mut last_redraw_at,
4910                                        resize_frame,
4911                                        &mut redraw_pending,
4912                                        &mut frame_trace,
4913                                        "window_resized",
4914                                    );
4915                                }
4916                            }
4917                            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
4918                                #[cfg(target_os = "ios")]
4919                                let _ = scale_factor;
4920                                #[cfg(target_os = "ios")]
4921                                let next_viewport = WindowViewportState::from_window(window);
4922                                #[cfg(not(target_os = "ios"))]
4923                                let next_viewport = pending_resize
4924                                    .unwrap_or_else(|| WindowViewportState::from_window(window))
4925                                    .with_scale_factor(scale_factor);
4926                                #[cfg(not(target_os = "android"))]
4927                                {
4928                                    window_viewport = next_viewport;
4929                                }
4930                                #[cfg(target_os = "android")]
4931                                {
4932                                    window_viewport = Some(next_viewport);
4933                                }
4934                                apply_authoritative_resize(
4935                                    &window,
4936                                    elwt,
4937                                    next_viewport,
4938                                    &mut pending_resize,
4939                                    &mut resize_needs_settled_frame,
4940                                    &mut pending_capture_settle,
4941                                    pending_screenshot_path.as_deref(),
4942                                    &mut live_resize,
4943                                    &mut invalidations,
4944                                    &mut last_redraw_at,
4945                                    resize_frame,
4946                                    &mut redraw_pending,
4947                                    &mut frame_trace,
4948                                    "scale_factor_changed",
4949                                );
4950                            }
4951                            WindowEvent::RedrawRequested => {
4952                                if debug_android_events {
4953                                    eprintln!("[android-events] redraw_requested");
4954                                }
4955                                redraw_pending = false;
4956                                diag::begin_frame(None);
4957                                let now = Instant::now();
4958                                let dt = now.duration_since(last_frame_time);
4959                                last_frame_time = now;
4960                                let dt_ms = dt.as_millis() as u64;
4961                                let pre_tick_active = active_animation_keys(&runtime);
4962                                match runtime.tick(dt_ms) {
4963                                    Ok(tick_result) => {
4964                                        let tick_invalidations = pipeline
4965                                            .classify_animation_updates(
4966                                                &tick_result.changed_motions,
4967                                            );
4968                                        invalidations.merge(tick_invalidations);
4969                                        let reasons = if tick_result.changed_motions.is_empty() {
4970                                            Vec::new()
4971                                        } else {
4972                                            tick_result
4973                                                .changed_motions
4974                                                .iter()
4975                                                .map(|(target, property)| {
4976                                                    format!(
4977                                                        "tick:{}:{:?}:{}",
4978                                                        target.as_u128(),
4979                                                        property,
4980                                                        tick_invalidations.highest_class()
4981                                                    )
4982                                                })
4983                                                .collect::<Vec<_>>()
4984                                        };
4985                                        frame_trace.emit(
4986                                            "redraw_requested",
4987                                            presented_frames + 1,
4988                                            &pre_tick_active,
4989                                            tick_invalidations,
4990                                            &reasons,
4991                                            &format!("dt_ms={}", dt_ms),
4992                                        );
4993                                    }
4994                                    Err(e) => {
4995                                        eprintln!("Runtime tick error: {:?}", e);
4996                                    }
4997                                }
4998                                if process_pending_effects(
4999                                    &mut runtime,
5000                                    &effect_result_tx,
5001                                    &event_proxy,
5002                                    &async_registry,
5003                                    &mut active_services,
5004                                    &mut service_bindings,
5005                                    &mut next_service_instance_id,
5006                                ) {
5007                                    invalidations.mark_build();
5008                                    request_redraw_logged(
5009                                        &window,
5010                                        elwt,
5011                                        &mut last_redraw_at,
5012                                        min_frame,
5013                                        &mut redraw_pending,
5014                                        &mut frame_trace,
5015                                        "redraw:effects",
5016                                    );
5017                                }
5018                                let viewport_state = pending_resize.unwrap_or_else(|| {
5019                                    #[cfg(not(target_os = "android"))]
5020                                    {
5021                                        window_viewport
5022                                    }
5023                                    #[cfg(target_os = "android")]
5024                                    {
5025                                        window_viewport.unwrap_or_else(|| {
5026                                            WindowViewportState::from_window(window)
5027                                        })
5028                                    }
5029                                });
5030                                #[cfg(not(target_os = "android"))]
5031                                {
5032                                    window_viewport = viewport_state;
5033                                }
5034                                #[cfg(target_os = "android")]
5035                                {
5036                                    window_viewport = Some(viewport_state);
5037                                }
5038                                let swapchain_size = viewport_state.physical_size;
5039                                if swapchain_size.width == 0 || swapchain_size.height == 0 {
5040                                    diag::end_frame(diag::FrameStats::default());
5041                                    return;
5042                                }
5043
5044                                let scale_factor = viewport_state.scale_factor;
5045                                let pending_layout_viewport = viewport_state.logical_size();
5046                                let render_target_size =
5047                                    (swapchain_size.width, swapchain_size.height);
5048
5049                                #[cfg(target_arch = "wasm32")]
5050                                if web_renderer.is_none() {
5051                                    let request = web_renderer_request();
5052                                    if matches!(request, RendererRequest::Canvas2dSoftware) {
5053                                        match WebCanvasPresenter::new(window) {
5054                                            Ok(mut presenter) => {
5055                                                presenter.report = RendererReport::new(
5056                                                    "canvas2d-software",
5057                                                    request,
5058                                                    None,
5059                                                    None,
5060                                                    Some("forced_by_renderer_request".to_string()),
5061                                                    render_target_size.0,
5062                                                    render_target_size.1,
5063                                                    scale_factor,
5064                                                );
5065                                                web_renderer =
5066                                                    Some(WebRenderer::Canvas2d(presenter));
5067                                            }
5068                                            Err(err) => {
5069                                                eprintln!("web canvas not ready yet: {err}");
5070                                                request_redraw_logged(
5071                                                    &window,
5072                                                    elwt,
5073                                                    &mut last_redraw_at,
5074                                                    min_frame,
5075                                                    &mut redraw_pending,
5076                                                    &mut frame_trace,
5077                                                    "web_canvas_pending",
5078                                                );
5079                                                diag::end_frame(diag::FrameStats::default());
5080                                                return;
5081                                            }
5082                                        }
5083                                    } else if let Some(result) =
5084                                        pending_webgpu_init.borrow_mut().take()
5085                                    {
5086                                        match result {
5087                                            Ok(presenter) => {
5088                                                web_renderer = Some(WebRenderer::WebGpu(presenter));
5089                                            }
5090                                            Err(error) if request.is_explicit_gpu() => {
5091                                                eprintln!(
5092                                                    "webgpu-vello renderer requested but initialization failed: {error}"
5093                                                );
5094                                                diag::end_frame(diag::FrameStats::default());
5095                                                panic!(
5096                                                    "webgpu-vello renderer requested but initialization failed: {error}"
5097                                                );
5098                                            }
5099                                            Err(error) => match WebCanvasPresenter::new(window) {
5100                                                Ok(mut presenter) => {
5101                                                    presenter.report = RendererReport::new(
5102                                                        "canvas2d-software",
5103                                                        request,
5104                                                        None,
5105                                                        None,
5106                                                        Some(format!(
5107                                                            "webgpu_vello_init_failed:{error}"
5108                                                        )),
5109                                                        render_target_size.0,
5110                                                        render_target_size.1,
5111                                                        scale_factor,
5112                                                    );
5113                                                    web_renderer =
5114                                                        Some(WebRenderer::Canvas2d(presenter));
5115                                                }
5116                                                Err(err) => {
5117                                                    eprintln!(
5118                                                        "web renderer fallback failed; webgpu error: {error}; canvas error: {err}"
5119                                                    );
5120                                                    request_redraw_logged(
5121                                                        &window,
5122                                                        elwt,
5123                                                        &mut last_redraw_at,
5124                                                        min_frame,
5125                                                        &mut redraw_pending,
5126                                                        &mut frame_trace,
5127                                                        "web_canvas_pending",
5128                                                    );
5129                                                    diag::end_frame(diag::FrameStats::default());
5130                                                    return;
5131                                                }
5132                                            },
5133                                        }
5134                                    } else {
5135                                        if !webgpu_init_in_flight {
5136                                            match window.canvas() {
5137                                                Some(canvas) => {
5138                                                    let pending = pending_webgpu_init.clone();
5139                                                    let proxy = event_proxy.clone();
5140                                                    let init_viewport = viewport_state;
5141                                                    webgpu_init_in_flight = true;
5142                                                    wasm_bindgen_futures::spawn_local(async move {
5143                                                        let result = create_webgpu_presenter(
5144                                                            canvas,
5145                                                            init_viewport,
5146                                                            request,
5147                                                        )
5148                                                        .await
5149                                                        .map_err(|error| error.to_string());
5150                                                        *pending.borrow_mut() = Some(result);
5151                                                        let _ = proxy.send_event(TestEvent::Wake);
5152                                                    });
5153                                                }
5154                                                None => {
5155                                                    eprintln!("web canvas not ready yet");
5156                                                }
5157                                            }
5158                                        }
5159                                        request_redraw_logged(
5160                                            &window,
5161                                            elwt,
5162                                            &mut last_redraw_at,
5163                                            min_frame,
5164                                            &mut redraw_pending,
5165                                            &mut frame_trace,
5166                                            "webgpu_renderer_pending",
5167                                        );
5168                                        diag::end_frame(diag::FrameStats::default());
5169                                        return;
5170                                    }
5171
5172                                    if !web_renderer_reported {
5173                                        if let Some(renderer) = web_renderer.as_ref() {
5174                                            publish_web_renderer_report(renderer.report());
5175                                            web_renderer_reported = true;
5176                                        }
5177                                    }
5178                                }
5179
5180                                #[cfg(not(target_arch = "wasm32"))]
5181                                {
5182                                    if render_state.is_none() {
5183                                        let Some(render_window) =
5184                                            platform_window.active_window_arc()
5185                                        else {
5186                                            diag::end_frame(diag::FrameStats::default());
5187                                            return;
5188                                        };
5189                                        match create_render_state(
5190                                            &mut render_cx,
5191                                            render_window,
5192                                            viewport_state,
5193                                        ) {
5194                                            Ok(state) => {
5195                                                render_state = Some(state);
5196                                            }
5197                                            Err(err) => {
5198                                                eprintln!("render surface not ready yet: {err}");
5199                                                request_redraw_logged(
5200                                                    &window,
5201                                                    elwt,
5202                                                    &mut last_redraw_at,
5203                                                    min_frame,
5204                                                    &mut redraw_pending,
5205                                                    &mut frame_trace,
5206                                                    "render_surface_pending",
5207                                                );
5208                                                diag::end_frame(diag::FrameStats::default());
5209                                                return;
5210                                            }
5211                                        }
5212                                    }
5213                                    let render_state = render_state.as_mut().expect("render state");
5214
5215                                    let mut surface_target_replaced = false;
5216                                    if swapchain_size.width != render_state.surface.config.width
5217                                        || swapchain_size.height
5218                                            != render_state.surface.config.height
5219                                    {
5220                                        render_cx.resize_surface(
5221                                            &mut render_state.surface,
5222                                            swapchain_size.width,
5223                                            swapchain_size.height,
5224                                        );
5225                                        let device_handle =
5226                                            &render_cx.devices[render_state.surface.dev_id];
5227                                        render_state.surface.config.alpha_mode =
5228                                            wgpu::CompositeAlphaMode::PostMultiplied;
5229                                        render_state.surface.surface.configure(
5230                                            &device_handle.device,
5231                                            &render_state.surface.config,
5232                                        );
5233                                        sync_tracked_target_texture_size_to_surface(
5234                                            &mut render_state.target_texture_size,
5235                                            swapchain_size,
5236                                        );
5237                                        surface_target_replaced = true;
5238                                    }
5239                                    if surface_target_replaced
5240                                        || render_target_size != render_state.target_texture_size
5241                                    {
5242                                        recreate_target_texture(
5243                                            &mut render_state.surface,
5244                                            &render_cx,
5245                                            render_target_size.0,
5246                                            render_target_size.1,
5247                                        );
5248                                        #[cfg(feature = "three-d")]
5249                                        {
5250                                            let device_handle =
5251                                                &render_cx.devices[render_state.surface.dev_id];
5252                                            // Keep the 3D depth target in lockstep with the shared render target.
5253                                            render_state.scene3d_renderer.resize(
5254                                                &device_handle.device,
5255                                                render_target_size.0,
5256                                                render_target_size.1,
5257                                            );
5258                                        }
5259                                        render_state.target_texture_size = render_target_size;
5260                                    }
5261                                }
5262
5263                                let resize_settled =
5264                                    resize_needs_settled_frame && !live_resize.is_live(now);
5265                                let target_viewport = pending_layout_viewport;
5266                                let build_viewport = resolve_build_viewport(
5267                                    last_built_viewport,
5268                                    target_viewport,
5269                                    pipeline.prev_ir.is_some(),
5270                                    &mut invalidations,
5271                                );
5272                                env.viewport_size = build_viewport;
5273                                env.window_insets =
5274                                    window_safe_area_insets(window, viewport_state.scale_factor);
5275
5276                                if let Some(sync) = &self.sync_env {
5277                                    let state = runtime.get_global_state::<S>().unwrap();
5278                                    sync(state, &mut env);
5279                                }
5280                                let desired_window_title = env.window.title.plain_text();
5281                                if desired_window_title != applied_window_title {
5282                                    if let Some(window) = platform_window.active_window() {
5283                                        window.set_title(desired_window_title);
5284                                    }
5285                                    applied_window_title = desired_window_title.to_string();
5286                                }
5287
5288                                if invalidations.build || pipeline.prev_ir.is_none() {
5289                                    let (
5290                                        node_tree,
5291                                        registry,
5292                                        resources,
5293                                        motion_declarations,
5294                                        videos,
5295                                        web_views,
5296                                        portals,
5297                                    ) = {
5298                                        let state = runtime.get_global_state::<S>().unwrap();
5299                                        let view = View::new(
5300                                            state,
5301                                            &runtime.runtime_state,
5302                                            &env,
5303                                            pipeline.last_snapshot.as_ref(),
5304                                        );
5305                                        let mut ctx = BuildCtx::new();
5306                                        let node =
5307                                            fission_core::build::enter(&mut ctx, &view, || {
5308                                                root_widget.clone().into()
5309                                            });
5310                                        let resources = ctx.take_resources();
5311                                        let motion_declarations = ctx.take_motion_declarations();
5312                                        let videos = ctx.take_video_registrations();
5313                                        let web_views = ctx.take_web_registrations();
5314                                        let portals_with_ids = ctx.take_portals();
5315
5316                                        let portals = portals_with_ids
5317                                            .into_iter()
5318                                            .map(|(id, node)| {
5319                                                wrap_portal_for_viewport(id, node, &env)
5320                                            })
5321                                            .collect::<Vec<_>>();
5322
5323                                        diag::emit(
5324                                            diag::DiagCategory::Layout,
5325                                            diag::DiagLevel::Debug,
5326                                            diag::DiagEventKind::PortalsComposed {
5327                                                portal_count: portals.len() as u32,
5328                                            },
5329                                        );
5330                                        (
5331                                            node,
5332                                            ctx.registry,
5333                                            resources,
5334                                            motion_declarations,
5335                                            videos,
5336                                            web_views,
5337                                            portals,
5338                                        )
5339                                    };
5340
5341                                    #[cfg(feature = "tray")]
5342                                    let tray_registry = if let Some(tray) = active_tray.as_mut() {
5343                                        match tray.refresh_menu(&runtime, &env, &pipeline) {
5344                                            Ok(registry) => Some(registry),
5345                                            Err(err) => {
5346                                                eprintln!(
5347                                                    "Runtime tray menu rebuild error: {:?}",
5348                                                    err
5349                                                );
5350                                                None
5351                                            }
5352                                        }
5353                                    } else {
5354                                        None
5355                                    };
5356
5357                                    runtime.clear_reducers();
5358                                    runtime.absorb_registry(registry);
5359                                    #[cfg(feature = "tray")]
5360                                    if let Some(registry) = tray_registry {
5361                                        runtime.absorb_registry(registry);
5362                                    }
5363                                    if let Err(err) = runtime.reconcile_resources(resources) {
5364                                        eprintln!(
5365                                            "Runtime resource reconciliation error: {:?}",
5366                                            err
5367                                        );
5368                                    }
5369                                    if !startup_dispatched {
5370                                        if let Some(action) = startup_action.clone() {
5371                                            if let Err(err) =
5372                                                runtime.dispatch(action, WidgetId::from_u128(0))
5373                                            {
5374                                                eprintln!("Startup action error: {:?}", err);
5375                                            }
5376                                        }
5377                                        startup_dispatched = true;
5378                                    }
5379                                    runtime.sync_motion_declarations(
5380                                        &motion_declarations,
5381                                        pipeline.last_snapshot.as_ref(),
5382                                    );
5383                                    runtime.sync_video_nodes(&videos);
5384                                    runtime.sync_web_nodes(&web_views);
5385
5386                                    let final_root: fission_core::Widget =
5387                                        fission_core::ui::Overlay {
5388                                            id: None,
5389                                            content: node_tree,
5390                                            overlay: fission_core::ui::ZStack {
5391                                                children: portals,
5392                                                ..Default::default()
5393                                            }
5394                                            .into(),
5395                                        }
5396                                        .into();
5397
5398                                    let mut lower_cx = InternalLoweringCx::new(
5399                                        &env,
5400                                        &runtime.runtime_state,
5401                                        runtime.measurer.as_ref(),
5402                                        pipeline.last_snapshot.as_ref(),
5403                                    );
5404                                    let root_id = fission_core::internal::lower_widget(
5405                                        &final_root,
5406                                        &mut lower_cx,
5407                                    );
5408                                    lower_cx.ir.root = Some(root_id);
5409
5410                                    let pipeline_invalidations =
5411                                        pipeline.replace_ir(lower_cx.ir, &env);
5412                                    invalidations.merge(pipeline_invalidations);
5413                                    last_built_viewport = Some(build_viewport);
5414                                }
5415
5416                                let layout_updates = match pipeline.ensure_layout(
5417                                    LayoutRect::new(
5418                                        0.0,
5419                                        0.0,
5420                                        target_viewport.width,
5421                                        target_viewport.height,
5422                                    ),
5423                                    &mut layout_engine,
5424                                    &runtime.runtime_state.scroll,
5425                                ) {
5426                                    Ok(updates) => updates,
5427                                    Err(e) => {
5428                                        eprintln!("Layout error: {:?}", e);
5429                                        diag::end_frame(diag::FrameStats::default());
5430                                        return;
5431                                    }
5432                                };
5433
5434                                if layout_updates > 0 {
5435                                    if let (Some(ir), Some(layout)) =
5436                                        (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref())
5437                                    {
5438                                        runtime.post_layout_hook(ir, layout);
5439                                    }
5440                                }
5441
5442                                match pipeline.prepare_current(
5443                                    target_viewport,
5444                                    target_viewport,
5445                                    false,
5446                                    &runtime.runtime_state.scroll,
5447                                    &runtime.runtime_state.motion,
5448                                    &runtime.runtime_state.video,
5449                                    &runtime.runtime_state.web,
5450                                ) {
5451                                    Ok(_stats) => {
5452                                        #[cfg(target_arch = "wasm32")]
5453                                        {
5454                                            let Some(renderer) = web_renderer.as_mut() else {
5455                                                eprintln!("web renderer is unavailable");
5456                                                diag::end_frame(diag::FrameStats::default());
5457                                                return;
5458                                            };
5459                                            let active_renderer =
5460                                                renderer.active_name().to_string();
5461                                            match renderer {
5462                                                WebRenderer::Canvas2d(presenter) => {
5463                                                    let retained_scene = pipeline
5464                                                        .retained_scene()
5465                                                        .expect(
5466                                                            "retained render scene missing before render",
5467                                                        );
5468                                                    let rgba = SoftwareRenderer::render(
5469                                                        retained_scene,
5470                                                        render_target_size.0,
5471                                                        render_target_size.1,
5472                                                        fission_render::Color {
5473                                                            r: env.theme.tokens.colors.background.r,
5474                                                            g: env.theme.tokens.colors.background.g,
5475                                                            b: env.theme.tokens.colors.background.b,
5476                                                            a: env.theme.tokens.colors.background.a,
5477                                                        },
5478                                                        scale_factor as f32,
5479                                                    )
5480                                                    .expect(
5481                                                        "failed to rasterize software web frame",
5482                                                    );
5483
5484                                                    if let Err(err) = presenter.present(
5485                                                        &rgba,
5486                                                        render_target_size.0,
5487                                                        render_target_size.1,
5488                                                        scale_factor,
5489                                                    ) {
5490                                                        eprintln!(
5491                                                            "failed to present web canvas frame: {err}"
5492                                                        );
5493                                                        diag::end_frame(diag::FrameStats::default());
5494                                                        return;
5495                                                    }
5496                                                }
5497                                                WebRenderer::WebGpu(presenter) => {
5498                                                    if swapchain_size.width
5499                                                        != presenter
5500                                                            .render_state
5501                                                            .surface
5502                                                            .config
5503                                                            .width
5504                                                        || swapchain_size.height
5505                                                            != presenter
5506                                                                .render_state
5507                                                                .surface
5508                                                                .config
5509                                                                .height
5510                                                    {
5511                                                        presenter.render_cx.resize_surface(
5512                                                            &mut presenter.render_state.surface,
5513                                                            swapchain_size.width,
5514                                                            swapchain_size.height,
5515                                                        );
5516                                                        let device_handle = &presenter
5517                                                            .render_cx
5518                                                            .devices
5519                                                            [presenter.render_state.surface.dev_id];
5520                                                        presenter
5521                                                            .render_state
5522                                                            .surface
5523                                                            .config
5524                                                            .alpha_mode =
5525                                                            wgpu::CompositeAlphaMode::PostMultiplied;
5526                                                        presenter
5527                                                            .render_state
5528                                                            .surface
5529                                                            .surface
5530                                                            .configure(
5531                                                                &device_handle.device,
5532                                                                &presenter
5533                                                                    .render_state
5534                                                                    .surface
5535                                                                    .config,
5536                                                            );
5537                                                        sync_tracked_target_texture_size_to_surface(
5538                                                            &mut presenter
5539                                                                .render_state
5540                                                                .target_texture_size,
5541                                                            swapchain_size,
5542                                                        );
5543                                                    }
5544                                                    if render_target_size
5545                                                        != presenter
5546                                                            .render_state
5547                                                            .target_texture_size
5548                                                    {
5549                                                        recreate_target_texture(
5550                                                            &mut presenter.render_state.surface,
5551                                                            &presenter.render_cx,
5552                                                            render_target_size.0,
5553                                                            render_target_size.1,
5554                                                        );
5555                                                        presenter
5556                                                            .render_state
5557                                                            .target_texture_size =
5558                                                            render_target_size;
5559                                                    }
5560
5561                                                    let surface_texture = match presenter
5562                                                        .render_state
5563                                                        .surface
5564                                                        .surface
5565                                                        .get_current_texture()
5566                                                    {
5567                                                        Ok(texture) => texture,
5568                                                        Err(err) => {
5569                                                            eprintln!(
5570                                                                "failed to get webgpu surface texture: {err}"
5571                                                            );
5572                                                            diag::end_frame(
5573                                                                diag::FrameStats::default(),
5574                                                            );
5575                                                            return;
5576                                                        }
5577                                                    };
5578                                                    let device_handle =
5579                                                        &presenter.render_cx.devices
5580                                                            [presenter.render_state.surface.dev_id];
5581
5582                                                    let clear_color = vello::wgpu::Color {
5583                                                        r: env.theme.tokens.colors.background.r
5584                                                            as f64
5585                                                            / 255.0,
5586                                                        g: env.theme.tokens.colors.background.g
5587                                                            as f64
5588                                                            / 255.0,
5589                                                        b: env.theme.tokens.colors.background.b
5590                                                            as f64
5591                                                            / 255.0,
5592                                                        a: env.theme.tokens.colors.background.a
5593                                                            as f64
5594                                                            / 255.0,
5595                                                    };
5596                                                    match &mut presenter.render_state.main_renderer
5597                                                    {
5598                                                        MainRenderer::Vello {
5599                                                            renderer,
5600                                                            texture_compositor,
5601                                                        } => {
5602                                                            let texture_plans =
5603                                                                pipeline.texture_compositor_plans();
5604                                                            let texture_plans_fit_limits =
5605                                                                texture_plans_fit_device_limits(
5606                                                                    texture_plans,
5607                                                                    scale_factor,
5608                                                                    device_handle
5609                                                                        .device
5610                                                                        .limits()
5611                                                                        .max_texture_dimension_2d,
5612                                                                );
5613                                                            let has_active_scroll_offsets = runtime
5614                                                                .runtime_state
5615                                                                .scroll
5616                                                                .offsets
5617                                                                .values()
5618                                                                .any(|offset| offset.abs() > 0.5);
5619                                                            let enable_texture_compositor =
5620                                                                web_bool_global(
5621                                                                    "FISSION_ENABLE_TEXTURE_COMPOSITOR",
5622                                                                );
5623                                                            if !enable_texture_compositor
5624                                                                || texture_plans.is_empty()
5625                                                                || !texture_plans_fit_limits
5626                                                                || has_active_scroll_offsets
5627                                                            {
5628                                                                let render_params =
5629                                                                    vello::RenderParams {
5630                                                                        base_color:
5631                                                                            vello::peniko::Color::from_rgba8(
5632                                                                                env.theme
5633                                                                                    .tokens
5634                                                                                    .colors
5635                                                                                    .background
5636                                                                                    .r,
5637                                                                                env.theme
5638                                                                                    .tokens
5639                                                                                    .colors
5640                                                                                    .background
5641                                                                                    .g,
5642                                                                                env.theme
5643                                                                                    .tokens
5644                                                                                    .colors
5645                                                                                    .background
5646                                                                                    .b,
5647                                                                                env.theme
5648                                                                                    .tokens
5649                                                                                    .colors
5650                                                                                    .background
5651                                                                                    .a,
5652                                                                            ),
5653                                                                        width: render_target_size.0,
5654                                                                        height: render_target_size.1,
5655                                                                        antialiasing_method:
5656                                                                            vello::AaConfig::Area,
5657                                                                    };
5658
5659                                                                presenter.scene.reset();
5660                                                                let retained_scene = pipeline
5661                                                                    .retained_scene()
5662                                                                    .expect(
5663                                                                        "retained render scene missing before render",
5664                                                                    );
5665                                                                let mut renderer_wrapper =
5666                                                                    VelloRenderer::new(
5667                                                                        &mut presenter.scene,
5668                                                                        measurer.clone(),
5669                                                                        &mut presenter
5670                                                                            .retained_scene_cache,
5671                                                                        scale_factor,
5672                                                                    );
5673                                                                renderer_wrapper
5674                                                                    .render_scene(retained_scene)
5675                                                                    .expect(
5676                                                                        "failed to encode retained scene",
5677                                                                    );
5678                                                                renderer
5679                                                                    .render_to_texture(
5680                                                                        &device_handle.device,
5681                                                                        &device_handle.queue,
5682                                                                        &presenter.scene,
5683                                                                        &presenter
5684                                                                            .render_state
5685                                                                            .surface
5686                                                                            .target_view,
5687                                                                        &render_params,
5688                                                                    )
5689                                                                    .expect(
5690                                                                        "failed to render webgpu frame",
5691                                                                    );
5692                                                            } else {
5693                                                                let force_full_compositor_redraw =
5694                                                                    invalidations.build
5695                                                                        || invalidations.layout
5696                                                                        || invalidations.paint;
5697                                                                let _compositor_stats =
5698                                                                    texture_compositor
5699                                                                        .render_layers(
5700                                                                            &device_handle.device,
5701                                                                            &device_handle.queue,
5702                                                                            renderer,
5703                                                                            &mut presenter
5704                                                                                .retained_scene_cache,
5705                                                                            measurer.clone(),
5706                                                                            scale_factor,
5707                                                                            render_target_size.0,
5708                                                                            render_target_size.1,
5709                                                                            pipeline
5710                                                                                .texture_compositor_root_transform(),
5711                                                                            texture_plans,
5712                                                                            force_full_compositor_redraw,
5713                                                                            clear_color,
5714                                                                            &presenter
5715                                                                                .render_state
5716                                                                                .surface
5717                                                                                .target_view,
5718                                                                        )
5719                                                                        .expect(
5720                                                                            "failed to composite webgpu texture layers",
5721                                                                        );
5722                                                            }
5723                                                        }
5724                                                        MainRenderer::Software => {}
5725                                                    }
5726
5727                                                    let surface_view =
5728                                                        surface_texture.texture.create_view(
5729                                                            &wgpu::TextureViewDescriptor::default(),
5730                                                        );
5731                                                    let mut encoder = device_handle
5732                                                        .device
5733                                                        .create_command_encoder(
5734                                                            &wgpu::CommandEncoderDescriptor {
5735                                                                label: Some("WebGPU Surface Blit"),
5736                                                            },
5737                                                        );
5738                                                    presenter.render_state.surface.blitter.copy(
5739                                                        &device_handle.device,
5740                                                        &mut encoder,
5741                                                        &presenter.render_state.surface.target_view,
5742                                                        &surface_view,
5743                                                    );
5744                                                    device_handle
5745                                                        .queue
5746                                                        .submit(Some(encoder.finish()));
5747                                                    surface_texture.present();
5748                                                }
5749                                            }
5750
5751                                            let capture_ready =
5752                                                !pending_capture_settle || resize_settled;
5753                                            if capture_ready {
5754                                                pending_capture_settle = false;
5755                                                let _ = pending_screenshot_path.take();
5756                                                let _ = pending_screenshot_response_tx.take();
5757                                            }
5758
5759                                            pending_resize = None;
5760                                            if resize_settled {
5761                                                resize_needs_settled_frame = false;
5762                                            }
5763                                            invalidations = InvalidationSet::default();
5764
5765                                            presented_frames = presented_frames.saturating_add(1);
5766                                            flush_text_traces(
5767                                                text_trace_enabled,
5768                                                &mut pending_text_traces,
5769                                                presented_frames,
5770                                            );
5771
5772                                            let total_ms = now.elapsed().as_secs_f64() * 1000.0;
5773                                            publish_web_frame_perf(&active_renderer, total_ms);
5774                                            if let Some(input_at) = pending_web_input_at.take() {
5775                                                publish_web_input_latency(
5776                                                    &active_renderer,
5777                                                    input_at.elapsed().as_secs_f64() * 1000.0,
5778                                                );
5779                                            }
5780
5781                                            diag::end_frame(diag::FrameStats::default());
5782                                        }
5783                                        #[cfg(not(target_arch = "wasm32"))]
5784                                        {
5785                                            let render_state =
5786                                                render_state.as_mut().expect("render state");
5787                                            let surface_texture = render_state
5788                                                .surface
5789                                                .surface
5790                                                .get_current_texture()
5791                                                .expect("failed to get texture");
5792                                            let device_handle =
5793                                                &render_cx.devices[render_state.surface.dev_id];
5794
5795                                            let clear_color = vello::wgpu::Color {
5796                                                r: env.theme.tokens.colors.background.r as f64
5797                                                    / 255.0,
5798                                                g: env.theme.tokens.colors.background.g as f64
5799                                                    / 255.0,
5800                                                b: env.theme.tokens.colors.background.b as f64
5801                                                    / 255.0,
5802                                                a: env.theme.tokens.colors.background.a as f64
5803                                                    / 255.0,
5804                                            };
5805                                            match &mut render_state.main_renderer {
5806                                                MainRenderer::Vello {
5807                                                    renderer,
5808                                                    texture_compositor,
5809                                                } => {
5810                                                    let texture_plans =
5811                                                        pipeline.texture_compositor_plans();
5812                                                    let texture_plans_fit_limits =
5813                                                        texture_plans_fit_device_limits(
5814                                                            texture_plans,
5815                                                            scale_factor,
5816                                                            device_handle
5817                                                                .device
5818                                                                .limits()
5819                                                                .max_texture_dimension_2d,
5820                                                        );
5821                                                    let has_active_scroll_offsets = runtime
5822                                                        .runtime_state
5823                                                        .scroll
5824                                                        .offsets
5825                                                        .values()
5826                                                        .any(|offset| offset.abs() > 0.5);
5827                                                    let enable_texture_compositor = std::env::var(
5828                                                        "FISSION_ENABLE_TEXTURE_COMPOSITOR",
5829                                                    )
5830                                                    .ok()
5831                                                    .as_deref()
5832                                                        == Some("1");
5833                                                    if !enable_texture_compositor
5834                                                        || texture_plans.is_empty()
5835                                                        || !texture_plans_fit_limits
5836                                                        || has_active_scroll_offsets
5837                                                    {
5838                                                        let render_params = vello::RenderParams {
5839                                                            base_color:
5840                                                                vello::peniko::Color::from_rgba8(
5841                                                                    env.theme
5842                                                                        .tokens
5843                                                                        .colors
5844                                                                        .background
5845                                                                        .r,
5846                                                                    env.theme
5847                                                                        .tokens
5848                                                                        .colors
5849                                                                        .background
5850                                                                        .g,
5851                                                                    env.theme
5852                                                                        .tokens
5853                                                                        .colors
5854                                                                        .background
5855                                                                        .b,
5856                                                                    env.theme
5857                                                                        .tokens
5858                                                                        .colors
5859                                                                        .background
5860                                                                        .a,
5861                                                                ),
5862                                                            width: render_target_size.0,
5863                                                            height: render_target_size.1,
5864                                                            antialiasing_method:
5865                                                                vello::AaConfig::Area,
5866                                                        };
5867
5868                                                        scene.reset();
5869                                                        let retained_scene = pipeline
5870                                                        .retained_scene()
5871                                                        .expect(
5872                                                            "retained render scene missing before render",
5873                                                        );
5874                                                        let mut renderer_wrapper =
5875                                                            VelloRenderer::new(
5876                                                                &mut scene,
5877                                                                measurer.clone(),
5878                                                                &mut retained_scene_cache,
5879                                                                scale_factor,
5880                                                            );
5881                                                        renderer_wrapper
5882                                                            .render_scene(retained_scene)
5883                                                            .expect(
5884                                                                "failed to encode retained scene",
5885                                                            );
5886                                                        renderer
5887                                                            .render_to_texture(
5888                                                                &device_handle.device,
5889                                                                &device_handle.queue,
5890                                                                &scene,
5891                                                                &render_state.surface.target_view,
5892                                                                &render_params,
5893                                                            )
5894                                                            .expect("failed to render");
5895                                                    } else {
5896                                                        let force_full_compositor_redraw =
5897                                                            invalidations.build
5898                                                                || invalidations.layout
5899                                                                || invalidations.paint;
5900                                                        let _compositor_stats = texture_compositor
5901                                                        .render_layers(
5902                                                            &device_handle.device,
5903                                                            &device_handle.queue,
5904                                                            renderer,
5905                                                            &mut retained_scene_cache,
5906                                                            measurer.clone(),
5907                                                            scale_factor,
5908                                                            render_target_size.0,
5909                                                            render_target_size.1,
5910                                                            pipeline
5911                                                                .texture_compositor_root_transform(
5912                                                                ),
5913                                                            texture_plans,
5914                                                            force_full_compositor_redraw,
5915                                                            clear_color,
5916                                                            &render_state.surface.target_view,
5917                                                        )
5918                                                        .expect(
5919                                                            "failed to composite texture layers",
5920                                                        );
5921                                                    }
5922                                                }
5923                                                MainRenderer::Software => {
5924                                                    let retained_scene = pipeline
5925                                                    .retained_scene()
5926                                                    .expect(
5927                                                    "retained render scene missing before render",
5928                                                );
5929                                                    let rgba = SoftwareRenderer::render(
5930                                                        retained_scene,
5931                                                        render_target_size.0,
5932                                                        render_target_size.1,
5933                                                        fission_render::Color {
5934                                                            r: env.theme.tokens.colors.background.r,
5935                                                            g: env.theme.tokens.colors.background.g,
5936                                                            b: env.theme.tokens.colors.background.b,
5937                                                            a: env.theme.tokens.colors.background.a,
5938                                                        },
5939                                                        scale_factor as f32,
5940                                                    )
5941                                                    .expect("failed to rasterize software frame");
5942                                                    device_handle.queue.write_texture(
5943                                                        wgpu::TexelCopyTextureInfo {
5944                                                            texture: &render_state
5945                                                                .surface
5946                                                                .target_texture,
5947                                                            mip_level: 0,
5948                                                            origin: wgpu::Origin3d::ZERO,
5949                                                            aspect: wgpu::TextureAspect::All,
5950                                                        },
5951                                                        &rgba,
5952                                                        wgpu::TexelCopyBufferLayout {
5953                                                            offset: 0,
5954                                                            bytes_per_row: Some(
5955                                                                render_target_size.0 * 4,
5956                                                            ),
5957                                                            rows_per_image: Some(
5958                                                                render_target_size.1,
5959                                                            ),
5960                                                        },
5961                                                        wgpu::Extent3d {
5962                                                            width: render_target_size.0,
5963                                                            height: render_target_size.1,
5964                                                            depth_or_array_layers: 1,
5965                                                        },
5966                                                    );
5967                                                }
5968                                            }
5969
5970                                            #[cfg(feature = "three-d")]
5971                                            {
5972                                                for (_, rect, payload) in
5973                                                    &pipeline.scene_3d_surfaces
5974                                                {
5975                                                    if let Ok(primitives) = bincode::deserialize::<
5976                                                        Vec<fission_3d::Primitive3D>,
5977                                                    >(
5978                                                        payload
5979                                                    ) {
5980                                                        let scene3d = fission_3d::Scene3D {
5981                                                            width: Some(rect.size.width),
5982                                                            height: Some(rect.size.height),
5983                                                            primitives,
5984                                                        };
5985                                                        let scale = scale_factor as f32;
5986                                                        render_state
5987                                                            .scene3d_renderer
5988                                                            .render_in_rect(
5989                                                            &device_handle.device,
5990                                                            &device_handle.queue,
5991                                                            &render_state.surface.target_view,
5992                                                            &scene3d,
5993                                                            fission_3d::render::Scene3DViewport {
5994                                                                x: rect.origin.x * scale,
5995                                                                y: rect.origin.y * scale,
5996                                                                width: rect.size.width * scale,
5997                                                                height: rect.size.height * scale,
5998                                                            },
5999                                                        );
6000                                                    }
6001                                                }
6002                                            }
6003
6004                                            let surface_view = surface_texture.texture.create_view(
6005                                                &wgpu::TextureViewDescriptor::default(),
6006                                            );
6007
6008                                            let mut encoder =
6009                                                device_handle.device.create_command_encoder(
6010                                                    &wgpu::CommandEncoderDescriptor {
6011                                                        label: Some("Surface Blit"),
6012                                                    },
6013                                                );
6014
6015                                            render_state.surface.blitter.copy(
6016                                                &device_handle.device,
6017                                                &mut encoder,
6018                                                &render_state.surface.target_view,
6019                                                &surface_view,
6020                                            );
6021
6022                                            device_handle.queue.submit(Some(encoder.finish()));
6023
6024                                            let capture_ready =
6025                                                !pending_capture_settle || resize_settled;
6026                                            if capture_ready {
6027                                                pending_capture_settle = false;
6028                                            }
6029                                            if capture_ready {
6030                                                if let Some(path) = pending_screenshot_path.take() {
6031                                                    let screenshot_dimensions =
6032                                                        layout_size_to_image_dimensions(
6033                                                            target_viewport,
6034                                                        );
6035                                                    if let Some(tx) =
6036                                                        pending_screenshot_response_tx.take()
6037                                                    {
6038                                                        if path == "__pump__" {
6039                                                            let _ = tx.send(
6040                                                            fission_test_driver::TestResponse::Ok {},
6041                                                        );
6042                                                        } else if path == "__capture__" {
6043                                                            let resp = gpu_screenshot(
6044                                                                &device_handle.device,
6045                                                                &device_handle.queue,
6046                                                                &render_state
6047                                                                    .surface
6048                                                                    .target_texture,
6049                                                                render_target_size.0,
6050                                                                render_target_size.1,
6051                                                                screenshot_dimensions.0,
6052                                                                screenshot_dimensions.1,
6053                                                                None,
6054                                                            );
6055                                                            let _ = tx.send(resp);
6056                                                        } else {
6057                                                            let resp = gpu_screenshot(
6058                                                                &device_handle.device,
6059                                                                &device_handle.queue,
6060                                                                &render_state
6061                                                                    .surface
6062                                                                    .target_texture,
6063                                                                render_target_size.0,
6064                                                                render_target_size.1,
6065                                                                screenshot_dimensions.0,
6066                                                                screenshot_dimensions.1,
6067                                                                Some(&path),
6068                                                            );
6069                                                            let _ = tx.send(resp);
6070                                                        }
6071                                                    }
6072                                                }
6073                                            }
6074
6075                                            surface_texture.present();
6076                                            pending_resize = None;
6077                                            if resize_settled {
6078                                                resize_needs_settled_frame = false;
6079                                            }
6080                                            invalidations = InvalidationSet::default();
6081
6082                                            presented_frames = presented_frames.saturating_add(1);
6083                                            flush_text_traces(
6084                                                text_trace_enabled,
6085                                                &mut pending_text_traces,
6086                                                presented_frames,
6087                                            );
6088
6089                                            diag::emit(
6090                                                diag::DiagCategory::Frame,
6091                                                diag::DiagLevel::Debug,
6092                                                diag::DiagEventKind::FramePerformance {
6093                                                    renderer: render_state
6094                                                        .renderer_report
6095                                                        .active
6096                                                        .clone(),
6097                                                    total_ms: now.elapsed().as_secs_f64() * 1000.0,
6098                                                },
6099                                            );
6100                                            diag::end_frame(diag::FrameStats::default());
6101                                        }
6102                                    }
6103                                    Err(e) => {
6104                                        eprintln!("Pipeline error: {:?}", e);
6105                                        diag::end_frame(diag::FrameStats::default());
6106                                    }
6107                                }
6108                            }
6109                            WindowEvent::CloseRequested => {
6110                                #[cfg(feature = "tray")]
6111                                if active_tray
6112                                    .as_ref()
6113                                    .map(|tray| {
6114                                        tray.close_behavior()
6115                                            == tray::WindowCloseBehavior::HideToTray
6116                                    })
6117                                    .unwrap_or(false)
6118                                {
6119                                    tray::hide_window_to_tray(window);
6120                                    return;
6121                                }
6122                                elwt.exit();
6123                            }
6124                            // Input Handling — delegates to the same extracted functions
6125                            // that TestEvent handlers use.
6126                            WindowEvent::CursorMoved { position, .. } => {
6127                                last_cursor_position = Some(position);
6128                                let point =
6129                                    window_physical_position_to_layout_point(window, position);
6130                                handle_cursor_moved(
6131                                    point.x,
6132                                    point.y,
6133                                    current_mods,
6134                                    &mut runtime,
6135                                    &pipeline,
6136                                    &effect_result_tx,
6137                                    &event_proxy,
6138                                    &async_registry,
6139                                    &mut active_services,
6140                                    &mut service_bindings,
6141                                    &mut next_service_instance_id,
6142                                    &window,
6143                                    elwt,
6144                                    &mut last_redraw_at,
6145                                    min_frame,
6146                                    &mut redraw_pending,
6147                                    &mut frame_trace,
6148                                    &mut invalidations,
6149                                );
6150                            }
6151                            WindowEvent::CursorLeft { .. } => {
6152                                handle_cursor_left(
6153                                    last_cursor_position,
6154                                    &mut runtime,
6155                                    &pipeline,
6156                                    &effect_result_tx,
6157                                    &event_proxy,
6158                                    &async_registry,
6159                                    &mut active_services,
6160                                    &mut service_bindings,
6161                                    &mut next_service_instance_id,
6162                                    &window,
6163                                    elwt,
6164                                    &mut last_redraw_at,
6165                                    min_frame,
6166                                    &mut redraw_pending,
6167                                    &mut frame_trace,
6168                                    &mut invalidations,
6169                                );
6170                                last_cursor_position = None;
6171                            }
6172                            WindowEvent::MouseInput { state, button, .. } => {
6173                                #[cfg(target_arch = "wasm32")]
6174                                pending_web_input_at.get_or_insert_with(Instant::now);
6175                                if let Some(position) = last_cursor_position {
6176                                    let point =
6177                                        window_physical_position_to_layout_point(window, position);
6178                                    if let Some(btn) = map_mouse_button(button) {
6179                                        let is_pressed = state.is_pressed();
6180                                        handle_mouse_button(
6181                                            point.x,
6182                                            point.y,
6183                                            btn,
6184                                            is_pressed,
6185                                            current_mods,
6186                                            &mut runtime,
6187                                            &pipeline,
6188                                            &effect_result_tx,
6189                                            &event_proxy,
6190                                            &async_registry,
6191                                            &mut active_services,
6192                                            &mut service_bindings,
6193                                            &mut next_service_instance_id,
6194                                            &window,
6195                                            elwt,
6196                                            &mut last_redraw_at,
6197                                            min_frame,
6198                                            &mut redraw_pending,
6199                                            text_trace_enabled,
6200                                            &mut pending_text_traces,
6201                                            &mut next_text_trace_seq,
6202                                            presented_frames,
6203                                            &mut last_blink_toggle,
6204                                            &mut frame_trace,
6205                                            &mut invalidations,
6206                                        );
6207                                    }
6208                                }
6209                            }
6210                            WindowEvent::MouseWheel { delta, .. } => {
6211                                #[cfg(target_arch = "wasm32")]
6212                                pending_web_input_at.get_or_insert_with(Instant::now);
6213                                if let Some(position) = last_cursor_position {
6214                                    let scale_factor = window.scale_factor();
6215                                    let point =
6216                                        window_physical_position_to_layout_point(window, position);
6217
6218                                    let (dx, dy) =
6219                                        normalize_winit_scroll_delta(&delta, scale_factor);
6220
6221                                    if std::env::var("FISSION_SCROLL_TRACE").ok().as_deref()
6222                                        == Some("1")
6223                                    {
6224                                        eprintln!(
6225                                            "[scroll-trace] mousewheel raw={:?} point=({:.1},{:.1}) delta=({:.1},{:.1})",
6226                                            delta, point.x, point.y, dx, dy
6227                                        );
6228                                    }
6229                                    handle_scroll(
6230                                        point.x,
6231                                        point.y,
6232                                        dx,
6233                                        dy,
6234                                        current_mods,
6235                                        &mut runtime,
6236                                        &pipeline,
6237                                        &effect_result_tx,
6238                                        &event_proxy,
6239                                        &async_registry,
6240                                        &mut active_services,
6241                                        &mut service_bindings,
6242                                        &mut next_service_instance_id,
6243                                        &window,
6244                                        elwt,
6245                                        &mut last_redraw_at,
6246                                        min_frame,
6247                                        &mut redraw_pending,
6248                                        &mut frame_trace,
6249                                        &mut invalidations,
6250                                    );
6251                                }
6252                            }
6253                            WindowEvent::Touch(touch) => {
6254                                #[cfg(target_arch = "wasm32")]
6255                                pending_web_input_at.get_or_insert_with(Instant::now);
6256                                let current_position = touch.location;
6257                                // Some mobile backends report the end/cancel location after the
6258                                // contact has already been cleared. Keep the last active touch
6259                                // position so a normal tap releases over the same hit target.
6260                                let position = match touch.phase {
6261                                    TouchPhase::Ended | TouchPhase::Cancelled => touch_positions
6262                                        .get(&touch.id)
6263                                        .copied()
6264                                        .unwrap_or(current_position),
6265                                    TouchPhase::Started | TouchPhase::Moved => current_position,
6266                                };
6267                                last_cursor_position = Some(position);
6268
6269                                let point =
6270                                    window_physical_position_to_layout_point(window, position);
6271
6272                                match touch.phase {
6273                                    TouchPhase::Started => {
6274                                        touch_positions.insert(touch.id, position);
6275                                        if active_primary_touch.is_none() {
6276                                            active_primary_touch = Some(touch.id);
6277                                        }
6278                                        if active_primary_touch == Some(touch.id) {
6279                                            handle_cursor_moved(
6280                                                point.x,
6281                                                point.y,
6282                                                current_mods,
6283                                                &mut runtime,
6284                                                &pipeline,
6285                                                &effect_result_tx,
6286                                                &event_proxy,
6287                                                &async_registry,
6288                                                &mut active_services,
6289                                                &mut service_bindings,
6290                                                &mut next_service_instance_id,
6291                                                &window,
6292                                                elwt,
6293                                                &mut last_redraw_at,
6294                                                min_frame,
6295                                                &mut redraw_pending,
6296                                                &mut frame_trace,
6297                                                &mut invalidations,
6298                                            );
6299                                            handle_mouse_button(
6300                                                point.x,
6301                                                point.y,
6302                                                PointerButton::Primary,
6303                                                true,
6304                                                current_mods,
6305                                                &mut runtime,
6306                                                &pipeline,
6307                                                &effect_result_tx,
6308                                                &event_proxy,
6309                                                &async_registry,
6310                                                &mut active_services,
6311                                                &mut service_bindings,
6312                                                &mut next_service_instance_id,
6313                                                &window,
6314                                                elwt,
6315                                                &mut last_redraw_at,
6316                                                min_frame,
6317                                                &mut redraw_pending,
6318                                                text_trace_enabled,
6319                                                &mut pending_text_traces,
6320                                                &mut next_text_trace_seq,
6321                                                presented_frames,
6322                                                &mut last_blink_toggle,
6323                                                &mut frame_trace,
6324                                                &mut invalidations,
6325                                            );
6326                                        }
6327                                    }
6328                                    TouchPhase::Moved => {
6329                                        touch_positions.insert(touch.id, position);
6330                                        if active_primary_touch == Some(touch.id) {
6331                                            handle_cursor_moved(
6332                                                point.x,
6333                                                point.y,
6334                                                current_mods,
6335                                                &mut runtime,
6336                                                &pipeline,
6337                                                &effect_result_tx,
6338                                                &event_proxy,
6339                                                &async_registry,
6340                                                &mut active_services,
6341                                                &mut service_bindings,
6342                                                &mut next_service_instance_id,
6343                                                &window,
6344                                                elwt,
6345                                                &mut last_redraw_at,
6346                                                min_frame,
6347                                                &mut redraw_pending,
6348                                                &mut frame_trace,
6349                                                &mut invalidations,
6350                                            );
6351                                        }
6352                                    }
6353                                    TouchPhase::Ended | TouchPhase::Cancelled => {
6354                                        if active_primary_touch == Some(touch.id) {
6355                                            handle_cursor_moved(
6356                                                point.x,
6357                                                point.y,
6358                                                current_mods,
6359                                                &mut runtime,
6360                                                &pipeline,
6361                                                &effect_result_tx,
6362                                                &event_proxy,
6363                                                &async_registry,
6364                                                &mut active_services,
6365                                                &mut service_bindings,
6366                                                &mut next_service_instance_id,
6367                                                &window,
6368                                                elwt,
6369                                                &mut last_redraw_at,
6370                                                min_frame,
6371                                                &mut redraw_pending,
6372                                                &mut frame_trace,
6373                                                &mut invalidations,
6374                                            );
6375                                            handle_mouse_button(
6376                                                point.x,
6377                                                point.y,
6378                                                PointerButton::Primary,
6379                                                false,
6380                                                current_mods,
6381                                                &mut runtime,
6382                                                &pipeline,
6383                                                &effect_result_tx,
6384                                                &event_proxy,
6385                                                &async_registry,
6386                                                &mut active_services,
6387                                                &mut service_bindings,
6388                                                &mut next_service_instance_id,
6389                                                &window,
6390                                                elwt,
6391                                                &mut last_redraw_at,
6392                                                min_frame,
6393                                                &mut redraw_pending,
6394                                                text_trace_enabled,
6395                                                &mut pending_text_traces,
6396                                                &mut next_text_trace_seq,
6397                                                presented_frames,
6398                                                &mut last_blink_toggle,
6399                                                &mut frame_trace,
6400                                                &mut invalidations,
6401                                            );
6402                                            active_primary_touch = None;
6403                                        }
6404                                        touch_positions.remove(&touch.id);
6405                                    }
6406                                }
6407                            }
6408                            WindowEvent::ModifiersChanged(modifiers) => {
6409                                current_mods = 0;
6410                                if modifiers.state().shift_key() {
6411                                    current_mods |= 1;
6412                                }
6413                                if modifiers.state().alt_key() {
6414                                    current_mods |= 2;
6415                                }
6416                                if modifiers.state().control_key() {
6417                                    current_mods |= 4;
6418                                }
6419                                if modifiers.state().super_key() {
6420                                    current_mods |= 8;
6421                                }
6422                            }
6423                            WindowEvent::KeyboardInput { event, .. } => {
6424                                #[cfg(target_arch = "wasm32")]
6425                                pending_web_input_at.get_or_insert_with(Instant::now);
6426                                if event.state.is_pressed() {
6427                                    use winit::keyboard::{Key, NamedKey};
6428                                    let key_code = match event.logical_key {
6429                                        Key::Named(NamedKey::Space) => Some(KeyCode::Space),
6430                                        Key::Named(NamedKey::Enter) => Some(KeyCode::Enter),
6431                                        Key::Named(NamedKey::Escape) => Some(KeyCode::Escape),
6432                                        Key::Named(NamedKey::Backspace) => Some(KeyCode::Backspace),
6433                                        Key::Named(NamedKey::Delete) => Some(KeyCode::Delete),
6434                                        Key::Named(NamedKey::Tab) => Some(KeyCode::Tab),
6435                                        Key::Named(NamedKey::ArrowLeft) => Some(KeyCode::Left),
6436                                        Key::Named(NamedKey::ArrowRight) => Some(KeyCode::Right),
6437                                        Key::Named(NamedKey::ArrowUp) => Some(KeyCode::Up),
6438                                        Key::Named(NamedKey::ArrowDown) => Some(KeyCode::Down),
6439                                        Key::Named(NamedKey::Home) => Some(KeyCode::Home),
6440                                        Key::Named(NamedKey::End) => Some(KeyCode::End),
6441                                        Key::Named(NamedKey::PageUp) => Some(KeyCode::PageUp),
6442                                        Key::Named(NamedKey::PageDown) => Some(KeyCode::PageDown),
6443                                        _ => {
6444                                            if let Some(text) = &event.text {
6445                                                text.chars().next().map(KeyCode::Char)
6446                                            } else {
6447                                                None
6448                                            }
6449                                        }
6450                                    };
6451
6452                                    if let Some(code) = key_code {
6453                                        handle_key_down::<S>(
6454                                            code,
6455                                            current_mods,
6456                                            &mut runtime,
6457                                            &pipeline,
6458                                            &effect_result_tx,
6459                                            &event_proxy,
6460                                            &async_registry,
6461                                            &mut active_services,
6462                                            &mut service_bindings,
6463                                            &mut next_service_instance_id,
6464                                            &window,
6465                                            elwt,
6466                                            &mut last_redraw_at,
6467                                            min_frame,
6468                                            &mut redraw_pending,
6469                                            text_trace_enabled,
6470                                            &mut pending_text_traces,
6471                                            &mut next_text_trace_seq,
6472                                            presented_frames,
6473                                            &mut last_blink_toggle,
6474                                            self.key_handler.as_ref(),
6475                                            &mut frame_trace,
6476                                            &mut invalidations,
6477                                        );
6478                                    }
6479                                }
6480                            }
6481                            WindowEvent::Ime(ime) => {
6482                                #[cfg(target_arch = "wasm32")]
6483                                pending_web_input_at.get_or_insert_with(Instant::now);
6484                                if let (Some(ir), Some(layout)) =
6485                                    (&pipeline.prev_ir, &pipeline.last_snapshot)
6486                                {
6487                                    let (input_event, source) = match ime {
6488                                        Ime::Commit(text) => (
6489                                            Some(InputEvent::Ime(
6490                                                fission_core::event::ImeEvent::Commit {
6491                                                    text: text.clone(),
6492                                                },
6493                                            )),
6494                                            Some(format!("ime_commit:{}", text.chars().count())),
6495                                        ),
6496                                        Ime::Preedit(text, _) => (
6497                                            Some(InputEvent::Ime(
6498                                                fission_core::event::ImeEvent::Preedit {
6499                                                    text: text.clone(),
6500                                                },
6501                                            )),
6502                                            Some(format!("ime_preedit:{}", text.chars().count())),
6503                                        ),
6504                                        _ => (None, None),
6505                                    };
6506
6507                                    if let Some(e) = input_event {
6508                                        let target = focused_text_input_id(
6509                                            &runtime,
6510                                            pipeline.prev_ir.as_ref(),
6511                                        );
6512                                        let trace_seq = start_text_trace(
6513                                            text_trace_enabled && target.is_some(),
6514                                            &mut pending_text_traces,
6515                                            &mut next_text_trace_seq,
6516                                            source.unwrap_or_else(|| "ime".to_string()),
6517                                            target,
6518                                            presented_frames,
6519                                        );
6520                                        runtime.handle_input(e, ir, layout).ok();
6521                                        invalidations.mark_build();
6522                                        mark_text_trace_handled(
6523                                            &mut pending_text_traces,
6524                                            trace_seq,
6525                                        );
6526                                        if process_pending_effects(
6527                                            &mut runtime,
6528                                            &effect_result_tx,
6529                                            &event_proxy,
6530                                            &async_registry,
6531                                            &mut active_services,
6532                                            &mut service_bindings,
6533                                            &mut next_service_instance_id,
6534                                        ) {
6535                                            mark_text_trace_effects(
6536                                                &mut pending_text_traces,
6537                                                trace_seq,
6538                                            );
6539                                            invalidations.mark_build();
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:effects",
6548                                            );
6549                                        }
6550                                        reset_text_input_caret(
6551                                            &mut runtime,
6552                                            pipeline.prev_ir.as_ref(),
6553                                            &mut last_blink_toggle,
6554                                        );
6555                                        request_redraw_logged(
6556                                            &window,
6557                                            elwt,
6558                                            &mut last_redraw_at,
6559                                            min_frame,
6560                                            &mut redraw_pending,
6561                                            &mut frame_trace,
6562                                            "ime",
6563                                        );
6564                                    }
6565                                }
6566                            }
6567                            _ => {}
6568                        }
6569                    }
6570                    _ => {}
6571                }
6572            };
6573
6574        #[cfg(target_arch = "wasm32")]
6575        {
6576            event_loop.spawn(event_handler);
6577            Ok(())
6578        }
6579        #[cfg(not(target_arch = "wasm32"))]
6580        {
6581            event_loop
6582                .run(event_handler)
6583                .map_err(|e| anyhow::anyhow!("Event loop error: {}", e))
6584        }
6585    }
6586}
6587
6588fn build_font_context() -> FontContext {
6589    let use_system_fonts = std::env::var("FISSION_USE_SYSTEM_FONTS")
6590        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
6591        .unwrap_or(false);
6592    let options = CollectionOptions {
6593        shared: false,
6594        system_fonts: use_system_fonts,
6595    };
6596    FontContext {
6597        collection: Collection::new(options),
6598        source_cache: SourceCache::default(),
6599    }
6600}
6601
6602// Helpers...
6603fn map_mouse_button(button: MouseButton) -> Option<PointerButton> {
6604    match button {
6605        MouseButton::Left => Some(PointerButton::Primary),
6606        MouseButton::Right => Some(PointerButton::Secondary),
6607        MouseButton::Middle => Some(PointerButton::Middle),
6608        MouseButton::Other(id) => Some(PointerButton::Other(id as u8)),
6609        _ => None,
6610    }
6611}
6612
6613fn clamp_copy_extent_to_texture(
6614    requested_width: u32,
6615    requested_height: u32,
6616    actual_width: u32,
6617    actual_height: u32,
6618) -> (u32, u32) {
6619    (
6620        requested_width.min(actual_width).max(1),
6621        requested_height.min(actual_height).max(1),
6622    )
6623}
6624
6625fn gpu_screenshot(
6626    device: &wgpu::Device,
6627    queue: &wgpu::Queue,
6628    texture: &wgpu::Texture,
6629    texture_width: u32,
6630    texture_height: u32,
6631    output_width: u32,
6632    output_height: u32,
6633    path: Option<&str>,
6634) -> fission_test_driver::TestResponse {
6635    let actual_texture_width = texture.width();
6636    let actual_texture_height = texture.height();
6637    let (texture_width, texture_height) = clamp_copy_extent_to_texture(
6638        texture_width,
6639        texture_height,
6640        actual_texture_width,
6641        actual_texture_height,
6642    );
6643    if output_width == 0 || output_height == 0 {
6644        return fission_test_driver::TestResponse::Error {
6645            message: "zero-size viewport".into(),
6646        };
6647    }
6648
6649    let bytes_per_pixel = 4u32;
6650    let unpadded_bytes_per_row = texture_width * bytes_per_pixel;
6651    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
6652    let padded_bytes_per_row = (unpadded_bytes_per_row + align - 1) / align * align;
6653    let buffer_size = (padded_bytes_per_row * texture_height) as u64;
6654
6655    let staging = device.create_buffer(&wgpu::BufferDescriptor {
6656        label: Some("screenshot staging"),
6657        size: buffer_size,
6658        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
6659        mapped_at_creation: false,
6660    });
6661
6662    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
6663        label: Some("screenshot copy"),
6664    });
6665
6666    encoder.copy_texture_to_buffer(
6667        wgpu::TexelCopyTextureInfo {
6668            texture,
6669            mip_level: 0,
6670            origin: wgpu::Origin3d::ZERO,
6671            aspect: wgpu::TextureAspect::All,
6672        },
6673        wgpu::TexelCopyBufferInfo {
6674            buffer: &staging,
6675            layout: wgpu::TexelCopyBufferLayout {
6676                offset: 0,
6677                bytes_per_row: Some(padded_bytes_per_row),
6678                rows_per_image: Some(texture_height),
6679            },
6680        },
6681        wgpu::Extent3d {
6682            width: texture_width,
6683            height: texture_height,
6684            depth_or_array_layers: 1,
6685        },
6686    );
6687
6688    queue.submit(Some(encoder.finish()));
6689
6690    let (tx, rx) = std::sync::mpsc::channel();
6691    staging
6692        .slice(..)
6693        .map_async(wgpu::MapMode::Read, move |result| {
6694            let _ = tx.send(result);
6695        });
6696    let _ = device.poll(wgpu::PollType::Wait);
6697
6698    match rx.recv() {
6699        Ok(Ok(())) => {}
6700        Ok(Err(e)) => {
6701            return fission_test_driver::TestResponse::Error {
6702                message: format!("buffer map failed: {:?}", e),
6703            };
6704        }
6705        Err(e) => {
6706            return fission_test_driver::TestResponse::Error {
6707                message: format!("buffer map channel error: {}", e),
6708            };
6709        }
6710    }
6711
6712    let data = staging.slice(..).get_mapped_range();
6713
6714    // Remove row padding (texture is Rgba8Unorm, no swizzle needed)
6715    let mut rgba = Vec::with_capacity((texture_width * texture_height * 4) as usize);
6716    for row in 0..texture_height {
6717        let start = (row * padded_bytes_per_row) as usize;
6718        let end = start + (texture_width * bytes_per_pixel) as usize;
6719        rgba.extend_from_slice(&data[start..end]);
6720    }
6721
6722    drop(data);
6723    staging.unmap();
6724
6725    let (rgba, width, height) = if texture_width == output_width && texture_height == output_height
6726    {
6727        (rgba, texture_width, texture_height)
6728    } else if let Some(resized) = downscale_rgba_box(
6729        &rgba,
6730        texture_width,
6731        texture_height,
6732        output_width,
6733        output_height,
6734    ) {
6735        (resized, output_width, output_height)
6736    } else {
6737        let Some(image) = image::RgbaImage::from_raw(texture_width, texture_height, rgba) else {
6738            return fission_test_driver::TestResponse::Error {
6739                message: "failed to decode screenshot RGBA buffer".into(),
6740            };
6741        };
6742        let resized = image::imageops::resize(
6743            &image,
6744            output_width,
6745            output_height,
6746            image::imageops::FilterType::Triangle,
6747        );
6748        (resized.into_raw(), output_width, output_height)
6749    };
6750
6751    let mut png = Vec::new();
6752    {
6753        use image::ImageEncoder;
6754        let encoder = image::codecs::png::PngEncoder::new(&mut png);
6755        if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) {
6756            return fission_test_driver::TestResponse::Error {
6757                message: format!("PNG encode failed: {}", e),
6758            };
6759        }
6760    }
6761
6762    if let Some(path) = path {
6763        match std::fs::write(path, &png) {
6764            Ok(()) => fission_test_driver::TestResponse::Ok {},
6765            Err(e) => fission_test_driver::TestResponse::Error {
6766                message: format!("PNG save failed: {}", e),
6767            },
6768        }
6769    } else {
6770        fission_test_driver::TestResponse::Screenshot {
6771            png_base64: base64::engine::general_purpose::STANDARD.encode(png),
6772            width,
6773            height,
6774        }
6775    }
6776}
6777
6778fn downscale_rgba_box(
6779    rgba: &[u8],
6780    input_width: u32,
6781    input_height: u32,
6782    output_width: u32,
6783    output_height: u32,
6784) -> Option<Vec<u8>> {
6785    if output_width == 0
6786        || output_height == 0
6787        || input_width % output_width != 0
6788        || input_height % output_height != 0
6789    {
6790        return None;
6791    }
6792
6793    let scale_x = input_width / output_width;
6794    let scale_y = input_height / output_height;
6795    if scale_x <= 1 && scale_y <= 1 {
6796        return None;
6797    }
6798
6799    let samples_per_pixel = scale_x.checked_mul(scale_y)?;
6800    let mut out = vec![0u8; (output_width * output_height * 4) as usize];
6801
6802    for out_y in 0..output_height {
6803        let src_y0 = out_y * scale_y;
6804        for out_x in 0..output_width {
6805            let src_x0 = out_x * scale_x;
6806            let mut sum = [0u32; 4];
6807            for dy in 0..scale_y {
6808                let src_y = src_y0 + dy;
6809                let row_offset = ((src_y * input_width) * 4) as usize;
6810                for dx in 0..scale_x {
6811                    let src_x = src_x0 + dx;
6812                    let src_index = row_offset + (src_x * 4) as usize;
6813                    sum[0] += rgba[src_index] as u32;
6814                    sum[1] += rgba[src_index + 1] as u32;
6815                    sum[2] += rgba[src_index + 2] as u32;
6816                    sum[3] += rgba[src_index + 3] as u32;
6817                }
6818            }
6819
6820            let dst_index = (((out_y * output_width) + out_x) * 4) as usize;
6821            out[dst_index] = (sum[0] / samples_per_pixel) as u8;
6822            out[dst_index + 1] = (sum[1] / samples_per_pixel) as u8;
6823            out[dst_index + 2] = (sum[2] / samples_per_pixel) as u8;
6824            out[dst_index + 3] = (sum[3] / samples_per_pixel) as u8;
6825        }
6826    }
6827
6828    Some(out)
6829}
6830
6831fn layout_size_to_image_dimensions(size: LayoutSize) -> (u32, u32) {
6832    let width = size.width.max(1.0).round() as u32;
6833    let height = size.height.max(1.0).round() as u32;
6834    (width.max(1), height.max(1))
6835}
6836
6837fn normalize_scale_factor(scale_factor: f64) -> f64 {
6838    if scale_factor.is_finite() && scale_factor > 0.0 {
6839        scale_factor
6840    } else {
6841        1.0
6842    }
6843}
6844
6845#[cfg(target_os = "ios")]
6846fn ios_effective_scale_factor(reported_scale_factor: f64) -> f64 {
6847    std::env::var("FISSION_IOS_SCALE_FACTOR")
6848        .ok()
6849        .and_then(|value| value.parse::<f64>().ok())
6850        .filter(|scale| scale.is_finite() && *scale > 0.0)
6851        .unwrap_or_else(|| {
6852            if reported_scale_factor >= 2.0 {
6853                reported_scale_factor
6854            } else {
6855                3.0
6856            }
6857        })
6858}
6859
6860#[cfg(target_arch = "wasm32")]
6861fn web_browser_viewport_state() -> Option<WindowViewportState> {
6862    let window = web_sys::window()?;
6863    let width = window.inner_width().ok()?.as_f64()? as f32;
6864    let height = window.inner_height().ok()?.as_f64()? as f32;
6865    if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
6866        return None;
6867    }
6868    let scale_factor = normalize_scale_factor(window.device_pixel_ratio());
6869    Some(WindowViewportState {
6870        physical_size: logical_viewport_to_physical_size(
6871            LayoutSize::new(width, height),
6872            scale_factor,
6873        ),
6874        scale_factor,
6875    })
6876}
6877
6878fn physical_size_to_layout_size(size: PhysicalSize<u32>, scale_factor: f64) -> LayoutSize {
6879    let scale_factor = normalize_scale_factor(scale_factor);
6880    LayoutSize {
6881        width: (size.width as f64 / scale_factor) as f32,
6882        height: (size.height as f64 / scale_factor) as f32,
6883    }
6884}
6885
6886fn logical_viewport_to_render_target_size(size: LayoutSize, scale_factor: f64) -> (u32, u32) {
6887    let scale_factor = normalize_scale_factor(scale_factor);
6888    let width = (size.width.max(1.0) as f64 * scale_factor).ceil() as u32;
6889    let height = (size.height.max(1.0) as f64 * scale_factor).ceil() as u32;
6890    (width.max(1), height.max(1))
6891}
6892
6893fn logical_viewport_to_physical_size(size: LayoutSize, scale_factor: f64) -> PhysicalSize<u32> {
6894    let (width, height) = logical_viewport_to_render_target_size(size, scale_factor);
6895    PhysicalSize::new(width, height)
6896}
6897
6898fn recreate_target_texture(
6899    surface: &mut RenderSurface,
6900    render_cx: &RenderContext,
6901    width: u32,
6902    height: u32,
6903) {
6904    let device = &render_cx.devices[surface.dev_id].device;
6905    let size = wgpu::Extent3d {
6906        width: width.max(1),
6907        height: height.max(1),
6908        depth_or_array_layers: 1,
6909    };
6910    let new_texture = device.create_texture(&wgpu::TextureDescriptor {
6911        label: Some("fission_target_with_copy"),
6912        size,
6913        mip_level_count: 1,
6914        sample_count: 1,
6915        dimension: wgpu::TextureDimension::D2,
6916        format: wgpu::TextureFormat::Rgba8Unorm, // Must match Vello's internal format
6917        usage: wgpu::TextureUsages::STORAGE_BINDING
6918            | wgpu::TextureUsages::TEXTURE_BINDING
6919            | wgpu::TextureUsages::RENDER_ATTACHMENT
6920            | wgpu::TextureUsages::COPY_SRC
6921            | wgpu::TextureUsages::COPY_DST,
6922        view_formats: &[],
6923    });
6924    let new_view = new_texture.create_view(&wgpu::TextureViewDescriptor::default());
6925    surface.target_texture = new_texture;
6926    surface.target_view = new_view;
6927}
6928
6929fn sync_tracked_target_texture_size_to_surface(
6930    target_texture_size: &mut (u32, u32),
6931    surface_size: PhysicalSize<u32>,
6932) {
6933    *target_texture_size = (surface_size.width.max(1), surface_size.height.max(1));
6934}
6935
6936#[cfg(any(test, not(any(target_os = "android", target_os = "ios"))))]
6937fn native_window_size_for_logical_viewport(size: LayoutSize) -> winit::dpi::LogicalSize<f64> {
6938    winit::dpi::LogicalSize::new(size.width as f64, size.height as f64)
6939}
6940
6941#[cfg(test)]
6942mod tests {
6943    use super::{
6944        animation_redraw_interval, clamp_copy_extent_to_texture, collect_startup_deep_links_from,
6945        cursor_icon_for, downscale_rgba_box, layout_size_to_image_dimensions,
6946        logical_viewport_to_physical_size, logical_viewport_to_render_target_size,
6947        native_window_size_for_logical_viewport, normalize_scale_factor,
6948        normalize_winit_scroll_delta, physical_position_to_layout_point,
6949        physical_size_to_layout_size, rect_visible_in_scroll_ancestors,
6950        repeating_animation_redraw_interval, resize_is_unsettled, resolve_build_viewport,
6951        sync_tracked_target_texture_size_to_surface, texture_plans_fit_device_limits,
6952        visual_rect_for_node, window_insets_from_safe_area_frames, LiveResizeController,
6953        WindowViewportState,
6954    };
6955    use crate::pipeline::CompositorTexturePlan;
6956    use crate::InvalidationSet;
6957    use fission_core::{ActiveMotion, MotionEasing, MotionStateMap, MotionValue, ScrollStateMap};
6958    use fission_core::{DeepLinkConfig, MotionPropertyId, WidgetId};
6959    use fission_ir::semantics::MouseCursor;
6960    use fission_ir::{CoreIR, FlexDirection, LayoutOp, Op};
6961    use fission_layout::{LayoutNodeGeometry, LayoutRect, LayoutSize, LayoutSnapshot};
6962    use std::collections::HashMap;
6963    use std::time::Duration;
6964    use winit::dpi::{PhysicalPosition, PhysicalSize};
6965    use winit::event::MouseScrollDelta;
6966    use winit::window::CursorIcon;
6967
6968    #[test]
6969    fn semantic_cursor_icons_map_to_winit_icons() {
6970        assert_eq!(cursor_icon_for(MouseCursor::Default), CursorIcon::Default);
6971        assert_eq!(cursor_icon_for(MouseCursor::Pointer), CursorIcon::Pointer);
6972        assert_eq!(cursor_icon_for(MouseCursor::Text), CursorIcon::Text);
6973        assert_eq!(
6974            cursor_icon_for(MouseCursor::NotAllowed),
6975            CursorIcon::NotAllowed
6976        );
6977        assert_eq!(
6978            cursor_icon_for(MouseCursor::VerticalText),
6979            CursorIcon::VerticalText
6980        );
6981    }
6982
6983    #[test]
6984    fn winit_scroll_delta_normalizes_to_positive_down_and_right() {
6985        assert_eq!(
6986            normalize_winit_scroll_delta(&MouseScrollDelta::LineDelta(-1.0, -2.0), 1.0),
6987            (50.0, 100.0)
6988        );
6989        assert_eq!(
6990            normalize_winit_scroll_delta(
6991                &MouseScrollDelta::PixelDelta(PhysicalPosition::new(-20.0, -40.0)),
6992                2.0,
6993            ),
6994            (10.0, 20.0)
6995        );
6996    }
6997
6998    #[test]
6999    fn physical_input_position_maps_into_layout_space() {
7000        let point = physical_position_to_layout_point(
7001            PhysicalPosition::new(240.0, 360.0),
7002            2.0,
7003            PhysicalPosition::new(0, 0),
7004        );
7005        assert_eq!(point, fission_render::LayoutPoint::new(120.0, 180.0));
7006    }
7007
7008    #[test]
7009    fn physical_input_position_subtracts_content_origin_before_scaling() {
7010        let point = physical_position_to_layout_point(
7011            PhysicalPosition::new(240.0, 460.0),
7012            2.0,
7013            PhysicalPosition::new(0, 100),
7014        );
7015        assert_eq!(point, fission_render::LayoutPoint::new(120.0, 180.0));
7016    }
7017
7018    #[test]
7019    fn safe_area_frames_convert_to_logical_window_insets() {
7020        let insets = window_insets_from_safe_area_frames(
7021            PhysicalPosition::new(0, 177),
7022            PhysicalPosition::new(0, 0),
7023            PhysicalSize::new(1206, 2343),
7024            PhysicalSize::new(1206, 2622),
7025            3.0,
7026        );
7027
7028        assert_eq!(insets.left, 0.0);
7029        assert_eq!(insets.right, 0.0);
7030        assert_eq!(insets.top, 59.0);
7031        assert_eq!(insets.bottom, 34.0);
7032    }
7033
7034    #[test]
7035    fn visual_rect_subtracts_ancestor_scroll_offset() {
7036        let scroll = WidgetId::from_u128(1);
7037        let child = WidgetId::from_u128(2);
7038        let mut ir = CoreIR::new();
7039        ir.add_node(
7040            child,
7041            Op::Paint(fission_ir::PaintOp::DrawRect {
7042                fill: None,
7043                stroke: None,
7044                corner_radius: 0.0,
7045                shadow: None,
7046            }),
7047            Vec::new(),
7048        );
7049        ir.add_node(
7050            scroll,
7051            Op::Layout(LayoutOp::Scroll {
7052                direction: FlexDirection::Column,
7053                show_scrollbar: true,
7054                width: None,
7055                height: None,
7056                min_width: None,
7057                max_width: None,
7058                min_height: None,
7059                max_height: None,
7060                padding: [0.0; 4],
7061                flex_grow: 0.0,
7062                flex_shrink: 1.0,
7063            }),
7064            vec![child],
7065        );
7066        ir.set_root(scroll);
7067
7068        let mut snapshot = LayoutSnapshot::new(LayoutSize::new(100.0, 100.0));
7069        snapshot.nodes.insert(
7070            scroll,
7071            LayoutNodeGeometry {
7072                rect: LayoutRect::new(0.0, 0.0, 100.0, 100.0),
7073                content_size: LayoutSize::new(100.0, 400.0),
7074            },
7075        );
7076        snapshot.nodes.insert(
7077            child,
7078            LayoutNodeGeometry {
7079                rect: LayoutRect::new(0.0, 150.0, 80.0, 20.0),
7080                content_size: LayoutSize::new(80.0, 20.0),
7081            },
7082        );
7083        let mut scroll_map = ScrollStateMap::default();
7084        scroll_map.set_offset(scroll, 120.0);
7085
7086        let visual = visual_rect_for_node(&ir, &snapshot, &scroll_map, child).unwrap();
7087        assert_eq!(visual, LayoutRect::new(0.0, 30.0, 80.0, 20.0));
7088        assert!(rect_visible_in_scroll_ancestors(
7089            &ir,
7090            &snapshot,
7091            &scroll_map,
7092            child,
7093            visual
7094        ));
7095    }
7096
7097    #[test]
7098    fn repeating_animation_uses_reduced_frame_rate() {
7099        let min_frame = Duration::from_millis(16);
7100        let repeat_frame = Duration::from_millis(66);
7101        assert_eq!(
7102            animation_redraw_interval(false, Some(repeat_frame), false, min_frame),
7103            Some(repeat_frame)
7104        );
7105    }
7106
7107    #[test]
7108    fn finite_animation_keeps_full_frame_rate() {
7109        let min_frame = Duration::from_millis(16);
7110        assert_eq!(
7111            animation_redraw_interval(true, None, false, min_frame),
7112            Some(min_frame)
7113        );
7114        assert_eq!(
7115            animation_redraw_interval(false, None, true, min_frame),
7116            Some(min_frame)
7117        );
7118    }
7119
7120    #[test]
7121    fn idle_video_does_not_force_full_frame_rate() {
7122        let min_frame = Duration::from_millis(16);
7123        let repeat_frame = Duration::from_millis(66);
7124        assert_eq!(
7125            animation_redraw_interval(false, Some(repeat_frame), false, min_frame),
7126            Some(repeat_frame)
7127        );
7128    }
7129
7130    #[test]
7131    fn no_repeat_interval_means_no_idle_animation_redraw() {
7132        let min_frame = Duration::from_millis(16);
7133        assert_eq!(
7134            animation_redraw_interval(false, None, false, min_frame),
7135            None
7136        );
7137    }
7138
7139    #[test]
7140    fn repeat_animation_interval_uses_low_priority_hint() {
7141        let mut animation = MotionStateMap::default();
7142        animation.active.insert(
7143            (WidgetId::explicit("spinner"), MotionPropertyId::opacity()),
7144            ActiveMotion {
7145                target: WidgetId::explicit("spinner"),
7146                property: MotionPropertyId::opacity(),
7147                start_value: MotionValue::Scalar(0.3),
7148                end_value: MotionValue::Scalar(1.0),
7149                start_time: 0,
7150                duration: 600,
7151                repeat: true,
7152                frame_interval_ms: Some(166),
7153                easing: MotionEasing::Linear,
7154            },
7155        );
7156        assert_eq!(
7157            repeating_animation_redraw_interval(&animation, Duration::from_millis(66)),
7158            Some(Duration::from_millis(166))
7159        );
7160    }
7161
7162    #[test]
7163    fn repeat_animation_interval_chooses_fastest_active_repeat() {
7164        let mut animation = MotionStateMap {
7165            values: HashMap::new(),
7166            active: HashMap::new(),
7167            ..Default::default()
7168        };
7169        animation.active.insert(
7170            (WidgetId::explicit("slow"), MotionPropertyId::opacity()),
7171            ActiveMotion {
7172                target: WidgetId::explicit("slow"),
7173                property: MotionPropertyId::opacity(),
7174                start_value: MotionValue::Scalar(0.3),
7175                end_value: MotionValue::Scalar(1.0),
7176                start_time: 0,
7177                duration: 600,
7178                repeat: true,
7179                frame_interval_ms: Some(200),
7180                easing: MotionEasing::Linear,
7181            },
7182        );
7183        animation.active.insert(
7184            (WidgetId::explicit("fast"), MotionPropertyId::opacity()),
7185            ActiveMotion {
7186                target: WidgetId::explicit("fast"),
7187                property: MotionPropertyId::opacity(),
7188                start_value: MotionValue::Scalar(0.3),
7189                end_value: MotionValue::Scalar(1.0),
7190                start_time: 0,
7191                duration: 600,
7192                repeat: true,
7193                frame_interval_ms: Some(100),
7194                easing: MotionEasing::Linear,
7195            },
7196        );
7197        assert_eq!(
7198            repeating_animation_redraw_interval(&animation, Duration::from_millis(66)),
7199            Some(Duration::from_millis(100))
7200        );
7201    }
7202
7203    #[test]
7204    fn live_resize_reports_unsettled_until_deadline() {
7205        let settle = Duration::from_millis(90);
7206        let mut resize = LiveResizeController::new(settle);
7207        let now = std::time::Instant::now();
7208        resize.note_resize(now);
7209
7210        assert!(resize.is_live(now + Duration::from_millis(30)));
7211        assert!(resize_is_unsettled(
7212            false,
7213            false,
7214            resize.is_live(now + Duration::from_millis(30))
7215        ));
7216        assert!(!resize.is_live(now + Duration::from_millis(95)));
7217    }
7218
7219    #[test]
7220    fn viewport_resize_forces_build_viewport_refresh() {
7221        let target = LayoutSize::new(1440.0, 900.0);
7222        let mut invalidations = InvalidationSet::default();
7223
7224        let build_viewport = resolve_build_viewport(
7225            Some(LayoutSize::new(1024.0, 768.0)),
7226            target,
7227            true,
7228            &mut invalidations,
7229        );
7230
7231        assert!(invalidations.build);
7232        assert_eq!(build_viewport, target);
7233    }
7234
7235    #[test]
7236    fn stable_viewport_preserves_existing_build_viewport() {
7237        let target = LayoutSize::new(1024.0, 768.0);
7238        let mut invalidations = InvalidationSet::default();
7239
7240        let build_viewport = resolve_build_viewport(Some(target), target, true, &mut invalidations);
7241
7242        assert!(!invalidations.build);
7243        assert_eq!(build_viewport, target);
7244    }
7245
7246    #[test]
7247    fn oversized_texture_plan_forces_scene_fallback() {
7248        let plans = vec![CompositorTexturePlan {
7249            key: 1,
7250            bounds: LayoutRect::new(0.0, 0.0, 320.0, 9000.0),
7251            scene: Some(fission_render::RenderScene::new(LayoutRect::new(
7252                0.0, 0.0, 320.0, 9000.0,
7253            ))),
7254            scene_cache_key: Some(1),
7255            content_key: 1,
7256            local_dynamic: false,
7257            composite_dynamic: false,
7258            opacity: 1.0,
7259            transform: None,
7260            transform_clip: false,
7261            clip: None,
7262            children: Vec::new(),
7263            source_layer_path: None,
7264        }];
7265        assert!(!texture_plans_fit_device_limits(&plans, 1.0, 8192));
7266    }
7267
7268    #[test]
7269    fn nested_texture_plans_must_all_fit_device_limits() {
7270        let child = CompositorTexturePlan {
7271            key: 2,
7272            bounds: LayoutRect::new(0.0, 0.0, 400.0, 8400.0),
7273            scene: Some(fission_render::RenderScene::new(LayoutRect::new(
7274                0.0, 0.0, 400.0, 8400.0,
7275            ))),
7276            scene_cache_key: Some(2),
7277            content_key: 2,
7278            local_dynamic: false,
7279            composite_dynamic: false,
7280            opacity: 1.0,
7281            transform: None,
7282            transform_clip: false,
7283            clip: None,
7284            children: Vec::new(),
7285            source_layer_path: None,
7286        };
7287        let plans = vec![CompositorTexturePlan {
7288            key: 1,
7289            bounds: LayoutRect::new(0.0, 0.0, 800.0, 600.0),
7290            scene: None,
7291            scene_cache_key: None,
7292            content_key: 3,
7293            local_dynamic: false,
7294            composite_dynamic: false,
7295            opacity: 1.0,
7296            transform: None,
7297            transform_clip: false,
7298            clip: None,
7299            children: vec![child],
7300            source_layer_path: None,
7301        }];
7302        assert!(!texture_plans_fit_device_limits(&plans, 1.0, 8192));
7303    }
7304
7305    #[test]
7306    fn screenshot_dimensions_follow_logical_viewport() {
7307        let dims = layout_size_to_image_dimensions(fission_layout::LayoutSize::new(1600.0, 1200.0));
7308        assert_eq!(dims, (1600, 1200));
7309
7310        let rounded =
7311            layout_size_to_image_dimensions(fission_layout::LayoutSize::new(999.6, 700.4));
7312        assert_eq!(rounded, (1000, 700));
7313    }
7314
7315    #[test]
7316    fn simulated_resize_uses_physical_render_target_size() {
7317        let dims = logical_viewport_to_render_target_size(
7318            fission_layout::LayoutSize::new(1600.0, 1200.0),
7319            2.0,
7320        );
7321        assert_eq!(dims, (3200, 2400));
7322
7323        let fractional = logical_viewport_to_render_target_size(
7324            fission_layout::LayoutSize::new(430.0, 900.0),
7325            1.5,
7326        );
7327        assert_eq!(fractional, (645, 1350));
7328    }
7329
7330    #[test]
7331    fn physical_viewport_maps_to_logical_size_with_scale_factor() {
7332        let logical = physical_size_to_layout_size(PhysicalSize::new(1728, 1117), 1.5);
7333        assert_eq!(logical.width, 1152.0);
7334        assert!((logical.height - 744.6667).abs() < 0.001);
7335    }
7336
7337    #[test]
7338    fn scale_factor_change_preserves_logical_viewport_until_resize_arrives() {
7339        let viewport = WindowViewportState {
7340            physical_size: PhysicalSize::new(1600, 1200),
7341            scale_factor: 1.0,
7342        }
7343        .with_scale_factor(2.0);
7344
7345        assert_eq!(viewport.physical_size, PhysicalSize::new(3200, 2400));
7346        assert_eq!(
7347            viewport.logical_size(),
7348            fission_layout::LayoutSize::new(1600.0, 1200.0)
7349        );
7350    }
7351
7352    #[test]
7353    fn resized_event_overrides_scale_factor_prediction_authoritatively() {
7354        let viewport = WindowViewportState {
7355            physical_size: PhysicalSize::new(1600, 1200),
7356            scale_factor: 1.0,
7357        }
7358        .with_scale_factor(1.5)
7359        .with_physical_size(PhysicalSize::new(2412, 1809));
7360
7361        assert_eq!(viewport.physical_size, PhysicalSize::new(2412, 1809));
7362        assert_eq!(
7363            viewport.logical_size(),
7364            fission_layout::LayoutSize::new(1608.0, 1206.0)
7365        );
7366    }
7367
7368    #[test]
7369    fn fractional_logical_viewports_round_up_for_render_targets() {
7370        let physical =
7371            logical_viewport_to_physical_size(fission_layout::LayoutSize::new(430.2, 900.1), 1.5);
7372        assert_eq!(physical, PhysicalSize::new(646, 1351));
7373    }
7374
7375    #[test]
7376    fn scale_factor_prediction_never_undershoots_fractional_viewports() {
7377        let initial = WindowViewportState {
7378            physical_size: PhysicalSize::new(1728, 1117),
7379            scale_factor: 1.5,
7380        };
7381        let predicted = initial.with_scale_factor(2.0);
7382
7383        assert_eq!(predicted.physical_size, PhysicalSize::new(2304, 1490));
7384        assert!(predicted.logical_size().width >= initial.logical_size().width);
7385        assert!(predicted.logical_size().height >= initial.logical_size().height);
7386    }
7387
7388    #[test]
7389    fn logical_resize_updates_native_viewport_prediction() {
7390        let initial = WindowViewportState {
7391            physical_size: PhysicalSize::new(800, 632),
7392            scale_factor: 2.0,
7393        };
7394        let resized = initial.with_logical_size(fission_layout::LayoutSize::new(1600.0, 1200.0));
7395
7396        assert_eq!(resized.physical_size, PhysicalSize::new(3200, 2400));
7397        assert_eq!(
7398            resized.logical_size(),
7399            fission_layout::LayoutSize::new(1600.0, 1200.0)
7400        );
7401    }
7402
7403    #[test]
7404    fn logical_resize_requests_logical_window_dimensions() {
7405        let requested = native_window_size_for_logical_viewport(fission_layout::LayoutSize::new(
7406            1600.0, 2200.0,
7407        ));
7408
7409        assert_eq!(requested.width, 1600.0);
7410        assert_eq!(requested.height, 2200.0);
7411    }
7412
7413    #[test]
7414    fn invalid_scale_factors_fall_back_to_unit_scale() {
7415        assert_eq!(normalize_scale_factor(0.0), 1.0);
7416        assert_eq!(normalize_scale_factor(-2.0), 1.0);
7417        assert_eq!(normalize_scale_factor(f64::NAN), 1.0);
7418        assert_eq!(normalize_scale_factor(f64::INFINITY), 1.0);
7419        assert_eq!(normalize_scale_factor(1.5), 1.5);
7420    }
7421
7422    #[test]
7423    fn invalid_scale_factor_does_not_shrink_viewport_math() {
7424        let logical = physical_size_to_layout_size(PhysicalSize::new(1600, 1200), 0.0);
7425        assert_eq!(logical, fission_layout::LayoutSize::new(1600.0, 1200.0));
7426
7427        let render_target = logical_viewport_to_render_target_size(
7428            fission_layout::LayoutSize::new(1600.0, 1200.0),
7429            0.0,
7430        );
7431        assert_eq!(render_target, (1600, 1200));
7432    }
7433
7434    #[test]
7435    fn surface_resize_resets_custom_target_texture_tracking() {
7436        let mut tracked_target_texture_size = (1600, 1200);
7437
7438        sync_tracked_target_texture_size_to_surface(
7439            &mut tracked_target_texture_size,
7440            PhysicalSize::new(1055, 791),
7441        );
7442
7443        assert_eq!(tracked_target_texture_size, (1055, 791));
7444        assert_ne!(
7445            tracked_target_texture_size,
7446            logical_viewport_to_render_target_size(
7447                fission_layout::LayoutSize::new(1600.0, 1200.0),
7448                1.0,
7449            )
7450        );
7451    }
7452
7453    #[test]
7454    fn resize_settle_signal_tracks_real_resize_state() {
7455        assert!(resize_is_unsettled(true, false, false));
7456        assert!(resize_is_unsettled(false, true, false));
7457        assert!(resize_is_unsettled(false, false, true));
7458        assert!(!resize_is_unsettled(false, false, false));
7459    }
7460
7461    #[test]
7462    fn screenshot_copy_extent_never_exceeds_texture_bounds() {
7463        assert_eq!(
7464            clamp_copy_extent_to_texture(1600, 1200, 1055, 791),
7465            (1055, 791)
7466        );
7467        assert_eq!(clamp_copy_extent_to_texture(0, 0, 1055, 791), (1, 1));
7468        assert_eq!(
7469            clamp_copy_extent_to_texture(640, 480, 1055, 791),
7470            (640, 480)
7471        );
7472    }
7473
7474    #[test]
7475    fn integer_downscale_uses_fast_box_path() {
7476        let rgba = vec![
7477            10, 20, 30, 255, 30, 40, 50, 255, 50, 60, 70, 255, 70, 80, 90, 255,
7478        ];
7479        let downscaled = downscale_rgba_box(&rgba, 2, 2, 1, 1).expect("downscale");
7480        assert_eq!(downscaled, vec![40, 50, 60, 255]);
7481    }
7482
7483    #[test]
7484    fn startup_deep_link_collection_filters_to_declared_config() {
7485        let config = DeepLinkConfig::new()
7486            .scheme("fission")
7487            .domain("example.com")
7488            .path_prefix("/tasks");
7489
7490        let links = collect_startup_deep_links_from(
7491            &config,
7492            vec![
7493                "--ignored".to_string(),
7494                "fission://open/tasks/1".to_string(),
7495                "other://open/tasks/1".to_string(),
7496            ],
7497            vec!["https://example.com/tasks/2?source=email".to_string()],
7498        );
7499
7500        assert_eq!(links.len(), 2);
7501        assert_eq!(links[0].url, "https://example.com/tasks/2?source=email");
7502        assert!(links[0].cold_start);
7503        assert_eq!(links[1].url, "fission://open/tasks/1");
7504        assert!(links[1].cold_start);
7505    }
7506}