Skip to main content

browser/
view.rs

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
13/// A webview showing one page.
14///
15/// The page is built the first time the view is painted, in the window it is
16/// painted in, and moves to each other window the view is painted in. It sits
17/// at the element's bounds in every frame the element is painted and is parked
18/// in every frame it is not. A parked page stays loaded. A view painted in two
19/// windows in turn moves its page on every paint.
20///
21/// The page takes keys while the view's focus handle is focused, and a press
22/// in the page focuses the handle. On macOS, a press on gpui's view outside
23/// the page takes the keys back, whether or not the handle loses focus, and
24/// key equivalents (cmd or ctrl held) reach gpui's key dispatch before the
25/// page sees them. On Windows, a key with ctrl or alt held, or a function key,
26/// goes to gpui's key dispatch in place of the page when the keymap binds it
27/// in the view's key context.
28///
29/// gpui elements behind the page are not hovered, and gpui's cursor over the
30/// page is the arrow. On macOS the page sets the cursor over itself.
31///
32/// In a frame where a [`ui::cover`] recorded after the view overlaps it, the
33/// page is parked. On macOS a still of the page, taken while it is still up,
34/// is painted in its place; until the still arrives the page stays over the
35/// cover. Elsewhere the place is left empty.
36///
37/// Paints nothing off macOS and Windows.
38pub struct WebView {
39    page: Rc<Page>,
40    location: Option<String>,
41    title: String,
42    loading: bool,
43    /// Whether the page could step back, and forward, when it last moved.
44    history: (bool, bool),
45    /// The window [`Self::_focus`] listens in.
46    focus_window: Option<AnyWindowHandle>,
47    _focus: [Subscription; 2],
48    _reports: Task<()>,
49}
50
51/// What the page reports about itself.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub enum WebViewEvent {
54    /// The page's URL changed: a load, a redirect, or the page's own history
55    /// move (`pushState`, `replaceState`, a fragment).
56    Location(String),
57    /// The page's title changed.
58    Title(String),
59    /// Whether [`WebView::back`] and [`WebView::forward`] would move the page
60    /// changed.
61    History {
62        back: bool,
63        forward: bool,
64    },
65    Load(LoadState),
66    /// The page asked to open this URL in a window of its own: a
67    /// `target="_blank"` link or `window.open`. No window opens and the page
68    /// stays where it is; where the URL goes is the host's.
69    NewWindow(String),
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum LoadState {
74    Started,
75    Finished,
76}
77
78/// Why [`WebView::eval`] has no value.
79#[derive(Debug)]
80pub enum EvalError {
81    /// The page is not built yet, or not on this platform.
82    Unavailable,
83    /// No answer within the timeout.
84    Timeout,
85    /// The script threw, or its value has no JSON form.
86    Script,
87    /// The JSON does not decode as the asked type.
88    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        // Ends when the page drops, which drops every sender.
112        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    /// Focus listeners are per window.
132    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    /// The URL the page last reported; `None` before its first report.
147    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    /// Whether [`Self::back`] would move the page, as of its last load or
156    /// history move. `false` before the first paint.
157    pub fn can_go_back(&self) -> bool {
158        self.history.0
159    }
160
161    /// Whether [`Self::forward`] would move the page, as of its last load or
162    /// history move. `false` before the first paint.
163    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    /// The user agent the page is built with, in place of the platform's.
172    /// Read at the first paint. On macOS the default is Safari's, with the
173    /// installed Safari's version.
174    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    /// The store the page is built with, in place of the platform's default.
180    /// Read at the first paint. A page keeps the store it was built with: a
181    /// different store takes a new `WebView`.
182    pub fn with_data_store(self, store: DataStore) -> Self {
183        *self.page.store.borrow_mut() = store;
184        self
185    }
186
187    /// Before the first paint, replaces the URL the page is built with.
188    /// After it, navigates the page.
189    pub fn load(&mut self, url: impl Into<String>) {
190        self.page.load(url.into());
191    }
192
193    /// Takes the keys back from the page if it holds them, whatever gpui's
194    /// focus is.
195    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    /// Reloads from the network, past the cache.
212    pub fn reload_bypassing_cache(&mut self) {
213        self.page.reload_bypassing_cache();
214    }
215
216    /// Evaluates `script`, a JavaScript expression, in the page's main frame
217    /// and decodes its value from JSON. `undefined` decodes as `null`; a
218    /// promise is not awaited.
219    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        // WebKit hands the value to `NSJSONSerialization`, which cannot encode
228        // a DOM node or `undefined`, so the page encodes it and this decodes
229        // twice.
230        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            // Deferred: the window's handlers may update this view.
261            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            // Live pages rewrite their title, some every second.
293            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
368/// Binds the edit keys in the view's context, once per app.
369fn 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}