1use gpui::prelude::*;
17use gpui::{div, px, Context, EventEmitter, FocusHandle, IntoElement, SharedString, Window};
18
19use crate::devtools::Probed;
20use crate::theme::{theme, Size};
21
22#[cfg(feature = "webview")]
23use {
24 gpui::{canvas, Bounds, Pixels},
25 std::{cell::RefCell, rc::Rc, time::Duration},
26 wry::{
27 dpi::{LogicalPosition, LogicalSize},
28 PageLoadEvent, Rect, WebViewBuilder,
29 },
30};
31
32#[derive(Debug, Clone)]
34pub enum WebViewEvent {
35 TitleChanged(SharedString),
37 UrlChanged(SharedString),
39 LoadStarted,
41 LoadFinished,
43 Message(SharedString),
46}
47
48#[derive(Clone)]
50enum Source {
51 Empty,
53 Url(SharedString),
55 Html(SharedString),
57}
58
59pub struct WebView {
61 source: Source,
62 focus: FocusHandle,
63 radius: Option<Size>,
64 bordered: bool,
65 transparent: bool,
66 width: Option<f32>,
67 height: Option<f32>,
68 #[cfg_attr(not(feature = "webview"), allow(dead_code))]
73 init_script: Option<SharedString>,
74 #[cfg_attr(not(feature = "webview"), allow(dead_code))]
76 serve_dir: Option<std::path::PathBuf>,
77
78 #[cfg(feature = "webview")]
79 inner: Option<Rc<wry::WebView>>,
80 #[cfg(feature = "webview")]
81 queue: Rc<RefCell<Vec<WebViewEvent>>>,
82 #[cfg(feature = "webview")]
83 draining: bool,
84}
85
86impl EventEmitter<WebViewEvent> for WebView {}
87
88impl WebView {
89 pub fn new(cx: &mut Context<Self>) -> Self {
90 WebView {
91 source: Source::Empty,
92 focus: cx.focus_handle(),
93 radius: None,
94 bordered: true,
95 transparent: false,
96 width: None,
97 height: None,
98 init_script: None,
99 serve_dir: None,
100
101 #[cfg(feature = "webview")]
102 inner: None,
103 #[cfg(feature = "webview")]
104 queue: Rc::new(RefCell::new(Vec::new())),
105 #[cfg(feature = "webview")]
106 draining: false,
107 }
108 }
109
110 pub fn init_script(mut self, js: impl Into<SharedString>) -> Self {
115 self.init_script = Some(js.into());
116 self
117 }
118
119 pub fn url(mut self, url: impl Into<SharedString>) -> Self {
121 self.source = Source::Url(url.into());
122 self
123 }
124
125 pub fn html(mut self, html: impl Into<SharedString>) -> Self {
127 self.source = Source::Html(html.into());
128 self
129 }
130
131 pub fn serve(mut self, dir: impl Into<std::path::PathBuf>, entry: impl AsRef<str>) -> Self {
137 self.serve_dir = Some(dir.into());
138 self.source = Source::Url(
139 format!(
140 "guise://localhost/{}",
141 entry.as_ref().trim_start_matches('/')
142 )
143 .into(),
144 );
145 self
146 }
147
148 pub fn radius(mut self, radius: Size) -> Self {
150 self.radius = Some(radius);
151 self
152 }
153
154 pub fn bordered(mut self, bordered: bool) -> Self {
156 self.bordered = bordered;
157 self
158 }
159
160 pub fn transparent(mut self, transparent: bool) -> Self {
162 self.transparent = transparent;
163 self
164 }
165
166 pub fn width(mut self, width: f32) -> Self {
168 self.width = Some(width);
169 self
170 }
171
172 pub fn height(mut self, height: f32) -> Self {
174 self.height = Some(height);
175 self
176 }
177
178 pub fn load_url(&mut self, url: impl Into<SharedString>, cx: &mut Context<Self>) {
180 let url = url.into();
181 #[cfg(feature = "webview")]
182 if let Some(inner) = &self.inner {
183 let _ = inner.load_url(&url);
184 }
185 self.source = Source::Url(url);
186 cx.notify();
187 }
188
189 pub fn load_html(&mut self, html: impl Into<SharedString>, cx: &mut Context<Self>) {
191 let html = html.into();
192 #[cfg(feature = "webview")]
193 if let Some(inner) = &self.inner {
194 let _ = inner.load_html(&html);
195 }
196 self.source = Source::Html(html);
197 cx.notify();
198 }
199
200 pub fn evaluate_script(&self, _js: &str) {
202 #[cfg(feature = "webview")]
203 if let Some(inner) = &self.inner {
204 let _ = inner.evaluate_script(_js);
205 }
206 }
207
208 pub fn set_visible(&mut self, _visible: bool) {
214 #[cfg(feature = "webview")]
215 if let Some(inner) = &self.inner {
216 let _ = inner.set_visible(_visible);
217 }
218 }
219
220 #[cfg(feature = "webview")]
223 fn ensure_view(&mut self, window: &mut Window, cx: &mut Context<Self>, bounds: Bounds<Pixels>) {
224 if self.inner.is_some() {
225 return;
226 }
227
228 let queue = self.queue.clone();
229 let (q_title, q_nav, q_load, q_ipc) =
230 (queue.clone(), queue.clone(), queue.clone(), queue.clone());
231
232 let mut builder = WebViewBuilder::new()
233 .with_bounds(rect_from(bounds))
234 .with_transparent(self.transparent)
235 .with_document_title_changed_handler(move |title| {
236 q_title
237 .borrow_mut()
238 .push(WebViewEvent::TitleChanged(title.into()));
239 })
240 .with_navigation_handler(move |url| {
241 q_nav
242 .borrow_mut()
243 .push(WebViewEvent::UrlChanged(url.into()));
244 true
245 })
246 .with_on_page_load_handler(move |event, _url| {
247 q_load.borrow_mut().push(match event {
248 PageLoadEvent::Started => WebViewEvent::LoadStarted,
249 PageLoadEvent::Finished => WebViewEvent::LoadFinished,
250 });
251 })
252 .with_ipc_handler(move |req| {
254 q_ipc
255 .borrow_mut()
256 .push(WebViewEvent::Message(req.into_body().into()));
257 });
258
259 if let Some(js) = &self.init_script {
260 builder = builder.with_initialization_script(js.to_string());
261 }
262
263 if let Some(dir) = self.serve_dir.clone() {
265 builder = builder.with_custom_protocol("guise".to_string(), move |_id, request| {
266 serve_local(&dir, request.uri().path())
267 });
268 }
269
270 builder = match &self.source {
271 Source::Url(url) => builder.with_url(url.as_ref()),
272 Source::Html(html) => builder.with_html(html.as_ref()),
273 Source::Empty => builder,
274 };
275
276 match builder.build_as_child(&*window) {
277 Ok(view) => self.inner = Some(Rc::new(view)),
278 Err(err) => {
279 eprintln!("guise: failed to create webview: {err}");
280 return;
281 }
282 }
283
284 if !self.draining {
285 self.draining = true;
286 cx.spawn(async move |this, cx| loop {
287 cx.background_executor()
288 .timer(Duration::from_millis(40))
289 .await;
290 let drained: Vec<WebViewEvent> = queue.borrow_mut().drain(..).collect();
291 let pushed = this.update(cx, |_this, cx| {
292 let any = !drained.is_empty();
293 for event in drained {
294 cx.emit(event);
295 }
296 if any {
297 cx.notify();
298 }
299 });
300 if pushed.is_err() {
301 break;
302 }
303 })
304 .detach();
305 }
306 }
307}
308
309#[cfg(feature = "webview")]
310fn rect_from(bounds: Bounds<Pixels>) -> Rect {
311 Rect {
312 position: LogicalPosition::new(bounds.origin.x.to_f64(), bounds.origin.y.to_f64()).into(),
313 size: LogicalSize::new(bounds.size.width.to_f64(), bounds.size.height.to_f64()).into(),
314 }
315}
316
317impl Render for WebView {
318 #[cfg(feature = "webview")]
319 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
320 if self.inner.is_none() {
324 let w = self.width.unwrap_or(800.0);
325 let h = self.height.unwrap_or(600.0);
326 let initial = Bounds {
327 origin: gpui::point(px(0.0), px(0.0)),
328 size: gpui::size(px(w), px(h)),
329 };
330 self.ensure_view(window, cx, initial);
331 }
332
333 let t = theme(cx);
334 let radius = t.radius(self.radius.unwrap_or(t.default_radius));
335 let border = t.border().hsla();
336 let bg = t.surface().hsla();
337
338 let view = self.inner.clone();
341 let surface = canvas(
342 move |_bounds, _window, _app| {},
343 move |bounds, _state, _window, _app| {
344 if let Some(view) = &view {
345 let _ = view.set_bounds(rect_from(bounds));
346 let _ = view.set_visible(true);
349 }
350 },
351 )
352 .size_full();
353
354 frame(self.bordered, radius, border, bg, self.width, self.height)
355 .track_focus(&self.focus)
356 .child(surface)
357 .probe("WebView")
358 }
359
360 #[cfg(not(feature = "webview"))]
361 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
362 let t = theme(cx);
363 let radius = t.radius(self.radius.unwrap_or(t.default_radius));
364 let border = t.border().hsla();
365 let bg = t.surface().hsla();
366 let dimmed = t.dimmed().hsla();
367 let label = match &self.source {
368 Source::Url(url) => url.clone(),
369 Source::Html(html) => SharedString::from(format!("inline HTML ({} bytes)", html.len())),
370 Source::Empty => SharedString::from("no source"),
371 };
372
373 frame(self.bordered, radius, border, bg, self.width, self.height)
374 .track_focus(&self.focus)
375 .items_center()
376 .justify_center()
377 .text_color(dimmed)
378 .child(SharedString::from(format!("WebView (disabled): {label}")))
379 .probe("WebView")
380 .attr("source", label)
381 }
382}
383
384fn frame(
386 bordered: bool,
387 radius: f32,
388 border: gpui::Hsla,
389 bg: gpui::Hsla,
390 width: Option<f32>,
391 height: Option<f32>,
392) -> gpui::Stateful<gpui::Div> {
393 let mut root = div().id("guise-webview").flex().overflow_hidden().bg(bg);
394 root = match width {
395 Some(w) => root.w(px(w)),
396 None => root.w_full(),
397 };
398 root = match height {
399 Some(h) => root.h(px(h)),
400 None => root.h_full(),
401 };
402 if bordered {
403 root = root.border_1().border_color(border).rounded(px(radius));
404 }
405 root
406}
407
408#[cfg(feature = "webview")]
411fn serve_local(
412 dir: &std::path::Path,
413 url_path: &str,
414) -> wry::http::Response<std::borrow::Cow<'static, [u8]>> {
415 use std::borrow::Cow;
416 use wry::http::{Response, StatusCode};
417
418 let not_found = || {
422 let mut response = Response::new(Cow::Borrowed(&b"not found"[..]));
423 *response.status_mut() = StatusCode::NOT_FOUND;
424 response
425 };
426
427 let rel = url_path.trim_start_matches('/');
428 let rel = if rel.is_empty() { "index.html" } else { rel };
429 if rel
431 .split('/')
432 .any(|c| c.is_empty() || c == "." || c == "..")
433 {
434 return not_found();
435 }
436 match std::fs::read(dir.join(rel)) {
437 Ok(bytes) => {
438 let mut response = Response::new(Cow::Owned(bytes));
439 let headers = response.headers_mut();
442 headers.insert(
443 wry::http::header::CONTENT_TYPE,
444 wry::http::HeaderValue::from_static(content_type(rel)),
445 );
446 headers.insert(
447 wry::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
448 wry::http::HeaderValue::from_static("*"),
449 );
450 response
451 }
452 Err(_) => not_found(),
453 }
454}
455
456#[cfg(feature = "webview")]
458fn content_type(rel: &str) -> &'static str {
459 match rel.rsplit('.').next() {
460 Some("html" | "htm") => "text/html; charset=utf-8",
461 Some("js" | "mjs") => "text/javascript; charset=utf-8",
462 Some("css") => "text/css; charset=utf-8",
463 Some("json") => "application/json; charset=utf-8",
464 Some("svg") => "image/svg+xml",
465 Some("png") => "image/png",
466 Some("jpg" | "jpeg") => "image/jpeg",
467 Some("gif") => "image/gif",
468 Some("webp") => "image/webp",
469 Some("ico") => "image/x-icon",
470 Some("woff2") => "font/woff2",
471 Some("woff") => "font/woff",
472 Some("ttf") => "font/ttf",
473 Some("wasm") => "application/wasm",
474 Some("map") => "application/json; charset=utf-8",
475 _ => "application/octet-stream",
476 }
477}