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

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 16:19:41 -04:00
1 parent f895424a08
commit 866335912a
24 files changed
+1806 -173

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
use std::time::Duration;
use gpui::{
AppContext as _, Context, Entity, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
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},
theme::Theme,
};
/// A toast stack, an alert and a notification center, recording what they do.
struct Desk {
root: FocusHandle,
toaster: Entity<Toaster>,
unread: bool,
log: Vec<String>,
}
impl Desk {
fn note(
&self,
cx: &mut Context<Self>,
what: &'static str,
) -> impl Fn(&mut Window, &mut gpui::App) + 'static + use<> {
let view = cx.entity();
move |_, cx| view.update(cx, |desk, _| desk.log.push(what.into()))
}
}
impl Render for Desk {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
FocusScope::new(&self.root)
.root()
.size_full()
.child(ToastViewport::new("toasts", &self.toaster))
.child(
Alert::new("alert", Severity::Warning, "Heads up")
.on_dismiss(self.note(cx, "alert")),
)
.child(
NotificationCenter::new("center")
.group(
"Today",
[Notification::new("n", IconName::Bell, "Hello").unread(self.unread)],
)
.on_read_all(self.note(cx, "read all"))
.on_clear(self.note(cx, "clear")),
)
}
}
/// 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<Desk>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, cx| Desk {
root: cx.focus_handle(),
toaster: cx.new(|_| Toaster::default()),
unread: true,
log: Vec::new(),
});
settle(cx);
let root = view.read_with(cx, |desk, _| desk.root.clone());
cx.update(|window, _| window.focus(&root));
(view, cx)
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// Toasts paint last, so their buttons follow the alert's and the center's two in Tab order.
fn tab_to_toast(cx: &mut VisualTestContext) {
for _ in 0..4 {
press("tab", cx);
}
}
fn wait(time: Duration, cx: &mut VisualTestContext) {
cx.executor().advance_clock(time);
settle(cx);
}
fn push(view: &Entity<Desk>, toast: Toast, cx: &mut VisualTestContext) -> u64 {
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
let id = toaster.update(cx, |toaster, cx| toaster.push(toast, cx));
settle(cx);
id
}
/// Each toast's id, and whether it is on its way out.
fn toasts(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<(u64, bool)> {
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
toaster.read_with(cx, |toaster, _| {
toaster
.live
.iter()
.map(|live| (live.id, live.leaving))
.collect()
})
}
fn log(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
view.read_with(cx, |desk, _| desk.log.clone())
}
#[gpui::test]
fn a_toast_folds_away_after_its_time(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Saved"), cx);
wait(Duration::from_millis(4900), cx);
assert_eq!(toasts(&view, cx), [(id, false)]);
wait(Duration::from_millis(200), cx);
assert!(
toasts(&view, cx).is_empty(),
"reduced motion folds it at once"
);
}
#[gpui::test]
fn the_pointer_on_the_stack_holds_the_clock(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Saved"), cx);
wait(Duration::from_secs(3), cx);
let screen = cx.update(|window, _| window.viewport_size());
let on_toast = point(screen.width - px(120.0), screen.height - px(48.0));
cx.simulate_mouse_move(on_toast, None, Modifiers::none());
settle(cx);
wait(Duration::from_secs(20), cx);
assert_eq!(toasts(&view, cx), [(id, false)], "held while pointed at");
cx.simulate_mouse_move(point(px(4.0), px(4.0)), None, Modifiers::none());
settle(cx);
wait(Duration::from_millis(1900), cx);
assert_eq!(toasts(&view, cx), [(id, false)], "two seconds were left");
wait(Duration::from_millis(200), cx);
assert!(toasts(&view, cx).is_empty());
}
#[gpui::test]
fn a_fifth_toast_sends_the_oldest_away(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let ids: Vec<u64> = (0..5).map(|_| push(&view, Toast::new("Hi"), cx)).collect();
let leaving: Vec<u64> = toasts(&view, cx)
.into_iter()
.filter(|(_, out)| *out)
.map(|(id, _)| id)
.collect();
assert_eq!(leaving, [ids[0]]);
}
#[gpui::test]
fn a_toast_without_a_time_stays(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Offline").stay(None), cx);
wait(Duration::from_secs(120), cx);
assert_eq!(toasts(&view, cx), [(id, false)]);
}
#[gpui::test]
fn undo_runs_then_sends_its_toast_away(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
let id = push(&view, Toast::new("Deleted").undo(undo), cx);
tab_to_toast(cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["undo"]);
assert_eq!(toasts(&view, cx), [(id, true)]);
}
#[gpui::test]
fn the_alert_closes_from_its_button(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["alert"]);
}
#[gpui::test]
fn mark_all_read_shows_only_with_something_unread(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
press("tab", cx);
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["read all"]);
view.update(cx, |desk, cx| {
desk.unread = false;
cx.notify();
});
settle(cx);
press("tab", cx);
press("tab", cx);
press("enter", cx);
assert_eq!(
log(&view, cx),
["read all", "clear"],
"the root took focus back, Clear is second"
);
}
#[gpui::test]
fn a_toast_gone_under_focus_hands_it_to_the_root(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
push(&view, Toast::new("Deleted").undo(undo), cx);
tab_to_toast(cx);
press("enter", cx);
wait(Duration::from_millis(300), cx);
assert!(toasts(&view, cx).is_empty());
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["undo", "alert"]);
}
#[gpui::test]
fn a_leaving_toast_runs_its_undo_once(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = false));
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
push(&view, Toast::new("Deleted").undo(undo), cx);
// Animations run on the wall clock; let the toast finish rising.
std::thread::sleep(Duration::from_millis(400));
settle(cx);
let screen = cx.update(|window, _| window.viewport_size());
let on_undo = point(screen.width - px(78.0), screen.height - px(49.0));
for _ in 0..2 {
cx.simulate_mouse_move(on_undo, None, Modifiers::none());
cx.simulate_click(on_undo, Modifiers::none());
settle(cx);
}
assert_eq!(log(&view, cx), ["undo"]);
}
+177
View File
@@ -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<String>,
}
impl Shelf {
fn note(
&self,
cx: &mut Context<Self>,
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<Self>) -> 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<Shelf>, &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<Shelf>, cx: &mut VisualTestContext) -> Vec<String> {
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<Self>) -> 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:?}");
}