From 866335912acc0300d2cd214d4cc125f9b1448554 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 16:19:41 -0400 Subject: [PATCH] T10b feedback: error views with 404, 403, maintenance and offline, error boundary, drawn results, confirmation card, saving, sync and connection lines, countdown and timer with rolling digits --- AGENTS.md | 2 + TASKS.md | 5 +- assets/icons/cloud-alert.svg | 17 + assets/icons/cloud-check.svg | 16 + assets/icons/cloud-upload.svg | 17 + assets/icons/wrench.svg | 15 + examples/gallery/pages/feedback/messages.rs | 3 +- examples/gallery/pages/feedback/mod.rs | 24 ++ examples/gallery/pages/feedback/states.rs | 337 ++++++++++++++++++++ src/feedback/clock.rs | 273 ++++++++++++++++ src/feedback/confirm.rs | 141 ++++++++ src/feedback/empty.rs | 106 ------ src/feedback/messages.rs | 44 ++- src/feedback/mod.rs | 12 +- src/feedback/result.rs | 168 ++++++++++ src/feedback/states.rs | 295 +++++++++++++++++ src/feedback/status.rs | 194 +++++++++++ src/feedback/{tests.rs => tests/mod.rs} | 9 +- src/feedback/tests/states.rs | 177 ++++++++++ src/primitives/icon.rs | 4 + src/shell/mod.rs | 1 + src/shell/status.rs | 63 ++-- src/typography/rolling.rs | 30 +- tasks/ch01-10.md | 26 +- 24 files changed, 1806 insertions(+), 173 deletions(-) create mode 100644 assets/icons/cloud-alert.svg create mode 100644 assets/icons/cloud-check.svg create mode 100644 assets/icons/cloud-upload.svg create mode 100644 assets/icons/wrench.svg create mode 100644 examples/gallery/pages/feedback/states.rs create mode 100644 src/feedback/clock.rs create mode 100644 src/feedback/confirm.rs delete mode 100644 src/feedback/empty.rs create mode 100644 src/feedback/result.rs create mode 100644 src/feedback/states.rs create mode 100644 src/feedback/status.rs rename src/feedback/{tests.rs => tests/mod.rs} (98%) create mode 100644 src/feedback/tests/states.rs diff --git a/AGENTS.md b/AGENTS.md index 3c6a1a3..93cf155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,12 +76,14 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`. +- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. - The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. - Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time. - Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. +- Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. diff --git a/TASKS.md b/TASKS.md index eaf6840..d4691f2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -37,7 +37,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T09a Overlays · Popovers & dialogs - [x] T09b Overlays · Guides & floats - [x] T10a Feedback · Messages -- [ ] T10b Feedback · States +- [x] T10b Feedback · States - [ ] T11a Loading — `tasks/ch11-20.md` - [ ] T11b Motion - [ ] T12 Data Display @@ -163,3 +163,6 @@ Per-component lines live in `tasks/`. Tags there: | T10a | 1 | FAIL | a second press on a leaving toast's Undo ran it again, EmptyState's prose width was a literal, and two gallery callouts described behavior the code lacks | | T10a | 2 | FAIL | a gallery callout said reduced motion settles every animation, but the undo line keeps its real time | | T10a | 3 | PASS | | +| T10b | 1 | FAIL | the state view's prose and detail widths were literals again, and pad put zeros ahead of a minus sign | +| T10b | 2 | FAIL | the error chain did not wrap inside its narrowed box, and ConnectionStatus's doc said OfflineIndicator shows only while offline | +| T10b | 3 | FAIL | a wrapped body did not push the actions down; the prose is now a plain block, and a test compares a short and a long body's height (fixed after the cap) | diff --git a/assets/icons/cloud-alert.svg b/assets/icons/cloud-alert.svg new file mode 100644 index 0000000..189592a --- /dev/null +++ b/assets/icons/cloud-alert.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/cloud-check.svg b/assets/icons/cloud-check.svg new file mode 100644 index 0000000..0f7945e --- /dev/null +++ b/assets/icons/cloud-check.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/cloud-upload.svg b/assets/icons/cloud-upload.svg new file mode 100644 index 0000000..8eb8a3a --- /dev/null +++ b/assets/icons/cloud-upload.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/wrench.svg b/assets/icons/wrench.svg new file mode 100644 index 0000000..2da7fbe --- /dev/null +++ b/assets/icons/wrench.svg @@ -0,0 +1,15 @@ + + + + diff --git a/examples/gallery/pages/feedback/messages.rs b/examples/gallery/pages/feedback/messages.rs index 6c0afe0..31bf43a 100644 --- a/examples/gallery/pages/feedback/messages.rs +++ b/examples/gallery/pages/feedback/messages.rs @@ -376,7 +376,8 @@ pub fn empties(cx: &mut App) -> impl IntoElement + use<> { .child( frame().child( EmptyState::new("empty-inbox", IconName::Inbox, "No messages yet") - .body("When someone writes to you, it lands here."), + .body("When someone writes to you, it lands here, newest first, until you archive it.") + .action(Button::new("empty-compose", "Write a message")), ), ) .child( diff --git a/examples/gallery/pages/feedback/mod.rs b/examples/gallery/pages/feedback/mod.rs index 97c815c..6a90ab5 100644 --- a/examples/gallery/pages/feedback/mod.rs +++ b/examples/gallery/pages/feedback/mod.rs @@ -1,4 +1,5 @@ mod messages; +mod states; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -36,6 +37,23 @@ const SCRIPT: &[Step] = &[ Step::Click("status"), Step::Wait(80), Step::Shot("status-rising"), + Step::Click("boundary-break"), + Step::Wait(700), + Step::Shot("boundary"), + Step::Click("boundary-break"), + Step::Click("result-replay"), + Step::Wait(120), + Step::Shot("result-drawing"), + Step::Wait(1000), + Step::Shot("result"), + Step::Click("saving-next"), + Step::Click("sync-next"), + Step::Click("connection-next"), + Step::Wait(400), + Step::Shot("statuses"), + Step::Click("countdown-restart"), + Step::Wait(1500), + Step::Shot("clocks"), Step::Rest, ]; @@ -48,5 +66,11 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(messages::callouts(cx)) .child(messages::lines(window, cx)) .child(messages::empties(cx)) + .child(states::errors(cx)) + .child(states::boundary(window, cx)) + .child(states::results(window, cx)) + .child(states::confirmations(window, cx)) + .child(states::statuses(window, cx)) + .child(states::clocks(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/feedback/states.rs b/examples/gallery/pages/feedback/states.rs new file mode 100644 index 0000000..c494e58 --- /dev/null +++ b/examples/gallery/pages/feedback/states.rs @@ -0,0 +1,337 @@ +use std::time::{Duration, Instant}; + +use anyhow::anyhow; +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + feedback::{ + ConfirmationCard, ConnectionStatus, Countdown, ErrorBoundary, ErrorView, InlineMessage, + ResultView, SaveState, SavingIndicator, SyncState, SyncStatus, Timer, + }, + primitives::Severity, + shell::Connectivity, + theme::{ActiveTheme, Radius, TextSize}, + typography::Caption, +}; +use gpui::{App, Div, IntoElement, ParentElement, Styled, Window, div, prelude::FluentBuilder}; + +use crate::{ + probe::probe, + ui::{keep, row, section, set, specimen, specimens}, +}; + +fn frame(cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex_1() + .min_w_0() + .h_80() + .flex() + .items_center() + .justify_center() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) +} + +pub fn errors(cx: &mut App) -> impl IntoElement + use<> { + section( + "ErrorView / NotFound / NoPermission / Maintenance / Offline", + "A view that could not show what was asked says what happened and offers a way on.", + cx, + ) + .child( + div() + .flex() + .gap_3() + .child(frame(cx).child( + ErrorView::not_found("error-404").action(Button::new("error-home", "Go home")), + )) + .child( + frame(cx).child( + ErrorView::no_permission("error-403") + .action(Button::new("error-ask", "Request access").primary()), + ), + ), + ) + .child( + div() + .flex() + .gap_3() + .child(frame(cx).child(ErrorView::maintenance("error-maintenance"))) + .child(frame(cx).child( + ErrorView::offline("error-offline").action(Button::new("error-retry", "Try again")), + )), + ) +} + +pub fn boundary(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let broken = keep("boundary-broken", || false, window, cx); + let (now, toggle, retry) = (*broken.read(cx), broken.clone(), broken.clone()); + let theme = cx.theme(); + let content = if now { + Err(anyhow!("the export service answered 503").context("Revenue, third quarter")) + } else { + Ok(div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(theme.colors.fg_muted) + .child("Revenue, third quarter: 1.2 M")) + }; + section( + "ErrorBoundary", + "Shows its content, or the error it holds, logged once. It renders a Result; it never catches a panic.", + cx, + ) + .child( + div().flex().child( + frame(cx).child( + ErrorBoundary::new("boundary", content) + .on_retry(move |_, cx| set(&retry, false, cx)), + ), + ), + ) + .child( + row().child(probe( + "boundary-break", + Button::new("boundary-toggle", if now { "Fix it" } else { "Break it" }) + .on_click(move |_, _, cx| set(&toggle, !now, cx)), + )), + ) +} + +pub fn results(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let turn = keep("result-turn", || 0usize, window, cx); + let (now, replay) = (*turn.read(cx), turn.clone()); + section( + "Result", + "Where a task ended. The ring and its mark draw themselves once.", + cx, + ) + .child( + div() + .flex() + .gap_3() + .child( + frame(cx).child( + ResultView::success(("result-ok", now), "Payment sent") + .body("€240.00 to Studio Ottanta. A receipt is on its way.") + .action(Button::new("result-done", "Done").primary()) + .action(Button::new("result-receipt", "View receipt")), + ), + ) + .child( + frame(cx).child( + ResultView::failure(("result-no", now), "Payment declined") + .body("Your bank declined the charge. No money moved.") + .action(Button::new("result-again", "Try again")), + ), + ), + ) + .child( + row().child(probe( + "result-replay", + Button::new("result-replay", "Replay") + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| set(&replay, now + 1, cx)), + )), + ) +} + +pub fn confirmations(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let said = keep("confirm-said", || None::<&'static str>, window, cx); + let theme = cx.theme(); + let answer = |what: &'static str| { + let said = said.clone(); + move |_: &mut Window, cx: &mut App| set(&said, Some(what), cx) + }; + let command = div() + .px_3() + .py_2() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .child("cargo clean && cargo build --release"); + section( + "ConfirmationCard", + "Asks in place before an action runs, with what it will do.", + cx, + ) + .child( + div() + .w_128() + .flex() + .flex_col() + .gap_3() + .child( + ConfirmationCard::new("confirm-run", "Run this command?") + .body("The agent wants to rebuild the project from scratch.") + .confirm("Run") + .on_confirm(answer("Ran the command")) + .on_cancel(answer("Kept the build")) + .child(command), + ) + .child( + ConfirmationCard::new("confirm-delete", "Delete 3 branches?") + .body("feature/toasts, feature/menus and old/draft go for good.") + .confirm("Delete") + .destructive() + .on_confirm(answer("Deleted 3 branches")) + .on_cancel(answer("Kept the branches")), + ) + .when_some(*said.read(cx), |cards, what| { + cards.child(InlineMessage::new(Severity::Info, what)) + }), + ) +} + +pub fn statuses(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + const SAVES: [SaveState; 3] = [SaveState::Saving, SaveState::Saved, SaveState::Failed]; + const SYNCS: [SyncState; 4] = [ + SyncState::Syncing(12), + SyncState::Synced, + SyncState::Paused, + SyncState::Failed, + ]; + const LINKS: [Connectivity; 3] = [ + Connectivity::Online, + Connectivity::Reconnecting, + Connectivity::Offline, + ]; + let at = keep("status-states", || [0usize; 3], window, cx); + let now = *at.read(cx); + let next = |slot: usize, count: usize| { + let at = at.clone(); + move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| { + let mut then = now; + then[slot] = (now[slot] + 1) % count; + set(&at, then, cx) + } + }; + let retry = at.clone(); + section( + "SavingIndicator / SyncStatus / ConnectionStatus", + "Quiet lines for work in the background. Each rises in when its state changes; sync arrows turn while they work.", + cx, + ) + .child( + specimens() + .child(specimen( + "saving", + row() + .child(probe( + "saving-next", + Button::new("saving-next", "Next").on_click(next(0, SAVES.len())), + )) + .child( + SavingIndicator::new("saving", SAVES[now[0]]).on_retry(move |_, cx| { + let mut then = now; + then[0] = 0; + set(&retry, then, cx) + }), + ), + cx, + )) + .child(specimen( + "sync", + row() + .child(probe( + "sync-next", + Button::new("sync-next", "Next").on_click(next(1, SYNCS.len())), + )) + .child(SyncStatus::new("sync", SYNCS[now[1]])), + cx, + )) + .child(specimen( + "connection", + row() + .child(probe( + "connection-next", + Button::new("connection-next", "Next").on_click(next(2, LINKS.len())), + )) + .child(ConnectionStatus::new("connection", LINKS[now[2]])), + cx, + )), + ) +} + +pub fn clocks(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let opened = keep("clock-opened", Instant::now, window, cx); + let short = keep( + "clock-short", + || Instant::now() + Duration::from_secs(10), + window, + cx, + ); + let stopped = keep("clock-stopped", || None::, window, cx); + let over = keep("clock-over", || false, window, cx); + let (since, until, halt) = (*opened.read(cx), *short.read(cx), *stopped.read(cx)); + let (restart, toggle, done, reset) = + (short.clone(), stopped.clone(), over.clone(), opened.clone()); + let reopen = over.clone(); + section( + "Countdown / Timer", + "Time left and time spent. Digits roll as they change.", + cx, + ) + .child( + specimens() + .child(specimen( + "launch in", + Countdown::new( + "countdown-launch", + since + Duration::from_secs(2 * 3_600 + 14 * 60 + 7), + ), + cx, + )) + .child(specimen( + "ten seconds", + div() + .flex() + .items_center() + .gap_3() + .child( + Countdown::new("countdown-short", until) + .on_done(move |_, cx| set(&done, true, cx)), + ) + .child(probe( + "countdown-restart", + Button::new("countdown-restart", "Restart") + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| { + set(&restart, Instant::now() + Duration::from_secs(10), cx); + set(&reopen, false, cx); + }), + )), + cx, + )) + .child(specimen( + "on this page", + div() + .flex() + .items_center() + .gap_3() + .child(Timer::new("timer", since).stopped(halt)) + .child( + Button::new( + "timer-toggle", + if halt.is_some() { "Reset" } else { "Stop" }, + ) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| match halt { + Some(_) => { + set(&reset, Instant::now(), cx); + set(&toggle, None, cx); + } + None => set(&toggle, Some(Instant::now()), cx), + }), + ), + cx, + )), + ) + .when(*over.read(cx), |section| { + section.child(Caption::new("The ten seconds ran out; on_done ran once.")) + }) +} diff --git a/src/feedback/clock.rs b/src/feedback/clock.rs new file mode 100644 index 0000000..0f6e4cc --- /dev/null +++ b/src/feedback/clock.rs @@ -0,0 +1,273 @@ +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task, Window, div, prelude::*, +}; + +use crate::{ + forms::Run, + theme::{ActiveTheme, TextSize}, + typography::AnimatedNumber, +}; + +const SECOND: Duration = Duration::from_secs(1); + +/// A face's anchor, which way it runs, and the task that wakes it each second. +struct Clock { + anchor: Instant, + up: bool, + on_done: Option, + _tick: Option>, +} + +impl Clock { + /// Whole seconds shown at `now`: time since the anchor, or time left rounded up. + fn shown(&self, now: Instant) -> u64 { + if self.up { + now.saturating_duration_since(self.anchor).as_secs() + } else { + let left = self.anchor.saturating_duration_since(now); + left.as_secs() + u64::from(left.subsec_nanos() > 0) + } + } + + /// Time until the shown second changes; `None` once a countdown is done. + fn wait(&self, now: Instant) -> Option { + if self.up { + let past = now.saturating_duration_since(self.anchor); + return Some(SECOND - Duration::from_nanos(u64::from(past.subsec_nanos()))); + } + let left = self.anchor.saturating_duration_since(now); + match (left.is_zero(), left.subsec_nanos()) { + (true, _) => None, + (false, 0) => Some(SECOND), + (false, nanos) => Some(Duration::from_nanos(u64::from(nanos))), + } + } +} + +/// Keeps a face's clock, waking its view each time the shown second changes. Returns that second. +fn tick( + id: &ElementId, + anchor: Instant, + up: bool, + on_done: Option, + window: &mut Window, + cx: &mut App, +) -> u64 { + let state = window.use_keyed_state((id.clone(), "clock"), cx, |_, _| Clock { + anchor, + up, + on_done: None, + _tick: None, + }); + state.update(cx, |clock, _| { + if clock.anchor != anchor { + clock.anchor = anchor; + clock._tick = None; + } + clock.on_done = on_done; + }); + if state.read(cx)._tick.is_none() { + let (view, weak, id) = (window.current_view(), state.downgrade(), id.clone()); + let task = window.spawn(cx, async move |cx| { + loop { + let wait = + weak.read_with(cx, |clock, cx| clock.wait(cx.background_executor().now())); + let Ok(wait) = wait else { break }; + if let Some(wait) = wait { + cx.background_executor().timer(wait).await; + } + let woke = cx.update(|_, cx| { + cx.notify(view); + weak.read_with(cx, |clock, cx| { + let over = + !clock.up && clock.wait(cx.background_executor().now()).is_none(); + (over, clock.on_done.clone().filter(|_| over)) + }) + }); + let Ok(Ok((over, done))) = woke else { break }; + if let Some(done) = done { + log::info!("countdown {id:?}: done"); + if let Err(error) = cx.update(|window, cx| done(window, cx)) { + log::error!("countdown {id:?}: its window closed first: {error:#}"); + } + } + if over { + break; + } + } + }); + state.update(cx, |clock, _| clock._tick = Some(task)); + } + state.read(cx).shown(cx.background_executor().now()) +} + +/// Days, hours, minutes and seconds in `seconds`. +fn parts(seconds: u64) -> (u64, u64, u64, u64) { + ( + seconds / 86_400, + seconds / 3_600 % 24, + seconds / 60 % 60, + seconds % 60, + ) +} + +/// A clock face whose digits roll: minutes and seconds, hours once there are any, days ahead of them. +fn face(id: &ElementId, seconds: u64, size: TextSize, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let (days, hours, minutes, secs) = parts(seconds); + let number = |part: &'static str, value: u64| { + AnimatedNumber::new((id.clone(), part), value as f64) + .pad(2) + .size(size) + }; + let colon = || { + div() + .text_size(theme.text_size(size)) + .text_color(theme.colors.fg_subtle) + .child(":") + }; + div() + .flex() + .items_center() + .gap_0p5() + .when(days > 0, |face| { + face.child(AnimatedNumber::new((id.clone(), "d"), days as f64).size(size)) + .child( + div() + .mr_2() + .text_size(theme.text_size(size)) + .text_color(theme.colors.fg_subtle) + .child("d"), + ) + }) + .when(seconds >= 3_600, |face| { + face.child(number("h", hours)).child(colon()) + }) + .child(number("m", minutes)) + .child(colon()) + .child(number("s", secs)) +} + +/// Time left until a moment, its digits rolling as they change. `on_done` runs once at zero. +#[derive(IntoElement)] +pub struct Countdown { + id: ElementId, + until: Instant, + size: TextSize, + on_done: Option, +} + +impl Countdown { + pub fn new(id: impl Into, until: Instant) -> Self { + Self { + id: id.into(), + until, + size: TextSize::Xxl, + on_done: None, + } + } + + pub fn size(mut self, size: TextSize) -> Self { + self.size = size; + self + } + + pub fn on_done(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_done = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Countdown { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let seconds = tick(&self.id, self.until, false, self.on_done, window, cx); + face(&self.id, seconds, self.size, cx) + } +} + +/// Time since a moment, counting up with rolling digits; `stopped` freezes it. +#[derive(IntoElement)] +pub struct Timer { + id: ElementId, + since: Instant, + stopped: Option, + size: TextSize, +} + +impl Timer { + pub fn new(id: impl Into, since: Instant) -> Self { + Self { + id: id.into(), + since, + stopped: None, + size: TextSize::Xxl, + } + } + + /// Shows the time from `since` to `at`, and stops ticking. + pub fn stopped(mut self, at: Option) -> Self { + self.stopped = at; + self + } + + pub fn size(mut self, size: TextSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for Timer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let seconds = match self.stopped { + Some(at) => at.saturating_duration_since(self.since).as_secs(), + None => tick(&self.id, self.since, true, None, window, cx), + }; + face(&self.id, seconds, self.size, cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parts_split_days_hours_minutes_and_seconds() { + assert_eq!(parts(0), (0, 0, 0, 0)); + assert_eq!(parts(3_661), (0, 1, 1, 1)); + assert_eq!(parts(2 * 86_400 + 4 * 3_600 + 12 * 60 + 9), (2, 4, 12, 9)); + } + + #[test] + fn a_countdown_shows_the_second_it_is_in_and_wakes_at_the_next() { + let start = Instant::now(); + let clock = Clock { + anchor: start + Duration::from_millis(4_300), + up: false, + on_done: None, + _tick: None, + }; + assert_eq!(clock.shown(start), 5); + assert_eq!(clock.wait(start), Some(Duration::from_millis(300))); + assert_eq!(clock.shown(start + Duration::from_millis(4_300)), 0); + assert_eq!(clock.wait(start + Duration::from_millis(4_300)), None); + } + + #[test] + fn a_timer_counts_whole_seconds_and_wakes_on_the_next() { + let start = Instant::now(); + let clock = Clock { + anchor: start, + up: true, + on_done: None, + _tick: None, + }; + let now = start + Duration::from_millis(65_250); + assert_eq!(clock.shown(now), 65); + assert_eq!(clock.wait(now), Some(Duration::from_millis(750))); + } +} diff --git a/src/feedback/confirm.rs b/src/feedback/confirm.rs new file mode 100644 index 0000000..f19ff2a --- /dev/null +++ b/src/feedback/confirm.rs @@ -0,0 +1,141 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Run, + theme::{ActiveTheme, Radius, TextSize}, +}; + +/// Asks before an action runs, in place: what will happen, any detail, then Cancel and a confirming button. +#[derive(IntoElement)] +pub struct ConfirmationCard { + id: ElementId, + title: SharedString, + body: Option, + detail: SmallVec<[AnyElement; 2]>, + confirm: SharedString, + destructive: bool, + on_confirm: Option, + on_cancel: Option, +} + +impl ConfirmationCard { + pub fn new(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + body: None, + detail: SmallVec::new(), + confirm: "Confirm".into(), + destructive: false, + on_confirm: None, + on_cancel: None, + } + } + + pub fn body(mut self, text: impl Into) -> Self { + self.body = Some(text.into()); + self + } + + /// The confirming button's label. + pub fn confirm(mut self, label: impl Into) -> Self { + self.confirm = label.into(); + self + } + + /// The confirming button turns danger. + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } + + pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } + + pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } +} + +impl ParentElement for ConfirmationCard { + fn extend(&mut self, elements: impl IntoIterator) { + self.detail.extend(elements); + } +} + +impl RenderOnce for ConfirmationCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (id, confirm, cancel) = (self.id.clone(), self.on_confirm, self.on_cancel); + let variant = if self.destructive { + ButtonVariant::Danger + } else { + ButtonVariant::Primary + }; + let run = |handler: Option, what: &'static str| { + let id = id.clone(); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + log::info!("confirmation {id:?}: {what}"); + if let Some(handler) = &handler { + handler(window, cx); + } + } + }; + div() + .id(self.id.clone()) + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Base)) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.title), + ) + .when_some(self.body, |words, body| { + words.child(div().text_color(colors.fg_muted).child(body)) + }), + ) + .when(!self.detail.is_empty(), |card| { + card.child(div().children(self.detail)) + }) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + Button::new((self.id.clone(), "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(run(cancel, "cancelled")), + ) + .child( + Button::new((self.id.clone(), "confirm"), self.confirm) + .variant(variant) + .on_click(run(confirm, "confirmed")), + ), + ) + } +} diff --git a/src/feedback/empty.rs b/src/feedback/empty.rs deleted file mode 100644 index 191690f..0000000 --- a/src/feedback/empty.rs +++ /dev/null @@ -1,106 +0,0 @@ -use gpui::{ - Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, - RenderOnce, SharedString, Styled, Window, div, prelude::*, -}; -use smallvec::SmallVec; - -use crate::{ - motion, - primitives::{Icon, IconName}, - theme::{ActiveTheme, IconSize, TextSize}, -}; - -/// What a view shows with nothing in it: an icon on a soft disc, a title, a line of help and actions. It rises in when it appears. -#[derive(IntoElement)] -pub struct EmptyState { - id: ElementId, - icon: IconName, - title: SharedString, - body: Option, - actions: SmallVec<[AnyElement; 2]>, -} - -impl EmptyState { - pub fn new(id: impl Into, icon: IconName, title: impl Into) -> Self { - Self { - id: id.into(), - icon, - title: title.into(), - body: None, - actions: SmallVec::new(), - } - } - - pub fn body(mut self, text: impl Into) -> Self { - self.body = Some(text.into()); - self - } - - pub fn action(mut self, action: impl IntoElement) -> Self { - self.actions.push(action.into_any_element()); - self - } -} - -impl RenderOnce for EmptyState { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let colors = &theme.colors; - div() - .w_full() - .flex() - .flex_col() - .items_center() - .gap_4() - .px_6() - .py_8() - .child( - div() - .flex() - .items_center() - .justify_center() - .size(theme.icon_size(IconSize::Xxl) + theme.icon_size(IconSize::Md)) - .rounded_full() - .bg(colors.sunken) - .child( - Icon::new(self.icon) - .size(IconSize::Lg) - .color(colors.fg_subtle), - ), - ) - .child( - div() - .flex() - .flex_col() - .items_center() - .gap_1() - .w_full() - .max_w(theme.prose_width()) - .text_center() - .child( - div() - .text_size(theme.text_size(TextSize::Lg)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(colors.fg) - .child(self.title), - ) - .when_some(self.body, |text, body| { - text.child( - div() - .text_size(theme.text_size(TextSize::Base)) - .text_color(colors.fg_muted) - .child(body), - ) - }), - ) - .when(!self.actions.is_empty(), |state| { - state.child(div().flex().gap_2().children(self.actions)) - }) - .with_animation( - self.id, - Animation::new(motion::duration(motion::SLOW, cx)) - .with_easing(motion::ease_out_cubic), - |state, t| state.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)), - ) - } -} diff --git a/src/feedback/messages.rs b/src/feedback/messages.rs index 3437935..b39d7e2 100644 --- a/src/feedback/messages.rs +++ b/src/feedback/messages.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, - RenderOnce, SharedString, Styled, Window, div, prelude::*, + Animation, AnimationExt, AnyElement, App, Div, ElementId, FontWeight, IntoElement, + ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, }; use smallvec::SmallVec; @@ -331,10 +331,9 @@ impl StatusMessage { impl RenderOnce for StatusMessage { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let turn = motion::changes((self.id.clone(), "text"), self.text.clone(), window, cx); let theme = cx.theme(); let muted = theme.colors.fg_muted; - div() + let line = div() .flex() .items_center() .gap_1p5() @@ -343,18 +342,29 @@ impl RenderOnce for StatusMessage { .when_some(self.icon, |line, icon| { line.child(Icon::new(icon).size(IconSize::Xs).color(muted)) }) - .child(self.text) - .with_animation( - (self.id, format!("turn-{turn}")), - Animation::new(motion::duration(motion::BASE, cx)) - .with_easing(motion::ease_out_cubic), - move |line, t| { - if turn == 0 { - line - } else { - line.opacity(t).mt(motion::NUDGE * (1.0 - t)) - } - }, - ) + .child(self.text.clone()); + rise(self.id, self.text, line, window, cx) } } + +/// `line`, rising in each time `key` changes; still on first paint. +pub(super) fn rise( + id: ElementId, + key: T, + line: Div, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use { + let turn = motion::changes((id.clone(), "key"), key, window, cx); + line.with_animation( + (id, format!("turn-{turn}")), + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic), + move |line, t| { + if turn == 0 { + line + } else { + line.opacity(t).mt(motion::NUDGE * (1.0 - t)) + } + }, + ) +} diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs index 09d4c0d..7fab612 100644 --- a/src/feedback/mod.rs +++ b/src/feedback/mod.rs @@ -1,12 +1,20 @@ -mod empty; +mod clock; +mod confirm; mod messages; mod notification; +mod result; +mod states; +mod status; mod toast; #[cfg(all(test, feature = "test-support"))] mod tests; -pub use empty::EmptyState; +pub use clock::{Countdown, Timer}; +pub use confirm::ConfirmationCard; pub use messages::{Alert, Banner, Callout, InlineMessage, StatusMessage}; 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 toast::{Toast, ToastViewport, Toaster}; diff --git a/src/feedback/result.rs b/src/feedback/result.rs new file mode 100644 index 0000000..bd28383 --- /dev/null +++ b/src/feedback/result.rs @@ -0,0 +1,168 @@ +use std::f32::consts::{FRAC_PI_2, TAU}; + +use gpui::{ + Animation, AnimationExt, App, Bounds, ElementId, Hsla, IntoElement, PathBuilder, Pixels, Point, + RenderOnce, SharedString, Styled, Window, canvas, div, point, prelude::*, +}; + +use super::states::{Words, state_view, words_builders}; +use crate::{ + motion, + primitives::Severity, + theme::{ActiveTheme, IconSize}, +}; + +/// Where the mark's strokes run, in a unit square: a check, or the two bars of an X. +const CHECK: [[(f32, f32); 3]; 1] = [[(0.31, 0.52), (0.45, 0.65), (0.70, 0.38)]]; +const CROSS: [[(f32, f32); 3]; 2] = [ + [(0.36, 0.36), (0.50, 0.50), (0.64, 0.64)], + [(0.64, 0.36), (0.50, 0.50), (0.36, 0.64)], +]; +/// Segments in the drawn ring. +const ARC: usize = 64; + +/// Where a task ended: a ring and a check or an X that draw themselves, a title, a line and actions. +#[derive(IntoElement)] +pub struct ResultView { + id: ElementId, + success: bool, + words: Words, +} + +impl ResultView { + pub fn success(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + success: true, + words: Words::new(title), + } + } + + pub fn failure(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + success: false, + words: Words::new(title), + } + } + + words_builders!(); +} + +/// `strokes`, each a polyline in the unit square, drawn to `share` of their length. +fn marks( + bounds: Bounds, + strokes: &[[(f32, f32); 3]], + share: f32, +) -> Vec>> { + let at = |(x, y): (f32, f32)| { + point( + bounds.origin.x + bounds.size.width * x, + bounds.origin.y + bounds.size.height * y, + ) + }; + let each = 1.0 / strokes.len() as f32; + strokes + .iter() + .enumerate() + .filter_map(|(ix, stroke)| { + let local = ((share - ix as f32 * each) / each).clamp(0.0, 1.0); + (local > 0.0).then(|| partial(&stroke.map(at), local)) + }) + .collect() +} + +/// The first `share` of a polyline's length. +fn partial(points: &[Point], share: f32) -> Vec> { + let lengths: Vec = points + .windows(2) + .map(|pair| { + let (x, y) = ( + f32::from(pair[1].x - pair[0].x), + f32::from(pair[1].y - pair[0].y), + ); + (x * x + y * y).sqrt() + }) + .collect(); + let mut left = lengths.iter().sum::() * share; + let mut drawn = vec![points[0]]; + for (pair, length) in points.windows(2).zip(lengths) { + if left >= length { + drawn.push(pair[1]); + left -= length; + } else { + let t = left / length; + drawn.push(pair[0] + (pair[1] - pair[0]) * t); + break; + } + } + drawn +} + +fn stroke(points: &[Point], width: Pixels, color: Hsla, window: &mut Window) { + let mut path = PathBuilder::stroke(width); + path.move_to(points[0]); + for at in &points[1..] { + path.line_to(*at); + } + match path.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!("result mark: a stroke failed to build: {error:#}"), + } +} + +impl RenderOnce for ResultView { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let severity = if self.success { + Severity::Success + } else { + Severity::Danger + }; + let (tone, fill) = ( + severity.color(&theme.colors), + severity.subtle(&theme.colors), + ); + let strokes: &'static [[(f32, f32); 3]] = if self.success { &CHECK } else { &CROSS }; + let head = div() + .size(theme.icon_size(IconSize::Xxl) * 2.0) + .rounded_full() + .bg(fill) + .with_animation( + (self.id.clone(), "draw"), + Animation::new(motion::duration(motion::SLOW * 3, cx)), + move |head, t| { + let ring = motion::ease_out_cubic((t / 0.6).min(1.0)); + let mark = motion::ease_out_cubic(((t - 0.45) / 0.55).clamp(0.0, 1.0)); + head.child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let side = bounds.size.width.min(bounds.size.height); + let width = side / 24.0; + let (center, radius) = (bounds.center(), side / 2.0 - width); + let arc: Vec> = (0..=((ARC as f32 * ring) as usize)) + .map(|step| { + let angle = -FRAC_PI_2 + TAU * step as f32 / ARC as f32; + point( + center.x + radius * angle.cos(), + center.y + radius * angle.sin(), + ) + }) + .collect(); + if arc.len() > 1 { + stroke(&arc, width, tone, window); + } + for line in marks(bounds, strokes, mark) { + stroke(&line, width * 1.5, tone, window); + } + }, + ) + .size_full(), + ) + }, + ) + .into_any_element(); + state_view(self.id, head, self.words, cx) + } +} diff --git a/src/feedback/states.rs b/src/feedback/states.rs new file mode 100644 index 0000000..7ae69fc --- /dev/null +++ b/src/feedback/states.rs @@ -0,0 +1,295 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Div, ElementId, FontWeight, IntoElement, + ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::Button, + forms::Run, + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::tabular, +}; + +/// The words and buttons under a state's head. +pub(super) struct Words { + title: SharedString, + pub(super) body: Option, + detail: Option, + pub(super) actions: SmallVec<[AnyElement; 2]>, +} + +impl Words { + pub(super) fn new(title: impl Into) -> Self { + Self { + title: title.into(), + body: None, + detail: None, + actions: SmallVec::new(), + } + } +} + +/// `block`, centered and no wider than prose; a flex row sets its width before its text wraps. +fn measure(block: Div, cx: &App) -> Div { + div() + .w_full() + .flex() + .justify_center() + .child(block.flex_1().min_w_0().max_w(cx.theme().prose_width())) +} + +/// A head, a title, a line, a detail and actions, centered; it rises in once. +pub(super) fn state_view( + id: ElementId, + head: AnyElement, + words: Words, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .w_full() + .flex() + .flex_col() + .items_center() + .gap_4() + .px_6() + .py_8() + .child(head) + .child(measure( + div() + .text_center() + .child( + div() + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(words.title), + ) + .when_some(words.body, |text, body| { + text.child( + div() + .mt_1() + .text_size(theme.text_size(TextSize::Base)) + .text_color(colors.fg_muted) + .child(body), + ) + }), + cx, + )) + .when_some(words.detail, |state, detail| { + state.child(measure( + div() + .px_3() + .py_2() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(detail), + cx, + )) + }) + .when(!words.actions.is_empty(), |state| { + state.child(div().flex().gap_2().children(words.actions)) + }) + .with_animation( + id, + Animation::new(motion::duration(motion::SLOW, cx)).with_easing(motion::ease_out_cubic), + |state, t| state.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)), + ) +} + +/// An icon on a soft disc, the head of an empty or broken view. +pub(super) fn disc(icon: IconName, cx: &App) -> AnyElement { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_center() + .size(theme.icon_size(IconSize::Xxl) + theme.icon_size(IconSize::Md)) + .rounded_full() + .bg(theme.colors.sunken) + .child( + Icon::new(icon) + .size(IconSize::Lg) + .color(theme.colors.fg_subtle), + ) + .into_any_element() +} + +macro_rules! words_builders { + () => { + pub fn body(mut self, text: impl Into) -> Self { + self.words.body = Some(text.into()); + self + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.words.actions.push(action.into_any_element()); + self + } + }; +} +pub(super) use words_builders; + +/// What a view shows with nothing in it: an icon on a soft disc, a title, a line of help and actions. It rises in when it appears. +#[derive(IntoElement)] +pub struct EmptyState { + id: ElementId, + icon: IconName, + words: Words, +} + +impl EmptyState { + pub fn new(id: impl Into, icon: IconName, title: impl Into) -> Self { + Self { + id: id.into(), + icon, + words: Words::new(title), + } + } + + words_builders!(); +} + +impl RenderOnce for EmptyState { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + state_view(self.id, disc(self.icon, cx), self.words, cx) + } +} + +enum Head { + Code(SharedString), + Icon(IconName), +} + +/// A view that could not show what was asked: a status code or an icon, what happened, and a way on. +#[derive(IntoElement)] +pub struct ErrorView { + id: ElementId, + head: Head, + words: Words, +} + +impl ErrorView { + pub fn new(id: impl Into, icon: IconName, title: impl Into) -> Self { + Self { + id: id.into(), + head: Head::Icon(icon), + words: Words::new(title), + } + } + + /// Headed by a status code, such as 404, instead of an icon. + pub fn with_code( + id: impl Into, + code: impl Into, + title: impl Into, + ) -> Self { + Self { + id: id.into(), + head: Head::Code(code.into()), + words: Words::new(title), + } + } + + pub fn not_found(id: impl Into) -> Self { + Self::with_code(id, "404", "Page not found") + .body("The link may be broken, or the page has moved.") + } + + pub fn no_permission(id: impl Into) -> Self { + Self::with_code(id, "403", "You don't have access") + .body("Ask an owner to share it with you.") + } + + pub fn maintenance(id: impl Into) -> Self { + Self::new(id, IconName::Wrench, "Down for maintenance").body("We'll be back shortly.") + } + + pub fn offline(id: impl Into) -> Self { + Self::new(id, IconName::WifiOff, "You're offline") + .body("Check your connection. Your work stays on this device.") + } + + /// The error itself, in a quiet mono box. + pub fn detail(mut self, text: impl Into) -> Self { + self.words.detail = Some(text.into()); + self + } + + words_builders!(); +} + +impl RenderOnce for ErrorView { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let head = match self.head { + Head::Icon(icon) => disc(icon, cx), + Head::Code(code) => { + let theme = cx.theme(); + tabular(div()) + .text_size(theme.text_size(TextSize::Display)) + .font_weight(FontWeight::LIGHT) + .text_color(theme.colors.fg_subtle) + .child(code) + .into_any_element() + } + }; + state_view(self.id, head, self.words, cx) + } +} + +/// Shows its content, or an `ErrorView` of the error it holds, logged once per error. It never catches panics. +#[derive(IntoElement)] +pub struct ErrorBoundary { + id: ElementId, + content: anyhow::Result, + on_retry: Option, +} + +impl ErrorBoundary { + pub fn new(id: impl Into, content: anyhow::Result) -> Self { + Self { + id: id.into(), + content: content.map(IntoElement::into_any_element), + on_retry: None, + } + } + + 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 ErrorBoundary { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let error = match self.content { + Ok(content) => return content, + Err(error) => error, + }; + let chain = format!("{error:#}"); + let logged = window.use_keyed_state((self.id.clone(), "logged"), cx, |_, _| None::); + if logged.read(cx).as_deref() != Some(chain.as_str()) { + log::error!("error boundary {:?}: {chain}", self.id); + logged.update(cx, |logged, _| *logged = Some(chain.clone())); + } + let retry = (self.id.clone(), "retry"); + ErrorView::new(self.id, IconName::CircleAlert, "Something went wrong") + .body(error.to_string()) + .detail(chain) + .when_some(self.on_retry, |view, run| { + view.action( + Button::new(retry, "Try again").on_click(move |_, window, cx| run(window, cx)), + ) + }) + .into_any_element() + } +} diff --git a/src/feedback/status.rs b/src/feedback/status.rs new file mode 100644 index 0000000..8635f5e --- /dev/null +++ b/src/feedback/status.rs @@ -0,0 +1,194 @@ +use std::{f32::consts::TAU, rc::Rc, time::Duration}; + +use gpui::{ + Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, + Window, div, prelude::*, radians, +}; + +use super::messages::rise; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Run, + primitives::{Icon, IconName}, + shell::{Connectivity, connectivity_dot}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::format::plural, +}; + +/// One turn of a working sync icon. +const TURN: Duration = Duration::from_millis(1400); + +/// Where a save stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveState { + Saving, + Saved, + Failed, +} + +/// A quiet line for autosave: saving, saved, or failed with Retry. +#[derive(IntoElement)] +pub struct SavingIndicator { + id: ElementId, + state: SaveState, + on_retry: Option, +} + +impl SavingIndicator { + pub fn new(id: impl Into, state: SaveState) -> Self { + Self { + id: id.into(), + state, + on_retry: None, + } + } + + 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 SavingIndicator { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (icon, text, tone) = match self.state { + SaveState::Saving => (IconName::CloudUpload, "Saving…", colors.fg_muted), + SaveState::Saved => (IconName::CloudCheck, "Saved", colors.fg_muted), + SaveState::Failed => (IconName::CloudAlert, "Couldn't save", colors.danger), + }; + let retry = (self.id.clone(), "retry"); + let line = div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(tone) + .child(Icon::new(icon).size(IconSize::Sm).color(tone)) + .child(text) + .when_some( + self.on_retry.filter(|_| self.state == SaveState::Failed), + |line, run| { + line.child( + Button::new(retry, "Retry") + .variant(ButtonVariant::Link) + .on_click(move |_, window, cx| run(window, cx)), + ) + }, + ); + rise(self.id, self.state, line, window, cx) + } +} + +/// Where a sync stands; `Syncing` counts what is left. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SyncState { + Synced, + Syncing(usize), + Paused, + Failed, +} + +/// A line for background sync. Its arrows turn while it works. +#[derive(IntoElement)] +pub struct SyncStatus { + id: ElementId, + state: SyncState, +} + +impl SyncStatus { + pub fn new(id: impl Into, state: SyncState) -> Self { + Self { + id: id.into(), + state, + } + } +} + +impl RenderOnce for SyncStatus { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let still = theme.reduced_motion; + let (icon, text, tone) = match self.state { + SyncState::Synced => ( + IconName::CloudCheck, + "Up to date".to_string(), + colors.fg_muted, + ), + SyncState::Syncing(left) => ( + IconName::RefreshCw, + format!("Syncing {}", plural(left as u64, "item", "items")), + colors.fg_muted, + ), + SyncState::Paused => ( + IconName::CirclePause, + "Sync paused".to_string(), + colors.fg_muted, + ), + SyncState::Failed => ( + IconName::CloudAlert, + "Sync failed".to_string(), + colors.danger, + ), + }; + let icon = Icon::new(icon).size(IconSize::Sm).color(tone); + let icon = if matches!(self.state, SyncState::Syncing(_)) && !still { + icon.with_animation( + (self.id.clone(), "turn"), + Animation::new(TURN).repeat(), + |icon, t| icon.rotate(radians(TAU * t)), + ) + .into_any_element() + } else { + icon.into_any_element() + }; + let line = div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(tone) + .child(icon) + .child(text); + let phase = std::mem::discriminant(&self.state); + rise(self.id, phase, line, window, cx) + } +} + +/// A dot and a word for a live connection: connected, reconnecting or offline. `shell::OfflineIndicator` is the pill that shows while offline or reconnecting, and briefly once back. +#[derive(IntoElement)] +pub struct ConnectionStatus { + id: ElementId, + state: Connectivity, +} + +impl ConnectionStatus { + pub fn new(id: impl Into, state: Connectivity) -> Self { + Self { + id: id.into(), + state, + } + } +} + +impl RenderOnce for ConnectionStatus { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let text = match self.state { + Connectivity::Online => "Connected", + Connectivity::Reconnecting => "Reconnecting…", + Connectivity::Offline => "Offline", + }; + let line = div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(connectivity_dot((self.id.clone(), "dot"), self.state, cx)) + .child(text); + rise(self.id, self.state, line, window, cx) + } +} diff --git a/src/feedback/tests.rs b/src/feedback/tests/mod.rs similarity index 98% rename from src/feedback/tests.rs rename to src/feedback/tests/mod.rs index b6744f0..1de28c3 100644 --- a/src/feedback/tests.rs +++ b/src/feedback/tests/mod.rs @@ -5,6 +5,8 @@ use gpui::{ Modifiers, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, point, px, }; +mod states; + use super::{Alert, Notification, NotificationCenter, Toast, ToastViewport, Toaster}; use crate::{ primitives::{FocusNext, FocusScope, IconName, Severity}, @@ -52,13 +54,18 @@ impl Render for Desk { } } -fn desk(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { +/// The theme with reduced motion, text keys and Tab. +fn setup(cx: &mut TestAppContext) { cx.update(|cx| { Theme::init(cx); Theme::update(cx, |theme| theme.reduced_motion = true); crate::forms::bind_keys(cx); cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); }); +} + +fn desk(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); let (view, cx) = cx.add_window_view(|_, cx| Desk { root: cx.focus_handle(), toaster: cx.new(|_| Toaster::default()), diff --git a/src/feedback/tests/states.rs b/src/feedback/tests/states.rs new file mode 100644 index 0000000..aeda963 --- /dev/null +++ b/src/feedback/tests/states.rs @@ -0,0 +1,177 @@ +use std::time::{Duration, Instant}; + +use anyhow::anyhow; +use gpui::{ + Context, Entity, FocusHandle, IntoElement, ParentElement, Pixels, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{press, settle, setup, wait}; +use crate::{ + buttons::Button, + feedback::{ + ConfirmationCard, Countdown, EmptyState, ErrorBoundary, SaveState, SavingIndicator, + }, + primitives::{FocusScope, IconName, Measure}, +}; + +/// A boundary, a confirmation card, a saving line and a countdown, recording what they do. +struct Shelf { + root: FocusHandle, + broken: bool, + save: SaveState, + until: Instant, + log: Vec, +} + +impl Shelf { + fn note( + &self, + cx: &mut Context, + what: &'static str, + ) -> impl Fn(&mut Window, &mut gpui::App) + 'static + use<> { + let view = cx.entity(); + move |_, cx| view.update(cx, |shelf, _| shelf.log.push(what.into())) + } +} + +impl Render for Shelf { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if self.broken { + Err(anyhow!("the service answered 503")) + } else { + Ok(div().child("fine")) + }; + FocusScope::new(&self.root) + .root() + .size_full() + .child(ErrorBoundary::new("boundary", content).on_retry(self.note(cx, "retry"))) + .child( + ConfirmationCard::new("card", "Sure?") + .on_cancel(self.note(cx, "cancel")) + .on_confirm(self.note(cx, "confirm")), + ) + .child(SavingIndicator::new("saving", self.save).on_retry(self.note(cx, "save again"))) + .child(Countdown::new("countdown", self.until).on_done(self.note(cx, "done"))) + } +} + +fn shelf( + broken: bool, + save: SaveState, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, cx| Shelf { + root: cx.focus_handle(), + broken, + save, + until: cx.background_executor().now() + Duration::from_secs(3), + log: Vec::new(), + }); + settle(cx); + let root = view.read_with(cx, |shelf, _| shelf.root.clone()); + cx.update(|window, _| window.focus(&root)); + (view, cx) +} + +fn log(view: &Entity, cx: &mut VisualTestContext) -> Vec { + view.read_with(cx, |shelf, _| shelf.log.clone()) +} + +#[gpui::test] +fn a_broken_boundary_offers_to_try_again(cx: &mut TestAppContext) { + let (view, cx) = shelf(true, SaveState::Saved, cx); + press("tab", cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["retry"]); +} + +#[gpui::test] +fn the_card_cancels_and_confirms(cx: &mut TestAppContext) { + let (view, cx) = shelf(false, SaveState::Saved, cx); + press("tab", cx); + press("enter", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["cancel", "confirm"]); +} + +#[gpui::test] +fn retry_shows_only_once_saving_failed(cx: &mut TestAppContext) { + let (view, cx) = shelf(false, SaveState::Failed, cx); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(log(&view, cx), ["save again"]); + view.update(cx, |shelf, cx| { + shelf.save = SaveState::Saved; + cx.notify(); + }); + settle(cx); + let root = view.read_with(cx, |shelf, _| shelf.root.clone()); + cx.update(|window, _| window.focus(&root)); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!( + log(&view, cx), + ["save again", "cancel"], + "Tab wrapped past a saved line" + ); +} + +#[gpui::test] +fn the_countdown_runs_on_done_once_at_zero(cx: &mut TestAppContext) { + let (view, cx) = shelf(false, SaveState::Saved, cx); + wait(Duration::from_millis(2_900), cx); + assert!(log(&view, cx).is_empty()); + wait(Duration::from_millis(200), cx); + assert_eq!(log(&view, cx), ["done"]); + wait(Duration::from_secs(5), cx); + assert_eq!(log(&view, cx), ["done"]); +} + +/// An empty state in a narrow column, and the height it took. +struct Column { + body: &'static str, + height: Pixels, +} + +impl Render for Column { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + div().w(px(420.0)).child( + Measure::new("column", move |bounds, _, cx| { + view.update(cx, |column, _| column.height = bounds.size.height) + }) + .child( + EmptyState::new("empty", IconName::Inbox, "Nothing yet") + .body(self.body) + .action(Button::new("act", "Act")), + ), + ) + } +} + +fn column_height(body: &'static str, cx: &mut TestAppContext) -> Pixels { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Column { + body, + height: Pixels::ZERO, + }); + settle(cx); + view.read_with(cx, |column, _| column.height) +} + +#[gpui::test] +fn a_wrapped_body_pushes_the_actions_down(cx: &mut TestAppContext) { + let short = column_height("One line.", cx); + let long = column_height( + "Many words that cannot fit on one line of a narrow column, so they wrap onto a second, a third and a fourth line.", + cx, + ); + assert!(long > short + px(24.0), "{short:?} then {long:?}"); +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index b09d2e6..a7f6cee 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -86,8 +86,11 @@ icons! { ClipboardCheck => "clipboard-check", Clipboard => "clipboard", Clock => "clock", + CloudAlert => "cloud-alert", + CloudCheck => "cloud-check", CloudOff => "cloud-off", Cloud => "cloud", + CloudUpload => "cloud-upload", Code => "code", Command => "command", Compass => "compass", @@ -250,6 +253,7 @@ icons! { WandSparkles => "wand-sparkles", WifiOff => "wifi-off", Wifi => "wifi", + Wrench => "wrench", X => "x", Zap => "zap", ZoomIn => "zoom-in", diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 28e7c38..3d61167 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -20,6 +20,7 @@ pub use crash::CrashReporter; pub use dialogs::{AboutDialog, SplashScreen}; pub use frame::ResizeBorder; pub use native::{TrayIcon, TrayItem, dock_badge, set_dock_badge}; +pub(crate) use status::connectivity_dot; pub use status::{Connectivity, OfflineIndicator, ZoomControl}; pub use switcher::WindowSwitcher; pub use tabs::{TabBar, WindowTab}; diff --git a/src/shell/status.rs b/src/shell/status.rs index 1dfdd55..abf25bd 100644 --- a/src/shell/status.rs +++ b/src/shell/status.rs @@ -1,8 +1,8 @@ use std::time::{Duration, Instant}; use gpui::{ - Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task, - Window, div, prelude::*, + Animation, AnimationExt, AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, + Styled, Task, Window, div, prelude::*, }; use crate::{ @@ -31,6 +31,33 @@ struct Seen { _hide: Option>, } +/// The state's dot, in its color; it breathes while reconnecting. +pub(crate) fn connectivity_dot( + id: impl Into, + state: Connectivity, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let tone = match state { + Connectivity::Online => colors.success, + Connectivity::Reconnecting => colors.warning, + Connectivity::Offline => colors.fg_subtle, + }; + let dot = div().size(theme.status_dot()).rounded_full().bg(tone); + if state != Connectivity::Reconnecting || theme.reduced_motion { + return dot.into_any_element(); + } + dot.with_animation( + id, + Animation::new(motion::duration(motion::SLOW, cx) * 3) + .repeat() + .with_easing(motion::ease_in_out_cubic), + |dot, t| dot.opacity(0.35 + 0.65 * (1.0 - (2.0 * t - 1.0).abs())), + ) + .into_any_element() +} + /// A quiet pill while offline or reconnecting, and briefly once back. #[derive(IntoElement)] pub struct OfflineIndicator { @@ -77,14 +104,10 @@ impl RenderOnce for OfflineIndicator { let (back_at, generation) = (seen.read(cx).back_at, seen.read(cx).generation); let theme = cx.theme(); let colors = &theme.colors; - let (icon, dot, label) = match state { - Connectivity::Offline => ( - IconName::WifiOff, - colors.fg_subtle, - "Offline · changes stay on this device", - ), - Connectivity::Reconnecting => (IconName::Wifi, colors.warning, "Reconnecting…"), - Connectivity::Online => (IconName::Wifi, colors.success, "Back online"), + let (icon, label) = match state { + Connectivity::Offline => (IconName::WifiOff, "Offline · changes stay on this device"), + Connectivity::Reconnecting => (IconName::Wifi, "Reconnecting…"), + Connectivity::Online => (IconName::Wifi, "Back online"), }; let showing = state != Connectivity::Online || back_at.is_some_and(|at| at.elapsed() < HOLD); @@ -107,25 +130,7 @@ impl RenderOnce for OfflineIndicator { .shadow(theme.elevation(crate::theme::Elevation::Raised)) .text_size(theme.text_size(TextSize::Sm)) .text_color(colors.fg_muted) - .child({ - let light = div() - .size(theme.icon_size(IconSize::Xs) / 2.0) - .rounded_full() - .bg(dot); - if state == Connectivity::Reconnecting && !still { - light - .with_animation( - "reconnect-pulse", - Animation::new(motion::duration(motion::SLOW, cx) * 3) - .repeat() - .with_easing(motion::ease_in_out_cubic), - |dot, t| dot.opacity(0.35 + 0.65 * (1.0 - (2.0 * t - 1.0).abs())), - ) - .into_any_element() - } else { - light.into_any_element() - } - }) + .child(connectivity_dot("dot", state, cx)) .child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) .child(label) .with_animation( diff --git a/src/typography/rolling.rs b/src/typography/rolling.rs index 1bb1e1c..b1c9565 100644 --- a/src/typography/rolling.rs +++ b/src/typography/rolling.rs @@ -32,6 +32,7 @@ pub struct AnimatedNumber { id: ElementId, value: f64, decimals: usize, + pad: usize, tween: Tween, size: TextSize, } @@ -42,6 +43,7 @@ impl AnimatedNumber { id: id.into(), value, decimals: 0, + pad: 0, tween: Tween::Roll, size: TextSize::Xxl, } @@ -52,6 +54,12 @@ impl AnimatedNumber { self } + /// Zero-pads to `width` characters, as a clock shows minutes. + pub fn pad(mut self, width: usize) -> Self { + self.pad = width; + self + } + /// Counts through the values instead of rolling digits. pub fn count_up(mut self) -> Self { self.tween = Tween::Count; @@ -64,6 +72,14 @@ impl AnimatedNumber { } } +/// Zero-pads `text` to `width` characters, after its minus sign. +fn padded(text: &str, width: usize) -> String { + match text.strip_prefix(format::MINUS) { + Some(digits) => format!("{}{digits:0>1$}", format::MINUS, width.saturating_sub(1)), + None => format!("{text:0>width$}"), + } +} + /// Pads the shorter string on the left so digits align by place. fn align(from: &str, to: &str) -> (Vec, Vec) { let width = from.chars().count().max(to.chars().count()); @@ -94,8 +110,9 @@ impl RenderOnce for AnimatedNumber { let change = state.read(cx); (change.from, change.to, change.generation) }; - let decimals = self.decimals; - let text = move |number: f64| format::number(number, decimals, Separators::EN); + let (decimals, pad) = (self.decimals, self.pad); + let text = + move |number: f64| padded(&format::number(number, decimals, Separators::EN), pad); let duration = motion::duration(motion::SLOW * 2, cx); let theme = cx.theme(); let size = theme.text_size(self.size); @@ -159,7 +176,14 @@ impl RenderOnce for AnimatedNumber { #[cfg(test)] mod tests { - use super::align; + use super::{align, padded}; + + #[test] + fn padding_keeps_the_minus_first() { + assert_eq!(padded("7", 2), "07"); + assert_eq!(padded("\u{2212}12", 5), "\u{2212}0012"); + assert_eq!(padded("123", 2), "123"); + } #[test] fn align_pads_by_place() { diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 00c33a6..eb4d1a1 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -364,17 +364,17 @@ ## T10b Feedback · States -- [ ] ErrorView -- [ ] ErrorBoundary — renders and logs a `Result` error; never catches panics -- [ ] NotFound / 404 → `feedback::ErrorView` -- [ ] NoPermission / 403 → `feedback::ErrorView` -- [ ] Maintenance → `feedback::ErrorView` -- [ ] Offline → `feedback::ErrorView` +- [x] ErrorView → `feedback::ErrorView` +- [x] ErrorBoundary — renders and logs a `Result` error; never catches panics → `feedback::ErrorBoundary` +- [x] NotFound / 404 → `feedback::ErrorView::not_found` +- [x] NoPermission / 403 → `feedback::ErrorView::no_permission` +- [x] Maintenance → `feedback::ErrorView::maintenance` +- [x] Offline → `feedback::ErrorView::offline` - [x] EmptyState → `feedback::EmptyState`, built with T10a -- [ ] Result -- [ ] ConfirmationCard -- [ ] SavingIndicator -- [ ] SyncStatus -- [ ] ConnectionStatus -- [ ] Countdown -- [ ] Timer +- [x] Result → `feedback::ResultView`; a plain `Result` would shadow std's +- [x] ConfirmationCard → `feedback::ConfirmationCard` +- [x] SavingIndicator → `feedback::SavingIndicator` +- [x] SyncStatus → `feedback::SyncStatus` +- [x] ConnectionStatus → `feedback::ConnectionStatus`; its dot is `shell::OfflineIndicator`'s +- [x] Countdown → `feedback::Countdown` +- [x] Timer → `feedback::Timer`