clauth 0.6.2

Simple Claude Code account switcher and usage monitor
//! Transient toast stack — top-right, floating, no border.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};

use super::super::app::{App, Toast, ToastKind};
use super::super::theme;

pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
    if app.toasts.is_empty() {
        return;
    }
    let toasts: Vec<&Toast> = app.toasts.iter().collect();

    // Max content width: min(36, terminal_width − 5); the −5 budgets the chrome —
    // `┃ ` (2) + 1-cell right pad inside the toast + 2-cell inset from the edge.
    let content_cap = 36_u16.min(area.width.saturating_sub(5));

    // Widest natural line across all toasts, clamped to cap — drives column width and wrap budget.
    let max_content_width = toasts
        .iter()
        .flat_map(|t| t.body.lines().map(|l| l.chars().count() as u16))
        .max()
        .unwrap_or(0)
        .min(content_cap);

    // +3: `┃ ` (bar cell + space) on the left, 1 trailing pad cell on the right.
    // The pad cell carries no text, so the Paragraph's `bg_sunken` base fills it.
    let col_width = max_content_width + 3;

    let x = area.x + area.width.saturating_sub(col_width + 2);
    let mut row = area.y + 2;

    for toast in &toasts {
        let color = match toast.kind {
            ToastKind::Info => theme::info_color(),
            ToastKind::Success => theme::success_color(),
            ToastKind::Warning => theme::warning_color(),
            ToastKind::Danger => theme::danger_color(),
        };
        let bar_style = Style::default().fg(color).bg(theme::bg_sunken());
        let title_style = Style::default()
            .fg(theme::text_color())
            .bg(theme::bg_sunken())
            .add_modifier(Modifier::BOLD);
        let detail_style = Style::default()
            .fg(theme::text_dim_color())
            .bg(theme::bg_sunken());

        let mut lines_iter = toast.body.lines();
        let first = lines_iter.next().unwrap_or("");

        let mut render_lines: Vec<Line<'_>> = Vec::new();
        for wrapped in word_wrap(first, max_content_width as usize) {
            render_lines.push(Line::from(vec![
                Span::styled("", bar_style),
                Span::styled(wrapped, title_style),
            ]));
        }
        for detail in lines_iter {
            for wrapped in word_wrap(detail, max_content_width as usize) {
                render_lines.push(Line::from(vec![
                    Span::styled("", bar_style),
                    Span::styled(wrapped, detail_style),
                ]));
            }
        }
        if render_lines.is_empty() {
            render_lines.push(Line::from(vec![Span::styled("", bar_style)]));
        }

        let height = render_lines.len() as u16;
        let rect = Rect {
            x,
            y: row,
            width: col_width,
            height,
        };
        if fits_in(area, rect) {
            // Glass pane: capture the bg currently beneath each cell, render the
            // toast (which paints a solid `bg_sunken` base), then re-blend each
            // cell's bg as `bg_sunken` at 75 % over what was beneath it.
            // `blend_over` no-ops on the compatible tier → solid `bg_sunken`.
            let buf = frame.buffer_mut();
            let mut beneath: Vec<ratatui::style::Color> =
                Vec::with_capacity((rect.width as usize) * (rect.height as usize));
            for cy in rect.y..rect.y + rect.height {
                for cx in rect.x..rect.x + rect.width {
                    let bg = buf
                        .cell((cx, cy))
                        .and_then(|c| c.style().bg)
                        .unwrap_or(theme::bg_sunken());
                    beneath.push(bg);
                }
            }

            // Clear wipes underlying symbols to spaces (the captured `beneath`
            // bg above is unaffected); without it, Paragraph leaves stray glyphs
            // in the pad/short-wrap cells it never writes.
            frame.render_widget(Clear, rect);
            frame.render_widget(
                Paragraph::new(render_lines).style(Style::default().bg(theme::bg_sunken())),
                rect,
            );

            let buf = frame.buffer_mut();
            let mut i = 0;
            for cy in rect.y..rect.y + rect.height {
                for cx in rect.x..rect.x + rect.width {
                    let glass = theme::blend_over(beneath[i], theme::bg_sunken(), 0.75);
                    if let Some(cell) = buf.cell_mut((cx, cy)) {
                        cell.set_bg(glass);
                    }
                    i += 1;
                }
            }
        }
        row += height;
    }
}

/// Soft-wrap `text` to at most `max_width` chars per visual line.
///
/// Splits on whitespace boundaries where possible; a single word longer than
/// `max_width` is emitted as its own line (hard-break fallback).  Returns at
/// least one element even for an empty input.
fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![text.to_owned()];
    }
    let mut lines: Vec<String> = Vec::new();
    let mut current = String::new();
    let mut current_len: usize = 0;

    for word in text.split_whitespace() {
        let word_len = word.chars().count();
        if current_len == 0 {
            if word_len > max_width {
                // Hard-break: word alone exceeds cap — emit in max_width-char chunks.
                let mut chars = word.chars();
                let mut chunk = String::new();
                let mut chunk_len = 0;
                for ch in chars.by_ref() {
                    chunk.push(ch);
                    chunk_len += 1;
                    if chunk_len == max_width {
                        lines.push(chunk.clone());
                        chunk.clear();
                        chunk_len = 0;
                    }
                }
                if !chunk.is_empty() {
                    current = chunk;
                    current_len = chunk_len;
                }
            } else {
                current.push_str(word);
                current_len = word_len;
            }
        } else if current_len + 1 + word_len <= max_width {
            current.push(' ');
            current.push_str(word);
            current_len += 1 + word_len;
        } else {
            lines.push(current.clone());
            current.clear();
            current_len = 0;
            if word_len > max_width {
                let mut chars = word.chars();
                let mut chunk = String::new();
                let mut chunk_len = 0;
                for ch in chars.by_ref() {
                    chunk.push(ch);
                    chunk_len += 1;
                    if chunk_len == max_width {
                        lines.push(chunk.clone());
                        chunk.clear();
                        chunk_len = 0;
                    }
                }
                if !chunk.is_empty() {
                    current = chunk;
                    current_len = chunk_len;
                }
            } else {
                current.push_str(word);
                current_len = word_len;
            }
        }
    }

    if !current.is_empty() {
        lines.push(current);
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

fn fits_in(outer: Rect, inner: Rect) -> bool {
    inner.x >= outer.x
        && inner.y >= outer.y
        && inner.x + inner.width <= outer.x + outer.width
        && inner.y + inner.height <= outer.y + outer.height
}

#[cfg(test)]
#[path = "../../../tests/inline/tui_render_toasts.rs"]
mod tests;