Skip to main content

guise/
webview.rs

1//! `WebView` — a native web view embedded in a gpui window (stateful entity).
2//!
3//! Backed by [`wry`](https://crates.io/crates/wry), which parents a real OS
4//! web view (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) as a
5//! child of the gpui window. The native view is positioned every frame to track
6//! the bounds of this component, so it composes inside normal `guise` layout.
7//!
8//! Create with `cx.new(|cx| WebView::new(cx).url("https://example.com"))` and
9//! subscribe for [`WebViewEvent`]s. Because the underlying view owns OS
10//! resources, it is built lazily on first render (when a window handle exists).
11//!
12//! The native backend lives behind the default-on `webview` feature. Disable it
13//! (`default-features = false`) for headless or docs-only builds; the component
14//! then renders a themed placeholder while keeping the same public API.
15
16use gpui::prelude::*;
17use gpui::{div, px, Context, EventEmitter, FocusHandle, IntoElement, SharedString, Window};
18
19use crate::theme::{theme, Size};
20
21#[cfg(feature = "webview")]
22use {
23    gpui::{canvas, Bounds, Pixels},
24    std::{cell::RefCell, rc::Rc, time::Duration},
25    wry::{
26        dpi::{LogicalPosition, LogicalSize},
27        PageLoadEvent, Rect, WebViewBuilder,
28    },
29};
30
31/// Emitted as the embedded page loads and changes.
32#[derive(Debug, Clone)]
33pub enum WebViewEvent {
34    /// The document title changed. Carries the new title.
35    TitleChanged(SharedString),
36    /// The view navigated to a new URL. Carries the destination.
37    UrlChanged(SharedString),
38    /// A page began loading.
39    LoadStarted,
40    /// A page finished loading.
41    LoadFinished,
42}
43
44/// What the view should display.
45#[derive(Clone)]
46enum Source {
47    /// Nothing requested yet.
48    Empty,
49    /// Load a remote or local URL.
50    Url(SharedString),
51    /// Load an inline HTML string.
52    Html(SharedString),
53}
54
55/// A native web view. Create with `cx.new(|cx| WebView::new(cx))`.
56pub struct WebView {
57    source: Source,
58    focus: FocusHandle,
59    radius: Option<Size>,
60    bordered: bool,
61    transparent: bool,
62    width: Option<f32>,
63    height: Option<f32>,
64
65    #[cfg(feature = "webview")]
66    inner: Option<Rc<wry::WebView>>,
67    #[cfg(feature = "webview")]
68    queue: Rc<RefCell<Vec<WebViewEvent>>>,
69    #[cfg(feature = "webview")]
70    draining: bool,
71}
72
73impl EventEmitter<WebViewEvent> for WebView {}
74
75impl WebView {
76    pub fn new(cx: &mut Context<Self>) -> Self {
77        WebView {
78            source: Source::Empty,
79            focus: cx.focus_handle(),
80            radius: None,
81            bordered: true,
82            transparent: false,
83            width: None,
84            height: None,
85
86            #[cfg(feature = "webview")]
87            inner: None,
88            #[cfg(feature = "webview")]
89            queue: Rc::new(RefCell::new(Vec::new())),
90            #[cfg(feature = "webview")]
91            draining: false,
92        }
93    }
94
95    /// Load a URL (`https://…`, `file://…`, etc.).
96    pub fn url(mut self, url: impl Into<SharedString>) -> Self {
97        self.source = Source::Url(url.into());
98        self
99    }
100
101    /// Load an inline HTML document.
102    pub fn html(mut self, html: impl Into<SharedString>) -> Self {
103        self.source = Source::Html(html.into());
104        self
105    }
106
107    /// Override the corner radius (defaults to the theme radius).
108    pub fn radius(mut self, radius: Size) -> Self {
109        self.radius = Some(radius);
110        self
111    }
112
113    /// Draw a border + rounded frame around the view (default `true`).
114    pub fn bordered(mut self, bordered: bool) -> Self {
115        self.bordered = bordered;
116        self
117    }
118
119    /// Let the page background show through (default `false`).
120    pub fn transparent(mut self, transparent: bool) -> Self {
121        self.transparent = transparent;
122        self
123    }
124
125    /// Fix the width in pixels. Defaults to filling the parent.
126    pub fn width(mut self, width: f32) -> Self {
127        self.width = Some(width);
128        self
129    }
130
131    /// Fix the height in pixels. Defaults to filling the parent.
132    pub fn height(mut self, height: f32) -> Self {
133        self.height = Some(height);
134        self
135    }
136
137    /// Navigate the live view to `url`, updating the stored source.
138    pub fn load_url(&mut self, url: impl Into<SharedString>, cx: &mut Context<Self>) {
139        let url = url.into();
140        #[cfg(feature = "webview")]
141        if let Some(inner) = &self.inner {
142            let _ = inner.load_url(&url);
143        }
144        self.source = Source::Url(url);
145        cx.notify();
146    }
147
148    /// Replace the live view with inline HTML, updating the stored source.
149    pub fn load_html(&mut self, html: impl Into<SharedString>, cx: &mut Context<Self>) {
150        let html = html.into();
151        #[cfg(feature = "webview")]
152        if let Some(inner) = &self.inner {
153            let _ = inner.load_html(&html);
154        }
155        self.source = Source::Html(html);
156        cx.notify();
157    }
158
159    /// Run JavaScript in the live view. No-op until the view exists.
160    pub fn evaluate_script(&self, _js: &str) {
161        #[cfg(feature = "webview")]
162        if let Some(inner) = &self.inner {
163            let _ = inner.evaluate_script(_js);
164        }
165    }
166
167    /// Build the native view once a window handle is available, then start the
168    /// loop that drains events from the wry handlers back onto the entity.
169    #[cfg(feature = "webview")]
170    fn ensure_view(&mut self, window: &mut Window, cx: &mut Context<Self>, bounds: Bounds<Pixels>) {
171        if self.inner.is_some() {
172            return;
173        }
174
175        let queue = self.queue.clone();
176        let (q_title, q_nav, q_load) = (queue.clone(), queue.clone(), queue.clone());
177
178        let mut builder = WebViewBuilder::new()
179            .with_bounds(rect_from(bounds))
180            .with_transparent(self.transparent)
181            .with_document_title_changed_handler(move |title| {
182                q_title
183                    .borrow_mut()
184                    .push(WebViewEvent::TitleChanged(title.into()));
185            })
186            .with_navigation_handler(move |url| {
187                q_nav
188                    .borrow_mut()
189                    .push(WebViewEvent::UrlChanged(url.into()));
190                true
191            })
192            .with_on_page_load_handler(move |event, _url| {
193                q_load.borrow_mut().push(match event {
194                    PageLoadEvent::Started => WebViewEvent::LoadStarted,
195                    PageLoadEvent::Finished => WebViewEvent::LoadFinished,
196                });
197            });
198
199        builder = match &self.source {
200            Source::Url(url) => builder.with_url(url.as_ref()),
201            Source::Html(html) => builder.with_html(html.as_ref()),
202            Source::Empty => builder,
203        };
204
205        match builder.build_as_child(&*window) {
206            Ok(view) => self.inner = Some(Rc::new(view)),
207            Err(err) => {
208                eprintln!("guise: failed to create webview: {err}");
209                return;
210            }
211        }
212
213        if !self.draining {
214            self.draining = true;
215            cx.spawn(async move |this, cx| loop {
216                cx.background_executor()
217                    .timer(Duration::from_millis(40))
218                    .await;
219                let drained: Vec<WebViewEvent> = queue.borrow_mut().drain(..).collect();
220                let pushed = this.update(cx, |_this, cx| {
221                    let any = !drained.is_empty();
222                    for event in drained {
223                        cx.emit(event);
224                    }
225                    if any {
226                        cx.notify();
227                    }
228                });
229                if pushed.is_err() {
230                    break;
231                }
232            })
233            .detach();
234        }
235    }
236}
237
238#[cfg(feature = "webview")]
239fn rect_from(bounds: Bounds<Pixels>) -> Rect {
240    Rect {
241        position: LogicalPosition::new(bounds.origin.x.to_f64(), bounds.origin.y.to_f64()).into(),
242        size: LogicalSize::new(bounds.size.width.to_f64(), bounds.size.height.to_f64()).into(),
243    }
244}
245
246impl Render for WebView {
247    #[cfg(feature = "webview")]
248    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
249        // Build the native view on the first frame that has a window handle.
250        // It is created at a best-guess size; the `canvas` paint below snaps it
251        // to the real layout bounds on this same frame.
252        if self.inner.is_none() {
253            let w = self.width.unwrap_or(800.0);
254            let h = self.height.unwrap_or(600.0);
255            let initial = Bounds {
256                origin: gpui::point(px(0.0), px(0.0)),
257                size: gpui::size(px(w), px(h)),
258            };
259            self.ensure_view(window, cx, initial);
260        }
261
262        let t = theme(cx);
263        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
264        let border = t.border().hsla();
265        let bg = t.surface().hsla();
266
267        // Sized region the native view tracks. `canvas` hands us the painted
268        // bounds in window coordinates each frame; we forward them to wry.
269        let view = self.inner.clone();
270        let surface = canvas(
271            move |_bounds, _window, _app| {},
272            move |bounds, _state, _window, _app| {
273                if let Some(view) = &view {
274                    let _ = view.set_bounds(rect_from(bounds));
275                }
276            },
277        )
278        .size_full();
279
280        frame(self.bordered, radius, border, bg, self.width, self.height)
281            .track_focus(&self.focus)
282            .child(surface)
283    }
284
285    #[cfg(not(feature = "webview"))]
286    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
287        let t = theme(cx);
288        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
289        let border = t.border().hsla();
290        let bg = t.surface().hsla();
291        let dimmed = t.dimmed().hsla();
292        let label = match &self.source {
293            Source::Url(url) => url.clone(),
294            Source::Html(html) => SharedString::from(format!("inline HTML ({} bytes)", html.len())),
295            Source::Empty => SharedString::from("no source"),
296        };
297
298        frame(self.bordered, radius, border, bg, self.width, self.height)
299            .track_focus(&self.focus)
300            .items_center()
301            .justify_center()
302            .text_color(dimmed)
303            .child(SharedString::from(format!("WebView (disabled): {label}")))
304    }
305}
306
307/// The themed container shared by both render paths.
308fn frame(
309    bordered: bool,
310    radius: f32,
311    border: gpui::Hsla,
312    bg: gpui::Hsla,
313    width: Option<f32>,
314    height: Option<f32>,
315) -> gpui::Stateful<gpui::Div> {
316    let mut root = div().id("guise-webview").flex().overflow_hidden().bg(bg);
317    root = match width {
318        Some(w) => root.w(px(w)),
319        None => root.w_full(),
320    };
321    root = match height {
322        Some(h) => root.h(px(h)),
323        None => root.h_full(),
324    };
325    if bordered {
326        root = root.border_1().border_color(border).rounded(px(radius));
327    }
328    root
329}