Skip to main content

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}