1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ViewportState {
43 Loading,
45 Empty,
47 Unavailable(SharedString),
50 Error(SharedString),
52 Ready,
54}
55
56impl Default for ViewportState {
57 fn default() -> Self {
58 Self::Unavailable(SharedString::default())
61 }
62}
63
64impl ViewportState {
65 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#[derive(IntoElement)]
85pub struct BrowserPanel {
86 ident: Ident,
87 url: SharedString,
90 url_set: bool,
94 state: ViewportState,
95 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 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 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 pub fn viewport(mut self, viewport: impl IntoElement) -> Self {
149 self.viewport = Some(viewport.into_any_element());
150 self
151 }
152
153 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 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 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 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 #[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 #[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 #[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}