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:
24 files changed
+1806
-173
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user