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//! # Who owns the pointer over the page
41//!
42//! A native subview is above the wgpu pass for *input* as well as for pixels:
43//! the OS routes a press over its rectangle to the engine, and Teksilo is not
44//! told. [`WebViewInput`] is the declaration of which side owns that
45//! rectangle, and it decides four things at once — see the enum's docs.
46//!
47//! [`Switcher`]: https://docs.rs/teksilo-widgets
48
49mod backend;
50
51#[path = "styles/recipe_web_view_style.rs"]
52mod recipe_web_view_style;
53
54#[cfg(feature = "wry-backend")]
55mod wry_backend;
56#[cfg(feature = "wry-backend")]
57pub use wry_backend::WryBackend;
58
59#[cfg(feature = "servo-backend")]
60mod servo_backend;
61#[cfg(feature = "servo-backend")]
62pub use servo_backend::ServoBackend;
63
64pub use backend::{
65    ConsoleLevel, MemoryWebViewBackend, MemoryWebViewRecords, NoopWebViewBackend, WebSource,
66    WebViewAttributes, WebViewBackend, WebViewEvent, WebViewEventPayload, WebViewHandle, WebViewId,
67    WebViewOp, WebViewRegistry, memory_registry,
68};
69pub use recipe_web_view_style::RecipeWebViewStyle;
70
71// Re-export the Tier-3 style surface (the trait lives in teksilo-core so the
72// core slot bag can name it, same as every other themable widget).
73pub use teksilo_core::styles::{
74    SharedWebViewStyle, WebViewStyle, WebViewStyleConfig, WebViewVisualState,
75};
76
77/// Whether the process is running under a Wayland session — the signal for
78/// choosing the Servo backend (wry's WebKitGTK does X11 reparenting only).
79///
80/// Mirrors winit's backend selection: an explicit `WINIT_UNIX_BACKEND=wayland|x11`
81/// wins (so XWayland forced to X11 correctly reports `false`, where wry works);
82/// otherwise a non-empty `WAYLAND_DISPLAY` means Wayland. Always `false` off
83/// Linux. Apps that drive engine selection themselves (`install_web_view(...)`)
84/// can use this to pick a backend.
85pub fn is_wayland() -> bool {
86    match std::env::var("WINIT_UNIX_BACKEND") {
87        Ok(b) if b.eq_ignore_ascii_case("wayland") => return true,
88        Ok(b) if b.eq_ignore_ascii_case("x11") => return false,
89        _ => {}
90    }
91    std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
92}
93
94/// Pump pending GTK / GLib main-loop events.
95///
96/// wry's Linux engine (WebKitGTK) lives on the GLib main loop. When the webview
97/// is embedded in a winit app (which does not run GTK's loop), the host must
98/// pump it each event-loop turn or the page never lays out, paints, or runs
99/// timers. Call this from `TeksiloAppBuilder::on_loop_tick` with a poll source
100/// held high while any `WebView` is alive.
101///
102/// No-op off Linux, or without the `wry-backend` engine. Safe to call
103/// unconditionally — before `gtk::init()` it does nothing.
104#[cfg(all(target_os = "linux", feature = "wry-backend"))]
105pub fn pump_gtk_events() {
106    if gtk::is_initialized() {
107        while gtk::events_pending() {
108            gtk::main_iteration_do(false);
109        }
110    }
111}
112
113/// No-op stub on platforms / builds where wry's GTK loop isn't in play.
114#[cfg(not(all(target_os = "linux", feature = "wry-backend")))]
115pub fn pump_gtk_events() {}
116
117use std::cell::{Cell, RefCell};
118use std::rc::Rc;
119
120use teksilo_canvas::{Rect, SizeProposal};
121use teksilo_core::accessibility::AccessNodeBuilder;
122use teksilo_core::accesskit::Role;
123use teksilo_core::build_context::BuildContext;
124use teksilo_core::signal::Signal;
125use teksilo_core::widget::{
126    EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
127    WidgetTreeView,
128};
129use teksilo_core::widget_id::WidgetId;
130use teksilo_core::window::TeksiloWindowId;
131
132type MessageCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
133type TitleCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
134type NavigationCallback = Rc<RefCell<dyn FnMut(NavigationInfo, &mut EventContext)>>;
135type PageLoadCallback = Rc<RefCell<dyn FnMut(PageLoadState, &mut EventContext)>>;
136type DownloadStartCallback = Rc<RefCell<dyn FnMut(DownloadStart, &mut EventContext)>>;
137type DownloadFinishCallback = Rc<RefCell<dyn FnMut(DownloadOutcome, &mut EventContext)>>;
138
139/// Page-load lifecycle phase, passed to [`WebView::on_page_load`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum PageLoadState {
142    /// The page began loading resources.
143    Started,
144    /// The page finished loading.
145    Finished,
146}
147
148/// A navigation the page initiated, passed to [`WebView::on_navigation`].
149///
150/// This is an **observer**, not a veto: wry decides navigation synchronously,
151/// but Teksilo delivers backend events on a later event-loop tick (events are
152/// posted, not delivered inline), so a true pre-navigation veto cannot be
153/// surfaced through this callback. `can_cancel` is therefore always `false` on
154/// the current backends — the field exists for forward compatibility. Use the
155/// callback for URL-bar sync and logging.
156#[derive(Debug, Clone)]
157pub struct NavigationInfo {
158    /// The URL being navigated to.
159    pub url: String,
160    /// Whether the backend supports vetoing this navigation (always `false`
161    /// today — see the type docs).
162    pub can_cancel: bool,
163}
164
165/// A download the page started, passed to [`WebView::on_download_started`].
166///
167/// Observational: the destination path is the engine's default and cannot be
168/// redirected from the callback (the decision is asynchronous). Use it to drive
169/// progress UI / toasts.
170#[derive(Debug, Clone)]
171pub struct DownloadStart {
172    /// Source URL of the download.
173    pub url: String,
174    /// The engine's chosen destination path.
175    pub suggested_path: std::path::PathBuf,
176}
177
178/// A finished (or failed) download, passed to [`WebView::on_download_finished`].
179#[derive(Debug, Clone)]
180pub struct DownloadOutcome {
181    /// Where the file was written.
182    pub path: std::path::PathBuf,
183    /// Whether the download completed successfully.
184    pub success: bool,
185}
186
187/// Who owns pointer input over the page's rectangle.
188///
189/// The engine's subview sits above the wgpu surface, so this is not a
190/// preference the toolkit can enforce on its own: in [`Native`](Self::Native)
191/// the OS hands a press over that rectangle to the engine and Teksilo never
192/// sees it, and in [`Transparent`](Self::Transparent) the engine has to be
193/// asked to stop taking it ([`WebViewHandle::set_input_passthrough`]) — which
194/// not every engine can do.
195///
196/// What Teksilo does on its own side follows from the declaration:
197///
198/// | | `Native` | `Transparent` |
199/// |---|---|---|
200/// | `touch_action` over the region | `NONE` | unset (`AUTO`) |
201/// | miss-only slop / grip outsets | off (`no_hit_slop`) | as any other widget |
202/// | a pointer event that does reach the node | answered, and the pointer's live sequence revoked | declined, so it bubbles |
203/// | the engine is asked to pass input through | no | yes |
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum WebViewInput {
206    /// The page owns its rectangle: links, form fields, its own scrolling and
207    /// its own long-press menus. The default, and what a browser-shaped view
208    /// wants.
209    ///
210    /// Teksilo therefore claims nothing over the region. `touch_action(NONE)`
211    /// stops a pan, a pinch or a tree-owned hold forming on the hit path (the
212    /// page scrolls itself; an enclosing `ScrollArea` must not also move under
213    /// the finger), and `no_hit_slop` makes the painted rectangle the exact
214    /// contract in both directions — no neighbouring control may claim a press
215    /// that landed on the page, and the page claims none that missed it.
216    #[default]
217    Native,
218    /// Teksilo owns the rectangle; the page is a display surface.
219    ///
220    /// For a view that renders rather than interacts — a document preview, a
221    /// rendered chart, a kiosk banner — and the mode to reach for when app
222    /// widgets, menus or a dialog have to be operable *over* the page: with the
223    /// engine passing input through, an overlay above the view receives the tap
224    /// instead of the engine swallowing it.
225    ///
226    /// The engine half is a request, not a guarantee. A backend that cannot
227    /// make its surface input-transparent reports so as a
228    /// [`WebViewEvent::ConsoleMessage`]; the Teksilo half (no `touch_action`
229    /// declaration, ordinary hit widening, pointer events declined so they
230    /// bubble) applies either way.
231    Transparent,
232}
233
234impl WebViewInput {
235    /// Whether the engine owns pointer input over the page.
236    pub fn is_native(self) -> bool {
237        matches!(self, WebViewInput::Native)
238    }
239}
240
241/// Why the engine subview is hidden, if it is.
242///
243/// Three independent reasons, resolved into one `set_visible` call so the
244/// engine is never told a visibility that only accounts for one of them: a
245/// `WebView` parked in an unselected tab AND scrolled out of view must not
246/// reappear when only the scroll changes.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248struct EngineVisibility {
249    /// The framework's per-node activation (a `Switcher` branch, a tab).
250    active: bool,
251    /// Whether any of the widget's bounds survives its clipping ancestors.
252    in_view: bool,
253    /// Whether an interactive overlay is standing over the page.
254    uncovered: bool,
255}
256
257impl EngineVisibility {
258    const VISIBLE: Self = Self {
259        active: true,
260        in_view: true,
261        uncovered: true,
262    };
263
264    fn resolved(self) -> bool {
265        self.active && self.in_view && self.uncovered
266    }
267}
268
269/// Shared slot holding the live engine handle once opened. Cloned into the
270/// activation-signal effect so the visibility bridge can reach the handle
271/// created later in `build`.
272type SharedHandle = Rc<RefCell<Option<Box<dyn WebViewHandle>>>>;
273
274/// An embeddable web view. Composing widget: it delegates layout/paint to a
275/// style-built overlay and drives a native engine subview on top.
276///
277/// See the [crate docs](crate) for the dormancy/visibility contract.
278pub struct WebView {
279    attrs: WebViewAttributes,
280    web_view_id: WebViewId,
281    handle: SharedHandle,
282    /// Shared with the post-mount open action so it can apply the first
283    /// `set_bounds` immediately after the engine opens.
284    last_bounds: Rc<Cell<Option<Rect>>>,
285    /// Host window HiDPI scale, read from `LayoutContext::scale_factor` in
286    /// `place_children`. Handed to the backend's `set_bounds` so engines that
287    /// position in device pixels (WebKitGTK on X11) land correctly under
288    /// fractional scaling. Shared so the post-mount open closure can read it.
289    /// Defaults to 1.0 until the first layout.
290    scale: Rc<Cell<f32>>,
291    /// Guards `run_after_mount` enqueue against rebuilds (queue at most once).
292    mount_queued: Cell<bool>,
293    /// Window id captured from `BuildContext::window()` (the post-mount
294    /// `EventContext` has no direct window-id accessor).
295    window_id: Cell<Option<TeksiloWindowId>>,
296    /// This widget's own arena id, captured in `build`. `place_children` needs
297    /// it to walk its clipping ancestors, and the engine-focus event needs it
298    /// to move the toolkit's focus onto the frame.
299    self_id: Cell<Option<WidgetId>>,
300    style_override: Option<SharedWebViewStyle>,
301    root_child_id: Option<WidgetId>,
302    /// Internal lifecycle state driving the overlay chrome.
303    state_signal: Signal<WebViewVisualState>,
304    /// Registry handle, written by the post-mount open action and read by
305    /// `Drop` for unregistration. Shared so the moved open closure can set it.
306    registry: Rc<RefCell<Option<WebViewRegistry>>>,
307    /// Whether the Teksilo-side node holds keyboard focus — i.e. the *frame*
308    /// is focused, which is not the same as the page having been entered.
309    /// Drives the style's focus ring so a keyboard user can see where Tab
310    /// landed even though the widget paints no content of its own.
311    focused: Signal<bool>,
312    /// Hand keyboard focus straight to the engine the moment the frame gains
313    /// focus, instead of waiting for Enter. Off by default — see
314    /// [`enter_page_on_focus`](Self::enter_page_on_focus).
315    enter_page_on_focus: bool,
316    /// Who owns pointer input over the page's rectangle. See [`WebViewInput`].
317    input: WebViewInput,
318    /// Whether the *page* holds the engine's keyboard focus, as the engine
319    /// reports it. Distinct from [`focused`](Self::focused), which is the
320    /// toolkit's own focus on the frame.
321    page_focused: Signal<bool>,
322    /// The three reasons the subview may be hidden, resolved into one
323    /// `set_visible`. Shared with the post-mount open action and the
324    /// activation effect.
325    visibility: Rc<Cell<EngineVisibility>>,
326    /// The last `set_visible` value actually issued, so a layout pass that
327    /// changes nothing issues nothing.
328    visible_applied: Rc<Cell<bool>>,
329
330    // Optional bindings.
331    /// Two-way: the engine writes the resolved URL on navigation-finish, and
332    /// an external `.set()` drives programmatic navigation (guarded against
333    /// the echo via `nav_guard`).
334    url_signal: Option<Signal<String>>,
335    title_signal: Option<Signal<String>>,
336    loading_signal: Option<Signal<bool>>,
337    // NOTE: can-go-back / can-go-forward bindings are intentionally absent
338    // until a history-aware backend can drive them — shipping builders that
339    // never update the bound signal would be a silent lie. Re-add alongside
340    // the wry/servo history wiring.
341    /// The URL the engine last reported / we last drove, so the inbound
342    /// navigation effect skips the engine's own echo (no navigate loop).
343    nav_guard: Rc<RefCell<Option<String>>>,
344
345    // User event callbacks.
346    on_message: Option<MessageCallback>,
347    on_title_changed: Option<TitleCallback>,
348    on_navigation: Option<NavigationCallback>,
349    on_page_load: Option<PageLoadCallback>,
350    on_download_started: Option<DownloadStartCallback>,
351    on_download_finished: Option<DownloadFinishCallback>,
352}
353
354impl std::fmt::Debug for WebView {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("WebView")
357            .field("web_view_id", &self.web_view_id)
358            .field("opened", &self.handle.borrow().is_some())
359            .field("source", &self.attrs.source)
360            .finish_non_exhaustive()
361    }
362}
363
364impl Default for WebView {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370impl WebView {
371    /// A blank web view. Set content with [`url`](Self::url) /
372    /// [`html`](Self::html) / [`source`](Self::source).
373    pub fn new() -> Self {
374        Self {
375            attrs: WebViewAttributes::default(),
376            web_view_id: WebViewId::next(),
377            handle: Rc::new(RefCell::new(None)),
378            last_bounds: Rc::new(Cell::new(None)),
379            scale: Rc::new(Cell::new(1.0)),
380            mount_queued: Cell::new(false),
381            window_id: Cell::new(None),
382            self_id: Cell::new(None),
383            style_override: None,
384            root_child_id: None,
385            state_signal: Signal::new(WebViewVisualState::Loading),
386            registry: Rc::new(RefCell::new(None)),
387            focused: Signal::new(false),
388            enter_page_on_focus: false,
389            input: WebViewInput::default(),
390            page_focused: Signal::new(false),
391            visibility: Rc::new(Cell::new(EngineVisibility::VISIBLE)),
392            visible_applied: Rc::new(Cell::new(true)),
393            url_signal: None,
394            title_signal: None,
395            loading_signal: None,
396            nav_guard: Rc::new(RefCell::new(None)),
397            on_message: None,
398            on_title_changed: None,
399            on_navigation: None,
400            on_page_load: None,
401            on_download_started: None,
402            on_download_finished: None,
403        }
404    }
405
406    /// Navigate to a URL on first open.
407    pub fn url(mut self, url: impl Into<String>) -> Self {
408        self.attrs.source = Some(WebSource::Url(url.into()));
409        self
410    }
411
412    /// Load inline HTML on first open.
413    pub fn html(mut self, html: impl Into<String>) -> Self {
414        self.attrs.source = Some(WebSource::Html {
415            html: html.into(),
416            base_url: None,
417        });
418        self
419    }
420
421    /// Set the initial content from a [`WebSource`].
422    pub fn source(mut self, source: WebSource) -> Self {
423        self.attrs.source = Some(source);
424        self
425    }
426
427    /// Override the engine `User-Agent`.
428    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
429        self.attrs.user_agent = Some(ua.into());
430        self
431    }
432
433    /// Request a transparent engine background.
434    pub fn transparent(mut self, transparent: bool) -> Self {
435        self.attrs.transparent = transparent;
436        self
437    }
438
439    /// Enable engine devtools (debug builds, by convention).
440    pub fn devtools(mut self, devtools: bool) -> Self {
441        self.attrs.devtools = devtools;
442        self
443    }
444
445    /// Register a custom-protocol scheme name (`"app"` → `app://`). The
446    /// dispatch closure lives app-side; the backend only needs the name.
447    pub fn custom_protocol(mut self, scheme: impl Into<String>) -> Self {
448        self.attrs.custom_protocols.push(scheme.into());
449        self
450    }
451
452    /// Two-way URL binding. The engine writes the resolved URL into `signal`
453    /// when an in-page navigation completes; calling `signal.set("…")`
454    /// externally drives programmatic navigation (equivalent to
455    /// [`load_url`](Self::load_url)). The engine's own echo is filtered, so
456    /// the two directions don't loop.
457    ///
458    /// The **initial** page still comes from [`url`](Self::url) /
459    /// [`html`](Self::html) / [`source`](Self::source); `url_signal` governs
460    /// navigation *after* the first load (the signal's value at build time is
461    /// taken as the baseline and does not trigger a navigation).
462    pub fn url_signal(mut self, signal: Signal<String>) -> Self {
463        self.url_signal = Some(signal);
464        self
465    }
466
467    /// Bind the page title (read-only — updated on `TitleChanged`).
468    pub fn title_signal(mut self, signal: Signal<String>) -> Self {
469        self.title_signal = Some(signal);
470        self
471    }
472
473    /// Bind the loading flag (read-only — true between page-load start/finish).
474    pub fn loading_signal(mut self, signal: Signal<bool>) -> Self {
475        self.loading_signal = Some(signal);
476        self
477    }
478
479    /// JS → Rust: called when the page runs `window.ipc.postMessage(...)`.
480    pub fn on_message(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
481        self.on_message = Some(Rc::new(RefCell::new(cb)));
482        self
483    }
484
485    /// Called when the document title changes.
486    pub fn on_title_changed(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
487        self.on_title_changed = Some(Rc::new(RefCell::new(cb)));
488        self
489    }
490
491    /// Called when a navigation starts (observer — see [`NavigationInfo`]; it
492    /// cannot veto). Useful for URL-bar sync before the load completes.
493    pub fn on_navigation(
494        mut self,
495        cb: impl FnMut(NavigationInfo, &mut EventContext) + 'static,
496    ) -> Self {
497        self.on_navigation = Some(Rc::new(RefCell::new(cb)));
498        self
499    }
500
501    /// Called when page loading starts and finishes (see [`PageLoadState`]).
502    pub fn on_page_load(
503        mut self,
504        cb: impl FnMut(PageLoadState, &mut EventContext) + 'static,
505    ) -> Self {
506        self.on_page_load = Some(Rc::new(RefCell::new(cb)));
507        self
508    }
509
510    /// Called when the page begins a download (see [`DownloadStart`]).
511    pub fn on_download_started(
512        mut self,
513        cb: impl FnMut(DownloadStart, &mut EventContext) + 'static,
514    ) -> Self {
515        self.on_download_started = Some(Rc::new(RefCell::new(cb)));
516        self
517    }
518
519    /// Called when a download finishes or fails (see [`DownloadOutcome`]).
520    pub fn on_download_finished(
521        mut self,
522        cb: impl FnMut(DownloadOutcome, &mut EventContext) + 'static,
523    ) -> Self {
524        self.on_download_finished = Some(Rc::new(RefCell::new(cb)));
525        self
526    }
527
528    /// Per-call style override (highest precedence).
529    pub fn style(mut self, style: impl WebViewStyle) -> Self {
530        self.style_override = Some(Rc::new(style));
531        self
532    }
533
534    /// Enter the page as soon as the frame receives keyboard focus, rather
535    /// than on Enter (the default two-step).
536    ///
537    /// Only appropriate when the web view *is* the window's content and there
538    /// is nothing else in the Tab cycle worth reaching — a kiosk view, a
539    /// full-window document preview. In a mixed UI it makes Tab a one-way
540    /// door: once the engine owns the keyboard, Teksilo sees no more keys and
541    /// getting back out is up to the engine and the OS. Off by default for
542    /// exactly that reason.
543    pub fn enter_page_on_focus(mut self, enter: bool) -> Self {
544        self.enter_page_on_focus = enter;
545        self
546    }
547
548    /// Declare who owns pointer input over the page's rectangle.
549    ///
550    /// [`WebViewInput::Native`] — the default — gives it to the engine;
551    /// [`WebViewInput::Transparent`] keeps it for Teksilo. See
552    /// [`WebViewInput`] for everything the choice decides.
553    pub fn input_mode(mut self, input: WebViewInput) -> Self {
554        self.input = input;
555        self
556    }
557
558    /// Whether the *page* currently holds the engine's keyboard focus.
559    ///
560    /// Written from [`WebViewEvent::EngineFocusChanged`], which is the only
561    /// thing that can know: once the native subview owns the keyboard, the
562    /// toolkit is told nothing more about what happens inside it. Distinct
563    /// from [`focused_signal`](Self::focused_signal), which reports Teksilo's
564    /// own focus on the *frame*.
565    pub fn page_focused_signal(&self) -> Signal<bool> {
566        self.page_focused.clone()
567    }
568
569    /// Hand keyboard focus to the engine subview, entering the page.
570    ///
571    /// The programmatic form of the frame's Enter key. No-op before the engine
572    /// has opened (the handle is created post-mount).
573    pub fn focus_page(&self) {
574        self.with_handle(|h| h.set_focus());
575    }
576
577    /// Whether the Teksilo-side frame currently holds keyboard focus.
578    ///
579    /// True while Tab has landed *on* the web view; it says nothing about
580    /// whether the page has been entered, because once the engine subview
581    /// owns the keyboard the toolkit is no longer told what happens inside it.
582    pub fn focused_signal(&self) -> Signal<bool> {
583        self.focused.clone()
584    }
585
586    /// The stable routing identity of this web view.
587    pub fn id(&self) -> WebViewId {
588        self.web_view_id
589    }
590
591    // --- Imperative controls (call via `ctx.with_widget_mut::<WebView>`) ---
592
593    /// Navigate to `url`.
594    pub fn load_url(&self, url: &str) {
595        self.with_handle(|h| h.load_url(url));
596    }
597    /// Rust → JS: dispatch a `teksilo-message` event carrying `msg`.
598    pub fn post_message(&self, msg: &str) {
599        self.with_handle(|h| h.post_message(msg));
600    }
601    /// Evaluate JavaScript in the page.
602    pub fn eval(&self, script: &str) {
603        self.with_handle(|h| h.eval(script));
604    }
605    /// Reload the page.
606    pub fn reload(&self) {
607        self.with_handle(|h| h.reload());
608    }
609    /// Navigate back.
610    pub fn go_back(&self) {
611        self.with_handle(|h| h.go_back());
612    }
613    /// Navigate forward.
614    pub fn go_forward(&self) {
615        self.with_handle(|h| h.go_forward());
616    }
617    /// Stop the current load.
618    pub fn stop(&self) {
619        self.with_handle(|h| h.stop());
620    }
621    /// Open the engine's developer tools (no-op where unsupported, e.g. Servo).
622    pub fn open_devtools(&self) {
623        self.with_handle(|h| h.open_devtools());
624    }
625    /// Close the engine's developer tools.
626    pub fn close_devtools(&self) {
627        self.with_handle(|h| h.close_devtools());
628    }
629
630    /// The part of `bounds` that survives every `clips_children` ancestor, or
631    /// `None` when nothing does.
632    ///
633    /// Walks the arena rather than reading `PaintContext::clip_bounds` because
634    /// the paint walker skips a subtree it has clipped away entirely — which is
635    /// exactly the case that has to reach the engine.
636    fn visible_rect(&self, bounds: Rect, ctx: &LayoutContext) -> Option<Rect> {
637        let (Some(arena), Some(id)) = (ctx.arena(), self.self_id.get()) else {
638            return Some(bounds);
639        };
640        let mut rect = bounds;
641        let mut cursor = arena.parent(id);
642        while let Some(ancestor) = cursor {
643            if arena.get(ancestor).is_some_and(|node| node.clips_children) {
644                rect = intersect(rect, arena.bounds(ancestor))?;
645            }
646            cursor = arena.parent(ancestor);
647        }
648        Some(rect)
649    }
650
651    fn with_handle(&self, f: impl FnOnce(&dyn WebViewHandle)) {
652        if let Some(h) = self.handle.borrow().as_ref() {
653            f(h.as_ref());
654        }
655    }
656
657    /// Build the JS→Rust / lifecycle event callback handed to the registry.
658    fn make_event_callback(
659        &self,
660        self_id: WidgetId,
661    ) -> impl FnMut(WebViewEvent, &mut EventContext) + 'static {
662        let page_focused = self.page_focused.clone();
663        let url_signal = self.url_signal.clone();
664        let title_signal = self.title_signal.clone();
665        let loading_signal = self.loading_signal.clone();
666        let state_signal = self.state_signal.clone();
667        let nav_guard = self.nav_guard.clone();
668        let on_message = self.on_message.clone();
669        let on_title_changed = self.on_title_changed.clone();
670        let on_navigation = self.on_navigation.clone();
671        let on_page_load = self.on_page_load.clone();
672        let on_download_started = self.on_download_started.clone();
673        let on_download_finished = self.on_download_finished.clone();
674
675        move |event, ctx| match event {
676            WebViewEvent::PageLoadStarted => {
677                if let Some(s) = &loading_signal {
678                    s.set(true);
679                }
680                state_signal.set(WebViewVisualState::Loading);
681                if let Some(cb) = &on_page_load {
682                    (cb.borrow_mut())(PageLoadState::Started, ctx);
683                }
684            }
685            WebViewEvent::PageLoadFinished => {
686                if let Some(s) = &loading_signal {
687                    s.set(false);
688                }
689                state_signal.set(WebViewVisualState::Ready);
690                if let Some(cb) = &on_page_load {
691                    (cb.borrow_mut())(PageLoadState::Finished, ctx);
692                }
693            }
694            WebViewEvent::NavigationStarted { url, can_cancel } => {
695                if let Some(cb) = &on_navigation {
696                    (cb.borrow_mut())(NavigationInfo { url, can_cancel }, ctx);
697                }
698            }
699            WebViewEvent::NavigationFinished { url, success } => {
700                if success {
701                    // Record the engine-resolved URL as the guard BEFORE
702                    // writing the bound signal, so the inbound navigation
703                    // effect (which fires on the `set`) recognises it as the
704                    // engine's own echo and does not re-navigate.
705                    *nav_guard.borrow_mut() = Some(url.clone());
706                    if let Some(s) = &url_signal {
707                        s.set(url);
708                    }
709                    state_signal.set(WebViewVisualState::Ready);
710                } else {
711                    state_signal.set(WebViewVisualState::Error);
712                }
713            }
714            WebViewEvent::TitleChanged(title) => {
715                if let Some(s) = &title_signal {
716                    s.set(title.clone());
717                }
718                if let Some(cb) = &on_title_changed {
719                    (cb.borrow_mut())(title, ctx);
720                }
721            }
722            WebViewEvent::Message(msg) => {
723                if let Some(cb) = &on_message {
724                    (cb.borrow_mut())(msg, ctx);
725                }
726            }
727            WebViewEvent::DownloadStarted {
728                url,
729                suggested_path,
730            } => {
731                if let Some(cb) = &on_download_started {
732                    (cb.borrow_mut())(
733                        DownloadStart {
734                            url,
735                            suggested_path,
736                        },
737                        ctx,
738                    );
739                }
740            }
741            WebViewEvent::DownloadFinished { path, success } => {
742                if let Some(cb) = &on_download_finished {
743                    (cb.borrow_mut())(DownloadOutcome { path, success }, ctx);
744                }
745            }
746            WebViewEvent::ConsoleMessage { .. } => {
747                // Diagnostics only (backend init / unsupported-op reports);
748                // not surfaced to a dedicated app callback today.
749            }
750            WebViewEvent::EngineFocusChanged(has_focus) => {
751                page_focused.set(has_focus);
752                // The OS has moved the keyboard into the page. Teksilo's own
753                // focus must follow, or whatever held it — a text field, with
754                // a blinking caret and an open IME — goes on believing it
755                // still does. Moving it onto the frame is the honest answer:
756                // the frame is the deepest node Teksilo owns, and the page's
757                // own focus ring lives in a tree the toolkit cannot see.
758                //
759                // Guarded, because the two-step entry path (Enter on the frame
760                // → `set_focus`) arrives here with the frame already focused,
761                // and a redundant focus request would re-run the whole focus
762                // machinery on every engine focus event.
763                if has_focus && ctx.focused() != Some(self_id) {
764                    ctx.request_focus(self_id);
765                }
766            }
767        }
768    }
769}
770
771impl Widget for WebView {
772    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
773        let self_id = ctx.self_id();
774
775        // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
776        let style = self
777            .style_override
778            .clone()
779            .or_else(|| ctx.theme().style_slots.web_view.clone())
780            .unwrap_or_else(|| Rc::new(RecipeWebViewStyle));
781
782        // Empty overlay content placeholder (apps install a richer overlay
783        // via a custom WebViewStyle). Keeps the default body self-contained.
784        let content = ctx.add(EmptyOverlayContent);
785        let body = style.make_body(
786            &WebViewStyleConfig {
787                state: self.state_signal.clone(),
788                focused: self.focused.clone(),
789                content,
790            },
791            ctx,
792        );
793        self.root_child_id = Some(body);
794
795        self.self_id.set(Some(self_id));
796
797        // --- Keyboard: put the frame in the Tab cycle, then let Enter in ---
798        //
799        // The page's own focus ring lives in the engine's tree, not ours, so a
800        // web view that is not focusable is simply unreachable without a mouse
801        // (WCAG 2.1.1 / 2.4.3). Making the *frame* focusable is the first half.
802        //
803        // The second half is deliberately a **two-step**: landing on the frame
804        // does not hand the keyboard to the engine, Enter (or Space) does. A
805        // web view has two disjoint focus rings — AccessKit's and the engine's
806        // platform tree — and once the native subview owns the keyboard the
807        // toolkit stops seeing keys entirely, so an automatic hand-off would
808        // turn Tab into a one-way door out of the app's own focus cycle. The
809        // same reasoning the HTML `<iframe>` / canvas-embed pattern arrives at.
810        // Apps whose web view is the whole window can opt into the one-step
811        // form with `enter_page_on_focus(true)`.
812        let focused = self.focused.clone();
813        let focus_handle = self.handle.clone();
814        let enter_on_focus = self.enter_page_on_focus;
815        let mut handlers = teksilo_core::widget_builder::HandlerSet::new()
816            .focusable(true)
817            .on_focus(move |gained, _ctx| {
818                focused.set(gained);
819                if gained
820                    && enter_on_focus
821                    && let Some(h) = focus_handle.borrow().as_ref()
822                {
823                    h.set_focus();
824                }
825            });
826
827        let key_handle = self.handle.clone();
828        handlers = handlers.on_key(move |event, _ctx| {
829            use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
830            // Enter / Space enter the page. Everything else — Tab included —
831            // is declined, so the frame never becomes a trap: focus cycles off
832            // it exactly as it would off any other control.
833            if let WidgetEvent::KeyDown { key, modifiers, .. } = event
834                && matches!(key, Key::Enter | Key::Space)
835                && *modifiers == Modifiers::NONE
836                && let Some(h) = key_handle.borrow().as_ref()
837            {
838                h.set_focus();
839                return EventResponse::Handled;
840            }
841            EventResponse::Ignored
842        });
843
844        // The advertised `Click` needs something behind it: an action a widget
845        // declares but does not execute is worse than one it never declared,
846        // because AT reports the control as operable when it is not.
847        let action_handle = self.handle.clone();
848        handlers = handlers.on_access_action(move |action, _ctx| {
849            use teksilo_core::event::EventResponse;
850            if matches!(
851                action,
852                teksilo_core::accesskit::Action::Click | teksilo_core::accesskit::Action::Focus
853            ) && let Some(h) = action_handle.borrow().as_ref()
854            {
855                h.set_focus();
856                return EventResponse::Handled;
857            }
858            EventResponse::Ignored
859        });
860
861        // --- Who owns the pointer over the page ---
862        //
863        // In `Native` mode the engine does, and the two declarations below say
864        // so to the framework: no default touch behaviour may form on the hit
865        // path (`TouchAction::NONE`), and the painted rectangle is the exact
866        // contract in both directions (`no_hit_slop`). The handler closes the
867        // third gap — a pointer Teksilo *does* see over the page, which is a
868        // pointer it will stop seeing samples for the moment the engine takes
869        // it. Leaving that interaction alive strands whatever it belonged to:
870        // an arbitration waiting for movement that never arrives, a press
871        // record waiting for an Up the OS will deliver to the page instead.
872        //
873        // In `Transparent` mode none of this applies: Teksilo owns the region,
874        // so the node widens and bubbles like any other widget and the engine
875        // is asked to keep its hands off.
876        //
877        // One honest note on `Handled` below: it is the correct statement that
878        // the page consumed the event, but it is **not** what keeps an ancestor
879        // out of the press — the revocation is. An ancestor's own
880        // `on_pointer_event` fires in the *preview* pass, before the target's,
881        // and is unreachable from here; its recognizers are denied by the
882        // cancel. Measured: returning `Ignored` instead leaves every test in
883        // `tests/input_and_clip.rs` green.
884        if self.input.is_native() {
885            use teksilo_core::event::EventResponse;
886            use teksilo_core::pointer::CancelReason;
887            use teksilo_core::pointer::touch_action::TouchAction;
888
889            handlers = handlers
890                .touch_action(TouchAction::NONE)
891                .no_hit_slop()
892                .on_pointer_event(move |event, ctx| {
893                    use teksilo_core::event::WidgetEvent;
894                    match event {
895                        WidgetEvent::PointerDown { .. } | WidgetEvent::PointerUp { .. } => {
896                            // `Deactivated` is the taxonomy's explicit
897                            // catch-all, and it is what this is: the pointer
898                            // was not revoked by the platform, by a peer or by
899                            // a modal — an embedded native surface simply owns
900                            // it from here on. See `docs/web-view.md`.
901                            ctx.cancel_pointer_sequence(CancelReason::Deactivated);
902                            EventResponse::Handled
903                        }
904                        WidgetEvent::PointerMove { .. } => {
905                            if ctx.press_is_inside() {
906                                ctx.cancel_pointer_sequence(CancelReason::Deactivated);
907                            }
908                            EventResponse::Handled
909                        }
910                        // Hover transitions are left to bubble: a hover-owner
911                        // change is how ancestors keep their `hover_within`
912                        // chains honest, and swallowing one buys nothing.
913                        _ => EventResponse::Ignored,
914                    }
915                });
916        }
917
918        ctx.apply_self_handlers(handlers);
919
920        // Capture the window id now — the post-mount EventContext has no
921        // direct window-id accessor, but BuildContext::window() does.
922        self.window_id.set(ctx.window().map(|w| w.id()));
923
924        // --- Visibility bridge: framework activation → engine set_visible ---
925        // The single reason this widget needs the activation signal: a native
926        // subview ignores the wgpu paint pass, so a Switcher parking us
927        // dormant would otherwise leave the engine surface visible. The effect
928        // no-ops until the engine handle exists (opened post-mount below).
929        let vis = ctx.activation_signal(self_id);
930        let effect_handle = self.handle.clone();
931        let effect_visibility = self.visibility.clone();
932        let effect_applied = self.visible_applied.clone();
933        ctx.effect(&vis, move |active| {
934            let mut state = effect_visibility.get();
935            state.active = *active;
936            effect_visibility.set(state);
937            apply_visibility(&effect_handle, &effect_visibility, &effect_applied);
938        });
939
940        // --- Inbound navigation: external `url_signal.set()` → load_url ---
941        // Seed the guard with the signal's current value so the effect's
942        // registration tick (it fires immediately with the current value) is
943        // treated as the baseline and does NOT navigate — the initial page
944        // comes from `attrs.source`, not the binding. Subsequent external
945        // changes that differ from the guard drive a navigation; the engine's
946        // own echo is filtered because `NavigationFinished` updates the guard
947        // before writing the signal.
948        if let Some(url_signal) = self.url_signal.clone() {
949            *self.nav_guard.borrow_mut() = Some(url_signal.get());
950            let nav_guard = self.nav_guard.clone();
951            let nav_handle = self.handle.clone();
952            ctx.effect(&url_signal, move |url| {
953                if nav_guard.borrow().as_deref() == Some(url.as_str()) {
954                    return;
955                }
956                *nav_guard.borrow_mut() = Some(url.clone());
957                if let Some(h) = nav_handle.borrow().as_ref() {
958                    h.load_url(url);
959                }
960            });
961        }
962
963        // --- Open the native engine subview once, AFTER mount ---
964        // Opening is deferred to a post-mount EventContext because that is the
965        // only place a widget can read the OS parent window handle
966        // (`ctx.parent_window_handle()`) together with `app_state` + `poster`
967        // — exactly what a real engine's `build_as_child(parent)` needs.
968        if !self.mount_queued.get() {
969            self.mount_queued.set(true);
970            let web_view_id = self.web_view_id;
971            let window_id = self.window_id.get();
972            let attrs = self.attrs.clone();
973            let handle_slot = self.handle.clone();
974            let bounds_slot = self.last_bounds.clone();
975            let scale_slot = self.scale.clone();
976            let registry_slot = self.registry.clone();
977            let activation = vis;
978            let on_event = self.make_event_callback(self_id);
979            let input = self.input;
980            let visibility = self.visibility.clone();
981            let visible_applied = self.visible_applied.clone();
982
983            ctx.run_after_mount(move |ectx| {
984                // Guard against a double-open if a rebuild ever re-queues.
985                if handle_slot.borrow().is_some() {
986                    return;
987                }
988                let Some(registry) = ectx.app_state::<WebViewRegistry>().cloned() else {
989                    // No engine configured (install_web_view not called) —
990                    // the widget renders just its overlay chrome.
991                    return;
992                };
993                let parent = ectx.parent_window_handle();
994                let poster = ectx.poster().cloned();
995                let wid = window_id.unwrap_or_else(|| TeksiloWindowId::new(0));
996
997                let handle = registry.open(web_view_id, wid, parent, attrs, poster, on_event);
998                // Apply the bounds layout already resolved, then the current
999                // activation state (so a view mounted while its tab is parked
1000                // opens hidden, not visible-then-flashing).
1001                if let Some(b) = bounds_slot.get() {
1002                    handle.set_bounds(b, scale_slot.get());
1003                }
1004                if input == WebViewInput::Transparent {
1005                    handle.set_input_passthrough(true);
1006                }
1007
1008                *handle_slot.borrow_mut() = Some(handle);
1009                *registry_slot.borrow_mut() = Some(registry);
1010                // The engine subview opens visible, so only a hidden target is
1011                // issued: a view mounted while its tab is parked, or already
1012                // scrolled out of its viewport, must be hidden at birth rather
1013                // than flashing once. `visible_applied` starts `true` for
1014                // exactly that reason, so an ordinary active open issues
1015                // nothing at all.
1016                let mut state = visibility.get();
1017                state.active = activation.get();
1018                visibility.set(state);
1019                apply_visibility(&handle_slot, &visibility, &visible_applied);
1020            });
1021        }
1022
1023        self.children()
1024    }
1025
1026    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1027        self.root_child_id
1028            .and_then(|id| ctx.child_size(id, proposal))
1029            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1030            .into()
1031    }
1032
1033    fn place_children(
1034        &self,
1035        bounds: Rect,
1036        _proposal: SizeProposal,
1037        children: &mut [WidgetPlacement],
1038        ctx: &LayoutContext,
1039    ) {
1040        for child in children.iter_mut() {
1041            child.origin = bounds.origin();
1042            child.size = bounds.size();
1043        }
1044        // Mirror the new bounds onto the native subview. The bounds are logical;
1045        // `ctx.scale_factor` is the host window's HiDPI device scale (a scale
1046        // change triggers a relayout, so this runs then too). The backend uses
1047        // both: engines that position in device pixels (WebKitGTK on X11) need
1048        // logical × scale. Store the scale so the post-mount open path can apply
1049        // the first bounds at the right scale.
1050        let scale = ctx.scale_factor;
1051        let scale_changed = (self.scale.get() - scale).abs() > f32::EPSILON;
1052        if scale_changed {
1053            self.scale.set(scale);
1054        }
1055
1056        // The rectangle the engine may occupy is not this widget's bounds — it
1057        // is what survives every clipping ancestor. A subview is parented to
1058        // the top-level window, so nothing clips it for us: a `WebView` inside
1059        // a scrolled `ScrollArea` would otherwise keep the page painted over
1060        // whatever sits outside the viewport, at full size, for as long as it
1061        // stayed mounted.
1062        //
1063        // Mirroring the *intersection* is the only geometric channel there is
1064        // (`set_bounds` positions and sizes; no engine here exposes a clip
1065        // region), so a partially-clipped page is laid out to the visible strip
1066        // rather than cropped to it, and one clipped away entirely is hidden.
1067        let visible = self.visible_rect(bounds, ctx);
1068        let mut state = self.visibility.get();
1069        state.in_view = visible.is_some();
1070        self.visibility.set(state);
1071
1072        if let Some(rect) = visible
1073            && (self.last_bounds.get() != Some(rect) || scale_changed)
1074        {
1075            self.last_bounds.set(Some(rect));
1076            self.with_handle(|h| h.set_bounds(rect, scale));
1077        }
1078        apply_visibility(&self.handle, &self.visibility, &self.visible_applied);
1079    }
1080
1081    fn wants_after_paint(&self) -> bool {
1082        true
1083    }
1084
1085    fn after_paint(&self, view: &WidgetTreeView<'_>, _ctx: &PaintContext) {
1086        // An interactive overlay — a menu, a popover, a modal dialog — renders
1087        // in the wgpu pass, i.e. *under* the engine subview, and the OS routes
1088        // a press over that region to the engine, not to the overlay. Standing
1089        // the subview down while one covers the page is what makes such an
1090        // overlay both visible and operable; nothing else in the toolkit can
1091        // reach over a native child.
1092        //
1093        // This is the one thing that cannot be decided in `place_children`:
1094        // overlays are positioned *after* the main tree is laid out, so a
1095        // layout pass reads the bounds an overlay had before it opened, and
1096        // nothing marks the tree dirty again once they are known. The paint
1097        // walk runs after both and is handed the frame's own rects.
1098        let Some(id) = self.self_id.get() else {
1099            return;
1100        };
1101        let bounds = view.bounds(id);
1102        let covered = view
1103            .overlay_rects()
1104            .iter()
1105            .any(|r| intersect(*r, bounds).is_some());
1106        let uncovered = !covered;
1107        let mut state = self.visibility.get();
1108        if state.uncovered == uncovered {
1109            return;
1110        }
1111        state.uncovered = uncovered;
1112        self.visibility.set(state);
1113        apply_visibility(&self.handle, &self.visibility, &self.visible_applied);
1114    }
1115
1116    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1117        // A single Teksilo-side node. The page's own AT tree is published by
1118        // the engine to the OS directly, so we don't duplicate it; our
1119        // descendants are just the presentational overlay (already hidden).
1120        builder.set_role(Role::WebView);
1121        if let Some(title) = &self.title_signal {
1122            builder.set_name(title.get());
1123        }
1124        // The frame is reachable by Tab and *enterable* by Enter. Both have to
1125        // be advertised: `Focus` so an AT client can put the toolkit's focus
1126        // here, `Click` so "activate" from a screen reader means the same as
1127        // pressing Enter — hand the keyboard to the engine. The `on_key` /
1128        // `on_access_action` paths both end at `WebViewHandle::set_focus`.
1129        builder.add_action(teksilo_core::accesskit::Action::Focus);
1130        builder.add_action(teksilo_core::accesskit::Action::Click);
1131        if !self.enter_page_on_focus {
1132            builder.set_keyboard_shortcut("Enter");
1133        }
1134    }
1135
1136    fn children(&self) -> Vec<WidgetId> {
1137        self.root_child_id.into_iter().collect()
1138    }
1139
1140    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1141        Some(self)
1142    }
1143}
1144
1145impl Drop for WebView {
1146    fn drop(&mut self) {
1147        // Unregister the event callback so a late backend event can't route
1148        // into freed widget state. The engine handle tears down via its own
1149        // Drop when `self.handle`'s last Rc clone goes — this, the focus / key
1150        // / access-action handlers held by the arena node, the activation and
1151        // navigation effects, and the post-mount open action.
1152        if let Some(registry) = self.registry.borrow().as_ref() {
1153            registry.unregister(self.web_view_id);
1154        }
1155    }
1156}
1157
1158/// Zero-size, zero-paint overlay content placeholder. Fills the proposed
1159/// bounds so the overlay container has a child to size against.
1160#[derive(Debug)]
1161struct EmptyOverlayContent;
1162
1163impl Widget for EmptyOverlayContent {
1164    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
1165        proposal.resolve(0.0, 0.0).into()
1166    }
1167
1168    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1169        builder.set_hidden();
1170    }
1171}
1172
1173/// The overlapping part of two rectangles, or `None` when they do not overlap.
1174///
1175/// Zero-area contact counts as no overlap: a page scrolled exactly to its
1176/// viewport's edge is not visible, and an overlay whose edge merely touches the
1177/// page's is not standing over it.
1178fn intersect(a: Rect, b: Rect) -> Option<Rect> {
1179    let x = a.x.max(b.x);
1180    let y = a.y.max(b.y);
1181    let right = a.right().min(b.right());
1182    let bottom = a.bottom().min(b.bottom());
1183    if right > x && bottom > y {
1184        Some(Rect::new(x, y, right - x, bottom - y))
1185    } else {
1186        None
1187    }
1188}
1189
1190/// Resolve the three reasons a subview may be hidden into one `set_visible`,
1191/// and issue it only when the answer changed.
1192///
1193/// A no-op before the engine opens: the post-mount open path applies the
1194/// resolved value once the handle exists, so a view whose tab was already
1195/// parked (or whose viewport had already scrolled past it) opens hidden instead
1196/// of flashing.
1197fn apply_visibility(
1198    handle: &SharedHandle,
1199    visibility: &Rc<Cell<EngineVisibility>>,
1200    applied: &Rc<Cell<bool>>,
1201) {
1202    let want = visibility.get().resolved();
1203    if applied.get() == want {
1204        return;
1205    }
1206    if let Some(h) = handle.borrow().as_ref() {
1207        h.set_visible(want);
1208        applied.set(want);
1209    }
1210}