use crate::theme::{ActiveTheme, Themeable};
use gpui::{
App, AsyncApp, EntityId, Hsla, IntoElement, ParentElement, Rems, RenderOnce, SharedString,
Styled, Task, Window, div, prelude::FluentBuilder, rems,
};
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, PartialEq, Eq)]
enum Frame {
Text(&'static str),
Dots { cols: u16, rows: u16, mask: u16 },
}
const fn braille(byte: u8) -> u16 {
const CELLS: [(u8, u16); 8] = [
(0, 0), (1, 2), (2, 4), (3, 1), (4, 3), (5, 5), (6, 6), (7, 7), ];
let mut mask = 0u16;
let mut i = 0;
while i < CELLS.len() {
let (bit, cell) = CELLS[i];
if byte & (1 << bit) != 0 {
mask |= 1 << cell;
}
i += 1;
}
mask
}
const fn braille_frame(byte: u8) -> Frame {
Frame::Dots {
cols: 2,
rows: 4,
mask: braille(byte),
}
}
const BRAILLE_SPINNER: [Frame; 8] = [
braille_frame(0xFE),
braille_frame(0xFD),
braille_frame(0xFB),
braille_frame(0xBF),
braille_frame(0x7F),
braille_frame(0xDF),
braille_frame(0xEF),
braille_frame(0xF7),
];
const BRAILLE_COUNTER: [Frame; 255] = {
let mut frames = [braille_frame(1); 255];
let mut i = 0;
while i < frames.len() {
frames[i] = braille_frame((i + 1) as u8);
i += 1;
}
frames
};
#[derive(Debug, Clone, Copy, Default)]
pub enum LoadingIndicatorVariant {
#[default]
Dots,
Ellipsis,
Dash,
Star,
Triangle,
Braille,
BrailleExtended,
}
impl LoadingIndicatorVariant {
fn frames(&self) -> &'static [Frame] {
match self {
LoadingIndicatorVariant::Dots => {
&[Frame::Text(". "), Frame::Text(".. "), Frame::Text("...")]
}
LoadingIndicatorVariant::Ellipsis => &[
Frame::Text(" "),
Frame::Text(". "),
Frame::Text(".. "),
Frame::Text("..."),
Frame::Text(".. "),
Frame::Text(". "),
],
LoadingIndicatorVariant::Dash => &[
Frame::Text("-"),
Frame::Text("\\"),
Frame::Text("|"),
Frame::Text("/"),
],
LoadingIndicatorVariant::Star => &[
Frame::Dots {
cols: 3,
rows: 3,
mask: 0b000_010_000,
},
Frame::Dots {
cols: 3,
rows: 3,
mask: 0b010_111_010,
},
Frame::Dots {
cols: 3,
rows: 3,
mask: 0b101_010_101,
},
Frame::Dots {
cols: 3,
rows: 3,
mask: 0b010_111_010,
},
],
LoadingIndicatorVariant::Triangle => &[
Frame::Dots {
cols: 2,
rows: 2,
mask: 0b11_10,
},
Frame::Dots {
cols: 2,
rows: 2,
mask: 0b11_01,
},
Frame::Dots {
cols: 2,
rows: 2,
mask: 0b01_11,
},
Frame::Dots {
cols: 2,
rows: 2,
mask: 0b10_11,
},
],
LoadingIndicatorVariant::Braille => &BRAILLE_SPINNER,
LoadingIndicatorVariant::BrailleExtended => &BRAILLE_COUNTER,
}
}
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(25_500),
}
}
fn frame_period(&self) -> Duration {
self.duration() / self.frames().len() as u32
}
fn width_ratio(&self) -> f32 {
match self.frames().first() {
Some(Frame::Text(_)) => {
let chars = self
.frames()
.iter()
.map(|frame| match frame {
Frame::Text(text) => text.chars().count(),
Frame::Dots { .. } => 1,
})
.max()
.unwrap_or(1);
chars as f32 * 0.6
}
Some(Frame::Dots { cols, rows, .. }) => *cols as f32 / *rows as f32,
None => 1.0,
}
}
}
#[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 LoadingIndicatorSize {
fn height(self) -> Rems {
match self {
LoadingIndicatorSize::XSmall => rems(0.75),
LoadingIndicatorSize::Small => rems(0.875),
LoadingIndicatorSize::Medium => rems(1.0),
LoadingIndicatorSize::Large => rems(1.25),
}
}
}
fn dot_grid(cols: u16, rows: u16, mask: u16, height: Rems, color: Hsla) -> impl IntoElement {
let cell = height / rows as f32;
let dot = cell * 0.72;
div()
.flex()
.flex_col()
.flex_none()
.h(height)
.children((0..rows).map(|row| {
div()
.flex()
.flex_row()
.h(cell)
.children((0..cols).map(move |col| {
let lit = mask & (1 << (row * cols + col)) != 0;
div()
.w(cell)
.h(cell)
.flex()
.items_center()
.justify_center()
.when(lit, |this| {
this.child(div().w(dot).h(dot).rounded_full().bg(color))
})
}))
}))
}
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 frame = 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 height = self.size.height();
div()
.text_color(color)
.flex_none()
.flex()
.items_center()
.justify_center()
.min_w(height * self.variant.width_ratio())
.text_size(height)
.line_height(height)
.map(|this| match frame {
Frame::Text(text) => this.child(SharedString::new_static(text)),
Frame::Dots { cols, rows, mask } => {
this.child(dot_grid(cols, rows, mask, height, color))
}
})
}
}
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::{Context, Render, TestAppContext, px};
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));
}
fn all_variants() -> [LoadingIndicatorVariant; 7] {
[
LoadingIndicatorVariant::Dots,
LoadingIndicatorVariant::Ellipsis,
LoadingIndicatorVariant::Dash,
LoadingIndicatorVariant::Star,
LoadingIndicatorVariant::Triangle,
LoadingIndicatorVariant::Braille,
LoadingIndicatorVariant::BrailleExtended,
]
}
#[test]
fn no_variant_bets_on_a_font_having_a_glyph() {
for variant in all_variants() {
for frame in variant.frames() {
if let Frame::Text(text) = frame {
assert!(
text.is_ascii(),
"{variant:?} draws {text:?} as text, which is a bet on the \
consumer's font; draw it as `Frame::Dots` instead"
);
}
}
}
}
#[test]
fn no_frame_is_empty() {
for variant in all_variants() {
for frame in variant.frames() {
match frame {
Frame::Text(text) => {
assert!(!text.is_empty(), "{variant:?} has an empty text frame")
}
Frame::Dots { cols, rows, mask } => {
assert_ne!(*mask, 0, "{variant:?} has a frame with no lit dots");
let cells = cols * rows;
assert!(
*mask < (1 << cells),
"{variant:?} lights a cell outside its {cols}x{rows} grid"
);
}
}
}
}
}
#[test]
fn braille_bytes_map_to_the_cells_braille_names() {
assert_eq!(braille(0b0000_0001), 1 << 0, "dot 1 is the top left");
assert_eq!(braille(0b0000_1000), 1 << 1, "dot 4 is the top right");
assert_eq!(braille(0b0100_0000), 1 << 6, "dot 7 is the bottom left");
assert_eq!(braille(0b1000_0000), 1 << 7, "dot 8 is the bottom right");
assert_eq!(braille(0xFF), 0b1111_1111, "every dot is every cell");
assert_eq!(braille(0x00), 0, "no dots, no cells");
}
#[test]
fn the_braille_spinner_walks_one_dark_dot_around_the_ring() {
let mut dark = Vec::new();
for frame in BRAILLE_SPINNER {
let Frame::Dots { mask, .. } = frame else {
panic!("the braille spinner is drawn, not typed");
};
assert_eq!(
mask.count_ones(),
7,
"a spinner frame lights {} dots, not seven",
mask.count_ones()
);
dark.push((!mask) & 0xFF);
}
dark.sort_unstable();
dark.dedup();
assert_eq!(dark.len(), 8, "two frames leave the same dot dark");
}
#[test]
fn the_braille_counter_counts() {
assert_eq!(BRAILLE_COUNTER.len(), 255);
for (index, frame) in BRAILLE_COUNTER.iter().enumerate() {
assert_eq!(*frame, braille_frame((index + 1) as u8));
}
}
#[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);
}
}