Skip to main content

fission_shell_winit/
lib.rs

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