Toast
Transient notifications stacked in a corner. Toast is one message, ToasterState is the stack your app keeps, and ToasterWidget paints it.
use ratcn::{Toast, ToasterState, ToasterWidget};
// In update(), when something happens:
state.toasts.push(Toast::success("Saved"), now);
// In draw(), after everything else, so toasts sit on top:
frame.render_widget(
ToasterWidget::new(&state.toasts, now).themed(&theme),
frame.area(),
);Toasts take no focus and handle no events. They are paint-only, so they work in a plain Ratatui app with no Ratcn runtime.
Kinds
The kind sets the accent color and icon. Each has a shorthand constructor, or pass one to .with_kind(...).
| Kind | Shorthand | Use for |
|---|---|---|
Default | Toast::new | Neutral news. |
Success | Toast::success | Something worked. |
Error | Toast::error | Something failed. Consider .persistent(). |
Warning | Toast::warning | Needs attention, but did not fail. |
Info | Toast::info | Neutral information, accented to stand out. |
Loading | Toast::loading | Work in progress. Usually persistent. |
Toast::error("Upload failed")
.with_description("Check your connection and try again.")
.persistent().with_description(...) adds a second line under the title, and .border(false) drops the border on one toast.
Your app owns the clock
Nothing in the library calls Instant::now. Every method that cares about time takes a Duration from you, which is what lets toasts work in the browser and be tested without sleeping.
The loop is three steps:
// 1. Push with the current reading from your clock.
state.toasts.push(Toast::success("Saved"), now);
// 2. Ask when to wake up next, and wait that long.
match state.toasts.time_until_next_expiry(now) {
Some(timeout) => poll_for_input(timeout)?,
None => wait_for_input()?, // nothing expires; block until input
}
// 3. When the timer fires, drop what expired and redraw if anything changed.
if state.toasts.prune_expired(now) {
redraw();
}Without pruning, expired entries remain in ToasterState, but ToasterWidget hides them while painting. In a browser loop that redraws continuously, pruning once per frame is enough.
Toasts expire after 4 seconds by default. .duration(...) changes that and .persistent() disables it. To clear the whole stack, persistent toasts included, assign a fresh ToasterState::new().
Addressing a toast by id
Give a toast an id and it can be dismissed or replaced later. That is how a persistent "working…" toast ends when the work finishes:
// When the upload starts:
state.toasts.push(
Toast::loading("Uploading…").persistent().with_id("upload"),
now,
);
// When it finishes, swap in place, restarting the 4-second lifetime from now:
state.toasts.replace("upload", Toast::success("Uploaded").with_id("upload"), now);
// Or just remove it:
state.toasts.dismiss("upload");Both return false when no toast carries the id, so a redraw can be skipped. Ids are not deduplicated: with several matches, the oldest entry is the one affected, expired or not, so prune first if that matters.
To let Escape dismiss the most recently pushed toast, identified or anonymous:
if key.code == KeyCode::Esc {
let _ = state.toasts.pop_newest();
}Placement
.position(...) picks the corner or edge; the stack grows away from it, so the newest toast is always nearest. .toast_width(...), .gap(...), and .inset(x, y) size and inset the stack, and .max_visible_toasts(n) caps how many show at once. Older toasts stay in the state and still expire on schedule.
use ratcn::ToastPosition;
ToasterWidget::new(&state.toasts, now)
.themed(&theme)
.position(ToastPosition::TopRight)
.max_visible_toasts(5)Positions are TopLeft, TopCenter, TopRight, BottomLeft, BottomCenter, and BottomRight. ToastPosition::is_top() reports which way a stack grows, if your own layout needs to know.
Sizing
Toasts have no fixed height. The title and description wrap at the stack width and the widget measures the result. If the area cannot hold every visible toast, the newest that fit whole are painted and the rest wait for the next frame. A toast is never clipped mid-content and toasts never overlap.
Styling
.themed(&theme) derives every color. .style(ToasterStyle) takes explicit ones: one background and border shared by all toasts, plus an accent per kind.
use ratcn::ToasterStyle;
let mut style = ToasterStyle::from_theme(&theme);
style.error = theme.accent;
ToasterWidget::new(&state.toasts, now).style(style)Every toast paints a border by default. .border(false) on a toast drops it for that toast, and ToasterStyle::border_style picks the line-drawing glyphs for all of them.
Custom stacks
ToasterState covers the common lifecycle. An app that needs a different one can keep its own collection of ToastEntry values, each pairing a toast with its creation time, and paint it with ToasterWidget::from_entries(...):
use ratcn::ToastEntry;
let entries = vec![ToastEntry::new(Toast::success("Saved"), now)];
frame.render_widget(
ToasterWidget::from_entries(&entries, now).themed(&theme),
frame.area(),
);To write your own renderer instead, read the stack through ToasterState::entries(). Toast and ToastEntry expose read accessors for everything the builders set; see their API pages below.
Full API
See Toast, ToastKind, ToasterState, ToastEntry, ToasterWidget, ToastPosition, and ToasterStyle on docs.rs for the full API.
See also
Loop and backend wiring, including the browser case: Host integration.