Skip to main content

gpui_kit/content/
browser.rs

1//! The chrome around an embedded web view.
2//!
3//! This crate draws no web pages. It cannot: rendering one means an engine —
4//! `wry`, CEF, WebKit — and a component library that pulled a browser engine
5//! into every binary that wanted a button would be charging every host for a
6//! feature almost none of them use. So this is the shell only. The host owns
7//! the engine, the navigation and the surface the page is painted on, and
8//! hands the panel a [`ViewportState`] saying what is actually there.
9//!
10//! That split is the point rather than a limitation. The states a web view
11//! spends its life in — loading, empty, unavailable, failed, ready — are the
12//! part a design system should get right and the part every host otherwise
13//! reimplements differently. What is left is a rectangle.
14//!
15//! # What an empty panel means
16//!
17//! A shell with no engine behind it draws [`ViewportState::Unavailable`], not
18//! a blank page. The two look similar and mean opposite things: one is a site
19//! that served nothing, the other is a build that cannot ask. A reader who
20//! cannot tell them apart will retry the wrong one.
21
22use std::rc::Rc;
23
24use gpui::{
25    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
26    Styled, Window, div, prelude::FluentBuilder, px,
27};
28use gpui_kit_assets::Icon;
29use gpui_kit_semantics::{NodeSpec, Role, Semantic};
30use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TypeScale};
31
32use crate::controls::button::IconButton;
33use crate::display::empty::{EmptyKind, EmptyState};
34use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
35use crate::strings::{ActiveStrings, StringKey};
36
37/// What is behind the panel right now.
38///
39/// Five separate answers, kept separate. `Ready` is the only one that shows a
40/// page, and the four that do not each say a different thing about why.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ViewportState {
43    /// A page is on its way.
44    Loading,
45    /// Navigation completed successfully and returned no page content.
46    Empty,
47    /// The host cannot open this address. The reason remains the host's own;
48    /// a refusal is not rewritten as an empty page or a failed navigation.
49    Unavailable(SharedString),
50    /// Navigation failed, in the host's own words.
51    Error(SharedString),
52    /// A page is there and the host is painting it.
53    Ready,
54}
55
56impl Default for ViewportState {
57    fn default() -> Self {
58        // An empty reason selects the localized no-engine explanation. The
59        // panel cannot assume an engine exists until its host says one does.
60        Self::Unavailable(SharedString::default())
61    }
62}
63
64impl ViewportState {
65    /// What the panel publishes as its value.
66    fn value(&self) -> &'static str {
67        match self {
68            Self::Loading => "loading",
69            Self::Empty => "empty",
70            Self::Unavailable(_) => "unavailable",
71            Self::Error(_) => "error",
72            Self::Ready => "ready",
73        }
74    }
75
76    fn shows_page(&self) -> bool {
77        matches!(self, Self::Ready)
78    }
79}
80
81type Action = Rc<dyn Fn(&mut Window, &mut App)>;
82
83/// A framed web view: address, controls, and the rectangle a page goes in.
84#[derive(IntoElement)]
85pub struct BrowserPanel {
86    ident: Ident,
87    /// The address as the host reports it. The panel never edits or completes
88    /// it, because what a typed string resolves to is the engine's decision.
89    url: SharedString,
90    /// Whether the host supplied an address at all. An empty string and "no
91    /// address yet" are different, and only one of them is worth drawing an
92    /// empty bar for.
93    url_set: bool,
94    state: ViewportState,
95    /// Whether history has anywhere to go. A control with nowhere to go is
96    /// disabled, and a disabled control installs no handler.
97    can_go_back: bool,
98    can_go_forward: bool,
99    on_back: Option<Action>,
100    on_forward: Option<Action>,
101    on_reload: Option<Action>,
102    /// The host's own painted surface for the page.
103    viewport: Option<AnyElement>,
104}
105
106impl std::fmt::Debug for BrowserPanel {
107    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        formatter
109            .debug_struct("BrowserPanel")
110            .field("ident", &self.ident)
111            .field("url", &self.url)
112            .field("state", &self.state)
113            .finish_non_exhaustive()
114    }
115}
116
117impl BrowserPanel {
118    pub fn new(ident: impl Into<Ident>) -> Self {
119        Self {
120            ident: ident.into(),
121            url: SharedString::default(),
122            // A panel nobody has told about an engine has not got one, so the
123            // honest default is the one that says so rather than the one that
124            // draws an empty page.
125            state: ViewportState::default(),
126            url_set: false,
127            can_go_back: false,
128            can_go_forward: false,
129            on_back: None,
130            on_forward: None,
131            on_reload: None,
132            viewport: None,
133        }
134    }
135
136    pub fn url(mut self, url: impl Into<SharedString>) -> Self {
137        self.url = url.into();
138        self.url_set = true;
139        self
140    }
141
142    pub fn state(mut self, state: ViewportState) -> Self {
143        self.state = state;
144        self
145    }
146
147    /// The surface the host paints the page onto.
148    pub fn viewport(mut self, viewport: impl IntoElement) -> Self {
149        self.viewport = Some(viewport.into_any_element());
150        self
151    }
152
153    /// Enables the back control and gives it somewhere to go.
154    pub fn on_back(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
155        self.can_go_back = true;
156        self.on_back = Some(Rc::new(handler));
157        self
158    }
159
160    pub fn on_forward(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
161        self.can_go_forward = true;
162        self.on_forward = Some(Rc::new(handler));
163        self
164    }
165
166    pub fn on_reload(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
167        self.on_reload = Some(Rc::new(handler));
168        self
169    }
170}
171
172impl RenderOnce for BrowserPanel {
173    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
174        let theme = cx.theme().clone();
175        let strings = cx.strings().clone();
176        let panel_id = self.ident.semantic_id();
177        let viewport_ident = self.ident.child("viewport");
178        let has_page = self.state.shows_page() && self.viewport.is_some();
179        let state_value = if self.state.shows_page() && self.viewport.is_none() {
180            "error"
181        } else {
182            self.state.value()
183        };
184        let busy = self.state == ViewportState::Loading;
185
186        let control = |ident: Ident, glyph: Icon, name: SharedString, action: Option<Action>| {
187            let mut button = IconButton::new(ident, glyph, name)
188                .ghost()
189                .small()
190                .semantic_parent(panel_id.clone());
191            // A control with nowhere to go is disabled and installs nothing,
192            // rather than being enabled and quietly doing nothing.
193            match action {
194                Some(action) => button = button.on_click(move |window, cx| action(window, cx)),
195                None => button = button.disabled(true),
196            }
197            button
198        };
199
200        let bar = div()
201            .row()
202            .w_full()
203            .items_center()
204            .gap_token(&theme, Space::Xs)
205            .px_token(&theme, Space::Sm)
206            .py(px(theme.spacing.xs))
207            .surface(&theme, Surface::Raised)
208            .child(control(
209                self.ident.child("back"),
210                Icon::ArrowLeft,
211                strings.text(StringKey::BrowserBack),
212                self.can_go_back.then_some(self.on_back).flatten(),
213            ))
214            .child(control(
215                self.ident.child("forward"),
216                Icon::ArrowRight,
217                strings.text(StringKey::BrowserForward),
218                self.can_go_forward.then_some(self.on_forward).flatten(),
219            ))
220            .child(control(
221                self.ident.child("reload"),
222                Icon::Refresh,
223                strings.text(StringKey::BrowserReload),
224                self.on_reload,
225            ))
226            .child(
227                // The address is a well, not a field: this panel does not
228                // accept typing, and a box that looks editable and is not is
229                // worse than one that does not look editable.
230                div()
231                    .flex_1()
232                    .min_w_0()
233                    .px_token(&theme, Space::Sm)
234                    .py(px(theme.spacing.xs / 2.0))
235                    .radius(&theme, Radius::Control)
236                    .well(&theme)
237                    .type_scale(&theme, TypeScale::Caption)
238                    .text_color(if self.url_set {
239                        theme.colors.text_muted
240                    } else {
241                        theme.colors.text_faint
242                    })
243                    .truncate()
244                    .child(if self.url_set {
245                        self.url.clone()
246                    } else {
247                        strings.text(StringKey::BrowserNoAddress)
248                    })
249                    .semantic_in(
250                        cx,
251                        NodeSpec::new(self.ident.child("address").semantic_id(), Role::Text)
252                            .parent(panel_id.clone())
253                            .text(if self.url_set {
254                                self.url.clone()
255                            } else {
256                                strings.text(StringKey::BrowserNoAddress)
257                            }),
258                    ),
259            );
260
261        let body: AnyElement = match &self.state {
262            ViewportState::Ready => self.viewport.unwrap_or_else(|| {
263                // Ready with nothing to draw is a host that said the page was
264                // there and did not hand one over. That is a mistake worth
265                // seeing rather than an empty rectangle worth ignoring.
266                EmptyState::new(
267                    viewport_ident.child("status"),
268                    strings.text(StringKey::BrowserNoViewport),
269                )
270                .kind(EmptyKind::Failed)
271                .detail(strings.text(StringKey::BrowserNoViewportDetail))
272                .into_any_element()
273            }),
274            ViewportState::Loading => div()
275                .size_full()
276                .flex()
277                .items_center()
278                .justify_center()
279                .type_scale(&theme, TypeScale::Caption)
280                .text_color(theme.colors.text_muted)
281                .child(strings.text(StringKey::Loading))
282                .semantic_in(
283                    cx,
284                    NodeSpec::new(viewport_ident.child("status").semantic_id(), Role::Status)
285                        .parent(viewport_ident.semantic_id())
286                        .text(strings.text(StringKey::Loading))
287                        .value("loading")
288                        .busy(true),
289                )
290                .into_any_element(),
291            ViewportState::Empty => EmptyState::new(
292                viewport_ident.child("status"),
293                strings.text(StringKey::BrowserEmpty),
294            )
295            .kind(EmptyKind::Empty)
296            .detail(strings.text(StringKey::BrowserEmptyDetail))
297            .into_any_element(),
298            ViewportState::Unavailable(reason) => EmptyState::new(
299                viewport_ident.child("status"),
300                strings.text(StringKey::BrowserUnavailable),
301            )
302            .kind(EmptyKind::Unavailable)
303            .detail(if reason.is_empty() {
304                strings.text(StringKey::BrowserNoEngineDetail)
305            } else {
306                reason.clone()
307            })
308            .into_any_element(),
309            ViewportState::Error(reason) => EmptyState::new(
310                viewport_ident.child("status"),
311                strings.text(StringKey::BrowserError),
312            )
313            .kind(EmptyKind::Failed)
314            .detail(reason.clone())
315            .into_any_element(),
316        };
317
318        div()
319            .id(self.ident.element_id())
320            .column()
321            .size_full()
322            .overflow_hidden()
323            .radius(&theme, Radius::Card)
324            .frame(&theme, Surface::Panel, Elevation::Raised)
325            .child(bar)
326            .child(
327                div()
328                    .flex_1()
329                    .min_h_0()
330                    .w_full()
331                    .surface(&theme, Surface::Canvas)
332                    .when(!has_page, |element| {
333                        element.flex().items_center().justify_center()
334                    })
335                    .child(body)
336                    .semantic_in(
337                        cx,
338                        NodeSpec::new(viewport_ident.semantic_id(), Role::Region)
339                            .parent(panel_id.clone())
340                            .value(state_value)
341                            .busy(busy),
342                    ),
343            )
344            .semantic_in(
345                cx,
346                NodeSpec::new(panel_id, Role::Group)
347                    .text(strings.text(StringKey::BrowserPanel))
348                    .value(state_value)
349                    .busy(busy),
350            )
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    /// The panel is the shell for an engine it does not have, so the state it
359    /// starts in has to be the one that says so. Defaulting to `Ready` would
360    /// draw an empty page for every host that forgot to set it.
361    #[test]
362    fn a_panel_nobody_has_configured_reports_no_engine() {
363        let panel = BrowserPanel::new("browser");
364        assert_eq!(panel.state, ViewportState::default());
365        assert!(!panel.url_set);
366    }
367
368    #[test]
369    fn only_a_ready_panel_shows_a_page() {
370        assert!(ViewportState::Ready.shows_page());
371        for state in [
372            ViewportState::Loading,
373            ViewportState::Empty,
374            ViewportState::Unavailable("blocked".into()),
375            ViewportState::Error("dns".into()),
376        ] {
377            assert!(!state.shows_page(), "{state:?}");
378        }
379    }
380
381    /// Nothing returned is not the same as something being unavailable or
382    /// failing, and the panel may not report one as another.
383    #[test]
384    fn every_non_ready_state_reports_differently() {
385        let values = [
386            ViewportState::Loading.value(),
387            ViewportState::Empty.value(),
388            ViewportState::Unavailable("no".into()).value(),
389            ViewportState::Error("no".into()).value(),
390        ];
391        assert_eq!(values, ["loading", "empty", "unavailable", "error"]);
392    }
393
394    /// A history control with nowhere to go must not install an action, so
395    /// setting a handler is what makes the direction available.
396    #[test]
397    fn history_is_only_available_once_a_handler_exists() {
398        let panel = BrowserPanel::new("browser");
399        assert!(!panel.can_go_back);
400        assert!(!panel.can_go_forward);
401
402        let panel = BrowserPanel::new("browser").on_back(|_, _| {});
403        assert!(panel.can_go_back);
404        assert!(!panel.can_go_forward);
405    }
406
407    #[test]
408    fn an_address_is_reported_only_once_the_host_supplies_one() {
409        let panel = BrowserPanel::new("browser").url("https://example.com");
410        assert!(panel.url_set);
411        assert_eq!(panel.url, "https://example.com");
412    }
413}