From ce31c7fb062c8bc37f61f13b29aea07a8d53f195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 17:27:21 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 +- TASKS.md | 7 +- examples/gallery/pages/motion/loading.rs | 2 +- examples/gallery/pages/motion/mod.rs | 20 ++ examples/gallery/pages/motion/progress.rs | 335 ++++++++++++++++++++++ src/feedback/mod.rs | 2 + src/feedback/suspense.rs | 154 ++++++++++ src/feedback/tests/loading.rs | 87 ++++++ src/feedback/tests/mod.rs | 1 + src/feedback/toast.rs | 3 +- src/forms/mod.rs | 2 + src/forms/tests/mod.rs | 1 + src/forms/tests/upload.rs | 78 +++++ src/forms/upload.rs | 200 +++++++++++++ src/layout/collapse.rs | 3 +- src/layout/grid.rs | 6 +- src/layout/scroll_aids.rs | 6 +- src/menus/menu.rs | 3 +- src/menus/pie.rs | 3 +- src/motion/mod.rs | 7 + src/motion/overlay.rs | 282 ++++++++++++++++++ src/motion/progress.rs | 245 ++++++++++++++++ src/motion/slide.rs | 6 +- src/motion/spinner.rs | 2 +- src/motion/tests.rs | 152 ++++++++++ src/navigation/toc.rs | 3 +- src/overlays/floats.rs | 3 +- src/overlays/hover.rs | 6 +- src/overlays/lightbox.rs | 3 +- src/overlays/popover.rs | 6 +- src/overlays/spotlight.rs | 3 +- src/primitives/measure.rs | 6 +- src/shell/dialogs.rs | 57 +--- src/shell/update.rs | 15 +- src/theme/tokens.rs | 5 + src/typography/fit.rs | 1 + tasks/ch01-10.md | 2 +- tasks/ch11-20.md | 18 +- 38 files changed, 1646 insertions(+), 91 deletions(-) create mode 100644 examples/gallery/pages/motion/progress.rs create mode 100644 src/feedback/suspense.rs create mode 100644 src/feedback/tests/loading.rs create mode 100644 src/forms/tests/upload.rs create mode 100644 src/forms/upload.rs create mode 100644 src/motion/overlay.rs create mode 100644 src/motion/progress.rs create mode 100644 src/motion/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 2982118..2ca10e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/TASKS.md b/TASKS.md index 6c2d45a..8dd2e12 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/motion/loading.rs b/examples/gallery/pages/motion/loading.rs index af26461..86f908f 100644 --- a/examples/gallery/pages/motion/loading.rs +++ b/examples/gallery/pages/motion/loading.rs @@ -20,7 +20,7 @@ use crate::{ }; /// Sets `state` to `value` after `wait`, as a slow server would answer. -fn later(state: &Entity, value: T, wait: Duration, cx: &mut App) { +pub fn later(state: &Entity, value: T, wait: Duration, cx: &mut App) { let state = state.clone(); cx.spawn(async move |cx| { cx.background_executor().timer(wait).await; diff --git a/examples/gallery/pages/motion/mod.rs b/examples/gallery/pages/motion/mod.rs index 16ca015..84e8562 100644 --- a/examples/gallery/pages/motion/mod.rs +++ b/examples/gallery/pages/motion/mod.rs @@ -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() } diff --git a/examples/gallery/pages/motion/progress.rs b/examples/gallery/pages/motion/progress.rs new file mode 100644 index 0000000..6fd4d4b --- /dev/null +++ b/examples/gallery/pages/motion/progress.rs @@ -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, +} + +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> = + 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>, + ix: usize, + cx: &mut App, + change: fn(&mut Vec, 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.", + )) +} diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs index 7fab612..8d62879 100644 --- a/src/feedback/mod.rs +++ b/src/feedback/mod.rs @@ -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}; diff --git a/src/feedback/suspense.rs b/src/feedback/suspense.rs new file mode 100644 index 0000000..104e43f --- /dev/null +++ b/src/feedback/suspense.rs @@ -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>, + fallback: Option, + on_retry: Option, +} + +impl Suspense { + /// `None` while the content is on the way. + pub fn new( + id: impl Into, + content: Option>, + ) -> 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::(error)) + .when_some(self.on_retry, |boundary, run| { + boundary.on_retry(move |window, cx| run(window, cx)) + }) + .into_any_element(), + } + } +} + +type Load = Rc Task>>; +type Show = Rc 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 { + id: ElementId, + load: Load, + show: Show, + state: Option>, + _task: Task<()>, +} + +impl AsyncView { + /// `load` runs once now, and again on each Try again. + pub fn new( + id: impl Into, + load: impl Fn() -> F + 'static, + show: impl Fn(&T, &mut Window, &mut App) -> E + 'static, + cx: &mut Context, + ) -> Self + where + F: Future> + Send + 'static, + T: Send, + E: IntoElement, + { + let load: Load = { + 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, cx: &mut Context) -> 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) { + log::info!("async view {:?}: loading again", self.id); + self.state = None; + self._task = Self::start(&self.load, cx); + cx.notify(); + } +} + +impl Render for AsyncView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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))) + } +} diff --git a/src/feedback/tests/loading.rs b/src/feedback/tests/loading.rs new file mode 100644 index 0000000..9bbb8ca --- /dev/null +++ b/src/feedback/tests/loading.rs @@ -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>, +} + +impl Render for Report { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + FocusScope::new(&self.root) + .root() + .size_full() + .child(self.view.clone()) + } +} + +fn report( + fail_first: bool, + cx: &mut TestAppContext, +) -> (Arc, Rc>, &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"); +} diff --git a/src/feedback/tests/mod.rs b/src/feedback/tests/mod.rs index 1de28c3..7e6f45b 100644 --- a/src/feedback/tests/mod.rs +++ b/src/feedback/tests/mod.rs @@ -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}; diff --git a/src/feedback/toast.rs b/src/feedback/toast.rs index dd5650b..4f57b65 100644 --- a/src/feedback/toast.rs +++ b/src/feedback/toast.rs @@ -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(); }); } }, diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 6333785..072e846 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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}; diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index 065e0eb..9ad2638 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -3,6 +3,7 @@ mod colors; mod dates; mod other; mod structure; +mod upload; use gpui::{ AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, diff --git a/src/forms/tests/upload.rs b/src/forms/tests/upload.rs new file mode 100644 index 0000000..1859a8f --- /dev/null +++ b/src/forms/tests/upload.rs @@ -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, +} + +impl Render for Shelf { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let note = |what: &'static str, cx: &mut Context| { + 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), + }], + ); +} diff --git a/src/forms/upload.rs b/src/forms/upload.rs new file mode 100644 index 0000000..83733ec --- /dev/null +++ b/src/forms/upload.rs @@ -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; + +/// 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, + on_cancel: Option, + on_retry: Option, + on_remove: Option, +} + +impl UploadList { + pub fn new(id: impl Into, uploads: impl IntoIterator) -> Self { + let uploads: Vec = 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, + 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) + } +} diff --git a/src/layout/collapse.rs b/src/layout/collapse.rs index cdad47f..c614017 100644 --- a/src/layout/collapse.rs +++ b/src/layout/collapse.rs @@ -46,11 +46,12 @@ impl ParentElement for Collapsible { fn measured(state: Entity) -> 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(); }); } }, diff --git a/src/layout/grid.rs b/src/layout/grid.rs index eb3c500..82c4036 100644 --- a/src/layout/grid.rs +++ b/src/layout/grid.rs @@ -22,11 +22,12 @@ fn shortest(heights: &[Pixels]) -> usize { fn measure_width(state: gpui::Entity) -> 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(); }); } }, diff --git a/src/layout/scroll_aids.rs b/src/layout/scroll_aids.rs index 93c940a..e1c782d 100644 --- a/src/layout/scroll_aids.rs +++ b/src/layout/scroll_aids.rs @@ -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(); }); } }, diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 196189f..2cc220c 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -240,9 +240,10 @@ pub(super) fn measure( write: impl Fn(&mut Open, Bounds) -> 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(); } }, |_, _, _, _| {}, diff --git a/src/menus/pie.rs b/src/menus/pie.rs index 6b06032..fe30544 100644 --- a/src/menus/pie.rs +++ b/src/menus/pie.rs @@ -218,12 +218,13 @@ impl RenderOnce for PieMenu { .children(bubbles) .child( canvas( - move |bounds: Bounds, _, cx| { + move |bounds: Bounds, 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(); }); } }, diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 4f60382..867bd21 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/overlay.rs b/src/motion/overlay.rs new file mode 100644 index 0000000..542426d --- /dev/null +++ b/src/motion/overlay.rs @@ -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, + body: SmallVec<[AnyElement; 2]>, +} + +impl LoadingOverlay { + pub fn new(id: impl Into, loading: bool) -> Self { + Self { + id: id.into(), + base: div(), + loading, + label: None, + body: SmallVec::new(), + } + } + + pub fn label(mut self, text: impl Into) -> 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) { + 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 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( + id: impl Into, + height: impl Into, + 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, 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, +} + +impl TypingIndicator { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + who: None, + } + } + + /// Names who is typing, as "Mia is typing…". + pub fn who(mut self, name: impl Into) -> 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…")), + ) + }) + } +} diff --git a/src/motion/progress.rs b/src/motion/progress.rs new file mode 100644 index 0000000..1f1d792 --- /dev/null +++ b/src/motion/progress.rs @@ -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, + buffer: Option, + segments: usize, +} + +impl ProgressBar { + pub fn new(id: impl Into, 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) -> 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, 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())), + ) + }) + }, + ) + } +} diff --git a/src/motion/slide.rs b/src/motion/slide.rs index 615962a..bda292a 100644 --- a/src/motion/slide.rs +++ b/src/motion/slide.rs @@ -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, ix: usize, axis: Axis) -> impl IntoElement { canvas( - move |bounds: Bounds, _, cx| { + move |bounds: Bounds, 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, 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, axis: Axis) -> impl IntoElement { canvas( - move |bounds: Bounds, _, cx| { + move |bounds: Bounds, 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(); }); } }, diff --git a/src/motion/spinner.rs b/src/motion/spinner.rs index faa0fec..5ad7b72 100644 --- a/src/motion/spinner.rs +++ b/src/motion/spinner.rs @@ -92,7 +92,7 @@ fn dot(center: Point, 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, radius: Pixels, from: f32, diff --git a/src/motion/tests.rs b/src/motion/tests.rs new file mode 100644 index 0000000..8f195f7 --- /dev/null +++ b/src/motion/tests.rs @@ -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>, +} + +impl Render for Bench { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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>, +} + +impl Render for Column { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"); +} diff --git a/src/navigation/toc.rs b/src/navigation/toc.rs index 6b5bc2c..4e9c2cd 100644 --- a/src/navigation/toc.rs +++ b/src/navigation/toc.rs @@ -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(); }); }, |_, _, _, _| {}, diff --git a/src/overlays/floats.rs b/src/overlays/floats.rs index cf2481c..daea893 100644 --- a/src/overlays/floats.rs +++ b/src/overlays/floats.rs @@ -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(); }); } }, diff --git a/src/overlays/hover.rs b/src/overlays/hover.rs index 9ae5ef2..f1c54be 100644 --- a/src/overlays/hover.rs +++ b/src/overlays/hover.rs @@ -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(); }); } }, diff --git a/src/overlays/lightbox.rs b/src/overlays/lightbox.rs index 9293b96..15e4646 100644 --- a/src/overlays/lightbox.rs +++ b/src/overlays/lightbox.rs @@ -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(); }); } }, diff --git a/src/overlays/popover.rs b/src/overlays/popover.rs index bf07346..7f7c19d 100644 --- a/src/overlays/popover.rs +++ b/src/overlays/popover.rs @@ -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(); }); } }, diff --git a/src/overlays/spotlight.rs b/src/overlays/spotlight.rs index 5358832..ef33f1f 100644 --- a/src/overlays/spotlight.rs +++ b/src/overlays/spotlight.rs @@ -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(); }); } }, diff --git a/src/primitives/measure.rs b/src/primitives/measure.rs index f1cc61f..05511b5 100644 --- a/src/primitives/measure.rs +++ b/src/primitives/measure.rs @@ -8,7 +8,7 @@ use gpui::{ type BoundsHandler = Rc, &mut Window, &mut App)>; type VisibilityHandler = Rc; -/// 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(); } }, |_, _, _, _| {}, diff --git a/src/shell/dialogs.rs b/src/shell/dialogs.rs index 4e3adde..4815644 100644 --- a/src/shell/dialogs.rs +++ b/src/shell/dialogs.rs @@ -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, cx: &App) -> impl IntoEleme }) } -/// A thin track: `Some` fills to the value, `None` sweeps. -pub(super) fn progress_track(value: Option, 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() diff --git a/src/shell/update.rs b/src/shell/update.rs index 16fe7a6..43affc5 100644 --- a/src/shell/update.rs +++ b/src/shell/update.rs @@ -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, } -fn progress_line(state: UpdateState, cx: &App) -> Option { +fn progress_line(state: UpdateState) -> Option { 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, diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index aea884f..a660f4a 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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 { size(px(360.0), px(400.0)) } diff --git a/src/typography/fit.rs b/src/typography/fit.rs index 66308bd..b9ec913 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -147,6 +147,7 @@ impl RenderOnce for EllipsisTooltip { cut.update(cx, |cut, cx| { *cut = overflow; cx.notify(); + window.request_animation_frame(); }); } }, diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index eb4d1a1..cb6345a 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -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 diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index d4b1718..c80c257 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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