teksilo_webview/lib.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `teksilo-webview` — an embeddable [`WebView`] widget for Teksilo.
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 crate accepts that reality and mirrors the established
10//! platform-backend pattern — a swappable [`WebViewBackend`] creates an
11//! engine-specific [`WebViewHandle`], and a per-app [`WebViewRegistry`]
12//! (installed in app-state) routes JS→Rust / lifecycle events back to the
13//! widget.
14//!
15//! ```rust
16//! # use teksilo_core::signal::Signal;
17//! use teksilo_webview::WebView;
18//!
19//! # let title_signal: Signal<String> = Signal::new(String::new());
20//! # let loading_signal: Signal<bool> = Signal::new(false);
21//! let _wv = WebView::new()
22//! .url("https://example.com")
23//! .title_signal(title_signal.clone())
24//! .loading_signal(loading_signal.clone())
25//! .on_message(|msg, _ctx| println!("JS said: {msg}"));
26//! ```
27//!
28//! # The Switcher / dormancy caveat
29//!
30//! Because the engine surface lives *outside* the wgpu pass, "not painted"
31//! does NOT mean "hidden" for a `WebView`. When a [`Switcher`] /
32//! `TabWidget` / `visible_when` gate parks the widget dormant, the framework
33//! simply stops painting it — but the native subview keeps floating over the
34//! output. `WebView` closes this gap by bridging the framework's per-node
35//! **activation signal** (`BuildContext::activation_signal`) to the engine's
36//! `set_visible`: tab-away → `set_visible(false)`, tab-back → `set_visible
37//! (true)`. This is the one place a widget must explicitly mirror framework
38//! visibility onto an OS resource, and it is wired automatically here.
39//!
40//! [`Switcher`]: https://docs.rs/teksilo-widgets
41
42mod backend;
43
44#[path = "styles/recipe_web_view_style.rs"]
45mod recipe_web_view_style;
46
47#[cfg(feature = "wry-backend")]
48mod wry_backend;
49#[cfg(feature = "wry-backend")]
50pub use wry_backend::WryBackend;
51
52#[cfg(feature = "servo-backend")]
53mod servo_backend;
54#[cfg(feature = "servo-backend")]
55pub use servo_backend::ServoBackend;
56
57pub use backend::{
58 ConsoleLevel, MemoryWebViewBackend, MemoryWebViewRecords, NoopWebViewBackend, WebSource,
59 WebViewAttributes, WebViewBackend, WebViewEvent, WebViewEventPayload, WebViewHandle, WebViewId,
60 WebViewOp, WebViewRegistry, memory_registry,
61};
62pub use recipe_web_view_style::RecipeWebViewStyle;
63
64// Re-export the Tier-3 style surface (the trait lives in teksilo-core so the
65// core slot bag can name it, same as every other themable widget).
66pub use teksilo_core::styles::{
67 SharedWebViewStyle, WebViewStyle, WebViewStyleConfig, WebViewVisualState,
68};
69
70/// Whether the process is running under a Wayland session — the signal for
71/// choosing the Servo backend (wry's WebKitGTK does X11 reparenting only).
72///
73/// Mirrors winit's backend selection: an explicit `WINIT_UNIX_BACKEND=wayland|x11`
74/// wins (so XWayland forced to X11 correctly reports `false`, where wry works);
75/// otherwise a non-empty `WAYLAND_DISPLAY` means Wayland. Always `false` off
76/// Linux. Apps that drive engine selection themselves (`install_web_view(...)`)
77/// can use this to pick a backend.
78pub fn is_wayland() -> bool {
79 match std::env::var("WINIT_UNIX_BACKEND") {
80 Ok(b) if b.eq_ignore_ascii_case("wayland") => return true,
81 Ok(b) if b.eq_ignore_ascii_case("x11") => return false,
82 _ => {}
83 }
84 std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
85}
86
87/// Pump pending GTK / GLib main-loop events.
88///
89/// wry's Linux engine (WebKitGTK) lives on the GLib main loop. When the webview
90/// is embedded in a winit app (which does not run GTK's loop), the host must
91/// pump it each event-loop turn or the page never lays out, paints, or runs
92/// timers. Call this from `TeksiloAppBuilder::on_loop_tick` with a poll source
93/// held high while any `WebView` is alive.
94///
95/// No-op off Linux, or without the `wry-backend` engine. Safe to call
96/// unconditionally — before `gtk::init()` it does nothing.
97#[cfg(all(target_os = "linux", feature = "wry-backend"))]
98pub fn pump_gtk_events() {
99 if gtk::is_initialized() {
100 while gtk::events_pending() {
101 gtk::main_iteration_do(false);
102 }
103 }
104}
105
106/// No-op stub on platforms / builds where wry's GTK loop isn't in play.
107#[cfg(not(all(target_os = "linux", feature = "wry-backend")))]
108pub fn pump_gtk_events() {}
109
110use std::cell::{Cell, RefCell};
111use std::rc::Rc;
112
113use teksilo_canvas::{Rect, SizeProposal};
114use teksilo_core::accessibility::AccessNodeBuilder;
115use teksilo_core::accesskit::Role;
116use teksilo_core::build_context::BuildContext;
117use teksilo_core::signal::Signal;
118use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
119use teksilo_core::widget_id::WidgetId;
120use teksilo_core::window::TeksiloWindowId;
121
122type MessageCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
123type TitleCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
124type NavigationCallback = Rc<RefCell<dyn FnMut(NavigationInfo, &mut EventContext)>>;
125type PageLoadCallback = Rc<RefCell<dyn FnMut(PageLoadState, &mut EventContext)>>;
126type DownloadStartCallback = Rc<RefCell<dyn FnMut(DownloadStart, &mut EventContext)>>;
127type DownloadFinishCallback = Rc<RefCell<dyn FnMut(DownloadOutcome, &mut EventContext)>>;
128
129/// Page-load lifecycle phase, passed to [`WebView::on_page_load`].
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum PageLoadState {
132 /// The page began loading resources.
133 Started,
134 /// The page finished loading.
135 Finished,
136}
137
138/// A navigation the page initiated, passed to [`WebView::on_navigation`].
139///
140/// This is an **observer**, not a veto: wry decides navigation synchronously,
141/// but Teksilo delivers backend events on a later event-loop tick (events are
142/// posted, not delivered inline), so a true pre-navigation veto cannot be
143/// surfaced through this callback. `can_cancel` is therefore always `false` on
144/// the current backends — the field exists for forward compatibility. Use the
145/// callback for URL-bar sync and logging.
146#[derive(Debug, Clone)]
147pub struct NavigationInfo {
148 /// The URL being navigated to.
149 pub url: String,
150 /// Whether the backend supports vetoing this navigation (always `false`
151 /// today — see the type docs).
152 pub can_cancel: bool,
153}
154
155/// A download the page started, passed to [`WebView::on_download_started`].
156///
157/// Observational: the destination path is the engine's default and cannot be
158/// redirected from the callback (the decision is asynchronous). Use it to drive
159/// progress UI / toasts.
160#[derive(Debug, Clone)]
161pub struct DownloadStart {
162 /// Source URL of the download.
163 pub url: String,
164 /// The engine's chosen destination path.
165 pub suggested_path: std::path::PathBuf,
166}
167
168/// A finished (or failed) download, passed to [`WebView::on_download_finished`].
169#[derive(Debug, Clone)]
170pub struct DownloadOutcome {
171 /// Where the file was written.
172 pub path: std::path::PathBuf,
173 /// Whether the download completed successfully.
174 pub success: bool,
175}
176
177/// Shared slot holding the live engine handle once opened. Cloned into the
178/// activation-signal effect so the visibility bridge can reach the handle
179/// created later in `build`.
180type SharedHandle = Rc<RefCell<Option<Box<dyn WebViewHandle>>>>;
181
182/// An embeddable web view. Composing widget: it delegates layout/paint to a
183/// style-built overlay and drives a native engine subview on top.
184///
185/// See the [crate docs](crate) for the dormancy/visibility contract.
186pub struct WebView {
187 attrs: WebViewAttributes,
188 web_view_id: WebViewId,
189 handle: SharedHandle,
190 /// Shared with the post-mount open action so it can apply the first
191 /// `set_bounds` immediately after the engine opens.
192 last_bounds: Rc<Cell<Option<Rect>>>,
193 /// Host window HiDPI scale, read from `LayoutContext::scale_factor` in
194 /// `place_children`. Handed to the backend's `set_bounds` so engines that
195 /// position in device pixels (WebKitGTK on X11) land correctly under
196 /// fractional scaling. Shared so the post-mount open closure can read it.
197 /// Defaults to 1.0 until the first layout.
198 scale: Rc<Cell<f32>>,
199 /// Guards `run_after_mount` enqueue against rebuilds (queue at most once).
200 mount_queued: Cell<bool>,
201 /// Window id captured from `BuildContext::window()` (the post-mount
202 /// `EventContext` has no direct window-id accessor).
203 window_id: Cell<Option<TeksiloWindowId>>,
204 style_override: Option<SharedWebViewStyle>,
205 root_child_id: Option<WidgetId>,
206 /// Internal lifecycle state driving the overlay chrome.
207 state_signal: Signal<WebViewVisualState>,
208 /// Registry handle, written by the post-mount open action and read by
209 /// `Drop` for unregistration. Shared so the moved open closure can set it.
210 registry: Rc<RefCell<Option<WebViewRegistry>>>,
211 /// Whether the Teksilo-side node holds keyboard focus — i.e. the *frame*
212 /// is focused, which is not the same as the page having been entered.
213 /// Drives the style's focus ring so a keyboard user can see where Tab
214 /// landed even though the widget paints no content of its own.
215 focused: Signal<bool>,
216 /// Hand keyboard focus straight to the engine the moment the frame gains
217 /// focus, instead of waiting for Enter. Off by default — see
218 /// [`enter_page_on_focus`](Self::enter_page_on_focus).
219 enter_page_on_focus: bool,
220
221 // Optional bindings.
222 /// Two-way: the engine writes the resolved URL on navigation-finish, and
223 /// an external `.set()` drives programmatic navigation (guarded against
224 /// the echo via `nav_guard`).
225 url_signal: Option<Signal<String>>,
226 title_signal: Option<Signal<String>>,
227 loading_signal: Option<Signal<bool>>,
228 // NOTE: can-go-back / can-go-forward bindings are intentionally absent
229 // until a history-aware backend can drive them — shipping builders that
230 // never update the bound signal would be a silent lie. Re-add alongside
231 // the wry/servo history wiring.
232 /// The URL the engine last reported / we last drove, so the inbound
233 /// navigation effect skips the engine's own echo (no navigate loop).
234 nav_guard: Rc<RefCell<Option<String>>>,
235
236 // User event callbacks.
237 on_message: Option<MessageCallback>,
238 on_title_changed: Option<TitleCallback>,
239 on_navigation: Option<NavigationCallback>,
240 on_page_load: Option<PageLoadCallback>,
241 on_download_started: Option<DownloadStartCallback>,
242 on_download_finished: Option<DownloadFinishCallback>,
243}
244
245impl std::fmt::Debug for WebView {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 f.debug_struct("WebView")
248 .field("web_view_id", &self.web_view_id)
249 .field("opened", &self.handle.borrow().is_some())
250 .field("source", &self.attrs.source)
251 .finish_non_exhaustive()
252 }
253}
254
255impl Default for WebView {
256 fn default() -> Self {
257 Self::new()
258 }
259}
260
261impl WebView {
262 /// A blank web view. Set content with [`url`](Self::url) /
263 /// [`html`](Self::html) / [`source`](Self::source).
264 pub fn new() -> Self {
265 Self {
266 attrs: WebViewAttributes::default(),
267 web_view_id: WebViewId::next(),
268 handle: Rc::new(RefCell::new(None)),
269 last_bounds: Rc::new(Cell::new(None)),
270 scale: Rc::new(Cell::new(1.0)),
271 mount_queued: Cell::new(false),
272 window_id: Cell::new(None),
273 style_override: None,
274 root_child_id: None,
275 state_signal: Signal::new(WebViewVisualState::Loading),
276 registry: Rc::new(RefCell::new(None)),
277 focused: Signal::new(false),
278 enter_page_on_focus: false,
279 url_signal: None,
280 title_signal: None,
281 loading_signal: None,
282 nav_guard: Rc::new(RefCell::new(None)),
283 on_message: None,
284 on_title_changed: None,
285 on_navigation: None,
286 on_page_load: None,
287 on_download_started: None,
288 on_download_finished: None,
289 }
290 }
291
292 /// Navigate to a URL on first open.
293 pub fn url(mut self, url: impl Into<String>) -> Self {
294 self.attrs.source = Some(WebSource::Url(url.into()));
295 self
296 }
297
298 /// Load inline HTML on first open.
299 pub fn html(mut self, html: impl Into<String>) -> Self {
300 self.attrs.source = Some(WebSource::Html {
301 html: html.into(),
302 base_url: None,
303 });
304 self
305 }
306
307 /// Set the initial content from a [`WebSource`].
308 pub fn source(mut self, source: WebSource) -> Self {
309 self.attrs.source = Some(source);
310 self
311 }
312
313 /// Override the engine `User-Agent`.
314 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
315 self.attrs.user_agent = Some(ua.into());
316 self
317 }
318
319 /// Request a transparent engine background.
320 pub fn transparent(mut self, transparent: bool) -> Self {
321 self.attrs.transparent = transparent;
322 self
323 }
324
325 /// Enable engine devtools (debug builds, by convention).
326 pub fn devtools(mut self, devtools: bool) -> Self {
327 self.attrs.devtools = devtools;
328 self
329 }
330
331 /// Register a custom-protocol scheme name (`"app"` → `app://`). The
332 /// dispatch closure lives app-side; the backend only needs the name.
333 pub fn custom_protocol(mut self, scheme: impl Into<String>) -> Self {
334 self.attrs.custom_protocols.push(scheme.into());
335 self
336 }
337
338 /// Two-way URL binding. The engine writes the resolved URL into `signal`
339 /// when an in-page navigation completes; calling `signal.set("…")`
340 /// externally drives programmatic navigation (equivalent to
341 /// [`load_url`](Self::load_url)). The engine's own echo is filtered, so
342 /// the two directions don't loop.
343 ///
344 /// The **initial** page still comes from [`url`](Self::url) /
345 /// [`html`](Self::html) / [`source`](Self::source); `url_signal` governs
346 /// navigation *after* the first load (the signal's value at build time is
347 /// taken as the baseline and does not trigger a navigation).
348 pub fn url_signal(mut self, signal: Signal<String>) -> Self {
349 self.url_signal = Some(signal);
350 self
351 }
352
353 /// Bind the page title (read-only — updated on `TitleChanged`).
354 pub fn title_signal(mut self, signal: Signal<String>) -> Self {
355 self.title_signal = Some(signal);
356 self
357 }
358
359 /// Bind the loading flag (read-only — true between page-load start/finish).
360 pub fn loading_signal(mut self, signal: Signal<bool>) -> Self {
361 self.loading_signal = Some(signal);
362 self
363 }
364
365 /// JS → Rust: called when the page runs `window.ipc.postMessage(...)`.
366 pub fn on_message(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
367 self.on_message = Some(Rc::new(RefCell::new(cb)));
368 self
369 }
370
371 /// Called when the document title changes.
372 pub fn on_title_changed(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
373 self.on_title_changed = Some(Rc::new(RefCell::new(cb)));
374 self
375 }
376
377 /// Called when a navigation starts (observer — see [`NavigationInfo`]; it
378 /// cannot veto). Useful for URL-bar sync before the load completes.
379 pub fn on_navigation(
380 mut self,
381 cb: impl FnMut(NavigationInfo, &mut EventContext) + 'static,
382 ) -> Self {
383 self.on_navigation = Some(Rc::new(RefCell::new(cb)));
384 self
385 }
386
387 /// Called when page loading starts and finishes (see [`PageLoadState`]).
388 pub fn on_page_load(
389 mut self,
390 cb: impl FnMut(PageLoadState, &mut EventContext) + 'static,
391 ) -> Self {
392 self.on_page_load = Some(Rc::new(RefCell::new(cb)));
393 self
394 }
395
396 /// Called when the page begins a download (see [`DownloadStart`]).
397 pub fn on_download_started(
398 mut self,
399 cb: impl FnMut(DownloadStart, &mut EventContext) + 'static,
400 ) -> Self {
401 self.on_download_started = Some(Rc::new(RefCell::new(cb)));
402 self
403 }
404
405 /// Called when a download finishes or fails (see [`DownloadOutcome`]).
406 pub fn on_download_finished(
407 mut self,
408 cb: impl FnMut(DownloadOutcome, &mut EventContext) + 'static,
409 ) -> Self {
410 self.on_download_finished = Some(Rc::new(RefCell::new(cb)));
411 self
412 }
413
414 /// Per-call style override (highest precedence).
415 pub fn style(mut self, style: impl WebViewStyle) -> Self {
416 self.style_override = Some(Rc::new(style));
417 self
418 }
419
420 /// Enter the page as soon as the frame receives keyboard focus, rather
421 /// than on Enter (the default two-step).
422 ///
423 /// Only appropriate when the web view *is* the window's content and there
424 /// is nothing else in the Tab cycle worth reaching — a kiosk view, a
425 /// full-window document preview. In a mixed UI it makes Tab a one-way
426 /// door: once the engine owns the keyboard, Teksilo sees no more keys and
427 /// getting back out is up to the engine and the OS. Off by default for
428 /// exactly that reason.
429 pub fn enter_page_on_focus(mut self, enter: bool) -> Self {
430 self.enter_page_on_focus = enter;
431 self
432 }
433
434 /// Hand keyboard focus to the engine subview, entering the page.
435 ///
436 /// The programmatic form of the frame's Enter key. No-op before the engine
437 /// has opened (the handle is created post-mount).
438 pub fn focus_page(&self) {
439 self.with_handle(|h| h.set_focus());
440 }
441
442 /// Whether the Teksilo-side frame currently holds keyboard focus.
443 ///
444 /// True while Tab has landed *on* the web view; it says nothing about
445 /// whether the page has been entered, because once the engine subview
446 /// owns the keyboard the toolkit is no longer told what happens inside it.
447 pub fn focused_signal(&self) -> Signal<bool> {
448 self.focused.clone()
449 }
450
451 /// The stable routing identity of this web view.
452 pub fn id(&self) -> WebViewId {
453 self.web_view_id
454 }
455
456 // --- Imperative controls (call via `ctx.with_widget_mut::<WebView>`) ---
457
458 /// Navigate to `url`.
459 pub fn load_url(&self, url: &str) {
460 self.with_handle(|h| h.load_url(url));
461 }
462 /// Rust → JS: dispatch a `teksilo-message` event carrying `msg`.
463 pub fn post_message(&self, msg: &str) {
464 self.with_handle(|h| h.post_message(msg));
465 }
466 /// Evaluate JavaScript in the page.
467 pub fn eval(&self, script: &str) {
468 self.with_handle(|h| h.eval(script));
469 }
470 /// Reload the page.
471 pub fn reload(&self) {
472 self.with_handle(|h| h.reload());
473 }
474 /// Navigate back.
475 pub fn go_back(&self) {
476 self.with_handle(|h| h.go_back());
477 }
478 /// Navigate forward.
479 pub fn go_forward(&self) {
480 self.with_handle(|h| h.go_forward());
481 }
482 /// Stop the current load.
483 pub fn stop(&self) {
484 self.with_handle(|h| h.stop());
485 }
486 /// Open the engine's developer tools (no-op where unsupported, e.g. Servo).
487 pub fn open_devtools(&self) {
488 self.with_handle(|h| h.open_devtools());
489 }
490 /// Close the engine's developer tools.
491 pub fn close_devtools(&self) {
492 self.with_handle(|h| h.close_devtools());
493 }
494
495 fn with_handle(&self, f: impl FnOnce(&dyn WebViewHandle)) {
496 if let Some(h) = self.handle.borrow().as_ref() {
497 f(h.as_ref());
498 }
499 }
500
501 /// Build the JS→Rust / lifecycle event callback handed to the registry.
502 fn make_event_callback(&self) -> impl FnMut(WebViewEvent, &mut EventContext) + 'static {
503 let url_signal = self.url_signal.clone();
504 let title_signal = self.title_signal.clone();
505 let loading_signal = self.loading_signal.clone();
506 let state_signal = self.state_signal.clone();
507 let nav_guard = self.nav_guard.clone();
508 let on_message = self.on_message.clone();
509 let on_title_changed = self.on_title_changed.clone();
510 let on_navigation = self.on_navigation.clone();
511 let on_page_load = self.on_page_load.clone();
512 let on_download_started = self.on_download_started.clone();
513 let on_download_finished = self.on_download_finished.clone();
514
515 move |event, ctx| match event {
516 WebViewEvent::PageLoadStarted => {
517 if let Some(s) = &loading_signal {
518 s.set(true);
519 }
520 state_signal.set(WebViewVisualState::Loading);
521 if let Some(cb) = &on_page_load {
522 (cb.borrow_mut())(PageLoadState::Started, ctx);
523 }
524 }
525 WebViewEvent::PageLoadFinished => {
526 if let Some(s) = &loading_signal {
527 s.set(false);
528 }
529 state_signal.set(WebViewVisualState::Ready);
530 if let Some(cb) = &on_page_load {
531 (cb.borrow_mut())(PageLoadState::Finished, ctx);
532 }
533 }
534 WebViewEvent::NavigationStarted { url, can_cancel } => {
535 if let Some(cb) = &on_navigation {
536 (cb.borrow_mut())(NavigationInfo { url, can_cancel }, ctx);
537 }
538 }
539 WebViewEvent::NavigationFinished { url, success } => {
540 if success {
541 // Record the engine-resolved URL as the guard BEFORE
542 // writing the bound signal, so the inbound navigation
543 // effect (which fires on the `set`) recognises it as the
544 // engine's own echo and does not re-navigate.
545 *nav_guard.borrow_mut() = Some(url.clone());
546 if let Some(s) = &url_signal {
547 s.set(url);
548 }
549 state_signal.set(WebViewVisualState::Ready);
550 } else {
551 state_signal.set(WebViewVisualState::Error);
552 }
553 }
554 WebViewEvent::TitleChanged(title) => {
555 if let Some(s) = &title_signal {
556 s.set(title.clone());
557 }
558 if let Some(cb) = &on_title_changed {
559 (cb.borrow_mut())(title, ctx);
560 }
561 }
562 WebViewEvent::Message(msg) => {
563 if let Some(cb) = &on_message {
564 (cb.borrow_mut())(msg, ctx);
565 }
566 }
567 WebViewEvent::DownloadStarted {
568 url,
569 suggested_path,
570 } => {
571 if let Some(cb) = &on_download_started {
572 (cb.borrow_mut())(
573 DownloadStart {
574 url,
575 suggested_path,
576 },
577 ctx,
578 );
579 }
580 }
581 WebViewEvent::DownloadFinished { path, success } => {
582 if let Some(cb) = &on_download_finished {
583 (cb.borrow_mut())(DownloadOutcome { path, success }, ctx);
584 }
585 }
586 WebViewEvent::ConsoleMessage { .. } => {
587 // Diagnostics only (backend init / unsupported-op reports);
588 // not surfaced to a dedicated app callback today.
589 }
590 }
591 }
592}
593
594impl Widget for WebView {
595 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
596 let self_id = ctx.self_id();
597
598 // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
599 let style = self
600 .style_override
601 .clone()
602 .or_else(|| ctx.theme().style_slots.web_view.clone())
603 .unwrap_or_else(|| Rc::new(RecipeWebViewStyle));
604
605 // Empty overlay content placeholder (apps install a richer overlay
606 // via a custom WebViewStyle). Keeps the default body self-contained.
607 let content = ctx.add(EmptyOverlayContent);
608 let body = style.make_body(
609 &WebViewStyleConfig {
610 state: self.state_signal.clone(),
611 focused: self.focused.clone(),
612 content,
613 },
614 ctx,
615 );
616 self.root_child_id = Some(body);
617
618 // --- Keyboard: put the frame in the Tab cycle, then let Enter in ---
619 //
620 // The page's own focus ring lives in the engine's tree, not ours, so a
621 // web view that is not focusable is simply unreachable without a mouse
622 // (WCAG 2.1.1 / 2.4.3). Making the *frame* focusable is the first half.
623 //
624 // The second half is deliberately a **two-step**: landing on the frame
625 // does not hand the keyboard to the engine, Enter (or Space) does. A
626 // web view has two disjoint focus rings — AccessKit's and the engine's
627 // platform tree — and once the native subview owns the keyboard the
628 // toolkit stops seeing keys entirely, so an automatic hand-off would
629 // turn Tab into a one-way door out of the app's own focus cycle. The
630 // same reasoning the HTML `<iframe>` / canvas-embed pattern arrives at.
631 // Apps whose web view is the whole window can opt into the one-step
632 // form with `enter_page_on_focus(true)`.
633 let focused = self.focused.clone();
634 let focus_handle = self.handle.clone();
635 let enter_on_focus = self.enter_page_on_focus;
636 let mut handlers = teksilo_core::widget_builder::HandlerSet::new()
637 .focusable(true)
638 .on_focus(move |gained, _ctx| {
639 focused.set(gained);
640 if gained
641 && enter_on_focus
642 && let Some(h) = focus_handle.borrow().as_ref()
643 {
644 h.set_focus();
645 }
646 });
647
648 let key_handle = self.handle.clone();
649 handlers = handlers.on_key(move |event, _ctx| {
650 use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
651 // Enter / Space enter the page. Everything else — Tab included —
652 // is declined, so the frame never becomes a trap: focus cycles off
653 // it exactly as it would off any other control.
654 if let WidgetEvent::KeyDown { key, modifiers, .. } = event
655 && matches!(key, Key::Enter | Key::Space)
656 && *modifiers == Modifiers::NONE
657 && let Some(h) = key_handle.borrow().as_ref()
658 {
659 h.set_focus();
660 return EventResponse::Handled;
661 }
662 EventResponse::Ignored
663 });
664
665 // The advertised `Click` needs something behind it: an action a widget
666 // declares but does not execute is worse than one it never declared,
667 // because AT reports the control as operable when it is not.
668 let action_handle = self.handle.clone();
669 handlers = handlers.on_access_action(move |action, _ctx| {
670 use teksilo_core::event::EventResponse;
671 if matches!(
672 action,
673 teksilo_core::accesskit::Action::Click | teksilo_core::accesskit::Action::Focus
674 ) && let Some(h) = action_handle.borrow().as_ref()
675 {
676 h.set_focus();
677 return EventResponse::Handled;
678 }
679 EventResponse::Ignored
680 });
681
682 ctx.apply_self_handlers(handlers);
683
684 // Capture the window id now — the post-mount EventContext has no
685 // direct window-id accessor, but BuildContext::window() does.
686 self.window_id.set(ctx.window().map(|w| w.id()));
687
688 // --- Visibility bridge: framework activation → engine set_visible ---
689 // The single reason this widget needs the activation signal: a native
690 // subview ignores the wgpu paint pass, so a Switcher parking us
691 // dormant would otherwise leave the engine surface visible. The effect
692 // no-ops until the engine handle exists (opened post-mount below).
693 let vis = ctx.activation_signal(self_id);
694 let effect_handle = self.handle.clone();
695 ctx.effect(&vis, move |active| {
696 if let Some(h) = effect_handle.borrow().as_ref() {
697 h.set_visible(*active);
698 }
699 });
700
701 // --- Inbound navigation: external `url_signal.set()` → load_url ---
702 // Seed the guard with the signal's current value so the effect's
703 // registration tick (it fires immediately with the current value) is
704 // treated as the baseline and does NOT navigate — the initial page
705 // comes from `attrs.source`, not the binding. Subsequent external
706 // changes that differ from the guard drive a navigation; the engine's
707 // own echo is filtered because `NavigationFinished` updates the guard
708 // before writing the signal.
709 if let Some(url_signal) = self.url_signal.clone() {
710 *self.nav_guard.borrow_mut() = Some(url_signal.get());
711 let nav_guard = self.nav_guard.clone();
712 let nav_handle = self.handle.clone();
713 ctx.effect(&url_signal, move |url| {
714 if nav_guard.borrow().as_deref() == Some(url.as_str()) {
715 return;
716 }
717 *nav_guard.borrow_mut() = Some(url.clone());
718 if let Some(h) = nav_handle.borrow().as_ref() {
719 h.load_url(url);
720 }
721 });
722 }
723
724 // --- Open the native engine subview once, AFTER mount ---
725 // Opening is deferred to a post-mount EventContext because that is the
726 // only place a widget can read the OS parent window handle
727 // (`ctx.parent_window_handle()`) together with `app_state` + `poster`
728 // — exactly what a real engine's `build_as_child(parent)` needs.
729 if !self.mount_queued.get() {
730 self.mount_queued.set(true);
731 let web_view_id = self.web_view_id;
732 let window_id = self.window_id.get();
733 let attrs = self.attrs.clone();
734 let handle_slot = self.handle.clone();
735 let bounds_slot = self.last_bounds.clone();
736 let scale_slot = self.scale.clone();
737 let registry_slot = self.registry.clone();
738 let activation = vis;
739 let on_event = self.make_event_callback();
740
741 ctx.run_after_mount(move |ectx| {
742 // Guard against a double-open if a rebuild ever re-queues.
743 if handle_slot.borrow().is_some() {
744 return;
745 }
746 let Some(registry) = ectx.app_state::<WebViewRegistry>().cloned() else {
747 // No engine configured (install_web_view not called) —
748 // the widget renders just its overlay chrome.
749 return;
750 };
751 let parent = ectx.parent_window_handle();
752 let poster = ectx.poster().cloned();
753 let wid = window_id.unwrap_or_else(|| TeksiloWindowId::new(0));
754
755 let handle = registry.open(web_view_id, wid, parent, attrs, poster, on_event);
756 // Apply the bounds layout already resolved, then the current
757 // activation state (so a view mounted while its tab is parked
758 // opens hidden, not visible-then-flashing).
759 if let Some(b) = bounds_slot.get() {
760 handle.set_bounds(b, scale_slot.get());
761 }
762 // The engine subview opens visible by default, so only act on
763 // the parked case: a view mounted while its tab is dormant must
764 // be hidden at birth (no visible-then-hidden flash). Active
765 // opens need no redundant set_visible(true).
766 if !activation.get() {
767 handle.set_visible(false);
768 }
769
770 *handle_slot.borrow_mut() = Some(handle);
771 *registry_slot.borrow_mut() = Some(registry);
772 });
773 }
774
775 self.children()
776 }
777
778 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
779 self.root_child_id
780 .and_then(|id| ctx.child_size(id, proposal))
781 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
782 .into()
783 }
784
785 fn place_children(
786 &self,
787 bounds: Rect,
788 _proposal: SizeProposal,
789 children: &mut [WidgetPlacement],
790 ctx: &LayoutContext,
791 ) {
792 for child in children.iter_mut() {
793 child.origin = bounds.origin();
794 child.size = bounds.size();
795 }
796 // Mirror the new bounds onto the native subview. The bounds are logical;
797 // `ctx.scale_factor` is the host window's HiDPI device scale (a scale
798 // change triggers a relayout, so this runs then too). The backend uses
799 // both: engines that position in device pixels (WebKitGTK on X11) need
800 // logical × scale. Store the scale so the post-mount open path can apply
801 // the first bounds at the right scale.
802 let scale = ctx.scale_factor;
803 let scale_changed = (self.scale.get() - scale).abs() > f32::EPSILON;
804 if scale_changed {
805 self.scale.set(scale);
806 }
807 if self.last_bounds.get() != Some(bounds) || scale_changed {
808 self.last_bounds.set(Some(bounds));
809 self.with_handle(|h| h.set_bounds(bounds, scale));
810 }
811 }
812
813 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
814 // A single Teksilo-side node. The page's own AT tree is published by
815 // the engine to the OS directly, so we don't duplicate it; our
816 // descendants are just the presentational overlay (already hidden).
817 builder.set_role(Role::WebView);
818 if let Some(title) = &self.title_signal {
819 builder.set_name(title.get());
820 }
821 // The frame is reachable by Tab and *enterable* by Enter. Both have to
822 // be advertised: `Focus` so an AT client can put the toolkit's focus
823 // here, `Click` so "activate" from a screen reader means the same as
824 // pressing Enter — hand the keyboard to the engine. The `on_key` /
825 // `on_access_action` paths both end at `WebViewHandle::set_focus`.
826 builder.add_action(teksilo_core::accesskit::Action::Focus);
827 builder.add_action(teksilo_core::accesskit::Action::Click);
828 if !self.enter_page_on_focus {
829 builder.set_keyboard_shortcut("Enter");
830 }
831 }
832
833 fn children(&self) -> Vec<WidgetId> {
834 self.root_child_id.into_iter().collect()
835 }
836
837 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
838 Some(self)
839 }
840}
841
842impl Drop for WebView {
843 fn drop(&mut self) {
844 // Unregister the event callback so a late backend event can't route
845 // into freed widget state. The engine handle tears down via its own
846 // Drop when `self.handle`'s last Rc clone (this + the effect) goes.
847 if let Some(registry) = self.registry.borrow().as_ref() {
848 registry.unregister(self.web_view_id);
849 }
850 }
851}
852
853/// Zero-size, zero-paint overlay content placeholder. Fills the proposed
854/// bounds so the overlay container has a child to size against.
855#[derive(Debug)]
856struct EmptyOverlayContent;
857
858impl Widget for EmptyOverlayContent {
859 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
860 proposal.resolve(0.0, 0.0).into()
861 }
862
863 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
864 builder.set_hidden();
865 }
866}