T11b loading: progress bars and rings that glide, loading overlay, lazy load, suspense and async view, refresh and typing indicators, upload list; draw-time notifies ask for a frame
This commit is contained in:
38 files changed
+1646
-91
No files matched your search
@@ -1,11 +1,18 @@
|
||||
mod changes;
|
||||
mod curve;
|
||||
mod overlay;
|
||||
mod progress;
|
||||
mod skeleton;
|
||||
mod slide;
|
||||
mod spinner;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use changes::changes;
|
||||
pub use curve::*;
|
||||
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
|
||||
pub use progress::{ProgressBar, ProgressRing};
|
||||
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,282 @@
|
||||
use std::{f32::consts::PI, time::Instant};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement,
|
||||
Length, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
|
||||
prelude::*, radians,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
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<SharedString>,
|
||||
body: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl LoadingOverlay {
|
||||
pub fn new(id: impl Into<ElementId>, loading: bool) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
loading,
|
||||
label: None,
|
||||
body: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, text: impl Into<SharedString>) -> 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<Item = AnyElement>) {
|
||||
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<dyn FnOnce(&mut Window, &mut App) -> 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<E: IntoElement>(
|
||||
id: impl Into<ElementId>,
|
||||
height: impl Into<Length>,
|
||||
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();
|
||||
});
|
||||
}
|
||||
})
|
||||
.w_full()
|
||||
.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<ElementId>, 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<SharedString>,
|
||||
}
|
||||
|
||||
impl TypingIndicator {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
who: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Names who is typing, as "Mia is typing…".
|
||||
pub fn who(mut self, name: impl Into<SharedString>) -> 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…")),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use std::f32::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled,
|
||||
Window, canvas, div, prelude::*, relative,
|
||||
};
|
||||
|
||||
use super::{BASE, SLOW, duration, ease_in_out_cubic, ease_out_cubic, spinner::arc};
|
||||
use crate::{
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
/// Where a bar or ring glides from, to its latest value.
|
||||
struct Glide {
|
||||
from: f32,
|
||||
to: f32,
|
||||
turn: usize,
|
||||
}
|
||||
|
||||
/// Keeps `value`'s last change; returns where to glide from, and the change's number.
|
||||
fn glide(id: &ElementId, value: f32, window: &mut Window, cx: &mut App) -> (f32, usize) {
|
||||
let state = window.use_keyed_state((id.clone(), "glide"), cx, |_, _| Glide {
|
||||
from: value,
|
||||
to: value,
|
||||
turn: 0,
|
||||
});
|
||||
if state.read(cx).to != value {
|
||||
state.update(cx, |glide, _| {
|
||||
glide.from = glide.to;
|
||||
glide.to = value;
|
||||
glide.turn += 1;
|
||||
});
|
||||
}
|
||||
let glide = state.read(cx);
|
||||
(glide.from, glide.turn)
|
||||
}
|
||||
|
||||
fn checked(value: f32, what: &str) -> f32 {
|
||||
assert!((0.0..=1.0).contains(&value), "{what} {value} is not 0..=1");
|
||||
value
|
||||
}
|
||||
|
||||
/// How far a task has come: a thin track that fills and glides to each new value, or sweeps when none is known. A buffer shows what has loaded ahead; segments split it into steps.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProgressBar {
|
||||
id: ElementId,
|
||||
value: Option<f32>,
|
||||
buffer: Option<f32>,
|
||||
segments: usize,
|
||||
}
|
||||
|
||||
impl ProgressBar {
|
||||
pub fn new(id: impl Into<ElementId>, value: f32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
value: Some(checked(value, "progress")),
|
||||
buffer: None,
|
||||
segments: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// No value known: a short fill sweeps the track.
|
||||
pub fn indeterminate(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
value: None,
|
||||
buffer: None,
|
||||
segments: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// What has loaded ahead of the value, drawn fainter.
|
||||
pub fn buffer(mut self, buffer: f32) -> Self {
|
||||
let value = self.value.expect("an indeterminate bar has no buffer");
|
||||
assert!(
|
||||
checked(buffer, "buffer") >= value,
|
||||
"buffer {buffer} is behind {value}"
|
||||
);
|
||||
self.buffer = Some(buffer);
|
||||
self
|
||||
}
|
||||
|
||||
/// Splits the track into `count` steps with gaps between.
|
||||
pub fn segments(mut self, count: usize) -> Self {
|
||||
assert!(count >= 2, "segments need at least two, got {count}");
|
||||
self.segments = count;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProgressBar {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (fill, faint, track) = (
|
||||
theme.colors.fg,
|
||||
theme.colors.fg.opacity(0.25),
|
||||
theme.colors.border,
|
||||
);
|
||||
let thickness = theme.progress_thickness();
|
||||
let lane = move || {
|
||||
div()
|
||||
.relative()
|
||||
.h(thickness)
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.bg(track)
|
||||
};
|
||||
let bar = move |share: f32| {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h_full()
|
||||
.rounded_full()
|
||||
.w(relative(share))
|
||||
};
|
||||
let Some(value) = self.value else {
|
||||
let sweep = bar(0.3).bg(fill);
|
||||
if theme.reduced_motion {
|
||||
return lane()
|
||||
.w_full()
|
||||
.child(sweep.left(relative(0.35)))
|
||||
.into_any_element();
|
||||
}
|
||||
return lane()
|
||||
.w_full()
|
||||
.child(
|
||||
sweep.with_animation(
|
||||
(self.id, "sweep"),
|
||||
Animation::new(SLOW * 4)
|
||||
.repeat()
|
||||
.with_easing(ease_in_out_cubic),
|
||||
|bar, t| bar.left(relative(-0.3 + 1.3 * t)),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
};
|
||||
let (from, turn) = glide(&self.id, value, window, cx);
|
||||
let (buffer, segments) = (self.buffer, self.segments);
|
||||
let lanes = move |at: f32| {
|
||||
(0..segments).map(move |ix| {
|
||||
let share = |whole: f32| (whole * segments as f32 - ix as f32).clamp(0.0, 1.0);
|
||||
lane()
|
||||
.flex_1()
|
||||
.when_some(buffer, |lane, buffer| {
|
||||
lane.child(bar(share(buffer)).bg(faint))
|
||||
})
|
||||
.child(bar(share(at)).bg(fill))
|
||||
})
|
||||
};
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.with_animation(
|
||||
(self.id, format!("glide-{turn}")),
|
||||
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
|
||||
move |row, t| {
|
||||
let at = if turn == 0 {
|
||||
value
|
||||
} else {
|
||||
from + (value - from) * t
|
||||
};
|
||||
row.children(lanes(at))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress as a ring that fills clockwise from the top and glides to each new value; the percent can sit inside.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProgressRing {
|
||||
id: ElementId,
|
||||
value: f32,
|
||||
percent: bool,
|
||||
}
|
||||
|
||||
impl ProgressRing {
|
||||
pub fn new(id: impl Into<ElementId>, value: f32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
value: checked(value, "progress"),
|
||||
percent: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the value as a percent inside the ring.
|
||||
pub fn percent(mut self) -> Self {
|
||||
self.percent = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProgressRing {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (value, percent) = (self.value, self.percent);
|
||||
let (from, turn) = glide(&self.id, value, window, cx);
|
||||
let theme = cx.theme();
|
||||
let (fill, track) = (theme.colors.fg, theme.colors.border);
|
||||
let text = theme.text_size(TextSize::Xs);
|
||||
div()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(theme.progress_ring())
|
||||
.with_animation(
|
||||
(self.id, format!("glide-{turn}")),
|
||||
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
|
||||
move |ring, t| {
|
||||
let at = if turn == 0 {
|
||||
value
|
||||
} else {
|
||||
from + (value - from) * t
|
||||
};
|
||||
ring.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let side = bounds.size.width.min(bounds.size.height);
|
||||
let width = side / 12.0;
|
||||
let (center, radius) = (bounds.center(), side / 2.0 - width);
|
||||
arc(center, radius, 0.0, TAU, width, track, window);
|
||||
if at > 0.0 {
|
||||
arc(center, radius, -FRAC_PI_2, TAU * at, width, fill, window);
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.when(percent, |ring| {
|
||||
ring.child(
|
||||
tabular(div())
|
||||
.text_size(text)
|
||||
.child(format!("{}%", (at * 100.0).round())),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -87,12 +87,13 @@ pub(crate) fn slide(
|
||||
/// Records item `ix`'s span; place it as an absolute child that fills the item.
|
||||
pub(crate) fn measure_item(state: Entity<Slide>, ix: usize, axis: Axis) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
move |bounds: Bounds<Pixels>, window, cx| {
|
||||
let span = Some(axis.span(bounds, state.read(cx).origin));
|
||||
if state.read(cx).spans.get(ix) != Some(&span) {
|
||||
state.update(cx, |slide, cx| {
|
||||
slide.spans[ix] = span;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -107,12 +108,13 @@ pub(crate) fn measure_item(state: Entity<Slide>, ix: usize, axis: Axis) -> impl
|
||||
/// Records where the container starts; place it as an absolute child that fills it.
|
||||
pub(crate) fn measure_origin(state: Entity<Slide>, axis: Axis) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
move |bounds: Bounds<Pixels>, window, cx| {
|
||||
let start = axis.start(bounds);
|
||||
if state.read(cx).origin != start {
|
||||
state.update(cx, |slide, cx| {
|
||||
slide.origin = start;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,7 +92,7 @@ fn dot(center: Point<Pixels>, radius: Pixels, color: Hsla, window: &mut Window)
|
||||
window.paint_quad(fill(Bounds::new(corner, size(side, side)), color).corner_radii(radius));
|
||||
}
|
||||
|
||||
fn arc(
|
||||
pub(super) fn arc(
|
||||
center: Point<Pixels>,
|
||||
radius: Pixels,
|
||||
from: f32,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Render, ScrollDelta,
|
||||
ScrollWheelEvent, StatefulInteractiveElement, Styled, TestAppContext, TouchPhase,
|
||||
VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
|
||||
use super::{LazyLoad, LoadingOverlay, ProgressBar};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// A pressable box under a loading veil, and a lazy row far down a scroll.
|
||||
struct Bench {
|
||||
loading: bool,
|
||||
presses: usize,
|
||||
built: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Render for Bench {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (view, built) = (cx.entity(), self.built.clone());
|
||||
div()
|
||||
.child(
|
||||
LoadingOverlay::new("veil", self.loading).child(
|
||||
div()
|
||||
.id("under")
|
||||
.w(px(200.0))
|
||||
.h(px(100.0))
|
||||
.on_click(move |_, _, cx| view.update(cx, |bench, _| bench.presses += 1)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("scroll")
|
||||
.h(px(100.0))
|
||||
.overflow_y_scroll()
|
||||
.child(div().h(px(1000.0)))
|
||||
.child(LazyLoad::new("row", px(20.0), move |_, _| {
|
||||
built.set(built.get() + 1);
|
||||
div().h(px(20.0)).child("row")
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(loading: bool, cx: &mut TestAppContext) -> (gpui::Entity<Bench>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, _| Bench {
|
||||
loading,
|
||||
presses: 0,
|
||||
built: Rc::new(Cell::new(0)),
|
||||
});
|
||||
cx.run_until_parked();
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn press_box(cx: &mut VisualTestContext) {
|
||||
let at = point(px(100.0), px(50.0));
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_veil_stops_presses_only_while_loading(cx: &mut TestAppContext) {
|
||||
let (view, cx) = bench(true, cx);
|
||||
press_box(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();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
press_box(cx);
|
||||
assert_eq!(view.read_with(cx, |bench, _| bench.presses), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_lazy_row_builds_once_it_scrolls_into_view(cx: &mut TestAppContext) {
|
||||
let (view, cx) = bench(false, cx);
|
||||
let built = view.read_with(cx, |bench, _| bench.built.clone());
|
||||
assert_eq!(built.get(), 0);
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: point(px(50.0), px(150.0)),
|
||||
delta: ScrollDelta::Pixels(point(px(0.0), px(-2000.0))),
|
||||
modifiers: Modifiers::none(),
|
||||
touch_phase: TouchPhase::Moved,
|
||||
});
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
assert!(built.get() >= 1, "the row was built");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "is behind")]
|
||||
fn a_buffer_behind_the_value_is_refused() {
|
||||
let _ = ProgressBar::new("bar", 0.6).buffer(0.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not 0..=1")]
|
||||
fn a_value_past_whole_is_refused() {
|
||||
let _ = ProgressBar::new("bar", 1.2);
|
||||
}
|
||||
|
||||
/// A sweeping bar at the top of a tall column, and where the next child starts.
|
||||
struct Column {
|
||||
after: Rc<Cell<f32>>,
|
||||
}
|
||||
|
||||
impl Render for Column {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
let after = self.after.clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.h(px(100.0))
|
||||
.child(ProgressBar::indeterminate("sweep"))
|
||||
.child(
|
||||
crate::primitives::Measure::new("after", move |bounds, _, _| {
|
||||
after.set(f32::from(bounds.origin.y))
|
||||
})
|
||||
.h(px(10.0)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_sweeping_bar_keeps_its_thickness_in_a_tall_column(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let after = Rc::new(Cell::new(-1.0));
|
||||
let (_, cx) = cx.add_window_view({
|
||||
let after = after.clone();
|
||||
move |_, _| Column { after }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let thickness = cx.update(|window, cx| {
|
||||
f32::from(
|
||||
crate::theme::ActiveTheme::theme(cx)
|
||||
.progress_thickness()
|
||||
.to_pixels(window.rem_size()),
|
||||
)
|
||||
});
|
||||
assert_eq!(after.get(), thickness, "the bar took only its thickness");
|
||||
}
|
||||
Reference in new issue
Block a user