Skip to main content

browser/
view.rs

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