Skip to main content

browser/
view.rs

1use crate::host::{Host, Surface};
2use gpui::{
3    Action, App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Global, IntoElement,
4    KeyBinding, Pixels, Render, Subscription, Task, Window, actions, div, prelude::*,
5};
6use serde::de::DeserializeOwned;
7use std::{
8    cell::{Cell, RefCell},
9    fmt,
10    rc::Rc,
11    time::Duration,
12};
13
14/// A webview showing one page.
15///
16/// The page is built the first time the view is painted, in the window it is
17/// painted in, and stays in that window. It sits at the element's bounds in
18/// every frame the element is painted and is parked in every frame it is not.
19/// A parked page stays loaded.
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, key equivalents (cmd or ctrl
23/// held) reach gpui's key dispatch before the page sees them; on Windows the
24/// page takes every key while it holds focus.
25///
26/// gpui elements behind the page are not hovered, and gpui's cursor over the
27/// page is the arrow. On macOS the page sets the cursor over itself.
28///
29/// Linux needs gpui on X11 and paints nothing under Wayland. Paints nothing
30/// off macOS, Windows and Linux.
31pub struct WebView {
32    page: Rc<Page>,
33    location: Option<String>,
34    title: String,
35    loading: bool,
36    _focus: [Subscription; 2],
37    _reports: Task<()>,
38}
39
40/// What the page reports about itself.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum WebViewEvent {
43    /// The page's URL changed: a load, a redirect, or the page's own history
44    /// move (`pushState`, `replaceState`, a fragment).
45    Location(String),
46    /// The page's title changed.
47    Title(String),
48    Load(LoadState),
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum LoadState {
53    Started,
54    Finished,
55}
56
57/// Why [`WebView::eval`] has no value.
58#[derive(Debug)]
59pub enum EvalError {
60    /// The page is not built yet, or not on this platform.
61    Unavailable,
62    /// No answer within the timeout.
63    Timeout,
64    /// The script threw, or its value has no JSON form.
65    Script,
66    /// The JSON does not decode as the asked type.
67    Decode(serde_json::Error),
68}
69
70impl fmt::Display for EvalError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Unavailable => f.write_str("no page to evaluate in"),
74            Self::Timeout => f.write_str("script timed out"),
75            Self::Script => f.write_str("script threw or returned no JSON value"),
76            Self::Decode(error) => write!(f, "script value: {error}"),
77        }
78    }
79}
80
81impl std::error::Error for EvalError {}
82
83impl WebView {
84    pub fn new(url: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
85        #[cfg(target_os = "linux")]
86        gtk_loop::start(window, cx);
87        bind_edits(cx);
88        let focus = cx.focus_handle();
89        let subscriptions = [
90            cx.on_focus(&focus, window, |this: &mut Self, _, _| {
91                this.page.take_keys()
92            }),
93            cx.on_blur(&focus, window, |this: &mut Self, _, _| {
94                this.page.give_keys()
95            }),
96        ];
97        let (reports, received) = async_channel::unbounded();
98        // Ends when the page drops, which drops every sender.
99        let task = cx.spawn_in(window, async move |this, cx| {
100            while let Ok(report) = received.recv().await {
101                if this
102                    .update_in(cx, |this, window, cx| this.report(report, window, cx))
103                    .is_err()
104                {
105                    return;
106                }
107            }
108        });
109        Self {
110            page: Rc::new(Page::new(url.into(), focus, reports)),
111            location: None,
112            title: String::new(),
113            loading: false,
114            _focus: subscriptions,
115            _reports: task,
116        }
117    }
118
119    /// The URL the page last reported; `None` before its first report.
120    pub fn location(&self) -> Option<&str> {
121        self.location.as_deref()
122    }
123
124    pub fn title(&self) -> &str {
125        &self.title
126    }
127
128    pub fn is_loading(&self) -> bool {
129        self.loading
130    }
131
132    /// The user agent the page is built with, in place of the platform's.
133    /// Read at the first paint. On macOS the default is Safari's, with the
134    /// installed Safari's version.
135    pub fn with_user_agent(self, user_agent: impl Into<String>) -> Self {
136        *self.page.user_agent.borrow_mut() = Some(user_agent.into());
137        self
138    }
139
140    /// Before the first paint, replaces the URL the page is built with.
141    /// After it, navigates the page.
142    pub fn load(&mut self, url: impl Into<String>) {
143        self.page.load(url.into());
144    }
145
146    pub fn back(&mut self) {
147        self.page.back();
148    }
149
150    pub fn forward(&mut self) {
151        self.page.forward();
152    }
153
154    pub fn reload(&mut self) {
155        self.page.reload();
156    }
157
158    /// Evaluates `script`, a JavaScript expression, in the page's main frame
159    /// and decodes its value from JSON. `undefined` decodes as `null`; a
160    /// promise is not awaited.
161    pub fn eval<T: DeserializeOwned + 'static>(
162        &self,
163        script: &str,
164        timeout: Duration,
165        cx: &App,
166    ) -> Task<Result<T, EvalError>> {
167        let (answer, answered) = async_channel::bounded::<Option<String>>(2);
168        let timer = answer.clone();
169        // WebKit hands the value to `NSJSONSerialization`, which cannot encode
170        // a DOM node or `undefined`, so the page encodes it and this decodes
171        // twice.
172        let script = format!("JSON.stringify((\n{script}\n) ?? null)");
173        if !self.page.eval(&script, move |json| {
174            let _ = answer.try_send(Some(json));
175        }) {
176            return Task::ready(Err(EvalError::Unavailable));
177        }
178        let expired = cx.background_executor().timer(timeout);
179        cx.foreground_executor()
180            .spawn(async move {
181                expired.await;
182                let _ = timer.try_send(None);
183            })
184            .detach();
185        cx.foreground_executor().spawn(async move {
186            let json = answered
187                .recv()
188                .await
189                .ok()
190                .flatten()
191                .ok_or(EvalError::Timeout)?;
192            let json = serde_json::from_str::<Option<String>>(&json)
193                .ok()
194                .flatten()
195                .ok_or(EvalError::Script)?;
196            serde_json::from_str(&json).map_err(EvalError::Decode)
197        })
198    }
199
200    fn report(&mut self, report: Report, window: &mut Window, cx: &mut Context<Self>) {
201        match report {
202            Report::Pressed => {
203                if self.page.holds_keys() {
204                    window.focus(&self.page.focus, cx);
205                }
206            }
207            Report::Moved => {
208                if let Some(url) = self.page.location() {
209                    self.locate(url, cx);
210                }
211            }
212            Report::Load(state, url) => {
213                self.locate(url, cx);
214                self.loading = state == LoadState::Started;
215                cx.emit(WebViewEvent::Load(state));
216            }
217            // Live pages rewrite their title, some every second.
218            Report::Title(title) => {
219                if title != self.title {
220                    self.title = title.clone();
221                    cx.emit(WebViewEvent::Title(title));
222                }
223            }
224        }
225    }
226
227    fn locate(&mut self, url: String, cx: &mut Context<Self>) {
228        if self.location.as_ref() != Some(&url) {
229            self.location = Some(url.clone());
230            cx.emit(WebViewEvent::Location(url));
231        }
232    }
233}
234
235impl EventEmitter<WebViewEvent> for WebView {}
236
237impl Focusable for WebView {
238    fn focus_handle(&self, _cx: &App) -> FocusHandle {
239        self.page.focus.clone()
240    }
241}
242
243impl Render for WebView {
244    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
245        div()
246            .size_full()
247            .key_context(KEY_CONTEXT)
248            .on_action(on_edit::<Copy>(Edit::Copy, cx))
249            .on_action(on_edit::<Cut>(Edit::Cut, cx))
250            .on_action(on_edit::<Paste>(Edit::Paste, cx))
251            .on_action(on_edit::<SelectAll>(Edit::SelectAll, cx))
252            .on_action(on_edit::<Undo>(Edit::Undo, cx))
253            .on_action(on_edit::<Redo>(Edit::Redo, cx))
254            .child(Host {
255                surface: self.page.clone(),
256            })
257    }
258}
259
260actions!(webview, [Copy, Cut, Paste, SelectAll, Undo, Redo]);
261
262const KEY_CONTEXT: &str = "WebView";
263
264/// Edits a WKWebView takes as responder actions (`copy:`, `paste:`) and only
265/// through them: without an Edit menu, cmd-c in the page copies nothing.
266#[derive(Clone, Copy)]
267#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
268enum Edit {
269    Copy,
270    Cut,
271    Paste,
272    SelectAll,
273    Undo,
274    Redo,
275}
276
277fn on_edit<A: Action>(
278    edit: Edit,
279    cx: &mut Context<WebView>,
280) -> impl Fn(&A, &mut Window, &mut App) + 'static {
281    cx.listener(move |this, _: &A, _, _| this.page.edit(edit))
282}
283
284/// Binds the edit keys in the view's context, once per app.
285fn bind_edits(cx: &mut App) {
286    struct Bound;
287    impl Global for Bound {}
288
289    if !cfg!(target_os = "macos") || cx.has_global::<Bound>() {
290        return;
291    }
292    cx.set_global(Bound);
293    let context = Some(KEY_CONTEXT);
294    cx.bind_keys([
295        KeyBinding::new("cmd-c", Copy, context),
296        KeyBinding::new("cmd-x", Cut, context),
297        KeyBinding::new("cmd-v", Paste, context),
298        KeyBinding::new("cmd-a", SelectAll, context),
299        KeyBinding::new("cmd-z", Undo, context),
300        KeyBinding::new("cmd-shift-z", Redo, context),
301    ]);
302}
303
304/// Sent from the page's callbacks to the view.
305#[cfg_attr(
306    not(any(target_os = "macos", target_os = "windows", target_os = "linux")),
307    allow(dead_code)
308)]
309enum Report {
310    Pressed,
311    /// The page moved its own history; its URL is read back from the page.
312    Moved,
313    Load(LoadState, String),
314    Title(String),
315}
316
317struct Page {
318    /// What the page is built with; unread once it is built.
319    #[cfg_attr(
320        not(any(target_os = "macos", target_os = "windows", target_os = "linux")),
321        allow(dead_code)
322    )]
323    url: RefCell<String>,
324    #[cfg_attr(
325        not(any(target_os = "macos", target_os = "windows", target_os = "linux")),
326        allow(dead_code)
327    )]
328    user_agent: RefCell<Option<String>>,
329    #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
330    view: std::cell::OnceCell<Option<wry::WebView>>,
331    #[cfg(target_os = "macos")]
332    cursor: std::cell::OnceCell<cursor::Watch>,
333    /// Where the page last sat; `None` before the first paint and while parked.
334    placed: Cell<Option<Bounds<Pixels>>>,
335    owner: Cell<u64>,
336    focus: FocusHandle,
337    #[cfg_attr(
338        not(any(target_os = "macos", target_os = "windows", target_os = "linux")),
339        allow(dead_code)
340    )]
341    reports: async_channel::Sender<Report>,
342    /// Whether the page holds keyboard focus, as WebView2 last reported.
343    #[cfg(any(target_os = "windows", target_os = "linux"))]
344    focused: Rc<Cell<bool>>,
345}
346
347impl Page {
348    fn new(url: String, focus: FocusHandle, reports: async_channel::Sender<Report>) -> Self {
349        Self {
350            url: RefCell::new(url),
351            user_agent: RefCell::new(None),
352            #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
353            view: std::cell::OnceCell::new(),
354            #[cfg(target_os = "macos")]
355            cursor: std::cell::OnceCell::new(),
356            placed: Cell::new(None),
357            owner: Cell::new(0),
358            focus,
359            reports,
360            #[cfg(any(target_os = "windows", target_os = "linux"))]
361            focused: Rc::new(Cell::new(false)),
362        }
363    }
364}
365
366impl Surface for Page {
367    fn focus(&self) -> Option<&FocusHandle> {
368        Some(&self.focus)
369    }
370
371    fn owner(&self) -> &Cell<u64> {
372        &self.owner
373    }
374
375    fn place(&self, bounds: Bounds<Pixels>, window: &Window) {
376        Page::place(self, bounds, window);
377    }
378
379    fn park(&self) {
380        Page::park(self);
381    }
382}
383
384#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
385impl Page {
386    /// Where a parked page waits. Hidden alone, a WKWebView stays registered as
387    /// a drag destination over its last rect and takes every drag that crosses
388    /// it.
389    const PARKED: f64 = -20_000.0;
390
391    /// Run in the main frame, where wry defines `window.ipc`.
392    const MOVED: &str = "(() => {
393        const moved = () => window.ipc.postMessage('moved');
394        for (const name of ['pushState', 'replaceState']) {
395            const original = history[name];
396            history[name] = function (...args) {
397                const result = original.apply(this, args);
398                moved();
399                return result;
400            };
401        }
402        addEventListener('popstate', moved);
403        addEventListener('hashchange', moved);
404    })();";
405
406    fn place(&self, bounds: Bounds<Pixels>, window: &Window) {
407        let view = self.view.get_or_init(|| self.build(bounds, window));
408        let Some(view) = view else { return };
409        let placed = self.placed.get();
410        if placed == Some(bounds) {
411            return;
412        }
413        let _ = view.set_bounds(rect(bounds));
414        let _ = view.set_visible(true);
415        self.placed.set(Some(bounds));
416        if placed.is_none() && self.focus.is_focused(window) {
417            self.take_keys();
418        }
419    }
420
421    fn build(&self, bounds: Bounds<Pixels>, window: &Window) -> Option<wry::WebView> {
422        let url = self.url.borrow();
423        let (ipc, loads, titles) = (
424            self.reports.clone(),
425            self.reports.clone(),
426            self.reports.clone(),
427        );
428        let builder = wry::WebViewBuilder::new()
429            .with_url(url.as_str())
430            .with_bounds(rect(bounds))
431            .with_initialization_script(Self::MOVED)
432            .with_ipc_handler(move |request| {
433                let report = match request.body().as_str() {
434                    "pressed" => Report::Pressed,
435                    "moved" => Report::Moved,
436                    _ => return,
437                };
438                let _ = ipc.try_send(report);
439            })
440            .with_on_page_load_handler(move |event, url| {
441                let state = match event {
442                    wry::PageLoadEvent::Started => LoadState::Started,
443                    wry::PageLoadEvent::Finished => LoadState::Finished,
444                };
445                let _ = loads.try_send(Report::Load(state, url));
446            })
447            .with_document_title_changed_handler(move |title| {
448                let _ = titles.try_send(Report::Title(title));
449            });
450        let user_agent = self.user_agent.borrow().clone().or_else(default_user_agent);
451        let builder = match user_agent {
452            Some(user_agent) => builder.with_user_agent(user_agent),
453            None => builder,
454        };
455        #[cfg(target_os = "linux")]
456        let window = &gtk_loop::Parent::of(window).filter(|_| gtk::is_initialized())?;
457        let view = self
458            .configure(builder)
459            .build_as_child(window)
460            .inspect_err(|error| tracing::warn!(%error, url = %url, "webview: build"))
461            .ok()?;
462        self.attach(&view);
463        Some(view)
464    }
465
466    fn built(&self) -> Option<&wry::WebView> {
467        self.view.get()?.as_ref()
468    }
469
470    fn load(&self, url: String) {
471        match self.built() {
472            Some(view) => {
473                let _ = view.load_url(&url);
474            }
475            None => *self.url.borrow_mut() = url,
476        }
477    }
478
479    fn reload(&self) {
480        if let Some(view) = self.built() {
481            let _ = view.reload();
482        }
483    }
484
485    fn location(&self) -> Option<String> {
486        self.built()?.url().ok()
487    }
488
489    /// Whether the script was handed to the page.
490    fn eval(&self, script: &str, done: impl Fn(String) + Send + 'static) -> bool {
491        self.built()
492            .is_some_and(|view| view.evaluate_script_with_callback(script, done).is_ok())
493    }
494
495    fn take_keys(&self) {
496        let Some(view) = self.built() else {
497            return;
498        };
499        if self.placed.get().is_some() && !closed(view) {
500            let _ = view.focus();
501        }
502    }
503
504    /// Hands keyboard focus back to gpui's view if the page holds it.
505    fn give_keys(&self) {
506        if let Some(view) = self.built()
507            && self.holds_keys()
508        {
509            let _ = view.focus_parent();
510        }
511    }
512
513    fn park(&self) {
514        let Some(view) = self.built() else {
515            return;
516        };
517        let Some(bounds) = self.placed.take() else {
518            return;
519        };
520        self.give_keys();
521        #[cfg(target_os = "macos")]
522        if let Some(cursor) = self.cursor.get() {
523            cursor.release();
524        }
525        let _ = view.set_visible(false);
526        if closed(view) {
527            return;
528        }
529        // At its own size, so the page does not lay out again for a viewport
530        // nobody sees.
531        let _ = view.set_bounds(wry::Rect {
532            position: wry::dpi::LogicalPosition::new(Self::PARKED, Self::PARKED).into(),
533            ..rect(bounds)
534        });
535    }
536}
537
538#[cfg(target_os = "macos")]
539impl Page {
540    /// Run in every frame. Any script in the page can post the same message.
541    const PRESSED: &str = "addEventListener('mousedown', () => \
542        window.webkit.messageHandlers.ipc.postMessage('pressed'), true);";
543
544    fn configure<'a>(&self, builder: wry::WebViewBuilder<'a>) -> wry::WebViewBuilder<'a> {
545        builder.with_initialization_script_for_main_only(Self::PRESSED, false)
546    }
547
548    fn attach(&self, view: &wry::WebView) {
549        use wry::WebViewExtMacOS;
550
551        let _ = self.cursor.set(cursor::Watch::new(&view.webview()));
552    }
553
554    /// Sent down the key window's responder chain, where the page's view is
555    /// first while it holds keys.
556    fn edit(&self, edit: Edit) {
557        use objc2::{MainThreadMarker, sel};
558        use objc2_app_kit::NSApplication;
559
560        if !self.holds_keys() {
561            return;
562        }
563        let Some(mtm) = MainThreadMarker::new() else {
564            return;
565        };
566        let action = match edit {
567            Edit::Copy => sel!(copy:),
568            Edit::Cut => sel!(cut:),
569            Edit::Paste => sel!(paste:),
570            Edit::SelectAll => sel!(selectAll:),
571            Edit::Undo => sel!(undo:),
572            Edit::Redo => sel!(redo:),
573        };
574        // SAFETY: a nil target resolves along the responder chain, and every
575        // action here takes a sender, which may be nil.
576        unsafe { NSApplication::sharedApplication(mtm).sendAction_to_from(action, None, None) };
577    }
578
579    fn back(&self) {
580        use wry::WebViewExtMacOS;
581
582        if let Some(view) = self.built() {
583            // SAFETY: called on the main thread.
584            unsafe { view.webview().goBack() };
585        }
586    }
587
588    fn forward(&self) {
589        use wry::WebViewExtMacOS;
590
591        if let Some(view) = self.built() {
592            // SAFETY: called on the main thread.
593            unsafe { view.webview().goForward() };
594        }
595    }
596
597    /// Whether the page, or a view inside it, is its window's first responder.
598    fn holds_keys(&self) -> bool {
599        use objc2_app_kit::NSView;
600        use wry::WebViewExtMacOS;
601
602        let Some(view) = self.built() else {
603            return false;
604        };
605        let page = view.webview();
606        let Some(window) = page.window() else {
607            return false;
608        };
609        window
610            .firstResponder()
611            .and_then(|responder| responder.downcast::<NSView>().ok())
612            .is_some_and(|responder| responder.isDescendantOf(&page))
613    }
614}
615
616/// A WKWebView reports `AppleWebKit/605.1.15 (KHTML, like Gecko)` and no
617/// browser, and some sites (Google) serve an unknown browser a basic page.
618#[cfg(target_os = "macos")]
619fn default_user_agent() -> Option<String> {
620    use objc2_foundation::{NSBundle, NSString};
621
622    let version = NSBundle::bundleWithPath(&NSString::from_str("/Applications/Safari.app"))
623        .and_then(|safari| {
624            safari.objectForInfoDictionaryKey(&NSString::from_str("CFBundleShortVersionString"))
625        })
626        .and_then(|version| version.downcast::<NSString>().ok())
627        .map_or_else(|| "26.0".to_owned(), |version| version.to_string());
628    Some(format!(
629        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 \
630         (KHTML, like Gecko) Version/{version} Safari/605.1.15"
631    ))
632}
633
634#[cfg(any(target_os = "windows", target_os = "linux"))]
635fn default_user_agent() -> Option<String> {
636    None
637}
638
639/// Hands the cursor to WebKit while the pointer is over the page.
640///
641/// gpui's view registers a cursor rect over its whole visible rect, the page's
642/// pixels included, and AppKit applies it over the cursor WebKit sets. While
643/// the pointer is inside the page the window's cursor rects are disabled.
644#[cfg(target_os = "macos")]
645mod cursor {
646    use objc2::{
647        AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send,
648        rc::Retained,
649        runtime::{NSObject, NSObjectProtocol},
650    };
651    use objc2_app_kit::{NSEvent, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow};
652    use objc2_foundation::NSRect;
653    use std::cell::Cell;
654
655    pub(super) struct Watch {
656        page: Retained<NSView>,
657        area: Retained<NSTrackingArea>,
658        /// A tracking area does not retain its owner.
659        watcher: Retained<Watcher>,
660    }
661
662    impl Watch {
663        pub(super) fn new(page: &NSView) -> Self {
664            let mtm = MainThreadMarker::new().expect("the page is built on the main thread");
665            let watcher: Retained<Watcher> = {
666                let watcher = Watcher::alloc(mtm).set_ivars(Inside::default());
667                // SAFETY: `NSObject`'s `init` on a freshly allocated instance.
668                unsafe { msg_send![super(watcher), init] }
669            };
670            // SAFETY: the watcher answers `mouseEntered:` and `mouseExited:`
671            // and outlives the area, which `Drop` removes first.
672            let area = unsafe {
673                NSTrackingArea::initWithRect_options_owner_userInfo(
674                    NSTrackingArea::alloc(),
675                    NSRect::ZERO,
676                    NSTrackingAreaOptions::MouseEnteredAndExited
677                        | NSTrackingAreaOptions::ActiveAlways
678                        | NSTrackingAreaOptions::InVisibleRect,
679                    Some(&watcher),
680                    None,
681                )
682            };
683            page.addTrackingArea(&area);
684            Self {
685                page: page.retain(),
686                area,
687                watcher,
688            }
689        }
690
691        /// Gives the cursor back to gpui if the pointer was inside the page.
692        /// A hidden view reports no exit.
693        pub(super) fn release(&self) {
694            if self.watcher.ivars().0.replace(false)
695                && let Some(window) = self.page.window()
696            {
697                enable(&window);
698            }
699        }
700    }
701
702    impl Drop for Watch {
703        fn drop(&mut self) {
704            self.page.removeTrackingArea(&self.area);
705            self.release();
706        }
707    }
708
709    #[derive(Default)]
710    struct Inside(Cell<bool>);
711
712    define_class!(
713        #[unsafe(super(NSObject))]
714        #[thread_kind = MainThreadOnly]
715        #[name = "BezelWebViewCursorWatcher"]
716        #[ivars = Inside]
717        struct Watcher;
718
719        unsafe impl NSObjectProtocol for Watcher {}
720
721        impl Watcher {
722            #[unsafe(method(mouseEntered:))]
723            fn entered(&self, event: &NSEvent) {
724                self.ivars().0.set(true);
725                if let Some(window) = event.window(self.mtm()) {
726                    window.disableCursorRects();
727                }
728            }
729
730            #[unsafe(method(mouseExited:))]
731            fn exited(&self, event: &NSEvent) {
732                if self.ivars().0.replace(false)
733                    && let Some(window) = event.window(self.mtm())
734                {
735                    enable(&window);
736                }
737            }
738        }
739    );
740
741    fn enable(window: &NSWindow) {
742        window.enableCursorRects();
743        if let Some(content) = window.contentView() {
744            window.invalidateCursorRectsForView(&content);
745        }
746    }
747}
748
749/// Whether the page's window has closed. wry's `set_bounds` and `focus`
750/// unwrap the window.
751#[cfg(target_os = "macos")]
752fn closed(view: &wry::WebView) -> bool {
753    use wry::WebViewExtMacOS;
754
755    view.webview().window().is_none()
756}
757
758#[cfg(target_os = "windows")]
759impl Page {
760    fn configure<'a>(&self, builder: wry::WebViewBuilder<'a>) -> wry::WebViewBuilder<'a> {
761        builder
762    }
763
764    /// WebView2 reports focus itself, so a press needs no script.
765    fn attach(&self, view: &wry::WebView) {
766        use webview2_com::FocusChangedEventHandler;
767        use wry::WebViewExtWindows;
768
769        let controller = view.controller();
770        let (got, lost) = (self.focused.clone(), self.focused.clone());
771        let pressed = self.reports.clone();
772        let mut token = 0;
773        // SAFETY: called on the thread that owns the controller.
774        unsafe {
775            let _ = controller.add_GotFocus(
776                &FocusChangedEventHandler::create(Box::new(move |_, _| {
777                    got.set(true);
778                    let _ = pressed.try_send(Report::Pressed);
779                    Ok(())
780                })),
781                &mut token,
782            );
783            let _ = controller.add_LostFocus(
784                &FocusChangedEventHandler::create(Box::new(move |_, _| {
785                    lost.set(false);
786                    Ok(())
787                })),
788                &mut token,
789            );
790        }
791    }
792
793    fn back(&self) {
794        use wry::WebViewExtWindows;
795
796        if let Some(view) = self.built() {
797            // SAFETY: called on the thread that owns the webview.
798            let _ = unsafe { view.webview().GoBack() };
799        }
800    }
801
802    fn forward(&self) {
803        use wry::WebViewExtWindows;
804
805        if let Some(view) = self.built() {
806            // SAFETY: called on the thread that owns the webview.
807            let _ = unsafe { view.webview().GoForward() };
808        }
809    }
810
811    fn holds_keys(&self) -> bool {
812        self.focused.get()
813    }
814
815    fn edit(&self, _edit: Edit) {}
816}
817
818#[cfg(target_os = "windows")]
819fn closed(_view: &wry::WebView) -> bool {
820    false
821}
822
823#[cfg(target_os = "linux")]
824impl Page {
825    fn configure<'a>(&self, builder: wry::WebViewBuilder<'a>) -> wry::WebViewBuilder<'a> {
826        builder
827    }
828
829    /// GTK reports focus itself, so a press needs no script.
830    fn attach(&self, view: &wry::WebView) {
831        use gtk::{glib::Propagation, prelude::WidgetExt};
832        use wry::WebViewExtUnix;
833
834        let page = view.webview();
835        let (got, lost) = (self.focused.clone(), self.focused.clone());
836        let pressed = self.reports.clone();
837        page.connect_focus_in_event(move |_, _| {
838            got.set(true);
839            let _ = pressed.try_send(Report::Pressed);
840            Propagation::Proceed
841        });
842        page.connect_focus_out_event(move |_, _| {
843            lost.set(false);
844            Propagation::Proceed
845        });
846    }
847
848    fn back(&self) {
849        use webkit2gtk::WebViewExt;
850        use wry::WebViewExtUnix;
851
852        if let Some(view) = self.built() {
853            view.webview().go_back();
854        }
855    }
856
857    fn forward(&self) {
858        use webkit2gtk::WebViewExt;
859        use wry::WebViewExtUnix;
860
861        if let Some(view) = self.built() {
862            view.webview().go_forward();
863        }
864    }
865
866    fn holds_keys(&self) -> bool {
867        self.focused.get()
868    }
869
870    fn edit(&self, _edit: Edit) {}
871}
872
873#[cfg(target_os = "linux")]
874fn closed(_view: &wry::WebView) -> bool {
875    false
876}
877
878/// wry's webview on Linux is webkit2gtk, driven by GTK's main loop, which gpui
879/// does not run.
880#[cfg(target_os = "linux")]
881mod gtk_loop {
882    use gpui::{App, Window};
883    use raw_window_handle::{
884        HandleError, HasWindowHandle, RawWindowHandle, WindowHandle, XlibWindowHandle,
885    };
886    use std::{os::raw::c_ulong, time::Duration};
887
888    /// How often GTK's pending events are dispatched. Page input, painting
889    /// and callbacks wait for it.
890    const PUMP: Duration = Duration::from_millis(8);
891
892    /// Initializes GTK on X11 and starts pumping its events, once per process.
893    /// Does nothing under Wayland, or when GTK is already initialized: the
894    /// host runs the loop then.
895    pub(super) fn start(window: &Window, cx: &mut App) {
896        if gtk::is_initialized() || Parent::of(window).is_none() {
897            return;
898        }
899        gtk::gdk::set_allowed_backends("x11");
900        if let Err(error) = gtk::init() {
901            tracing::warn!(%error, "webview: gtk");
902            return;
903        }
904        let timers = cx.background_executor().clone();
905        cx.foreground_executor()
906            .spawn(async move {
907                loop {
908                    timers.timer(PUMP).await;
909                    while gtk::events_pending() {
910                        gtk::main_iteration_do(false);
911                    }
912                }
913            })
914            .detach();
915    }
916
917    /// gpui's X11 window, as the Xlib handle wry takes. gpui hands out an XCB
918    /// one for the same window.
919    pub(super) struct Parent(XlibWindowHandle);
920
921    impl Parent {
922        /// `None` under Wayland.
923        pub(super) fn of(window: &Window) -> Option<Self> {
924            match HasWindowHandle::window_handle(window).ok()?.as_raw() {
925                RawWindowHandle::Xcb(handle) => Some(Self(XlibWindowHandle::new(c_ulong::from(
926                    handle.window.get(),
927                )))),
928                _ => None,
929            }
930        }
931    }
932
933    impl HasWindowHandle for Parent {
934        fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
935            // SAFETY: an X window id, valid while gpui's window is.
936            Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Xlib(self.0)) })
937        }
938    }
939}
940
941#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
942impl Page {
943    fn place(&self, bounds: Bounds<Pixels>, _window: &Window) {
944        self.placed.set(Some(bounds));
945    }
946
947    fn load(&self, url: String) {
948        *self.url.borrow_mut() = url;
949    }
950
951    fn back(&self) {}
952
953    fn forward(&self) {}
954
955    fn reload(&self) {}
956
957    fn location(&self) -> Option<String> {
958        None
959    }
960
961    fn eval(&self, _script: &str, _done: impl Fn(String) + Send + 'static) -> bool {
962        false
963    }
964
965    fn holds_keys(&self) -> bool {
966        false
967    }
968
969    fn edit(&self, _edit: Edit) {}
970
971    fn park(&self) {
972        self.placed.set(None);
973    }
974
975    fn take_keys(&self) {}
976
977    fn give_keys(&self) {}
978}
979
980#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
981fn rect(bounds: Bounds<Pixels>) -> wry::Rect {
982    wry::Rect {
983        position: wry::dpi::LogicalPosition::new(
984            f64::from(f32::from(bounds.origin.x)),
985            f64::from(f32::from(bounds.origin.y)),
986        )
987        .into(),
988        size: wry::dpi::LogicalSize::new(
989            f64::from(f32::from(bounds.size.width)),
990            f64::from(f32::from(bounds.size.height)),
991        )
992        .into(),
993    }
994}