1use crate::{
2 DataStore,
3 host::Host,
4 page::{Edit, Page, Report},
5};
6use gpui::{
7 Action, AnyWindowHandle, App, Context, EventEmitter, FocusHandle, Focusable, Global,
8 IntoElement, KeyBinding, Render, Subscription, Task, Window, actions, div, prelude::*,
9};
10use serde::de::DeserializeOwned;
11use std::{fmt, rc::Rc, time::Duration};
12
13pub struct WebView {
39 page: Rc<Page>,
40 location: Option<String>,
41 title: String,
42 loading: bool,
43 history: (bool, bool),
45 focus_window: Option<AnyWindowHandle>,
47 _focus: [Subscription; 2],
48 _reports: Task<()>,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum WebViewEvent {
54 Location(String),
57 Title(String),
59 History {
62 back: bool,
63 forward: bool,
64 },
65 Load(LoadState),
66 NewWindow(String),
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum LoadState {
74 Started,
75 Finished,
76}
77
78#[derive(Debug)]
80pub enum EvalError {
81 Unavailable,
83 Timeout,
85 Script,
87 Decode(serde_json::Error),
89}
90
91impl fmt::Display for EvalError {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 match self {
94 Self::Unavailable => f.write_str("no page to evaluate in"),
95 Self::Timeout => f.write_str("script timed out"),
96 Self::Script => f.write_str("script threw or returned no JSON value"),
97 Self::Decode(error) => write!(f, "script value: {error}"),
98 }
99 }
100}
101
102impl std::error::Error for EvalError {}
103
104impl WebView {
105 pub fn new(url: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
106 Page::start(window, cx);
107 bind_edits(cx);
108 let focus = cx.focus_handle();
109 let subscriptions = Self::listen(&focus, window, cx);
110 let (reports, received) = async_channel::unbounded();
111 let task = cx.spawn(async move |this, cx| {
113 while let Ok(report) = received.recv().await {
114 if this.update(cx, |this, cx| this.report(report, cx)).is_err() {
115 return;
116 }
117 }
118 });
119 Self {
120 page: Rc::new(Page::new(url.into(), focus, reports, cx)),
121 location: None,
122 title: String::new(),
123 loading: false,
124 history: (false, false),
125 focus_window: Some(window.window_handle()),
126 _focus: subscriptions,
127 _reports: task,
128 }
129 }
130
131 fn listen(
133 focus: &FocusHandle,
134 window: &mut Window,
135 cx: &mut Context<Self>,
136 ) -> [Subscription; 2] {
137 [
138 cx.on_focus(focus, window, |this: &mut Self, window, _| {
139 this.page.watch_keys(window);
140 this.page.take_keys()
141 }),
142 cx.on_blur(focus, window, |this: &mut Self, _, _| this.page.give_keys()),
143 ]
144 }
145
146 pub fn location(&self) -> Option<&str> {
148 self.location.as_deref()
149 }
150
151 pub fn title(&self) -> &str {
152 &self.title
153 }
154
155 pub fn can_go_back(&self) -> bool {
158 self.history.0
159 }
160
161 pub fn can_go_forward(&self) -> bool {
164 self.history.1
165 }
166
167 pub fn is_loading(&self) -> bool {
168 self.loading
169 }
170
171 pub fn with_user_agent(self, user_agent: impl Into<String>) -> Self {
175 *self.page.user_agent.borrow_mut() = Some(user_agent.into());
176 self
177 }
178
179 pub fn with_data_store(self, store: DataStore) -> Self {
183 *self.page.store.borrow_mut() = store;
184 self
185 }
186
187 pub fn load(&mut self, url: impl Into<String>) {
190 self.page.load(url.into());
191 }
192
193 pub fn release_keys(&self) {
196 self.page.give_keys();
197 }
198
199 pub fn back(&mut self) {
200 self.page.back();
201 }
202
203 pub fn forward(&mut self) {
204 self.page.forward();
205 }
206
207 pub fn reload(&mut self) {
208 self.page.reload();
209 }
210
211 pub fn reload_bypassing_cache(&mut self) {
213 self.page.reload_bypassing_cache();
214 }
215
216 pub fn eval<T: DeserializeOwned + 'static>(
220 &self,
221 script: &str,
222 timeout: Duration,
223 cx: &App,
224 ) -> Task<Result<T, EvalError>> {
225 let (answer, answered) = async_channel::bounded::<Option<String>>(2);
226 let timer = answer.clone();
227 let script = format!("JSON.stringify((\n{script}\n) ?? null)");
231 if !self.page.eval(&script, move |json| {
232 let _ = answer.try_send(Some(json));
233 }) {
234 return Task::ready(Err(EvalError::Unavailable));
235 }
236 let expired = cx.background_executor().timer(timeout);
237 cx.foreground_executor()
238 .spawn(async move {
239 expired.await;
240 let _ = timer.try_send(None);
241 })
242 .detach();
243 cx.foreground_executor().spawn(async move {
244 let json = answered
245 .recv()
246 .await
247 .ok()
248 .flatten()
249 .ok_or(EvalError::Timeout)?;
250 let json = serde_json::from_str::<Option<String>>(&json)
251 .ok()
252 .flatten()
253 .ok_or(EvalError::Script)?;
254 serde_json::from_str(&json).map_err(EvalError::Decode)
255 })
256 }
257
258 fn report(&mut self, report: Report, cx: &mut Context<Self>) {
259 match report {
260 Report::Pressed => {
262 if self.page.holds_keys()
263 && let Some(window) = self.page.window()
264 {
265 let focus = self.page.focus.clone();
266 App::defer(cx, move |cx| {
267 let _ = window.update(cx, |_, window, cx| window.focus(&focus, cx));
268 });
269 }
270 }
271 Report::Key(keystroke) => {
272 if let Some(window) = self.page.window() {
273 App::defer(cx, move |cx| {
274 let _ = window.update(cx, |_, window, cx| {
275 window.dispatch_keystroke(keystroke, cx);
276 });
277 });
278 }
279 }
280 Report::Moved => {
281 if let Some(url) = self.page.location() {
282 self.locate(url, cx);
283 }
284 self.step(cx);
285 }
286 Report::Load(state, url) => {
287 self.locate(url, cx);
288 self.loading = state == LoadState::Started;
289 cx.emit(WebViewEvent::Load(state));
290 self.step(cx);
291 }
292 Report::Title(title) => {
294 if title != self.title {
295 self.title = title.clone();
296 cx.emit(WebViewEvent::Title(title));
297 }
298 }
299 Report::Opened(url) => cx.emit(WebViewEvent::NewWindow(url)),
300 Report::Still(still) => {
301 self.page.captured(still);
302 cx.notify();
303 }
304 }
305 }
306
307 fn step(&mut self, cx: &mut Context<Self>) {
308 let history = self.page.history();
309 if history != self.history {
310 self.history = history;
311 let (back, forward) = history;
312 cx.emit(WebViewEvent::History { back, forward });
313 }
314 }
315
316 fn locate(&mut self, url: String, cx: &mut Context<Self>) {
317 if self.location.as_ref() != Some(&url) {
318 self.location = Some(url.clone());
319 cx.emit(WebViewEvent::Location(url));
320 }
321 }
322}
323
324impl EventEmitter<WebViewEvent> for WebView {}
325
326impl Focusable for WebView {
327 fn focus_handle(&self, _cx: &App) -> FocusHandle {
328 self.page.focus.clone()
329 }
330}
331
332impl Render for WebView {
333 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
334 if self.focus_window != Some(window.window_handle()) {
335 Page::start(window, cx);
336 self._focus = Self::listen(&self.page.focus, window, cx);
337 self.focus_window = Some(window.window_handle());
338 }
339 if self.page.focus.is_focused(window) {
340 self.page.watch_keys(window);
341 }
342 div()
343 .size_full()
344 .key_context(KEY_CONTEXT)
345 .on_action(on_edit::<Copy>(Edit::Copy, cx))
346 .on_action(on_edit::<Cut>(Edit::Cut, cx))
347 .on_action(on_edit::<Paste>(Edit::Paste, cx))
348 .on_action(on_edit::<SelectAll>(Edit::SelectAll, cx))
349 .on_action(on_edit::<Undo>(Edit::Undo, cx))
350 .on_action(on_edit::<Redo>(Edit::Redo, cx))
351 .child(Host {
352 surface: self.page.clone(),
353 })
354 }
355}
356
357actions!(webview, [Copy, Cut, Paste, SelectAll, Undo, Redo]);
358
359const KEY_CONTEXT: &str = "WebView";
360
361fn on_edit<A: Action>(
362 edit: Edit,
363 cx: &mut Context<WebView>,
364) -> impl Fn(&A, &mut Window, &mut App) + 'static {
365 cx.listener(move |this, _: &A, _, _| this.page.edit(edit))
366}
367
368fn bind_edits(cx: &mut App) {
370 struct Bound;
371 impl Global for Bound {}
372
373 if !Page::BINDS_EDITS || cx.has_global::<Bound>() {
374 return;
375 }
376 cx.set_global(Bound);
377 let context = Some(KEY_CONTEXT);
378 cx.bind_keys([
379 KeyBinding::new("cmd-c", Copy, context),
380 KeyBinding::new("cmd-x", Cut, context),
381 KeyBinding::new("cmd-v", Paste, context),
382 KeyBinding::new("cmd-a", SelectAll, context),
383 KeyBinding::new("cmd-z", Undo, context),
384 KeyBinding::new("cmd-shift-z", Redo, context),
385 ]);
386}