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`