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