use crate::theme::{ActiveTheme, Themeable};
use gpui::{
div, prelude::FluentBuilder, rems, App, AsyncApp, EntityId, Hsla, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Task, Window,
};
use std::cell::RefCell;
use std::time::Duration;
const SUBSCRIBER_GRACE_FRAMES: u32 = 4;
pub fn loading_indicator() -> LoadingIndicator {
LoadingIndicator::new()
}
#[derive(Debug, Clone, Copy, Default)]
pub enum LoadingIndicatorVariant {
#[default]
Dots,
Ellipsis,
Dash,
Star,
Triangle,
Braille,
BrailleExtended,
}
impl LoadingIndicatorVariant {
fn frames(&self) -> &'static [&'static str] {
match self {
LoadingIndicatorVariant::Dots => &[". ", ".. ", "..."],
LoadingIndicatorVariant::Ellipsis => &[" ", ". ", ".. ", "...", ".. ", ". "],
LoadingIndicatorVariant::Dash => &["-", "\\", "|", "/"],
LoadingIndicatorVariant::Star => &["❊", "❊", "✳︎", "※"],
LoadingIndicatorVariant::Triangle => &["◢", "◣", "◤", "◥"],
LoadingIndicatorVariant::Braille => &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
LoadingIndicatorVariant::BrailleExtended => &[
"⡀", "⡁", "⡂", "⡃", "⡄", "⡅", "⡆", "⡇", "⡈", "⡉", "⡊", "⡋", "⡌", "⡍", "⡎", "⡏",
"⡐", "⡑", "⡒", "⡓", "⡔", "⡕", "⡖", "⡗", "⡘", "⡙", "⡚", "⡛", "⡜", "⡝", "⡞", "⡟",
"⡠", "⡡", "⡢", "⡣", "⡤", "⡥", "⡦", "⡧", "⡨", "⡩", "⡪", "⡫", "⡬", "⡭", "⡮", "⡯",
"⡰", "⡱", "⡲", "⡳", "⡴", "⡵", "⡶", "⡷", "⡸", "⡹", "⡺", "⡻", "⡼", "⡽", "⡾", "⡿",
"⢀", "⢁", "⢂", "⢃", "⢄", "⢅", "⢆", "⢇", "⢈", "⢉", "⢊", "⢋", "⢌", "⢍", "⢎", "⢏",
"⢐", "⢑", "⢒", "⢓", "⢔", "⢕", "⢖", "⢗", "⢘", "⢙", "⢚", "⢛", "⢜", "⢝", "⢞", "⢟",
"⢠", "⢡", "⢢", "⢣", "⢤", "⢥", "⢦", "⢧", "⢨", "⢩", "⢪", "⢫", "⢬", "⢭", "⢮", "⢯",
"⢰", "⢱", "⢲", "⢳", "⢴", "⢵", "⢶", "⢷", "⢸", "⢹", "⢺", "⢻", "⢼", "⢽", "⢾", "⢿",
"⣀", "⣁", "⣂", "⣃", "⣄", "⣅", "⣆", "⣇", "⣈", "⣉", "⣊", "⣋", "⣌", "⣍", "⣎", "⣏",
"⣐", "⣑", "⣒", "⣓", "⣔", "⣕", "⣖", "⣗", "⣘", "⣙", "⣚", "⣛", "⣜", "⣝", "⣞", "⣟",
"⣠", "⣡", "⣢", "⣣", "⣤", "⣥", "⣦", "⣧", "⣨", "⣩", "⣪", "⣫", "⣬", "⣭", "⣮", "⣯",
"⣰", "⣱", "⣲", "⣳", "⣴", "⣵", "⣶", "⣷", "⣸", "⣹", "⣺", "⣻", "⣼", "⣽", "⣾", "⣿",
],
}
}
fn duration(&self) -> Duration {
match self {
LoadingIndicatorVariant::Dots => Duration::from_millis(1500),
LoadingIndicatorVariant::Ellipsis => Duration::from_millis(1800),
LoadingIndicatorVariant::Dash => Duration::from_millis(400),
LoadingIndicatorVariant::Star => Duration::from_millis(1000),
LoadingIndicatorVariant::Triangle => Duration::from_millis(1200),
LoadingIndicatorVariant::Braille => Duration::from_millis(1000),
LoadingIndicatorVariant::BrailleExtended => Duration::from_millis(30000),
}
}
fn frame_period(&self) -> Duration {
self.duration() / self.frames().len() as u32
}
fn char_width(&self) -> usize {
self.frames()
.iter()
.map(|f| f.chars().count())
.max()
.unwrap_or(1)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum LoadingIndicatorSize {
XSmall,
Small,
#[default]
Medium,
Large,
}
#[derive(IntoElement)]
pub struct LoadingIndicator {
variant: LoadingIndicatorVariant,
size: LoadingIndicatorSize,
color: Option<Hsla>,
playing: bool,
}
impl LoadingIndicator {
pub fn new() -> Self {
Self {
variant: LoadingIndicatorVariant::default(),
size: LoadingIndicatorSize::default(),
color: None,
playing: true,
}
}
pub fn variant(mut self, variant: LoadingIndicatorVariant) -> Self {
self.variant = variant;
self
}
pub fn dots(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Dots;
self
}
pub fn ellipsis(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Ellipsis;
self
}
pub fn dash(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Dash;
self
}
pub fn star(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Star;
self
}
pub fn triangle(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Triangle;
self
}
pub fn braille(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Braille;
self
}
pub fn braille_extended(mut self) -> Self {
self.variant = LoadingIndicatorVariant::BrailleExtended;
self
}
pub fn size(mut self, size: LoadingIndicatorSize) -> Self {
self.size = size;
self
}
pub fn xsmall(mut self) -> Self {
self.size = LoadingIndicatorSize::XSmall;
self
}
pub fn small(mut self) -> Self {
self.size = LoadingIndicatorSize::Small;
self
}
pub fn medium(mut self) -> Self {
self.size = LoadingIndicatorSize::Medium;
self
}
pub fn large(mut self) -> Self {
self.size = LoadingIndicatorSize::Large;
self
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
pub fn playing(mut self, playing: bool) -> Self {
self.playing = playing;
self
}
}
impl Default for LoadingIndicator {
fn default() -> Self {
Self::new()
}
}
impl RenderOnce for LoadingIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let color = self.color.unwrap_or_else(|| theme.accent());
let frames = self.variant.frames();
let glyph = if self.playing && !cx.reduce_motion() {
let period = self.variant.frame_period();
let view = window.current_view();
let elapsed = LoadingClock::subscribe(view, period, cx);
frames[(frame_index(elapsed, period) % frames.len() as u64) as usize]
} else {
frames[0]
};
let size = self.size;
let width_rems = self.variant.char_width() as f32 * 0.6;
div()
.text_color(color)
.flex_none()
.min_w(rems(width_rems))
.text_center()
.when(matches!(size, LoadingIndicatorSize::XSmall), |this| {
this.text_xs()
})
.when(matches!(size, LoadingIndicatorSize::Small), |this| {
this.text_sm()
})
.when(matches!(size, LoadingIndicatorSize::Medium), |this| {
this.text_base()
})
.when(matches!(size, LoadingIndicatorSize::Large), |this| {
this.text_xl()
})
.child(SharedString::new_static(glyph))
}
}
fn frame_index(elapsed: Duration, period: Duration) -> u64 {
debug_assert!(!period.is_zero());
(elapsed.as_nanos() / period.as_nanos().max(1)) as u64
}
struct Subscriber {
view: EntityId,
period: Duration,
last_frame: u64,
idle_frames: u32,
}
#[derive(Default)]
struct LoadingClock {
elapsed: Duration,
subscribers: Vec<Subscriber>,
running: bool,
driver: Option<Task<()>>,
}
thread_local! {
static LOADING_CLOCK: RefCell<LoadingClock> = RefCell::new(LoadingClock::default());
}
fn with_clock<R>(f: impl FnOnce(&mut LoadingClock) -> R) -> R {
LOADING_CLOCK.with(|clock| f(&mut clock.borrow_mut()))
}
impl LoadingClock {
fn subscribe(view: EntityId, period: Duration, cx: &mut App) -> Duration {
let (elapsed, start_driver) = with_clock(|clock| {
clock.register(view, period);
let start_driver = !clock.running;
clock.running = true;
(clock.elapsed, start_driver)
});
if start_driver {
let driver = cx.spawn(async move |cx| Self::drive(cx).await);
with_clock(|clock| clock.driver = Some(driver));
}
elapsed
}
fn register(&mut self, view: EntityId, period: Duration) {
if let Some(existing) = self
.subscribers
.iter_mut()
.find(|s| s.view == view && s.period == period)
{
existing.idle_frames = 0;
return;
}
self.subscribers.push(Subscriber {
view,
period,
last_frame: frame_index(self.elapsed, period),
idle_frames: 0,
});
}
fn next_interval(&self) -> Option<Duration> {
let elapsed = self.elapsed.as_nanos();
self.subscribers
.iter()
.map(|s| {
let period = s.period.as_nanos().max(1);
period - (elapsed % period)
})
.min()
.map(|nanos| Duration::from_nanos(nanos as u64))
}
fn tick(&mut self, interval: Duration) -> Vec<EntityId> {
self.elapsed += interval;
let elapsed = self.elapsed;
let mut due: Vec<EntityId> = Vec::new();
self.subscribers.retain_mut(|subscriber| {
let frame = frame_index(elapsed, subscriber.period);
if frame == subscriber.last_frame {
return true;
}
subscriber.last_frame = frame;
subscriber.idle_frames += 1;
if subscriber.idle_frames > SUBSCRIBER_GRACE_FRAMES {
return false;
}
if !due.contains(&subscriber.view) {
due.push(subscriber.view);
}
true
});
due
}
async fn drive(cx: &mut AsyncApp) {
while let Some(interval) = with_clock(|clock| clock.next_interval()) {
cx.background_executor().timer(interval).await;
let due = with_clock(|clock| clock.tick(interval));
if due.is_empty() {
continue;
}
cx.update(|cx| {
for view in due {
cx.notify(view);
}
});
}
with_clock(|clock| clock.running = false);
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{px, Context, Render, TestAppContext};
fn reset_clock() {
with_clock(|clock| {
clock.elapsed = Duration::ZERO;
clock.subscribers.clear();
clock.driver = None;
clock.running = false;
});
}
fn view(id: u64) -> EntityId {
EntityId::from(id)
}
struct Indicators;
impl Render for Indicators {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size(px(200.))
.child(loading_indicator().dash())
.child(loading_indicator().braille())
.child(loading_indicator().dots())
}
}
struct Paused;
impl Render for Paused {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size(px(200.))
.child(loading_indicator().braille().playing(false))
}
}
#[gpui::test]
fn indicators_do_not_request_a_frame_per_display_frame(cx: &mut TestAppContext) {
reset_clock();
cx.update(crate::theme::init);
let (_view, cx) = cx.add_window_view(|_window, _cx| Indicators);
let requested = cx.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!(requested, 0, "an indicator asked for another frame");
}
#[gpui::test]
fn the_shared_clock_advances_over_time(cx: &mut TestAppContext) {
reset_clock();
cx.update(crate::theme::init);
let cx = cx.add_window_view(|_window, _cx| Indicators).1;
assert!(with_clock(|clock| clock.running), "the clock never started");
assert_eq!(with_clock(|clock| clock.elapsed), Duration::ZERO);
cx.executor().advance_clock(Duration::from_millis(300));
assert!(
with_clock(|clock| clock.elapsed) >= Duration::from_millis(300),
"the shared timeline did not move"
);
}
#[gpui::test]
fn a_paused_indicator_subscribes_to_nothing(cx: &mut TestAppContext) {
reset_clock();
cx.update(crate::theme::init);
cx.add_window_view(|_window, _cx| Paused);
assert!(with_clock(|clock| clock.subscribers.is_empty()));
assert!(!with_clock(|clock| clock.running));
}
#[test]
fn every_variant_divides_its_cycle_evenly() {
for variant in [
LoadingIndicatorVariant::Dots,
LoadingIndicatorVariant::Ellipsis,
LoadingIndicatorVariant::Dash,
LoadingIndicatorVariant::Star,
LoadingIndicatorVariant::Triangle,
LoadingIndicatorVariant::Braille,
LoadingIndicatorVariant::BrailleExtended,
] {
let frames = variant.frames().len() as u32;
assert_eq!(
variant.frame_period() * frames,
variant.duration(),
"{variant:?} loses time to rounding"
);
}
}
#[test]
fn a_tick_wakes_only_the_views_whose_glyph_changed() {
let (fast, slow) = (view(1), view(2));
let mut clock = LoadingClock::default();
clock.register(fast, Duration::from_millis(100));
clock.register(slow, Duration::from_millis(250));
assert_eq!(clock.next_interval(), Some(Duration::from_millis(100)));
assert_eq!(clock.tick(Duration::from_millis(100)), vec![fast]);
clock.register(fast, Duration::from_millis(100));
assert_eq!(clock.next_interval(), Some(Duration::from_millis(100)));
assert_eq!(clock.tick(Duration::from_millis(100)), vec![fast]);
clock.register(fast, Duration::from_millis(100));
assert_eq!(clock.next_interval(), Some(Duration::from_millis(50)));
assert_eq!(clock.tick(Duration::from_millis(50)), vec![slow]);
}
#[test]
fn a_view_showing_several_indicators_is_woken_once() {
let both = view(1);
let mut clock = LoadingClock::default();
clock.register(both, Duration::from_millis(100));
clock.register(both, Duration::from_millis(50));
assert_eq!(clock.tick(Duration::from_millis(100)), vec![both]);
}
#[test]
fn a_subscriber_that_stops_rendering_expires_and_the_clock_goes_quiet() {
let view = view(1);
let period = Duration::from_millis(100);
let mut clock = LoadingClock::default();
clock.register(view, period);
for _ in 0..(SUBSCRIBER_GRACE_FRAMES * 3) {
assert_eq!(clock.tick(period), vec![view]);
clock.register(view, period);
}
for _ in 0..SUBSCRIBER_GRACE_FRAMES {
assert_eq!(clock.tick(period), vec![view]);
}
assert!(clock.tick(period).is_empty());
assert_eq!(clock.next_interval(), None, "the clock kept ticking");
}
#[test]
fn a_slow_subscriber_is_not_aged_out_by_a_fast_one() {
let mut clock = LoadingClock::default();
clock.register(view(1), Duration::from_millis(500));
for _ in 0..10 {
clock.tick(Duration::from_millis(100));
}
assert_eq!(clock.subscribers.len(), 1);
}
}