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.borrow_mut().push(WebViewEvent::UrlChanged(url.into()));
188                true
189            })
190            .with_on_page_load_handler(move |event, _url| {
191                q_load.borrow_mut().push(match event {
192                    PageLoadEvent::Started => WebViewEvent::LoadStarted,
193                    PageLoadEvent::Finished => WebViewEvent::LoadFinished,
194                });
195            });
196
197        builder = match &self.source {
198            Source::Url(url) => builder.with_url(url.as_ref()),
199            Source::Html(html) => builder.with_html(html.as_ref()),
200            Source::Empty => builder,
201        };
202
203        match builder.build_as_child(&*window) {
204            Ok(view) => self.inner = Some(Rc::new(view)),
205            Err(err) => {
206                eprintln!("guise: failed to create webview: {err}");
207                return;
208            }
209        }
210
211        if !self.draining {
212            self.draining = true;
213            cx.spawn(async move |this, cx| loop {
214                cx.background_executor()
215                    .timer(Duration::from_millis(40))
216                    .await;
217                let drained: Vec<WebViewEvent> = queue.borrow_mut().drain(..).collect();
218                let pushed = this.update(cx, |_this, cx| {
219                    let any = !drained.is_empty();
220                    for event in drained {
221                        cx.emit(event);
222                    }
223                    if any {
224                        cx.notify();
225                    }
226                });
227                if pushed.is_err() {
228                    break;
229                }
230            })
231            .detach();
232        }
233    }
234}
235
236#[cfg(feature = "webview")]
237fn rect_from(bounds: Bounds<Pixels>) -> Rect {
238    Rect {
239        position: LogicalPosition::new(bounds.origin.x.to_f64(), bounds.origin.y.to_f64()).into(),
240        size: LogicalSize::new(bounds.size.width.to_f64(), bounds.size.height.to_f64()).into(),
241    }
242}
243
244impl Render for WebView {
245    #[cfg(feature = "webview")]
246    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
247        // Build the native view on the first frame that has a window handle.
248        // It is created at a best-guess size; the `canvas` paint below snaps it
249        // to the real layout bounds on this same frame.
250        if self.inner.is_none() {
251            let w = self.width.unwrap_or(800.0);
252            let h = self.height.unwrap_or(600.0);
253            let initial = Bounds {
254                origin: gpui::point(px(0.0), px(0.0)),
255                size: gpui::size(px(w), px(h)),
256            };
257            self.ensure_view(window, cx, initial);
258        }
259
260        let t = theme(cx);
261        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
262        let border = t.border().hsla();
263        let bg = t.surface().hsla();
264
265        // Sized region the native view tracks. `canvas` hands us the painted
266        // bounds in window coordinates each frame; we forward them to wry.
267        let view = self.inner.clone();
268        let surface = canvas(
269            move |_bounds, _window, _app| {},
270            move |bounds, _state, _window, _app| {
271                if let Some(view) = &view {
272                    let _ = view.set_bounds(rect_from(bounds));
273                }
274            },
275        )
276        .size_full();
277
278        frame(self.bordered, radius, border, bg, self.width, self.height)
279            .track_focus(&self.focus)
280            .child(surface)
281    }
282
283    #[cfg(not(feature = "webview"))]
284    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
285        let t = theme(cx);
286        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
287        let border = t.border().hsla();
288        let bg = t.surface().hsla();
289        let dimmed = t.dimmed().hsla();
290        let label = match &self.source {
291            Source::Url(url) => url.clone(),
292            Source::Html(html) => SharedString::from(format!("inline HTML ({} bytes)", html.len())),
293            Source::Empty => SharedString::from("no source"),
294        };
295
296        frame(self.bordered, radius, border, bg, self.width, self.height)
297            .track_focus(&self.focus)
298            .items_center()
299            .justify_center()
300            .text_color(dimmed)
301            .child(SharedString::from(format!("WebView (disabled): {label}")))
302    }
303}
304
305/// The themed container shared by both render paths.
306fn frame(
307    bordered: bool,
308    radius: f32,
309    border: gpui::Hsla,
310    bg: gpui::Hsla,
311    width: Option<f32>,
312    height: Option<f32>,
313) -> gpui::Stateful<gpui::Div> {
314    let mut root = div().id("guise-webview").flex().overflow_hidden().bg(bg);
315    root = match width {
316        Some(w) => root.w(px(w)),
317        None => root.w_full(),
318    };
319    root = match height {
320        Some(h) => root.h(px(h)),
321        None => root.h_full(),
322    };
323    if bordered {
324        root = root.border_1().border_color(border).rounded(px(radius));
325    }
326    root
327}