use super::toast::{ToastLevel, ToastPosition};
use crate::render::Cell;
use crate::style::Color;
use crate::utils::{char_width, truncate_to_width};
use crate::widget::theme::DISABLED_FG;
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StackDirection {
#[default]
Down,
Up,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum ToastPriority {
Low = 0,
#[default]
Normal = 1,
High = 2,
Critical = 3,
}
#[derive(Clone, Debug)]
pub struct ToastEntry {
pub id: Option<String>,
pub message: String,
pub level: ToastLevel,
pub priority: ToastPriority,
pub duration: Option<Duration>,
pub created_at: Instant,
pub shown_at: Option<Instant>,
pub dismissible: bool,
}
impl ToastEntry {
pub fn new(message: impl Into<String>, level: ToastLevel) -> Self {
Self {
id: None,
message: message.into(),
level,
priority: ToastPriority::Normal,
duration: None,
created_at: Instant::now(),
shown_at: None,
dismissible: true,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_priority(mut self, priority: ToastPriority) -> Self {
self.priority = priority;
self
}
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
pub fn dismissible(mut self, dismissible: bool) -> Self {
self.dismissible = dismissible;
self
}
fn is_expired(&self, default_duration: Duration) -> bool {
if let Some(shown) = self.shown_at {
let duration = self.duration.unwrap_or(default_duration);
shown.elapsed() >= duration
} else {
false
}
}
}
pub struct ToastQueue {
queue: Vec<ToastEntry>,
visible: Vec<ToastEntry>,
position: ToastPosition,
stack_direction: StackDirection,
max_visible: usize,
default_duration: Duration,
gap: u16,
toast_width: u16,
deduplicate: bool,
pause_on_hover: bool,
paused: bool,
props: WidgetProps,
}
impl ToastQueue {
pub fn new() -> Self {
Self {
queue: Vec::new(),
visible: Vec::new(),
position: ToastPosition::TopRight,
stack_direction: StackDirection::Down,
max_visible: 5,
default_duration: Duration::from_secs(4),
gap: 1,
toast_width: 40,
deduplicate: true,
pause_on_hover: false,
paused: false,
props: WidgetProps::new(),
}
}
pub fn position(mut self, position: ToastPosition) -> Self {
self.position = position;
self
}
pub fn stack_direction(mut self, direction: StackDirection) -> Self {
self.stack_direction = direction;
self
}
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
pub fn default_duration(mut self, duration: Duration) -> Self {
self.default_duration = duration;
self
}
pub fn gap(mut self, gap: u16) -> Self {
self.gap = gap;
self
}
pub fn toast_width(mut self, width: u16) -> Self {
self.toast_width = width;
self
}
pub fn deduplicate(mut self, deduplicate: bool) -> Self {
self.deduplicate = deduplicate;
self
}
pub fn pause_on_hover(mut self, pause: bool) -> Self {
self.pause_on_hover = pause;
self
}
pub fn pause(&mut self) {
if self.pause_on_hover {
self.paused = true;
}
}
pub fn resume(&mut self) {
self.paused = false;
}
pub fn is_paused(&self) -> bool {
self.paused
}
pub fn handle_mouse(
&mut self,
event: &crate::event::MouseEvent,
area: crate::layout::Rect,
) -> bool {
if !self.pause_on_hover {
return false;
}
let in_area = event.x >= area.x
&& event.x < area.x + area.width
&& event.y >= area.y
&& event.y < area.y + area.height;
if in_area {
self.pause();
} else {
self.resume();
}
in_area
}
pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
self.push_entry(ToastEntry::new(message, level));
}
pub fn push_with_id(
&mut self,
id: impl Into<String>,
message: impl Into<String>,
level: ToastLevel,
) {
self.push_entry(ToastEntry::new(message, level).with_id(id));
}
pub fn info(&mut self, message: impl Into<String>) {
self.push(message, ToastLevel::Info);
}
pub fn success(&mut self, message: impl Into<String>) {
self.push(message, ToastLevel::Success);
}
pub fn warning(&mut self, message: impl Into<String>) {
self.push(message, ToastLevel::Warning);
}
pub fn error(&mut self, message: impl Into<String>) {
self.push(message, ToastLevel::Error);
}
pub fn push_entry(&mut self, entry: ToastEntry) {
if self.deduplicate {
if let Some(ref id) = entry.id {
let exists = self.visible.iter().any(|t| t.id.as_ref() == Some(id))
|| self.queue.iter().any(|t| t.id.as_ref() == Some(id));
if exists {
return;
}
}
}
let pos = self
.queue
.iter()
.position(|t| t.priority < entry.priority)
.unwrap_or(self.queue.len());
self.queue.insert(pos, entry);
}
pub fn tick(&mut self) {
if !self.paused {
self.visible
.retain(|t| !t.is_expired(self.default_duration));
}
while self.visible.len() < self.max_visible && !self.queue.is_empty() {
let mut entry = self.queue.remove(0);
entry.shown_at = Some(Instant::now());
self.visible.push(entry);
}
}
pub fn dismiss(&mut self, id: &str) {
self.visible.retain(|t| t.id.as_deref() != Some(id));
self.queue.retain(|t| t.id.as_deref() != Some(id));
}
pub fn dismiss_first(&mut self) {
if !self.visible.is_empty() {
let first = &self.visible[0];
if first.dismissible {
self.visible.remove(0);
}
}
}
pub fn dismiss_all(&mut self) {
self.visible.retain(|t| !t.dismissible);
self.queue.retain(|t| !t.dismissible);
}
pub fn clear(&mut self) {
self.visible.clear();
self.queue.clear();
}
pub fn visible_count(&self) -> usize {
self.visible.len()
}
pub fn pending_count(&self) -> usize {
self.queue.len()
}
pub fn total_count(&self) -> usize {
self.visible.len() + self.queue.len()
}
pub fn is_empty(&self) -> bool {
self.visible.is_empty() && self.queue.is_empty()
}
fn toast_height(&self) -> u16 {
3 }
fn calculate_base_position(&self, area_width: u16, area_height: u16) -> (u16, u16) {
let margin = 1u16;
let toast_w = self.toast_width;
let total_height = (self.visible.len() as u16) * (self.toast_height() + self.gap);
let x = match self.position {
ToastPosition::TopLeft | ToastPosition::BottomLeft => margin,
ToastPosition::TopCenter | ToastPosition::BottomCenter => {
area_width.saturating_sub(toast_w) / 2
}
ToastPosition::TopRight | ToastPosition::BottomRight => {
area_width.saturating_sub(toast_w + margin)
}
};
let y = match self.position {
ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight => margin,
ToastPosition::BottomLeft
| ToastPosition::BottomCenter
| ToastPosition::BottomRight => area_height.saturating_sub(total_height + margin),
};
(x, y)
}
fn render_toast(&self, ctx: &mut RenderContext, entry: &ToastEntry, x: u16, y: u16) {
let area = ctx.area;
let toast_w = self.toast_width.min(area.width.saturating_sub(x));
let toast_h = self.toast_height();
if x >= area.width || y >= area.height {
return;
}
let color = entry.level.color();
let bg = entry.level.bg_color();
let mut top_left = Cell::new('╭');
top_left.fg = Some(color);
top_left.bg = Some(bg);
ctx.set(x, y, top_left);
for i in 1..toast_w.saturating_sub(1) {
let mut cell = Cell::new('─');
cell.fg = Some(color);
cell.bg = Some(bg);
ctx.set(x + i, y, cell);
}
let mut top_right = Cell::new('╮');
top_right.fg = Some(color);
top_right.bg = Some(bg);
ctx.set(x + toast_w - 1, y, top_right);
let mut bottom_left = Cell::new('╰');
bottom_left.fg = Some(color);
bottom_left.bg = Some(bg);
ctx.set(x, y + toast_h - 1, bottom_left);
for i in 1..toast_w.saturating_sub(1) {
let mut cell = Cell::new('─');
cell.fg = Some(color);
cell.bg = Some(bg);
ctx.set(x + i, y + toast_h - 1, cell);
}
let mut bottom_right = Cell::new('╯');
bottom_right.fg = Some(color);
bottom_right.bg = Some(bg);
ctx.set(x + toast_w - 1, y + toast_h - 1, bottom_right);
for row in 1..toast_h.saturating_sub(1) {
let mut left = Cell::new('│');
left.fg = Some(color);
left.bg = Some(bg);
ctx.set(x, y + row, left);
let mut right = Cell::new('│');
right.fg = Some(color);
right.bg = Some(bg);
ctx.set(x + toast_w - 1, y + row, right);
for col in 1..toast_w.saturating_sub(1) {
let mut fill = Cell::new(' ');
fill.bg = Some(bg);
ctx.set(x + col, y + row, fill);
}
}
let content_x = x + 2;
let content_y = y + 1;
let mut icon_cell = Cell::new(entry.level.icon());
icon_cell.fg = Some(color);
icon_cell.bg = Some(bg);
ctx.set(content_x, content_y, icon_cell);
let msg_x = content_x + 2;
let max_msg_width = toast_w.saturating_sub(5) as usize;
let truncated_msg = truncate_to_width(&entry.message, max_msg_width);
let mut dx: u16 = 0;
for ch in truncated_msg.chars() {
let cw = char_width(ch) as u16;
if dx + cw > max_msg_width as u16 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(Color::WHITE);
cell.bg = Some(bg);
ctx.set(msg_x + dx, content_y, cell);
dx += cw;
}
if entry.dismissible && toast_w > 10 {
let dismiss_x = x + toast_w - 3;
let mut dismiss = Cell::new('×');
dismiss.fg = Some(DISABLED_FG);
dismiss.bg = Some(bg);
ctx.set(dismiss_x, content_y, dismiss);
}
}
}
impl Default for ToastQueue {
fn default() -> Self {
Self::new()
}
}
impl View for ToastQueue {
crate::impl_view_meta!("ToastQueue");
fn render(&self, ctx: &mut RenderContext) {
if self.visible.is_empty() {
return;
}
let area = ctx.area;
let (base_x, base_y) = self.calculate_base_position(area.width, area.height);
for (i, entry) in self.visible.iter().enumerate() {
let offset = (i as u16) * (self.toast_height() + self.gap);
let y = match self.stack_direction {
StackDirection::Down => base_y + offset,
StackDirection::Up => base_y.saturating_sub(offset),
};
if y < area.height {
self.render_toast(ctx, entry, base_x, y);
}
}
}
}
impl ToastQueue {
#[doc(hidden)]
pub fn get_queue(&self) -> &[ToastEntry] {
&self.queue
}
#[doc(hidden)]
pub fn get_visible(&self) -> &[ToastEntry] {
&self.visible
}
#[doc(hidden)]
pub fn get_position(&self) -> ToastPosition {
self.position
}
#[doc(hidden)]
pub fn get_stack_direction(&self) -> StackDirection {
self.stack_direction
}
#[doc(hidden)]
pub fn get_max_visible(&self) -> usize {
self.max_visible
}
#[doc(hidden)]
pub fn get_default_duration(&self) -> Duration {
self.default_duration
}
#[doc(hidden)]
pub fn get_gap(&self) -> u16 {
self.gap
}
#[doc(hidden)]
pub fn get_toast_width(&self) -> u16 {
self.toast_width
}
#[doc(hidden)]
pub fn get_deduplicate(&self) -> bool {
self.deduplicate
}
}
impl_styled_view!(ToastQueue);
impl_props_builders!(ToastQueue);
pub fn toast_queue() -> ToastQueue {
ToastQueue::new()
}