diff --git a/AGENTS.md b/AGENTS.md
index 16ee15f..3c6a1a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,9 +38,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
- The focus ring is a 1px focus-colored border (`FocusRing`). gpui paints shadows under the element, so a shadow ring floods transparent elements. Focusable elements keep a 1px border, transparent at rest.
-- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` and focus its handle.
+- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` with `.root()` and focus its handle. A root scope takes focus when the focused element leaves the tree, as a toast's or an alert's button does.
- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
- `hover()` needs an element id.
+- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
- gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags.
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
@@ -71,6 +72,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`).
- gpui's `Hsla::opacity` scales alpha; `alpha` sets it.
- A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one.
+- A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`.
- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them.
- gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`.
- `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`.
diff --git a/TASKS.md b/TASKS.md
index 579836f..eaf6840 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -36,7 +36,8 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T08b Menus · Bar, search & pie
- [x] T09a Overlays · Popovers & dialogs
- [x] T09b Overlays · Guides & floats
-- [ ] T10 Feedback
+- [x] T10a Feedback · Messages
+- [ ] T10b Feedback · States
- [ ] T11a Loading — `tasks/ch11-20.md`
- [ ] T11b Motion
- [ ] T12 Data Display
@@ -159,3 +160,6 @@ Per-component lines live in `tasks/`. Tags there:
| T09b | 1 | FAIL | the lightbox photo was cropped, the toolbar kept last frame's anchor, Escape missed a spotlight whose target held focus, a disabled Next dropped focus, and a dead arrow's press closed the lightbox |
| T09b | 2 | FAIL | Enter on a focused Back that returned the tour to its first step removed the button and lost focus, so the arrows stopped |
| T09b | 3 | FAIL | Left from a focused Back also removed it and lost focus; take_focus now takes focus back whenever the focused element leaves the tree (fixed after the cap) |
+| T10a | 1 | FAIL | a second press on a leaving toast's Undo ran it again, EmptyState's prose width was a literal, and two gallery callouts described behavior the code lacks |
+| T10a | 2 | FAIL | a gallery callout said reduced motion settles every animation, but the undo line keeps its real time |
+| T10a | 3 | PASS | |
diff --git a/assets/icons/search-x.svg b/assets/icons/search-x.svg
new file mode 100644
index 0000000..2702803
--- /dev/null
+++ b/assets/icons/search-x.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/examples/gallery/pages/feedback/messages.rs b/examples/gallery/pages/feedback/messages.rs
new file mode 100644
index 0000000..6c0afe0
--- /dev/null
+++ b/examples/gallery/pages/feedback/messages.rs
@@ -0,0 +1,405 @@
+use ely_gpui_component::{
+ buttons::{Button, ButtonVariant},
+ feedback::{
+ Alert, Banner, Callout, EmptyState, InlineMessage, Notification, NotificationCenter,
+ StatusMessage, Toast, ToastViewport, Toaster,
+ },
+ layout::Collapsible,
+ primitives::{IconName, Severity},
+ theme::{ActiveTheme, Radius},
+ typography::Caption,
+};
+use gpui::{App, IntoElement, ParentElement, Styled, Window, div, prelude::FluentBuilder};
+
+use crate::{
+ probe::probe,
+ ui::{keep, row, section, set, specimen, specimens},
+};
+
+pub fn toasts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let toaster = keep("toaster", Toaster::default, window, cx);
+ let fire = |key: &'static str, label: &'static str, make: fn() -> Toast| {
+ let toaster = toaster.clone();
+ probe(
+ key,
+ Button::new(key, label).on_click(move |_, _, cx| {
+ toaster.update(cx, |toaster, cx| {
+ toaster.push(make(), cx);
+ })
+ }),
+ )
+ };
+ section(
+ "Toast / Snackbar / UndoToast",
+ "Short messages stack in the corner, the newest nearest it. Each rises in as its room opens and folds away under the rest. The pointer on the stack holds their time.",
+ cx,
+ )
+ .child(
+ row()
+ .child(fire("toast-saved", "Success", || {
+ Toast::new("Changes saved").severity(Severity::Success)
+ }))
+ .child(fire("toast-error", "Error", || {
+ Toast::new("Couldn't sync")
+ .body("Check your connection, then try again.")
+ .severity(Severity::Danger)
+ .action("Retry", |_, _| log::info!("gallery: retry"))
+ }))
+ .child(fire("toast-warning", "Warning", || {
+ Toast::new("Storage almost full")
+ .body("46 of 50 GB used.")
+ .severity(Severity::Warning)
+ }))
+ .child(fire("toast-info", "Info", || {
+ Toast::new("A new version is ready")
+ .severity(Severity::Info)
+ .action("Reload", |_, _| log::info!("gallery: reload"))
+ }))
+ .child(fire("toast-undo", "Undo", || {
+ Toast::new("3 files moved to Trash").undo(|_, _| log::info!("gallery: undo"))
+ }))
+ .child(fire("toast-plain", "Plain", || Toast::new("Link copied"))),
+ )
+ .child(ToastViewport::new("toast-viewport", &toaster))
+}
+
+/// Which demo notifications are left, and which are still unread.
+#[derive(Clone)]
+struct Inbox {
+ unread: [bool; 5],
+ cleared: bool,
+}
+
+impl Inbox {
+ fn fresh() -> Self {
+ Self {
+ unread: [true, true, false, false, false],
+ cleared: false,
+ }
+ }
+}
+
+pub fn center(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let inbox = keep("inbox", Inbox::fresh, window, cx);
+ let now = inbox.read(cx).clone();
+ let (read, clear, reset) = (inbox.clone(), inbox.clone(), inbox.clone());
+ let theme = cx.theme();
+ let item =
+ |ix: usize, icon: IconName, title: &'static str, body: &'static str, time: &'static str| {
+ Notification::new(("note", ix), icon, title)
+ .body(body)
+ .time(time)
+ .unread(now.unread[ix])
+ };
+ let (today, earlier) = if now.cleared {
+ (Vec::new(), Vec::new())
+ } else {
+ (
+ vec![
+ item(
+ 0,
+ IconName::MessageSquare,
+ "Mia Chen replied",
+ "“Looks right. Ship it after the copy pass.”",
+ "2m",
+ ),
+ item(
+ 1,
+ IconName::GitPullRequest,
+ "Review requested",
+ "Hana asked you to review #482, toast motion.",
+ "18m",
+ )
+ .action(Button::new("note-review", "Review").primary())
+ .action(Button::new("note-later", "Later").variant(ButtonVariant::Ghost)),
+ item(
+ 2,
+ IconName::CalendarDays,
+ "Design review moved",
+ "Now Thursday, 14:00 CET.",
+ "1h",
+ ),
+ ],
+ vec![
+ item(
+ 3,
+ IconName::Download,
+ "Export ready",
+ "quarterly-report.pdf · 2.4 MB",
+ "Yesterday",
+ ),
+ item(
+ 4,
+ IconName::Users,
+ "Léa Martin joined",
+ "Say hello in #studio.",
+ "Mon",
+ ),
+ ],
+ )
+ };
+ section(
+ "Notification / NotificationCenter",
+ "Messages under day headings. Unread ones carry a dot; Mark all read and Clear show only while they have work.",
+ cx,
+ )
+ .child(
+ div()
+ .flex()
+ .items_end()
+ .gap_4()
+ .child(probe(
+ "center",
+ div()
+ .w_96()
+ .h_112()
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(theme.colors.border)
+ .bg(theme.colors.surface)
+ .child(
+ NotificationCenter::new("center")
+ .group("Today", today)
+ .group("Earlier", earlier)
+ .on_read_all(move |_, cx| {
+ let cleared = read.read(cx).cleared;
+ set(&read, Inbox { unread: [false; 5], cleared }, cx)
+ })
+ .on_clear(move |_, cx| {
+ set(&clear, Inbox { unread: [false; 5], cleared: true }, cx)
+ }),
+ ),
+ ))
+ .child(probe(
+ "center-reset",
+ Button::new("center-reset", "Reset")
+ .variant(ButtonVariant::Ghost)
+ .on_click(move |_, _, cx| set(&reset, Inbox::fresh(), cx)),
+ )),
+ )
+}
+
+pub fn alerts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let shown = keep("alert-shown", || true, window, cx);
+ let (hide, show) = (shown.clone(), shown.clone());
+ section(
+ "Alert",
+ "A message in the flow of the page, tinted by how much it matters.",
+ cx,
+ )
+ .child(
+ div()
+ .w_128()
+ .flex()
+ .flex_col()
+ .gap_3()
+ .child(
+ Alert::new("alert-info", Severity::Info, "Scheduled maintenance")
+ .body("Sync pauses Sunday, 02:00 to 03:00 UTC."),
+ )
+ .child(
+ Alert::new("alert-success", Severity::Success, "Domain verified")
+ .body("ely.design now points to this workspace."),
+ )
+ .child(
+ Alert::new(
+ "alert-warning",
+ Severity::Warning,
+ "Your trial ends in 3 days",
+ )
+ .body("Add a payment method to keep your projects.")
+ .action(Button::new("alert-pay", "Add payment method")),
+ )
+ .child(if *shown.read(cx) {
+ Alert::new("alert-danger", Severity::Danger, "Couldn't publish")
+ .body("Two pages link to files that no longer exist.")
+ .action(Button::new("alert-show", "Show pages"))
+ .on_dismiss(move |_, cx| set(&hide, false, cx))
+ .into_any_element()
+ } else {
+ Button::new("alert-again", "Show the closed alert")
+ .variant(ButtonVariant::Link)
+ .on_click(move |_, _, cx| set(&show, true, cx))
+ .into_any_element()
+ }),
+ )
+}
+
+pub fn banner(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let open = keep("banner-open", || true, window, cx);
+ let shown = *open.read(cx);
+ let (close, again) = (open.clone(), open.clone());
+ let theme = cx.theme();
+ section(
+ "Banner",
+ "A strip across the top of a window. Closing it folds it away.",
+ cx,
+ )
+ .child(
+ div()
+ .w_128()
+ .h_40()
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(theme.colors.border)
+ .bg(theme.colors.bg)
+ .child(
+ Collapsible::new("banner-fold", shown).child(
+ Banner::new(
+ "banner",
+ Severity::Warning,
+ "You're offline. Edits sync when you reconnect.",
+ )
+ .action("Retry", |_, _| log::info!("gallery: retry"))
+ .on_dismiss(move |_, cx| set(&close, false, cx)),
+ ),
+ )
+ .child(
+ div()
+ .p_4()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .child(Caption::new("The window's content sits under it."))
+ .when(!shown, |content| {
+ content.child(
+ Button::new("banner-again", "Show the banner")
+ .variant(ButtonVariant::Link)
+ .on_click(move |_, _, cx| set(&again, true, cx)),
+ )
+ }),
+ ),
+ )
+}
+
+pub fn callouts(cx: &mut App) -> impl IntoElement + use<> {
+ let theme = cx.theme();
+ section(
+ "Callout / Admonition",
+ "A note set apart in reading: note, tip, warning and danger.",
+ cx,
+ )
+ .child(
+ div()
+ .w_128()
+ .flex()
+ .flex_col()
+ .gap_4()
+ .text_color(theme.colors.fg)
+ .child(
+ Callout::new(Severity::Info)
+ .child("Themes cross-fade in 280 ms. The theme's reduced motion settles entrances, folds and fades at once; an undo line still counts real time."),
+ )
+ .child(
+ Callout::new(Severity::Success)
+ .child("Tab walks every control on a page; Shift-Tab walks back."),
+ )
+ .child(
+ Callout::new(Severity::Warning)
+ .child("Layouts saved by a newer version are refused, not guessed."),
+ )
+ .child(
+ Callout::new(Severity::Danger)
+ .title("Irreversible")
+ .child("Deleting a workspace removes its files for everyone."),
+ ),
+ )
+}
+
+pub fn lines(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ const STEPS: [&str; 3] = ["Ready to upload", "Uploading 3 files…", "Uploaded 3 files"];
+ let at = keep("status-at", || 0usize, window, cx);
+ let now = *at.read(cx);
+ let step = at.clone();
+ section(
+ "InlineMessage / StatusMessage",
+ "One line beside what it is about. A status line rises in each time it changes.",
+ cx,
+ )
+ .child(
+ specimens()
+ .child(specimen(
+ "success",
+ InlineMessage::new(Severity::Success, "Saved"),
+ cx,
+ ))
+ .child(specimen(
+ "danger",
+ InlineMessage::new(Severity::Danger, "That name is taken"),
+ cx,
+ ))
+ .child(specimen(
+ "warning",
+ InlineMessage::new(Severity::Warning, "Unsaved changes"),
+ cx,
+ ))
+ .child(specimen(
+ "info",
+ InlineMessage::new(Severity::Info, "Updated 2 minutes ago"),
+ cx,
+ )),
+ )
+ .child(
+ row()
+ .child(probe(
+ "status",
+ Button::new("status-step", "Next status")
+ .on_click(move |_, _, cx| set(&step, (now + 1) % STEPS.len(), cx)),
+ ))
+ .child(StatusMessage::new("status", STEPS[now]).icon(IconName::Upload)),
+ )
+}
+
+pub fn empties(cx: &mut App) -> impl IntoElement + use<> {
+ let theme = cx.theme();
+ let frame = || {
+ div()
+ .flex_1()
+ .min_w_0()
+ .h_80()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(theme.colors.border)
+ };
+ section(
+ "EmptyState",
+ "What a view shows with nothing in it: no data yet, no results, or a first start.",
+ cx,
+ )
+ .child(
+ div()
+ .flex()
+ .gap_3()
+ .child(
+ frame().child(
+ EmptyState::new("empty-inbox", IconName::Inbox, "No messages yet")
+ .body("When someone writes to you, it lands here."),
+ ),
+ )
+ .child(
+ frame().child(
+ EmptyState::new(
+ "empty-search",
+ IconName::SearchX,
+ "No results for “quarterly”",
+ )
+ .body("Try fewer words, or check the spelling.")
+ .action(Button::new("empty-clear", "Clear search")),
+ ),
+ )
+ .child(
+ frame().child(
+ EmptyState::new(
+ "empty-first",
+ IconName::Sparkles,
+ "Start your first project",
+ )
+ .body("Projects keep files, people and tasks together.")
+ .action(Button::new("empty-new", "New project").primary()),
+ ),
+ ),
+ )
+}
diff --git a/examples/gallery/pages/feedback/mod.rs b/examples/gallery/pages/feedback/mod.rs
new file mode 100644
index 0000000..97c815c
--- /dev/null
+++ b/examples/gallery/pages/feedback/mod.rs
@@ -0,0 +1,52 @@
+mod messages;
+
+use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
+
+use super::Page;
+use crate::script::Step;
+
+pub const PAGE: Page = Page {
+ number: 10,
+ slug: "feedback",
+ title: "Feedback",
+ summary: "What the app tells you: toasts, notifications, alerts and the states a view can be in.",
+ render,
+ script: SCRIPT,
+};
+
+const SCRIPT: &[Step] = &[
+ Step::Rest,
+ Step::Click("center-reset"),
+ Step::Click("toast-saved"),
+ Step::Wait(200),
+ Step::Click("toast-error"),
+ Step::Wait(200),
+ Step::Click("toast-undo"),
+ Step::Wait(800),
+ Step::Shot("toasts"),
+ Step::Wait(5600),
+ Step::DownAt("center", 265.0, 20.0),
+ Step::UpAt("center", 265.0, 20.0),
+ Step::Wait(300),
+ Step::Shot("center-read"),
+ Step::DownAt("center", 350.0, 20.0),
+ Step::UpAt("center", 350.0, 20.0),
+ Step::Wait(700),
+ Step::Shot("center-clear"),
+ Step::Click("status"),
+ Step::Wait(80),
+ Step::Shot("status-rising"),
+ Step::Rest,
+];
+
+fn render(window: &mut Window, cx: &mut App) -> AnyElement {
+ div()
+ .child(messages::toasts(window, cx))
+ .child(messages::center(window, cx))
+ .child(messages::alerts(window, cx))
+ .child(messages::banner(window, cx))
+ .child(messages::callouts(cx))
+ .child(messages::lines(window, cx))
+ .child(messages::empties(cx))
+ .into_any_element()
+}
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index e7b92f0..ec60e33 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -1,4 +1,5 @@
mod buttons;
+mod feedback;
mod forms;
mod layout;
mod menus;
@@ -33,6 +34,7 @@ pub const ALL: &[Page] = &[
navigation::PAGE,
menus::PAGE,
overlays::PAGE,
+ feedback::PAGE,
theme::PAGE,
];
diff --git a/examples/gallery/shell.rs b/examples/gallery/shell.rs
index 3baef63..f30334f 100644
--- a/examples/gallery/shell.rs
+++ b/examples/gallery/shell.rs
@@ -199,6 +199,7 @@ impl Render for Gallery {
let colors = &theme.colors;
FocusScope::new(&self.focus)
+ .root()
.size_full()
.flex()
.bg(colors.bg)
diff --git a/src/feedback/empty.rs b/src/feedback/empty.rs
new file mode 100644
index 0000000..191690f
--- /dev/null
+++ b/src/feedback/empty.rs
@@ -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,
+ actions: SmallVec<[AnyElement; 2]>,
+}
+
+impl EmptyState {
+ pub fn new(id: impl Into, icon: IconName, title: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ icon,
+ title: title.into(),
+ body: None,
+ actions: SmallVec::new(),
+ }
+ }
+
+ pub fn body(mut self, text: impl Into) -> 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)),
+ )
+ }
+}
diff --git a/src/feedback/messages.rs b/src/feedback/messages.rs
new file mode 100644
index 0000000..3437935
--- /dev/null
+++ b/src/feedback/messages.rs
@@ -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,
+ actions: SmallVec<[AnyElement; 2]>,
+ on_dismiss: Option,
+}
+
+impl Alert {
+ pub fn new(
+ id: impl Into,
+ severity: Severity,
+ title: impl Into,
+ ) -> 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) -> 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,
+}
+
+impl Banner {
+ pub fn new(
+ id: impl Into,
+ severity: Severity,
+ message: impl Into,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ severity,
+ message: message.into(),
+ action: None,
+ on_dismiss: None,
+ }
+ }
+
+ pub fn action(
+ mut self,
+ label: impl Into,
+ 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) -> Self {
+ self.title = title.into();
+ self
+ }
+}
+
+impl ParentElement for Callout {
+ fn extend(&mut self, elements: impl IntoIterator- ) {
+ 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) -> 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,
+}
+
+impl StatusMessage {
+ pub fn new(id: impl Into, text: impl Into) -> 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))
+ }
+ },
+ )
+ }
+}
diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs
new file mode 100644
index 0000000..09d4c0d
--- /dev/null
+++ b/src/feedback/mod.rs
@@ -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};
diff --git a/src/feedback/notification.rs b/src/feedback/notification.rs
new file mode 100644
index 0000000..d96b72c
--- /dev/null
+++ b/src/feedback/notification.rs
@@ -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,
+ time: Option,
+ unread: bool,
+ actions: SmallVec<[AnyElement; 2]>,
+}
+
+impl Notification {
+ pub fn new(id: impl Into, icon: IconName, title: impl Into) -> 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) -> Self {
+ self.body = Some(text.into());
+ self
+ }
+
+ /// When it happened, as the owner words it.
+ pub fn time(mut self, time: impl Into) -> 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)>,
+ on_read_all: Option,
+ on_clear: Option,
+}
+
+impl NotificationCenter {
+ pub fn new(id: impl Into) -> 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,
+ items: impl IntoIterator
- ,
+ ) -> Self {
+ let items: Vec = 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)
+ }
+}
diff --git a/src/feedback/tests.rs b/src/feedback/tests.rs
new file mode 100644
index 0000000..b6744f0
--- /dev/null
+++ b/src/feedback/tests.rs
@@ -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,
+ unread: bool,
+ log: Vec,
+}
+
+impl Desk {
+ fn note(
+ &self,
+ cx: &mut Context,
+ 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) -> 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, &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, 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, 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, cx: &mut VisualTestContext) -> Vec {
+ 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 = (0..5).map(|_| push(&view, Toast::new("Hi"), cx)).collect();
+ let leaving: Vec = 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"]);
+}
diff --git a/src/feedback/toast.rs b/src/feedback/toast.rs
new file mode 100644
index 0000000..dd5650b
--- /dev/null
+++ b/src/feedback/toast.rs
@@ -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,
+ severity: Option,
+ action: Option<(SharedString, Run)>,
+ undo: Option,
+ stay: Option,
+}
+
+impl Toast {
+ pub fn new(title: impl Into) -> Self {
+ Self {
+ title: title.into(),
+ body: None,
+ severity: None,
+ action: None,
+ undo: None,
+ stay: Some(STAY),
+ }
+ }
+
+ pub fn body(mut self, text: impl Into) -> 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,
+ 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) -> 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,
+ runs: usize,
+ pub(super) leaving: bool,
+ _timer: Option>,
+}
+
+/// The toasts a `ToastViewport` shows. Keep one per window and push to it.
+#[derive(Default)]
+pub struct Toaster {
+ pub(super) live: Vec,
+ next: u64,
+ held: bool,
+}
+
+impl Toaster {
+ /// Shows `toast`; the id sends it away early.
+ pub fn push(&mut self, toast: Toast, cx: &mut Context) -> 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 = 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) {
+ 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
- {
+ 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) {
+ 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) {
+ 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,
+}
+
+impl ToastViewport {
+ pub fn new(id: impl Into, toaster: &Entity) -> 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,
+ 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| {
+ 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> {
+ 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),
+ )
+}
diff --git a/src/lib.rs b/src/lib.rs
index 0199962..1f3d734 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
+pub mod feedback;
pub mod forms;
pub mod layout;
pub mod menus;
diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs
index cc9bf59..e35ab64 100644
--- a/src/primitives/focus.rs
+++ b/src/primitives/focus.rs
@@ -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
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index d8856ad..b09d2e6 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -205,6 +205,7 @@ icons! {
Save => "save",
Scissors => "scissors",
Search => "search",
+ SearchX => "search-x",
Send => "send",
Server => "server",
Settings => "settings",
diff --git a/src/shell/windows.rs b/src/shell/windows.rs
index 2929939..ac48c0f 100644
--- a/src/shell/windows.rs
+++ b/src/shell/windows.rs
@@ -47,6 +47,7 @@ impl Render for Hosted {
})
.child(
FocusScope::new(&self.focus)
+ .root()
.size_full()
.flex()
.flex_col()
diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs
index 0bacf0e..8579656 100644
--- a/src/theme/tokens.rs
+++ b/src/theme/tokens.rs
@@ -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)
diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md
index b18a5de..00c33a6 100644
--- a/tasks/ch01-10.md
+++ b/tasks/ch01-10.md
@@ -349,25 +349,28 @@
- [x] InlinePopup → `overlays::Popover` with the Link variant
- [x] ToastViewport → lands with Feedback (T10): it holds Toasts
-## T10 Feedback — `src/feedback`
+## T10a Feedback · Messages — `src/feedback`
+
+- [x] Toast — success, error, warning, info, action, undo; also T09b ToastViewport → `feedback::Toast`, `feedback::Toaster`, `feedback::ToastViewport`
+- [x] Notification → `feedback::Notification`
+- [x] NotificationCenter → `feedback::NotificationCenter`; also T10b EmptyState, which it shows when empty
+- [x] Alert → `feedback::Alert`
+- [x] Banner → `feedback::Banner`
+- [x] Callout / Admonition → `feedback::Callout`
+- [x] InlineMessage → `feedback::InlineMessage`
+- [x] StatusMessage → `feedback::StatusMessage`
+- [x] Snackbar → `feedback::Toast`
+- [x] UndoToast → `feedback::Toast`
+
+## T10b Feedback · States
-- [ ] Toast — success, error, warning, info, action, undo; also T09b ToastViewport
-- [ ] Notification
-- [ ] NotificationCenter
-- [ ] Alert
-- [ ] Banner
-- [ ] Callout / Admonition
-- [ ] InlineMessage
-- [ ] StatusMessage
-- [ ] Snackbar → `feedback::Toast`
-- [ ] UndoToast → `feedback::Toast`
- [ ] ErrorView
- [ ] ErrorBoundary — renders and logs a `Result` error; never catches panics
- [ ] NotFound / 404 → `feedback::ErrorView`
- [ ] NoPermission / 403 → `feedback::ErrorView`
- [ ] Maintenance → `feedback::ErrorView`
- [ ] Offline → `feedback::ErrorView`
-- [ ] EmptyState
+- [x] EmptyState → `feedback::EmptyState`, built with T10a
- [ ] Result
- [ ] ConfirmationCard
- [ ] SavingIndicator