Skip to main content

fission_shell_winit/
lib.rs

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