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}