teksilo-webview 0.13.0

Embeddable WebView widget for Teksilo — pluggable native engine backend (wry / Servo) behind a Teksilo-native widget.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Backend abstraction for [`WebView`](crate::WebView).
//!
//! A web view is the one widget that cannot render into Teksilo's wgpu
//! surface — every realistic engine (WKWebView, WebView2, WebKitGTK, Servo)
//! owns its own rendering and lives as a native subview *on top of* the wgpu
//! pass. This module mirrors the established platform-backend pattern
//! (`FileDialogBackend` /
//! `ExternalDndBackend`): a swappable [`WebViewBackend`] trait creates an
//! engine-specific [`WebViewHandle`], and a per-app [`WebViewRegistry`]
//! (registered in app-state) owns the backend and routes JS→Rust /
//! browser-lifecycle events back into the originating widget tree.
//!
//! The default build ships only the [`MemoryWebViewBackend`] (headless,
//! deterministic). The native `wry` / `servo` backends live behind the
//! `wry-backend` / `servo-backend` features.

use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use teksilo_canvas::Rect;
use teksilo_core::AppEventPoster;
use teksilo_core::raw_handle::ParentHandle;
use teksilo_core::widget::EventContext;
use teksilo_core::window::TeksiloWindowId;

/// Process-unique identity for a single web view instance. Allocated once at
/// `WebView` construction and stable across rebuilds, so backend events route
/// to the correct widget. Same shape as `MenuItemId`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WebViewId(u64);

impl WebViewId {
    /// Allocate the next process-unique id.
    pub fn next() -> Self {
        static COUNTER: AtomicU64 = AtomicU64::new(1);
        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
    }

    /// The raw numeric value (diagnostics / map keys).
    pub fn raw(self) -> u64 {
        self.0
    }
}

/// What a web view should initially display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebSource {
    /// Navigate to a URL.
    Url(String),
    /// Load an inline HTML string, with an optional base URL for relative
    /// asset resolution.
    Html {
        html: String,
        base_url: Option<String>,
    },
}

/// Severity of a [`WebViewEvent::ConsoleMessage`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsoleLevel {
    Log,
    Warn,
    Error,
}

/// Engine configuration accumulated by the [`WebView`](crate::WebView)
/// builders and handed to [`WebViewBackend::open`].
#[derive(Debug, Clone, Default)]
pub struct WebViewAttributes {
    /// Initial content. `None` means "blank page".
    pub source: Option<WebSource>,
    /// Override the engine's `User-Agent`.
    pub user_agent: Option<String>,
    /// Transparent engine background (compose over Teksilo content).
    pub transparent: bool,
    /// Enable the engine's devtools (debug builds only by convention).
    pub devtools: bool,
    /// Custom-protocol scheme names the app wants to serve (`app` → `app://`).
    /// The dispatch closures live app-side; the backend only needs the names
    /// at open time to register the schemes.
    pub custom_protocols: Vec<String>,
}

/// A live native engine subview. Dropping the handle tears the subview down
/// (RAII, same contract as `ExternalDndGuard`).
///
/// All methods are `&self` — the handle is cheaply shareable and the engine
/// state lives behind the platform's own interior mutability.
pub trait WebViewHandle: 'static {
    /// Reposition / resize the native subview within its parent window.
    /// `bounds` is in **logical** pixels (Teksilo's coordinate system);
    /// `scale_factor` is the host window's HiDPI scale. Most engines position
    /// in logical units, but some need device pixels (`bounds × scale_factor`)
    /// because their own toolkit runs at a different scale than the wgpu
    /// surface — notably WebKitGTK on X11/XWayland, which uses integer GDK
    /// scaling and ignores fractional factors. Issued whenever the widget's
    /// layout bounds or the window scale change.
    fn set_bounds(&self, bounds: Rect, scale_factor: f32);
    /// Navigate to a URL.
    fn load_url(&self, url: &str);
    /// Load inline HTML.
    fn load_html(&self, html: &str, base_url: Option<&str>);
    /// Evaluate JavaScript in the page.
    fn eval(&self, script: &str);
    /// Rust → JS: dispatch a `teksilo-message` `MessageEvent` carrying `msg`.
    fn post_message(&self, msg: &str);
    /// Reload the current page.
    fn reload(&self);
    /// Navigate back in history.
    fn go_back(&self);
    /// Navigate forward in history.
    fn go_forward(&self);
    /// Stop the current load.
    fn stop(&self);
    /// Show / hide the native subview. **Load-bearing**: a native subview
    /// lives outside the wgpu pass, so framework dormancy (a `Switcher`
    /// parking the page) does NOT hide it — the `WebView` widget bridges
    /// its activation signal to this call. See `WebView`'s rustdoc.
    fn set_visible(&self, visible: bool);
    /// Give the engine subview keyboard focus.
    fn set_focus(&self);
    /// Ask the engine to stop taking pointer input over its own rectangle, so
    /// the OS delivers those events to the host window and Teksilo routes them
    /// — the engine half of [`WebViewInput::Transparent`].
    ///
    /// Returns whether the engine honoured it. **Not every engine can**: the
    /// call needs control over the native surface's hit region, which the
    /// embedding API may simply not expose, and a backend that cannot do it
    /// must answer `false` rather than pretend. The widget reports a declined
    /// pass-through as a [`WebViewEvent::ConsoleMessage`] so the mode never
    /// fails silently.
    ///
    /// **Not every engine can do this**, and one that cannot must say so
    /// through [`WebViewEvent::ConsoleMessage`] — the channel this crate
    /// already reserves for reporting an unsupported operation — rather than
    /// accept the call and change nothing. There is deliberately no return
    /// value and no default implementation: an answer invented here would be
    /// an answer for an engine nobody asked.
    ///
    /// [`WebViewInput::Transparent`]: crate::WebViewInput::Transparent
    fn set_input_passthrough(&self, passthrough: bool);
    /// Open the engine's developer tools (no-op on backends that don't
    /// expose them — Servo's embedding API has no clean devtools hook today).
    fn open_devtools(&self) {}
    /// Close the engine's developer tools (no-op where unsupported).
    fn close_devtools(&self) {}
}

/// A browser lifecycle / JS→Rust event surfaced by a backend.
#[derive(Debug, Clone)]
pub enum WebViewEvent {
    /// A navigation is starting. `can_cancel` is true on backends that
    /// support pre-navigation veto.
    NavigationStarted { url: String, can_cancel: bool },
    /// A navigation finished (or failed).
    NavigationFinished { url: String, success: bool },
    /// The page began loading resources.
    PageLoadStarted,
    /// The page finished loading.
    PageLoadFinished,
    /// The document title changed.
    TitleChanged(String),
    /// `window.ipc.postMessage(payload)` fired in the page.
    Message(String),
    /// A download began.
    DownloadStarted {
        url: String,
        suggested_path: PathBuf,
    },
    /// A download finished (or failed).
    DownloadFinished { path: PathBuf, success: bool },
    /// A console message (forwarded in debug builds / by best-effort
    /// backends to report unsupported operations).
    ConsoleMessage { level: ConsoleLevel, text: String },
    /// The engine's own keyboard focus changed: `true` when the page took the
    /// keyboard, `false` when it gave it up.
    ///
    /// A web view has two disjoint focus rings — the toolkit's and the
    /// engine's platform tree — and the engine's is the one Teksilo cannot
    /// see. Without this event a tap inside the page moves the OS focus while
    /// Teksilo goes on believing a text field elsewhere still owns it, caret
    /// blinking. The `WebView` widget follows the event with
    /// `EventContext::request_focus` on its own frame, so the toolkit's focus
    /// agrees with the OS.
    EngineFocusChanged(bool),
}

/// Boxed inside `AppEvent::External` when a backend produces an event.
/// `teksilo-app`'s app-event handler downcasts to this type and routes to
/// [`WebViewRegistry::deliver`]. Mirrors `FileDialogEventPayload`.
pub struct WebViewEventPayload {
    /// The window the web view lives in — routes delivery to the right tree.
    pub window_id_owner: TeksiloWindowId,
    /// Which web view the event belongs to.
    pub web_view_id: WebViewId,
    /// The event itself.
    pub event: WebViewEvent,
}

/// Post a [`WebViewEvent`] back to the UI loop, if a poster is available.
/// Shared by every engine backend so the emit path lives in one place.
#[allow(dead_code)] // used only by the feature-gated engine backends
pub(crate) fn post_event(
    poster: &Option<Arc<dyn AppEventPoster>>,
    window_id: TeksiloWindowId,
    web_view_id: WebViewId,
    event: WebViewEvent,
) {
    if let Some(poster) = poster {
        let payload = WebViewEventPayload {
            window_id_owner: window_id,
            web_view_id,
            event,
        };
        poster.post_external(Box::new(payload) as Box<dyn std::any::Any + Send>);
    }
}

/// Encode `s` as a JavaScript string literal (double-quoted, fully escaped) so
/// it can be safely interpolated into an `evaluate_script` body. Lives in the
/// shared backend module (not in one engine's file) so every JS-executing
/// backend uses the same audited escaper — an incomplete escape is a JS
/// injection / silent-SyntaxError hazard.
///
/// Escapes the JS-significant characters: `"`, `\`, the C0 controls (incl.
/// `\n` / `\r` / `\t`), and U+2028 / U+2029 (LINE / PARAGRAPH SEPARATOR — these
/// are line terminators *inside* JS string literals pre-ES2019 and silently
/// break the literal otherwise).
#[allow(dead_code)] // used only by the feature-gated engine backends
pub(crate) fn js_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{2028}' => out.push_str("\\u2028"),
            '\u{2029}' => out.push_str("\\u2029"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Swappable web-view engine backend.
///
/// The real backends (`WryBackend` / `ServoBackend`, behind their features)
/// create a native engine subview parented to the host window. The test
/// backend ([`MemoryWebViewBackend`]) records calls and synthesizes events.
pub trait WebViewBackend {
    /// Create a native engine subview for `web_view_id`, parented to
    /// `window_id`'s OS window. The backend MUST deliver browser events by
    /// calling [`AppEventPoster::post_external`] on `poster` with a boxed
    /// [`WebViewEventPayload`] whose `web_view_id` / `window_id_owner` match.
    ///
    /// `parent` is `None` when the host context can't surface an OS handle
    /// (headless tests, or a build-time open before the window-ops sink is
    /// available); native backends treat `None` as "defer until a handle
    /// arrives" rather than failing hard.
    fn open(
        &mut self,
        web_view_id: WebViewId,
        window_id: TeksiloWindowId,
        parent: Option<ParentHandle>,
        attrs: WebViewAttributes,
        poster: Option<Arc<dyn AppEventPoster>>,
    ) -> Box<dyn WebViewHandle>;
}

// ============================================================
// WebViewRegistry — per-app service (app-state)
// ============================================================

/// Callback the `WebView` widget installs to receive its own backend events.
type EventCallback = Box<dyn FnMut(WebViewEvent, &mut EventContext)>;

struct Registered {
    window_id: TeksiloWindowId,
    callback: EventCallback,
}

struct RegistryState {
    backend: RefCell<Box<dyn WebViewBackend>>,
    callbacks: RefCell<HashMap<WebViewId, Registered>>,
    /// Bumped per `open`, purely for diagnostics.
    open_count: Cell<u64>,
    /// The `(web_view_id, window)` of the delivery currently in flight, if any
    /// (`deliver` removes the callback, runs it, then reinserts). If a
    /// re-entrant `unregister`/`purge_window` hits this id/window while the
    /// callback runs, `delivery_aborted` is set and `deliver` skips the
    /// reinsert — so a since-purged callback is never resurrected even if
    /// widget teardown becomes synchronous. `deliver` is not itself re-entrant
    /// (backend events are posted, not delivered inline), so a single slot
    /// suffices.
    delivering: Cell<Option<(WebViewId, TeksiloWindowId)>>,
    delivery_aborted: Cell<bool>,
}

/// Per-app web-view service. Registered in app-state by
/// `TeksiloAppBuilderWebViewExt::install_web_view` (in the `teksilo` umbrella
/// crate); reachable from any `build()` / handler via
/// `ctx.app_state::<WebViewRegistry>()`. Cloneable; clones share the same
/// backend and event-callback map.
#[derive(Clone)]
pub struct WebViewRegistry {
    inner: Rc<RegistryState>,
}

impl WebViewRegistry {
    /// Build a registry wrapping `backend`.
    pub fn new<B: WebViewBackend + 'static>(backend: B) -> Self {
        Self {
            inner: Rc::new(RegistryState {
                backend: RefCell::new(Box::new(backend)),
                callbacks: RefCell::new(HashMap::new()),
                open_count: Cell::new(0),
                delivering: Cell::new(None),
                delivery_aborted: Cell::new(false),
            }),
        }
    }

    /// Open a native subview and register the widget's event callback in one
    /// step. Returns the live [`WebViewHandle`] (dropped on widget removal).
    pub fn open(
        &self,
        web_view_id: WebViewId,
        window_id: TeksiloWindowId,
        parent: Option<ParentHandle>,
        attrs: WebViewAttributes,
        poster: Option<Arc<dyn AppEventPoster>>,
        on_event: impl FnMut(WebViewEvent, &mut EventContext) + 'static,
    ) -> Box<dyn WebViewHandle> {
        self.inner
            .open_count
            .set(self.inner.open_count.get().wrapping_add(1));
        self.inner.callbacks.borrow_mut().insert(
            web_view_id,
            Registered {
                window_id,
                callback: Box::new(on_event),
            },
        );
        self.inner
            .backend
            .borrow_mut()
            .open(web_view_id, window_id, parent, attrs, poster)
    }

    /// Route a backend-produced payload to its registered widget callback.
    /// Called by `teksilo-app` from the `AppEvent::External` arm. Dropped
    /// silently if the callback was already purged (window/widget gone).
    pub fn deliver(&self, payload: WebViewEventPayload, ctx: &mut EventContext) {
        // Take the callback out so the map borrow isn't held while the
        // (re-entrant-capable) callback runs — it may itself open another web
        // view, which inserts. Then put it back via `or_insert`, so a *newer*
        // registration created during the callback wins and is not clobbered.
        //
        // The `delivering` slot guards the one remaining hazard: if the
        // callback synchronously tears the widget/window down (a re-entrant
        // `unregister` / `purge_window` for this id/window), we must NOT
        // resurrect the dead callback. That marks `delivery_aborted`, and we
        // skip the reinsert below. (Today teardown is deferred so this never
        // fires, but the guard makes the invariant hold unconditionally.)
        let entry = self
            .inner
            .callbacks
            .borrow_mut()
            .remove(&payload.web_view_id);
        let Some(mut reg) = entry else {
            return;
        };
        if reg.window_id != payload.window_id_owner {
            // Stale routing — drop, don't reinsert.
            return;
        }
        self.inner
            .delivering
            .set(Some((payload.web_view_id, reg.window_id)));
        self.inner.delivery_aborted.set(false);

        (reg.callback)(payload.event, ctx);

        self.inner.delivering.set(None);
        if !self.inner.delivery_aborted.get() {
            self.inner
                .callbacks
                .borrow_mut()
                .entry(payload.web_view_id)
                .or_insert(reg);
        }
    }

    /// Drop the registration for a single web view (widget removed).
    pub fn unregister(&self, web_view_id: WebViewId) {
        self.inner.callbacks.borrow_mut().remove(&web_view_id);
        if matches!(self.inner.delivering.get(), Some((id, _)) if id == web_view_id) {
            self.inner.delivery_aborted.set(true);
        }
    }

    /// Drop every registration owned by `window_id`. Called by
    /// `teksilo-app`'s window-close path so callbacks capturing widget state
    /// cannot fire into a torn-down tree. Mirrors
    /// `FileDialogHandle::purge_window`.
    pub fn purge_window(&self, window_id: TeksiloWindowId) {
        self.inner
            .callbacks
            .borrow_mut()
            .retain(|_, r| r.window_id != window_id);
        if matches!(self.inner.delivering.get(), Some((_, win)) if win == window_id) {
            self.inner.delivery_aborted.set(true);
        }
    }

    /// Number of registered web views. Test helper.
    pub fn registered_count(&self) -> usize {
        self.inner.callbacks.borrow().len()
    }
}

impl std::fmt::Debug for WebViewRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WebViewRegistry")
            .field("registered", &self.inner.callbacks.borrow().len())
            .field("opens", &self.inner.open_count.get())
            .finish_non_exhaustive()
    }
}

// ============================================================
// MemoryWebViewBackend (headless test backend)
// ============================================================

/// One recorded backend operation. Lets tests assert the exact call sequence
/// (open → set_bounds → set_visible(false) → set_visible(true) → …) without a
/// real engine, window, or GPU.
#[derive(Debug, Clone, PartialEq)]
pub enum WebViewOp {
    Open {
        web_view_id: WebViewId,
    },
    SetBounds {
        web_view_id: WebViewId,
        bounds: Rect,
    },
    LoadUrl {
        web_view_id: WebViewId,
        url: String,
    },
    LoadHtml {
        web_view_id: WebViewId,
    },
    Eval {
        web_view_id: WebViewId,
        script: String,
    },
    PostMessage {
        web_view_id: WebViewId,
        msg: String,
    },
    Reload {
        web_view_id: WebViewId,
    },
    GoBack {
        web_view_id: WebViewId,
    },
    GoForward {
        web_view_id: WebViewId,
    },
    Stop {
        web_view_id: WebViewId,
    },
    SetVisible {
        web_view_id: WebViewId,
        visible: bool,
    },
    SetFocus {
        web_view_id: WebViewId,
    },
    SetInputPassthrough {
        web_view_id: WebViewId,
        passthrough: bool,
    },
    OpenDevtools {
        web_view_id: WebViewId,
    },
    CloseDevtools {
        web_view_id: WebViewId,
    },
    Dropped {
        web_view_id: WebViewId,
    },
}

/// Shared, cloneable recorder. Both the backend and the test hold a clone, so
/// the test can read the op log after driving the tree.
#[derive(Clone, Default)]
pub struct MemoryWebViewRecords {
    ops: Rc<RefCell<Vec<WebViewOp>>>,
}

impl MemoryWebViewRecords {
    /// All recorded ops, in order.
    pub fn ops(&self) -> Vec<WebViewOp> {
        self.ops.borrow().clone()
    }

    /// Every op for a given web view.
    pub fn ops_for(&self, id: WebViewId) -> Vec<WebViewOp> {
        self.ops
            .borrow()
            .iter()
            .filter(|op| op_web_view_id(op) == id)
            .cloned()
            .collect()
    }

    /// The ordered `set_visible` booleans for a web view — the headline
    /// dormancy assertion (`[false, true]` across a tab-away / tab-back).
    pub fn visibility_log(&self, id: WebViewId) -> Vec<bool> {
        self.ops
            .borrow()
            .iter()
            .filter_map(|op| match op {
                WebViewOp::SetVisible {
                    web_view_id,
                    visible,
                } if *web_view_id == id => Some(*visible),
                _ => None,
            })
            .collect()
    }

    fn push(&self, op: WebViewOp) {
        self.ops.borrow_mut().push(op);
    }
}

fn op_web_view_id(op: &WebViewOp) -> WebViewId {
    match op {
        WebViewOp::Open { web_view_id }
        | WebViewOp::SetBounds { web_view_id, .. }
        | WebViewOp::LoadUrl { web_view_id, .. }
        | WebViewOp::LoadHtml { web_view_id }
        | WebViewOp::Eval { web_view_id, .. }
        | WebViewOp::PostMessage { web_view_id, .. }
        | WebViewOp::Reload { web_view_id }
        | WebViewOp::GoBack { web_view_id }
        | WebViewOp::GoForward { web_view_id }
        | WebViewOp::Stop { web_view_id }
        | WebViewOp::SetVisible { web_view_id, .. }
        | WebViewOp::SetFocus { web_view_id }
        | WebViewOp::SetInputPassthrough { web_view_id, .. }
        | WebViewOp::OpenDevtools { web_view_id }
        | WebViewOp::CloseDevtools { web_view_id }
        | WebViewOp::Dropped { web_view_id } => *web_view_id,
    }
}

/// In-memory deterministic backend for headless tests. Records every op into a
/// shared [`MemoryWebViewRecords`]; never renders. Mirrors `MemoryFileDialog`.
pub struct MemoryWebViewBackend {
    records: MemoryWebViewRecords,
}

impl MemoryWebViewBackend {
    /// Build a backend plus its shared recorder; clone the returned records
    /// before moving the backend into a [`WebViewRegistry`].
    pub fn new() -> (Self, MemoryWebViewRecords) {
        let records = MemoryWebViewRecords::default();
        (
            Self {
                records: records.clone(),
            },
            records,
        )
    }
}

struct MemoryWebViewHandle {
    web_view_id: WebViewId,
    records: MemoryWebViewRecords,
}

impl WebViewHandle for MemoryWebViewHandle {
    fn set_bounds(&self, bounds: Rect, _scale_factor: f32) {
        // Record logical bounds (scale-independent) so test assertions stay
        // resolution-agnostic.
        self.records.push(WebViewOp::SetBounds {
            web_view_id: self.web_view_id,
            bounds,
        });
    }
    fn load_url(&self, url: &str) {
        self.records.push(WebViewOp::LoadUrl {
            web_view_id: self.web_view_id,
            url: url.to_string(),
        });
    }
    fn load_html(&self, _html: &str, _base_url: Option<&str>) {
        self.records.push(WebViewOp::LoadHtml {
            web_view_id: self.web_view_id,
        });
    }
    fn eval(&self, script: &str) {
        self.records.push(WebViewOp::Eval {
            web_view_id: self.web_view_id,
            script: script.to_string(),
        });
    }
    fn post_message(&self, msg: &str) {
        self.records.push(WebViewOp::PostMessage {
            web_view_id: self.web_view_id,
            msg: msg.to_string(),
        });
    }
    fn reload(&self) {
        self.records.push(WebViewOp::Reload {
            web_view_id: self.web_view_id,
        });
    }
    fn go_back(&self) {
        self.records.push(WebViewOp::GoBack {
            web_view_id: self.web_view_id,
        });
    }
    fn go_forward(&self) {
        self.records.push(WebViewOp::GoForward {
            web_view_id: self.web_view_id,
        });
    }
    fn stop(&self) {
        self.records.push(WebViewOp::Stop {
            web_view_id: self.web_view_id,
        });
    }
    fn set_visible(&self, visible: bool) {
        self.records.push(WebViewOp::SetVisible {
            web_view_id: self.web_view_id,
            visible,
        });
    }
    fn set_focus(&self) {
        self.records.push(WebViewOp::SetFocus {
            web_view_id: self.web_view_id,
        });
    }
    fn set_input_passthrough(&self, passthrough: bool) {
        self.records.push(WebViewOp::SetInputPassthrough {
            web_view_id: self.web_view_id,
            passthrough,
        });
    }
    fn open_devtools(&self) {
        self.records.push(WebViewOp::OpenDevtools {
            web_view_id: self.web_view_id,
        });
    }
    fn close_devtools(&self) {
        self.records.push(WebViewOp::CloseDevtools {
            web_view_id: self.web_view_id,
        });
    }
}

impl Drop for MemoryWebViewHandle {
    fn drop(&mut self) {
        self.records.push(WebViewOp::Dropped {
            web_view_id: self.web_view_id,
        });
    }
}

impl WebViewBackend for MemoryWebViewBackend {
    fn open(
        &mut self,
        web_view_id: WebViewId,
        _window_id: TeksiloWindowId,
        _parent: Option<ParentHandle>,
        attrs: WebViewAttributes,
        _poster: Option<Arc<dyn AppEventPoster>>,
    ) -> Box<dyn WebViewHandle> {
        self.records.push(WebViewOp::Open { web_view_id });
        // Replay the initial source as the corresponding load op so tests can
        // see what the widget asked to display.
        match attrs.source {
            Some(WebSource::Url(url)) => self.records.push(WebViewOp::LoadUrl { web_view_id, url }),
            Some(WebSource::Html { .. }) => self.records.push(WebViewOp::LoadHtml { web_view_id }),
            None => {}
        }
        Box::new(MemoryWebViewHandle {
            web_view_id,
            records: self.records.clone(),
        })
    }
}

/// Convenience: a registry backed by a fresh [`MemoryWebViewBackend`], plus
/// its shared recorder. The one-liner headless-test setup.
pub fn memory_registry() -> (WebViewRegistry, MemoryWebViewRecords) {
    let (backend, records) = MemoryWebViewBackend::new();
    (WebViewRegistry::new(backend), records)
}

/// A backend that renders nothing and records nothing — every call is a no-op.
///
/// Unlike [`MemoryWebViewBackend`] (which accumulates an unbounded op log for
/// test assertions), this is safe to install in a long-running app as the
/// placeholder default until a native engine backend is wired. Used by
/// `install_web_view_default`.
#[derive(Debug, Default)]
pub struct NoopWebViewBackend;

/// A [`WebViewHandle`] whose every method is a no-op. Returned by
/// [`NoopWebViewBackend`], and by the `WryBackend` / `ServoBackend` engine
/// backends on their failure paths (no parent handle, engine-init error) so a
/// failed open still yields a live, harmless handle. Defined once so a method
/// added to the trait is implemented in exactly one place. `pub(crate)` —
/// backends return it boxed; apps never name it.
pub(crate) struct NoopWebViewHandle;

impl WebViewHandle for NoopWebViewHandle {
    fn set_bounds(&self, _bounds: Rect, _scale_factor: f32) {}
    fn load_url(&self, _url: &str) {}
    fn load_html(&self, _html: &str, _base_url: Option<&str>) {}
    fn eval(&self, _script: &str) {}
    fn post_message(&self, _msg: &str) {}
    fn reload(&self) {}
    fn go_back(&self) {}
    fn go_forward(&self) {}
    fn stop(&self) {}
    fn set_visible(&self, _visible: bool) {}
    fn set_focus(&self) {}
    fn set_input_passthrough(&self, _passthrough: bool) {
        // No surface, so nothing to make transparent.
    }
}

impl WebViewBackend for NoopWebViewBackend {
    fn open(
        &mut self,
        _web_view_id: WebViewId,
        _window_id: TeksiloWindowId,
        _parent: Option<ParentHandle>,
        _attrs: WebViewAttributes,
        _poster: Option<Arc<dyn AppEventPoster>>,
    ) -> Box<dyn WebViewHandle> {
        Box::new(NoopWebViewHandle)
    }
}