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}