T11a loading: spinner in six motions, skeletons that breathe, a shimmer cut to its corners, loading buttons and load more
This commit is contained in:
17 files changed
+1184
-28
No files matched your search
+43
-9
@@ -4,6 +4,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion::Spinner,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
|
||||
typography::keys::{Platform, keystroke_labels},
|
||||
@@ -119,6 +120,7 @@ pub struct Button {
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
disabled: bool,
|
||||
loading: bool,
|
||||
full_width: bool,
|
||||
pub(crate) slot: Option<Slot>,
|
||||
on_click: Option<ClickHandler>,
|
||||
@@ -135,6 +137,7 @@ impl Button {
|
||||
variant: ButtonVariant::default(),
|
||||
size: ControlSize::default(),
|
||||
disabled: false,
|
||||
loading: false,
|
||||
full_width: false,
|
||||
slot: None,
|
||||
on_click: None,
|
||||
@@ -178,6 +181,12 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Spins in place of its content, at the same width, and ignores presses. Tab still reaches it.
|
||||
pub fn loading(mut self, loading: bool) -> Self {
|
||||
self.loading = loading;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn full_width(mut self) -> Self {
|
||||
self.full_width = true;
|
||||
self
|
||||
@@ -201,6 +210,23 @@ impl RenderOnce for Button {
|
||||
let icon = |name| Icon::new(name).size(icon_size).color(tone.fg);
|
||||
let radius = theme.radius(Radius::Md);
|
||||
let hint = self.shortcut.as_ref().map(shortcut_text);
|
||||
let spinner = (self.id.clone(), "spinner");
|
||||
let parts = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.when(!self.label.is_empty(), |el| el.child(self.label))
|
||||
.when_some(self.trailing_icon, |el, name| el.child(icon(name)))
|
||||
.when_some(hint, |el, hint| {
|
||||
el.child(
|
||||
div()
|
||||
.pl_1()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(tone.fg.opacity(0.55))
|
||||
.child(hint),
|
||||
)
|
||||
});
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
@@ -224,22 +250,30 @@ impl RenderOnce for Button {
|
||||
.text_color(tone.fg)
|
||||
.bg(tone.bg)
|
||||
.when(self.full_width, |el| el.w_full())
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.when(!self.label.is_empty(), |el| el.child(self.label))
|
||||
.when_some(self.trailing_icon, |el, name| el.child(icon(name)))
|
||||
.when_some(hint, |el, hint| {
|
||||
el.child(
|
||||
.map(|el| {
|
||||
if !self.loading {
|
||||
return el.child(parts);
|
||||
}
|
||||
el.relative().child(parts.invisible()).child(
|
||||
div()
|
||||
.pl_1()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(tone.fg.opacity(0.55))
|
||||
.child(hint),
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new(spinner).size(icon_size).color(tone.fg)),
|
||||
)
|
||||
})
|
||||
.map(|el| {
|
||||
if self.disabled {
|
||||
return el.opacity(0.45).cursor_not_allowed();
|
||||
}
|
||||
if self.loading {
|
||||
return el
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default());
|
||||
}
|
||||
el.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.hover(|style| {
|
||||
|
||||
@@ -11,6 +11,9 @@ mod segmented;
|
||||
mod share;
|
||||
mod sheet;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use bars::{ActionBar, BulkActionBar};
|
||||
pub use button::{Button, ButtonVariant};
|
||||
pub use confirm::{ConfirmButton, ConfirmMode};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use gpui::{
|
||||
Context, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render,
|
||||
Styled, TestAppContext, VisualTestContext, Window,
|
||||
};
|
||||
|
||||
use super::Button;
|
||||
use crate::{
|
||||
primitives::{FocusNext, FocusScope},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// One button that counts its presses.
|
||||
struct Bench {
|
||||
root: FocusHandle,
|
||||
loading: bool,
|
||||
presses: usize,
|
||||
}
|
||||
|
||||
impl Render for Bench {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
FocusScope::new(&self.root).root().size_full().child(
|
||||
Button::new("save", "Save")
|
||||
.loading(self.loading)
|
||||
.on_click(move |_, _, cx| view.update(cx, |bench, _| bench.presses += 1)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_loading_button_ignores_presses_and_keeps_its_tab_stop(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Bench {
|
||||
root: cx.focus_handle(),
|
||||
loading: true,
|
||||
presses: 0,
|
||||
});
|
||||
let root = view.read_with(cx, |bench, _| bench.root.clone());
|
||||
cx.update(|window, _| window.focus(&root));
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |bench, _| bench.presses), 0);
|
||||
view.update(cx, |bench, cx| {
|
||||
bench.loading = false;
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
view.read_with(cx, |bench, _| bench.presses),
|
||||
1,
|
||||
"focus stayed on it"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
mod changes;
|
||||
mod curve;
|
||||
mod skeleton;
|
||||
mod slide;
|
||||
mod spinner;
|
||||
|
||||
pub(crate) use changes::changes;
|
||||
pub use curve::*;
|
||||
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
|
||||
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
|
||||
pub use spinner::{Spinner, SpinnerStyle};
|
||||
@@ -0,0 +1,396 @@
|
||||
use std::{f32::consts::TAU, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
AbsoluteLength, Animation, AnimationExt, AnyElement, App, Bounds, Div, ElementId, Hsla,
|
||||
IntoElement, ParentElement, PathBuilder, Pixels, RenderOnce, StyleRefinement, Styled, Window,
|
||||
canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, relative,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::theme::{ActiveTheme, ControlSize, Mix, Radius, TextSize};
|
||||
|
||||
/// One breath of a skeleton, in and out.
|
||||
const BREATH: Duration = Duration::from_millis(1_600);
|
||||
/// One pass of a shimmer's band.
|
||||
const SWEEP: Duration = Duration::from_millis(1_800);
|
||||
/// Columns in each half of a shimmer's band, where it meets rounded corners.
|
||||
const RAMP: usize = 12;
|
||||
/// Widths a skeleton table's cells cycle through.
|
||||
const CELLS: [f32; 4] = [0.72, 0.48, 0.6, 0.36];
|
||||
|
||||
/// A placeholder in the shape of what is loading; it breathes until the content arrives. Size it as the content.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Skeleton {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
}
|
||||
|
||||
impl Skeleton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Round, for avatars and icons.
|
||||
pub fn circle(mut self) -> Self {
|
||||
self.base = self.base.rounded_full();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Skeleton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Skeleton {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let mut block = div()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(theme.colors.active);
|
||||
block.style().refine(self.base.style());
|
||||
if theme.reduced_motion {
|
||||
return block.into_any_element();
|
||||
}
|
||||
block
|
||||
.with_animation(self.id, Animation::new(BREATH).repeat(), |block, t| {
|
||||
block.opacity(0.775 + 0.225 * (TAU * t).cos())
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of text to come; the last runs short.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SkeletonText {
|
||||
id: ElementId,
|
||||
lines: usize,
|
||||
}
|
||||
|
||||
impl SkeletonText {
|
||||
pub fn new(id: impl Into<ElementId>, lines: usize) -> Self {
|
||||
assert!(lines > 0, "a skeleton text needs a line");
|
||||
Self {
|
||||
id: id.into(),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SkeletonText {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let line = cx.theme().text_size(TextSize::Base);
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children((0..self.lines).map(|ix| {
|
||||
let width = if ix + 1 == self.lines && self.lines > 1 {
|
||||
0.6
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
Skeleton::new((self.id.clone(), format!("line-{ix}")))
|
||||
.h(line)
|
||||
.w(relative(width))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A round avatar to come.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SkeletonAvatar {
|
||||
id: ElementId,
|
||||
}
|
||||
|
||||
impl SkeletonAvatar {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self { id: id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SkeletonAvatar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
Skeleton::new(self.id)
|
||||
.circle()
|
||||
.flex_none()
|
||||
.size(cx.theme().control_height(ControlSize::Lg))
|
||||
}
|
||||
}
|
||||
|
||||
/// A card to come: who, a picture and a few lines.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SkeletonCard {
|
||||
id: ElementId,
|
||||
}
|
||||
|
||||
impl SkeletonCard {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self { id: id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SkeletonCard {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let line = theme.text_size(TextSize::Base);
|
||||
let id = |part: &'static str| (self.id.clone(), part);
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(SkeletonAvatar::new(id("avatar")))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(Skeleton::new(id("name")).h(line).w(relative(0.45)))
|
||||
.child(Skeleton::new(id("when")).h(line).w(relative(0.25))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Skeleton::new(id("picture"))
|
||||
.w_full()
|
||||
.h(theme.skeleton_media())
|
||||
.rounded(theme.radius(Radius::Md)),
|
||||
)
|
||||
.child(SkeletonText::new(id("text"), 2))
|
||||
}
|
||||
}
|
||||
|
||||
/// A table to come: a header and rows of cells.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SkeletonTable {
|
||||
id: ElementId,
|
||||
rows: usize,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
impl SkeletonTable {
|
||||
pub fn new(id: impl Into<ElementId>, rows: usize, columns: usize) -> Self {
|
||||
assert!(rows > 0 && columns > 0, "a skeleton table needs a cell");
|
||||
Self {
|
||||
id: id.into(),
|
||||
rows,
|
||||
columns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SkeletonTable {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (line, border) = (theme.text_size(TextSize::Base), theme.colors.border);
|
||||
let row = |row: usize| {
|
||||
div()
|
||||
.flex()
|
||||
.gap_4()
|
||||
.py_3()
|
||||
.border_b_1()
|
||||
.border_color(border)
|
||||
.children((0..self.columns).map(|column| {
|
||||
let width = if row == 0 {
|
||||
0.4
|
||||
} else {
|
||||
CELLS[(row + column) % CELLS.len()]
|
||||
};
|
||||
div().flex_1().child(
|
||||
Skeleton::new((self.id.clone(), format!("cell-{row}-{column}")))
|
||||
.h(line)
|
||||
.w(relative(width)),
|
||||
)
|
||||
}))
|
||||
};
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children((0..=self.rows).map(row))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweeps a soft band of light across what it holds while that loads. It holds still under reduced motion.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Shimmer {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
body: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl Shimmer {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
body: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Shimmer {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Shimmer {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.body.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
/// The corners' radii, clockwise from the top left.
|
||||
type Radii = [Pixels; 4];
|
||||
|
||||
/// Where a rounded box's outline crosses the column at `x`: its top and bottom. Radii past half the box are clamped, as gpui draws them.
|
||||
fn span(bounds: Bounds<Pixels>, radii: Radii, x: Pixels) -> (Pixels, Pixels) {
|
||||
let limit = bounds.size.width.min(bounds.size.height) / 2.0;
|
||||
let [tl, tr, br, bl] = radii.map(|radius| radius.min(limit));
|
||||
let bite = |radius: Pixels, from_edge: Pixels| {
|
||||
if radius <= Pixels::ZERO || from_edge >= radius {
|
||||
return Pixels::ZERO;
|
||||
}
|
||||
let d = f32::from(radius - from_edge.max(Pixels::ZERO));
|
||||
radius - px((f32::from(radius).powi(2) - d * d).max(0.0).sqrt())
|
||||
};
|
||||
let (left, right) = (x - bounds.left(), bounds.right() - x);
|
||||
let top = bite(tl, left).max(bite(tr, right));
|
||||
let bottom = bite(bl, left).max(bite(br, right));
|
||||
(bounds.top() + top, bounds.bottom() - bottom)
|
||||
}
|
||||
|
||||
/// Fills the part of the rounded box between `from` and `to` with a ramp from `start` to `end`.
|
||||
fn ramp(
|
||||
bounds: Bounds<Pixels>,
|
||||
radii: Radii,
|
||||
(from, to): (Pixels, Pixels),
|
||||
(start, end): (Hsla, Hsla),
|
||||
window: &mut Window,
|
||||
) {
|
||||
let (a, b) = (from.max(bounds.left()), to.min(bounds.right()));
|
||||
if a >= b {
|
||||
return;
|
||||
}
|
||||
let at = |x: Pixels| {
|
||||
let share = f32::from(x - from) / f32::from(to - from);
|
||||
start.mix(&end, share)
|
||||
};
|
||||
let columns: Vec<Pixels> = (0..=RAMP)
|
||||
.map(|ix| a + (b - a) * (ix as f32 / RAMP as f32))
|
||||
.collect();
|
||||
let mut path = PathBuilder::fill();
|
||||
for (ix, x) in columns.iter().enumerate() {
|
||||
let top = point(*x, span(bounds, radii, *x).0);
|
||||
if ix == 0 {
|
||||
path.move_to(top);
|
||||
} else {
|
||||
path.line_to(top);
|
||||
}
|
||||
}
|
||||
for x in columns.iter().rev() {
|
||||
path.line_to(point(*x, span(bounds, radii, *x).1));
|
||||
}
|
||||
path.close();
|
||||
match path.build() {
|
||||
Ok(path) => window.paint_path(
|
||||
path,
|
||||
linear_gradient(
|
||||
90.0,
|
||||
linear_color_stop(at(a), 0.0),
|
||||
linear_color_stop(at(b), 1.0),
|
||||
),
|
||||
),
|
||||
Err(error) => log::error!("shimmer: a band failed to build: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Shimmer {
|
||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (shine, clear) = (theme.colors.shimmer, theme.colors.shimmer.alpha(0.0));
|
||||
let rem = window.rem_size();
|
||||
let radius =
|
||||
|corner: Option<AbsoluteLength>| corner.map_or(Pixels::ZERO, |r| r.to_pixels(rem));
|
||||
let corners = self.base.style().corner_radii.clone();
|
||||
let radii: Radii = [
|
||||
radius(corners.top_left),
|
||||
radius(corners.top_right),
|
||||
radius(corners.bottom_right),
|
||||
radius(corners.bottom_left),
|
||||
];
|
||||
let still = theme.reduced_motion;
|
||||
let base = self.base.relative().children(self.body);
|
||||
if still {
|
||||
return base.into_any_element();
|
||||
}
|
||||
base.child(
|
||||
div().absolute().inset_0().with_animation(
|
||||
self.id,
|
||||
Animation::new(SWEEP)
|
||||
.repeat()
|
||||
.with_easing(crate::motion::ease_in_out_cubic),
|
||||
move |overlay, t| {
|
||||
overlay.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let width = bounds.size.width * 0.4;
|
||||
let from = bounds.left() + bounds.size.width * (-0.4 + 1.4 * t);
|
||||
let middle = from + width / 2.0;
|
||||
ramp(bounds, radii, (from, middle), (clear, shine), window);
|
||||
ramp(
|
||||
bounds,
|
||||
radii,
|
||||
(middle, from + width),
|
||||
(shine, clear),
|
||||
window,
|
||||
);
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use super::span;
|
||||
|
||||
#[test]
|
||||
fn a_band_keeps_inside_rounded_corners() {
|
||||
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(50.0)));
|
||||
let radii = [px(8.0); 4];
|
||||
assert_eq!(span(bounds, radii, px(0.0)), (px(8.0), px(42.0)));
|
||||
assert_eq!(span(bounds, radii, px(50.0)), (px(0.0), px(50.0)));
|
||||
assert_eq!(span(bounds, radii, px(100.0)), (px(8.0), px(42.0)));
|
||||
let pill = [px(9999.0); 4];
|
||||
assert_eq!(span(bounds, pill, px(0.0)), (px(25.0), px(25.0)));
|
||||
assert_eq!(span(bounds, pill, px(50.0)), (px(0.0), px(50.0)));
|
||||
let (top, _) = span(bounds, radii, px(4.0));
|
||||
assert!(top > px(0.0) && top < px(8.0), "the arc eases in: {top:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::{
|
||||
f32::consts::{FRAC_PI_2, TAU},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Bounds, ElementId, Hsla, IntoElement, PathBuilder, Pixels, Point,
|
||||
RenderOnce, Styled, Window, canvas, div, fill, point, prelude::*, size,
|
||||
};
|
||||
|
||||
use crate::theme::{ActiveTheme, IconSize};
|
||||
|
||||
/// How a spinner moves.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SpinnerStyle {
|
||||
/// An arc that runs around a faint ring.
|
||||
#[default]
|
||||
Ring,
|
||||
/// Three dots that brighten in turn.
|
||||
Dots,
|
||||
/// Four bars that rise in turn.
|
||||
Bars,
|
||||
/// A dot that sends out a fading ring.
|
||||
Pulse,
|
||||
/// A dot that circles a faint ring.
|
||||
Orbit,
|
||||
/// Three dots that bob in a wave.
|
||||
Wave,
|
||||
}
|
||||
|
||||
impl SpinnerStyle {
|
||||
fn period(self) -> Duration {
|
||||
Duration::from_millis(match self {
|
||||
Self::Ring => 900,
|
||||
Self::Dots | Self::Wave => 1_200,
|
||||
Self::Bars => 1_000,
|
||||
Self::Pulse => 1_400,
|
||||
Self::Orbit => 1_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where in its cycle a still spinner rests.
|
||||
const STILL: f32 = 0.3;
|
||||
/// Segments in a drawn arc.
|
||||
const ARC: usize = 32;
|
||||
|
||||
/// A small busy mark, one of six motions. Under reduced motion it rests on one frame.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Spinner {
|
||||
id: ElementId,
|
||||
style: SpinnerStyle,
|
||||
size: IconSize,
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl Spinner {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: SpinnerStyle::default(),
|
||||
size: IconSize::Md,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: SpinnerStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: IconSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Defaults to muted text.
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0..1 bump that peaks once per cycle, `lag` cycles late.
|
||||
fn bump(t: f32, lag: f32) -> f32 {
|
||||
0.5 - 0.5 * (TAU * (t - lag)).cos()
|
||||
}
|
||||
|
||||
fn dot(center: Point<Pixels>, radius: Pixels, color: Hsla, window: &mut Window) {
|
||||
let side = radius * 2.0;
|
||||
let corner = point(center.x - radius, center.y - radius);
|
||||
window.paint_quad(fill(Bounds::new(corner, size(side, side)), color).corner_radii(radius));
|
||||
}
|
||||
|
||||
fn arc(
|
||||
center: Point<Pixels>,
|
||||
radius: Pixels,
|
||||
from: f32,
|
||||
sweep: f32,
|
||||
width: Pixels,
|
||||
color: Hsla,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let steps = ((ARC as f32 * sweep / TAU).ceil() as usize).max(2);
|
||||
let mut path = PathBuilder::stroke(width);
|
||||
for step in 0..=steps {
|
||||
let angle = from + sweep * step as f32 / steps as f32;
|
||||
let at = point(
|
||||
center.x + radius * angle.cos(),
|
||||
center.y + radius * angle.sin(),
|
||||
);
|
||||
if step == 0 {
|
||||
path.move_to(at);
|
||||
} else {
|
||||
path.line_to(at);
|
||||
}
|
||||
}
|
||||
match path.build() {
|
||||
Ok(path) => window.paint_path(path, color),
|
||||
Err(error) => log::error!("spinner: an arc failed to build: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of `style` at `t` in its cycle.
|
||||
fn paint(style: SpinnerStyle, bounds: Bounds<Pixels>, t: f32, color: Hsla, window: &mut Window) {
|
||||
let side = bounds.size.width.min(bounds.size.height);
|
||||
let center = bounds.center();
|
||||
let stroke = side / 8.0;
|
||||
let faint = color.opacity(0.2);
|
||||
match style {
|
||||
SpinnerStyle::Ring => {
|
||||
let radius = side / 2.0 - stroke;
|
||||
arc(center, radius, 0.0, TAU, stroke, faint, window);
|
||||
let sweep = TAU * (0.2 + 0.3 * bump(t, 0.0));
|
||||
arc(
|
||||
center,
|
||||
radius,
|
||||
-FRAC_PI_2 + TAU * t,
|
||||
sweep,
|
||||
stroke,
|
||||
color,
|
||||
window,
|
||||
);
|
||||
}
|
||||
SpinnerStyle::Dots | SpinnerStyle::Wave => {
|
||||
let radius = side / 9.0;
|
||||
for ix in 0..3 {
|
||||
let lag = ix as f32 * 0.18;
|
||||
let x = center.x + (ix as f32 - 1.0) * radius * 3.2;
|
||||
if style == SpinnerStyle::Dots {
|
||||
dot(
|
||||
point(x, center.y),
|
||||
radius,
|
||||
color.opacity(0.3 + 0.7 * bump(t, lag)),
|
||||
window,
|
||||
);
|
||||
} else {
|
||||
let lift = side * 0.22 * bump(t, lag);
|
||||
dot(
|
||||
point(x, center.y + side * 0.11 - lift),
|
||||
radius,
|
||||
color,
|
||||
window,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpinnerStyle::Bars => {
|
||||
let width = side / 8.0;
|
||||
for ix in 0..4 {
|
||||
let height = side * (0.3 + 0.55 * bump(t, ix as f32 * 0.14));
|
||||
let x = center.x + (ix as f32 - 1.5) * width * 2.0 - width / 2.0;
|
||||
let top = center.y - height / 2.0;
|
||||
window.paint_quad(
|
||||
fill(Bounds::new(point(x, top), size(width, height)), color)
|
||||
.corner_radii(width / 2.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
SpinnerStyle::Pulse => {
|
||||
let core = side / 6.0;
|
||||
let reach = core + (side / 2.0 - core) * t;
|
||||
arc(
|
||||
center,
|
||||
reach,
|
||||
0.0,
|
||||
TAU,
|
||||
stroke * 0.75,
|
||||
color.opacity(1.0 - t),
|
||||
window,
|
||||
);
|
||||
dot(center, core, color, window);
|
||||
}
|
||||
SpinnerStyle::Orbit => {
|
||||
let radius = side / 2.0 - stroke;
|
||||
arc(center, radius, 0.0, TAU, stroke * 0.75, faint, window);
|
||||
let angle = -FRAC_PI_2 + TAU * t;
|
||||
let at = point(
|
||||
center.x + radius * angle.cos(),
|
||||
center.y + radius * angle.sin(),
|
||||
);
|
||||
dot(at, stroke * 1.1, color, window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Spinner {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let color = self.color.unwrap_or(theme.colors.fg_muted);
|
||||
let style = self.style;
|
||||
let face = move |t: f32| {
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| paint(style, bounds, t, color, window),
|
||||
)
|
||||
.size_full()
|
||||
};
|
||||
let frame = div().flex_none().size(theme.icon_size(self.size));
|
||||
if theme.reduced_motion {
|
||||
return frame.child(face(STILL)).into_any_element();
|
||||
}
|
||||
frame
|
||||
.with_animation(
|
||||
self.id,
|
||||
Animation::new(style.period()).repeat(),
|
||||
move |frame, t| frame.child(face(t)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub use history::BackForwardNavigation;
|
||||
pub use menu::NavigationMenu;
|
||||
pub use nav::{NavGroup, NavItem};
|
||||
pub use overflow::TabOverflowMenu;
|
||||
pub use pages::Pagination;
|
||||
pub use pages::{LoadMore, Pagination};
|
||||
pub use palette::{
|
||||
Command, CommandPalette, QuickLauncher, QuickOpen, QuickSwitcher, SearchPalette,
|
||||
};
|
||||
|
||||
+77
-2
@@ -1,13 +1,16 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window,
|
||||
div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::Run,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
typography::format::{self, Separators},
|
||||
};
|
||||
|
||||
/// The first, the last, `current` and `near` pages either side; `None` marks a gap of two or more.
|
||||
@@ -164,3 +167,75 @@ mod tests {
|
||||
assert_eq!(shown(3, 5), "1 2 3 4 5");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a list that loads in pages: a button that spins while more come, and how many show. Once all show, only the count stays.
|
||||
#[derive(IntoElement)]
|
||||
pub struct LoadMore {
|
||||
id: ElementId,
|
||||
loading: bool,
|
||||
shown: Option<(usize, usize)>,
|
||||
on_load: Option<Run>,
|
||||
}
|
||||
|
||||
impl LoadMore {
|
||||
pub fn new(id: impl Into<ElementId>, loading: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
loading,
|
||||
shown: None,
|
||||
on_load: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many show, of how many there are.
|
||||
pub fn shown(mut self, shown: usize, total: usize) -> Self {
|
||||
assert!(shown <= total, "{shown} shown of only {total}");
|
||||
self.shown = Some((shown, total));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_load(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_load = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for LoadMore {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let count = |value: usize| format::number(value as f64, 0, Separators::EN);
|
||||
let done = self.shown.is_some_and(|(shown, total)| shown == total);
|
||||
let (id, on_load) = (self.id.clone(), self.on_load);
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_4()
|
||||
.when(!done, |more| {
|
||||
more.child(
|
||||
Button::new((self.id.clone(), "button"), "Load more")
|
||||
.loading(self.loading)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("load more {id:?}: asked");
|
||||
if let Some(on_load) = &on_load {
|
||||
on_load(window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(self.shown, |more, (shown, total)| {
|
||||
more.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(if done {
|
||||
format!("All {} shown", count(total))
|
||||
} else {
|
||||
format!("Showing {} of {}", count(shown), count(total))
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -5,8 +5,8 @@ use gpui::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, NavigationMenu,
|
||||
Tabs, Wizard,
|
||||
BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, LoadMore,
|
||||
NavigationMenu, Tabs, Wizard,
|
||||
};
|
||||
use crate::{
|
||||
forms::{Choice, TextInput},
|
||||
@@ -391,3 +391,50 @@ fn enter_jumps_to_a_line_and_column(cx: &mut TestAppContext) {
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(view.read_with(cx, |view, _| view.to), Some((12, Some(4))));
|
||||
}
|
||||
|
||||
/// A list's end that loads more, counting what it asked.
|
||||
struct More {
|
||||
loading: bool,
|
||||
shown: usize,
|
||||
asks: usize,
|
||||
}
|
||||
|
||||
impl Render for More {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
LoadMore::new("more", self.loading)
|
||||
.shown(self.shown, 30)
|
||||
.on_load(move |_, cx| view.update(cx, |more, _| more.asks += 1))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn load_more_asks_once_per_press_and_goes_once_all_show(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| More {
|
||||
loading: false,
|
||||
shown: 10,
|
||||
asks: 0,
|
||||
});
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |more, _| more.asks), 1);
|
||||
view.update(cx, |more, cx| {
|
||||
more.loading = true;
|
||||
cx.notify();
|
||||
});
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |more, _| more.asks), 1, "busy");
|
||||
view.update(cx, |more, cx| {
|
||||
more.loading = false;
|
||||
more.shown = 30;
|
||||
cx.notify();
|
||||
});
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
view.read_with(cx, |more, _| more.asks),
|
||||
1,
|
||||
"all shown, no button"
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,7 @@ mixable!(
|
||||
info_subtle: Hsla,
|
||||
backdrop: Hsla,
|
||||
media_backdrop: Hsla,
|
||||
shimmer: Hsla,
|
||||
shadow: Hsla,
|
||||
tooltip_bg: Hsla,
|
||||
tooltip_fg: Hsla,
|
||||
@@ -146,6 +147,7 @@ impl Palette {
|
||||
info_subtle: c(0xebf4ff),
|
||||
backdrop: rgba(0x18161352).into(),
|
||||
media_backdrop: rgba(0x0e0d0bf8).into(),
|
||||
shimmer: rgba(0xffffffb3).into(),
|
||||
shadow: c(0x181613),
|
||||
tooltip_bg: c(0x181613),
|
||||
tooltip_fg: c(0xfcfaf7),
|
||||
@@ -212,6 +214,7 @@ impl Palette {
|
||||
info_subtle: c(0x19273a),
|
||||
backdrop: rgba(0x00000080).into(),
|
||||
media_backdrop: rgba(0x000000f8).into(),
|
||||
shimmer: rgba(0xffffff10).into(),
|
||||
shadow: c(0x000000),
|
||||
tooltip_bg: c(0x373533),
|
||||
tooltip_fg: c(0xf3f1f0),
|
||||
|
||||
@@ -248,6 +248,11 @@ impl Theme {
|
||||
px_to_rems(360.0)
|
||||
}
|
||||
|
||||
/// A picture's height in a skeleton card.
|
||||
pub fn skeleton_media(&self) -> Rems {
|
||||
px_to_rems(128.0)
|
||||
}
|
||||
|
||||
/// Widest a centered block of prose runs, such as an empty state's help.
|
||||
pub fn prose_width(&self) -> Rems {
|
||||
px_to_rems(320.0)
|
||||
|
||||
Reference in new issue
Block a user