use std::collections::VecDeque;
use bevy::ecs::lifecycle::HookContext;
use bevy::ecs::world::DeferredWorld;
use bevy::input_focus::InputFocus;
use bevy::input_focus::tab_navigation::TabIndex;
use bevy::picking::hover::Hovered;
use bevy::prelude::*;
use bevy::ui::ComputedNode;
use bevy::ui::auto_directional_navigation::AutoDirectionalNavigation;
use bevy::ui_widgets::Activate;
use crate::model::{toast_alpha, toast_grow, toast_hold, toast_life};
use crate::skin::Role;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Debug, Default, PartialEq, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ToastAnchor {
TopLeft,
TopCenter,
TopRight,
BottomLeft,
#[default]
BottomCenter,
BottomRight,
}
impl ToastAnchor {
pub(crate) fn layout(self) -> (FlexDirection, JustifyContent, AlignItems) {
use ToastAnchor::*;
let direction = match self {
TopLeft | TopCenter | TopRight => FlexDirection::ColumnReverse,
BottomLeft | BottomCenter | BottomRight => FlexDirection::Column,
};
let across = match self {
TopLeft | BottomLeft => AlignItems::FlexStart,
TopCenter | BottomCenter => AlignItems::Center,
TopRight | BottomRight => AlignItems::FlexEnd,
};
(direction, JustifyContent::FlexEnd, across)
}
}
#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
pub struct ToastSettings {
pub anchor: ToastAnchor,
pub max_visible: usize,
pub base_seconds: f32,
pub chars_per_second: f32,
pub duration_scale: f32,
pub fade_seconds: f32,
pub action_seconds: f32,
pub merge_seconds: f32,
}
impl Default for ToastSettings {
fn default() -> Self {
Self {
anchor: ToastAnchor::default(),
max_visible: 3,
base_seconds: 2.0,
chars_per_second: 20.0,
duration_scale: 1.0,
fade_seconds: 0.3,
action_seconds: 6.0,
merge_seconds: 2.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Debug, Default, PartialEq, Clone)]
pub enum ToastKind {
#[default]
Info,
Alert,
}
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Debug, Clone)]
#[component(on_add = queue_join)]
pub struct Toast {
kind: ToastKind,
text: String,
characters: usize,
count: u32,
since_last: f32,
sticky: bool,
actions: Vec<(String, String)>,
dismissed: bool,
age: f32,
hold_cap: f32,
alpha: f32,
grow: f32,
body_px: f32,
}
impl Toast {
pub(crate) fn new(kind: ToastKind, text: &str) -> Self {
Self {
kind,
text: text.to_string(),
characters: text.chars().count(),
count: 1,
since_last: 0.0,
sticky: false,
actions: Vec::new(),
dismissed: false,
age: 0.0,
hold_cap: f32::INFINITY,
alpha: 0.0,
grow: 0.0,
body_px: 0.0,
}
}
pub fn kind(&self) -> ToastKind {
self.kind
}
pub fn text(&self) -> &str {
&self.text
}
pub fn count(&self) -> u32 {
self.count
}
pub fn is_sticky(&self) -> bool {
self.sticky
}
pub fn actions(&self) -> &[(String, String)] {
&self.actions
}
pub fn age(&self) -> f32 {
self.age
}
pub fn alpha(&self) -> f32 {
self.alpha
}
pub fn grow(&self) -> f32 {
self.grow
}
pub fn body_px(&self) -> f32 {
self.body_px
}
}
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
#[reflect(Component, Debug, Default, Clone)]
pub struct ToastStack;
const STACK_Z: i32 = 150;
const STICKY_HOLD_SECS: f32 = 1.0e9;
#[derive(Bundle)]
pub struct ToastBundle {
toast: Toast,
node: Node,
role: Role,
pickable: Pickable,
}
impl ToastBundle {
pub(crate) fn new(kind: ToastKind, text: String) -> Self {
Self {
toast: Toast::new(kind, &text),
node: Node {
flex_direction: FlexDirection::Column,
overflow: Overflow::clip(),
max_height: Val::Px(0.0),
..default()
},
role: Role::Toast,
pickable: Pickable::IGNORE,
}
}
pub fn action(mut self, label: impl Into<String>, id: impl Into<String>) -> Self {
self.toast.actions.push((label.into(), id.into()));
self
}
pub fn sticky(mut self) -> Self {
self.toast.sticky = true;
self
}
}
#[derive(EntityEvent, Clone, Debug, PartialEq, Eq)]
pub struct ToastAction {
#[event_target]
pub toast: Entity,
pub id: String,
}
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct ToastButton {
toast: Entity,
id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Reflect)]
#[reflect(Debug, Clone, PartialEq)]
pub struct ToastLogEntry {
pub seconds: f32,
pub kind: ToastKind,
pub text: String,
}
#[derive(Resource, Clone, Debug, Default, PartialEq, Reflect)]
#[reflect(Resource, Default, Debug, Clone, PartialEq)]
pub struct ToastLog {
entries: VecDeque<ToastLogEntry>,
}
impl ToastLog {
pub const CAPACITY: usize = 200;
pub fn entries(&self) -> impl DoubleEndedIterator<Item = &ToastLogEntry> {
self.entries.iter()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn push(&mut self, entry: ToastLogEntry) {
if self.entries.len() >= Self::CAPACITY {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
}
fn queue_join(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
world.commands().queue(move |world: &mut World| {
let Some(toast) = world.get::<Toast>(entity).cloned() else {
return;
};
let seconds = world
.get_resource::<Time<Real>>()
.map_or(0.0, |time| time.elapsed_secs());
if let Some(mut log) = world.get_resource_mut::<ToastLog>() {
log.push(ToastLogEntry {
seconds,
kind: toast.kind,
text: toast.text.clone(),
});
}
let existing = world
.query_filtered::<Entity, With<ToastStack>>()
.iter(world)
.next();
let window = world
.get_resource::<ToastSettings>()
.map_or(0.0, |settings| settings.merge_seconds);
if let Some(stack) = existing
&& window > 0.0
&& merge_into_standing(world, stack, entity, &toast, window)
{
world.entity_mut(entity).despawn();
return;
}
let stack = existing.unwrap_or_else(|| {
world
.spawn((
Name::new("ToastStack"),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
Role::ToastStack,
ToastStack,
Pickable::IGNORE,
GlobalZIndex(STACK_Z),
))
.id()
});
world.entity_mut(entity).insert(ChildOf(stack));
spawn_body(world, entity, &toast);
});
}
fn merge_into_standing(
world: &mut World,
stack: Entity,
new: Entity,
toast: &Toast,
window: f32,
) -> bool {
let candidates: Vec<Entity> = world
.get::<Children>(stack)
.map(|kids| kids.iter().filter(|&k| k != new).collect())
.unwrap_or_default();
let found = candidates.into_iter().find(|&other| {
world.get::<Toast>(other).is_some_and(|o| {
!o.dismissed
&& o.since_last < window
&& o.kind == toast.kind
&& o.text == toast.text
&& o.sticky == toast.sticky
&& o.actions == toast.actions
})
});
let Some(other) = found else {
return false;
};
let fade = world
.get_resource::<ToastSettings>()
.map_or(0.0, |settings| settings.fade_seconds.max(0.0));
let (text, count) = {
let Some(mut standing) = world.get_mut::<Toast>(other) else {
return false;
};
standing.count += 1;
standing.since_last = 0.0;
standing.age = standing.age.min(fade);
standing.hold_cap = f32::INFINITY;
(standing.text.clone(), standing.count)
};
let mut stack_of_nodes = vec![other];
while let Some(at) = stack_of_nodes.pop() {
if world.get::<Role>(at) == Some(&Role::ToastText) {
if let Some(mut words) = world.get_mut::<Text>(at) {
words.0 = counted(&text, count);
}
break;
}
if let Some(kids) = world.get::<Children>(at) {
stack_of_nodes.extend(kids.iter());
}
}
true
}
fn counted(text: &str, count: u32) -> String {
if count > 1 {
format!("{text} x{count}")
} else {
text.to_string()
}
}
fn spawn_body(world: &mut World, entity: Entity, toast: &Toast) {
let body = world
.spawn((
Node {
flex_direction: FlexDirection::Row,
..default()
},
Role::ToastBody,
BackgroundColor(Color::NONE),
BorderColor::all(Color::NONE),
Pickable::IGNORE,
ChildOf(entity),
))
.id();
world.spawn((
Node::default(),
Role::ToastMark,
BackgroundColor(Color::NONE),
Pickable::IGNORE,
ChildOf(body),
));
world.spawn((
Text::new(counted(&toast.text, toast.count)),
TextColor(Color::NONE),
Role::ToastText,
Pickable::IGNORE,
ChildOf(body),
));
let buttons = toast
.actions
.iter()
.map(|(label, id)| (label.clone(), Some(id.clone())))
.chain(toast.sticky.then(|| ("Dismiss".to_string(), None)));
for (label, id) in buttons {
world
.spawn((
Node {
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
bevy::ui_widgets::Button,
Role::ToastAction,
ToastButton { toast: entity, id },
Hovered::default(),
TabIndex(0),
AutoDirectionalNavigation::default(),
BackgroundColor(Color::NONE),
BorderColor::all(Color::NONE),
ChildOf(body),
))
.with_child((
Text::new(label),
TextColor(Color::NONE),
Role::ToastActionText,
crate::widgets::ONE_LINE,
));
}
}
pub(crate) fn toast_button_removed(
removed: On<Remove<ToastButton>>,
mut focus: ResMut<InputFocus>,
) {
if focus.get() == Some(removed.entity) {
focus.clear();
}
}
pub(crate) fn toast_on_activate(
activate: On<Activate>,
buttons: Query<&ToastButton>,
mut toasts: Query<&mut Toast>,
mut commands: Commands,
) {
let Ok(button) = buttons.get(activate.entity) else {
return;
};
let Ok(mut toast) = toasts.get_mut(button.toast) else {
return;
};
if toast.dismissed {
return;
}
toast.dismissed = true;
if let Some(id) = &button.id {
commands.trigger(ToastAction {
toast: button.toast,
id: id.clone(),
});
}
}
pub fn update_toasts(
time: Res<Time<Real>>,
settings: Res<ToastSettings>,
mut commands: Commands,
mut stacks: Query<(&mut Node, &Children), With<ToastStack>>,
mut toasts: Query<(Entity, &mut Toast)>,
kids: Query<&Children, With<Toast>>,
bodies: Query<&ComputedNode, Without<Toast>>,
) {
let delta = time.delta_secs();
let usable = |x: f32| x.is_finite() && x > 0.0;
let fade = if usable(settings.fade_seconds) {
settings.fade_seconds
} else {
0.0
};
let action_hold = if usable(settings.action_seconds) && usable(settings.duration_scale) {
settings.action_seconds * settings.duration_scale
} else {
0.0
};
let hold_of = |toast: &Toast| {
let reading = toast_hold(
toast.characters,
settings.base_seconds,
settings.chars_per_second,
settings.duration_scale,
);
let hold = if toast.sticky {
STICKY_HOLD_SECS
} else if toast.actions.is_empty() {
reading
} else {
reading.max(action_hold)
};
hold.min(toast.hold_cap)
};
let (direction, justify, across) = settings.anchor.layout();
let max = settings.max_visible.max(1);
for (mut node, children) in &mut stacks {
if node.flex_direction != direction {
node.flex_direction = direction;
}
if node.justify_content != justify {
node.justify_content = justify;
}
if node.align_items != across {
node.align_items = across;
}
let mut standing = 0usize;
let mut live = toasts.iter_many_mut(children.iter());
while let Some(item) = live.fetch_next() {
let Ok((entity, mut toast)) = item else {
continue;
};
{
let quiet = toast.bypass_change_detection();
quiet.age += delta;
quiet.since_last += delta;
if quiet.dismissed && quiet.hold_cap.is_infinite() {
quiet.hold_cap = (quiet.age - fade).max(0.0);
}
}
let hold = hold_of(&toast);
if toast.age >= toast_life(hold, fade) {
commands.entity(entity).despawn();
} else if toast.age < fade + hold && !toast.sticky {
standing += 1;
}
}
let mut excess = standing.saturating_sub(max);
let mut live = toasts.iter_many_mut(children.iter());
while let Some(item) = live.fetch_next() {
if excess == 0 {
break;
}
let Ok((_, mut toast)) = item else {
continue;
};
if !toast.sticky && toast.age < fade + hold_of(&toast) {
let cut = (toast.age - fade).max(0.0);
toast.bypass_change_detection().hold_cap = cut;
excess -= 1;
}
}
let mut live = toasts.iter_many_mut(children.iter());
while let Some(item) = live.fetch_next() {
let Ok((entity, mut toast)) = item else {
continue;
};
let hold = hold_of(&toast);
let alpha = toast_alpha(toast.age, hold, fade);
let grow = toast_grow(toast.age, hold, fade);
let body_px = if grow < 1.0 {
kids.get(entity)
.ok()
.and_then(|kids| kids.first())
.and_then(|&body| bodies.get(body).ok())
.map_or(toast.body_px, |body| {
body.size().y * body.inverse_scale_factor()
})
} else {
toast.body_px
};
if toast.alpha != alpha || toast.grow != grow || toast.body_px != body_px {
toast.alpha = alpha;
toast.grow = grow;
toast.body_px = body_px;
}
}
}
}