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

+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:?}");
}