use std::f32::consts::PI; use gpui::{ Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, Length, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::*, radians, }; use smallvec::SmallVec; use web_time::Instant; use super::{BASE, FAST, NUDGE, Skeleton, Spinner, changes, duration, ease_out_cubic}; use crate::{ primitives::{Icon, IconName, IntersectionObserver}, theme::{ActiveTheme, IconSize, TextSize}, }; /// Veils what it holds with a spinner while `loading`; presses stop at the veil. It fades in and out. #[derive(IntoElement)] pub struct LoadingOverlay { id: ElementId, base: Div, loading: bool, label: Option, body: SmallVec<[AnyElement; 2]>, } impl LoadingOverlay { pub fn new(id: impl Into, loading: bool) -> Self { Self { id: id.into(), base: div(), loading, label: None, body: SmallVec::new(), } } pub fn label(mut self, text: impl Into) -> Self { self.label = Some(text.into()); self } } impl Styled for LoadingOverlay { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl ParentElement for LoadingOverlay { fn extend(&mut self, elements: impl IntoIterator) { self.body.extend(elements); } } impl RenderOnce for LoadingOverlay { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let loading = self.loading; let turn = changes((self.id.clone(), "loading"), loading, window, cx); let fade = duration(FAST, cx); let since = window.use_keyed_state((self.id.clone(), "since"), cx, |_, _| { (loading, Instant::now()) }); if since.read(cx).0 != loading { since.update(cx, |since, _| *since = (loading, Instant::now())); } let fading = !loading && turn > 0 && since.read(cx).1.elapsed() < fade; let radii = self.base.style().corner_radii.clone(); let theme = cx.theme(); let veil = div() .id((self.id.clone(), "veil")) .absolute() .inset_0() .flex() .flex_col() .items_center() .justify_center() .gap_2() .bg(theme.colors.bg.opacity(0.72)) .text_size(theme.text_size(TextSize::Sm)) .text_color(theme.colors.fg_muted) .when(loading, |veil| veil.occlude()) .child(Spinner::new((self.id.clone(), "spinner")).size(IconSize::Lg)) .children(self.label) .map(|mut veil| { veil.style().corner_radii = radii; veil }) .with_animation( (self.id.clone(), format!("veil-{turn}")), Animation::new(fade).with_easing(ease_out_cubic), move |veil, t| match (turn, loading) { (0, _) => veil, (_, true) => veil.opacity(t), (_, false) => veil.opacity(1.0 - t), }, ); self.base .id(self.id) .relative() .children(self.body) .when(loading || fading, |host| host.child(veil)) } } type Build = Box AnyElement>; /// Builds its content once it first scrolls into view; until then a skeleton holds its place. #[derive(IntoElement)] pub struct LazyLoad { id: ElementId, height: Length, build: Build, } impl LazyLoad { /// `height` is the placeholder's, before the content is built. pub fn new( id: impl Into, height: impl Into, build: impl FnOnce(&mut Window, &mut App) -> E + 'static, ) -> Self { Self { id: id.into(), height: height.into(), build: Box::new(move |window, cx| build(window, cx).into_any_element()), } } } impl RenderOnce for LazyLoad { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let seen = window.use_keyed_state((self.id.clone(), "seen"), cx, |_, _| false); if *seen.read(cx) { return (self.build)(window, cx); } let id = self.id.clone(); IntersectionObserver::new((self.id.clone(), "watch"), move |visible, _, cx| { if visible { log::debug!("lazy load {id:?}: in view, building"); seen.update(cx, |seen, cx| { *seen = true; cx.notify(); }); } }) .child( Skeleton::new((self.id, "placeholder")) .w_full() .h(self.height), ) .into_any_element() } } /// Where a pull to refresh stands. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Refresh { Idle, /// Pulled this far toward the point where it lets go, 0 to 1. Pulling(f32), Refreshing, Done, } /// The mark at the top of a list that refreshes: an arrow that turns with the pull, a spinner while it works, a check when done. #[derive(IntoElement)] pub struct RefreshIndicator { id: ElementId, state: Refresh, } impl RefreshIndicator { pub fn new(id: impl Into, state: Refresh) -> Self { if let Refresh::Pulling(pull) = state { assert!((0.0..=1.0).contains(&pull), "pull {pull} is not 0..=1"); } Self { id: id.into(), state, } } } impl RenderOnce for RefreshIndicator { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let phase = std::mem::discriminant(&self.state); let turn = changes((self.id.clone(), "phase"), phase, window, cx); let theme = cx.theme(); let muted = theme.colors.fg_muted; let mark = match self.state { Refresh::Idle => return div().id(self.id).into_any_element(), Refresh::Pulling(pull) => Icon::new(IconName::RefreshCw) .size(IconSize::Md) .color(muted.opacity(0.35 + 0.65 * pull)) .rotate(radians(PI * 1.5 * pull)) .into_any_element(), Refresh::Refreshing => Spinner::new((self.id.clone(), "spinner")).into_any_element(), Refresh::Done => div() .flex() .items_center() .gap_1p5() .text_size(theme.text_size(TextSize::Sm)) .text_color(muted) .child(Icon::new(IconName::Check).size(IconSize::Sm).color(muted)) .child("Up to date") .into_any_element(), }; div() .id(self.id.clone()) .flex() .justify_center() .py_2() .child(mark) .with_animation( (self.id, format!("phase-{turn}")), Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic), move |row, t| { if turn == 0 { row } else { row.opacity(t).mt(NUDGE * (t - 1.0)) } }, ) .into_any_element() } } /// Someone is typing: three dots bob in a small bubble, beside who it is when named. #[derive(IntoElement)] pub struct TypingIndicator { id: ElementId, who: Option, } impl TypingIndicator { pub fn new(id: impl Into) -> Self { Self { id: id.into(), who: None, } } /// Names who is typing, as "Mia is typing…". pub fn who(mut self, name: impl Into) -> Self { self.who = Some(name.into()); self } } impl RenderOnce for TypingIndicator { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let muted = theme.colors.fg_muted; div() .flex() .items_center() .gap_2() .child( div() .px_3() .py_2() .rounded_full() .bg(theme.colors.sunken) .child( Spinner::new(self.id) .style(super::SpinnerStyle::Wave) .size(IconSize::Lg) .color(muted), ), ) .when_some(self.who, |row, who| { row.child( div() .text_size(theme.text_size(TextSize::Sm)) .text_color(muted) .child(format!("{who} is typing…")), ) }) } }