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    /// The page logged, with a page built [`WebView::with_console`].
71    Console(ConsoleMessage),
72}
73
74/// One `console` call, uncaught error or unhandled rejection in the page.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct ConsoleMessage {
77    pub level: ConsoleLevel,
78    /// The logged values, each a string as is, an error as its stack, and
79    /// anything else as JSON, joined by spaces.
80    pub text: String,
81    /// The URL of the frame that logged.
82    pub source: String,
83}
84
85/// The `console` method a [`ConsoleMessage`] came from. Uncaught errors and
86/// unhandled rejections are `Error`.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum ConsoleLevel {
89    Debug,
90    Log,
91    Info,
92    Warn,
93    Error,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum LoadState {
98    Started,
99    Finished,
100}
101
102/// Why [`WebView::eval`] has no value.
103#[derive(Debug)]
104pub enum EvalError {
105    /// The page is not built yet, or not on this platform.
106    Unavailable,
107    /// No answer within the timeout.
108    Timeout,
109    /// The script threw, or its value has no JSON form.
110    Script,
111    /// The JSON does not decode as the asked type.
112    Decode(serde_json::Error),
113}
114
115impl fmt::Display for EvalError {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Unavailable => f.write_str("no page to evaluate in"),
119            Self::Timeout => f.write_str("script timed out"),
120            Self::Script => f.write_str("script threw or returned no JSON value"),
121            Self::Decode(error) => write!(f, "script value: {error}"),
122        }
123    }
124}
125
126impl std::error::Error for EvalError {}
127
128impl WebView {
129    pub fn new(url: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
130        Page::start(window, cx);
131        bind_edits(cx);
132        let focus = cx.focus_handle();
133        let subscriptions = Self::listen(&focus, window, cx);
134        let (reports, received) = async_channel::unbounded();
135        // Ends when the page drops, which drops every sender.
136        let task = cx.spawn(async move |this, cx| {
137            while let Ok(report) = received.recv().await {
138                if this.update(cx, |this, cx| this.report(report, cx)).is_err() {
139                    return;
140                }
141            }
142        });
143        Self {
144            page: Rc::new(Page::new(url.into(), focus, reports, cx)),
145            location: None,
146            title: String::new(),
147            loading: false,
148            history: (false, false),
149            focus_window: Some(window.window_handle()),
150            _focus: subscriptions,
151            _reports: task,
152        }
153    }
154
155    /// Focus listeners are per window.
156    fn listen(
157        focus: &FocusHandle,
158        window: &mut Window,
159        cx: &mut Context<Self>,
160    ) -> [Subscription; 2] {
161        [
162            cx.on_focus(focus, window, |this: &mut Self, window, _| {
163                this.page.watch_keys(window);
164                this.page.take_keys()
165            }),
166            cx.on_blur(focus, window, |this: &mut Self, _, _| this.page.give_keys()),
167        ]
168    }
169
170    /// The URL the page last reported; `None` before its first report.
171    pub fn location(&self) -> Option<&str> {
172        self.location.as_deref()
173    }
174
175    pub fn title(&self) -> &str {
176        &self.title
177    }
178
179    /// Whether [`Self::back`] would move the page, as of its last load or
180    /// history move. `false` before the first paint.
181    pub fn can_go_back(&self) -> bool {
182        self.history.0
183    }
184
185    /// Whether [`Self::forward`] would move the page, as of its last load or
186    /// history move. `false` before the first paint.
187    pub fn can_go_forward(&self) -> bool {
188        self.history.1
189    }
190
191    pub fn is_loading(&self) -> bool {
192        self.loading
193    }
194
195    /// The user agent the page is built with, in place of the platform's.
196    /// Read at the first paint. On macOS the default is Safari's, with the
197    /// installed Safari's version.
198    pub fn with_user_agent(self, user_agent: impl Into<String>) -> Self {
199        *self.page.user_agent.borrow_mut() = Some(user_agent.into());
200        self
201    }
202
203    /// Reports the page's console as [`WebViewEvent::Console`]. Read at the
204    /// first paint. Engine messages such as failed loads are not console
205    /// calls and are not reported. In the inspector, every console message
206    /// is attributed to the script that reports it.
207    pub fn with_console(self) -> Self {
208        self.page.console.set(true);
209        self
210    }
211
212    /// The store the page is built with, in place of the platform's default.
213    /// Read at the first paint. A page keeps the store it was built with: a
214    /// different store takes a new `WebView`.
215    pub fn with_data_store(self, store: DataStore) -> Self {
216        *self.page.store.borrow_mut() = store;
217        self
218    }
219
220    /// Builds the page inspectable. Read at the first paint. On macOS 13.3+
221    /// Safari's Develop menu lists the page, and the page's context menu has
222    /// Inspect Element.
223    #[cfg(feature = "inspector")]
224    pub fn with_inspector(self) -> Self {
225        self.page.inspector.set(true);
226        self
227    }
228
229    /// Opens the inspector of a page built with [`Self::with_inspector`].
230    /// Does nothing before the first paint.
231    #[cfg(feature = "inspector")]
232    pub fn open_inspector(&self) {
233        self.page.open_inspector();
234    }
235
236    /// Before the first paint, replaces the URL the page is built with.
237    /// After it, navigates the page.
238    pub fn load(&mut self, url: impl Into<String>) {
239        self.page.load(url.into());
240    }
241
242    /// Takes the keys back from the page if it holds them, whatever gpui's
243    /// focus is.
244    pub fn release_keys(&self) {
245        self.page.give_keys();
246    }
247
248    pub fn back(&mut self) {
249        self.page.back();
250    }
251
252    pub fn forward(&mut self) {
253        self.page.forward();
254    }
255
256    pub fn reload(&mut self) {
257        self.page.reload();
258    }
259
260    /// Reloads from the network, past the cache.
261    pub fn reload_bypassing_cache(&mut self) {
262        self.page.reload_bypassing_cache();
263    }
264
265    /// Evaluates `script`, a JavaScript expression, in the page's main frame
266    /// and decodes its value from JSON. `undefined` decodes as `null`; a
267    /// promise is not awaited.
268    pub fn eval<T: DeserializeOwned + 'static>(
269        &self,
270        script: &str,
271        timeout: Duration,
272        cx: &App,
273    ) -> Task<Result<T, EvalError>> {
274        let (answer, answered) = async_channel::bounded::<Option<String>>(2);
275        let timer = answer.clone();
276        // WebKit hands the value to `NSJSONSerialization`, which cannot encode
277        // a DOM node or `undefined`, so the page encodes it and this decodes
278        // twice.
279        let script = format!("JSON.stringify((\n{script}\n) ?? null)");
280        if !self.page.eval(&script, move |json| {
281            let _ = answer.try_send(Some(json));
282        }) {
283            return Task::ready(Err(EvalError::Unavailable));
284        }
285        let expired = cx.background_executor().timer(timeout);
286        cx.foreground_executor()
287            .spawn(async move {
288                expired.await;
289                let _ = timer.try_send(None);
290            })
291            .detach();
292        cx.foreground_executor().spawn(async move {
293            let json = answered
294                .recv()
295                .await
296                .ok()
297                .flatten()
298                .ok_or(EvalError::Timeout)?;
299            let json = serde_json::from_str::<Option<String>>(&json)
300                .ok()
301                .flatten()
302                .ok_or(EvalError::Script)?;
303            serde_json::from_str(&json).map_err(EvalError::Decode)
304        })
305    }
306
307    fn report(&mut self, report: Report, cx: &mut Context<Self>) {
308        match report {
309            // Deferred: the window's handlers may update this view.
310            Report::Pressed => {
311                if self.page.holds_keys()
312                    && let Some(window) = self.page.window()
313                {
314                    let focus = self.page.focus.clone();
315                    App::defer(cx, move |cx| {
316                        let _ = window.update(cx, |_, window, cx| window.focus(&focus, cx));
317                    });
318                }
319            }
320            Report::Key(keystroke) => {
321                if let Some(window) = self.page.window() {
322                    App::defer(cx, move |cx| {
323                        let _ = window.update(cx, |_, window, cx| {
324                            window.dispatch_keystroke(keystroke, cx);
325                        });
326                    });
327                }
328            }
329            Report::Moved => {
330                if let Some(url) = self.page.location() {
331                    self.locate(url, cx);
332                }
333                self.step(cx);
334            }
335            Report::Load(state, url) => {
336                self.locate(url, cx);
337                self.loading = state == LoadState::Started;
338                cx.emit(WebViewEvent::Load(state));
339                self.step(cx);
340            }
341            // Live pages rewrite their title, some every second.
342            Report::Title(title) => {
343                if title != self.title {
344                    self.title = title.clone();
345                    cx.emit(WebViewEvent::Title(title));
346                }
347            }
348            Report::Opened(url) => cx.emit(WebViewEvent::NewWindow(url)),
349            Report::Console(message) => cx.emit(WebViewEvent::Console(message)),
350            Report::Still(still) => {
351                self.page.captured(still);
352                cx.notify();
353            }
354        }
355    }
356
357    fn step(&mut self, cx: &mut Context<Self>) {
358        let history = self.page.history();
359        if history != self.history {
360            self.history = history;
361            let (back, forward) = history;
362            cx.emit(WebViewEvent::History { back, forward });
363        }
364    }
365
366    fn locate(&mut self, url: String, cx: &mut Context<Self>) {
367        if self.location.as_ref() != Some(&url) {
368            self.location = Some(url.clone());
369            cx.emit(WebViewEvent::Location(url));
370        }
371    }
372}
373
374impl EventEmitter<WebViewEvent> for WebView {}
375
376impl Focusable for WebView {
377    fn focus_handle(&self, _cx: &App) -> FocusHandle {
378        self.page.focus.clone()
379    }
380}
381
382impl Render for WebView {
383    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
384        if self.focus_window != Some(window.window_handle()) {
385            Page::start(window, cx);
386            self._focus = Self::listen(&self.page.focus, window, cx);
387            self.focus_window = Some(window.window_handle());
388        }
389        if self.page.focus.is_focused(window) {
390            self.page.watch_keys(window);
391        }
392        div()
393            .size_full()
394            .key_context(KEY_CONTEXT)
395            .on_action(on_edit::<Copy>(Edit::Copy, cx))
396            .on_action(on_edit::<Cut>(Edit::Cut, cx))
397            .on_action(on_edit::<Paste>(Edit::Paste, cx))
398            .on_action(on_edit::<SelectAll>(Edit::SelectAll, cx))
399            .on_action(on_edit::<Undo>(Edit::Undo, cx))
400            .on_action(on_edit::<Redo>(Edit::Redo, cx))
401            .child(Host {
402                surface: self.page.clone(),
403            })
404    }
405}
406
407actions!(webview, [Copy, Cut, Paste, SelectAll, Undo, Redo]);
408
409const KEY_CONTEXT: &str = "WebView";
410
411fn on_edit<A: Action>(
412    edit: Edit,
413    cx: &mut Context<WebView>,
414) -> impl Fn(&A, &mut Window, &mut App) + 'static {
415    cx.listener(move |this, _: &A, _, _| this.page.edit(edit))
416}
417
418/// Binds the edit keys in the view's context, once per app.
419fn bind_edits(cx: &mut App) {
420    struct Bound;
421    impl Global for Bound {}
422
423    if !Page::BINDS_EDITS || cx.has_global::<Bound>() {
424        return;
425    }
426    cx.set_global(Bound);
427    let context = Some(KEY_CONTEXT);
428    cx.bind_keys([
429        KeyBinding::new("cmd-c", Copy, context),
430        KeyBinding::new("cmd-x", Cut, context),
431        KeyBinding::new("cmd-v", Paste, context),
432        KeyBinding::new("cmd-a", SelectAll, context),
433        KeyBinding::new("cmd-z", Undo, context),
434        KeyBinding::new("cmd-shift-z", Redo, context),
435    ]);
436}