use alloc::string::String;
use alloc::vec::Vec;
use denise::{Color, Point, Radius, Rect, Role, Size, Theme};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
const FADE_IN_MS: u64 = 120;
const FADE_OUT_MS: u64 = 280;
pub(crate) const HOLD_MS: u64 = 4_000;
const MARGIN: i32 = 12;
const PADDING: i32 = 10;
const MAX_WIDTH: i32 = 420;
const MAX_VISIBLE: usize = 3;
#[derive(Clone, Debug)]
struct Note {
text: String,
role: Role,
born_ms: u64,
hold_ms: u64,
}
impl Note {
#[inline]
const fn life_ms(&self) -> u64 {
FADE_IN_MS + self.hold_ms + FADE_OUT_MS
}
fn alpha(&self, now_ms: u64) -> Option<u8> {
let age = now_ms.saturating_sub(self.born_ms);
if age >= self.life_ms() {
return None;
}
if age < FADE_IN_MS {
return Some((age * 255 / FADE_IN_MS.max(1)) as u8);
}
let fading_at = FADE_IN_MS + self.hold_ms;
if age < fading_at {
return Some(255);
}
let out = age - fading_at;
Some((255 - (out * 255 / FADE_OUT_MS.max(1)).min(255)) as u8)
}
fn next_wake(&self, now_ms: u64, frame_ms: u64) -> u64 {
let age = now_ms.saturating_sub(self.born_ms);
let fading_at = FADE_IN_MS + self.hold_ms;
if age < FADE_IN_MS {
now_ms + frame_ms
} else if age < fading_at {
self.born_ms + fading_at
} else {
now_ms + frame_ms
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Toasts {
notes: Vec<Note>,
last_painted: Option<Rect>,
style: TextStyle,
frame_ms: u64,
}
impl Toasts {
pub(crate) fn new() -> Self {
Self {
notes: Vec::new(),
last_painted: None,
style: TextStyle::built_in(16),
frame_ms: 33,
}
}
pub(crate) fn push(&mut self, text: String, role: Role, hold_ms: u64, now_ms: u64) {
if self.notes.len() >= MAX_VISIBLE {
self.notes.remove(0);
}
self.notes.push(Note {
text,
role,
born_ms: now_ms,
hold_ms,
});
}
#[inline]
pub(crate) fn len(&self) -> usize {
self.notes.len()
}
pub(crate) fn clear(&mut self) {
self.notes.clear();
}
pub(crate) fn retire(&mut self, now_ms: u64) -> bool {
let before = self.notes.len();
self.notes.retain(|note| note.alpha(now_ms).is_some());
self.notes.len() != before
}
pub(crate) fn is_changing(&self, now_ms: u64) -> bool {
self.notes.iter().any(|note| {
let age = now_ms.saturating_sub(note.born_ms);
age < FADE_IN_MS || age >= FADE_IN_MS + note.hold_ms
})
}
pub(crate) fn next_wake(&self, now_ms: u64) -> Option<u64> {
self.notes
.iter()
.map(|note| note.next_wake(now_ms, self.frame_ms))
.min()
}
pub(crate) fn dismiss_at(
&mut self,
point: Point,
surface: Size,
engine: &mut TextEngine,
now_ms: u64,
) -> bool {
let hit = self
.placed(surface, engine, now_ms)
.into_iter()
.find(|(_, rect, _)| rect.contains(point))
.map(|(index, _, _)| index);
match hit {
Some(index) => {
self.notes.remove(index);
true
}
None => false,
}
}
fn placed(
&self,
surface: Size,
engine: &mut TextEngine,
now_ms: u64,
) -> Vec<(usize, Rect, u8)> {
let mut out = Vec::new();
let mut bottom = surface.height as i32 - MARGIN;
for (index, note) in self.notes.iter().enumerate().rev() {
let Some(alpha) = note.alpha(now_ms) else {
continue;
};
let size = measure(note, self.style, surface, engine);
let rect = Rect::new(
(surface.width as i32 - size.width as i32) / 2,
bottom - size.height as i32,
size.width as i32,
size.height as i32,
);
bottom = rect.y - MARGIN;
out.push((index, rect, alpha));
}
out
}
pub(crate) fn bounds(
&self,
surface: Size,
engine: &mut TextEngine,
now_ms: u64,
) -> Option<Rect> {
let next = self
.placed(surface, engine, now_ms)
.into_iter()
.map(|(_, rect, _)| rect)
.reduce(|a, b| a.union(&b));
match (self.last_painted, next) {
(Some(a), Some(b)) => Some(a.union(&b)),
(a, b) => a.or(b),
}
}
pub(crate) fn paint(
&mut self,
theme: &Theme,
surface: Size,
engine: &mut TextEngine,
now_ms: u64,
canvas: &mut Canvas<'_>,
) {
let placed = self.placed(surface, engine, now_ms);
self.last_painted = placed
.iter()
.map(|&(_, rect, _)| rect)
.reduce(|a, b| a.union(&b));
for &(index, rect, alpha) in &placed {
let note = &self.notes[index];
let (fill, content) = theme.pair(note.role);
canvas.fill_rounded_rect(rect, theme.radius(Radius::Box), fade(fill, alpha));
let available = rect.width - PADDING * 2;
let line_height = engine.line_height(self.style);
let lines: Vec<&str> = engine.wrap(self.style, ¬e.text, available.max(1));
for (index, line) in lines.iter().enumerate() {
let y = rect.y + PADDING + index as i32 * line_height;
if y >= rect.bottom() {
break;
}
engine.draw(
canvas,
self.style,
Point::new(rect.x + PADDING, y),
line,
fade(content, alpha),
);
}
}
}
}
fn fade(color: Color, alpha: u8) -> Color {
Color::rgba(
color.r,
color.g,
color.b,
((color.a as u32 * alpha as u32) / 255) as u8,
)
}
fn measure(note: &Note, style: TextStyle, surface: Size, engine: &mut TextEngine) -> Size {
let limit = MAX_WIDTH.min(surface.width as i32 - MARGIN * 2).max(1);
let available = (limit - PADDING * 2).max(1);
let height = engine.wrapped_height(style, ¬e.text, available);
let lines: Vec<&str> = engine.wrap(style, ¬e.text, available);
let widest = lines
.iter()
.map(|line| engine.measure_line(style, line))
.max()
.unwrap_or(0);
Size::new(
(widest + PADDING * 2).clamp(1, limit) as u32,
(height + PADDING * 2).max(1) as u32,
)
}
#[cfg(test)]
mod tests {
use super::*;
const SURFACE: Size = Size::new(400, 240);
fn note(now_ms: u64) -> Note {
Note {
text: String::from("Lagret"),
role: Role::Success,
born_ms: now_ms,
hold_ms: HOLD_MS,
}
}
#[test]
fn a_toast_fades_in_holds_and_fades_out_by_itself() {
let note = note(1_000);
assert_eq!(note.alpha(1_000), Some(0), "born invisible");
assert!(note.alpha(1_000 + FADE_IN_MS / 2).expect("fading in") > 0);
assert_eq!(note.alpha(1_000 + FADE_IN_MS), Some(255), "arrived");
assert_eq!(
note.alpha(1_000 + FADE_IN_MS + HOLD_MS / 2),
Some(255),
"holding"
);
let fading = 1_000 + FADE_IN_MS + HOLD_MS + FADE_OUT_MS / 2;
let half = note.alpha(fading).expect("fading out");
assert!((80..180).contains(&half), "half way out is {half}");
assert_eq!(note.alpha(1_000 + note.life_ms()), None, "gone");
assert_eq!(note.alpha(u64::MAX), None, "and stays gone");
}
#[test]
fn a_holding_toast_asks_for_exactly_one_wake() {
let note = note(0);
let fading_at = FADE_IN_MS + HOLD_MS;
let wake = note.next_wake(FADE_IN_MS, 33);
assert_eq!(wake, fading_at, "a holding toast wakes once, at the fade");
assert_eq!(note.next_wake(fading_at - 1, 33), fading_at);
assert_eq!(note.next_wake(0, 33), 33, "fading in");
assert_eq!(note.next_wake(fading_at + 10, 33), fading_at + 43, "out");
}
#[test]
fn an_empty_stack_wakes_for_nothing() {
let toasts = Toasts::new();
assert_eq!(toasts.next_wake(0), None);
assert_eq!(toasts.len(), 0);
}
#[test]
fn expired_toasts_are_retired() {
let mut toasts = Toasts::new();
toasts.push(String::from("Lagret"), Role::Success, HOLD_MS, 0);
assert!(!toasts.retire(100), "still alive");
assert_eq!(toasts.len(), 1);
assert!(toasts.retire(FADE_IN_MS + HOLD_MS + FADE_OUT_MS));
assert_eq!(toasts.len(), 0);
assert!(!toasts.retire(u64::MAX), "nothing left to retire");
}
#[test]
fn toasts_stack_without_overlapping() {
let mut engine = TextEngine::new();
let mut toasts = Toasts::new();
toasts.push(String::from("Først"), Role::Info, HOLD_MS, 0);
toasts.push(String::from("Så dette"), Role::Success, HOLD_MS, 0);
let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
assert_eq!(placed.len(), 2);
let (newest_index, newest, _) = placed[0];
let (oldest_index, oldest, _) = placed[1];
assert_eq!(newest_index, 1, "the newest is the last one pushed");
assert_eq!(oldest_index, 0);
assert!(
newest.y > oldest.y,
"the newest should be nearest the bottom: {newest:?} {oldest:?}"
);
assert!(
oldest.bottom() <= newest.y,
"they overlap: {oldest:?} {newest:?}"
);
assert!(
newest.bottom() <= SURFACE.height as i32 - MARGIN,
"the stack ran off the bottom"
);
for (_, rect, _) in &placed {
assert!(rect.x >= 0 && rect.right() <= SURFACE.width as i32);
}
}
#[test]
fn the_oldest_is_dropped_when_the_stack_is_full() {
let mut toasts = Toasts::new();
for i in 0..MAX_VISIBLE + 2 {
toasts.push(alloc::format!("Melding {i}"), Role::Info, HOLD_MS, 0);
}
assert_eq!(toasts.len(), MAX_VISIBLE);
assert_eq!(
toasts.notes[0].text, "Melding 2",
"the oldest two should have gone"
);
}
#[test]
fn a_press_inside_a_toast_dismisses_it_and_is_consumed() {
let mut engine = TextEngine::new();
let mut toasts = Toasts::new();
toasts.push(String::from("Lagret"), Role::Success, HOLD_MS, 0);
let (_, rect, _) = toasts.placed(SURFACE, &mut engine, FADE_IN_MS)[0];
let outside = Point::new(rect.x - 5, rect.y - 5);
assert!(!toasts.dismiss_at(outside, SURFACE, &mut engine, FADE_IN_MS));
assert_eq!(toasts.len(), 1, "and it is still there");
let inside = Point::new(rect.x + 2, rect.y + 2);
assert!(toasts.dismiss_at(inside, SURFACE, &mut engine, FADE_IN_MS));
assert_eq!(toasts.len(), 0);
}
#[test]
fn dismissing_removes_the_toast_that_was_pressed() {
let mut engine = TextEngine::new();
let mut toasts = Toasts::new();
toasts.push(String::from("Først"), Role::Info, HOLD_MS, 0);
toasts.push(String::from("Andre"), Role::Success, HOLD_MS, 0);
toasts.push(String::from("Tredje"), Role::Error, HOLD_MS, 0);
let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
let (_, newest, _) = placed[0];
assert!(toasts.dismiss_at(
Point::new(newest.x + 2, newest.y + 2),
SURFACE,
&mut engine,
FADE_IN_MS
));
assert_eq!(toasts.len(), 2);
assert_eq!(
[toasts.notes[0].text.as_str(), toasts.notes[1].text.as_str()],
["Først", "Andre"],
"the wrong toast was dismissed"
);
let placed = toasts.placed(SURFACE, &mut engine, FADE_IN_MS);
let (_, oldest, _) = placed[1];
assert!(toasts.dismiss_at(
Point::new(oldest.x + 2, oldest.y + 2),
SURFACE,
&mut engine,
FADE_IN_MS
));
assert_eq!(toasts.notes[0].text, "Andre");
}
#[test]
fn an_expired_toast_does_not_misalign_the_rest() {
let mut engine = TextEngine::new();
let mut toasts = Toasts::new();
toasts.push(String::from("Gammel"), Role::Info, 0, 0);
toasts.push(String::from("Ny"), Role::Success, HOLD_MS, 0);
let late = FADE_IN_MS + FADE_OUT_MS + 1;
let placed = toasts.placed(SURFACE, &mut engine, late);
assert_eq!(placed.len(), 1, "only the live one is placed");
assert_eq!(placed[0].0, 1, "and it knows which message it is");
}
#[test]
fn a_long_message_wraps_instead_of_overflowing() {
let mut engine = TextEngine::new();
let short = measure(¬e(0), TextStyle::built_in(16), SURFACE, &mut engine);
let long = Note {
text: String::from("Kunne ikke lagre fordi disken er full og det er ingen plass igjen"),
..note(0)
};
let wrapped = measure(&long, TextStyle::built_in(16), SURFACE, &mut engine);
assert!(wrapped.height > short.height, "it did not wrap");
assert!(
wrapped.width as i32 <= SURFACE.width as i32 - MARGIN * 2,
"it is wider than the surface allows: {wrapped:?}"
);
}
#[test]
fn fading_touches_only_the_alpha() {
let colour = Color::rgba(200, 100, 50, 255);
assert_eq!(fade(colour, 255), colour);
let half = fade(colour, 128);
assert_eq!((half.r, half.g, half.b), (200, 100, 50));
assert!(half.a < colour.a && half.a > 0);
assert_eq!(fade(colour, 0).a, 0);
}
}