Skip to main content

fission_shell_desktop/
lib.rs

1//! Desktop shell for Fission applications.
2//!
3//! Provides the native window, GPU-accelerated rendering pipeline (via Vello + wgpu),
4//! input handling, clipboard, IME, and platform video integration needed to run a
5//! Fission UI on macOS, Windows, and Linux.
6//!
7//! The main entry point is [`DesktopApp::new(root_widget)`](DesktopApp::new), which
8//! creates a winit event loop and runs the full build-layout-paint-present cycle.
9
10use anyhow::Result;
11use std::collections::{HashMap, VecDeque};
12use std::num::NonZeroU32;
13use std::sync::mpsc;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16use winit::{
17    dpi::PhysicalPosition,
18    event::{ElementState, Event, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
19    event_loop::{ControlFlow, EventLoop, EventLoopWindowTarget},
20    keyboard::PhysicalKey,
21    window::{Window, WindowBuilder},
22};
23
24use fission_core::env::{VideoState, VideoStateMap, VideoStatus};
25use fission_core::lowering::{build_layout_tree, LoweringContext};
26use fission_core::{
27    Action, ActionId, AppState, BuildCtx, Clock, Env, ImeHandler, InputEvent, KeyCode,
28    KeyEvent as FissionKeyEvent, Lower, Node, PointerButton, PointerEvent, Runtime, ScrollStateMap,
29    View, Widget,
30};
31use fission_core::{ActionInput, Effect, EffectPayload, SystemEffect};
32use fission_diagnostics::prelude as diag;
33use fission_ir::{op::Color as IrColor, CoreIR, FlexDirection, NodeId, Op, PaintOp, WidgetNodeId};
34use fission_layout::{LayoutEngine, LayoutSize};
35use fission_render::{
36    Color as RenderColor, DisplayList, LayoutPoint, LayoutRect, LayoutUnit, Renderer,
37};
38use fission_render_vello::parley::FontContext;
39use fission_render_vello::{VelloRenderer, VelloTextMeasurer};
40use fission_shell::{Platform, VideoBackend, VideoEvent, VideoPlayer};
41use fission_theme::fonts;
42use fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, SourceCache};
43
44// Vello / WGPU
45use pollster::block_on;
46use vello::util::{RenderContext, RenderSurface};
47use vello::wgpu;
48use vello::{AaConfig, AaSupport, Renderer as VelloSceneRenderer, RendererOptions, Scene};
49
50mod pipeline;
51pub use pipeline::Pipeline;
52mod video_backend;
53#[cfg(target_os = "macos")]
54use video_backend::MacVideoBackend;
55#[cfg(not(target_os = "macos"))]
56use video_backend::MockVideoBackend;
57
58mod clipboard;
59use clipboard::DesktopClipboard;
60mod ime;
61use ime::DesktopImeHandler;
62pub mod test_control;
63
64use fission_core::action::ActionEnvelope;
65
66/// A single completed background effect result, ready to be dispatched on the main thread.
67///
68/// Fields: `(req_id, result_payload_or_error, on_ok_continuation, on_err_continuation)`
69type EffectResult = (
70    u64,
71    std::result::Result<EffectPayload, String>,
72    Option<ActionEnvelope>,
73    Option<ActionEnvelope>,
74);
75
76/// Callback signature for application-specific effect handlers.
77///
78/// The handler receives the opaque `Vec<u8>` payload from `Effect::App(...)`,
79/// plus the envelope metadata needed to send a result back on the channel.
80pub type AppEffectHandler = Box<
81    dyn Fn(Vec<u8>, u64, Option<ActionEnvelope>, Option<ActionEnvelope>, mpsc::Sender<EffectResult>)
82        + Send
83        + Sync,
84>;
85
86struct ActivePlayer {
87    player: Box<dyn VideoPlayer>,
88    last_status: Option<VideoStatus>,
89    last_rate: Option<f32>,
90    last_volume: Option<f32>,
91    last_muted: Option<bool>,
92}
93
94fn request_redraw_throttled(
95    window: &Window,
96    elwt: &EventLoopWindowTarget<()>,
97    last_redraw_at: &mut Instant,
98    min_frame: Duration,
99    redraw_pending: &mut bool,
100) {
101    let now = Instant::now();
102    let next = *last_redraw_at + min_frame;
103    if now >= next {
104        *last_redraw_at = now;
105        *redraw_pending = false;
106        window.request_redraw();
107    } else {
108        *redraw_pending = true;
109        elwt.set_control_flow(ControlFlow::WaitUntil(next));
110    }
111}
112
113/// Drain pending effects from the runtime and either execute them synchronously
114/// (fire-and-forget effects like `OpenUrl`) or spawn background threads for I/O
115/// effects (`FileRead`, `HttpGet`) and send results back through `effect_tx`.
116///
117/// Returns `true` if any synchronous callback was dispatched (caller should redraw).
118fn process_pending_effects(
119    runtime: &mut Runtime,
120    effect_tx: &mpsc::Sender<EffectResult>,
121    app_effect_handler: Option<&AppEffectHandler>,
122) -> bool {
123    use std::process::Command;
124
125    let pending = std::mem::take(&mut runtime.pending_effects);
126    if pending.is_empty() {
127        return false;
128    }
129
130    let mut dispatched_callback = false;
131
132    for env in pending {
133        match env.effect {
134            Effect::System(ref system) => {
135                match system {
136                    // ── Fire-and-forget: OpenUrl ─────────────────────────────
137                    SystemEffect::OpenUrl { url, in_app } => {
138                        diag::emit(
139                            diag::DiagCategory::Input,
140                            diag::DiagLevel::Info,
141                            diag::DiagEventKind::InputEvent {
142                                kind: format!("system_effect:OpenUrl in_app={}", in_app),
143                                target: None,
144                                position: None,
145                            },
146                        );
147
148                        let result = if cfg!(target_os = "macos") {
149                            Command::new("open").arg(url).spawn().map(|_| ())
150                        } else if cfg!(target_os = "windows") {
151                            Command::new("cmd")
152                                .args(["/C", "start", url])
153                                .spawn()
154                                .map(|_| ())
155                        } else {
156                            Command::new("xdg-open").arg(url).spawn().map(|_| ())
157                        };
158
159                        if let Err(e) = result {
160                            diag::emit(
161                                diag::DiagCategory::Input,
162                                diag::DiagLevel::Error,
163                                diag::DiagEventKind::InputEvent {
164                                    kind: format!("system_effect:OpenUrl failed: {}", e),
165                                    target: None,
166                                    position: None,
167                                },
168                            );
169                        }
170
171                        // Dispatch immediate callback (fire-and-forget success).
172                        if let Some(on_ok) = env.on_ok {
173                            let _ = runtime.dispatch_with_input(
174                                on_ok,
175                                NodeId::derived(0, &[0]),
176                                &ActionInput::EffectOk {
177                                    req_id: env.req_id,
178                                    payload: EffectPayload::Empty,
179                                },
180                            );
181                            dispatched_callback = true;
182                        }
183                    }
184
185                    // ── Fire-and-forget: Authenticate ────────────────────────
186                    SystemEffect::Authenticate { url, .. } => {
187                        diag::emit(
188                            diag::DiagCategory::Input,
189                            diag::DiagLevel::Info,
190                            diag::DiagEventKind::InputEvent {
191                                kind: "system_effect:Authenticate".into(),
192                                target: None,
193                                position: None,
194                            },
195                        );
196                        let _ = if cfg!(target_os = "macos") {
197                            Command::new("open").arg(url).spawn()
198                        } else if cfg!(target_os = "windows") {
199                            Command::new("cmd").args(["/C", "start", url]).spawn()
200                        } else {
201                            Command::new("xdg-open").arg(url).spawn()
202                        };
203
204                        if let Some(on_ok) = env.on_ok {
205                            let _ = runtime.dispatch_with_input(
206                                on_ok,
207                                NodeId::derived(0, &[0]),
208                                &ActionInput::EffectOk {
209                                    req_id: env.req_id,
210                                    payload: EffectPayload::Empty,
211                                },
212                            );
213                            dispatched_callback = true;
214                        }
215                    }
216
217                    // ── Fire-and-forget: Alert (log only) ────────────────────
218                    SystemEffect::Alert { title, message } => {
219                        diag::emit(
220                            diag::DiagCategory::Input,
221                            diag::DiagLevel::Info,
222                            diag::DiagEventKind::InputEvent {
223                                kind: format!("system_effect:Alert title={}", title),
224                                target: None,
225                                position: None,
226                            },
227                        );
228                        eprintln!("[alert] {}: {}", title, message);
229
230                        if let Some(on_ok) = env.on_ok {
231                            let _ = runtime.dispatch_with_input(
232                                on_ok,
233                                NodeId::derived(0, &[0]),
234                                &ActionInput::EffectOk {
235                                    req_id: env.req_id,
236                                    payload: EffectPayload::Empty,
237                                },
238                            );
239                            dispatched_callback = true;
240                        }
241                    }
242
243                    // ── Background: FileRead ─────────────────────────────────
244                    SystemEffect::FileRead { path } => {
245                        let tx = effect_tx.clone();
246                        let on_ok = env.on_ok.clone();
247                        let on_err = env.on_err.clone();
248                        let req_id = env.req_id;
249                        let path = path.clone();
250                        std::thread::spawn(move || {
251                            match std::fs::read_to_string(&path) {
252                                Ok(content) => {
253                                    let payload = EffectPayload::InlineBytes(content.into_bytes());
254                                    let _ = tx.send((req_id, Ok(payload), on_ok, on_err));
255                                }
256                                Err(e) => {
257                                    let _ = tx.send((req_id, Err(e.to_string()), on_ok, on_err));
258                                }
259                            }
260                        });
261                    }
262
263                    // ── Background: HttpGet ───────────────────────────────────
264                    SystemEffect::HttpGet { url, headers } => {
265                        let tx = effect_tx.clone();
266                        let on_ok = env.on_ok.clone();
267                        let on_err = env.on_err.clone();
268                        let req_id = env.req_id;
269                        let url = url.clone();
270                        let headers = headers.clone();
271                        std::thread::spawn(move || {
272                            // Minimal blocking HTTP GET using std only (no external crate).
273                            // This parses the URL, opens a TCP stream, and reads the response.
274                            let result = (|| -> std::result::Result<Vec<u8>, String> {
275                                use std::io::{Read, Write};
276                                use std::net::TcpStream;
277
278                                // Parse URL (very basic: http://host[:port]/path)
279                                let url_trimmed = url.trim();
280                                let (scheme, rest) = if let Some(r) = url_trimmed.strip_prefix("https://") {
281                                    ("https", r)
282                                } else if let Some(r) = url_trimmed.strip_prefix("http://") {
283                                    ("http", r)
284                                } else {
285                                    return Err(format!("unsupported URL scheme: {}", url_trimmed));
286                                };
287
288                                let (host_port, path) = match rest.find('/') {
289                                    Some(i) => (&rest[..i], &rest[i..]),
290                                    None => (rest, "/"),
291                                };
292
293                                let default_port: u16 = if scheme == "https" { 443 } else { 80 };
294                                let (host, port) = match host_port.rfind(':') {
295                                    Some(i) => {
296                                        let p = host_port[i + 1..].parse::<u16>().unwrap_or(default_port);
297                                        (&host_port[..i], p)
298                                    }
299                                    None => (host_port, default_port),
300                                };
301
302                                if scheme == "https" {
303                                    return Err("HTTPS not supported in minimal HttpGet executor; use a custom app effect handler for TLS".into());
304                                }
305
306                                let addr = format!("{}:{}", host, port);
307                                let mut stream = TcpStream::connect(&addr)
308                                    .map_err(|e| format!("connect {}: {}", addr, e))?;
309                                stream
310                                    .set_read_timeout(Some(std::time::Duration::from_secs(30)))
311                                    .ok();
312
313                                let mut request = format!(
314                                    "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
315                                    path, host
316                                );
317                                for (k, v) in &headers {
318                                    request.push_str(&format!("{}: {}\r\n", k, v));
319                                }
320                                request.push_str("\r\n");
321
322                                stream
323                                    .write_all(request.as_bytes())
324                                    .map_err(|e| format!("write: {}", e))?;
325
326                                let mut buf = Vec::new();
327                                stream
328                                    .read_to_end(&mut buf)
329                                    .map_err(|e| format!("read: {}", e))?;
330
331                                // Strip HTTP headers (find \r\n\r\n)
332                                let body_start = buf
333                                    .windows(4)
334                                    .position(|w| w == b"\r\n\r\n")
335                                    .map(|i| i + 4)
336                                    .unwrap_or(0);
337
338                                Ok(buf[body_start..].to_vec())
339                            })();
340
341                            match result {
342                                Ok(bytes) => {
343                                    let payload = EffectPayload::InlineBytes(bytes);
344                                    let _ = tx.send((req_id, Ok(payload), on_ok, on_err));
345                                }
346                                Err(msg) => {
347                                    let _ = tx.send((req_id, Err(msg), on_ok, on_err));
348                                }
349                            }
350                        });
351                    }
352
353                    // ── Cancel / ReleaseResource: no-op at shell level ───────
354                    SystemEffect::Cancel { .. } | SystemEffect::ReleaseResource { .. } => {
355                        diag::emit(
356                            diag::DiagCategory::Input,
357                            diag::DiagLevel::Debug,
358                            diag::DiagEventKind::InputEvent {
359                                kind: format!("system_effect:{:?} (no-op)", system),
360                                target: None,
361                                position: None,
362                            },
363                        );
364                    }
365                }
366            }
367
368            // ── App-specific effects ─────────────────────────────────────
369            Effect::App(payload) => {
370                if let Some(handler) = app_effect_handler {
371                    handler(
372                        payload,
373                        env.req_id,
374                        env.on_ok.clone(),
375                        env.on_err.clone(),
376                        effect_tx.clone(),
377                    );
378                } else {
379                    diag::emit(
380                        diag::DiagCategory::Input,
381                        diag::DiagLevel::Warn,
382                        diag::DiagEventKind::InputEvent {
383                            kind: "app_effect:unhandled (no handler registered)".into(),
384                            target: None,
385                            position: None,
386                        },
387                    );
388                }
389            }
390        }
391    }
392
393    dispatched_callback
394}
395
396/// Drain completed background effect results from the channel and dispatch
397/// their continuations on the main thread.
398///
399/// Returns `true` if any continuation was dispatched (caller should redraw).
400fn drain_effect_results(
401    runtime: &mut Runtime,
402    effect_rx: &mpsc::Receiver<EffectResult>,
403) -> bool {
404    let mut dispatched = false;
405
406    while let Ok((req_id, result, on_ok, on_err)) = effect_rx.try_recv() {
407        match result {
408            Ok(payload) => {
409                if let Some(action) = on_ok {
410                    let _ = runtime.dispatch_with_input(
411                        action,
412                        NodeId::derived(0, &[0]),
413                        &ActionInput::EffectOk { req_id, payload },
414                    );
415                    dispatched = true;
416                }
417            }
418            Err(msg) => {
419                if let Some(action) = on_err {
420                    let _ = runtime.dispatch_with_input(
421                        action,
422                        NodeId::derived(0, &[0]),
423                        &ActionInput::EffectErr {
424                            req_id,
425                            message: msg,
426                        },
427                    );
428                    dispatched = true;
429                }
430            }
431        }
432    }
433
434    dispatched
435}
436
437fn focused_text_input_id(runtime: &Runtime, ir: Option<&CoreIR>) -> Option<NodeId> {
438    let focused = runtime.runtime_state.interaction.focused?;
439    let ir = ir?;
440    let mut current = Some(focused);
441    while let Some(id) = current {
442        let node = ir.nodes.get(&id)?;
443        if let Op::Semantics(sem) = &node.op {
444            if sem.role == fission_ir::Role::TextInput {
445                return Some(id);
446            }
447        }
448        current = node.parent;
449    }
450    None
451}
452
453fn reset_text_input_caret(
454    runtime: &mut Runtime,
455    ir: Option<&CoreIR>,
456    last_blink_toggle: &mut Instant,
457) {
458    if let Some(id) = focused_text_input_id(runtime, ir) {
459        runtime.runtime_state.caret_visible.insert(id, true);
460        *last_blink_toggle = Instant::now();
461    }
462}
463
464#[derive(Debug, Clone)]
465struct PendingTextTrace {
466    seq: u64,
467    source: String,
468    target: Option<NodeId>,
469    started_at: Instant,
470    handled_at: Option<Instant>,
471    effects_at: Option<Instant>,
472    present_after_frame: u64,
473}
474
475fn start_text_trace(
476    enabled: bool,
477    traces: &mut VecDeque<PendingTextTrace>,
478    next_seq: &mut u64,
479    source: String,
480    target: Option<NodeId>,
481    presented_frames: u64,
482) -> Option<u64> {
483    if !enabled {
484        return None;
485    }
486    *next_seq += 1;
487    let seq = *next_seq;
488    traces.push_back(PendingTextTrace {
489        seq,
490        source,
491        target,
492        started_at: Instant::now(),
493        handled_at: None,
494        effects_at: None,
495        present_after_frame: presented_frames + 1,
496    });
497    Some(seq)
498}
499
500fn mark_text_trace_handled(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
501    if let Some(seq) = seq {
502        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
503            trace.handled_at = Some(Instant::now());
504        }
505    }
506}
507
508fn mark_text_trace_effects(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
509    if let Some(seq) = seq {
510        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
511            trace.effects_at = Some(Instant::now());
512        }
513    }
514}
515
516fn set_text_trace_target(
517    traces: &mut VecDeque<PendingTextTrace>,
518    seq: Option<u64>,
519    target: Option<NodeId>,
520) {
521    if let Some(seq) = seq {
522        if let Some(trace) = traces.iter_mut().rev().find(|trace| trace.seq == seq) {
523            trace.target = target;
524        }
525    }
526}
527
528fn cancel_text_trace(traces: &mut VecDeque<PendingTextTrace>, seq: Option<u64>) {
529    if let Some(seq) = seq {
530        traces.retain(|trace| trace.seq != seq);
531    }
532}
533
534fn flush_text_traces(
535    enabled: bool,
536    traces: &mut VecDeque<PendingTextTrace>,
537    presented_frames: u64,
538) {
539    if !enabled {
540        traces.clear();
541        return;
542    }
543
544    loop {
545        let should_flush = traces
546            .front()
547            .map(|trace| trace.present_after_frame <= presented_frames)
548            .unwrap_or(false);
549        if !should_flush {
550            break;
551        }
552
553        let Some(trace) = traces.pop_front() else {
554            break;
555        };
556        let now = Instant::now();
557        let handled_at = trace.handled_at.unwrap_or(now);
558        let effects_at = trace.effects_at.unwrap_or(handled_at);
559        let total_ms = now.duration_since(trace.started_at).as_secs_f64() * 1000.0;
560        let handle_ms = handled_at
561            .duration_since(trace.started_at)
562            .as_secs_f64()
563            * 1000.0;
564        let effects_ms = effects_at
565            .duration_since(handled_at)
566            .as_secs_f64()
567            * 1000.0;
568        let queue_ms = now.duration_since(effects_at).as_secs_f64() * 1000.0;
569
570        let target_u128 = trace.target.map(|id| id.as_u128());
571        let msg = format!(
572            "text_input_latency seq={} src={} handle_ms={:.2} effects_ms={:.2} queue_ms={:.2} total_ms={:.2} frame={}",
573            trace.seq, trace.source, handle_ms, effects_ms, queue_ms, total_ms, presented_frames
574        );
575        eprintln!("[text-trace] {}", msg);
576        diag::emit(
577            diag::DiagCategory::Input,
578            diag::DiagLevel::Info,
579            diag::DiagEventKind::InputEvent {
580                kind: msg,
581                target: target_u128,
582                position: None,
583            },
584        );
585    }
586}
587
588/// Type alias for an application-level key handler.
589///
590/// Receives `(&mut S, &KeyCode, modifiers)` and returns `true` if the key was consumed.
591pub type KeyHandler<S> = Arc<dyn Fn(&mut S, &fission_core::KeyCode, u8) -> bool + Send + Sync>;
592
593/// Type alias for a per-frame hook callback.
594///
595/// Receives `(&mut S)` and returns `true` to request a redraw.
596pub type FrameHook<S> = Arc<dyn Fn(&mut S) -> bool + Send + Sync>;
597
598/// The desktop application shell that owns the window, event loop, and rendering pipeline.
599///
600/// Generic over `S` (the application state type) and `W` (the root widget type).
601/// Construct via [`DesktopApp::new()`] and configure with builder methods
602/// (`with_title`, `with_state_init`, `with_key_handler`, etc.) before calling
603/// [`run()`](DesktopApp::run) to start the event loop.
604pub struct DesktopApp<S: AppState, W: Widget<S>> {
605    runtime: Runtime,
606    layout_engine: LayoutEngine,
607    root_widget: W,
608    env: Env,
609    pipeline: Pipeline,
610    measurer: Arc<VelloTextMeasurer>,
611    sync_env: Option<Arc<dyn Fn(&S, &mut Env) + Send + Sync>>,
612    key_handler: Option<KeyHandler<S>>,
613    frame_hook: Option<FrameHook<S>>,
614    title: String,
615    /// Channel pair for receiving completed background effect results.
616    effect_result_tx: mpsc::Sender<EffectResult>,
617    effect_result_rx: mpsc::Receiver<EffectResult>,
618    /// Optional handler for `Effect::App(...)` payloads.
619    app_effect_handler: Option<AppEffectHandler>,
620    _phantom: std::marker::PhantomData<S>,
621}
622
623impl<S: AppState + Default, W: Widget<S> + 'static> DesktopApp<S, W> {
624    pub fn new(root_widget: W) -> Self {
625        let mut runtime = Runtime::default();
626        runtime.add_app_state(Box::new(S::default())).unwrap();
627
628        const DEFAULT_FONT_FAMILY: &str = "Fission Default";
629        let font_cx = Arc::new(Mutex::new(build_font_context()));
630        {
631            let mut font_cx = font_cx.lock().unwrap();
632            let font_data = fonts::default_font_bytes().to_vec();
633            let info_override = FontInfoOverride {
634                family_name: Some(DEFAULT_FONT_FAMILY),
635                ..Default::default()
636            };
637            font_cx
638                .collection
639                .register_fonts(Blob::from(font_data), Some(info_override));
640        }
641        let measurer = Arc::new(VelloTextMeasurer::new_with_default_family(
642            font_cx.clone(),
643            DEFAULT_FONT_FAMILY,
644        ));
645        let env = Env::new(measurer.clone() as Arc<dyn fission_layout::TextMeasurer>);
646        let clipboard: Arc<dyn fission_core::env::Clipboard> = Arc::new(DesktopClipboard::new());
647
648        let layout_engine = LayoutEngine::new().with_measurer(measurer.clone());
649        let runtime = runtime
650            .with_measurer(measurer.clone())
651            .with_clipboard(clipboard);
652
653        let (effect_result_tx, effect_result_rx) = mpsc::channel();
654
655        Self {
656            runtime,
657            layout_engine,
658            root_widget,
659            env,
660            pipeline: Pipeline::new(),
661            measurer,
662            sync_env: None,
663            key_handler: None,
664            frame_hook: None,
665            title: "Fission".into(),
666            effect_result_tx,
667            effect_result_rx,
668            app_effect_handler: None,
669            _phantom: std::marker::PhantomData,
670        }
671    }
672
673    pub fn with_key_handler<F>(mut self, handler: F) -> Self
674    where
675        F: Fn(&mut S, &fission_core::KeyCode, u8) -> bool + Send + Sync + 'static,
676    {
677        self.key_handler = Some(Arc::new(handler));
678        self
679    }
680
681    pub fn with_title(mut self, title: impl Into<String>) -> Self {
682        self.title = title.into();
683        self
684    }
685
686    /// Mutate the initial application state before the first frame.
687    pub fn with_state_init<F>(mut self, init: F) -> Self
688    where
689        F: FnOnce(&mut S),
690    {
691        if let Some(state) = self.runtime.get_app_state_mut::<S>() {
692            init(state);
693        }
694        self
695    }
696
697    pub fn with_env(mut self, env: Env) -> Self {
698        self.env = env;
699        self
700    }
701
702    pub fn with_sync_env<F>(mut self, f: F) -> Self
703    where
704        F: Fn(&S, &mut Env) + Send + Sync + 'static,
705    {
706        self.sync_env = Some(Arc::new(f));
707        self
708    }
709
710    /// Register a hook that runs on every `AboutToWait` event with mutable
711    /// access to the application state.  Return `true` to request a redraw.
712    /// Useful for polling background services (e.g. LSP) between key events.
713    pub fn with_frame_hook<F>(mut self, f: F) -> Self
714    where
715        F: Fn(&mut S) -> bool + Send + Sync + 'static,
716    {
717        self.frame_hook = Some(Arc::new(f));
718        self
719    }
720
721    /// Register a handler for `Effect::App(payload)` effects.
722    ///
723    /// The handler runs on the calling thread and should spawn its own
724    /// background work if needed, sending results through the provided
725    /// `mpsc::Sender<EffectResult>`.
726    pub fn with_app_effect_handler<F>(mut self, handler: F) -> Self
727    where
728        F: Fn(Vec<u8>, u64, Option<ActionEnvelope>, Option<ActionEnvelope>, mpsc::Sender<EffectResult>)
729            + Send
730            + Sync
731            + 'static,
732    {
733        self.app_effect_handler = Some(Box::new(handler));
734        self
735    }
736
737    pub fn register_reducer(
738        &mut self,
739        action_id: ActionId,
740        reducer: fn(&mut S, &fission_core::ActionEnvelope, NodeId) -> Result<()>,
741    ) -> Result<()> {
742        self.runtime.register_reducer::<S>(action_id, reducer)
743    }
744
745    pub fn absorb_registry(&mut self, registry: fission_core::ActionRegistry<S>) {
746        self.runtime.absorb_persistent_registry(registry);
747    }
748
749    pub fn run(mut self) -> Result<()> {
750        diag::emit(
751            diag::DiagCategory::Frame,
752            diag::DiagLevel::Info,
753            diag::DiagEventKind::FrameStart { root: None },
754        );
755        diag::init_from_env();
756        let event_loop =
757            EventLoop::new().map_err(|e| anyhow::anyhow!("Event loop error: {}", e))?;
758        let window = Arc::new(
759            WindowBuilder::new()
760                .with_title(&self.title)
761                .build(&event_loop)
762                .map_err(|e| anyhow::anyhow!("Window build error: {}", e))?,
763        );
764
765        let ime_handler: Arc<dyn ImeHandler> = Arc::new(DesktopImeHandler::new(window.clone()));
766        self.runtime = self.runtime.with_ime_handler(ime_handler);
767
768        // Vello Context
769        let mut render_cx = RenderContext::new();
770        let mut surface = block_on(render_cx.create_surface(
771            window.clone(),
772            window.inner_size().width,
773            window.inner_size().height,
774            wgpu::PresentMode::AutoVsync,
775        ))
776        .unwrap();
777
778        // Enable Alpha for video hole punching
779        let device_handle = &render_cx.devices[surface.dev_id];
780        surface.config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied;
781        surface
782            .surface
783            .configure(&device_handle.device, &surface.config);
784
785        // Recreate target texture with COPY_SRC so GPU screenshots work
786        recreate_target_texture(&mut surface, &render_cx);
787
788        let mut vello_renderer = VelloSceneRenderer::new(
789            &device_handle.device,
790            RendererOptions {
791                use_cpu: false,
792                antialiasing_support: AaSupport::all(),
793                num_init_threads: None,
794                pipeline_cache: None,
795            },
796        )
797        .unwrap();
798
799        let mut scene = Scene::new();
800
801        window.request_redraw();
802
803        let mut runtime = self.runtime;
804        let mut layout_engine = self.layout_engine;
805        let root_widget = self.root_widget;
806        let mut env = self.env;
807        let mut pipeline = self.pipeline;
808        let measurer = self.measurer;
809        let effect_result_tx = self.effect_result_tx;
810        let effect_result_rx = self.effect_result_rx;
811        let app_effect_handler = self.app_effect_handler;
812
813        #[cfg(target_os = "macos")]
814        let video_backend: Arc<dyn VideoBackend> = Arc::new(MacVideoBackend::new(&window));
815        #[cfg(not(target_os = "macos"))]
816        let video_backend: Arc<dyn VideoBackend> = Arc::new(MockVideoBackend::new());
817        let mut players: HashMap<WidgetNodeId, ActivePlayer> = HashMap::new();
818
819        let mut last_cursor_position: Option<PhysicalPosition<f64>> = None;
820        let max_fps = std::env::var("FISSION_MAX_FPS")
821            .ok()
822            .and_then(|v| v.parse::<u32>().ok())
823            .filter(|v| *v > 0)
824            .unwrap_or(60);
825        let min_frame = Duration::from_secs_f32(1.0 / max_fps as f32);
826        let mut last_redraw_at = Instant::now()
827            .checked_sub(min_frame)
828            .unwrap_or_else(Instant::now);
829        let mut redraw_pending = false;
830        let mut last_frame_time = Instant::now();
831        let blink_enabled = std::env::var("FISSION_TEXTINPUT_BLINK")
832            .map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
833            .unwrap_or(true);
834        let blink_period = Duration::from_millis(
835            std::env::var("FISSION_TEXTINPUT_BLINK_MS")
836                .ok()
837                .and_then(|v| v.parse::<u64>().ok())
838                .filter(|v| *v > 0)
839                .unwrap_or(530),
840        );
841        let mut last_blink_toggle = Instant::now();
842        let mut blink_focus_id: Option<NodeId> = None;
843        let text_trace_enabled = std::env::var("FISSION_TEXT_TRACE")
844            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
845            .unwrap_or(false);
846        let mut presented_frames: u64 = 0;
847        let mut next_text_trace_seq: u64 = 0;
848        let mut pending_text_traces: VecDeque<PendingTextTrace> = VecDeque::new();
849
850        let mut current_mods: u8 = 0;
851
852        // Test control channel (enabled via FISSION_TEST_CONTROL_PORT env var)
853        let test_control_rx: Option<test_control::CommandReceiver> = std::env::var("FISSION_TEST_CONTROL_PORT")
854            .ok()
855            .and_then(|v| v.parse::<u16>().ok())
856            .map(|port| {
857                let (tx, rx) = test_control::create_channel();
858                test_control::spawn_server(port, tx);
859                rx
860            });
861        let mut pending_screenshot: Option<(String, test_control::ResponseSender)> = None;
862
863        event_loop
864            .run(move |event, elwt| {
865                elwt.set_control_flow(ControlFlow::Wait);
866
867                match event {
868                    Event::AboutToWait => {
869                        let now = Instant::now();
870                        let dt = now.duration_since(last_frame_time);
871                        last_frame_time = now;
872
873                        // Tick Runtime (Animations)
874                        let dt_ms = dt.as_millis() as u64;
875                        if let Err(e) = runtime.tick(dt_ms) {
876                            eprintln!("Runtime tick error: {:?}", e);
877                        }
878
879                        // Video Logic
880                        let surfaces = pipeline.take_video_surfaces();
881                        let mut active_nodes = std::collections::HashSet::new();
882
883                        for surface in &surfaces {
884                            active_nodes.insert(surface.widget_id);
885
886                            // Create player if missing
887                            if !players.contains_key(&surface.widget_id) {
888                                if let Some(state) = runtime.runtime_state.video.states.get(&surface.widget_id) {
889                                    let source = &state.asset_source;
890                                    if !source.is_empty() {
891                                        let player = video_backend.create_player(source);
892                                        if let Some(state) = runtime.runtime_state.video.states.get_mut(&surface.widget_id) {
893                                            state.surface_id = Some(player.surface_id());
894                                        }
895                                        players.insert(surface.widget_id, ActivePlayer {
896                                            player,
897                                            last_status: None,
898                                            last_rate: None,
899                                            last_volume: None,
900                                            last_muted: None,
901                                        });
902                                    }
903                                }
904                            }
905                        }
906
907                        // Cleanup inactive players
908                        players.retain(|id, _| active_nodes.contains(id));
909
910                        // Update backend
911                        video_backend.present_surfaces(&surfaces);
912
913                        // Video Logic - Process Player Events and Sync State
914                        for (widget_id, active_player) in players.iter_mut() {
915                            if let Some(video_state) = runtime.runtime_state.video.states.get_mut(widget_id) {
916                                let player = &mut active_player.player;
917
918                                // Sync player controls from runtime state
919                                if active_player.last_status != Some(video_state.status) {
920                                    match video_state.status {
921                                        VideoStatus::Playing => player.play(),
922                                        VideoStatus::Paused => player.pause(),
923                                        VideoStatus::Stopped => player.stop(),
924                                        _ => {}
925                                    }
926                                    active_player.last_status = Some(video_state.status);
927                                }
928
929                                // Update runtime state from player events
930                                for event in player.poll_events() {
931                                    match event {
932                                        VideoEvent::Ready { duration } => {
933                                            video_state.duration_ms = Some(duration);
934                                            if video_state.status == VideoStatus::Playing {
935                                                player.play();
936                                            }
937                                        },
938                                        VideoEvent::Ended => {
939                                            video_state.status = VideoStatus::Ended;
940                                            active_player.last_status = Some(VideoStatus::Ended);
941                                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
942                                        },
943                                        VideoEvent::Error(e) => {
944                                            eprintln!("Video playback error for {:?}: {:?}", widget_id, e);
945                                            video_state.status = VideoStatus::Error;
946                                            active_player.last_status = Some(VideoStatus::Error);
947                                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
948                                        },
949                                    }
950                                }
951                                // Sync other properties
952                                video_state.position_ms = player.position();
953
954                                if active_player.last_rate != Some(video_state.rate) {
955                                    player.set_rate(video_state.rate);
956                                    active_player.last_rate = Some(video_state.rate);
957                                }
958                                if active_player.last_volume != Some(video_state.volume) {
959                                    player.set_volume(video_state.volume);
960                                    active_player.last_volume = Some(video_state.volume);
961                                }
962                                if active_player.last_muted != Some(video_state.muted) {
963                                    player.set_muted(video_state.muted);
964                                    active_player.last_muted = Some(video_state.muted);
965                                }
966
967                                if let Some(seek_pos) = video_state.pending_seek.take() {
968                                    player.seek_to(seek_pos);
969                                }
970                            }
971                        }
972
973                        // Check if we need a redraw (Animation or Video playing)
974                        // Only force continuous redraws for non-repeating animations
975                        // or active video players. Repeating animations (spinners, skeleton
976                        // shimmer) should not burn CPU when idle.
977                        let has_finite_animation = runtime.runtime_state.animation.active.values()
978                            .any(|a| !a.repeat);
979                        let has_repeating_animation = runtime.runtime_state.animation.active.values()
980                            .any(|a| a.repeat);
981                        let needs_redraw = has_finite_animation || !players.is_empty();
982
983                        let focused_text_input = focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
984                        if focused_text_input != blink_focus_id {
985                            if let Some(prev) = blink_focus_id {
986                                runtime.runtime_state.caret_visible.remove(&prev);
987                            }
988                            blink_focus_id = focused_text_input;
989                            if let Some(id) = blink_focus_id {
990                                runtime.runtime_state.caret_visible.insert(id, true);
991                                last_blink_toggle = now;
992                                request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
993                            }
994                        }
995
996                        // Cursor blink: toggle visibility but DON'T request a full
997                        // rebuild.  A full build/layout/paint cycle is too expensive
998                        // for the editor (tree-sitter, minimap, etc.).  Instead, we
999                        // just note the flag change and let the NEXT user-triggered
1000                        // redraw pick it up.  The cursor will appear steady but
1001                        // that's how VS Code / Zed work too.
1002                        if blink_enabled {
1003                            if let Some(id) = blink_focus_id {
1004                                if now.duration_since(last_blink_toggle) >= blink_period {
1005                                    let visible = runtime.runtime_state.caret_visible.get(&id).copied().unwrap_or(true);
1006                                    runtime.runtime_state.caret_visible.insert(id, !visible);
1007                                    last_blink_toggle = now;
1008                                    // NOTE: intentionally NOT requesting redraw here.
1009                                    // The caret state will be picked up on the next
1010                                    // user-triggered frame (typing, scrolling, etc.)
1011                                }
1012                            }
1013                        }
1014
1015                        let blink_wake_at = if blink_enabled && blink_focus_id.is_some() {
1016                            Some(last_blink_toggle + blink_period)
1017                        } else {
1018                            None
1019                        };
1020
1021                        // Poll test control channel
1022                        if let Some(ref rx) = test_control_rx {
1023                            while let Ok((cmd, responder)) = rx.try_recv() {
1024                                use fission_test_driver::{TestCommand, TestResponse, TextItem, SemanticNode};
1025                                let resp = match cmd {
1026                                    TestCommand::Tap { x, y } => {
1027                                        let point = LayoutPoint::new(x, y);
1028                                        if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1029                                            let _ = runtime.handle_input(InputEvent::Pointer(PointerEvent::Down { point, button: PointerButton::Primary }), ir, snap);
1030                                            let _ = runtime.handle_input(InputEvent::Pointer(PointerEvent::Up { point, button: PointerButton::Primary }), ir, snap);
1031                                        }
1032                                        TestResponse::Ok {}
1033                                    }
1034                                    TestCommand::TapText { text } => {
1035                                        if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1036                                            let mut found = None;
1037                                            for (id, node) in &ir.nodes {
1038                                                let txt = match &node.op {
1039                                                    fission_ir::Op::Paint(fission_ir::PaintOp::DrawText { text: t, .. }) => Some(t.as_str()),
1040                                                    fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
1041                                                        // Check concatenated text
1042                                                        let combined: String = runs.iter().map(|r| r.text.clone()).collect();
1043                                                        if combined.contains(&text) { Some("") } else { None }
1044                                                    }
1045                                                    _ => None,
1046                                                };
1047                                                if let Some(t) = txt {
1048                                                    if t.contains(&text) || t.is_empty() {
1049                                                        // Find parent layout node for position
1050                                                        let check_id = node.parent.unwrap_or(*id);
1051                                                        if let Some(rect) = snap.get_node_rect(check_id).or_else(|| snap.get_node_rect(*id)) {
1052                                                            found = Some((rect.x() + rect.width() / 2.0, rect.y() + rect.height() / 2.0));
1053                                                            break;
1054                                                        }
1055                                                    }
1056                                                }
1057                                            }
1058                                            if let Some((cx, cy)) = found {
1059                                                let point = LayoutPoint::new(cx, cy);
1060                                                let _ = runtime.handle_input(InputEvent::Pointer(PointerEvent::Down { point, button: PointerButton::Primary }), ir, snap);
1061                                                let _ = runtime.handle_input(InputEvent::Pointer(PointerEvent::Up { point, button: PointerButton::Primary }), ir, snap);
1062                                                TestResponse::Ok {}
1063                                            } else {
1064                                                TestResponse::Error { message: format!("text '{}' not found", text) }
1065                                            }
1066                                        } else {
1067                                            TestResponse::Error { message: "no frame rendered yet".into() }
1068                                        }
1069                                    }
1070                                    TestCommand::Scroll { x, y, dx, dy } => {
1071                                        let point = LayoutPoint::new(x, y);
1072                                        let delta = LayoutPoint::new(dx, dy);
1073                                        if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1074                                            let _ = runtime.handle_input(InputEvent::Pointer(PointerEvent::Scroll { point, delta }), ir, snap);
1075                                        }
1076                                        TestResponse::Ok {}
1077                                    }
1078                                    TestCommand::TypeText { text } => {
1079                                        if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1080                                            for ch in text.chars() {
1081                                                let key = if ch == ' ' { KeyCode::Space } else if ch == '\n' { KeyCode::Enter } else { KeyCode::Char(ch) };
1082                                                let _ = runtime.handle_input(InputEvent::Keyboard(FissionKeyEvent::Down { key_code: key, modifiers: 0 }), ir, snap);
1083                                            }
1084                                        }
1085                                        TestResponse::Ok {}
1086                                    }
1087                                    TestCommand::PressKey { key, modifiers } => {
1088                                        let kc = match key.as_str() {
1089                                            "Enter" => KeyCode::Enter,
1090                                            "Escape" => KeyCode::Escape,
1091                                            "Tab" => KeyCode::Tab,
1092                                            "Backspace" => KeyCode::Backspace,
1093                                            "Left" => KeyCode::Left,
1094                                            "Right" => KeyCode::Right,
1095                                            "Up" => KeyCode::Up,
1096                                            "Down" => KeyCode::Down,
1097                                            "Home" => KeyCode::Home,
1098                                            "End" => KeyCode::End,
1099                                            "Space" => KeyCode::Space,
1100                                            s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()),
1101                                            _ => KeyCode::Space,
1102                                        };
1103                                        // Check app key handler first
1104                                        let mut handled = false;
1105                                        if let Some(handler) = &self.key_handler {
1106                                            let handler = handler.clone();
1107                                            if let Some(state) = runtime.get_app_state_mut::<S>() {
1108                                                handled = handler(state, &kc, modifiers);
1109                                            }
1110                                        }
1111                                        if !handled {
1112                                            if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1113                                                let _ = runtime.handle_input(InputEvent::Keyboard(FissionKeyEvent::Down { key_code: kc, modifiers }), ir, snap);
1114                                            }
1115                                        }
1116                                        TestResponse::Ok {}
1117                                    }
1118                                    TestCommand::Screenshot { path } => {
1119                                        pending_screenshot = Some((path, responder));
1120                                        window.request_redraw();
1121                                        continue; // Don't respond yet, respond after render
1122                                    }
1123                                    TestCommand::GetText {} => {
1124                                        let mut items = Vec::new();
1125                                        if let (Some(ir), Some(snap)) = (pipeline.prev_ir.as_ref(), pipeline.last_snapshot.as_ref()) {
1126                                            for (id, node) in &ir.nodes {
1127                                                let text_content = match &node.op {
1128                                                    fission_ir::Op::Paint(fission_ir::PaintOp::DrawText { text, .. }) => Some(text.clone()),
1129                                                    fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
1130                                                        Some(runs.iter().map(|r| r.text.clone()).collect::<String>())
1131                                                    }
1132                                                    _ => None,
1133                                                };
1134                                                if let Some(text) = text_content {
1135                                                    if text.is_empty() { continue; }
1136                                                    let check_id = node.parent.unwrap_or(*id);
1137                                                    let rect = snap.get_node_rect(check_id).or_else(|| snap.get_node_rect(*id));
1138                                                    let (x, y, w, h) = rect.map(|r| (r.x(), r.y(), r.width(), r.height())).unwrap_or((0.0, 0.0, 0.0, 0.0));
1139                                                    items.push(TextItem { text, x, y, width: w, height: h });
1140                                                }
1141                                            }
1142                                        }
1143                                        TestResponse::Text { items }
1144                                    }
1145                                    TestCommand::GetTree {} => {
1146                                        let mut nodes = Vec::new();
1147                                        if let Some(ir) = &pipeline.prev_ir {
1148                                            for (id, node) in &ir.nodes {
1149                                                if let fission_ir::Op::Semantics(sem) = &node.op {
1150                                                    let rect = pipeline.last_snapshot.as_ref()
1151                                                        .and_then(|s| s.get_node_rect(*id));
1152                                                    let (x, y, w, h) = rect.map(|r| (r.x(), r.y(), r.width(), r.height())).unwrap_or((0.0, 0.0, 0.0, 0.0));
1153                                                    nodes.push(SemanticNode {
1154                                                        role: format!("{:?}", sem.role),
1155                                                        label: sem.label.clone(),
1156                                                        value: sem.value.clone(),
1157                                                        focusable: sem.focusable,
1158                                                        x, y, width: w, height: h,
1159                                                    });
1160                                                }
1161                                            }
1162                                        }
1163                                        TestResponse::Tree { nodes }
1164                                    }
1165                                    TestCommand::Wait { ms } => {
1166                                        std::thread::sleep(std::time::Duration::from_millis(ms));
1167                                        TestResponse::Ok {}
1168                                    }
1169                                    TestCommand::Pump {} => {
1170                                        // Defer response until after the next frame renders
1171                                        pending_screenshot = Some(("__pump__".into(), responder));
1172                                        window.request_redraw();
1173                                        continue; // Don't respond yet
1174                                    }
1175                                    TestCommand::Quit {} => {
1176                                        elwt.exit();
1177                                        TestResponse::Ok {}
1178                                    }
1179                                };
1180                                let _ = responder.send(resp);
1181                                window.request_redraw();
1182                            }
1183                        }
1184
1185                        // Drain completed background effect results and dispatch
1186                        // their continuations back into the runtime on the main thread.
1187                        let effect_results_dispatched = drain_effect_results(&mut runtime, &effect_result_rx);
1188                        if effect_results_dispatched {
1189                            // Background work completed — process any new effects
1190                            // the continuation reducers may have emitted.
1191                            if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1192                                request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1193                            }
1194                            window.request_redraw();
1195                        }
1196
1197                        // Application frame hook (e.g. LSP polling).
1198                        let frame_hook_wants_redraw = if let Some(ref hook) = self.frame_hook {
1199                            let hook = hook.clone();
1200                            if let Some(state) = runtime.get_app_state_mut::<S>() {
1201                                hook(state)
1202                            } else {
1203                                false
1204                            }
1205                        } else {
1206                            false
1207                        };
1208                        if frame_hook_wants_redraw {
1209                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1210                        }
1211
1212                        // When a frame_hook is registered, ensure the event loop
1213                        // wakes at least every 2 seconds so the hook fires even
1214                        // when no user input or animation is happening (e.g. for
1215                        // asynchronous LSP diagnostics).
1216                        let frame_hook_wake_at = if self.frame_hook.is_some() {
1217                            Some(now + Duration::from_secs(2))
1218                        } else {
1219                            None
1220                        };
1221
1222                        if needs_redraw || redraw_pending || effect_results_dispatched || frame_hook_wants_redraw {
1223                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1224                            let mut wake_at = last_redraw_at + min_frame;
1225                            if let Some(blink_at) = blink_wake_at {
1226                                if blink_at < wake_at {
1227                                    wake_at = blink_at;
1228                                }
1229                            }
1230                            if let Some(hook_at) = frame_hook_wake_at {
1231                                if hook_at < wake_at {
1232                                    wake_at = hook_at;
1233                                }
1234                            }
1235                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
1236                        } else if let Some(blink_at) = blink_wake_at {
1237                            let mut wake_at = blink_at;
1238                            if let Some(hook_at) = frame_hook_wake_at {
1239                                if hook_at < wake_at {
1240                                    wake_at = hook_at;
1241                                }
1242                            }
1243                            elwt.set_control_flow(ControlFlow::WaitUntil(wake_at));
1244                        } else if let Some(hook_at) = frame_hook_wake_at {
1245                            elwt.set_control_flow(ControlFlow::WaitUntil(hook_at));
1246                        } else if has_repeating_animation {
1247                            // Wake at 200ms for repeating animations (spinners, shimmer)
1248                            let anim_at = now + Duration::from_millis(200);
1249                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1250                            elwt.set_control_flow(ControlFlow::WaitUntil(anim_at));
1251                        } else {
1252                            elwt.set_control_flow(ControlFlow::Wait);
1253                        }
1254                    }
1255                    Event::WindowEvent { window_id, event } if window_id == window.id() => {
1256                        match event {
1257                            WindowEvent::Resized(size) => {
1258                                if size.width > 0 && size.height > 0 {
1259                                    // Invalidate viewport to force full layout rebuild
1260                                    pipeline.last_viewport = None;
1261                                    request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1262                                }
1263                            }
1264                            WindowEvent::ScaleFactorChanged { .. } => {
1265                                request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1266                            }
1267                            WindowEvent::RedrawRequested => {
1268                                redraw_pending = false;
1269                                diag::begin_frame(None);
1270                                // Drain pending effects before building the next frame.
1271                                // This prevents the effect queue from growing unbounded.
1272                                if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1273                                    request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1274                                }
1275                                let size = window.inner_size();
1276                                if size.width > 0 && size.height > 0 {
1277                                    if size.width != surface.config.width || size.height != surface.config.height {
1278                                        render_cx.resize_surface(&mut surface, size.width, size.height);
1279                                        // Re-apply alpha mode after resize
1280                                        let device_handle = &render_cx.devices[surface.dev_id];
1281                                        surface.config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied;
1282                                        surface.surface.configure(&device_handle.device, &surface.config);
1283                                        // Recreate target texture with COPY_SRC for screenshots
1284                                        recreate_target_texture(&mut surface, &render_cx);
1285                                    }
1286
1287                                    let scale_factor = window.scale_factor();
1288                                    let layout_width = (size.width as f64 / scale_factor) as f32;
1289                                    let layout_height = (size.height as f64 / scale_factor) as f32;
1290                                    env.viewport_size = LayoutSize {
1291                                        width: layout_width,
1292                                        height: layout_height,
1293                                    };
1294
1295                                    if let Some(sync) = &self.sync_env {
1296                                        let state = runtime.get_app_state::<S>().unwrap();
1297                                        sync(state, &mut env);
1298                                    }
1299
1300                                    let (node_tree, registry, anims, videos, web_views, portals) = {
1301                                        let state = runtime.get_app_state::<S>().unwrap();
1302                                        let view = View::new(state, &runtime.runtime_state, &env, pipeline.last_snapshot.as_ref());
1303                                        let mut ctx = BuildCtx::new();
1304                                        let node = root_widget.build(&mut ctx, &view);
1305                                        let anims = ctx.take_animation_requests();
1306                                        let videos = ctx.take_video_registrations();
1307                                        let web_views = ctx.take_web_registrations();
1308                                        let portals_with_ids = ctx.take_portals();
1309                                        
1310                                        let portals = portals_with_ids.into_iter().map(|(id, node)| {
1311                                            if let Some(id) = id {
1312                                                // Use a derived ID for the wrapper to avoid conflict with the widget's own node
1313                                                let wrapper_id = fission_core::NodeId::derived(id.as_u128(), &[0x0000_F001]);
1314                                                fission_core::ui::Container::new(node)
1315                                                    .id(wrapper_id)
1316                                                    .width(env.viewport_size.width)
1317                                                    .height(env.viewport_size.height)
1318                                                    .into_node()
1319                                            } else {
1320                                                node
1321                                            }
1322                                        }).collect::<Vec<_>>();
1323
1324                                        // Emit portal summary to diagnostics
1325                                        {
1326                                            use fission_diagnostics::prelude as diag;
1327                                            diag::emit(
1328                                                diag::DiagCategory::Layout,
1329                                                diag::DiagLevel::Debug,
1330                                                diag::DiagEventKind::PortalsComposed { portal_count: portals.len() as u32 },
1331                                            );
1332                                        }
1333                                        (node, ctx.registry, anims, videos, web_views, portals)
1334                                    };
1335
1336                                    runtime.clear_reducers();
1337                                    runtime.absorb_registry(registry);
1338                                    for (target, req) in anims {
1339                                        runtime.enqueue_animation(target, req);
1340                                    }
1341                                    runtime.sync_video_nodes(&videos);
1342                                    runtime.sync_web_nodes(&web_views);
1343
1344                                    // Always compose an overlay layer above content.
1345                                    // Portals are injected into that layer and never
1346                                    // participate in normal layout.
1347                                    let final_root = fission_core::Node::Overlay(
1348                                        fission_core::ui::Overlay {
1349                                            id: None,
1350                                            content: Box::new(node_tree),
1351                                            overlay: Box::new(fission_core::Node::ZStack(
1352                                                fission_core::ui::ZStack {
1353                                                    children: portals,
1354                                                    ..Default::default()
1355                                                },
1356                                            )),
1357                                        }
1358                                    );
1359
1360                                    let mut lower_cx = LoweringContext::new(&env, &runtime.runtime_state, runtime.measurer.as_ref(), pipeline.last_snapshot.as_ref());
1361                                    let root_id = final_root.lower(&mut lower_cx);
1362                                    lower_cx.ir.root = Some(root_id);
1363                                    let cx_ir = lower_cx.ir;
1364
1365                                    let viewport = LayoutSize {
1366                                        width: layout_width,
1367                                        height: layout_height,
1368                                    };
1369
1370                                    // Vello Rendering
1371                                    scene.reset();
1372
1373                                    let mut renderer_wrapper = VelloRenderer::new(&mut scene, measurer.clone(), scale_factor);
1374
1375                                    match pipeline.render(
1376                                        cx_ir,
1377                                        viewport,
1378                                        &mut layout_engine,
1379                                        &runtime.runtime_state.scroll,
1380                                        &mut renderer_wrapper,
1381                                        &runtime.runtime_state.video,
1382                                        &runtime.runtime_state.web,
1383                                        &env,
1384                                    ) {
1385                                        Ok(_stats) => {
1386                                            let surface_texture = surface.surface.get_current_texture().expect("failed to get texture");
1387                                            let device_handle = &render_cx.devices[surface.dev_id];
1388
1389                                            let render_params = vello::RenderParams {
1390                                                base_color: vello::peniko::Color::from_rgb8(30, 30, 30),
1391                                                width: size.width,
1392                                                height: size.height,
1393                                                antialiasing_method: vello::AaConfig::Area,
1394                                            };
1395
1396                                            vello_renderer.render_to_texture(
1397                                                &device_handle.device,
1398                                                &device_handle.queue,
1399                                                &scene,
1400                                                &surface.target_view,
1401                                                &render_params,
1402                                            ).expect("failed to render");
1403
1404                                            let surface_view = surface_texture.texture.create_view(&wgpu::TextureViewDescriptor::default());
1405
1406                                            let mut encoder = device_handle.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1407                                                label: Some("Surface Blit"),
1408                                            });
1409
1410                                            surface.blitter.copy(
1411                                                &device_handle.device,
1412                                                &mut encoder,
1413                                                &surface.target_view,
1414                                                &surface_view,
1415                                            );
1416
1417                                            device_handle.queue.submit(Some(encoder.finish()));
1418
1419                                            // GPU screenshot BEFORE present (target texture has content)
1420                                            if let Some((path, responder)) = pending_screenshot.take() {
1421                                                if path == "__pump__" {
1422                                                    let _ = responder.send(fission_test_driver::TestResponse::Ok {});
1423                                                } else {
1424                                                    let resp = gpu_screenshot(
1425                                                        &device_handle.device,
1426                                                        &device_handle.queue,
1427                                                        &surface.target_texture,
1428                                                        size.width,
1429                                                        size.height,
1430                                                        &path,
1431                                                    );
1432                                                    let _ = responder.send(resp);
1433                                                }
1434                                            }
1435
1436                                            surface_texture.present();
1437
1438                                            presented_frames = presented_frames.saturating_add(1);
1439                                            flush_text_traces(
1440                                                text_trace_enabled,
1441                                                &mut pending_text_traces,
1442                                                presented_frames,
1443                                            );
1444
1445                                            diag::end_frame(diag::FrameStats::default());
1446                                        }
1447                                        Err(e) => {
1448                                            eprintln!("Pipeline error: {:?}", e);
1449                                        }
1450                                    }
1451                                }
1452                            }
1453                            WindowEvent::CloseRequested => {
1454                                elwt.exit();
1455                            }
1456                            // Input Handling
1457                            WindowEvent::CursorMoved { position, .. } => {
1458                                if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
1459                                    last_cursor_position = Some(position);
1460                                    let scale_factor = window.scale_factor();
1461                                    let point = LayoutPoint {
1462                                        x: (position.x / scale_factor) as f32,
1463                                        y: (position.y / scale_factor) as f32,
1464                                    };
1465                                    let event = InputEvent::Pointer(PointerEvent::Move { point });
1466                                    if let Err(e) = runtime.handle_input(event, ir, layout) {
1467                                        eprintln!("Input handling error: {:?}", e);
1468                                    }
1469                                    if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1470                                        request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1471                                    }
1472                                    request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1473                                }
1474                            }
1475                            WindowEvent::MouseInput { state, button, .. } => {
1476                                if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
1477                                    if let Some(position) = last_cursor_position {
1478                                        let scale_factor = window.scale_factor();
1479                                        let point = LayoutPoint {
1480                                            x: (position.x / scale_factor) as f32,
1481                                            y: (position.y / scale_factor) as f32,
1482                                        };
1483                                        if let Some(btn) = map_mouse_button(button) {
1484                                            if let Some(event) = build_pointer_event(state, btn, point) {
1485                                                let trace_seq = if text_trace_enabled && state.is_pressed() {
1486                                                    start_text_trace(
1487                                                        text_trace_enabled,
1488                                                        &mut pending_text_traces,
1489                                                        &mut next_text_trace_seq,
1490                                                        "pointer_down".to_string(),
1491                                                        None,
1492                                                        presented_frames,
1493                                                    )
1494                                                } else {
1495                                                    None
1496                                                };
1497                                                // println!("Dispatching input: {:?} at {:?}", event, point);
1498                                                if let Err(e) = runtime.handle_input(event, ir, layout) {
1499                                                    eprintln!("Input handling error: {:?}", e);
1500                                                } else {
1501                                                    // println!("Input dispatched successfully");
1502                                                }
1503                                                mark_text_trace_handled(&mut pending_text_traces, trace_seq);
1504                                                if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1505                                                    mark_text_trace_effects(&mut pending_text_traces, trace_seq);
1506                                                    request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1507                                                }
1508                                                if state.is_pressed() {
1509                                                    let target = focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
1510                                                    if target.is_some() {
1511                                                        set_text_trace_target(&mut pending_text_traces, trace_seq, target);
1512                                                    } else {
1513                                                        cancel_text_trace(&mut pending_text_traces, trace_seq);
1514                                                    }
1515                                                    reset_text_input_caret(&mut runtime, pipeline.prev_ir.as_ref(), &mut last_blink_toggle);
1516                                                }
1517                                                request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1518                                            }
1519                                        }
1520                                    }
1521                                }
1522                            }
1523                            WindowEvent::MouseWheel { delta, .. } => {
1524                                if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
1525                                    if let Some(position) = last_cursor_position {
1526                                        let scale_factor = window.scale_factor();
1527                                        let point = LayoutPoint {
1528                                            x: (position.x / scale_factor) as f32,
1529                                            y: (position.y / scale_factor) as f32,
1530                                        };
1531
1532                                        let scroll_delta = match delta {
1533                                            MouseScrollDelta::LineDelta(x, y) => LayoutPoint { x: -x * 50.0, y: -y * 50.0 },
1534                                            MouseScrollDelta::PixelDelta(p) => LayoutPoint {
1535                                                x: -(p.x / scale_factor) as f32,
1536                                                y: -(p.y / scale_factor) as f32,
1537                                            },
1538                                        };
1539
1540                                        let event = InputEvent::Pointer(PointerEvent::Scroll { point, delta: scroll_delta });
1541                                        if std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1") {
1542                                            eprintln!(
1543                                                "[scroll-trace] mousewheel raw={:?} point=({:.1},{:.1}) delta=({:.1},{:.1})",
1544                                                delta,
1545                                                point.x,
1546                                                point.y,
1547                                                scroll_delta.x,
1548                                                scroll_delta.y
1549                                            );
1550                                        }
1551                                        if let Err(e) = runtime.handle_input(event, ir, layout) {
1552                                            eprintln!("Scroll error: {:?}", e);
1553                                        }
1554                                        if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1555                                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1556                                        }
1557                                        request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1558                                    }
1559                                }
1560                            }
1561                            WindowEvent::ModifiersChanged(modifiers) => {
1562                                current_mods = 0;
1563                                if modifiers.state().shift_key() { current_mods |= 1; }
1564                                if modifiers.state().alt_key() { current_mods |= 2; }
1565                                if modifiers.state().control_key() { current_mods |= 4; }
1566                                if modifiers.state().super_key() { current_mods |= 8; }
1567                            }
1568                            WindowEvent::KeyboardInput { event, .. } => {
1569                                if event.state.is_pressed() {
1570                                    use winit::keyboard::{Key, NamedKey};
1571                                    let key_code = match event.logical_key {
1572                                        Key::Named(NamedKey::Space) => Some(KeyCode::Space),
1573                                        Key::Named(NamedKey::Enter) => Some(KeyCode::Enter),
1574                                        Key::Named(NamedKey::Escape) => Some(KeyCode::Escape),
1575                                        Key::Named(NamedKey::Backspace) => Some(KeyCode::Backspace),
1576                                        Key::Named(NamedKey::Tab) => Some(KeyCode::Tab),
1577                                        Key::Named(NamedKey::ArrowLeft) => Some(KeyCode::Left),
1578                                        Key::Named(NamedKey::ArrowRight) => Some(KeyCode::Right),
1579                                        Key::Named(NamedKey::ArrowUp) => Some(KeyCode::Up),
1580                                        Key::Named(NamedKey::ArrowDown) => Some(KeyCode::Down),
1581                                        Key::Named(NamedKey::Home) => Some(KeyCode::Home),
1582                                        Key::Named(NamedKey::End) => Some(KeyCode::End),
1583                                        _ => {
1584                                            if let Some(text) = &event.text {
1585                                                text.chars().next().map(KeyCode::Char)
1586                                            } else {
1587                                                None
1588                                            }
1589                                        }
1590                                    };
1591
1592                                    if let (Some(code), Some(ir), Some(layout)) = (key_code, &pipeline.prev_ir, &pipeline.last_snapshot) {
1593                                        // App-level key handler intercepts before framework
1594                                        let mut key_handled_by_app = false;
1595                                        if let Some(handler) = &self.key_handler {
1596                                            let handler = handler.clone();
1597                                            if let Some(state) = runtime.get_app_state_mut::<S>() {
1598                                                if handler(state, &code, current_mods) {
1599                                                    if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1600                                                        request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1601                                                    }
1602                                                    request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1603                                                    key_handled_by_app = true;
1604                                                }
1605                                            }
1606                                        }
1607
1608                                        if key_handled_by_app {
1609                                            // Skip normal key handling
1610                                        } else {
1611
1612                                        let target = focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
1613                                        let trace_seq = start_text_trace(
1614                                            text_trace_enabled && target.is_some(),
1615                                            &mut pending_text_traces,
1616                                            &mut next_text_trace_seq,
1617                                            format!("keyboard:{:?}", code),
1618                                            target,
1619                                            presented_frames,
1620                                        );
1621                                        let input_event = InputEvent::Keyboard(FissionKeyEvent::Down {
1622                                            key_code: code,
1623                                            modifiers: current_mods,
1624                                        });
1625                                        if let Err(e) = runtime.handle_input(input_event, ir, layout) {
1626                                            eprintln!("Keyboard error: {:?}", e);
1627                                        }
1628                                        mark_text_trace_handled(&mut pending_text_traces, trace_seq);
1629                                        if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1630                                            mark_text_trace_effects(&mut pending_text_traces, trace_seq);
1631                                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1632                                        }
1633                                        reset_text_input_caret(&mut runtime, pipeline.prev_ir.as_ref(), &mut last_blink_toggle);
1634                                        request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1635                                    } // else (not handled by app key handler)
1636                                    }
1637                                }
1638                            }
1639                            WindowEvent::Ime(ime) => {
1640                                if let (Some(ir), Some(layout)) = (&pipeline.prev_ir, &pipeline.last_snapshot) {
1641                                    let (input_event, source) = match ime {
1642                                        Ime::Commit(text) => (
1643                                            Some(InputEvent::Ime(fission_core::event::ImeEvent::Commit { text: text.clone() })),
1644                                            Some(format!("ime_commit:{}", text.chars().count())),
1645                                        ),
1646                                        Ime::Preedit(text, _) => (
1647                                            Some(InputEvent::Ime(fission_core::event::ImeEvent::Preedit { text: text.clone() })),
1648                                            Some(format!("ime_preedit:{}", text.chars().count())),
1649                                        ),
1650                                        _ => (None, None),
1651                                    };
1652
1653                                    if let Some(e) = input_event {
1654                                        let target = focused_text_input_id(&runtime, pipeline.prev_ir.as_ref());
1655                                        let trace_seq = start_text_trace(
1656                                            text_trace_enabled && target.is_some(),
1657                                            &mut pending_text_traces,
1658                                            &mut next_text_trace_seq,
1659                                            source.unwrap_or_else(|| "ime".to_string()),
1660                                            target,
1661                                            presented_frames,
1662                                        );
1663                                        runtime.handle_input(e, ir, layout).ok();
1664                                        mark_text_trace_handled(&mut pending_text_traces, trace_seq);
1665                                        if process_pending_effects(&mut runtime, &effect_result_tx, app_effect_handler.as_ref()) {
1666                                            mark_text_trace_effects(&mut pending_text_traces, trace_seq);
1667                                            request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1668                                        }
1669                                        reset_text_input_caret(&mut runtime, pipeline.prev_ir.as_ref(), &mut last_blink_toggle);
1670                                        request_redraw_throttled(&window, elwt, &mut last_redraw_at, min_frame, &mut redraw_pending);
1671                                    }
1672                                }
1673                            }
1674                            _ => {}
1675                        }
1676                    }
1677                    _ => {}
1678                }
1679            })
1680            .map_err(|e| anyhow::anyhow!("Event loop error: {}", e))
1681    }
1682}
1683
1684fn build_font_context() -> FontContext {
1685    let use_system_fonts = std::env::var("FISSION_USE_SYSTEM_FONTS")
1686        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
1687        .unwrap_or(false);
1688    let options = CollectionOptions {
1689        shared: false,
1690        system_fonts: use_system_fonts,
1691    };
1692    FontContext {
1693        collection: Collection::new(options),
1694        source_cache: SourceCache::default(),
1695    }
1696}
1697
1698// Helpers...
1699fn map_mouse_button(button: MouseButton) -> Option<PointerButton> {
1700    match button {
1701        MouseButton::Left => Some(PointerButton::Primary),
1702        MouseButton::Right => Some(PointerButton::Secondary),
1703        MouseButton::Middle => Some(PointerButton::Middle),
1704        MouseButton::Other(id) => Some(PointerButton::Other(id as u8)),
1705        _ => None,
1706    }
1707}
1708
1709fn build_pointer_event(
1710    state: ElementState,
1711    button: PointerButton,
1712    point: LayoutPoint,
1713) -> Option<InputEvent> {
1714    let pointer_event = match state {
1715        ElementState::Pressed => PointerEvent::Down { point, button },
1716        ElementState::Released => PointerEvent::Up { point, button },
1717    };
1718
1719    Some(InputEvent::Pointer(pointer_event))
1720}
1721
1722fn gpu_screenshot(
1723    device: &wgpu::Device,
1724    queue: &wgpu::Queue,
1725    texture: &wgpu::Texture,
1726    width: u32,
1727    height: u32,
1728    path: &str,
1729) -> fission_test_driver::TestResponse {
1730    if width == 0 || height == 0 {
1731        return fission_test_driver::TestResponse::Error {
1732            message: "zero-size viewport".into(),
1733        };
1734    }
1735
1736    let bytes_per_pixel = 4u32;
1737    let unpadded_bytes_per_row = width * bytes_per_pixel;
1738    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
1739    let padded_bytes_per_row = (unpadded_bytes_per_row + align - 1) / align * align;
1740    let buffer_size = (padded_bytes_per_row * height) as u64;
1741
1742    let staging = device.create_buffer(&wgpu::BufferDescriptor {
1743        label: Some("screenshot staging"),
1744        size: buffer_size,
1745        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1746        mapped_at_creation: false,
1747    });
1748
1749    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1750        label: Some("screenshot copy"),
1751    });
1752
1753    encoder.copy_texture_to_buffer(
1754        wgpu::TexelCopyTextureInfo {
1755            texture,
1756            mip_level: 0,
1757            origin: wgpu::Origin3d::ZERO,
1758            aspect: wgpu::TextureAspect::All,
1759        },
1760        wgpu::TexelCopyBufferInfo {
1761            buffer: &staging,
1762            layout: wgpu::TexelCopyBufferLayout {
1763                offset: 0,
1764                bytes_per_row: Some(padded_bytes_per_row),
1765                rows_per_image: Some(height),
1766            },
1767        },
1768        wgpu::Extent3d {
1769            width,
1770            height,
1771            depth_or_array_layers: 1,
1772        },
1773    );
1774
1775    queue.submit(Some(encoder.finish()));
1776
1777    let (tx, rx) = std::sync::mpsc::channel();
1778    staging.slice(..).map_async(wgpu::MapMode::Read, move |result| {
1779        let _ = tx.send(result);
1780    });
1781    let _ = device.poll(wgpu::PollType::Wait);
1782
1783    match rx.recv() {
1784        Ok(Ok(())) => {}
1785        Ok(Err(e)) => {
1786            return fission_test_driver::TestResponse::Error {
1787                message: format!("buffer map failed: {:?}", e),
1788            };
1789        }
1790        Err(e) => {
1791            return fission_test_driver::TestResponse::Error {
1792                message: format!("buffer map channel error: {}", e),
1793            };
1794        }
1795    }
1796
1797    let data = staging.slice(..).get_mapped_range();
1798
1799    // Remove row padding (texture is Rgba8Unorm, no swizzle needed)
1800    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
1801    for row in 0..height {
1802        let start = (row * padded_bytes_per_row) as usize;
1803        let end = start + (width * bytes_per_pixel) as usize;
1804        rgba.extend_from_slice(&data[start..end]);
1805    }
1806
1807    drop(data);
1808    staging.unmap();
1809
1810    match image::save_buffer(path, &rgba, width, height, image::ColorType::Rgba8) {
1811        Ok(()) => fission_test_driver::TestResponse::Ok {},
1812        Err(e) => fission_test_driver::TestResponse::Error {
1813            message: format!("PNG save failed: {}", e),
1814        },
1815    }
1816}
1817
1818fn recreate_target_texture(
1819    surface: &mut RenderSurface,
1820    render_cx: &RenderContext,
1821) {
1822    let device = &render_cx.devices[surface.dev_id].device;
1823    let size = wgpu::Extent3d {
1824        width: surface.config.width.max(1),
1825        height: surface.config.height.max(1),
1826        depth_or_array_layers: 1,
1827    };
1828    let new_texture = device.create_texture(&wgpu::TextureDescriptor {
1829        label: Some("fission_target_with_copy"),
1830        size,
1831        mip_level_count: 1,
1832        sample_count: 1,
1833        dimension: wgpu::TextureDimension::D2,
1834        format: wgpu::TextureFormat::Rgba8Unorm, // Must match Vello's internal format
1835        usage: wgpu::TextureUsages::STORAGE_BINDING
1836            | wgpu::TextureUsages::TEXTURE_BINDING
1837            | wgpu::TextureUsages::COPY_SRC,
1838        view_formats: &[],
1839    });
1840    let new_view = new_texture.create_view(&wgpu::TextureViewDescriptor::default());
1841    surface.target_texture = new_texture;
1842    surface.target_view = new_view;
1843}