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
@@ -49,7 +49,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
|
||||
- Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar.
|
||||
- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. While open, it takes focus back whenever the focused element leaves the tree (gpui's `on_focus_lost`).
|
||||
- A state change made outside render calls `cx.notify()`, or nothing redraws.
|
||||
- A state change made outside render calls `cx.notify()`, or nothing redraws. A notify made while drawing, as in a canvas's prepaint, marks the view but schedules no frame (gpui's `invalidate_view`), so it also calls `window.request_animation_frame()`.
|
||||
- The macOS share picker draws its content in another process; a capture shows only its frame.
|
||||
- NSColorSampler runs in another process too. The harness cannot pick or cancel with it; `read_srgb` is tested on a constructed `NSColor` instead.
|
||||
- Every text field is a `forms::TextInput` entity. Its keys live under the `ElyInput` context, bound in `init`. Wrappers take Up, Down, Enter and Backspace first with `capture_action`.
|
||||
|
||||
@@ -39,8 +39,9 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T10a Feedback · Messages
|
||||
- [x] T10b Feedback · States
|
||||
- [x] T11a Loading · Spinners & skeletons — `tasks/ch11-20.md`
|
||||
- [ ] T11b Loading · Progress & states
|
||||
- [ ] T11c Motion
|
||||
- [x] T11b Loading · Progress & states
|
||||
- [ ] T11c Motion · Presence & order
|
||||
- [ ] T11d Motion · Effects
|
||||
- [ ] T12 Data Display
|
||||
- [ ] T13 Lists & Trees
|
||||
- [ ] T14 Tables
|
||||
@@ -170,3 +171,5 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T11a | 1 | FAIL | the shimmer band crossed the container's rounded corners, and the skeleton card's picture height was a literal |
|
||||
| T11a | 2 | FAIL | a rounded_full shimmer used the raw 9999px radius, so the band's path left the box |
|
||||
| T11a | 3 | PASS | |
|
||||
| T11b | 1 | FAIL | the sweeping bar grew in a tall flex column, the overlay's veil ignored rounded corners, the fail demo could not start a load, and two UploadList docs said more than the rows show |
|
||||
| T11b | 2 | PASS | |
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Sets `state` to `value` after `wait`, as a slow server would answer.
|
||||
fn later<T: 'static>(state: &Entity<T>, value: T, wait: Duration, cx: &mut App) {
|
||||
pub fn later<T: 'static>(state: &Entity<T>, value: T, wait: Duration, cx: &mut App) {
|
||||
let state = state.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(wait).await;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod loading;
|
||||
mod progress;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
@@ -28,6 +29,19 @@ const SCRIPT: &[Step] = &[
|
||||
Step::Wait(500),
|
||||
Step::Shot("shimmer-loaded"),
|
||||
Step::Click("shimmer-load"),
|
||||
Step::Click("progress-step"),
|
||||
Step::Wait(400),
|
||||
Step::Shot("progress"),
|
||||
Step::Click("overlay-reload"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("overlay"),
|
||||
Step::Wait(1500),
|
||||
Step::Click("refresh-run"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("refreshing"),
|
||||
Step::Click("uploads-tick"),
|
||||
Step::Wait(400),
|
||||
Step::Shot("uploads"),
|
||||
Step::Rest,
|
||||
];
|
||||
|
||||
@@ -38,5 +52,11 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(loading::shimmer(window, cx))
|
||||
.child(loading::buttons(window, cx))
|
||||
.child(loading::more(window, cx))
|
||||
.child(progress::bars(window, cx))
|
||||
.child(progress::overlay(window, cx))
|
||||
.child(progress::lazy(cx))
|
||||
.child(progress::suspense(window, cx))
|
||||
.child(progress::refresh(window, cx))
|
||||
.child(progress::uploads(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
feedback::AsyncView,
|
||||
forms::{Upload, UploadList, UploadState},
|
||||
motion::{
|
||||
LazyLoad, LoadingOverlay, ProgressBar, ProgressRing, Refresh, RefreshIndicator,
|
||||
TypingIndicator,
|
||||
},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{
|
||||
App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
|
||||
use super::loading::later;
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, row, section, set, specimen, specimens},
|
||||
};
|
||||
|
||||
const STEPS: [f32; 5] = [0.0, 0.35, 0.72, 1.0, 0.5];
|
||||
|
||||
pub fn bars(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let at = keep("progress-at", || 1usize, window, cx);
|
||||
let (now, step) = (*at.read(cx), at.clone());
|
||||
let value = STEPS[now];
|
||||
section(
|
||||
"ProgressBar / ProgressCircle / ProgressRing",
|
||||
"A track that fills and glides to each new value; a sweep when none is known; a buffer ahead; steps as segments; the same in a ring.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_128()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_5()
|
||||
.child(specimen("determinate, gliding", ProgressBar::new("bar-value", value), cx))
|
||||
.child(specimen("buffered", ProgressBar::new("bar-buffer", 0.4).buffer(0.7), cx))
|
||||
.child(specimen("segmented, four steps", ProgressBar::new("bar-steps", 0.62).segments(4), cx))
|
||||
.child(specimen("indeterminate", ProgressBar::indeterminate("bar-sweep"), cx)),
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("gliding", ProgressRing::new("ring-value", value).percent(), cx))
|
||||
.child(specimen("a quarter", ProgressRing::new("ring-quarter", 0.25), cx))
|
||||
.child(specimen("done", ProgressRing::new("ring-done", 1.0).percent(), cx)),
|
||||
)
|
||||
.child(row().child(probe(
|
||||
"progress-step",
|
||||
Button::new("progress-step", "Step")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| set(&step, (now + 1) % STEPS.len(), cx)),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn overlay(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let busy = keep("overlay-busy", || false, window, cx);
|
||||
let (loading, start) = (*busy.read(cx), busy.clone());
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"LoadingOverlay",
|
||||
"A veil and a spinner over what is reloading. Presses stop at the veil; it fades in and out.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
LoadingOverlay::new("overlay", loading).w_96().label("Refreshing prices…").child(
|
||||
div()
|
||||
.w_96()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.children(["Oat milk · 2.40", "Espresso beans · 14.90", "Rye bread · 4.20"].map(
|
||||
|line| div().px_4().py_3().border_b_1().border_color(theme.colors.border).child(line),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(row().child(probe(
|
||||
"overlay-reload",
|
||||
Button::new("overlay-reload", "Reload")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
set(&start, true, cx);
|
||||
later(&start, false, Duration::from_millis(1400), cx);
|
||||
}),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn lazy(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let rows = (0..12).map(|ix| {
|
||||
LazyLoad::new(
|
||||
SharedString::from(format!("lazy-{ix}")),
|
||||
px(36.0),
|
||||
move |_, _| {
|
||||
div()
|
||||
.h(px(36.0))
|
||||
.px_3()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(format!("Row {} of 12", ix + 1))
|
||||
},
|
||||
)
|
||||
});
|
||||
section(
|
||||
"LazyLoad",
|
||||
"Rows build only once they scroll into view; a skeleton holds each place until then. Scroll the list.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("lazy-scroll")
|
||||
.w_96()
|
||||
.h_48()
|
||||
.overflow_y_scroll()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(div().flex().flex_col().gap_1().p_1().children(rows)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A report that takes a moment to fetch, and can be told to fail.
|
||||
struct Report {
|
||||
fail: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub fn suspense(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let report = keep(
|
||||
"report",
|
||||
|| Report {
|
||||
fail: Arc::new(AtomicBool::new(false)),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let fail = report.read(cx).fail.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
let view: Entity<AsyncView<String>> =
|
||||
window.use_keyed_state("report-view", cx, move |_, cx| {
|
||||
AsyncView::new(
|
||||
"report",
|
||||
move || {
|
||||
let (executor, fail) = (executor.clone(), fail.clone());
|
||||
async move {
|
||||
executor.timer(Duration::from_millis(1500)).await;
|
||||
if fail.swap(false, Ordering::Relaxed) {
|
||||
return Err(anyhow!("the report service answered 503"));
|
||||
}
|
||||
Ok("Revenue rose 12% on the quarter; churn held at 2.1%.".to_string())
|
||||
}
|
||||
},
|
||||
|text, _, cx| {
|
||||
div()
|
||||
.py_6()
|
||||
.text_size(cx.theme().text_size(TextSize::Base))
|
||||
.child(text.clone())
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let breaker = report.read(cx).fail.clone();
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"Suspense / AsyncView",
|
||||
"A spinner while a value loads off the main thread, then the value as it fades in, or the error with Try again.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_128()
|
||||
.min_h_40()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.px_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(view.clone()),
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(
|
||||
Button::new("report-reload", "Reload")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click({
|
||||
let view = view.clone();
|
||||
move |_, _, cx| view.update(cx, |view, cx| view.reload(cx))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("report-fail", "Reload and fail")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click({
|
||||
let view = view.clone();
|
||||
move |_, _, cx| {
|
||||
breaker.store(true, Ordering::Relaxed);
|
||||
view.update(cx, |view, cx| view.reload(cx))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let phase = keep("refresh-phase", || Refresh::Idle, window, cx);
|
||||
let (now, go) = (*phase.read(cx), phase.clone());
|
||||
section(
|
||||
"RefreshIndicator / TypingIndicator",
|
||||
"An arrow that turns with a pull, a spinner while it works, a check when done. Someone typing, as three dots in a bubble.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("pulling", RefreshIndicator::new("refresh-pull", Refresh::Pulling(0.3)), cx))
|
||||
.child(specimen("almost", RefreshIndicator::new("refresh-almost", Refresh::Pulling(0.85)), cx))
|
||||
.child(specimen("refreshing", RefreshIndicator::new("refresh-busy", Refresh::Refreshing), cx))
|
||||
.child(specimen("done", RefreshIndicator::new("refresh-done", Refresh::Done), cx)),
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(probe(
|
||||
"refresh-run",
|
||||
Button::new("refresh-run", "Refresh").on_click(move |_, _, cx| {
|
||||
set(&go, Refresh::Refreshing, cx);
|
||||
later(&go, Refresh::Done, Duration::from_millis(1200), cx);
|
||||
later(&go, Refresh::Idle, Duration::from_millis(2600), cx);
|
||||
}),
|
||||
))
|
||||
.child(div().w_48().child(RefreshIndicator::new("refresh-live", now))),
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("named", TypingIndicator::new("typing-mia").who("Mia"), cx))
|
||||
.child(specimen("alone", TypingIndicator::new("typing"), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uploads(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let files = keep(
|
||||
"uploads",
|
||||
|| {
|
||||
vec![
|
||||
Upload {
|
||||
name: "brand-guidelines.pdf".into(),
|
||||
bytes: 8_400_000,
|
||||
state: UploadState::Uploading(0.62),
|
||||
},
|
||||
Upload {
|
||||
name: "hero@2x.png".into(),
|
||||
bytes: 2_430_000,
|
||||
state: UploadState::Done,
|
||||
},
|
||||
Upload {
|
||||
name: "interview.mov".into(),
|
||||
bytes: 2_600_000_000,
|
||||
state: UploadState::Failed("Over the 2 GB limit".into()),
|
||||
},
|
||||
Upload {
|
||||
name: "notes.md".into(),
|
||||
bytes: 12_800,
|
||||
state: UploadState::Uploading(0.12),
|
||||
},
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let list = files.read(cx).clone();
|
||||
let (cancel, retry, remove, tick) =
|
||||
(files.clone(), files.clone(), files.clone(), files.clone());
|
||||
let edit = |files: &Entity<Vec<Upload>>,
|
||||
ix: usize,
|
||||
cx: &mut App,
|
||||
change: fn(&mut Vec<Upload>, usize)| {
|
||||
let mut next = files.read(cx).clone();
|
||||
change(&mut next, ix);
|
||||
set(files, next, cx);
|
||||
};
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"UploadList",
|
||||
"Files on their way up: the percent and a bar that glides while they go, then their size and a check, or why they failed and a way to retry.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_128()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
UploadList::new("uploads", list)
|
||||
.on_cancel(move |ix, _, cx| edit(&cancel, ix, cx, |files, ix| { files.remove(ix); }))
|
||||
.on_retry(move |ix, _, cx| {
|
||||
edit(&retry, ix, cx, |files, ix| files[ix].state = UploadState::Uploading(0.0))
|
||||
})
|
||||
.on_remove(move |ix, _, cx| edit(&remove, ix, cx, |files, ix| { files.remove(ix); })),
|
||||
),
|
||||
)
|
||||
.child(row().child(probe(
|
||||
"uploads-tick",
|
||||
Button::new("uploads-tick", "Send more")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
let next = tick
|
||||
.read(cx)
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut file| {
|
||||
if let UploadState::Uploading(share) = file.state {
|
||||
file.state = if share >= 0.8 { UploadState::Done } else { UploadState::Uploading(share + 0.2) };
|
||||
}
|
||||
file
|
||||
})
|
||||
.collect();
|
||||
set(&tick, next, cx)
|
||||
}),
|
||||
)))
|
||||
.child(Caption::new(
|
||||
"StepProgress is navigation::Steps, chapter 7. LoadingScreen is shell::SplashScreen, which also renders in place, chapter 4.",
|
||||
))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod notification;
|
||||
mod result;
|
||||
mod states;
|
||||
mod status;
|
||||
mod suspense;
|
||||
mod toast;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
@@ -17,4 +18,5 @@ pub use notification::{Notification, NotificationCenter};
|
||||
pub use result::ResultView;
|
||||
pub use states::{EmptyState, ErrorBoundary, ErrorView};
|
||||
pub use status::{ConnectionStatus, SaveState, SavingIndicator, SyncState, SyncStatus};
|
||||
pub use suspense::{AsyncView, Suspense};
|
||||
pub use toast::{Toast, ToastViewport, Toaster};
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::{future::Future, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Context, ElementId, IntoElement, ParentElement,
|
||||
Render, RenderOnce, Styled, Task, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::ErrorBoundary;
|
||||
use crate::{
|
||||
forms::Run,
|
||||
motion::{self, Spinner},
|
||||
};
|
||||
|
||||
/// Shows a fallback while its content is on the way, then the content as it fades in, or an error view of the failure.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Suspense {
|
||||
id: ElementId,
|
||||
content: Option<anyhow::Result<AnyElement>>,
|
||||
fallback: Option<AnyElement>,
|
||||
on_retry: Option<Run>,
|
||||
}
|
||||
|
||||
impl Suspense {
|
||||
/// `None` while the content is on the way.
|
||||
pub fn new<E: IntoElement>(
|
||||
id: impl Into<ElementId>,
|
||||
content: Option<anyhow::Result<E>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
content: content.map(|content| content.map(IntoElement::into_any_element)),
|
||||
fallback: None,
|
||||
on_retry: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown while waiting instead of a centered spinner, such as a skeleton.
|
||||
pub fn fallback(mut self, fallback: impl IntoElement) -> Self {
|
||||
self.fallback = Some(fallback.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_retry = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Suspense {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
match self.content {
|
||||
None => self.fallback.unwrap_or_else(|| {
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_8()
|
||||
.child(Spinner::new((self.id.clone(), "waiting")))
|
||||
.into_any_element()
|
||||
}),
|
||||
Some(Ok(content)) => div()
|
||||
.child(content)
|
||||
.with_animation(
|
||||
(self.id, "ready"),
|
||||
Animation::new(motion::duration(motion::BASE, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|ready, t| ready.opacity(t),
|
||||
)
|
||||
.into_any_element(),
|
||||
Some(Err(error)) => ErrorBoundary::new(self.id, Err::<AnyElement, _>(error))
|
||||
.when_some(self.on_retry, |boundary, run| {
|
||||
boundary.on_retry(move |window, cx| run(window, cx))
|
||||
})
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Load<T> = Rc<dyn Fn() -> Task<anyhow::Result<T>>>;
|
||||
type Show<T> = Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
/// Loads a value off the main thread and shows it through a `Suspense`: a spinner while it loads, then `show`'s view of it, or the error with Try again.
|
||||
pub struct AsyncView<T> {
|
||||
id: ElementId,
|
||||
load: Load<T>,
|
||||
show: Show<T>,
|
||||
state: Option<anyhow::Result<T>>,
|
||||
_task: Task<()>,
|
||||
}
|
||||
|
||||
impl<T: 'static> AsyncView<T> {
|
||||
/// `load` runs once now, and again on each Try again.
|
||||
pub fn new<F, E>(
|
||||
id: impl Into<ElementId>,
|
||||
load: impl Fn() -> F + 'static,
|
||||
show: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self
|
||||
where
|
||||
F: Future<Output = anyhow::Result<T>> + Send + 'static,
|
||||
T: Send,
|
||||
E: IntoElement,
|
||||
{
|
||||
let load: Load<T> = {
|
||||
let executor = cx.background_executor().clone();
|
||||
Rc::new(move || executor.spawn(load()))
|
||||
};
|
||||
let task = Self::start(&load, cx);
|
||||
Self {
|
||||
id: id.into(),
|
||||
load,
|
||||
show: Rc::new(move |value, window, cx| show(value, window, cx).into_any_element()),
|
||||
state: None,
|
||||
_task: task,
|
||||
}
|
||||
}
|
||||
|
||||
fn start(load: &Load<T>, cx: &mut Context<Self>) -> Task<()> {
|
||||
let running = load();
|
||||
cx.spawn(async move |view, cx| {
|
||||
let result = running.await;
|
||||
let landed = view.update(cx, |view, cx| {
|
||||
match &result {
|
||||
Ok(_) => log::info!("async view {:?}: loaded", view.id),
|
||||
Err(error) => log::error!("async view {:?}: {error:#}", view.id),
|
||||
}
|
||||
view.state = Some(result);
|
||||
cx.notify();
|
||||
});
|
||||
if landed.is_err() {
|
||||
log::debug!("async view: dropped before its value landed");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads anew, showing the fallback until the value lands.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
log::info!("async view {:?}: loading again", self.id);
|
||||
self.state = None;
|
||||
self._task = Self::start(&self.load, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static> Render for AsyncView<T> {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let content = self.state.as_ref().map(|state| match state {
|
||||
Ok(value) => Ok((self.show)(value, window, cx)),
|
||||
Err(error) => Err(anyhow::anyhow!("{error:#}")),
|
||||
});
|
||||
let view = cx.entity();
|
||||
Suspense::new(self.id.clone(), content)
|
||||
.on_retry(move |_, cx| view.update(cx, |view, cx| view.reload(cx)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use std::{
|
||||
cell::Cell,
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, FocusHandle, IntoElement, ParentElement, Render, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div,
|
||||
};
|
||||
|
||||
use super::{press, settle, setup};
|
||||
use crate::{feedback::AsyncView, primitives::FocusScope};
|
||||
|
||||
/// An async view whose first load fails when told to, counting loads and shows.
|
||||
struct Report {
|
||||
root: FocusHandle,
|
||||
view: Entity<AsyncView<u32>>,
|
||||
}
|
||||
|
||||
impl Render for Report {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
FocusScope::new(&self.root)
|
||||
.root()
|
||||
.size_full()
|
||||
.child(self.view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn report(
|
||||
fail_first: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Arc<AtomicUsize>, Rc<Cell<usize>>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (loads, shows) = (Arc::new(AtomicUsize::new(0)), Rc::new(Cell::new(0)));
|
||||
let (counted, seen) = (loads.clone(), shows.clone());
|
||||
let (_, cx) = cx.add_window_view(move |_, cx| {
|
||||
let view = cx.new(|cx| {
|
||||
AsyncView::new(
|
||||
"report",
|
||||
move || {
|
||||
let turn = counted.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if fail_first && turn == 0 {
|
||||
Err(anyhow!("the service answered 503"))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
}
|
||||
},
|
||||
move |value, _, _| {
|
||||
seen.set(seen.get() + 1);
|
||||
div().child(value.to_string())
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
Report {
|
||||
root: cx.focus_handle(),
|
||||
view,
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
(loads, shows, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_async_view_shows_its_value_once_loaded(cx: &mut TestAppContext) {
|
||||
let (loads, shows, _cx) = report(false, cx);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||
assert!(shows.get() >= 1, "the value was shown");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_failed_load_tries_again_from_its_button(cx: &mut TestAppContext) {
|
||||
let (loads, shows, cx) = report(true, cx);
|
||||
assert_eq!((loads.load(Ordering::SeqCst), shows.get()), (1, 0));
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
settle(cx);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 2);
|
||||
assert!(shows.get() >= 1, "the second load was shown");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use gpui::{
|
||||
Modifiers, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, point, px,
|
||||
};
|
||||
|
||||
mod loading;
|
||||
mod states;
|
||||
|
||||
use super::{Alert, Notification, NotificationCenter, Toast, ToastViewport, Toaster};
|
||||
|
||||
@@ -341,11 +341,12 @@ fn card(
|
||||
.child(card)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if *height.read(cx) != bounds.size.height {
|
||||
height.update(cx, |height, cx| {
|
||||
*height = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ mod tests;
|
||||
mod text;
|
||||
mod transfer;
|
||||
mod unit;
|
||||
mod upload;
|
||||
|
||||
pub use card::{CheckboxCard, RadioCard};
|
||||
pub use cascade::{Cascade, Cascader};
|
||||
@@ -92,3 +93,4 @@ pub(crate) use text::{Down, Enter, Up, bind_keys};
|
||||
pub use text::{Highlight, InputEvent, TextInput};
|
||||
pub use transfer::TransferList;
|
||||
pub use unit::UnitInput;
|
||||
pub use upload::{Upload, UploadList, UploadState};
|
||||
@@ -3,6 +3,7 @@ mod colors;
|
||||
mod dates;
|
||||
mod other;
|
||||
mod structure;
|
||||
mod upload;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use gpui::{
|
||||
Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Render, TestAppContext,
|
||||
VisualTestContext, Window,
|
||||
};
|
||||
|
||||
use super::setup;
|
||||
use crate::{
|
||||
forms::{Upload, UploadList, UploadState},
|
||||
primitives::FocusNext,
|
||||
};
|
||||
|
||||
/// Three uploads, one in each state, recording which buttons ran.
|
||||
struct Shelf {
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Shelf {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let note = |what: &'static str, cx: &mut Context<Self>| {
|
||||
let view = cx.entity();
|
||||
move |ix: usize, _: &mut Window, cx: &mut gpui::App| {
|
||||
view.update(cx, |shelf, _| shelf.log.push(format!("{what} {ix}")))
|
||||
}
|
||||
};
|
||||
let file = |name: &str, state| Upload {
|
||||
name: name.to_string().into(),
|
||||
bytes: 1_000,
|
||||
state,
|
||||
};
|
||||
UploadList::new(
|
||||
"uploads",
|
||||
[
|
||||
file("a", UploadState::Uploading(0.5)),
|
||||
file("b", UploadState::Failed("Too big".into())),
|
||||
file("c", UploadState::Done),
|
||||
],
|
||||
)
|
||||
.on_cancel(note("cancel", cx))
|
||||
.on_retry(note("retry", cx))
|
||||
.on_remove(note("remove", cx))
|
||||
}
|
||||
}
|
||||
|
||||
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 each_row_offers_what_its_state_allows(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
cx.update(|cx| cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]));
|
||||
let (view, cx) = cx.add_window_view(|_, _| Shelf { log: Vec::new() });
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
let log = view.read_with(cx, |shelf, _| shelf.log.clone());
|
||||
assert_eq!(log, ["cancel 0", "retry 1", "remove 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not 0..=1")]
|
||||
fn an_upload_past_whole_is_refused() {
|
||||
let _ = UploadList::new(
|
||||
"uploads",
|
||||
[Upload {
|
||||
name: "a".into(),
|
||||
bytes: 1,
|
||||
state: UploadState::Uploading(1.5),
|
||||
}],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
motion::ProgressBar,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
|
||||
typography::{format::file_size, tabular},
|
||||
};
|
||||
|
||||
/// Where one upload stands.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum UploadState {
|
||||
/// Sent this share, 0 to 1.
|
||||
Uploading(f32),
|
||||
Done,
|
||||
/// Why it stopped.
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// One file on its way up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Upload {
|
||||
pub name: SharedString,
|
||||
pub bytes: u64,
|
||||
pub state: UploadState,
|
||||
}
|
||||
|
||||
type OnRow = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// Files on their way up. Each row shows its name, the percent and a progress bar while it goes, then its size and a check, or why it failed with Retry.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UploadList {
|
||||
id: ElementId,
|
||||
uploads: Vec<Upload>,
|
||||
on_cancel: Option<OnRow>,
|
||||
on_retry: Option<OnRow>,
|
||||
on_remove: Option<OnRow>,
|
||||
}
|
||||
|
||||
impl UploadList {
|
||||
pub fn new(id: impl Into<ElementId>, uploads: impl IntoIterator<Item = Upload>) -> Self {
|
||||
let uploads: Vec<Upload> = uploads.into_iter().collect();
|
||||
for upload in &uploads {
|
||||
if let UploadState::Uploading(share) = upload.state {
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&share),
|
||||
"{} is {share} sent, not 0..=1",
|
||||
upload.name
|
||||
);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
uploads,
|
||||
on_cancel: None,
|
||||
on_retry: None,
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs with the row of an upload still under way.
|
||||
pub fn on_cancel(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_cancel = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs with the row of a failed upload.
|
||||
pub fn on_retry(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_retry = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs with the row of a finished upload.
|
||||
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_remove = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UploadList {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let button = |name: String,
|
||||
icon: IconName,
|
||||
tip: &'static str,
|
||||
handler: &Option<OnRow>,
|
||||
ix: usize| {
|
||||
handler.clone().map(|run| {
|
||||
IconButton::new((self.id.clone(), SharedString::from(name)), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(tip)
|
||||
.on_click(move |_, window, cx| run(ix, window, cx))
|
||||
})
|
||||
};
|
||||
let rows = self.uploads.iter().enumerate().map(|(ix, upload)| {
|
||||
let (note, tone) = match &upload.state {
|
||||
UploadState::Uploading(share) => {
|
||||
(format!("{}%", (share * 100.0).round()), colors.fg_subtle)
|
||||
}
|
||||
UploadState::Done => (file_size(upload.bytes, false), colors.fg_subtle),
|
||||
UploadState::Failed(why) => (why.to_string(), colors.danger),
|
||||
};
|
||||
let action = match &upload.state {
|
||||
UploadState::Uploading(_) => button(
|
||||
format!("cancel-{ix}"),
|
||||
IconName::X,
|
||||
"Cancel",
|
||||
&self.on_cancel,
|
||||
ix,
|
||||
),
|
||||
UploadState::Failed(_) => button(
|
||||
format!("retry-{ix}"),
|
||||
IconName::RotateCw,
|
||||
"Retry",
|
||||
&self.on_retry,
|
||||
ix,
|
||||
),
|
||||
UploadState::Done => button(
|
||||
format!("remove-{ix}"),
|
||||
IconName::X,
|
||||
"Remove",
|
||||
&self.on_remove,
|
||||
ix,
|
||||
),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(match upload.state {
|
||||
UploadState::Done => Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Md)
|
||||
.color(colors.success),
|
||||
UploadState::Failed(_) => Icon::new(IconName::CircleAlert)
|
||||
.size(IconSize::Md)
|
||||
.color(colors.danger),
|
||||
UploadState::Uploading(_) => Icon::new(IconName::FileText)
|
||||
.size(IconSize::Md)
|
||||
.color(colors.fg_muted),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(upload.name.clone()),
|
||||
)
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(tone)
|
||||
.child(note),
|
||||
),
|
||||
)
|
||||
.when_some(
|
||||
match upload.state {
|
||||
UploadState::Uploading(share) => Some(share),
|
||||
_ => None,
|
||||
},
|
||||
|text, share| {
|
||||
text.child(ProgressBar::new(
|
||||
(self.id.clone(), SharedString::from(format!("bar-{ix}"))),
|
||||
share,
|
||||
))
|
||||
},
|
||||
),
|
||||
)
|
||||
.children(action)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.children(rows)
|
||||
}
|
||||
}
|
||||
@@ -46,11 +46,12 @@ impl ParentElement for Collapsible {
|
||||
|
||||
fn measured(state: Entity<Fold>) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if state.read(cx).height != bounds.size.height {
|
||||
state.update(cx, |fold, cx| {
|
||||
fold.height = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+4
-2
@@ -22,11 +22,12 @@ fn shortest(heights: &[Pixels]) -> usize {
|
||||
|
||||
fn measure_width(state: gpui::Entity<Pixels>) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if *state.read(cx) != bounds.size.width {
|
||||
state.update(cx, |width, cx| {
|
||||
*width = bounds.size.width;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -135,11 +136,12 @@ impl RenderOnce for Masonry {
|
||||
.child(child)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if state.read(cx)[ix] != bounds.size.height {
|
||||
state.update(cx, |heights, cx| {
|
||||
heights[ix] = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,11 +177,12 @@ impl RenderOnce for StickyHeader {
|
||||
.child((self.header)(window, cx))
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if header_state.read(cx).header != bounds.size.height {
|
||||
header_state.update(cx, |at, cx| {
|
||||
at.header = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -196,7 +197,7 @@ impl RenderOnce for StickyHeader {
|
||||
.children(self.body)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
let content_top = handle.bounds().top() + handle.offset().y;
|
||||
let (top, height) = (bounds.top() - content_top, bounds.size.height);
|
||||
let at = *section_state.read(cx);
|
||||
@@ -205,6 +206,7 @@ impl RenderOnce for StickyHeader {
|
||||
at.top = top;
|
||||
at.height = height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+2
-1
@@ -240,9 +240,10 @@ pub(super) fn measure(
|
||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
||||
) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if state.update(cx, |open, _| write(open, bounds)) {
|
||||
state.update(cx, |_, cx| cx.notify());
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
|
||||
+2
-1
@@ -218,12 +218,13 @@ impl RenderOnce for PieMenu {
|
||||
.children(bubbles)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
move |bounds: Bounds<Pixels>, window, cx| {
|
||||
let center = bounds.center();
|
||||
if measure.read(cx).center != center {
|
||||
measure.update(cx, |pie, cx| {
|
||||
pie.center = center;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -69,7 +69,7 @@ impl RenderOnce for Anchor {
|
||||
let (sections, value) = (self.sections, self.value);
|
||||
div().relative().children(self.children).child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if sections.read(cx).spot(&value) == Some(bounds) {
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +79,7 @@ impl RenderOnce for Anchor {
|
||||
None => sections.spots.push((value.clone(), bounds)),
|
||||
}
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
|
||||
@@ -53,13 +53,14 @@ impl RenderOnce for FloatingToolbar {
|
||||
.id(self.id.clone())
|
||||
.child(canvas(
|
||||
|_, _, _| {},
|
||||
move |_, _, _, cx| {
|
||||
move |_, _, window, cx| {
|
||||
let now = field.read(cx).bounds_for(from);
|
||||
if now != start {
|
||||
log::debug!("floating toolbar: the selection moved to {now:?}");
|
||||
seen.update(cx, |seen, cx| {
|
||||
*seen = now;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -84,11 +84,12 @@ impl RenderOnce for HoverCard {
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if measure.read(cx).host != bounds {
|
||||
measure.update(cx, |hover, cx| {
|
||||
hover.host = bounds;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -114,11 +115,12 @@ impl RenderOnce for HoverCard {
|
||||
.child((self.content)(window, cx))
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if tall.read(cx).height != bounds.size.height {
|
||||
tall.update(cx, |hover, cx| {
|
||||
hover.height = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -223,11 +223,12 @@ impl RenderOnce for Lightbox {
|
||||
.child(picture)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if area.read(cx) != &bounds.size {
|
||||
area.update(cx, |area, cx| {
|
||||
*area = bounds.size;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -97,11 +97,12 @@ impl RenderOnce for Popover {
|
||||
.child(trigger)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if measure.read(cx).host != bounds {
|
||||
measure.update(cx, |pop, cx| {
|
||||
pop.host = bounds;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -150,11 +151,12 @@ impl RenderOnce for Popover {
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if tall.read(cx).height != bounds.size.height {
|
||||
tall.update(cx, |pop, cx| {
|
||||
pop.height = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -174,11 +174,12 @@ fn light(
|
||||
.child(card)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
move |bounds, window, cx| {
|
||||
if measure.read(cx).card != bounds.size.height {
|
||||
measure.update(cx, |lit, cx| {
|
||||
lit.card = bounds.size.height;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ use gpui::{
|
||||
type BoundsHandler = Rc<dyn Fn(Bounds<Pixels>, &mut Window, &mut App)>;
|
||||
type VisibilityHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A div that reports its bounds whenever they change.
|
||||
/// A div that reports its bounds whenever they change, then draws once more so what the report changed shows.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Measure {
|
||||
id: ElementId,
|
||||
@@ -51,6 +51,7 @@ impl RenderOnce for Measure {
|
||||
if *last.read(cx) != Some(bounds) {
|
||||
last.update(cx, |seen, _| *seen = Some(bounds));
|
||||
on_measure(bounds, window, cx);
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
@@ -63,7 +64,7 @@ impl RenderOnce for Measure {
|
||||
}
|
||||
}
|
||||
|
||||
/// A div that reports when it enters or leaves the visible area.
|
||||
/// A div that reports when it enters or leaves the visible area, then draws once more so what the report changed shows.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IntersectionObserver {
|
||||
id: ElementId,
|
||||
@@ -107,6 +108,7 @@ impl RenderOnce for IntersectionObserver {
|
||||
if *last.read(cx) != Some(visible) {
|
||||
last.update(cx, |seen, _| *seen = Some(visible));
|
||||
on_change(visible, window, cx);
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
|
||||
+8
-49
@@ -1,12 +1,12 @@
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, AppContext, Bounds, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, WindowBounds, WindowHandle, WindowOptions, div,
|
||||
prelude::*, relative,
|
||||
Animation, AnimationExt, App, AppContext, Bounds, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, WindowBounds, WindowHandle, WindowOptions, div, prelude::*,
|
||||
relative,
|
||||
};
|
||||
|
||||
use super::{Hosted, open_hosted};
|
||||
use crate::{
|
||||
motion,
|
||||
motion::{self, ProgressBar},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Caption, CopyableText, ExternalLink, Paragraph, Title},
|
||||
@@ -32,50 +32,6 @@ fn mark(name: &SharedString, icon: Option<IconName>, cx: &App) -> impl IntoEleme
|
||||
})
|
||||
}
|
||||
|
||||
/// A thin track: `Some` fills to the value, `None` sweeps.
|
||||
pub(super) fn progress_track(value: Option<f32>, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let fill = div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.h_full()
|
||||
.rounded_full()
|
||||
.bg(colors.fg);
|
||||
let track = div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h(theme.progress_thickness())
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.bg(colors.border);
|
||||
match value {
|
||||
Some(value) => {
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&value),
|
||||
"progress {value} is not 0..=1"
|
||||
);
|
||||
track
|
||||
.child(fill.left_0().w(relative(value)))
|
||||
.into_any_element()
|
||||
}
|
||||
None if theme.reduced_motion => track
|
||||
.child(fill.left(relative(0.35)).w(relative(0.3)))
|
||||
.into_any_element(),
|
||||
None => track
|
||||
.child(
|
||||
fill.w(relative(0.3)).with_animation(
|
||||
"progress-sweep",
|
||||
Animation::new(motion::duration(motion::SLOW, cx) * 4)
|
||||
.repeat()
|
||||
.with_easing(motion::ease_in_out_cubic),
|
||||
|bar, t| bar.left(relative(-0.3 + 1.3 * t)),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name, version, credits: the About window's content.
|
||||
#[derive(IntoElement, Clone)]
|
||||
pub struct AboutDialog {
|
||||
@@ -243,7 +199,10 @@ impl RenderOnce for SplashScreen {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let enter = motion::duration(motion::SLOW, cx);
|
||||
let track = progress_track(self.progress, cx);
|
||||
let track = match self.progress {
|
||||
Some(value) => ProgressBar::new("splash-progress", value).into_any_element(),
|
||||
None => ProgressBar::indeterminate("splash-progress").into_any_element(),
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
|
||||
+8
-7
@@ -5,10 +5,11 @@ use gpui::{
|
||||
Window, WindowHandle, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{Hosted, dialogs::progress_track, open_hosted};
|
||||
use super::{Hosted, open_hosted};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
layout::ScrollArea,
|
||||
motion::ProgressBar,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Caption, Label, Title},
|
||||
@@ -33,9 +34,11 @@ struct Actions {
|
||||
later: Option<Action>,
|
||||
}
|
||||
|
||||
fn progress_line(state: UpdateState, cx: &App) -> Option<AnyElement> {
|
||||
fn progress_line(state: UpdateState) -> Option<AnyElement> {
|
||||
match state {
|
||||
UpdateState::Downloading(value) => Some(progress_track(Some(value), cx)),
|
||||
UpdateState::Downloading(value) => {
|
||||
Some(ProgressBar::new("update-progress", value).into_any_element())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -159,7 +162,7 @@ impl RenderOnce for UpdateBanner {
|
||||
.on_click(move |_, window, cx| dismiss(window, cx)),
|
||||
)
|
||||
})
|
||||
.when_some(progress_line(self.state, cx), |banner, line| {
|
||||
.when_some(progress_line(self.state), |banner, line| {
|
||||
banner.child(div().absolute().left_0().right_0().bottom_0().child(line))
|
||||
})
|
||||
}
|
||||
@@ -258,9 +261,7 @@ impl RenderOnce for UpdateDialog {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(progress_line(self.state, cx), |dialog, line| {
|
||||
dialog.child(line)
|
||||
})
|
||||
.when_some(progress_line(self.state), |dialog, line| dialog.child(line))
|
||||
.child(div().flex().justify_end().gap_2().children(buttons(
|
||||
self.state,
|
||||
&self.actions,
|
||||
|
||||
@@ -171,6 +171,11 @@ impl Theme {
|
||||
px_to_rems(2.0)
|
||||
}
|
||||
|
||||
/// A progress ring's width, room for a percent inside.
|
||||
pub fn progress_ring(&self) -> Rems {
|
||||
px_to_rems(48.0)
|
||||
}
|
||||
|
||||
pub fn about_window(&self) -> Size<Pixels> {
|
||||
size(px(360.0), px(400.0))
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ impl RenderOnce for EllipsisTooltip {
|
||||
cut.update(cx, |cut, cx| {
|
||||
*cut = overflow;
|
||||
cx.notify();
|
||||
window.request_animation_frame();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@
|
||||
- [x] FilePicker / FileInput → `forms::FileInput`
|
||||
- [x] FolderPicker → `forms::PathInput::directories` (T06a)
|
||||
- [x] DropZone → `forms::DropZone`
|
||||
- [x] UploadList → lands with Loading & Motion (T11): its rows are ProgressBars
|
||||
- [x] UploadList → `forms::UploadList`, built with Loading & Motion (T11b): its rows are ProgressBars
|
||||
- [x] ImageUpload — preview, crop → lands with Media (T26): its crop is the ImageCropper
|
||||
- [x] AvatarUpload → lands with Data Display (T12): it is an Avatar you replace
|
||||
|
||||
|
||||
+9
-9
@@ -14,16 +14,16 @@
|
||||
|
||||
## T11b Loading · Progress & states
|
||||
|
||||
- [ ] ProgressBar — determinate, indeterminate, segmented, buffered — also T06e UploadList
|
||||
- [ ] ProgressCircle / ProgressRing
|
||||
- [ ] StepProgress
|
||||
- [ ] LoadingOverlay
|
||||
- [ ] LoadingScreen
|
||||
- [x] ProgressBar — determinate, indeterminate, segmented, buffered — also T06e UploadList → `motion::ProgressBar`, `forms::UploadList`
|
||||
- [x] ProgressCircle / ProgressRing → `motion::ProgressRing`
|
||||
- [x] StepProgress → `navigation::Steps`
|
||||
- [x] LoadingOverlay → `motion::LoadingOverlay`
|
||||
- [x] LoadingScreen → `shell::SplashScreen`, which renders in place too
|
||||
- [x] InlineLoader → `motion::Spinner`
|
||||
- [ ] LazyLoad
|
||||
- [ ] Suspense / AsyncView
|
||||
- [ ] RefreshIndicator
|
||||
- [ ] TypingIndicator
|
||||
- [x] LazyLoad → `motion::LazyLoad`
|
||||
- [x] Suspense / AsyncView → `feedback::Suspense`, `feedback::AsyncView`; a failure shows the error view, so they live with it
|
||||
- [x] RefreshIndicator → `motion::RefreshIndicator`
|
||||
- [x] TypingIndicator → `motion::TypingIndicator`
|
||||
|
||||
## T11c Motion
|
||||
|
||||
|
||||
Reference in new issue
Block a user