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}