use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TypeScale};
use crate::controls::button::IconButton;
use crate::display::empty::{EmptyKind, EmptyState};
use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
use crate::strings::{ActiveStrings, StringKey};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViewportState {
Loading,
Empty,
Unavailable(SharedString),
Error(SharedString),
Ready,
}
impl Default for ViewportState {
fn default() -> Self {
Self::Unavailable(SharedString::default())
}
}
impl ViewportState {
fn value(&self) -> &'static str {
match self {
Self::Loading => "loading",
Self::Empty => "empty",
Self::Unavailable(_) => "unavailable",
Self::Error(_) => "error",
Self::Ready => "ready",
}
}
fn shows_page(&self) -> bool {
matches!(self, Self::Ready)
}
}
type Action = Rc<dyn Fn(&mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct BrowserPanel {
ident: Ident,
url: SharedString,
url_set: bool,
state: ViewportState,
can_go_back: bool,
can_go_forward: bool,
on_back: Option<Action>,
on_forward: Option<Action>,
on_reload: Option<Action>,
viewport: Option<AnyElement>,
}
impl std::fmt::Debug for BrowserPanel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BrowserPanel")
.field("ident", &self.ident)
.field("url", &self.url)
.field("state", &self.state)
.finish_non_exhaustive()
}
}
impl BrowserPanel {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
url: SharedString::default(),
state: ViewportState::default(),
url_set: false,
can_go_back: false,
can_go_forward: false,
on_back: None,
on_forward: None,
on_reload: None,
viewport: None,
}
}
pub fn url(mut self, url: impl Into<SharedString>) -> Self {
self.url = url.into();
self.url_set = true;
self
}
pub fn state(mut self, state: ViewportState) -> Self {
self.state = state;
self
}
pub fn viewport(mut self, viewport: impl IntoElement) -> Self {
self.viewport = Some(viewport.into_any_element());
self
}
pub fn on_back(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.can_go_back = true;
self.on_back = Some(Rc::new(handler));
self
}
pub fn on_forward(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.can_go_forward = true;
self.on_forward = Some(Rc::new(handler));
self
}
pub fn on_reload(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_reload = Some(Rc::new(handler));
self
}
}
impl RenderOnce for BrowserPanel {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let strings = cx.strings().clone();
let panel_id = self.ident.semantic_id();
let viewport_ident = self.ident.child("viewport");
let has_page = self.state.shows_page() && self.viewport.is_some();
let state_value = if self.state.shows_page() && self.viewport.is_none() {
"error"
} else {
self.state.value()
};
let busy = self.state == ViewportState::Loading;
let control = |ident: Ident, glyph: Icon, name: SharedString, action: Option<Action>| {
let mut button = IconButton::new(ident, glyph, name)
.ghost()
.small()
.semantic_parent(panel_id.clone());
match action {
Some(action) => button = button.on_click(move |window, cx| action(window, cx)),
None => button = button.disabled(true),
}
button
};
let bar = div()
.row()
.w_full()
.items_center()
.gap_token(&theme, Space::Xs)
.px_token(&theme, Space::Sm)
.py(px(theme.spacing.xs))
.surface(&theme, Surface::Raised)
.child(control(
self.ident.child("back"),
Icon::ArrowLeft,
strings.text(StringKey::BrowserBack),
self.can_go_back.then_some(self.on_back).flatten(),
))
.child(control(
self.ident.child("forward"),
Icon::ArrowRight,
strings.text(StringKey::BrowserForward),
self.can_go_forward.then_some(self.on_forward).flatten(),
))
.child(control(
self.ident.child("reload"),
Icon::Refresh,
strings.text(StringKey::BrowserReload),
self.on_reload,
))
.child(
div()
.flex_1()
.min_w_0()
.px_token(&theme, Space::Sm)
.py(px(theme.spacing.xs / 2.0))
.radius(&theme, Radius::Control)
.well(&theme)
.type_scale(&theme, TypeScale::Caption)
.text_color(if self.url_set {
theme.colors.text_muted
} else {
theme.colors.text_faint
})
.truncate()
.child(if self.url_set {
self.url.clone()
} else {
strings.text(StringKey::BrowserNoAddress)
})
.semantic_in(
cx,
NodeSpec::new(self.ident.child("address").semantic_id(), Role::Text)
.parent(panel_id.clone())
.text(if self.url_set {
self.url.clone()
} else {
strings.text(StringKey::BrowserNoAddress)
}),
),
);
let body: AnyElement = match &self.state {
ViewportState::Ready => self.viewport.unwrap_or_else(|| {
EmptyState::new(
viewport_ident.child("status"),
strings.text(StringKey::BrowserNoViewport),
)
.kind(EmptyKind::Failed)
.detail(strings.text(StringKey::BrowserNoViewportDetail))
.into_any_element()
}),
ViewportState::Loading => div()
.size_full()
.flex()
.items_center()
.justify_center()
.type_scale(&theme, TypeScale::Caption)
.text_color(theme.colors.text_muted)
.child(strings.text(StringKey::Loading))
.semantic_in(
cx,
NodeSpec::new(viewport_ident.child("status").semantic_id(), Role::Status)
.parent(viewport_ident.semantic_id())
.text(strings.text(StringKey::Loading))
.value("loading")
.busy(true),
)
.into_any_element(),
ViewportState::Empty => EmptyState::new(
viewport_ident.child("status"),
strings.text(StringKey::BrowserEmpty),
)
.kind(EmptyKind::Empty)
.detail(strings.text(StringKey::BrowserEmptyDetail))
.into_any_element(),
ViewportState::Unavailable(reason) => EmptyState::new(
viewport_ident.child("status"),
strings.text(StringKey::BrowserUnavailable),
)
.kind(EmptyKind::Unavailable)
.detail(if reason.is_empty() {
strings.text(StringKey::BrowserNoEngineDetail)
} else {
reason.clone()
})
.into_any_element(),
ViewportState::Error(reason) => EmptyState::new(
viewport_ident.child("status"),
strings.text(StringKey::BrowserError),
)
.kind(EmptyKind::Failed)
.detail(reason.clone())
.into_any_element(),
};
div()
.id(self.ident.element_id())
.column()
.size_full()
.overflow_hidden()
.radius(&theme, Radius::Card)
.frame(&theme, Surface::Panel, Elevation::Raised)
.child(bar)
.child(
div()
.flex_1()
.min_h_0()
.w_full()
.surface(&theme, Surface::Canvas)
.when(!has_page, |element| {
element.flex().items_center().justify_center()
})
.child(body)
.semantic_in(
cx,
NodeSpec::new(viewport_ident.semantic_id(), Role::Region)
.parent(panel_id.clone())
.value(state_value)
.busy(busy),
),
)
.semantic_in(
cx,
NodeSpec::new(panel_id, Role::Group)
.text(strings.text(StringKey::BrowserPanel))
.value(state_value)
.busy(busy),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panel_nobody_has_configured_reports_no_engine() {
let panel = BrowserPanel::new("browser");
assert_eq!(panel.state, ViewportState::default());
assert!(!panel.url_set);
}
#[test]
fn only_a_ready_panel_shows_a_page() {
assert!(ViewportState::Ready.shows_page());
for state in [
ViewportState::Loading,
ViewportState::Empty,
ViewportState::Unavailable("blocked".into()),
ViewportState::Error("dns".into()),
] {
assert!(!state.shows_page(), "{state:?}");
}
}
#[test]
fn every_non_ready_state_reports_differently() {
let values = [
ViewportState::Loading.value(),
ViewportState::Empty.value(),
ViewportState::Unavailable("no".into()).value(),
ViewportState::Error("no".into()).value(),
];
assert_eq!(values, ["loading", "empty", "unavailable", "error"]);
}
#[test]
fn history_is_only_available_once_a_handler_exists() {
let panel = BrowserPanel::new("browser");
assert!(!panel.can_go_back);
assert!(!panel.can_go_forward);
let panel = BrowserPanel::new("browser").on_back(|_, _| {});
assert!(panel.can_go_back);
assert!(!panel.can_go_forward);
}
#[test]
fn an_address_is_reported_only_once_the_host_supplies_one() {
let panel = BrowserPanel::new("browser").url("https://example.com");
assert!(panel.url_set);
assert_eq!(panel.url, "https://example.com");
}
}