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:
19 files changed
+1966
-16
No files matched your search
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user