Skip to main content

gpui_webview/
lib.rs

1extern crate gpui_kit as gpui;
2
3#[cfg(all(feature = "gpui-fast", target_os = "macos"))]
4mod composition;
5#[cfg(target_os = "linux")]
6mod linux;
7#[cfg(target_os = "macos")]
8mod macos;
9
10#[cfg(all(feature = "gpui-fast", target_os = "macos"))]
11use composition::NativeWebViewSurface;
12#[cfg(all(feature = "gpui-fast", target_os = "linux"))]
13use linux::NativeWebViewSurface;
14
15use std::{cell::Cell, ops::Deref, rc::Rc};
16
17use wry::Rect;
18
19use gpui::{
20    App, Bounds, ContentMask, DismissEvent, Element, ElementId, Entity, EventEmitter, FocusHandle,
21    Focusable, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, MouseDownEvent,
22    ParentElement as _, Pixels, Render, Size, Style, Styled as _, Window, canvas, div,
23};
24
25// `gpui-kit` selects the GPUI backend; this crate's `gpui-fast` feature only follows it, so
26// the two must agree. A mismatch would otherwise keep the gpui-pre attachment on GPUI Fast.
27const _: () = {
28    assert!(
29        !gpui::GPUI_FAST || cfg!(feature = "gpui-fast"),
30        "gpui-kit is built with `gpui-fast`, but gpui-webview is not: enable the \
31         `gpui-webview/gpui-fast` feature so the WebView uses GPUI Fast's window composition"
32    );
33    assert!(
34        gpui::GPUI_FAST || !cfg!(feature = "gpui-fast"),
35        "gpui-webview is built with `gpui-fast`, but gpui-kit is not: enable the \
36         `gpui-kit/gpui-fast` feature, which selects the GPUI backend"
37    );
38};
39
40/// An owned, UI-thread-local handle to the raw wry webview.
41///
42/// Cloning this handle prolongs the native webview's lifetime. Dropping the owning [`WebView`]
43/// entity hides the child view, but final native destruction waits until all handle and frame
44/// clones are dropped. All handles must be dropped before the parent window is destroyed.
45#[derive(Clone)]
46pub struct WebViewHandle(Rc<wry::WebView>);
47
48impl WebViewHandle {
49    /// Get the raw wry webview.
50    pub fn raw(&self) -> &wry::WebView {
51        &self.0
52    }
53}
54
55/// A webview based on wry WebView.
56///
57/// With the `gpui-fast` feature, macOS and Linux host the native view in a window
58/// composition surface, so deferred GPUI overlays render above the page. Without it, the
59/// native view covers GPUI content in its bounds and extends past a container that clips it.
60///
61/// [experimental]
62pub struct WebView {
63    focus_handle: FocusHandle,
64    webview: Rc<wry::WebView>,
65    visible: Cell<bool>,
66    bounds: Bounds<Pixels>,
67    /// The page zoom the script asked for, before any display scale compensation.
68    zoom: Cell<f64>,
69    /// Whether the last geometry update failed, so each failure is logged once until it recovers.
70    geometry_failed: Cell<bool>,
71    #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
72    composition: Option<NativeWebViewSurface>,
73    /// The GPUI scale factor the page zoom was last matched to.
74    #[cfg(target_os = "linux")]
75    scale_factor: Cell<f32>,
76    /// The window-relative origin that page clicks are forwarded to GPUI from.
77    #[cfg(target_os = "linux")]
78    origin: Rc<Cell<gpui::Point<Pixels>>>,
79}
80
81impl Drop for WebView {
82    fn drop(&mut self) {
83        self.hide();
84        #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
85        if let Some(surface) = self.composition.take() {
86            surface.release(self.webview.clone());
87        }
88    }
89}
90
91impl WebView {
92    /// Create a new WebView from a wry WebView.
93    pub fn new(webview: wry::WebView, window: &mut Window, cx: &mut App) -> Self {
94        let _ = webview.set_bounds(Rect::default());
95
96        #[cfg(all(feature = "gpui-fast", target_os = "macos"))]
97        let composition = match NativeWebViewSurface::new(&webview, window, cx) {
98            Ok(surface) => Some(surface),
99            Err(error) => {
100                log::warn!("WebView composition unavailable: {error:#}");
101                None
102            }
103        };
104        #[cfg(target_os = "linux")]
105        let origin = Rc::new(Cell::new(gpui::Point::default()));
106        #[cfg(target_os = "linux")]
107        linux::forward_mouse_down(&webview, origin.clone(), window, cx);
108        #[cfg(target_os = "linux")]
109        linux::match_scale_factor(&webview, window.scale_factor(), 1.0);
110        #[cfg(not(any(all(feature = "gpui-fast", target_os = "macos"), target_os = "linux")))]
111        let _ = window;
112
113        Self {
114            focus_handle: cx.focus_handle(),
115            visible: Cell::new(true),
116            bounds: Bounds::default(),
117            zoom: Cell::new(1.0),
118            geometry_failed: Cell::new(false),
119            webview: Rc::new(webview),
120            #[cfg(all(feature = "gpui-fast", target_os = "macos"))]
121            composition,
122            #[cfg(all(feature = "gpui-fast", target_os = "linux"))]
123            composition: None,
124            #[cfg(target_os = "linux")]
125            scale_factor: Cell::new(window.scale_factor()),
126            #[cfg(target_os = "linux")]
127            origin,
128        }
129    }
130
131    /// Build a webview as a child of `window`, performing the platform setup Wry requires.
132    ///
133    /// On Linux the window must run on X11 or XWayland: start the application with
134    /// `gpui_kit::platform::linux(WindowingModes::X11)`. A Wayland window returns an error.
135    /// This also initializes GTK and drives its main loop from GPUI. With the `gpui-fast` feature,
136    /// the webview is built in a window composition surface so GPUI overlays can render above it.
137    pub fn build(
138        builder: wry::WebViewBuilder,
139        window: &mut Window,
140        cx: &mut App,
141    ) -> wry::Result<Self> {
142        #[cfg(target_os = "linux")]
143        let webview = {
144            let parent = linux::X11Parent::new(window)?;
145            linux::ensure_gtk(cx)?;
146
147            #[cfg(feature = "gpui-fast")]
148            match NativeWebViewSurface::new(window, cx) {
149                Ok(surface) => {
150                    let webview = builder.build_as_child(&surface.parent()?)?;
151                    let mut this = Self::new(webview, window, cx);
152                    this.composition = Some(surface);
153                    return Ok(this);
154                }
155                Err(error) => log::warn!("WebView composition unavailable: {error:#}"),
156            }
157
158            builder.build_as_child(&parent)?
159        };
160        #[cfg(not(target_os = "linux"))]
161        let webview = builder.build_as_child(&*window)?;
162
163        Ok(Self::new(webview, window, cx))
164    }
165
166    /// Set window-relative bounds, including offscreen loading bounds.
167    /// With window composition, position the managed container and its child together.
168    pub fn set_bounds(&self, bounds: Rect) -> wry::Result<()> {
169        #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
170        if let Some(surface) = &self.composition {
171            return surface
172                .set_bounds(&self.webview, bounds)
173                .map_err(|error| wry::Error::Io(std::io::Error::other(error)));
174        }
175        self.webview.set_bounds(bounds)
176    }
177
178    /// Apply full document bounds and an inherited clip without changing the DOM viewport.
179    /// Empty clips hide the native surface while preserving the loaded document.
180    /// Partial clipping requires native window composition; without it, the native view
181    /// takes the full document bounds and extends past the clip.
182    pub fn set_geometry(
183        &self,
184        document: Bounds<Pixels>,
185        clip: Bounds<Pixels>,
186    ) -> anyhow::Result<()> {
187        let visible = document.intersect(&clip);
188        if visible.is_empty() || !self.visible.get() {
189            self.set_native_visible(false)?;
190            #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
191            if let Some(surface) = &self.composition {
192                // Hidden preparation still gives WebKit its real layout width.
193                if self.visible.get() && !document.is_empty() {
194                    surface.set_geometry(&self.webview, document, document)?;
195                }
196            }
197            return Ok(());
198        }
199        #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
200        if let Some(surface) = &self.composition {
201            surface.set_geometry(&self.webview, document, visible)?;
202            surface.set_visible(true)?;
203            self.webview.set_visible(true)?;
204            return Ok(());
205        }
206        #[cfg(target_os = "linux")]
207        let rect = linux::device_bounds(document, self.scale_factor.get());
208        #[cfg(not(target_os = "linux"))]
209        let rect = Rect {
210            position: wry::dpi::LogicalPosition::new(
211                f64::from(document.origin.x),
212                f64::from(document.origin.y),
213            )
214            .into(),
215            size: wry::dpi::LogicalSize::new(
216                f64::from(document.size.width),
217                f64::from(document.size.height),
218            )
219            .into(),
220        };
221        self.set_bounds(rect)?;
222        self.set_native_visible(true)?;
223        Ok(())
224    }
225
226    // Geometry can temporarily hide the native view without changing the caller's
227    // visibility preference, so prepaint can retry when the view becomes unclipped.
228    fn set_native_visible(&self, visible: bool) -> wry::Result<()> {
229        self.webview.set_visible(visible)?;
230        #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
231        if let Some(surface) = &self.composition {
232            surface
233                .set_visible(visible)
234                .map_err(|error| wry::Error::Io(std::io::Error::other(error)))?;
235        }
236        Ok(())
237    }
238
239    /// Set visibility and report native errors without discarding the loaded page.
240    pub fn set_visible(&self, visible: bool) -> wry::Result<()> {
241        // A focus error must not leave a closing native view covering the window.
242        let focus_result = if !visible && self.visible.get() {
243            self.webview.focus_parent()
244        } else {
245            Ok(())
246        };
247        self.set_native_visible(visible)?;
248        self.visible.set(visible);
249        focus_result
250    }
251
252    /// Show the webview.
253    pub fn show(&mut self) {
254        let _ = self.set_visible(true);
255    }
256
257    /// Hide the webview.
258    pub fn hide(&mut self) {
259        let _ = self.set_visible(false);
260    }
261
262    /// Get whether the webview is visible.
263    pub fn visible(&self) -> bool {
264        self.visible.get()
265    }
266
267    /// Get the current bounds of the webview.
268    pub fn bounds(&self) -> Bounds<Pixels> {
269        self.bounds
270    }
271
272    /// Go back in the webview history.
273    pub fn back(&mut self) -> anyhow::Result<()> {
274        Ok(self.webview.evaluate_script("history.back();")?)
275    }
276
277    /// Go forward in the webview history.
278    pub fn forward(&mut self) -> anyhow::Result<()> {
279        Ok(self.webview.evaluate_script("history.forward();")?)
280    }
281
282    /// Load a URL in the webview.
283    pub fn load_url(&mut self, url: &str) {
284        let _ = self.webview.load_url(url);
285    }
286
287    /// Set the colour scheme the page sees in `prefers-color-scheme` and draws its
288    /// native controls in. `Theme::Auto` follows the system again.
289    ///
290    /// On Linux this is GTK's process-wide `gtk-application-prefer-dark-theme`, so it
291    /// applies to every webview in the application.
292    pub fn set_theme(&self, theme: wry::Theme) -> wry::Result<()> {
293        #[cfg(target_os = "linux")]
294        linux::set_theme(theme);
295        #[cfg(target_os = "macos")]
296        macos::set_theme(&self.webview, theme);
297        #[cfg(target_os = "windows")]
298        wry::WebViewExtWindows::set_theme(&*self.webview, theme)?;
299        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
300        let _ = theme;
301        Ok(())
302    }
303
304    /// Zoom the page, where `1.0` is its natural size. The engine scales the page
305    /// itself, without the relayout and repaint a CSS `zoom` causes, and keeps the
306    /// factor across navigations.
307    pub fn set_zoom(&self, zoom: f64) -> wry::Result<()> {
308        self.zoom.set(zoom);
309        // WebKitGTK's zoom also makes up for the gap between GPUI's scale factor and
310        // GTK's integer one, so the page zoom is applied on top of it.
311        #[cfg(target_os = "linux")]
312        linux::match_scale_factor(&self.webview, self.scale_factor.get(), zoom);
313        #[cfg(not(target_os = "linux"))]
314        self.webview.zoom(zoom)?;
315        Ok(())
316    }
317
318    /// The page zoom last set with [`WebView::set_zoom`].
319    pub fn zoom(&self) -> f64 {
320        self.zoom.get()
321    }
322
323    /// Get the raw wry webview.
324    pub fn raw(&self) -> &wry::WebView {
325        &self.webview
326    }
327
328    /// Get an owned, UI-thread-local handle to the raw wry webview.
329    pub fn handle(&self) -> WebViewHandle {
330        WebViewHandle(self.webview.clone())
331    }
332}
333
334impl Deref for WebView {
335    type Target = wry::WebView;
336
337    fn deref(&self) -> &Self::Target {
338        &self.webview
339    }
340}
341
342impl Focusable for WebView {
343    fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
344        self.focus_handle.clone()
345    }
346}
347
348impl EventEmitter<DismissEvent> for WebView {}
349
350impl Render for WebView {
351    fn render(
352        &mut self,
353        window: &mut gpui::Window,
354        cx: &mut gpui::Context<Self>,
355    ) -> impl IntoElement {
356        let view = cx.entity().clone();
357
358        div()
359            .track_focus(&self.focus_handle)
360            .size_full()
361            .child({
362                let view = cx.entity().clone();
363                canvas(
364                    move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
365                    |_, _, _, _| {},
366                )
367                .absolute()
368                .size_full()
369            })
370            .child(WebViewElement::new(self.webview.clone(), view, window, cx))
371    }
372}
373
374/// A webview element can display a wry webview.
375pub struct WebViewElement {
376    parent: Entity<WebView>,
377    view: Rc<wry::WebView>,
378}
379
380impl WebViewElement {
381    /// Create a new webview element from a wry WebView.
382    pub fn new(
383        view: Rc<wry::WebView>,
384        parent: Entity<WebView>,
385        _window: &mut Window,
386        _cx: &mut App,
387    ) -> Self {
388        Self { view, parent }
389    }
390}
391
392impl IntoElement for WebViewElement {
393    type Element = WebViewElement;
394
395    fn into_element(self) -> Self::Element {
396        self
397    }
398}
399
400impl Element for WebViewElement {
401    type RequestLayoutState = ();
402    type PrepaintState = Option<Hitbox>;
403
404    fn id(&self) -> Option<ElementId> {
405        None
406    }
407
408    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
409        None
410    }
411
412    fn request_layout(
413        &mut self,
414        _: Option<&GlobalElementId>,
415        _: Option<&gpui::InspectorElementId>,
416        window: &mut Window,
417        cx: &mut App,
418    ) -> (LayoutId, Self::RequestLayoutState) {
419        let style = Style {
420            size: Size::full(),
421            flex_shrink: 1.,
422            ..Default::default()
423        };
424
425        // If the parent view is no longer visible, we don't need to layout the webview
426        let id = window.request_layout(style, [], cx);
427        (id, ())
428    }
429
430    fn prepaint(
431        &mut self,
432        _: Option<&GlobalElementId>,
433        _: Option<&gpui::InspectorElementId>,
434        bounds: Bounds<Pixels>,
435        _: &mut Self::RequestLayoutState,
436        window: &mut Window,
437        cx: &mut App,
438    ) -> Self::PrepaintState {
439        if !self.parent.read(cx).visible() {
440            return None;
441        }
442
443        let parent = self.parent.read(cx);
444        #[cfg(all(feature = "gpui-fast", any(target_os = "macos", target_os = "linux")))]
445        if let Some(surface) = &parent.composition {
446            surface.set_scale_factor(window.scale_factor());
447        }
448        #[cfg(target_os = "linux")]
449        if parent.scale_factor.replace(window.scale_factor()) != window.scale_factor() {
450            linux::match_scale_factor(&parent.webview, window.scale_factor(), parent.zoom.get());
451        }
452        #[cfg(target_os = "linux")]
453        parent.origin.set(bounds.origin);
454        let clip = window.content_mask().bounds;
455        if let Err(error) = parent.set_geometry(bounds, clip) {
456            if !parent.geometry_failed.replace(true) {
457                log::warn!("WebView geometry update failed: {error:#}");
458            }
459            let _ = parent.set_native_visible(false);
460            return None;
461        }
462        parent.geometry_failed.set(false);
463        let visible = bounds.intersect(&clip);
464        if visible.is_empty() {
465            return None;
466        }
467
468        // The native view covers this area, so GPUI content below it must not receive input.
469        Some(window.insert_hitbox(visible, gpui::HitboxBehavior::BlockMouse))
470    }
471
472    fn paint(
473        &mut self,
474        _: Option<&GlobalElementId>,
475        _: Option<&gpui::InspectorElementId>,
476        bounds: Bounds<Pixels>,
477        _: &mut Self::RequestLayoutState,
478        hitbox: &mut Self::PrepaintState,
479        window: &mut Window,
480        _: &mut App,
481    ) {
482        let bounds = hitbox.clone().map(|h| h.bounds).unwrap_or(bounds);
483        window.with_content_mask(Some(ContentMask { bounds }), |window| {
484            let webview = self.view.clone();
485            window.on_mouse_event(move |event: &MouseDownEvent, _, _, _| {
486                if !bounds.contains(&event.position) {
487                    // Click white space to blur the input focus
488                    let _ = webview.focus_parent();
489                }
490            });
491        });
492    }
493}