Skip to main content

teksilo_webview/
backend.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Backend abstraction for [`WebView`](crate::WebView).
5//!
6//! A web view is the one widget that cannot render into Teksilo's wgpu
7//! surface — every realistic engine (WKWebView, WebView2, WebKitGTK, Servo)
8//! owns its own rendering and lives as a native subview *on top of* the wgpu
9//! pass. This module mirrors the established platform-backend pattern
10//! (`FileDialogBackend` /
11//! `ExternalDndBackend`): a swappable [`WebViewBackend`] trait creates an
12//! engine-specific [`WebViewHandle`], and a per-app [`WebViewRegistry`]
13//! (registered in app-state) owns the backend and routes JS→Rust /
14//! browser-lifecycle events back into the originating widget tree.
15//!
16//! The default build ships only the [`MemoryWebViewBackend`] (headless,
17//! deterministic). The native `wry` / `servo` backends live behind the
18//! `wry-backend` / `servo-backend` features.
19
20use std::cell::{Cell, RefCell};
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::rc::Rc;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use teksilo_canvas::Rect;
28use teksilo_core::AppEventPoster;
29use teksilo_core::raw_handle::ParentHandle;
30use teksilo_core::widget::EventContext;
31use teksilo_core::window::TeksiloWindowId;
32
33/// Process-unique identity for a single web view instance. Allocated once at
34/// `WebView` construction and stable across rebuilds, so backend events route
35/// to the correct widget. Same shape as `MenuItemId`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct WebViewId(u64);
38
39impl WebViewId {
40    /// Allocate the next process-unique id.
41    pub fn next() -> Self {
42        static COUNTER: AtomicU64 = AtomicU64::new(1);
43        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
44    }
45
46    /// The raw numeric value (diagnostics / map keys).
47    pub fn raw(self) -> u64 {
48        self.0
49    }
50}
51
52/// What a web view should initially display.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum WebSource {
55    /// Navigate to a URL.
56    Url(String),
57    /// Load an inline HTML string, with an optional base URL for relative
58    /// asset resolution.
59    Html {
60        html: String,
61        base_url: Option<String>,
62    },
63}
64
65/// Severity of a [`WebViewEvent::ConsoleMessage`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ConsoleLevel {
68    Log,
69    Warn,
70    Error,
71}
72
73/// Engine configuration accumulated by the [`WebView`](crate::WebView)
74/// builders and handed to [`WebViewBackend::open`].
75#[derive(Debug, Clone, Default)]
76pub struct WebViewAttributes {
77    /// Initial content. `None` means "blank page".
78    pub source: Option<WebSource>,
79    /// Override the engine's `User-Agent`.
80    pub user_agent: Option<String>,
81    /// Transparent engine background (compose over Teksilo content).
82    pub transparent: bool,
83    /// Enable the engine's devtools (debug builds only by convention).
84    pub devtools: bool,
85    /// Custom-protocol scheme names the app wants to serve (`app` → `app://`).
86    /// The dispatch closures live app-side; the backend only needs the names
87    /// at open time to register the schemes.
88    pub custom_protocols: Vec<String>,
89}
90
91/// A live native engine subview. Dropping the handle tears the subview down
92/// (RAII, same contract as `ExternalDndGuard`).
93///
94/// All methods are `&self` — the handle is cheaply shareable and the engine
95/// state lives behind the platform's own interior mutability.
96pub trait WebViewHandle: 'static {
97    /// Reposition / resize the native subview within its parent window.
98    /// `bounds` is in **logical** pixels (Teksilo's coordinate system);
99    /// `scale_factor` is the host window's HiDPI scale. Most engines position
100    /// in logical units, but some need device pixels (`bounds × scale_factor`)
101    /// because their own toolkit runs at a different scale than the wgpu
102    /// surface — notably WebKitGTK on X11/XWayland, which uses integer GDK
103    /// scaling and ignores fractional factors. Issued whenever the widget's
104    /// layout bounds or the window scale change.
105    fn set_bounds(&self, bounds: Rect, scale_factor: f32);
106    /// Navigate to a URL.
107    fn load_url(&self, url: &str);
108    /// Load inline HTML.
109    fn load_html(&self, html: &str, base_url: Option<&str>);
110    /// Evaluate JavaScript in the page.
111    fn eval(&self, script: &str);
112    /// Rust → JS: dispatch a `teksilo-message` `MessageEvent` carrying `msg`.
113    fn post_message(&self, msg: &str);
114    /// Reload the current page.
115    fn reload(&self);
116    /// Navigate back in history.
117    fn go_back(&self);
118    /// Navigate forward in history.
119    fn go_forward(&self);
120    /// Stop the current load.
121    fn stop(&self);
122    /// Show / hide the native subview. **Load-bearing**: a native subview
123    /// lives outside the wgpu pass, so framework dormancy (a `Switcher`
124    /// parking the page) does NOT hide it — the `WebView` widget bridges
125    /// its activation signal to this call. See `WebView`'s rustdoc.
126    fn set_visible(&self, visible: bool);
127    /// Give the engine subview keyboard focus.
128    fn set_focus(&self);
129    /// Ask the engine to stop taking pointer input over its own rectangle, so
130    /// the OS delivers those events to the host window and Teksilo routes them
131    /// — the engine half of [`WebViewInput::Transparent`].
132    ///
133    /// Ask the engine to stop taking pointer input over its own rectangle, so
134    /// the OS delivers those events to the host window and Teksilo routes them
135    /// — the engine half of [`WebViewInput::Transparent`].
136    ///
137    /// **Not every engine can do this** — the call needs control over the
138    /// native surface's hit region, which the embedding API may simply not
139    /// expose — and one that cannot must say so through
140    /// [`WebViewEvent::ConsoleMessage`] — the channel this crate
141    /// already reserves for reporting an unsupported operation — rather than
142    /// accept the call and change nothing. There is deliberately no return
143    /// value and no default implementation: an answer invented here would be
144    /// an answer for an engine nobody asked.
145    ///
146    /// [`WebViewInput::Transparent`]: crate::WebViewInput::Transparent
147    ///
148    /// **Not every engine can do this**, and one that cannot must say so
149    /// through [`WebViewEvent::ConsoleMessage`] — the channel this crate
150    /// already reserves for reporting an unsupported operation — rather than
151    /// accept the call and change nothing. There is deliberately no return
152    /// value and no default implementation: an answer invented here would be
153    /// an answer for an engine nobody asked.
154    ///
155    /// [`WebViewInput::Transparent`]: crate::WebViewInput::Transparent
156    fn set_input_passthrough(&self, passthrough: bool);
157    /// Open the engine's developer tools (no-op on backends that don't
158    /// expose them — Servo's embedding API has no clean devtools hook today).
159    fn open_devtools(&self) {}
160    /// Close the engine's developer tools (no-op where unsupported).
161    fn close_devtools(&self) {}
162}
163
164/// A browser lifecycle / JS→Rust event surfaced by a backend.
165#[derive(Debug, Clone)]
166pub enum WebViewEvent {
167    /// A navigation is starting. `can_cancel` is true on backends that
168    /// support pre-navigation veto.
169    NavigationStarted { url: String, can_cancel: bool },
170    /// A navigation finished (or failed).
171    NavigationFinished { url: String, success: bool },
172    /// The page began loading resources.
173    PageLoadStarted,
174    /// The page finished loading.
175    PageLoadFinished,
176    /// The document title changed.
177    TitleChanged(String),
178    /// `window.ipc.postMessage(payload)` fired in the page.
179    Message(String),
180    /// A download began.
181    DownloadStarted {
182        url: String,
183        suggested_path: PathBuf,
184    },
185    /// A download finished (or failed).
186    DownloadFinished { path: PathBuf, success: bool },
187    /// A console message (forwarded in debug builds / by best-effort
188    /// backends to report unsupported operations).
189    ConsoleMessage { level: ConsoleLevel, text: String },
190    /// The engine's own keyboard focus changed: `true` when the page took the
191    /// keyboard, `false` when it gave it up.
192    ///
193    /// A web view has two disjoint focus rings — the toolkit's and the
194    /// engine's platform tree — and the engine's is the one Teksilo cannot
195    /// see. Without this event a tap inside the page moves the OS focus while
196    /// Teksilo goes on believing a text field elsewhere still owns it, caret
197    /// blinking. The `WebView` widget follows the event with
198    /// `EventContext::request_focus` on its own frame, so the toolkit's focus
199    /// agrees with the OS.
200    EngineFocusChanged(bool),
201}
202
203/// Boxed inside `AppEvent::External` when a backend produces an event.
204/// `teksilo-app`'s app-event handler downcasts to this type and routes to
205/// [`WebViewRegistry::deliver`]. Mirrors `FileDialogEventPayload`.
206pub struct WebViewEventPayload {
207    /// The window the web view lives in — routes delivery to the right tree.
208    pub window_id_owner: TeksiloWindowId,
209    /// Which web view the event belongs to.
210    pub web_view_id: WebViewId,
211    /// The event itself.
212    pub event: WebViewEvent,
213}
214
215/// Post a [`WebViewEvent`] back to the UI loop, if a poster is available.
216/// Shared by every engine backend so the emit path lives in one place.
217#[allow(dead_code)] // used only by the feature-gated engine backends
218pub(crate) fn post_event(
219    poster: &Option<Arc<dyn AppEventPoster>>,
220    window_id: TeksiloWindowId,
221    web_view_id: WebViewId,
222    event: WebViewEvent,
223) {
224    if let Some(poster) = poster {
225        let payload = WebViewEventPayload {
226            window_id_owner: window_id,
227            web_view_id,
228            event,
229        };
230        poster.post_external(Box::new(payload) as Box<dyn std::any::Any + Send>);
231    }
232}
233
234/// Encode `s` as a JavaScript string literal (double-quoted, fully escaped) so
235/// it can be safely interpolated into an `evaluate_script` body. Lives in the
236/// shared backend module (not in one engine's file) so every JS-executing
237/// backend uses the same audited escaper — an incomplete escape is a JS
238/// injection / silent-SyntaxError hazard.
239///
240/// Escapes the JS-significant characters: `"`, `\`, the C0 controls (incl.
241/// `\n` / `\r` / `\t`), and U+2028 / U+2029 (LINE / PARAGRAPH SEPARATOR — these
242/// are line terminators *inside* JS string literals pre-ES2019 and silently
243/// break the literal otherwise).
244#[allow(dead_code)] // used only by the feature-gated engine backends
245pub(crate) fn js_string(s: &str) -> String {
246    let mut out = String::with_capacity(s.len() + 2);
247    out.push('"');
248    for c in s.chars() {
249        match c {
250            '"' => out.push_str("\\\""),
251            '\\' => out.push_str("\\\\"),
252            '\n' => out.push_str("\\n"),
253            '\r' => out.push_str("\\r"),
254            '\t' => out.push_str("\\t"),
255            '\u{2028}' => out.push_str("\\u2028"),
256            '\u{2029}' => out.push_str("\\u2029"),
257            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
258            c => out.push(c),
259        }
260    }
261    out.push('"');
262    out
263}
264
265/// Swappable web-view engine backend.
266///
267/// The real backends (`WryBackend` / `ServoBackend`, behind their features)
268/// create a native engine subview parented to the host window. The test
269/// backend ([`MemoryWebViewBackend`]) records calls and synthesizes events.
270pub trait WebViewBackend {
271    /// Create a native engine subview for `web_view_id`, parented to
272    /// `window_id`'s OS window. The backend MUST deliver browser events by
273    /// calling [`AppEventPoster::post_external`] on `poster` with a boxed
274    /// [`WebViewEventPayload`] whose `web_view_id` / `window_id_owner` match.
275    ///
276    /// `parent` is `None` when the host context can't surface an OS handle
277    /// (headless tests, or a build-time open before the window-ops sink is
278    /// available); native backends treat `None` as "defer until a handle
279    /// arrives" rather than failing hard.
280    fn open(
281        &mut self,
282        web_view_id: WebViewId,
283        window_id: TeksiloWindowId,
284        parent: Option<ParentHandle>,
285        attrs: WebViewAttributes,
286        poster: Option<Arc<dyn AppEventPoster>>,
287    ) -> Box<dyn WebViewHandle>;
288}
289
290// ============================================================
291// WebViewRegistry — per-app service (app-state)
292// ============================================================
293
294/// Callback the `WebView` widget installs to receive its own backend events.
295type EventCallback = Box<dyn FnMut(WebViewEvent, &mut EventContext)>;
296
297struct Registered {
298    window_id: TeksiloWindowId,
299    callback: EventCallback,
300}
301
302struct RegistryState {
303    backend: RefCell<Box<dyn WebViewBackend>>,
304    callbacks: RefCell<HashMap<WebViewId, Registered>>,
305    /// Bumped per `open`, purely for diagnostics.
306    open_count: Cell<u64>,
307    /// The `(web_view_id, window)` of the delivery currently in flight, if any
308    /// (`deliver` removes the callback, runs it, then reinserts). If a
309    /// re-entrant `unregister`/`purge_window` hits this id/window while the
310    /// callback runs, `delivery_aborted` is set and `deliver` skips the
311    /// reinsert — so a since-purged callback is never resurrected even if
312    /// widget teardown becomes synchronous. `deliver` is not itself re-entrant
313    /// (backend events are posted, not delivered inline), so a single slot
314    /// suffices.
315    delivering: Cell<Option<(WebViewId, TeksiloWindowId)>>,
316    delivery_aborted: Cell<bool>,
317}
318
319/// Per-app web-view service. Registered in app-state by
320/// `TeksiloAppBuilderWebViewExt::install_web_view` (in the `teksilo` umbrella
321/// crate); reachable from any `build()` / handler via
322/// `ctx.app_state::<WebViewRegistry>()`. Cloneable; clones share the same
323/// backend and event-callback map.
324#[derive(Clone)]
325pub struct WebViewRegistry {
326    inner: Rc<RegistryState>,
327}
328
329impl WebViewRegistry {
330    /// Build a registry wrapping `backend`.
331    pub fn new<B: WebViewBackend + 'static>(backend: B) -> Self {
332        Self {
333            inner: Rc::new(RegistryState {
334                backend: RefCell::new(Box::new(backend)),
335                callbacks: RefCell::new(HashMap::new()),
336                open_count: Cell::new(0),
337                delivering: Cell::new(None),
338                delivery_aborted: Cell::new(false),
339            }),
340        }
341    }
342
343    /// Open a native subview and register the widget's event callback in one
344    /// step. Returns the live [`WebViewHandle`] (dropped on widget removal).
345    pub fn open(
346        &self,
347        web_view_id: WebViewId,
348        window_id: TeksiloWindowId,
349        parent: Option<ParentHandle>,
350        attrs: WebViewAttributes,
351        poster: Option<Arc<dyn AppEventPoster>>,
352        on_event: impl FnMut(WebViewEvent, &mut EventContext) + 'static,
353    ) -> Box<dyn WebViewHandle> {
354        self.inner
355            .open_count
356            .set(self.inner.open_count.get().wrapping_add(1));
357        self.inner.callbacks.borrow_mut().insert(
358            web_view_id,
359            Registered {
360                window_id,
361                callback: Box::new(on_event),
362            },
363        );
364        self.inner
365            .backend
366            .borrow_mut()
367            .open(web_view_id, window_id, parent, attrs, poster)
368    }
369
370    /// Route a backend-produced payload to its registered widget callback.
371    /// Called by `teksilo-app` from the `AppEvent::External` arm. Dropped
372    /// silently if the callback was already purged (window/widget gone).
373    pub fn deliver(&self, payload: WebViewEventPayload, ctx: &mut EventContext) {
374        // Take the callback out so the map borrow isn't held while the
375        // (re-entrant-capable) callback runs — it may itself open another web
376        // view, which inserts. Then put it back via `or_insert`, so a *newer*
377        // registration created during the callback wins and is not clobbered.
378        //
379        // The `delivering` slot guards the one remaining hazard: if the
380        // callback synchronously tears the widget/window down (a re-entrant
381        // `unregister` / `purge_window` for this id/window), we must NOT
382        // resurrect the dead callback. That marks `delivery_aborted`, and we
383        // skip the reinsert below. (Today teardown is deferred so this never
384        // fires, but the guard makes the invariant hold unconditionally.)
385        let entry = self
386            .inner
387            .callbacks
388            .borrow_mut()
389            .remove(&payload.web_view_id);
390        let Some(mut reg) = entry else {
391            return;
392        };
393        if reg.window_id != payload.window_id_owner {
394            // Stale routing — drop, don't reinsert.
395            return;
396        }
397        self.inner
398            .delivering
399            .set(Some((payload.web_view_id, reg.window_id)));
400        self.inner.delivery_aborted.set(false);
401
402        (reg.callback)(payload.event, ctx);
403
404        self.inner.delivering.set(None);
405        if !self.inner.delivery_aborted.get() {
406            self.inner
407                .callbacks
408                .borrow_mut()
409                .entry(payload.web_view_id)
410                .or_insert(reg);
411        }
412    }
413
414    /// Drop the registration for a single web view (widget removed).
415    pub fn unregister(&self, web_view_id: WebViewId) {
416        self.inner.callbacks.borrow_mut().remove(&web_view_id);
417        if matches!(self.inner.delivering.get(), Some((id, _)) if id == web_view_id) {
418            self.inner.delivery_aborted.set(true);
419        }
420    }
421
422    /// Drop every registration owned by `window_id`. Called by
423    /// `teksilo-app`'s window-close path so callbacks capturing widget state
424    /// cannot fire into a torn-down tree. Mirrors
425    /// `FileDialogHandle::purge_window`.
426    pub fn purge_window(&self, window_id: TeksiloWindowId) {
427        self.inner
428            .callbacks
429            .borrow_mut()
430            .retain(|_, r| r.window_id != window_id);
431        if matches!(self.inner.delivering.get(), Some((_, win)) if win == window_id) {
432            self.inner.delivery_aborted.set(true);
433        }
434    }
435
436    /// Number of registered web views. Test helper.
437    pub fn registered_count(&self) -> usize {
438        self.inner.callbacks.borrow().len()
439    }
440}
441
442impl std::fmt::Debug for WebViewRegistry {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("WebViewRegistry")
445            .field("registered", &self.inner.callbacks.borrow().len())
446            .field("opens", &self.inner.open_count.get())
447            .finish_non_exhaustive()
448    }
449}
450
451// ============================================================
452// MemoryWebViewBackend (headless test backend)
453// ============================================================
454
455/// One recorded backend operation. Lets tests assert the exact call sequence
456/// (open → set_bounds → set_visible(false) → set_visible(true) → …) without a
457/// real engine, window, or GPU.
458#[derive(Debug, Clone, PartialEq)]
459pub enum WebViewOp {
460    Open {
461        web_view_id: WebViewId,
462    },
463    SetBounds {
464        web_view_id: WebViewId,
465        bounds: Rect,
466    },
467    LoadUrl {
468        web_view_id: WebViewId,
469        url: String,
470    },
471    LoadHtml {
472        web_view_id: WebViewId,
473    },
474    Eval {
475        web_view_id: WebViewId,
476        script: String,
477    },
478    PostMessage {
479        web_view_id: WebViewId,
480        msg: String,
481    },
482    Reload {
483        web_view_id: WebViewId,
484    },
485    GoBack {
486        web_view_id: WebViewId,
487    },
488    GoForward {
489        web_view_id: WebViewId,
490    },
491    Stop {
492        web_view_id: WebViewId,
493    },
494    SetVisible {
495        web_view_id: WebViewId,
496        visible: bool,
497    },
498    SetFocus {
499        web_view_id: WebViewId,
500    },
501    SetInputPassthrough {
502        web_view_id: WebViewId,
503        passthrough: bool,
504    },
505    OpenDevtools {
506        web_view_id: WebViewId,
507    },
508    CloseDevtools {
509        web_view_id: WebViewId,
510    },
511    Dropped {
512        web_view_id: WebViewId,
513    },
514}
515
516/// Shared, cloneable recorder. Both the backend and the test hold a clone, so
517/// the test can read the op log after driving the tree.
518#[derive(Clone, Default)]
519pub struct MemoryWebViewRecords {
520    ops: Rc<RefCell<Vec<WebViewOp>>>,
521}
522
523impl MemoryWebViewRecords {
524    /// All recorded ops, in order.
525    pub fn ops(&self) -> Vec<WebViewOp> {
526        self.ops.borrow().clone()
527    }
528
529    /// Every op for a given web view.
530    pub fn ops_for(&self, id: WebViewId) -> Vec<WebViewOp> {
531        self.ops
532            .borrow()
533            .iter()
534            .filter(|op| op_web_view_id(op) == id)
535            .cloned()
536            .collect()
537    }
538
539    /// The ordered `set_visible` booleans for a web view — the headline
540    /// dormancy assertion (`[false, true]` across a tab-away / tab-back).
541    pub fn visibility_log(&self, id: WebViewId) -> Vec<bool> {
542        self.ops
543            .borrow()
544            .iter()
545            .filter_map(|op| match op {
546                WebViewOp::SetVisible {
547                    web_view_id,
548                    visible,
549                } if *web_view_id == id => Some(*visible),
550                _ => None,
551            })
552            .collect()
553    }
554
555    fn push(&self, op: WebViewOp) {
556        self.ops.borrow_mut().push(op);
557    }
558}
559
560fn op_web_view_id(op: &WebViewOp) -> WebViewId {
561    match op {
562        WebViewOp::Open { web_view_id }
563        | WebViewOp::SetBounds { web_view_id, .. }
564        | WebViewOp::LoadUrl { web_view_id, .. }
565        | WebViewOp::LoadHtml { web_view_id }
566        | WebViewOp::Eval { web_view_id, .. }
567        | WebViewOp::PostMessage { web_view_id, .. }
568        | WebViewOp::Reload { web_view_id }
569        | WebViewOp::GoBack { web_view_id }
570        | WebViewOp::GoForward { web_view_id }
571        | WebViewOp::Stop { web_view_id }
572        | WebViewOp::SetVisible { web_view_id, .. }
573        | WebViewOp::SetFocus { web_view_id }
574        | WebViewOp::SetInputPassthrough { web_view_id, .. }
575        | WebViewOp::OpenDevtools { web_view_id }
576        | WebViewOp::CloseDevtools { web_view_id }
577        | WebViewOp::Dropped { web_view_id } => *web_view_id,
578    }
579}
580
581/// In-memory deterministic backend for headless tests. Records every op into a
582/// shared [`MemoryWebViewRecords`]; never renders. Mirrors `MemoryFileDialog`.
583pub struct MemoryWebViewBackend {
584    records: MemoryWebViewRecords,
585}
586
587impl MemoryWebViewBackend {
588    /// Build a backend plus its shared recorder; clone the returned records
589    /// before moving the backend into a [`WebViewRegistry`].
590    pub fn new() -> (Self, MemoryWebViewRecords) {
591        let records = MemoryWebViewRecords::default();
592        (
593            Self {
594                records: records.clone(),
595            },
596            records,
597        )
598    }
599}
600
601struct MemoryWebViewHandle {
602    web_view_id: WebViewId,
603    records: MemoryWebViewRecords,
604}
605
606impl WebViewHandle for MemoryWebViewHandle {
607    fn set_bounds(&self, bounds: Rect, _scale_factor: f32) {
608        // Record logical bounds (scale-independent) so test assertions stay
609        // resolution-agnostic.
610        self.records.push(WebViewOp::SetBounds {
611            web_view_id: self.web_view_id,
612            bounds,
613        });
614    }
615    fn load_url(&self, url: &str) {
616        self.records.push(WebViewOp::LoadUrl {
617            web_view_id: self.web_view_id,
618            url: url.to_string(),
619        });
620    }
621    fn load_html(&self, _html: &str, _base_url: Option<&str>) {
622        self.records.push(WebViewOp::LoadHtml {
623            web_view_id: self.web_view_id,
624        });
625    }
626    fn eval(&self, script: &str) {
627        self.records.push(WebViewOp::Eval {
628            web_view_id: self.web_view_id,
629            script: script.to_string(),
630        });
631    }
632    fn post_message(&self, msg: &str) {
633        self.records.push(WebViewOp::PostMessage {
634            web_view_id: self.web_view_id,
635            msg: msg.to_string(),
636        });
637    }
638    fn reload(&self) {
639        self.records.push(WebViewOp::Reload {
640            web_view_id: self.web_view_id,
641        });
642    }
643    fn go_back(&self) {
644        self.records.push(WebViewOp::GoBack {
645            web_view_id: self.web_view_id,
646        });
647    }
648    fn go_forward(&self) {
649        self.records.push(WebViewOp::GoForward {
650            web_view_id: self.web_view_id,
651        });
652    }
653    fn stop(&self) {
654        self.records.push(WebViewOp::Stop {
655            web_view_id: self.web_view_id,
656        });
657    }
658    fn set_visible(&self, visible: bool) {
659        self.records.push(WebViewOp::SetVisible {
660            web_view_id: self.web_view_id,
661            visible,
662        });
663    }
664    fn set_focus(&self) {
665        self.records.push(WebViewOp::SetFocus {
666            web_view_id: self.web_view_id,
667        });
668    }
669    fn set_input_passthrough(&self, passthrough: bool) {
670        self.records.push(WebViewOp::SetInputPassthrough {
671            web_view_id: self.web_view_id,
672            passthrough,
673        });
674    }
675    fn open_devtools(&self) {
676        self.records.push(WebViewOp::OpenDevtools {
677            web_view_id: self.web_view_id,
678        });
679    }
680    fn close_devtools(&self) {
681        self.records.push(WebViewOp::CloseDevtools {
682            web_view_id: self.web_view_id,
683        });
684    }
685}
686
687impl Drop for MemoryWebViewHandle {
688    fn drop(&mut self) {
689        self.records.push(WebViewOp::Dropped {
690            web_view_id: self.web_view_id,
691        });
692    }
693}
694
695impl WebViewBackend for MemoryWebViewBackend {
696    fn open(
697        &mut self,
698        web_view_id: WebViewId,
699        _window_id: TeksiloWindowId,
700        _parent: Option<ParentHandle>,
701        attrs: WebViewAttributes,
702        _poster: Option<Arc<dyn AppEventPoster>>,
703    ) -> Box<dyn WebViewHandle> {
704        self.records.push(WebViewOp::Open { web_view_id });
705        // Replay the initial source as the corresponding load op so tests can
706        // see what the widget asked to display.
707        match attrs.source {
708            Some(WebSource::Url(url)) => self.records.push(WebViewOp::LoadUrl { web_view_id, url }),
709            Some(WebSource::Html { .. }) => self.records.push(WebViewOp::LoadHtml { web_view_id }),
710            None => {}
711        }
712        Box::new(MemoryWebViewHandle {
713            web_view_id,
714            records: self.records.clone(),
715        })
716    }
717}
718
719/// Convenience: a registry backed by a fresh [`MemoryWebViewBackend`], plus
720/// its shared recorder. The one-liner headless-test setup.
721pub fn memory_registry() -> (WebViewRegistry, MemoryWebViewRecords) {
722    let (backend, records) = MemoryWebViewBackend::new();
723    (WebViewRegistry::new(backend), records)
724}
725
726/// A backend that renders nothing and records nothing — every call is a no-op.
727///
728/// Unlike [`MemoryWebViewBackend`] (which accumulates an unbounded op log for
729/// test assertions), this is safe to install in a long-running app as the
730/// placeholder default until a native engine backend is wired. Used by
731/// `install_web_view_default`.
732#[derive(Debug, Default)]
733pub struct NoopWebViewBackend;
734
735/// A [`WebViewHandle`] whose every method is a no-op. Returned by
736/// [`NoopWebViewBackend`], and by the `WryBackend` / `ServoBackend` engine
737/// backends on their failure paths (no parent handle, engine-init error) so a
738/// failed open still yields a live, harmless handle. Defined once so a method
739/// added to the trait is implemented in exactly one place. `pub(crate)` —
740/// backends return it boxed; apps never name it.
741pub(crate) struct NoopWebViewHandle;
742
743impl WebViewHandle for NoopWebViewHandle {
744    fn set_bounds(&self, _bounds: Rect, _scale_factor: f32) {}
745    fn load_url(&self, _url: &str) {}
746    fn load_html(&self, _html: &str, _base_url: Option<&str>) {}
747    fn eval(&self, _script: &str) {}
748    fn post_message(&self, _msg: &str) {}
749    fn reload(&self) {}
750    fn go_back(&self) {}
751    fn go_forward(&self) {}
752    fn stop(&self) {}
753    fn set_visible(&self, _visible: bool) {}
754    fn set_focus(&self) {}
755    fn set_input_passthrough(&self, _passthrough: bool) {
756        // No surface, so nothing to make transparent.
757    }
758}
759
760impl WebViewBackend for NoopWebViewBackend {
761    fn open(
762        &mut self,
763        _web_view_id: WebViewId,
764        _window_id: TeksiloWindowId,
765        _parent: Option<ParentHandle>,
766        _attrs: WebViewAttributes,
767        _poster: Option<Arc<dyn AppEventPoster>>,
768    ) -> Box<dyn WebViewHandle> {
769        Box::new(NoopWebViewHandle)
770    }
771}