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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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."))
|
||||
})
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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`
|
||||
Reference in new issue
Block a user