use std::time::Duration;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::app::{App, PendingUndo, ProgressItem, ProgressStatus, ToastEntry, ToastLevel};
use crate::ui::theme;
const MAX_WIDTH: u16 = 64;
const RIGHT_MARGIN: u16 = 1;
const BOTTOM_MARGIN: u16 = 2; const FADE_TAIL: Duration = Duration::from_millis(800);
const MAX_VISIBLE_TOASTS: usize = 5;
const SPINNER_FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
const SPINNER_FRAME_MS: u128 = 100;
pub fn draw(frame: &mut Frame, app: &mut App) {
app.rects.toast_stack_rects.clear();
let has_persistent = !app.persistent_toasts.is_empty();
let has_stack = !app.toast_stack.is_empty();
let has_progress = !app.progress_items.is_empty();
let has_undo = app.pending_undo.is_some();
if !has_persistent && !has_stack && !has_progress && !has_undo {
return;
}
let area = frame.area();
if area.width < 20 || area.height < 6 {
return;
}
let t = theme::cur();
let max_x_right = area.x + area.width.saturating_sub(RIGHT_MARGIN);
let mut y_bottom = area.y + area.height.saturating_sub(BOTTOM_MARGIN);
app.rects.pending_undo_chip = None;
if let Some(u) = app.pending_undo.clone()
&& let Some(chip_rect) = draw_undo_chip(frame, &u, &mut y_bottom, max_x_right, area, &t)
{
app.rects.pending_undo_chip = Some(chip_rect);
}
let total = app.toast_stack.len();
let show_more_chip = total > MAX_VISIBLE_TOASTS;
let visible_take = if show_more_chip {
MAX_VISIBLE_TOASTS.saturating_sub(1)
} else {
MAX_VISIBLE_TOASTS
};
for entry in app.toast_stack.iter().take(visible_take) {
let before = y_bottom;
if !draw_toast_box(frame, entry, &mut y_bottom, max_x_right, area, &t) {
break;
}
let box_h = before.saturating_sub(y_bottom);
if box_h > 0 {
let box_w = 40u16.min(area.width);
let rect = ratatui::layout::Rect {
x: max_x_right.saturating_sub(box_w),
y: y_bottom,
width: box_w,
height: box_h,
};
app.rects.toast_stack_rects.push(rect);
}
}
if show_more_chip {
let hidden = total.saturating_sub(visible_take);
draw_more_chip(frame, hidden, &mut y_bottom, max_x_right, area, &t);
}
for entry in app.persistent_toasts.iter().rev() {
if !draw_toast_box(frame, entry, &mut y_bottom, max_x_right, area, &t) {
break;
}
}
for item in app.progress_items.iter().rev() {
if !draw_progress_box(frame, item, &mut y_bottom, max_x_right, area, &t) {
break;
}
}
}
fn draw_toast_box(
frame: &mut Frame,
entry: &ToastEntry,
y_bottom: &mut u16,
max_x_right: u16,
area: Rect,
t: &crate::ui::theme::Theme,
) -> bool {
let cap = (MAX_WIDTH as usize).saturating_sub(4);
let full_len = entry.text.chars().count();
let text: String = if full_len > cap {
let mut s: String = entry.text.chars().take(cap.saturating_sub(1)).collect();
s.push('…');
s
} else {
entry.text.clone()
};
let inner_w = text.chars().count() as u16 + 2;
let box_w = (inner_w + 2)
.min(MAX_WIDTH)
.min(area.width.saturating_sub(2));
let box_h: u16 = 3;
if *y_bottom < area.y + box_h {
return false;
}
let y = *y_bottom - box_h;
let x = max_x_right.saturating_sub(box_w);
let rect = Rect {
x,
y,
width: box_w,
height: box_h,
};
let is_persistent = entry.persistent_id.is_some();
let age = entry.created_at.elapsed();
let fading = !is_persistent && age + FADE_TAIL >= Duration::from_secs(4);
let border_fg = match entry.level {
ToastLevel::Error => t.red,
ToastLevel::Warn | ToastLevel::Info if fading => t.bg3,
_ => t.comment,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_fg).bg(t.bg_darker))
.style(Style::default().bg(t.bg_darker));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
text,
Style::default()
.fg(t.fg)
.bg(t.bg_darker)
.add_modifier(if fading {
Modifier::DIM
} else {
Modifier::empty()
}),
),
]);
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(t.bg_darker)),
inner,
);
*y_bottom = y;
true
}
fn draw_more_chip(
frame: &mut Frame,
hidden: usize,
y_bottom: &mut u16,
max_x_right: u16,
area: Rect,
t: &crate::ui::theme::Theme,
) {
let text = format!("+{hidden} more…");
let inner_w = text.chars().count() as u16 + 2;
let box_w = (inner_w + 2)
.min(MAX_WIDTH)
.min(area.width.saturating_sub(2));
let box_h: u16 = 3;
if *y_bottom < area.y + box_h {
return;
}
let y = *y_bottom - box_h;
let x = max_x_right.saturating_sub(box_w);
let rect = Rect {
x,
y,
width: box_w,
height: box_h,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.comment).bg(t.bg_darker))
.style(Style::default().bg(t.bg_darker));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
text,
Style::default()
.fg(t.comment)
.bg(t.bg_darker)
.add_modifier(Modifier::DIM),
),
]);
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(t.bg_darker)),
inner,
);
*y_bottom = y;
}
fn draw_undo_chip(
frame: &mut Frame,
u: &PendingUndo,
y_bottom: &mut u16,
max_x_right: u16,
area: Rect,
t: &crate::ui::theme::Theme,
) -> Option<Rect> {
let label: String = u.label.chars().take(MAX_WIDTH as usize - 20).collect();
let suffix = " \u{21B6} Undo (click) ";
let inner_text = format!(" {label}{suffix}");
let inner_w = inner_text.chars().count() as u16;
let box_w = (inner_w + 2)
.min(MAX_WIDTH)
.min(area.width.saturating_sub(2));
let box_h: u16 = 3;
if *y_bottom < area.y + box_h {
return None;
}
let y = *y_bottom - box_h;
let x = max_x_right.saturating_sub(box_w);
let rect = Rect {
x,
y,
width: box_w,
height: box_h,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(t.cyan).bg(t.bg_darker))
.style(Style::default().bg(t.bg_darker));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let line = Line::from(vec![
Span::styled(
format!(" {label} "),
Style::default().fg(t.fg).bg(t.bg_darker),
),
Span::styled(
"· \u{21B6} Undo ",
Style::default()
.fg(t.cyan)
.bg(t.bg_darker)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"(click) ",
Style::default()
.fg(t.comment)
.bg(t.bg_darker)
.add_modifier(Modifier::DIM),
),
]);
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(t.bg_darker)),
inner,
);
*y_bottom = y;
Some(rect)
}
fn draw_progress_box(
frame: &mut Frame,
item: &ProgressItem,
y_bottom: &mut u16,
max_x_right: u16,
area: Rect,
t: &crate::ui::theme::Theme,
) -> bool {
let glyph: String = match item.finished {
None => {
let ms = item.started_at.elapsed().as_millis();
let phase = (ms / SPINNER_FRAME_MS) as usize % SPINNER_FRAMES.len();
SPINNER_FRAMES[phase].to_string()
}
Some((ProgressStatus::Success, _)) => "✓".to_string(),
Some((ProgressStatus::Failed, _)) => "✗".to_string(),
Some((ProgressStatus::Cancelled, _)) => "⊘".to_string(),
};
let percent_suffix = item.percent.map(|p| format!(" {p}%")).unwrap_or_default();
let label: String = item
.label
.chars()
.take(MAX_WIDTH as usize - percent_suffix.chars().count() - 6)
.collect();
let body_text = format!(" {glyph} {label}{percent_suffix}");
let inner_w = body_text.chars().count() as u16;
let box_w = (inner_w + 2)
.min(MAX_WIDTH)
.min(area.width.saturating_sub(2));
let box_h: u16 = 3;
if *y_bottom < area.y + box_h {
return false;
}
let y = *y_bottom - box_h;
let x = max_x_right.saturating_sub(box_w);
let rect = Rect {
x,
y,
width: box_w,
height: box_h,
};
let border_fg = match item.finished {
None => t.cyan,
Some((ProgressStatus::Success, _)) => t.green,
Some((ProgressStatus::Failed, _)) => t.red,
Some((ProgressStatus::Cancelled, _)) => t.comment,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_fg).bg(t.bg_darker))
.style(Style::default().bg(t.bg_darker));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let line = Line::from(Span::styled(
body_text,
Style::default().fg(t.fg).bg(t.bg_darker),
));
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(t.bg_darker)),
inner,
);
*y_bottom = y;
true
}