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

+2
View File
@@ -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.
+4 -1
View File
@@ -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) |
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-cloud-alert"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 12v4" />
<path d="M12 20h.01" />
<path d="M8.128 16.949A7 7 0 1 1 15.71 8h1.79a1 1 0 0 1 0 9h-1.642" />
</svg>

After

Width:  |  Height:  |  Size: 413 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-cloud-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m17 15-5.5 5.5L9 18" />
<path d="M5.516 16.07A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 3.501 7.327" />
</svg>

After

Width:  |  Height:  |  Size: 402 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-cloud-upload"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-wrench"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
</svg>

After

Width:  |  Height:  |  Size: 494 B

+2 -1
View File
@@ -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(
+24
View File
@@ -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()
}
+337
View File
@@ -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::<Instant>, 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."))
})
}
+273
View File
@@ -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<Run>,
_tick: Option<Task<()>>,
}
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<Duration> {
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<Run>,
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<Run>,
}
impl Countdown {
pub fn new(id: impl Into<ElementId>, 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<Instant>,
size: TextSize,
}
impl Timer {
pub fn new(id: impl Into<ElementId>, 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<Instant>) -> 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)));
}
}
+141
View File
@@ -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<SharedString>,
detail: SmallVec<[AnyElement; 2]>,
confirm: SharedString,
destructive: bool,
on_confirm: Option<Run>,
on_cancel: Option<Run>,
}
impl ConfirmationCard {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> 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<SharedString>) -> Self {
self.body = Some(text.into());
self
}
/// The confirming button's label.
pub fn confirm(mut self, label: impl Into<SharedString>) -> 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<Item = AnyElement>) {
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<Run>, 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")),
),
)
}
}
-106
View File
@@ -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<SharedString>,
actions: SmallVec<[AnyElement; 2]>,
}
impl EmptyState {
pub fn new(id: impl Into<ElementId>, icon: IconName, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
icon,
title: title.into(),
body: None,
actions: SmallVec::new(),
}
}
pub fn body(mut self, text: impl Into<SharedString>) -> 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)),
)
}
}
+27 -17
View File
@@ -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<T: Clone + PartialEq + 'static>(
id: ElementId,
key: T,
line: Div,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement + use<T> {
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))
}
},
)
}
+10 -2
View File
@@ -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};
+168
View File
@@ -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<ElementId>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
success: true,
words: Words::new(title),
}
}
pub fn failure(id: impl Into<ElementId>, title: impl Into<SharedString>) -> 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<Pixels>,
strokes: &[[(f32, f32); 3]],
share: f32,
) -> Vec<Vec<Point<Pixels>>> {
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<Pixels>], share: f32) -> Vec<Point<Pixels>> {
let lengths: Vec<f32> = 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::<f32>() * 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<Pixels>], 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<Point<Pixels>> = (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)
}
}
+295
View File
@@ -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<SharedString>,
detail: Option<SharedString>,
pub(super) actions: SmallVec<[AnyElement; 2]>,
}
impl Words {
pub(super) fn new(title: impl Into<SharedString>) -> 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<SharedString>) -> 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<ElementId>, icon: IconName, title: impl Into<SharedString>) -> 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<ElementId>, icon: IconName, title: impl Into<SharedString>) -> 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<ElementId>,
code: impl Into<SharedString>,
title: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
head: Head::Code(code.into()),
words: Words::new(title),
}
}
pub fn not_found(id: impl Into<ElementId>) -> 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<ElementId>) -> 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<ElementId>) -> Self {
Self::new(id, IconName::Wrench, "Down for maintenance").body("We'll be back shortly.")
}
pub fn offline(id: impl Into<ElementId>) -> 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<SharedString>) -> 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<AnyElement>,
on_retry: Option<Run>,
}
impl ErrorBoundary {
pub fn new<E: IntoElement>(id: impl Into<ElementId>, content: anyhow::Result<E>) -> 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::<String>);
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()
}
}
+194
View File
@@ -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<Run>,
}
impl SavingIndicator {
pub fn new(id: impl Into<ElementId>, 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<ElementId>, 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<ElementId>, 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)
}
}
@@ -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<Desk>, &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<Desk>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, cx| Desk {
root: cx.focus_handle(),
toaster: cx.new(|_| Toaster::default()),
+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:?}");
}
+4
View File
@@ -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",
+1
View File
@@ -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};
+34 -29
View File
@@ -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<Task<()>>,
}
/// The state's dot, in its color; it breathes while reconnecting.
pub(crate) fn connectivity_dot(
id: impl Into<ElementId>,
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(
+27 -3
View File
@@ -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<char>, Vec<char>) {
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() {
+13 -13
View File
@@ -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`