T10a feedback: toasts with undo and a hover-held clock, notifications and their center, alert, banner, callout, inline and status messages, empty state; root focus scopes take lost focus

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 15:43:31 -04:00
1 parent ef74bac823
commit f895424a08
19 files changed
+1966 -16

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
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)),
)
}
}
+360
View File
@@ -0,0 +1,360 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use smallvec::SmallVec;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::Run,
motion,
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// A ghost close button that keeps its row's height.
fn dismiss(id: ElementId, run: Run) -> impl IntoElement {
div().my_neg_1().child(
IconButton::new(id, IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| run(window, cx)),
)
}
/// A message in a page's flow: the severity's icon on its tint, a title, a line, actions and a close button.
#[derive(IntoElement)]
pub struct Alert {
id: ElementId,
severity: Severity,
title: SharedString,
body: Option<SharedString>,
actions: SmallVec<[AnyElement; 2]>,
on_dismiss: Option<Run>,
}
impl Alert {
pub fn new(
id: impl Into<ElementId>,
severity: Severity,
title: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
severity,
title: title.into(),
body: None,
actions: SmallVec::new(),
on_dismiss: None,
}
}
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
}
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Alert {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let close = (self.id.clone(), "dismiss");
div()
.id(self.id)
.flex()
.items_start()
.gap_3()
.p_3()
.rounded(theme.radius(Radius::Lg))
.bg(self.severity.subtle(colors))
.text_size(theme.text_size(TextSize::Base))
.child(
div().mt_0p5().child(
Icon::new(self.severity.icon())
.size(IconSize::Md)
.color(self.severity.color(colors)),
),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_1()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(self.title),
)
.when_some(self.body, |text, body| {
text.child(div().text_color(colors.fg_muted).child(body))
})
.when(!self.actions.is_empty(), |text| {
text.child(div().flex().gap_2().pt_1().children(self.actions))
}),
)
.when_some(self.on_dismiss, |alert, run| {
alert.child(dismiss(close.into(), run))
})
}
}
/// A strip across the top of a window or page: an icon, one line, a link and a close button.
#[derive(IntoElement)]
pub struct Banner {
id: ElementId,
severity: Severity,
message: SharedString,
action: Option<(SharedString, Run)>,
on_dismiss: Option<Run>,
}
impl Banner {
pub fn new(
id: impl Into<ElementId>,
severity: Severity,
message: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
severity,
message: message.into(),
action: None,
on_dismiss: None,
}
}
pub fn action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.action = Some((label.into(), Rc::new(handler)));
self
}
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Banner {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let (link, close) = ((self.id.clone(), "action"), (self.id.clone(), "dismiss"));
div()
.id(self.id)
.relative()
.flex()
.items_center()
.justify_center()
.gap_2()
.w_full()
.min_h(theme.control_height(ControlSize::Lg))
.py_1()
.px_10()
.bg(self.severity.subtle(colors))
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg)
.child(
Icon::new(self.severity.icon())
.size(IconSize::Sm)
.color(self.severity.color(colors)),
)
.child(self.message)
.when_some(self.action, |banner, (label, run)| {
banner.child(
Button::new(link, label)
.variant(ButtonVariant::Link)
.on_click(move |_, window, cx| run(window, cx)),
)
})
.when_some(self.on_dismiss, |banner, run| {
banner.child(
div()
.absolute()
.right_1()
.top_0()
.bottom_0()
.flex()
.items_center()
.child(dismiss(close.into(), run)),
)
})
}
}
/// A note set apart in reading: a rule in the severity's color, a labeled icon, then the text.
#[derive(IntoElement)]
pub struct Callout {
severity: Severity,
title: SharedString,
body: SmallVec<[AnyElement; 2]>,
}
impl Callout {
/// Titled Note, Tip, Warning or Danger until `title` says otherwise.
pub fn new(severity: Severity) -> Self {
let title = match severity {
Severity::Info => "Note",
Severity::Success => "Tip",
Severity::Warning => "Warning",
Severity::Danger => "Danger",
};
Self {
severity,
title: title.into(),
body: SmallVec::new(),
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = title.into();
self
}
}
impl ParentElement for Callout {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for Callout {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let tone = self.severity.color(colors);
div()
.flex()
.flex_col()
.gap_1()
.py_1()
.pl_4()
.border_l_2()
.border_color(tone)
.text_size(theme.text_size(TextSize::Base))
.child(
div()
.flex()
.items_center()
.gap_2()
.font_weight(FontWeight::MEDIUM)
.text_color(tone)
.child(
Icon::new(self.severity.icon())
.size(IconSize::Sm)
.color(tone),
)
.child(self.title),
)
.child(div().text_color(colors.fg).children(self.body))
}
}
/// One line beside what it is about, in the severity's color.
#[derive(IntoElement)]
pub struct InlineMessage {
severity: Severity,
text: SharedString,
}
impl InlineMessage {
pub fn new(severity: Severity, text: impl Into<SharedString>) -> Self {
Self {
severity,
text: text.into(),
}
}
}
impl RenderOnce for InlineMessage {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let tone = self.severity.color(&theme.colors);
div()
.flex()
.items_center()
.gap_1()
.text_size(theme.text_size(TextSize::Sm))
.text_color(tone)
.child(
Icon::new(self.severity.icon())
.size(IconSize::Xs)
.color(tone),
)
.child(self.text)
}
}
/// A line that says what just happened. Each new message rises in; the first stays still.
#[derive(IntoElement)]
pub struct StatusMessage {
id: ElementId,
text: SharedString,
icon: Option<IconName>,
}
impl StatusMessage {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
icon: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
}
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()
.flex()
.items_center()
.gap_1p5()
.text_size(theme.text_size(TextSize::Sm))
.text_color(muted)
.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))
}
},
)
}
}
+12
View File
@@ -0,0 +1,12 @@
mod empty;
mod messages;
mod notification;
mod toast;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use empty::EmptyState;
pub use messages::{Alert, Banner, Callout, InlineMessage, StatusMessage};
pub use notification::{Notification, NotificationCenter};
pub use toast::{Toast, ToastViewport, Toaster};
+296
View File
@@ -0,0 +1,296 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use smallvec::SmallVec;
use super::EmptyState;
use crate::{
buttons::{Button, ButtonVariant},
forms::Run,
layout::ScrollArea,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// A titled message with a line, a time and actions: a row of a `NotificationCenter`, or on its own.
#[derive(IntoElement)]
pub struct Notification {
id: ElementId,
icon: IconName,
title: SharedString,
body: Option<SharedString>,
time: Option<SharedString>,
unread: bool,
actions: SmallVec<[AnyElement; 2]>,
}
impl Notification {
pub fn new(id: impl Into<ElementId>, icon: IconName, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
icon,
title: title.into(),
body: None,
time: None,
unread: false,
actions: SmallVec::new(),
}
}
pub fn body(mut self, text: impl Into<SharedString>) -> Self {
self.body = Some(text.into());
self
}
/// When it happened, as the owner words it.
pub fn time(mut self, time: impl Into<SharedString>) -> Self {
self.time = Some(time.into());
self
}
pub fn unread(mut self, unread: bool) -> Self {
self.unread = unread;
self
}
pub fn action(mut self, action: impl IntoElement) -> Self {
self.actions.push(action.into_any_element());
self
}
}
impl RenderOnce for Notification {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let disc = theme.control_height(ControlSize::Lg);
div()
.id(self.id)
.relative()
.flex()
.items_start()
.gap_3()
.px_4()
.py_3()
.text_size(theme.text_size(TextSize::Base))
.hover(|row| row.bg(colors.hover))
.when(self.unread, |row| {
row.child(
div()
.absolute()
.left_1p5()
.top_0()
.h(disc)
.mt_3()
.flex()
.items_center()
.child(
div()
.size(theme.status_dot())
.rounded_full()
.bg(colors.accent),
),
)
})
.child(
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(disc)
.rounded_full()
.bg(colors.sunken)
.child(
Icon::new(self.icon)
.size(IconSize::Md)
.color(colors.fg_muted),
),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.flex()
.items_baseline()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(self.title),
)
.when_some(self.time, |head, time| {
head.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(time),
)
}),
)
.when_some(self.body, |text, body| {
text.child(div().text_color(colors.fg_muted).line_clamp(2).child(body))
})
.when(!self.actions.is_empty(), |text| {
text.child(div().flex().gap_2().pt_1p5().children(self.actions))
}),
)
}
}
/// Notifications under day headings, with Mark all read and Clear. The owner keeps the list and what is read.
#[derive(IntoElement)]
pub struct NotificationCenter {
id: ElementId,
groups: Vec<(SharedString, Vec<Notification>)>,
on_read_all: Option<Run>,
on_clear: Option<Run>,
}
impl NotificationCenter {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
groups: Vec::new(),
on_read_all: None,
on_clear: None,
}
}
/// A heading, then its notifications, newest first. Empty groups are left out.
pub fn group(
mut self,
heading: impl Into<SharedString>,
items: impl IntoIterator<Item = Notification>,
) -> Self {
let items: Vec<Notification> = items.into_iter().collect();
if !items.is_empty() {
self.groups.push((heading.into(), items));
}
self
}
pub fn on_read_all(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_read_all = Some(Rc::new(handler));
self
}
pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_clear = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NotificationCenter {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let unread = self
.groups
.iter()
.flat_map(|(_, items)| items)
.filter(|item| item.unread)
.count();
let empty = self.groups.is_empty();
let header = div()
.flex()
.flex_none()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Lg))
.pl_4()
.pr_2()
.my_1()
.child(
div()
.text_size(theme.text_size(TextSize::Md))
.font_weight(FontWeight::SEMIBOLD)
.child("Notifications"),
)
.when(unread > 0, |header| {
header.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child(format!("{unread} new")),
)
})
.child(div().flex_1())
.when_some(self.on_read_all.filter(|_| unread > 0), |header, run| {
header.child(
Button::new((self.id.clone(), "read-all"), "Mark all read")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| run(window, cx)),
)
})
.when_some(self.on_clear.filter(|_| !empty), |header, run| {
header.child(
Button::new((self.id.clone(), "clear"), "Clear")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| run(window, cx)),
)
});
let body = if empty {
div()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
EmptyState::new(
(self.id.clone(), "empty"),
IconName::CheckCheck,
"All caught up",
)
.body("New notifications will show here."),
)
.into_any_element()
} else {
ScrollArea::new((self.id.clone(), "list"))
.flex_1()
.child(
div()
.flex()
.flex_col()
.pb_2()
.children(self.groups.into_iter().map(|(heading, items)| {
div()
.child(
div()
.px_4()
.pt_3()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_subtle)
.child(heading),
)
.children(items)
})),
)
.into_any_element()
};
div()
.id(self.id)
.flex()
.flex_col()
.size_full()
.child(header)
.child(div().flex_none().h_px().bg(colors.border))
.child(body)
}
}
+248
View File
@@ -0,0 +1,248 @@
use std::time::Duration;
use gpui::{
AppContext as _, Context, Entity, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
Modifiers, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, point, px,
};
use super::{Alert, Notification, NotificationCenter, Toast, ToastViewport, Toaster};
use crate::{
primitives::{FocusNext, FocusScope, IconName, Severity},
theme::Theme,
};
/// A toast stack, an alert and a notification center, recording what they do.
struct Desk {
root: FocusHandle,
toaster: Entity<Toaster>,
unread: bool,
log: Vec<String>,
}
impl Desk {
fn note(
&self,
cx: &mut Context<Self>,
what: &'static str,
) -> impl Fn(&mut Window, &mut gpui::App) + 'static + use<> {
let view = cx.entity();
move |_, cx| view.update(cx, |desk, _| desk.log.push(what.into()))
}
}
impl Render for Desk {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
FocusScope::new(&self.root)
.root()
.size_full()
.child(ToastViewport::new("toasts", &self.toaster))
.child(
Alert::new("alert", Severity::Warning, "Heads up")
.on_dismiss(self.note(cx, "alert")),
)
.child(
NotificationCenter::new("center")
.group(
"Today",
[Notification::new("n", IconName::Bell, "Hello").unread(self.unread)],
)
.on_read_all(self.note(cx, "read all"))
.on_clear(self.note(cx, "clear")),
)
}
}
fn desk(cx: &mut TestAppContext) -> (Entity<Desk>, &mut VisualTestContext) {
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)]);
});
let (view, cx) = cx.add_window_view(|_, cx| Desk {
root: cx.focus_handle(),
toaster: cx.new(|_| Toaster::default()),
unread: true,
log: Vec::new(),
});
settle(cx);
let root = view.read_with(cx, |desk, _| desk.root.clone());
cx.update(|window, _| window.focus(&root));
(view, cx)
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// Toasts paint last, so their buttons follow the alert's and the center's two in Tab order.
fn tab_to_toast(cx: &mut VisualTestContext) {
for _ in 0..4 {
press("tab", cx);
}
}
fn wait(time: Duration, cx: &mut VisualTestContext) {
cx.executor().advance_clock(time);
settle(cx);
}
fn push(view: &Entity<Desk>, toast: Toast, cx: &mut VisualTestContext) -> u64 {
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
let id = toaster.update(cx, |toaster, cx| toaster.push(toast, cx));
settle(cx);
id
}
/// Each toast's id, and whether it is on its way out.
fn toasts(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<(u64, bool)> {
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
toaster.read_with(cx, |toaster, _| {
toaster
.live
.iter()
.map(|live| (live.id, live.leaving))
.collect()
})
}
fn log(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
view.read_with(cx, |desk, _| desk.log.clone())
}
#[gpui::test]
fn a_toast_folds_away_after_its_time(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Saved"), cx);
wait(Duration::from_millis(4900), cx);
assert_eq!(toasts(&view, cx), [(id, false)]);
wait(Duration::from_millis(200), cx);
assert!(
toasts(&view, cx).is_empty(),
"reduced motion folds it at once"
);
}
#[gpui::test]
fn the_pointer_on_the_stack_holds_the_clock(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Saved"), cx);
wait(Duration::from_secs(3), cx);
let screen = cx.update(|window, _| window.viewport_size());
let on_toast = point(screen.width - px(120.0), screen.height - px(48.0));
cx.simulate_mouse_move(on_toast, None, Modifiers::none());
settle(cx);
wait(Duration::from_secs(20), cx);
assert_eq!(toasts(&view, cx), [(id, false)], "held while pointed at");
cx.simulate_mouse_move(point(px(4.0), px(4.0)), None, Modifiers::none());
settle(cx);
wait(Duration::from_millis(1900), cx);
assert_eq!(toasts(&view, cx), [(id, false)], "two seconds were left");
wait(Duration::from_millis(200), cx);
assert!(toasts(&view, cx).is_empty());
}
#[gpui::test]
fn a_fifth_toast_sends_the_oldest_away(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let ids: Vec<u64> = (0..5).map(|_| push(&view, Toast::new("Hi"), cx)).collect();
let leaving: Vec<u64> = toasts(&view, cx)
.into_iter()
.filter(|(_, out)| *out)
.map(|(id, _)| id)
.collect();
assert_eq!(leaving, [ids[0]]);
}
#[gpui::test]
fn a_toast_without_a_time_stays(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let id = push(&view, Toast::new("Offline").stay(None), cx);
wait(Duration::from_secs(120), cx);
assert_eq!(toasts(&view, cx), [(id, false)]);
}
#[gpui::test]
fn undo_runs_then_sends_its_toast_away(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
let id = push(&view, Toast::new("Deleted").undo(undo), cx);
tab_to_toast(cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["undo"]);
assert_eq!(toasts(&view, cx), [(id, true)]);
}
#[gpui::test]
fn the_alert_closes_from_its_button(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["alert"]);
}
#[gpui::test]
fn mark_all_read_shows_only_with_something_unread(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
press("tab", cx);
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["read all"]);
view.update(cx, |desk, cx| {
desk.unread = false;
cx.notify();
});
settle(cx);
press("tab", cx);
press("tab", cx);
press("enter", cx);
assert_eq!(
log(&view, cx),
["read all", "clear"],
"the root took focus back, Clear is second"
);
}
#[gpui::test]
fn a_toast_gone_under_focus_hands_it_to_the_root(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
push(&view, Toast::new("Deleted").undo(undo), cx);
tab_to_toast(cx);
press("enter", cx);
wait(Duration::from_millis(300), cx);
assert!(toasts(&view, cx).is_empty());
press("tab", cx);
press("enter", cx);
assert_eq!(log(&view, cx), ["undo", "alert"]);
}
#[gpui::test]
fn a_leaving_toast_runs_its_undo_once(cx: &mut TestAppContext) {
let (view, cx) = desk(cx);
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = false));
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
push(&view, Toast::new("Deleted").undo(undo), cx);
// Animations run on the wall clock; let the toast finish rising.
std::thread::sleep(Duration::from_millis(400));
settle(cx);
let screen = cx.update(|window, _| window.viewport_size());
let on_undo = point(screen.width - px(78.0), screen.height - px(49.0));
for _ in 0..2 {
cx.simulate_mouse_move(on_undo, None, Modifiers::none());
cx.simulate_click(on_undo, Modifiers::none());
settle(cx);
}
assert_eq!(log(&view, cx), ["undo"]);
}
+406
View File
@@ -0,0 +1,406 @@
use std::{
rc::Rc,
time::{Duration, Instant},
};
use gpui::{
Animation, AnimationExt, AnyElement, App, ClickEvent, Context, Corner, ElementId, Entity,
FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Task, Window, anchored, canvas, deferred, div, point,
prelude::*, relative,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::Run,
motion,
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
};
/// How long a toast stays unless told otherwise.
const STAY: Duration = Duration::from_secs(5);
/// A toast past this many sends the oldest away.
const MOST: usize = 4;
/// A short message for a `ToastViewport`. With an action it is a snackbar; with `undo` it counts down.
#[derive(Clone)]
pub struct Toast {
title: SharedString,
body: Option<SharedString>,
severity: Option<Severity>,
action: Option<(SharedString, Run)>,
undo: Option<Run>,
stay: Option<Duration>,
}
impl Toast {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
body: None,
severity: None,
action: None,
undo: None,
stay: Some(STAY),
}
}
pub fn body(mut self, text: impl Into<SharedString>) -> Self {
self.body = Some(text.into());
self
}
pub fn severity(mut self, severity: Severity) -> Self {
self.severity = Some(severity);
self
}
/// A button that runs `handler`, then sends the toast away.
pub fn action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.action = Some((label.into(), Rc::new(handler)));
self
}
/// An Undo button, and a line that runs out with the time left to press it.
pub fn undo(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.undo = Some(Rc::new(handler));
self
}
/// How long it stays; `None` keeps it until closed.
pub fn stay(mut self, stay: Option<Duration>) -> Self {
assert!(stay != Some(Duration::ZERO), "a toast must stay a while");
self.stay = stay;
self
}
}
/// A toast on screen, and the time it has left.
pub(super) struct Live {
pub(super) id: u64,
toast: Toast,
left: Duration,
since: Option<Instant>,
runs: usize,
pub(super) leaving: bool,
_timer: Option<Task<()>>,
}
/// The toasts a `ToastViewport` shows. Keep one per window and push to it.
#[derive(Default)]
pub struct Toaster {
pub(super) live: Vec<Live>,
next: u64,
held: bool,
}
impl Toaster {
/// Shows `toast`; the id sends it away early.
pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) -> u64 {
let id = self.next;
self.next += 1;
log::info!("toast {id}: {}", toast.title);
let mut live = Live {
id,
left: toast.stay.unwrap_or_default(),
toast,
since: None,
runs: 0,
leaving: false,
_timer: None,
};
if !self.held {
run(&mut live, cx);
}
self.live.push(live);
let staying: Vec<u64> = self.staying().map(|live| live.id).collect();
if staying.len() > MOST {
self.dismiss(staying[0], cx);
}
cx.notify();
id
}
/// Sends a toast away: it fades and folds, then leaves the stack.
pub fn dismiss(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(live) = self
.live
.iter_mut()
.find(|live| live.id == id && !live.leaving)
else {
log::debug!("toast {id}: already gone");
return;
};
log::info!("toast {id}: dismissed");
live.leaving = true;
let exit = motion::duration(motion::BASE, cx);
live._timer = Some(cx.spawn(async move |toaster, cx| {
cx.background_executor().timer(exit).await;
let gone = toaster.update(cx, |toaster, cx| {
toaster.live.retain(|live| live.id != id);
if toaster.live.is_empty() {
toaster.held = false;
}
cx.notify();
});
if let Err(error) = gone {
log::error!("toast {id}: the toaster went first: {error:#}");
}
}));
cx.notify();
}
fn staying(&self) -> impl Iterator<Item = &Live> {
self.live.iter().filter(|live| !live.leaving)
}
/// Clocks wait while the pointer is on the stack.
fn hold(&mut self, held: bool, cx: &mut Context<Self>) {
if self.held == held {
return;
}
self.held = held;
log::debug!("toasts: {}", if held { "held" } else { "running" });
let now = cx.background_executor().now();
for live in self.live.iter_mut().filter(|live| !live.leaving) {
if !held {
run(live, cx);
} else if let Some(since) = live.since.take() {
live.left = live.left.saturating_sub(now - since);
live._timer = None;
}
}
cx.notify();
}
}
/// Starts a toast's clock; at zero the toast is dismissed.
fn run(live: &mut Live, cx: &mut Context<Toaster>) {
if live.toast.stay.is_none() {
return;
}
let (id, left) = (live.id, live.left);
live.since = Some(cx.background_executor().now());
live.runs += 1;
live._timer = Some(cx.spawn(async move |toaster, cx| {
cx.background_executor().timer(left).await;
if let Err(error) = toaster.update(cx, |toaster, cx| toaster.dismiss(id, cx)) {
log::error!("toast {id}: the toaster went first: {error:#}");
}
}));
}
/// Where a window's toasts show: stacked in its bottom-right corner, the newest nearest it.
#[derive(IntoElement)]
pub struct ToastViewport {
id: ElementId,
toaster: Entity<Toaster>,
}
impl ToastViewport {
pub fn new(id: impl Into<ElementId>, toaster: &Entity<Toaster>) -> Self {
Self {
id: id.into(),
toaster: toaster.clone(),
}
}
}
impl RenderOnce for ToastViewport {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.toaster.read(cx).live.is_empty() {
return div().id(self.id).into_any_element();
}
let theme = cx.theme();
let (width, margin) = (theme.toast_width(), theme.window_margin());
let viewport = window.viewport_size();
let hold = self.toaster.clone();
let mut cards = Vec::new();
for ix in 0..self.toaster.read(cx).live.len() {
cards.push(card(&self.id, ix, &self.toaster, window, cx));
}
let stack = div()
.id(self.id)
.occlude()
.flex()
.flex_col()
.w(width)
.on_hover(move |hovered, _, cx| {
hold.update(cx, |toaster, cx| toaster.hold(*hovered, cx))
})
.children(cards);
deferred(
anchored()
.position(point(viewport.width - margin, viewport.height - margin))
.anchor(Corner::BottomRight)
.child(stack),
)
.with_priority(1)
.into_any_element()
}
}
/// One toast: it rises in as its room opens, and folds away under the newer ones.
fn card(
owner: &ElementId,
ix: usize,
toaster: &Entity<Toaster>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let live = &toaster.read(cx).live[ix];
let (id, leaving, toast) = (live.id, live.leaving, live.toast.clone());
let key = ElementId::from((owner.clone(), SharedString::from(format!("toast-{id}"))));
let countdown = countdown(&key, live, cx);
let height = window.use_keyed_state((key.clone(), "height"), cx, |_, _| Pixels::ZERO);
let room = *height.read(cx);
let send = |run: Option<Run>| {
let toaster = toaster.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
if !toaster.read(cx).staying().any(|live| live.id == id) {
log::debug!("toast {id}: pressed while leaving");
return;
}
if let Some(run) = &run {
run(window, cx);
}
toaster.update(cx, |toaster, cx| toaster.dismiss(id, cx));
}
};
let theme = cx.theme();
let colors = &theme.colors;
let card = div()
.id(key.clone())
.relative()
.flex()
.items_center()
.gap_3()
.p_3()
.rounded(theme.radius(Radius::Lg))
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.when_some(toast.severity, |card, severity| {
card.child(
Icon::new(severity.icon())
.size(IconSize::Sm)
.color(severity.color(colors)),
)
})
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(toast.title),
)
.when_some(toast.body, |text, body| {
text.child(div().text_color(colors.fg_muted).child(body))
}),
)
.when_some(toast.undo, |card, undo| {
card.child(
Button::new((key.clone(), "undo"), "Undo")
.size(ControlSize::Sm)
.on_click(send(Some(undo))),
)
})
.when_some(toast.action, |card, (label, run)| {
card.child(
Button::new((key.clone(), "action"), label)
.size(ControlSize::Sm)
.on_click(send(Some(run))),
)
})
.child(
IconButton::new((key.clone(), "close"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(send(None)),
)
.children(countdown);
let placed = div()
.absolute()
.top_0()
.left_0()
.right_0()
.pt_2()
.child(card)
.child(
canvas(
move |bounds, _, cx| {
if *height.read(cx) != bounds.size.height {
height.update(cx, |height, cx| {
*height = bounds.size.height;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
);
let frame = div().relative().w_full().child(placed);
if leaving {
frame
.with_animation(
(key, "out"),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_in_out_cubic),
move |frame, t| frame.h(room * (1.0 - t)).opacity(1.0 - t),
)
.into_any_element()
} else {
frame
.with_animation(
(key, "in"),
Animation::new(motion::duration(motion::SLOW, cx))
.with_easing(motion::ease_out_cubic),
move |frame, t| frame.h(room * t).opacity(t),
)
.into_any_element()
}
}
/// The line under an undo toast that runs out with its time.
fn countdown(key: &ElementId, live: &Live, cx: &App) -> Option<impl IntoElement + use<>> {
let total = live.toast.stay.filter(|_| live.toast.undo.is_some())?;
let from = live.left.as_secs_f32() / total.as_secs_f32();
let theme = cx.theme();
let line = div().h_full().bg(theme.colors.border_strong);
let line = match live.since {
Some(_) => line
.with_animation(
(key.clone(), format!("left-{}", live.runs)),
Animation::new(live.left),
move |line, t| line.w(relative(from * (1.0 - t))),
)
.into_any_element(),
None => line.w(relative(from)).into_any_element(),
};
Some(
div()
.absolute()
.left_3()
.right_3()
.bottom_1()
.h(theme.progress_thickness())
.child(line),
)
}
+1
View File
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
pub mod feedback;
pub mod forms;
pub mod layout;
pub mod menus;
+23 -1
View File
@@ -23,6 +23,7 @@ pub struct FocusScope {
base: Div,
handle: FocusHandle,
trap: bool,
root: bool,
}
impl FocusScope {
@@ -31,6 +32,7 @@ impl FocusScope {
base: div().track_focus(handle),
handle: handle.clone(),
trap: false,
root: false,
}
}
@@ -38,6 +40,17 @@ impl FocusScope {
self.trap = true;
self
}
/// The window's outermost scope: it takes focus when the focused element leaves the tree.
pub fn root(mut self) -> Self {
self.root = true;
self
}
}
/// Keeps a root scope's claim on lost focus while it renders.
struct Root {
_lost: Subscription,
}
impl Styled for FocusScope {
@@ -53,7 +66,16 @@ impl ParentElement for FocusScope {
}
impl RenderOnce for FocusScope {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.root {
let handle = self.handle.clone();
window.use_keyed_state("focus-root", cx, |window, cx| Root {
_lost: cx.on_focus_lost(window, move |_: &mut Root, window, _| {
log::info!("focus: its element left the tree; the root takes it");
window.focus(&handle);
}),
});
}
let (next, prev) = (self.handle.clone(), self.handle);
let trap = self.trap;
self.base
+1
View File
@@ -205,6 +205,7 @@ icons! {
Save => "save",
Scissors => "scissors",
Search => "search",
SearchX => "search-x",
Send => "send",
Server => "server",
Settings => "settings",
+1
View File
@@ -47,6 +47,7 @@ impl<T: IntoElement + Clone + 'static> Render for Hosted<T> {
})
.child(
FocusScope::new(&self.focus)
.root()
.size_full()
.flex()
.flex_col()
+11 -1
View File
@@ -189,7 +189,7 @@ impl Theme {
px(16.0)
}
/// Gap between a mini window and the screen edge.
/// Gap between the edge of a screen or window and what floats by it: a mini window, a toast stack.
pub fn window_margin(&self) -> Pixels {
px(24.0)
}
@@ -243,6 +243,16 @@ impl Theme {
px_to_rems(440.0)
}
/// Width of a toast stack.
pub fn toast_width(&self) -> Rems {
px_to_rems(360.0)
}
/// Widest a centered block of prose runs, such as an empty state's help.
pub fn prose_width(&self) -> Rems {
px_to_rems(320.0)
}
/// Narrowest a menu's panel gets.
pub fn menu_width(&self) -> Rems {
px_to_rems(200.0)