Skip to main content

browser/
view.rs

1use crate::{
2    host::Host,
3    page::{Edit, Page, Report},
4};
5use gpui::{
6    Action, AnyWindowHandle, App, Context, EventEmitter, FocusHandle, Focusable, Global,
7    IntoElement, KeyBinding, Render, Subscription, Task, Window, actions, div, prelude::*,
8};
9use serde::de::DeserializeOwned;
10use std::{fmt, rc::Rc, time::Duration};
11
12/// A webview showing one page.
13///
14/// The page is built the first time the view is painted, in the window it is
15/// painted in, and moves to each other window the view is painted in. It sits
16/// at the element's bounds in every frame the element is painted and is parked
17/// in every frame it is not. A parked page stays loaded. A view painted in two
18/// windows in turn moves its page on every paint.
19///
20/// The page takes keys while the view's focus handle is focused, and a press
21/// in the page focuses the handle. On macOS, a press on gpui's view outside
22/// the page takes the keys back, whether or not the handle loses focus, and
23/// key equivalents (cmd or ctrl held) reach gpui's key dispatch before the
24/// page sees them. On Windows, a key with ctrl or alt held, or a function key,
25/// goes to gpui's key dispatch in place of the page when the keymap binds it
26/// in the view's key context.
27///
28/// gpui elements behind the page are not hovered, and gpui's cursor over the
29/// page is the arrow. On macOS the page sets the cursor over itself.
30///
31/// In a frame where a [`ui::cover`] recorded after the view overlaps it, the
32/// page is parked. On macOS a still of the page, taken while it is still up,
33/// is painted in its place; until the still arrives the page stays over the
34/// cover. Elsewhere the place is left empty.
35///
36/// Linux needs gpui on X11 and paints nothing under Wayland. Paints nothing
37/// off macOS, Windows and Linux.
38pub struct WebView {
39    page: Rc<Page>,
40    location: Option<String>,
41    title: String,
42    loading: bool,
43    /// The window [`Self::_focus`] listens in.
44    focus_window: Option<AnyWindowHandle>,
45    _focus: [Subscription; 2],
46    _reports: Task<()>,
47}
48
49/// What the page reports about itself.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub enum WebViewEvent {
52    /// The page's URL changed: a load, a redirect, or the page's own history
53    /// move (`pushState`, `replaceState`, a fragment).
54    Location(String),
55    /// The page's title changed.
56    Title(String),
57    Load(LoadState),
58    /// The page asked to open this URL in a window of its own: a
59    /// `target="_blank"` link or `window.open`. No window opens and the page
60    /// stays where it is; where the URL goes is the host's.
61    NewWindow(String),
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum LoadState {
66    Started,
67    Finished,
68}
69
70/// Why [`WebView::eval`] has no value.
71#[derive(Debug)]
72pub enum EvalError {
73    /// The page is not built yet, or not on this platform.
74    Unavailable,
75    /// No answer within the timeout.
76    Timeout,
77    /// The script threw, or its value has no JSON form.
78    Script,
79    /// The JSON does not decode as the asked type.
80    Decode(serde_json::Error),
81}
82
83impl fmt::Display for EvalError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Unavailable => f.write_str("no page to evaluate in"),
87            Self::Timeout => f.write_str("script timed out"),
88            Self::Script => f.write_str("script threw or returned no JSON value"),
89            Self::Decode(error) => write!(f, "script value: {error}"),
90        }
91    }
92}
93
94impl std::error::Error for EvalError {}
95
96impl WebView {
97    pub fn new(url: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
98        Page::start(window, cx);
99        bind_edits(cx);
100        let focus = cx.focus_handle();
101        let subscriptions = Self::listen(&focus, window, cx);
102        let (reports, received) = async_channel::unbounded();
103        // Ends when the page drops, which drops every sender.
104        let task = cx.spawn(async move |this, cx| {
105            while let Ok(report) = received.recv().await {
106                if this.update(cx, |this, cx| this.report(report, cx)).is_err() {
107                    return;
108                }
109            }
110        });
111        Self {
112            page: Rc::new(Page::new(url.into(), focus, reports, cx)),
113            location: None,
114            title: String::new(),
115            loading: false,
116            focus_window: Some(window.window_handle()),
117            _focus: subscriptions,
118            _reports: task,
119        }
120    }
121
122    /// Focus listeners are per window.
123    fn listen(
124        focus: &FocusHandle,
125        window: &mut Window,
126        cx: &mut Context<Self>,
127    ) -> [Subscription; 2] {
128        [
129            cx.on_focus(focus, window, |this: &mut Self, window, _| {
130                this.page.watch_keys(window);
131                this.page.take_keys()
132            }),
133            cx.on_blur(focus, window, |this: &mut Self, _, _| this.page.give_keys()),
134        ]
135    }
136
137    /// The URL the page last reported; `None` before its first report.
138    pub fn location(&self) -> Option<&str> {
139        self.location.as_deref()
140    }
141
142    pub fn title(&self) -> &str {
143        &self.title
144    }
145
146    pub fn is_loading(&self) -> bool {
147        self.loading
148    }
149
150    /// The user agent the page is built with, in place of the platform's.
151    /// Read at the first paint. On macOS the default is Safari's, with the
152    /// installed Safari's version.
153    pub fn with_user_agent(self, user_agent: impl Into<String>) -> Self {
154        *self.page.user_agent.borrow_mut() = Some(user_agent.into());
155        self
156    }
157
158    /// Before the first paint, replaces the URL the page is built with.
159    /// After it, navigates the page.
160    pub fn load(&mut self, url: impl Into<String>) {
161        self.page.load(url.into());
162    }
163
164    /// Takes the keys back from the page if it holds them, whatever gpui's
165    /// focus is.
166    pub fn release_keys(&self) {
167        self.page.give_keys();
168    }
169
170    pub fn back(&mut self) {
171        self.page.back();
172    }
173
174    pub fn forward(&mut self) {
175        self.page.forward();
176    }
177
178    pub fn reload(&mut self) {
179        self.page.reload();
180    }
181
182    /// Evaluates `script`, a JavaScript expression, in the page's main frame
183    /// and decodes its value from JSON. `undefined` decodes as `null`; a
184    /// promise is not awaited.
185    pub fn eval<T: DeserializeOwned + 'static>(
186        &self,
187        script: &str,
188        timeout: Duration,
189        cx: &App,
190    ) -> Task<Result<T, EvalError>> {
191        let (answer, answered) = async_channel::bounded::<Option<String>>(2);
192        let timer = answer.clone();
193        // WebKit hands the value to `NSJSONSerialization`, which cannot encode
194        // a DOM node or `undefined`, so the page encodes it and this decodes
195        // twice.
196        let script = format!("JSON.stringify((\n{script}\n) ?? null)");
197        if !self.page.eval(&script, move |json| {
198            let _ = answer.try_send(Some(json));
199        }) {
200            return Task::ready(Err(EvalError::Unavailable));
201        }
202        let expired = cx.background_executor().timer(timeout);
203        cx.foreground_executor()
204            .spawn(async move {
205                expired.await;
206                let _ = timer.try_send(None);
207            })
208            .detach();
209        cx.foreground_executor().spawn(async move {
210            let json = answered
211                .recv()
212                .await
213                .ok()
214                .flatten()
215                .ok_or(EvalError::Timeout)?;
216            let json = serde_json::from_str::<Option<String>>(&json)
217                .ok()
218                .flatten()
219                .ok_or(EvalError::Script)?;
220            serde_json::from_str(&json).map_err(EvalError::Decode)
221        })
222    }
223
224    fn report(&mut self, report: Report, cx: &mut Context<Self>) {
225        match report {
226            // Deferred: the window's handlers may update this view.
227            Report::Pressed => {
228                if self.page.holds_keys()
229                    && let Some(window) = self.page.window()
230                {
231                    let focus = self.page.focus.clone();
232                    App::defer(cx, move |cx| {
233                        let _ = window.update(cx, |_, window, cx| window.focus(&focus, cx));
234                    });
235                }
236            }
237            Report::Key(keystroke) => {
238                if let Some(window) = self.page.window() {
239                    App::defer(cx, move |cx| {
240                        let _ = window.update(cx, |_, window, cx| {
241                            window.dispatch_keystroke(keystroke, cx);
242                        });
243                    });
244                }
245            }
246            Report::Moved => {
247                if let Some(url) = self.page.location() {
248                    self.locate(url, cx);
249                }
250            }
251            Report::Load(state, url) => {
252                self.locate(url, cx);
253                self.loading = state == LoadState::Started;
254                cx.emit(WebViewEvent::Load(state));
255            }
256            // Live pages rewrite their title, some every second.
257            Report::Title(title) => {
258                if title != self.title {
259                    self.title = title.clone();
260                    cx.emit(WebViewEvent::Title(title));
261                }
262            }
263            Report::Opened(url) => cx.emit(WebViewEvent::NewWindow(url)),
264            Report::Still(still) => {
265                self.page.captured(still);
266                cx.notify();
267            }
268        }
269    }
270
271    fn locate(&mut self, url: String, cx: &mut Context<Self>) {
272        if self.location.as_ref() != Some(&url) {
273            self.location = Some(url.clone());
274            cx.emit(WebViewEvent::Location(url));
275        }
276    }
277}
278
279impl EventEmitter<WebViewEvent> for WebView {}
280
281impl Focusable for WebView {
282    fn focus_handle(&self, _cx: &App) -> FocusHandle {
283        self.page.focus.clone()
284    }
285}
286
287impl Render for WebView {
288    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
289        if self.focus_window != Some(window.window_handle()) {
290            Page::start(window, cx);
291            self._focus = Self::listen(&self.page.focus, window, cx);
292            self.focus_window = Some(window.window_handle());
293        }
294        if self.page.focus.is_focused(window) {
295            self.page.watch_keys(window);
296        }
297        div()
298            .size_full()
299            .key_context(KEY_CONTEXT)
300            .on_action(on_edit::<Copy>(Edit::Copy, cx))
301            .on_action(on_edit::<Cut>(Edit::Cut, cx))
302            .on_action(on_edit::<Paste>(Edit::Paste, cx))
303            .on_action(on_edit::<SelectAll>(Edit::SelectAll, cx))
304            .on_action(on_edit::<Undo>(Edit::Undo, cx))
305            .on_action(on_edit::<Redo>(Edit::Redo, cx))
306            .child(Host {
307                surface: self.page.clone(),
308            })
309    }
310}
311
312actions!(webview, [Copy, Cut, Paste, SelectAll, Undo, Redo]);
313
314const KEY_CONTEXT: &str = "WebView";
315
316fn on_edit<A: Action>(
317    edit: Edit,
318    cx: &mut Context<WebView>,
319) -> impl Fn(&A, &mut Window, &mut App) + 'static {
320    cx.listener(move |this, _: &A, _, _| this.page.edit(edit))
321}
322
323/// Binds the edit keys in the view's context, once per app.
324fn bind_edits(cx: &mut App) {
325    struct Bound;
326    impl Global for Bound {}
327
328    if !Page::BINDS_EDITS || cx.has_global::<Bound>() {
329        return;
330    }
331    cx.set_global(Bound);
332    let context = Some(KEY_CONTEXT);
333    cx.bind_keys([
334        KeyBinding::new("cmd-c", Copy, context),
335        KeyBinding::new("cmd-x", Cut, context),
336        KeyBinding::new("cmd-v", Paste, context),
337        KeyBinding::new("cmd-a", SelectAll, context),
338        KeyBinding::new("cmd-z", Undo, context),
339        KeyBinding::new("cmd-shift-z", Redo, context),
340    ]);
341}