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