//! Top-level TUI render pipeline: layouts the terminal into chat / input //! / status regions, dispatches overlay rendering, and floats toast //! notifications over the top-right corner. pub mod chat; pub mod markdown; pub mod status; pub mod theme; pub mod workflow; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use theme::Theme; /// Top-level render entry point called once per TUI frame. /// /// Flow: split the frame into main / input / status regions → if an /// overlay is active, render it inside the main region; otherwise render /// the chat transcript → always render the input bar and status bar → /// overlay toast notifications in the top-right corner. /// /// Why: a single function owns the layout so every state change /// re-renders the whole UI from a known template. /// /// Return: nothing; writes directly into `frame`. pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let area = frame.area(); let show_todo = !state.misc.todo_content.is_empty() || state.misc.overlay == crate::app::state::types::Overlay::Todo; let (main_area, todo_area) = if show_todo && area.width > 60 { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(40), Constraint::Length(30), ]) .split(area); (h_chunks[0], Some(h_chunks[1])) } else { (area, None) }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(3), Constraint::Length(3), Constraint::Length(1), ]) .split(main_area); let chat_area = chunks[0]; let input_area = chunks[1]; let status_area = chunks[2]; if state.misc.overlay.is_active() && state.misc.overlay != crate::app::state::types::Overlay::Todo { let overlay = state.misc.overlay; render_overlay(frame, chat_area, overlay, state); } else { render_main_panel(frame, chat_area, state); } render_input_bar(frame, input_area, state); status::draw_status_bar(frame, status_area, state); if let Some(todo_rect) = todo_area { render_todo_panel(frame, todo_rect, state); } // Toast notifications at top-right (like Hyprland) render_toasts(frame, state); } fn render_todo_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let block = Block::default() .title(" Todo ") .borders(Borders::ALL) .border_style(Style::default().fg(Theme::PRIMARY)) .style(Style::default().bg(Theme::BG)); let content = if state.misc.todo_content.is_empty() { "No tasks yet." } else { &state.misc.todo_content }; let paragraph = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { chat::draw_chat(frame, area, state); } /// Render the active modal overlay (Help, Settings, Workflow, Bash, Editor, etc.). /// /// Flow: compute a centered sub-area → clear it to make the rest of the /// frame visible behind → match on the Overlay variant to pick the /// panel's title, content Lines, and styling → render as a Paragraph or /// delegate to a specialized drawer (e.g. `workflow::draw_workflow_panel`). /// /// Why: each Overlay variant has its own data sources (settings, /// session_runtime, app_config) and its own visual treatment, so they /// are dispatched individually rather than table-driven. /// /// Return: nothing; draws directly into `frame`. fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) { let overlay_area = centered_rect(area, 70, 60); frame.render_widget(Clear, overlay_area); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::PRIMARY)) .style(Style::default().bg(Theme::BG)); match overlay { crate::app::state::types::Overlay::None => {} crate::app::state::types::Overlay::Help => { let block = block.title(" Help "); let content = crate::resources::HELP_TEXT; let paragraph = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Settings => { let block = block.title(" Settings "); let lines = vec![ Line::from(Span::styled( format!("Provider: {}", state.settings.provider), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!("Model: {}", state.settings.model), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!("Max tokens: {}", state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!("Temperature: {}", state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!("Internet mode: {:?}", state.settings.internet_mode), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!("Review enabled: {}", state.settings.review_enabled), Style::default().fg(Theme::TEXT), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Bash => { let block = block.title(" Bash "); let lines: Vec = state.session_runtime.as_ref().map(|r| { r.bash_jobs.iter().map(|job| { Line::from(Span::styled( format!("[{}] {} - {}", job.id, job.command, if job.running { "running" } else { "done" }), Style::default().fg(Theme::TEXT), )) }).collect() }).unwrap_or_default(); let paragraph = if lines.is_empty() { Paragraph::new(Line::from(Span::styled( "No active bash jobs", Style::default().fg(Theme::DIM), ))).block(block) } else { Paragraph::new(lines).block(block) }; frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::QuitConfirm => { let block = block .title(" Quit ") .border_style(Style::default().fg(Theme::ERROR)); let lines = vec![ Line::from(Span::styled( "Are you sure you want to quit?", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( "Press Enter to confirm, Esc to cancel.", Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Workflow => { workflow::draw_workflow_panel(frame, overlay_area, state); } crate::app::state::types::Overlay::KeyInput => { let block = block.title(" Input "); let input_text = &state.input.buffer; let display = if input_text.is_empty() { "Type your input..." } else { input_text.as_str() }; let paragraph = Paragraph::new(display) .block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Editor => { let block = block.title(" Editor "); let lines = vec![ Line::from(Span::styled( "Editor Mode", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( "Current input buffer:", Style::default().fg(Theme::DIM), )), Line::from(Span::styled( &state.input.buffer, Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( format!("Cursor at position {} / {}", state.input.cursor, state.input.buffer.len()), Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Effort => { let block = block.title(" Effort Level "); let levels = crate::app::mode::effort::EFFORT_LEVELS; let current_idx = crate::app::mode::effort::current_effort(state); let mut lines: Vec = levels.iter().enumerate().map(|(i, l)| { let selected = i == current_idx; Line::from(Span::styled( if selected { format!("> {} (current)", l) } else { format!(" {}", l) }, if selected { Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }, )) }).collect(); lines.insert(0, Line::from(Span::styled( "Use arrow keys to change effort level", Style::default().fg(Theme::DIM), ))); let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Mcp => { let block = block.title(" MCP Servers "); let lines: Vec = vec![ Line::from(Span::styled( "MCP Server Management", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( format!("Session dir: {}", state.session_dir.display()), Style::default().fg(Theme::DIM), )), Line::from(Span::styled( "No MCP servers configured.", Style::default().fg(Theme::INFO), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( "Press Ctrl+P to configure provider settings.", Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Todo => { let block = block.title(" Tasks "); let msg_count = state.transcript_cache.messages.len(); let lines = vec![ Line::from(Span::styled( "Session Activity", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "", Style::default(), )), Line::from(Span::styled( format!("Messages: {}", msg_count), Style::default().fg(Theme::INFO), )), Line::from(Span::styled( format!("Overlay: {:?}", state.misc.overlay), Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Rewind => { let block = block.title(" Rewind "); let mut lines: Vec = vec![ Line::from(Span::styled( "Session History", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "", Style::default(), )), ]; let messages = &state.transcript_cache.messages; if messages.is_empty() { lines.push(Line::from(Span::styled( "No messages in current session.", Style::default().fg(Theme::DIM), ))); } else { let start = if messages.len() > 5 { messages.len() - 5 } else { 0 }; for msg in &messages[start..] { let role_str = match msg.role { crate::dto::chat::message::Role::User => "User", crate::dto::chat::message::Role::Assistant => "Asst", crate::dto::chat::message::Role::System => "Sys", crate::dto::chat::message::Role::Tool => "Tool", }; let preview: String = msg.content.chars().take(60).collect(); lines.push(Line::from(Span::styled( format!("[{}] {}", role_str, preview), Style::default().fg(if matches!(msg.role, crate::dto::chat::message::Role::User) { Theme::INFO } else { Theme::TEXT }), ))); } if messages.len() > 5 { lines.push(Line::from(Span::styled( format!("... and {} more messages", messages.len() - 5), Style::default().fg(Theme::DIM), ))); } } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Learning => { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(40), // Left: List Constraint::Percentage(60), // Right: Details ]) .split(overlay_area); let left_block = Block::default() .title(" Lessons ") .borders(Borders::ALL) .border_style(Style::default().fg(Theme::PRIMARY)) .style(Style::default().bg(Theme::BG)); let right_block = Block::default() .title(" Lesson Details ") .borders(Borders::ALL) .border_style(Style::default().fg(Theme::PRIMARY)) .style(Style::default().bg(Theme::BG)); let items = crate::app::mode::learning::get_learning_items(state); let mut left_lines = Vec::new(); if items.is_empty() { left_lines.push(Line::from(Span::styled( "No lessons found.", Style::default().fg(Theme::DIM), ))); } else { for (i, item) in items.iter().enumerate() { let is_selected = i == state.misc.selected_index; let prefix = if is_selected { "▸ " } else { " " }; let (label, style) = match item { crate::app::mode::learning::LearningItem::Pending { name, .. } => { ( format!("{}[Pending] {}", prefix, name), if is_selected { Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::WARNING) } ) } crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => { let status = if lifecycle == "stale" { "Stale" } else { "Active" }; ( format!("{}[{}] {}", prefix, status, name), if is_selected { Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) } ) } }; left_lines.push(Line::from(Span::styled(label, style))); } } // Scroll the left list so the selected index is always visible let max_lines = h_chunks[0].height.saturating_sub(2) as usize; let selected = state.misc.selected_index; let start_idx = if selected >= max_lines { selected - max_lines + 1 } else { 0 }; let end_idx = (start_idx + max_lines).min(left_lines.len()); let visible_lines = if left_lines.is_empty() { Vec::new() } else { left_lines[start_idx..end_idx].to_vec() }; let left_paragraph = Paragraph::new(visible_lines).block(left_block); frame.render_widget(left_paragraph, h_chunks[0]); let mut right_lines = Vec::new(); if let Some(item) = items.get(selected) { match item { crate::app::mode::learning::LearningItem::Pending { name, content, scope, confidence } => { right_lines.push(Line::from(Span::styled("Name:", Style::default().fg(Theme::DIM)))); right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); right_lines.push(Line::from("")); right_lines.push(Line::from(Span::styled("Status: Pending Approval", Style::default().fg(Theme::WARNING)))); right_lines.push(Line::from(Span::styled(format!("Scope: {}", scope), Style::default().fg(Theme::TEXT)))); right_lines.push(Line::from(Span::styled(format!("Confidence: {}", confidence), Style::default().fg(Theme::TEXT)))); right_lines.push(Line::from("")); right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::DIM)))); for line in content.lines() { right_lines.push(Line::from(Span::styled(line, Style::default().fg(Theme::TEXT)))); } right_lines.push(Line::from("")); right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); right_lines.push(Line::from(Span::styled(" [Enter] or [a] to Accept", Style::default().fg(Theme::SUCCESS)))); right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[r] to Reject", Style::default().fg(Theme::ERROR)))); } crate::app::mode::learning::LearningItem::Stored { name, content, lifecycle, scope, description } => { right_lines.push(Line::from(Span::styled("Name:", Style::default().fg(Theme::DIM)))); right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); right_lines.push(Line::from("")); let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS }; right_lines.push(Line::from(Span::styled(format!("Status: {}", lifecycle), Style::default().fg(status_color)))); right_lines.push(Line::from(Span::styled(format!("Scope: {}", scope), Style::default().fg(Theme::TEXT)))); right_lines.push(Line::from(Span::styled(format!("Description: {}", description), Style::default().fg(Theme::TEXT)))); right_lines.push(Line::from("")); right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::DIM)))); for line in content.lines() { right_lines.push(Line::from(Span::styled(line, Style::default().fg(Theme::TEXT)))); } right_lines.push(Line::from("")); right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[d] to Delete Lesson", Style::default().fg(Theme::ERROR)))); } } } else { right_lines.push(Line::from(Span::styled( "Select a lesson on the left to see details.", Style::default().fg(Theme::DIM), ))); } let right_paragraph = Paragraph::new(right_lines).block(right_block).wrap(Wrap { trim: false }); frame.render_widget(right_paragraph, h_chunks[1]); } crate::app::state::types::Overlay::Usage => { let block = block.title(" Usage "); let runtime = state.session_runtime.as_ref(); let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.map(|r| { (r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start) }).unwrap_or((0, 0, 0, 0, 0)); let (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| { (r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews) }).unwrap_or((0, 0, 0, 0)); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let hours = elapsed_ms / 3600000; let minutes = (elapsed_ms % 3600000) / 60000; let seconds = (elapsed_ms % 60000) / 1000; let total_tokens = tokens_in.saturating_add(tokens_out); let self_learning_total = review_tokens; let main_tokens = total_tokens.saturating_sub(self_learning_total); let lines = vec![ Line::from(Span::styled( "Usage Dashboard", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), )), Line::from(Span::styled("", Style::default())), Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))), Line::from(Span::styled( format!(" Main agent tokens: {}", main_tokens), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Self-learning tokens: {}", self_learning_total), Style::default().fg(Theme::INFO), )), Line::from(Span::styled( format!(" Total tokens: {}", total_tokens), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( format!(" API calls: {}", api_calls), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled("", Style::default())), Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))), Line::from(Span::styled( format!(" Edits this session: {}", edit_count), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Reviews completed: {}", review_count), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Lessons found: {}", lesson_count), Style::default().fg(Theme::INFO), )), Line::from(Span::styled( format!(" Consecutive empty revs: {}", consec_empty), Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }), )), Line::from(Span::styled("", Style::default())), Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))), Line::from(Span::styled( format!(" Duration: {}h {}m {}s", hours, minutes, seconds), Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::Loading => { let block = block.title(" Loading "); let content = "Processing, please wait..."; let paragraph = Paragraph::new(content) .block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::ModelSelector => { let block = block.title(" Model Selector "); let mut lines: Vec = vec![ Line::from(Span::styled( format!("Current: {} / {}", state.settings.provider, state.settings.model), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), Line::from(Span::styled("", Style::default())), Line::from(Span::styled("Providers:", Style::default().fg(Theme::DIM))), ]; let providers: Vec<(&String, &crate::model::app_config::ProviderConfig)> = state.app_config.providers.iter().collect(); for (i, (name, cfg)) in providers.iter().enumerate() { let is_current = *name == &state.settings.provider; let is_selected = i == state.misc.selected_index; let prefix = if is_selected { "▸ " } else { " " }; let model_str = cfg.default_model.as_deref().unwrap_or("(any)"); let label = format!("{}{} ({})", prefix, name, model_str); let style = if is_current { Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else if is_selected { Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) } else { Style::default().fg(Theme::TEXT) }; lines.push(Line::from(Span::styled(label, style))); } lines.push(Line::from(Span::styled("", Style::default()))); lines.push(Line::from(Span::styled( "↑↓ navigate · Enter select · Esc close · /model add to add", Style::default().fg(Theme::DIM), ))); let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } crate::app::state::types::Overlay::ClearConfirm => { let block = block.title(" Clear Transcript "); let lines = vec![ Line::from(Span::styled( "Clear all messages from the transcript?", Style::default().fg(Theme::TEXT), )), Line::from(Span::styled("", Style::default())), Line::from(Span::styled( "Enter to confirm · Esc to cancel", Style::default().fg(Theme::DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } } } /// Render the bottom input bar including the autocomplete dropdown above it. /// /// Flow: if autocomplete is open and has candidates, draw a borderless /// dropdown anchored just above the input bar showing up to 10 candidates /// with the current selection highlighted → then render the prompt, /// placeholder, and the buffer with a single-character highlight under /// the cursor position. /// /// Why: the cursor highlight is drawn by splitting the buffer at /// `state.input.cursor` and styling one character (or trailing space) /// with the highlight color, since ratatui Paragraph does not expose a /// native cursor widget. /// /// Return: nothing; draws directly into `frame` at `area`. fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { // Render autocomplete dropdown if visible if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { let n = state.input.autocomplete_candidates.len().min(10) as u16; let dropdown_height = n + 2; // border + items let dropdown_area = Rect { x: area.x, y: area.y.saturating_sub(dropdown_height), width: area.width.min(40), height: dropdown_height, }; let dropdown_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(" Commands "); let mut lines: Vec = Vec::new(); let selected = state.input.autocomplete_idx; for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) { let prefix = if i == selected { "▸ " } else { " " }; let style = if i == selected { Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) } else { Style::default().fg(Theme::TEXT) }; let label = format!("{}{}", prefix, candidate); lines.push(Line::from(Span::styled(label, style))); } let dropdown = Paragraph::new(lines).block(dropdown_block); frame.render_widget(dropdown, dropdown_area); } let block = Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Theme::BORDER)); let input_text = &state.input.buffer; let cursor_pos = state.input.cursor; let prompt = Span::styled( "> ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), ); let mut spans = vec![prompt]; if input_text.is_empty() { spans.push(Span::styled( "Type a message...", Style::default().fg(Theme::DIM), )); } else { let (before, after) = input_text.split_at(cursor_pos); spans.push(Span::raw(before.to_string())); spans.push(Span::styled( if after.is_empty() { " " } else { &after[..1] }, Style::default().bg(Theme::HIGHLIGHT).fg(Theme::BG), )); if after.len() > 1 { spans.push(Span::raw(after[1..].to_string())); } } let line = Line::from(spans); let paragraph = Paragraph::new(line).block(block); frame.render_widget(paragraph, area); } /// Render active toasts as a floating stack at top-right of the terminal. /// Each toast auto-expires after its lifetime_ms. Max 4 visible at once. fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let now_ms = chrono::Utc::now().timestamp_millis(); let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter() .filter(|t| !t.expired(now_ms)) .collect(); if active.is_empty() { return; } let area = frame.area(); let toast_w: u16 = 45; let x = area.width.saturating_sub(toast_w).saturating_sub(2); let mut y: u16 = 1; for toast in active.iter().rev().take(4) { let line_count = toast.message.lines().count().max(1) as u16; let h = line_count + 2; // border top + border bottom let toast_area = Rect { x, y, width: toast_w, height: h }; if toast_area.bottom() > area.height { break; } frame.render_widget(ratatui::widgets::Clear, toast_area); let border_color = match toast.kind { crate::app::state::types::ToastKind::Success => Theme::SUCCESS, crate::app::state::types::ToastKind::Warning => Theme::WARNING, crate::app::state::types::ToastKind::Error => Theme::ERROR, crate::app::state::types::ToastKind::Info => Theme::INFO, crate::app::state::types::ToastKind::Lesson => Theme::PRIMARY, }; let block = ratatui::widgets::Block::default() .borders(ratatui::widgets::Borders::ALL) .border_style(ratatui::style::Style::default().fg(border_color)) .style(ratatui::style::Style::default().bg(ratatui::style::Color::Black)); let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str()) .block(block) .wrap(ratatui::widgets::Wrap { trim: false }); frame.render_widget(paragraph, toast_area); y = y.saturating_add(h).saturating_add(1); } } fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2; let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2; Rect { x: area.x.saturating_add(x_pad), y: area.y.saturating_add(y_pad), width: area.width.saturating_sub(x_pad * 2).max(40), height: area.height.saturating_sub(y_pad * 2).max(10), } }