use crate::event::Rect;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToastOverlay {
pub id: WidgetId,
pub corner: ToastCorner,
pub toasts: Vec<Toast>,
#[serde(default)]
pub focus: Option<ToastFocus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ToastCorner {
#[default]
BottomRight,
BottomLeft,
TopRight,
TopLeft,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toast {
pub id: WidgetId,
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub severity: ToastSeverity,
#[serde(default)]
pub actions: Vec<ToastButton>,
#[serde(default)]
pub accent: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ToastSeverity {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToastButton {
pub id: WidgetId,
pub label: String,
#[serde(default)]
pub primary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToastFocus {
pub toast_id: WidgetId,
pub target: ToastFocusTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastFocusTarget {
Dismiss,
Action(usize),
}
pub(crate) const MAX_BODY_LINES: usize = 3;
pub(crate) fn wrap_text_lines(
text: &str,
max_width: f32,
max_lines: usize,
width_of: &dyn Fn(&str) -> f32,
) -> Vec<String> {
if text.is_empty() || max_lines == 0 {
return Vec::new();
}
if max_width <= 0.0 {
return vec![text.to_string()];
}
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return Vec::new();
}
let mut lines: Vec<String> = Vec::new();
let mut idx = 0;
while idx < words.len() && lines.len() < max_lines {
let mut line = String::new();
loop {
if idx >= words.len() {
break;
}
let word = words[idx];
let candidate = if line.is_empty() {
word.to_string()
} else {
format!("{line} {word}")
};
if width_of(&candidate) <= max_width {
line = candidate;
idx += 1;
continue;
}
if line.is_empty() {
let mut piece = String::new();
for ch in word.chars() {
let next = format!("{piece}{ch}…");
if width_of(&next) > max_width && !piece.is_empty() {
break;
}
piece.push(ch);
}
piece.push('…');
line = piece;
idx += 1;
}
break;
}
lines.push(line);
}
if idx < words.len() {
if let Some(last) = lines.last_mut() {
while !last.is_empty() && width_of(&format!("{last}…")) > max_width {
last.pop();
}
last.push('…');
}
}
lines
}
#[cfg_attr(
not(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
)),
allow(dead_code)
)]
pub(crate) fn truncate_line(text: &str, max_width: f32, width_of: &dyn Fn(&str) -> f32) -> String {
wrap_text_lines(text, max_width, 1, width_of)
.into_iter()
.next()
.unwrap_or_default()
}
#[cfg(test)]
mod wrap_tests {
use super::{truncate_line, wrap_text_lines};
fn chars(s: &str) -> f32 {
s.chars().count() as f32
}
#[test]
fn short_text_is_one_line_unchanged() {
let lines = wrap_text_lines("Hello", 20.0, 3, &chars);
assert_eq!(lines, vec!["Hello".to_string()]);
}
#[test]
fn wraps_on_word_boundaries() {
let lines = wrap_text_lines("one two three four", 9.0, 3, &chars);
assert_eq!(lines[0], "one two");
for line in &lines {
assert!(chars(line) <= 9.0, "line {line:?} exceeds max_width");
}
}
#[test]
fn ellipsizes_last_line_when_content_overflows_max_lines() {
let lines = wrap_text_lines("a b c d e f g h", 3.0, 2, &chars);
assert_eq!(lines.len(), 2);
assert!(lines[1].ends_with('…'));
for line in &lines {
assert!(chars(line) <= 3.0, "line {line:?} exceeds max_width");
}
}
#[test]
fn truncate_line_never_wraps() {
let line = truncate_line("Install Markdown Language Server?", 10.0, &chars);
assert!(chars(&line) <= 10.0);
assert!(line.ends_with('…'));
}
#[test]
fn single_word_wider_than_max_width_is_hard_truncated() {
let lines = wrap_text_lines("supercalifragilisticexpialidocious", 5.0, 1, &chars);
assert_eq!(lines.len(), 1);
assert!(
chars(&lines[0]) <= 5.0,
"line {:?} exceeds max_width",
lines[0]
);
assert!(lines[0].ends_with('…'));
}
#[test]
fn empty_text_yields_no_lines() {
assert!(wrap_text_lines("", 20.0, 3, &chars).is_empty());
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToastMeasure {
pub width: f32,
pub height: f32,
pub dismiss_rect: Option<Rect>,
pub action_rects: Vec<Rect>,
}
impl ToastMeasure {
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
dismiss_rect: None,
action_rects: Vec::new(),
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn toast_button_rects(
width: f32,
height: f32,
padding: f32,
dismiss_width: f32,
dismiss_height: f32,
action_widths: &[f32],
action_height: f32,
action_gap: f32,
) -> (Option<Rect>, Vec<Rect>) {
let dismiss_rect = if dismiss_width > 0.0 && dismiss_height > 0.0 {
Some(Rect::new(
(width - padding - dismiss_width).max(0.0),
padding,
dismiss_width,
dismiss_height,
))
} else {
None
};
let mut action_rects: Vec<Rect> = Vec::with_capacity(action_widths.len());
if !action_widths.is_empty() && action_height > 0.0 {
let row_y = (height - padding - action_height).max(0.0);
let mut x_cursor = width - padding;
for w in action_widths.iter().rev() {
x_cursor -= w;
action_rects.push(Rect::new(x_cursor.max(0.0), row_y, *w, action_height));
x_cursor -= action_gap;
}
action_rects.reverse();
}
(dismiss_rect, action_rects)
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleToast {
pub toast_idx: usize,
pub id: WidgetId,
pub bounds: Rect,
pub dismiss_bounds: Option<Rect>,
pub action_rects: Vec<Rect>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToastHit {
Action(WidgetId),
Dismiss(WidgetId),
Body(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToastStackLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub visible_toasts: Vec<VisibleToast>,
pub hit_regions: Vec<(Rect, ToastHit)>,
}
fn shift_rect(r: Rect, dx: f32, dy: f32) -> Rect {
Rect::new(r.x + dx, r.y + dy, r.width, r.height)
}
impl ToastStackLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ToastHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ToastHit::Empty
}
}
impl ToastOverlay {
#[allow(clippy::too_many_arguments)]
pub fn layout<F>(
&self,
origin_x: f32,
origin_y: f32,
viewport_width: f32,
viewport_height: f32,
margin: f32,
gap: f32,
measure_toast: F,
) -> ToastStackLayout
where
F: Fn(usize) -> ToastMeasure,
{
let mut visible_toasts: Vec<VisibleToast> = Vec::new();
let mut hit_regions: Vec<(Rect, ToastHit)> = Vec::new();
if self.toasts.is_empty() {
return ToastStackLayout {
viewport_width,
viewport_height,
visible_toasts,
hit_regions,
};
}
let is_right = matches!(
self.corner,
ToastCorner::BottomRight | ToastCorner::TopRight
);
let is_bottom = matches!(
self.corner,
ToastCorner::BottomRight | ToastCorner::BottomLeft
);
let ordered: Vec<(usize, ToastMeasure)> = if is_bottom {
(0..self.toasts.len())
.rev()
.map(|i| (i, measure_toast(i)))
.collect()
} else {
(0..self.toasts.len())
.map(|i| (i, measure_toast(i)))
.collect()
};
let mut y_cursor = if is_bottom {
viewport_height - margin
} else {
margin
};
for (i, m) in ordered {
if m.width <= 0.0 || m.height <= 0.0 {
continue;
}
let x = if is_right {
(viewport_width - margin - m.width).max(0.0)
} else {
margin
};
let y = if is_bottom {
(y_cursor - m.height).max(0.0)
} else {
y_cursor
};
if (is_bottom && y >= y_cursor) || (!is_bottom && y + m.height > viewport_height) {
break;
}
let bounds = Rect::new(x, y, m.width, m.height);
let dismiss_bounds = m.dismiss_rect.map(|r| shift_rect(r, bounds.x, bounds.y));
let action_rects: Vec<Rect> = m
.action_rects
.iter()
.map(|r| shift_rect(*r, bounds.x, bounds.y))
.collect();
let toast_id = self.toasts[i].id;
visible_toasts.push(VisibleToast {
toast_idx: i,
id: toast_id,
bounds,
dismiss_bounds,
action_rects: action_rects.clone(),
});
if let Some(db) = dismiss_bounds {
hit_regions.push((db, ToastHit::Dismiss(toast_id)));
}
for (ab, act) in action_rects.iter().zip(self.toasts[i].actions.iter()) {
hit_regions.push((*ab, ToastHit::Action(act.id)));
}
hit_regions.push((bounds, ToastHit::Body(toast_id)));
if is_bottom {
y_cursor = y - gap;
if y_cursor <= 0.0 {
break;
}
} else {
y_cursor = y + m.height + gap;
if y_cursor >= viewport_height {
break;
}
}
}
if origin_x != 0.0 || origin_y != 0.0 {
for vt in &mut visible_toasts {
vt.bounds = shift_rect(vt.bounds, origin_x, origin_y);
vt.dismiss_bounds = vt.dismiss_bounds.map(|r| shift_rect(r, origin_x, origin_y));
vt.action_rects = vt
.action_rects
.iter()
.map(|r| shift_rect(*r, origin_x, origin_y))
.collect();
}
for (rect, _) in &mut hit_regions {
*rect = shift_rect(*rect, origin_x, origin_y);
}
}
ToastStackLayout {
viewport_width,
viewport_height,
visible_toasts,
hit_regions,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{
shift_rect, toast_button_rects, truncate_line, wrap_text_lines, Toast, ToastFocus,
ToastFocusTarget, ToastMeasure, ToastOverlay, ToastSeverity, ToastStackLayout,
VisibleToast, MAX_BODY_LINES,
};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::style::Style;
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
const TOAST_WIDTH: f32 = 320.0;
const TOAST_MARGIN: f32 = 12.0;
const TOAST_GAP: f32 = 8.0;
const DISMISS_WIDTH: f32 = 28.0;
const ACTION_PADDING: f32 = 16.0;
const ACTION_GAP: f32 = 8.0;
const TOAST_PADDING: f32 = 8.0;
fn severity_bg(severity: ToastSeverity, theme: &Theme) -> Color {
match severity {
ToastSeverity::Info => theme.surface_bg,
ToastSeverity::Success => Color::rgb(30, 80, 30),
ToastSeverity::Warning => Color::rgb(100, 80, 20),
ToastSeverity::Error => theme.error_fg,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint(
stack: &ToastOverlay,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
origin_x: f32,
origin_y: f32,
viewport_width: f32,
viewport_height: f32,
line_height: f32,
) -> ToastStackLayout {
paint_with_x_offset(
stack,
surface,
theme,
style,
origin_x,
origin_y,
viewport_width,
viewport_height,
line_height,
|_| 0.0,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_with_x_offset<F>(
stack: &ToastOverlay,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
origin_x: f32,
origin_y: f32,
viewport_width: f32,
viewport_height: f32,
line_height: f32,
x_offset_for: F,
) -> ToastStackLayout
where
F: Fn(&WidgetId) -> f32,
{
let layout = stack.layout(
origin_x,
origin_y,
viewport_width,
viewport_height,
TOAST_MARGIN,
TOAST_GAP,
|i| {
let toast = &stack.toasts[i];
let width = TOAST_WIDTH.min((viewport_width - TOAST_MARGIN * 2.0).max(0.0));
let body_avail = (width - TOAST_PADDING * 2.0).max(0.0);
let body_lines = if toast.body.is_empty() {
0
} else {
wrap_text_lines(&toast.body, body_avail, MAX_BODY_LINES, &|s| {
surface.surface_measure_text(s).0
})
.len()
.max(1)
};
let has_actions = !toast.actions.is_empty();
let button_row_h = if has_actions {
TOAST_PADDING + line_height
} else {
0.0
};
let h = line_height
+ TOAST_PADDING * 2.0
+ body_lines as f32 * line_height
+ button_row_h;
let action_widths: Vec<f32> = toast
.actions
.iter()
.map(|a| surface.surface_measure_text(&a.label).0 + ACTION_PADDING)
.collect();
let (dismiss_rect, action_rects) = toast_button_rects(
width,
h,
TOAST_PADDING,
DISMISS_WIDTH,
line_height,
&action_widths,
line_height,
ACTION_GAP,
);
ToastMeasure {
width,
height: h,
dismiss_rect,
action_rects,
}
},
);
for vt in &layout.visible_toasts {
let toast = &stack.toasts[vt.toast_idx];
let focus = stack.focus.as_ref().filter(|f| f.toast_id == toast.id);
let dx = x_offset_for(&vt.id);
if dx == 0.0 {
paint_toast(surface, theme, style, vt, toast, line_height, focus);
} else {
let shifted = VisibleToast {
toast_idx: vt.toast_idx,
id: vt.id,
bounds: shift_rect(vt.bounds, dx, 0.0),
dismiss_bounds: vt.dismiss_bounds.map(|r| shift_rect(r, dx, 0.0)),
action_rects: vt
.action_rects
.iter()
.map(|r| shift_rect(*r, dx, 0.0))
.collect(),
};
paint_toast(surface, theme, style, &shifted, toast, line_height, focus);
}
}
layout
}
fn paint_toast(
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
vt: &VisibleToast,
toast: &Toast,
line_height: f32,
focus: Option<&ToastFocus>,
) {
let bg_color = toast
.accent
.unwrap_or_else(|| severity_bg(toast.severity, theme));
surface.surface_draw_shadow(
vt.bounds,
style.corner_radius,
style.shadow_elevation,
Color::rgba(0, 0, 0, 100),
);
surface.surface_fill_rounded_rect(vt.bounds, style.corner_radius, bg_color);
surface.surface_stroke_rounded_rect(
vt.bounds,
style.corner_radius,
theme.border_fg,
style.border_width,
);
let dismiss_w = vt.dismiss_bounds.map(|d| d.width).unwrap_or(0.0);
let title_avail_w = (vt.bounds.width - TOAST_PADDING * 2.0 - dismiss_w).max(0.0);
let title_line = truncate_line(&toast.title, title_avail_w, &|s| {
surface.surface_measure_text(s).0
});
let title_rect = Rect::new(
vt.bounds.x + TOAST_PADDING,
vt.bounds.y + TOAST_PADDING,
title_avail_w,
line_height,
);
surface.surface_draw_text_run(title_rect, &title_line, theme.foreground);
if !toast.body.is_empty() {
let (_, title_h) = surface.surface_measure_text(&toast.title);
let body_avail_w = (vt.bounds.width - TOAST_PADDING * 2.0).max(0.0);
let body_lines = wrap_text_lines(&toast.body, body_avail_w, MAX_BODY_LINES, &|s| {
surface.surface_measure_text(s).0
});
for (i, line) in body_lines.iter().enumerate() {
let body_rect = Rect::new(
title_rect.x,
title_rect.y + title_h + i as f32 * line_height,
body_avail_w,
line_height,
);
surface.surface_draw_text_run(body_rect, line, theme.foreground);
}
}
if let Some(db) = vt.dismiss_bounds {
let (tw, _) = surface.surface_measure_text("×");
let rect = Rect::new(db.x + (db.width - tw) / 2.0, db.y, db.width, db.height);
surface.surface_draw_text_run(rect, "×", theme.foreground);
if matches!(focus, Some(f) if f.target == ToastFocusTarget::Dismiss) {
surface.surface_stroke_rect(db, theme.link_fg, 2.0);
}
}
for (i, (ab, action)) in vt.action_rects.iter().zip(toast.actions.iter()).enumerate() {
if action.primary {
surface.surface_fill_rect(*ab, theme.accent_bg);
}
let (tw, _) = surface.surface_measure_text(&action.label);
let fg = if action.primary {
theme.foreground
} else {
theme.link_fg
};
let rect = Rect::new(ab.x + (ab.width - tw) / 2.0, ab.y, ab.width, ab.height);
surface.surface_draw_text_run(rect, &action.label, fg);
if matches!(focus, Some(f) if f.target == ToastFocusTarget::Action(i)) {
surface.surface_stroke_rect(*ab, theme.link_fg, 2.0);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::toast::{ToastButton, ToastCorner, ToastFocusTarget};
use crate::types::WidgetId;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
strokes: Vec<(Rect, Color, f32)>,
measured_text_height: Option<f32>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(400.0, 300.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(
text.chars().count() as f32 * 8.0,
self.measured_text_height.unwrap_or(16.0),
)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_stroke_rounded_rect(
&mut self,
rect: Rect,
_radius: f32,
color: Color,
stroke_width: f32,
) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_draw_shadow(
&mut self,
_rect: Rect,
_radius: f32,
_elevation: u8,
_color: Color,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn toast(id: &str, title: &str) -> Toast {
Toast {
id: WidgetId::new(id),
title: title.into(),
body: String::new(),
severity: ToastSeverity::Info,
actions: Vec::new(),
accent: None,
}
}
fn stack_br(toasts: Vec<Toast>) -> ToastOverlay {
ToastOverlay {
id: WidgetId::new("toasts"),
corner: ToastCorner::BottomRight,
toasts,
focus: None,
}
}
#[test]
fn empty_stack_paints_nothing() {
let stack = stack_br(vec![]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
assert!(layout.visible_toasts.is_empty());
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn accent_overrides_severity_tint() {
let accent = Color::rgb(10, 20, 30);
let mut t = toast("t1", "Hello");
t.accent = Some(accent);
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
assert_eq!(surface.fills[0].1, accent);
}
#[test]
fn severity_tint_used_when_no_accent() {
let mut t = toast("t1", "Hello");
t.severity = ToastSeverity::Error;
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
assert_eq!(surface.fills[0].1, theme.error_fg);
}
#[test]
fn dismiss_and_action_are_centred_in_their_sub_region() {
let mut t = toast("t1", "Build failed");
t.actions = vec![ToastButton {
id: WidgetId::new("open_log"),
label: "Open log".into(),
primary: false,
}];
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let vt = &layout.visible_toasts[0];
let db = vt.dismiss_bounds.expect("dismiss bounds present");
let ab = vt.action_rects[0];
let (dismiss_rect, _, _) = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "×")
.expect("dismiss glyph painted");
let dismiss_w = 8.0; assert!((dismiss_rect.x - (db.x + (db.width - dismiss_w) / 2.0)).abs() < 0.01);
let (action_rect, _, _) = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Open log")
.expect("action label painted");
let action_w = "Open log".chars().count() as f32 * 8.0;
assert!((action_rect.x - (ab.x + (ab.width - action_w) / 2.0)).abs() < 0.01);
}
#[test]
fn multiple_actions_are_right_aligned_on_their_own_row() {
let mut t = toast("t1", "Install Markdown Language Server?");
t.actions = vec![
ToastButton {
id: WidgetId::new("install"),
label: "Install".into(),
primary: true,
},
ToastButton {
id: WidgetId::new("dont-ask"),
label: "Don't ask again".into(),
primary: false,
},
];
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let vt = &layout.visible_toasts[0];
let db = vt.dismiss_bounds.expect("dismiss bounds present");
assert_eq!(vt.action_rects.len(), 2);
let install = vt.action_rects[0];
let dont_ask = vt.action_rects[1];
assert_eq!(install.y, dont_ask.y);
assert!(install.y > db.y + db.height - 0.01);
assert!(install.x + install.width <= dont_ask.x + 0.01);
assert!(
(dont_ask.x + dont_ask.width - (vt.bounds.x + vt.bounds.width - 8.0)).abs() < 0.01
);
}
#[test]
fn primary_action_gets_accent_fill() {
let mut t = toast("t1", "Install?");
t.actions = vec![
ToastButton {
id: WidgetId::new("install"),
label: "Install".into(),
primary: true,
},
ToastButton {
id: WidgetId::new("skip"),
label: "Skip".into(),
primary: false,
},
];
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let vt = &layout.visible_toasts[0];
let install_bounds = vt.action_rects[0];
let skip_bounds = vt.action_rects[1];
assert!(surface
.fills
.iter()
.any(|(r, c)| *r == install_bounds && *c == theme.accent_bg));
assert!(!surface.fills.iter().any(|(r, _)| *r == skip_bounds));
}
#[test]
fn focused_dismiss_gets_a_focus_ring() {
let t = toast("t1", "Saved");
let mut stack = stack_br(vec![t]);
stack.focus = Some(ToastFocus {
toast_id: WidgetId::new("t1"),
target: ToastFocusTarget::Dismiss,
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let db = layout.visible_toasts[0]
.dismiss_bounds
.expect("dismiss bounds present");
assert!(surface
.strokes
.iter()
.any(|(r, c, w)| *r == db && *c == theme.link_fg && *w > 1.0));
}
#[test]
fn focused_action_gets_a_focus_ring() {
let t1 = toast("t1", "Saved");
let mut t2 = toast("t2", "Install?");
t2.actions = vec![ToastButton {
id: WidgetId::new("install"),
label: "Install".into(),
primary: true,
}];
let mut stack = stack_br(vec![t1, t2]);
stack.focus = Some(ToastFocus {
toast_id: WidgetId::new("t2"),
target: ToastFocusTarget::Action(0),
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let t2_visible = layout
.visible_toasts
.iter()
.find(|vt| vt.id == WidgetId::new("t2"))
.expect("t2 visible");
let ab = t2_visible.action_rects[0];
assert!(surface
.strokes
.iter()
.any(|(r, c, w)| *r == ab && *c == theme.link_fg && *w > 1.0));
let t1_visible = layout
.visible_toasts
.iter()
.find(|vt| vt.id == WidgetId::new("t1"))
.expect("t1 visible");
let t1_db = t1_visible.dismiss_bounds.expect("dismiss bounds present");
assert!(!surface
.strokes
.iter()
.any(|(r, c, w)| *r == t1_db && *c == theme.link_fg && *w > 1.0));
}
#[test]
fn body_line_painted_below_title() {
let mut t = toast("t1", "Title");
t.body = "Body text".into();
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let title_run = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Title")
.expect("title painted");
let body_run = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Body text")
.expect("body painted");
assert_eq!(body_run.0.y, title_run.0.y + 16.0);
}
#[test]
fn body_offset_uses_measured_title_height_not_nominal_line_height() {
let mut t = toast("t1", "Title");
t.body = "Body text".into();
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface {
measured_text_height: Some(20.0),
..Default::default()
};
paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let title_run = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Title")
.expect("title painted");
let body_run = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Body text")
.expect("body painted");
assert_eq!(body_run.0.y, title_run.0.y + 20.0);
}
#[test]
fn long_title_and_body_stay_inside_bounds_and_dont_overlap_action() {
let mut t = toast("t1", "Install Markdown Language Server?");
t.actions = vec![ToastButton {
id: WidgetId::new("install"),
label: "Install".into(),
primary: true,
}];
t.body =
"N: don't ask again · :ExtInstall markdown-language-server for full details".into();
let stack = stack_br(vec![t]);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let layout = paint(
&stack,
&mut surface,
&theme,
&Style::default(),
0.0,
0.0,
400.0,
300.0,
16.0,
);
let vt = &layout.visible_toasts[0];
let ab = vt.action_rects[0];
let db = vt.dismiss_bounds.expect("dismiss bounds present");
for (rect, text, _) in &surface.text_runs {
if text == "×" {
continue;
}
let (w, _) = surface.surface_measure_text(text);
assert!(
rect.x >= vt.bounds.x - 0.01,
"{text:?} starts left of the toast bounds"
);
assert!(
rect.x + w <= vt.bounds.x + vt.bounds.width + 0.01,
"{text:?} (rect {rect:?}, measured width {w}) overflows the toast's right edge"
);
assert!(
rect.y + 16.0 <= vt.bounds.y + vt.bounds.height + 0.01,
"{text:?} overflows the toast's bottom edge"
);
}
let title_run = surface
.text_runs
.iter()
.find(|(_, text, _)| text.starts_with("Install Markdown"))
.expect("truncated title painted");
let (title_w, _) = surface.surface_measure_text(&title_run.1);
assert!(
title_run.0.x + title_w <= db.x + 0.01,
"title run {:?} overlaps the dismiss button at {:?}",
title_run,
db
);
assert!(
ab.y > db.y + db.height - 0.01,
"action row overlaps dismiss's row"
);
let single_line_height = 16.0 + 8.0 * 2.0 + 16.0; assert!(
vt.bounds.height > single_line_height,
"box should have grown to fit the wrapped body: height={}",
vt.bounds.height
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_toast(id: &str, title: &str) -> Toast {
Toast {
id: WidgetId::new(id),
title: title.to_string(),
body: String::new(),
severity: ToastSeverity::Info,
actions: Vec::new(),
accent: None,
}
}
fn make_toast_stack(corner: ToastCorner, toasts: Vec<Toast>) -> ToastOverlay {
ToastOverlay {
id: WidgetId::new("toasts"),
corner,
toasts,
focus: None,
}
}
#[test]
fn toast_layout_empty() {
let stack = make_toast_stack(ToastCorner::BottomRight, vec![]);
let layout = stack.layout(0.0, 0.0, 800.0, 600.0, 16.0, 8.0, |_| {
ToastMeasure::new(300.0, 64.0)
});
assert_eq!(layout.visible_toasts.len(), 0);
assert_eq!(layout.hit_test(100.0, 100.0), ToastHit::Empty);
}
#[test]
fn toast_layout_bottom_right_newest_at_bottom() {
let stack = make_toast_stack(
ToastCorner::BottomRight,
vec![
make_toast("first", "First"),
make_toast("second", "Second"),
make_toast("third", "Third"),
],
);
let layout = stack.layout(0.0, 0.0, 800.0, 600.0, 16.0, 8.0, |_| {
ToastMeasure::new(300.0, 64.0)
});
assert_eq!(layout.visible_toasts.len(), 3);
let newest = &layout.visible_toasts[0];
assert_eq!(newest.toast_idx, 2);
assert_eq!(newest.id.as_str(), "third");
assert_eq!(newest.bounds.y, 520.0);
assert_eq!(newest.bounds.x, 484.0);
assert_eq!(layout.visible_toasts[1].id.as_str(), "second");
assert_eq!(layout.visible_toasts[1].bounds.y, 520.0 - 8.0 - 64.0);
}
#[test]
fn toast_layout_top_left_newest_at_top() {
let stack = make_toast_stack(
ToastCorner::TopLeft,
vec![make_toast("a", "A"), make_toast("b", "B")],
);
let layout = stack.layout(0.0, 0.0, 800.0, 600.0, 10.0, 5.0, |_| {
ToastMeasure::new(200.0, 50.0)
});
assert_eq!(layout.visible_toasts.len(), 2);
let first = &layout.visible_toasts[0];
assert_eq!(first.id.as_str(), "a");
assert_eq!(first.bounds.x, 10.0);
assert_eq!(first.bounds.y, 10.0);
let second = &layout.visible_toasts[1];
assert_eq!(second.bounds.y, 10.0 + 50.0 + 5.0);
}
#[test]
fn toast_layout_action_and_dismiss_regions() {
let mut toast = make_toast("t1", "Build failed");
toast.actions = vec![ToastButton {
id: WidgetId::new("open_log"),
label: "Open log".to_string(),
primary: false,
}];
let stack = make_toast_stack(ToastCorner::BottomRight, vec![toast]);
let layout = stack.layout(0.0, 0.0, 800.0, 600.0, 16.0, 8.0, |_| ToastMeasure {
width: 300.0,
height: 64.0,
dismiss_rect: Some(Rect::new(300.0 - 24.0, 0.0, 24.0, 24.0)),
action_rects: vec![Rect::new(300.0 - 80.0, 64.0 - 20.0, 80.0, 20.0)],
});
let v = &layout.visible_toasts[0];
assert!(v.dismiss_bounds.is_some());
assert_eq!(v.action_rects.len(), 1);
let db = v.dismiss_bounds.unwrap();
let ab = v.action_rects[0];
assert_eq!(db.x + db.width, v.bounds.x + v.bounds.width);
assert!(ab.y >= db.y + db.height);
match layout.hit_test(db.x + 5.0, db.y + 10.0) {
ToastHit::Dismiss(id) => assert_eq!(id.as_str(), "t1"),
_ => panic!("expected Dismiss hit"),
}
match layout.hit_test(ab.x + 5.0, ab.y + 10.0) {
ToastHit::Action(id) => assert_eq!(id.as_str(), "open_log"),
_ => panic!("expected Action hit"),
}
match layout.hit_test(v.bounds.x + 5.0, v.bounds.y + 10.0) {
ToastHit::Body(id) => assert_eq!(id.as_str(), "t1"),
_ => panic!("expected Body hit"),
}
}
#[test]
fn toast_layout_multiple_action_regions() {
let mut toast = make_toast("t1", "Install Markdown Language Server?");
toast.actions = vec![
ToastButton {
id: WidgetId::new("install"),
label: "Install".to_string(),
primary: true,
},
ToastButton {
id: WidgetId::new("dont-ask"),
label: "Don't ask again".to_string(),
primary: false,
},
];
let stack = make_toast_stack(ToastCorner::BottomRight, vec![toast]);
let layout = stack.layout(0.0, 0.0, 800.0, 600.0, 16.0, 8.0, |_| ToastMeasure {
width: 300.0,
height: 80.0,
dismiss_rect: Some(Rect::new(276.0, 0.0, 24.0, 24.0)),
action_rects: vec![
Rect::new(120.0, 60.0, 70.0, 20.0),
Rect::new(198.0, 60.0, 90.0, 20.0),
],
});
let v = &layout.visible_toasts[0];
assert_eq!(v.action_rects.len(), 2);
let install = v.action_rects[0];
let dont_ask = v.action_rects[1];
assert!(install.x + install.width <= dont_ask.x);
match layout.hit_test(install.x + 1.0, install.y + 1.0) {
ToastHit::Action(id) => assert_eq!(id.as_str(), "install"),
other => panic!("expected Action(install), got {other:?}"),
}
match layout.hit_test(dont_ask.x + 1.0, dont_ask.y + 1.0) {
ToastHit::Action(id) => assert_eq!(id.as_str(), "dont-ask"),
other => panic!("expected Action(dont-ask), got {other:?}"),
}
}
#[test]
fn toast_layout_stack_clips_when_out_of_room() {
let stack = make_toast_stack(
ToastCorner::BottomRight,
(0..5)
.map(|i| make_toast(&format!("t{i}"), &format!("T{i}")))
.collect(),
);
let layout = stack.layout(0.0, 0.0, 800.0, 200.0, 10.0, 8.0, |_| {
ToastMeasure::new(300.0, 64.0)
});
assert!(layout.visible_toasts.len() >= 2);
assert!(layout.visible_toasts.len() <= 3);
}
#[test]
fn toast_layout_nonzero_origin_shifts_every_bound() {
let mut toast = make_toast("t1", "Build failed");
toast.actions = vec![ToastButton {
id: WidgetId::new("open_log"),
label: "Open log".to_string(),
primary: false,
}];
let stack = make_toast_stack(ToastCorner::BottomRight, vec![toast]);
let measure = |_: usize| ToastMeasure {
width: 300.0,
height: 64.0,
dismiss_rect: Some(Rect::new(276.0, 0.0, 24.0, 24.0)),
action_rects: vec![Rect::new(220.0, 44.0, 80.0, 20.0)],
};
let origin = stack.layout(0.0, 0.0, 800.0, 600.0, 16.0, 8.0, measure);
let shifted = stack.layout(7.0, 13.0, 800.0, 600.0, 16.0, 8.0, measure);
let o = &origin.visible_toasts[0];
let s = &shifted.visible_toasts[0];
assert_eq!(s.bounds.x, o.bounds.x + 7.0);
assert_eq!(s.bounds.y, o.bounds.y + 13.0);
assert_eq!(s.bounds.width, o.bounds.width);
assert_eq!(
s.dismiss_bounds.unwrap().x,
o.dismiss_bounds.unwrap().x + 7.0
);
assert_eq!(s.action_rects[0].y, o.action_rects[0].y + 13.0);
let db = s.dismiss_bounds.unwrap();
assert_eq!(
shifted.hit_test(db.x + 5.0, db.y + 10.0),
ToastHit::Dismiss(WidgetId::new("t1")),
);
}
}