use std::rc::Rc;
use std::time::Duration;
use gpui::{
AnyElement, App, Context, Entity, Global, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{LiveRegion, NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Layer, Space, Theme, TypeScale};
use web_time::Instant;
use crate::controls::button::Button;
use crate::display::badge::Tone;
use crate::display::status::StatusDot;
use crate::foundation::{FocusRing, Ident, Sizable, StyledExt};
use crate::motion::{Easing, Flipping, MotionSpec, Phase, Presence, flip};
use crate::overlay::layer::{pinned, priority, surface};
use crate::strings::{ActiveStrings, StringKey};
const DEFAULT_CAPACITY: usize = 3;
const CARD_WIDTH: f32 = 320.0;
const TRAVEL: f32 = 8.0;
type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToastCorner {
TopLeft,
TopRight,
BottomLeft,
#[default]
BottomRight,
}
impl ToastCorner {
fn is_top(self) -> bool {
matches!(self, Self::TopLeft | Self::TopRight)
}
fn is_leading(self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Lifetime {
ByTone,
Timed(Duration),
Persistent,
}
struct Action {
label: SharedString,
handler: ActionHandler,
}
pub struct Toast {
ident: Ident,
message: SharedString,
detail: Option<SharedString>,
tone: Tone,
action: Option<Action>,
dismissable: bool,
lifetime: Lifetime,
}
impl std::fmt::Debug for Toast {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Toast")
.field("ident", &self.ident)
.field("message", &self.message)
.field("detail", &self.detail)
.field("tone", &self.tone)
.field("has_action", &self.action.is_some())
.field("dismissable", &self.dismissable)
.field("lifetime", &self.lifetime)
.finish()
}
}
impl Toast {
pub fn new(ident: impl Into<Ident>, message: impl Into<SharedString>) -> Self {
Self {
ident: ident.into(),
message: message.into(),
detail: None,
tone: Tone::default(),
action: None,
dismissable: true,
lifetime: Lifetime::ByTone,
}
}
pub fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.action = Some(Action {
label: label.into(),
handler: Rc::new(handler),
});
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.lifetime = Lifetime::Timed(timeout);
self
}
pub fn persistent(mut self) -> Self {
self.lifetime = Lifetime::Persistent;
self
}
pub fn ident(&self) -> &Ident {
&self.ident
}
fn allowance(&self, theme: &Theme) -> Option<Duration> {
match self.lifetime {
Lifetime::Timed(timeout) => Some(timeout),
Lifetime::Persistent => None,
Lifetime::ByTone if reports_failure(self.tone) => None,
Lifetime::ByTone => Some(Duration::from_millis(theme.motion.toast_ms)),
}
}
}
fn reports_failure(tone: Tone) -> bool {
matches!(tone, Tone::Danger | Tone::Warning)
}
fn enter_spec(theme: &Theme) -> MotionSpec {
MotionSpec::new(theme.motion.menu_ms, Easing::Settle.curve(theme))
}
fn exit_spec(theme: &Theme) -> MotionSpec {
MotionSpec::new(theme.motion.quick_ms, Easing::Exit.curve(theme))
}
struct Entry {
toast: Toast,
presence: Presence,
remaining: Option<Duration>,
hovered: bool,
progress: f32,
}
impl Entry {
fn new(toast: Toast, remaining: Option<Duration>, theme: &Theme) -> Self {
let mut presence = Presence::hidden(enter_spec(theme), exit_spec(theme));
presence.show();
Self {
toast,
presence,
remaining,
hovered: false,
progress: 0.0,
}
}
fn id(&self) -> SharedString {
self.toast.ident.semantic_id()
}
fn leaving(&self) -> bool {
matches!(self.presence.phase(), Phase::Exiting | Phase::Gone)
}
fn evictable(&self) -> bool {
!self.leaving() && self.toast.dismissable && self.remaining.is_some()
}
fn count_down(&mut self, delta: Duration) -> bool {
if self.hovered || !matches!(self.presence.phase(), Phase::Entering | Phase::Present) {
return false;
}
let Some(remaining) = self.remaining else {
return false;
};
let left = remaining.saturating_sub(delta);
self.remaining = Some(left);
if left.is_zero() {
self.presence.hide();
return false;
}
true
}
}
pub struct ToastLayer {
corner: ToastCorner,
capacity: usize,
entries: Vec<Entry>,
last_tick: Option<Instant>,
}
impl std::fmt::Debug for ToastLayer {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ToastLayer")
.field("corner", &self.corner)
.field("capacity", &self.capacity)
.field("toasts", &self.entries.len())
.finish()
}
}
impl ToastLayer {
pub fn new(cx: &mut Context<Self>) -> Self {
let handle = cx.weak_entity();
cx.set_global(ToastCenter {
layer: Some(handle),
});
Self {
corner: ToastCorner::default(),
capacity: DEFAULT_CAPACITY,
entries: Vec::new(),
last_tick: None,
}
}
pub fn corner(mut self, corner: ToastCorner) -> Self {
self.corner = corner;
self
}
pub fn capacity(mut self, capacity: usize) -> Self {
self.capacity = capacity.max(1);
self
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn phase(&self, ident: &str) -> Option<Phase> {
self.entry(ident).map(|entry| entry.presence.phase())
}
pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) {
let theme = cx.theme().clone();
let remaining = toast.allowance(&theme);
let id = toast.ident.semantic_id();
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == id) {
entry.toast = toast;
entry.remaining = remaining;
entry.hovered = false;
entry.presence.show();
cx.notify();
return;
}
self.make_room();
self.entries.push(Entry::new(toast, remaining, &theme));
cx.notify();
}
pub fn dismiss(&mut self, ident: &str, cx: &mut Context<Self>) -> bool {
let Some(entry) = self
.entries
.iter_mut()
.find(|entry| entry.id() == ident && !entry.leaving())
else {
return false;
};
entry.presence.hide();
cx.notify();
true
}
pub fn clear(&mut self, cx: &mut Context<Self>) {
for entry in &mut self.entries {
entry.presence.hide();
}
cx.notify();
}
fn entry(&self, ident: &str) -> Option<&Entry> {
self.entries.iter().find(|entry| entry.id() == ident)
}
fn set_hovered(&mut self, ident: &SharedString, hovered: bool, cx: &mut Context<Self>) {
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == *ident) {
entry.hovered = hovered;
cx.notify();
}
}
fn make_room(&mut self) {
while self.standing() >= self.capacity {
let Some(index) = self.entries.iter().position(Entry::evictable) else {
break;
};
self.entries[index].presence.hide();
}
}
fn standing(&self) -> usize {
self.entries.iter().filter(|entry| !entry.leaving()).count()
}
fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let now = cx.background_executor().now();
let delta = self
.last_tick
.map(|last| now.saturating_duration_since(last))
.unwrap_or_default();
let mut timing = false;
for entry in &mut self.entries {
timing |= entry.count_down(delta);
}
for entry in &mut self.entries {
entry.progress = entry.presence.animate(window, cx);
}
self.entries.retain(|entry| entry.presence.is_rendered());
self.last_tick = timing.then_some(now);
if timing {
window.request_animation_frame();
}
}
fn card(
&self,
index: usize,
theme: &Theme,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let entry = &self.entries[index];
let toast = &entry.toast;
let id = toast.ident.semantic_id();
let color = toast.tone.color(theme);
let layer = cx.entity().downgrade();
let action = toast.action.as_ref().map(|action| {
let handler = Rc::clone(&action.handler);
let layer = layer.clone();
let id = id.clone();
Button::new(toast.ident.child("action"))
.semantic_parent(id.clone())
.label(action.label.clone())
.secondary()
.small()
.on_click(move |window, cx| {
handler(window, cx);
layer
.update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
.ok();
})
});
let dismiss = toast.dismissable.then(|| {
let dismiss_ident = toast.ident.child("dismiss");
let mut control = div()
.id(dismiss_ident.element_id())
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(16.0))
.cursor_pointer()
.tab_index(0)
.focus_ring(theme)
.child(
icon(Icon::Close)
.size(px(11.0))
.text_color(theme.colors.text_faint),
)
.semantic_in(
cx,
NodeSpec::new(dismiss_ident.semantic_id(), Role::Button)
.parent(id.clone())
.text(cx.strings().text(StringKey::Dismiss)),
);
let clicked = {
let layer = layer.clone();
let id = id.clone();
move |_window: &mut Window, cx: &mut App| {
layer
.update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
.ok();
}
};
let keyed = clicked.clone();
control
.interactivity()
.on_click(move |_, window, cx| clicked(window, cx));
control
.interactivity()
.on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
keyed(window, cx);
cx.stop_propagation();
}
});
control
});
let detail = toast.detail.clone().map(|detail| {
div()
.type_scale(theme, TypeScale::Caption)
.text_color(theme.colors.text_muted)
.child(detail.clone())
.semantic_in(
cx,
NodeSpec::new(toast.ident.child("detail").semantic_id(), Role::Text)
.parent(id.clone())
.text(detail),
)
});
let travel = TRAVEL * (1.0 - entry.progress);
let hovered = entry.hovered;
let hover_id = id.clone();
let card = surface(theme, Elevation::Overlay)
.id(toast.ident.element_id())
.row()
.items_start()
.w(px(CARD_WIDTH))
.gap_token(theme, Space::Sm)
.p_token(theme, Space::Md)
.bg(color.opacity(0.12))
.opacity(entry.progress)
.top(px(if self.corner.is_top() {
-travel
} else {
travel
}))
.on_hover(cx.listener(move |layer, hovered: &bool, _window, cx| {
layer.set_hovered(&hover_id, *hovered, cx);
}))
.child(
div()
.flex_none()
.mt(px(5.0))
.child(StatusDot::new(toast.tone)),
)
.child(
div()
.column()
.flex_1()
.min_w_0()
.gap_token(theme, Space::Xs)
.child(
div()
.type_scale(theme, TypeScale::Label)
.child(toast.message.clone()),
)
.children(detail),
)
.children(action)
.children(dismiss)
.semantic_in(
cx,
NodeSpec::new(id, Role::Toast)
.text(toast.message.clone())
.value(toast.tone.name())
.live(if toast.tone == Tone::Danger {
LiveRegion::Assertive
} else {
LiveRegion::Polite
})
.live_atomic(true)
.hovered(hovered),
);
let slot = flip(toast.ident.child("slot").semantic_id(), cx);
div().child(card).flip(&slot, window, cx).into_any_element()
}
}
impl Render for ToastLayer {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
self.tick(window, cx);
if self.entries.is_empty() {
return div().into_any_element();
}
let mut order: Vec<usize> = (0..self.entries.len()).collect();
if self.corner.is_top() {
order.reverse();
}
let mut cards: Vec<AnyElement> = Vec::with_capacity(order.len());
for index in order {
cards.push(self.card(index, &theme, window, cx));
}
let leading = self.corner.is_leading();
let top = self.corner.is_top();
let viewport = window.viewport_size();
let stack = div()
.column()
.gap_token(&theme, Space::Sm)
.when(leading, |element| element.items_start())
.when(!leading, |element| element.items_end())
.children(cards);
let frame = div()
.absolute()
.top_0()
.left_0()
.w(viewport.width)
.h(viewport.height)
.flex()
.flex_col()
.p_token(&theme, Space::Lg)
.when(top, |element| element.justify_start())
.when(!top, |element| element.justify_end())
.when(leading, |element| element.items_start())
.when(!leading, |element| element.items_end())
.child(stack);
pinned(
gpui::deferred(frame)
.priority(priority(&theme, Layer::Toast))
.into_any_element(),
)
}
}
#[derive(Default)]
struct ToastCenter {
layer: Option<WeakEntity<ToastLayer>>,
}
impl Global for ToastCenter {}
pub fn install(cx: &mut App) {
if !cx.has_global::<ToastCenter>() {
cx.set_global(ToastCenter::default());
}
}
fn mounted(cx: &App) -> Option<Entity<ToastLayer>> {
cx.try_global::<ToastCenter>()?.layer.as_ref()?.upgrade()
}
pub fn is_mounted(cx: &App) -> bool {
mounted(cx).is_some()
}
pub fn push(cx: &mut App, toast: Toast) -> bool {
let Some(layer) = mounted(cx) else {
return false;
};
layer.update(cx, |layer, cx| layer.push(toast, cx));
true
}
pub fn dismiss(cx: &mut App, ident: &str) -> bool {
let Some(layer) = mounted(cx) else {
return false;
};
layer.update(cx, |layer, cx| layer.dismiss(ident, cx))
}
pub fn clear(cx: &mut App) {
if let Some(layer) = mounted(cx) {
layer.update(cx, |layer, cx| layer.clear(cx));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::studio_dark()
}
#[test]
fn a_failure_stays_and_everything_else_times_out() {
let theme = theme();
for tone in [Tone::Danger, Tone::Warning] {
assert_eq!(
Toast::new("run.failed", "Refused")
.tone(tone)
.allowance(&theme),
None
);
}
for tone in [Tone::Success, Tone::Info, Tone::Neutral, Tone::Accent] {
assert_eq!(
Toast::new("run.saved", "Saved")
.tone(tone)
.allowance(&theme),
Some(Duration::from_millis(theme.motion.toast_ms))
);
}
}
#[test]
fn an_explicit_lifetime_outranks_the_tone() {
let theme = theme();
assert_eq!(
Toast::new("run.failed", "Refused")
.tone(Tone::Danger)
.timeout(Duration::from_millis(200))
.allowance(&theme),
Some(Duration::from_millis(200))
);
assert_eq!(
Toast::new("run.saved", "Saved")
.tone(Tone::Success)
.persistent()
.allowance(&theme),
None
);
}
#[test]
fn only_a_dismissable_toast_that_times_out_can_be_evicted() {
let theme = theme();
let entry = |toast: Toast| {
let remaining = toast.allowance(&theme);
Entry::new(toast, remaining, &theme)
};
assert!(entry(Toast::new("a", "Saved").tone(Tone::Success)).evictable());
assert!(!entry(Toast::new("b", "Refused").tone(Tone::Danger)).evictable());
assert!(
!entry(
Toast::new("c", "Saved")
.tone(Tone::Success)
.dismissable(false)
)
.evictable()
);
}
#[test]
fn a_hovered_toast_spends_none_of_its_allowance() {
let theme = theme();
let mut entry = Entry::new(
Toast::new("a", "Saved").timeout(Duration::from_millis(300)),
Some(Duration::from_millis(300)),
&theme,
);
entry.hovered = true;
assert!(!entry.count_down(Duration::from_millis(500)));
assert_eq!(entry.remaining, Some(Duration::from_millis(300)));
entry.hovered = false;
assert!(entry.count_down(Duration::from_millis(100)));
assert_eq!(entry.remaining, Some(Duration::from_millis(200)));
}
#[test]
fn a_spent_allowance_starts_the_exit_rather_than_dropping_the_toast() {
let theme = theme();
let mut entry = Entry::new(
Toast::new("a", "Saved"),
Some(Duration::from_millis(100)),
&theme,
);
entry.presence.settle();
assert!(!entry.count_down(Duration::from_millis(100)));
assert_eq!(entry.presence.phase(), Phase::Exiting);
assert!(entry.presence.is_rendered());
}
#[test]
fn the_stack_grows_from_the_corner_it_is_given() {
assert!(!ToastCorner::default().is_top());
assert!(!ToastCorner::default().is_leading());
assert!(ToastCorner::TopLeft.is_top() && ToastCorner::TopLeft.is_leading());
}
}