vkit 0.1.4

Fast Rust dev CLI: manage git worktrees, Node ports, run scripts, install & sync VS Code / Cursor extensions.
//! 共享 TUI 小组件与 toast 封装。

use std::time::Duration;

use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use ratatui_notifications::{
    Anchor, Animation, AutoDismiss, Level, Notification, Notifications, SlideDirection,
};

use crate::theme;

/// 列表光标行高亮符号(所有选择列表统一用 `❯ `)。
pub const LIST_CURSOR: &str = "";

/// Toast 默认停留时间。
pub const TOAST_TTL: Duration = Duration::from_secs(3);

/// 列表光标行高亮样式:橙色(highlight)前景加粗,作用于整行。
///
/// 各工具的选择列表统一用它作为 `List::highlight_style`,保证光标行观感一致。
pub fn list_cursor_style() -> Style {
    Style::default()
        .fg(theme::highlight())
        .add_modifier(Modifier::BOLD)
}

/// 复选框标记:勾选 `[x]`(selected 黄)、未勾选 `[ ]`(muted)。
pub fn checkbox(checked: bool) -> Span<'static> {
    let (mark, color) = if checked {
        ("[x] ", theme::selected())
    } else {
        ("[ ] ", theme::muted())
    };
    Span::styled(mark.to_string(), Style::default().fg(color))
}

/// 列表项标题样式:勾选 / 选中用 selected 黄,未选中弱化为 muted。
pub fn item_title_style(checked: bool) -> Style {
    if checked {
        Style::default().fg(theme::selected())
    } else {
        Style::default().fg(theme::muted())
    }
}

/// 列表项描述 / 次要信息样式:muted 且更弱(DIM)。
pub fn item_desc_style() -> Style {
    Style::default()
        .fg(theme::muted())
        .add_modifier(Modifier::DIM)
}

/// 状态行图标:成功 `✓`(success 绿)、失败 `✗`(warning)。
pub fn status_icon(ok: bool) -> Span<'static> {
    let (icon, color) = if ok {
        ("", theme::success())
    } else {
        ("", theme::warning())
    };
    Span::styled(icon.to_string(), Style::default().fg(color))
}

/// Toast 级别(映射到库的 Level,并用主题色覆盖边框)。
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub enum ToastKind {
    Info,
    Success,
    Warn,
    Error,
}

/// 推入一条右上角滑入 toast,约 3 秒后自动消失。
///
/// **何时用 toast**:列表操作结果、后台任务成败、与当前输入焦点无关的瞬时反馈
/// (如 Remove 失败、刷新完成、kill 结果)。
///
/// **不要用 toast**:表单校验 / 输入旁反馈(空字段、名称碰撞、下载失败回到输入框)
/// ——应贴在表单下方(如 worktree `name_error`、vsix `form_status`)。
pub fn push_toast(toasts: &mut Notifications, message: impl Into<String>, kind: ToastKind) {
    let message = message.into();
    if message.is_empty() {
        return;
    }
    let (level, color) = match kind {
        ToastKind::Info => (Level::Info, theme::accent()),
        ToastKind::Success => (Level::Info, theme::success()),
        ToastKind::Warn => (Level::Warn, theme::warning()),
        ToastKind::Error => (Level::Error, theme::danger()),
    };
    let Ok(notif) = Notification::new(message)
        .level(level)
        .anchor(Anchor::TopRight)
        .animation(Animation::Slide)
        .slide_direction(SlideDirection::FromRight)
        .auto_dismiss(AutoDismiss::After(TOAST_TTL))
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(color))
        .title_style(Style::default().fg(color).add_modifier(Modifier::BOLD))
        .build()
    else {
        return;
    };
    let _ = toasts.add(notif);
}

#[allow(dead_code)]
pub fn toast_info(toasts: &mut Notifications, message: impl Into<String>) {
    push_toast(toasts, message, ToastKind::Info);
}

pub fn toast_ok(toasts: &mut Notifications, message: impl Into<String>) {
    push_toast(toasts, message, ToastKind::Success);
}

pub fn toast_warn(toasts: &mut Notifications, message: impl Into<String>) {
    push_toast(toasts, message, ToastKind::Warn);
}

pub fn toast_error(toasts: &mut Notifications, message: impl Into<String>) {
    push_toast(toasts, message, ToastKind::Error);
}

/// 推进动画;有活跃 toast 时应在事件循环里定期调用。
pub fn tick_toasts(toasts: &mut Notifications, dt: Duration) {
    toasts.tick(dt);
}

/// 叠在主界面之上绘制 toast。
pub fn render_toasts(toasts: &mut Notifications, frame: &mut Frame<'_>, area: Rect) {
    toasts.render(frame, area);
}

/// 列表光标环形移动:在第一个按上跳到最后一个,在最后一个按下回到第一个。
///
/// 返回 `None` 表示列表为空。`delta` 通常是 `-1`(上)或 `1`(下)。
pub fn wrapping_index(current: usize, delta: isize, len: usize) -> Option<usize> {
    if len == 0 {
        return None;
    }
    let len_i = len as isize;
    Some((current as isize + delta).rem_euclid(len_i) as usize)
}

/// 公共输入框组件:所有工具(port / run / vsix)共用同一套输入 / 搜索样式。
///
/// - 空值时显示 `placeholder`(弱化色);聚焦时光标 `▏` 在最前,形成「幽灵提示」效果。
/// - 有值时显示正文色,聚焦时光标 `▏` 在末尾。
/// - 聚焦时边框为 accent 高亮,否则弱化。标题统一为 accent 加粗。
pub fn input_paragraph(
    value: &str,
    placeholder: &str,
    active: bool,
    title: &str,
) -> Paragraph<'static> {
    let cursor = Span::styled("".to_string(), Style::default().fg(theme::text()));
    let mut spans: Vec<Span<'static>> = Vec::new();

    if value.is_empty() {
        if active {
            spans.push(cursor);
        }
        spans.push(Span::styled(
            placeholder.to_string(),
            Style::default().fg(theme::muted()),
        ));
    } else {
        spans.push(Span::styled(
            value.to_string(),
            Style::default().fg(theme::text()),
        ));
        if active {
            spans.push(cursor);
        }
    }

    let border = if active {
        theme::accent()
    } else {
        theme::muted()
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(border))
        .title(Span::styled(
            title.to_string(),
            Style::default()
                .fg(theme::accent())
                .add_modifier(Modifier::BOLD),
        ));

    Paragraph::new(Line::from(spans)).block(block)
}

/// 公共按键提示组件:keycap 键帽风格 `▏ key ▏ label   …`。
///
/// 键名带深色底块(像键盘按键),后跟弱化的动作标签,条目间留空隙。
/// dashboard 与各工具共用,保证键位提示风格一致。
pub fn key_hint_line(items: &[(&str, &str)]) -> Line<'static> {
    let key_style = Style::default()
        .fg(theme::text())
        .bg(theme::surface())
        .add_modifier(Modifier::BOLD);
    let label_style = Style::default().fg(theme::muted());

    let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 3);
    for (index, (key, label)) in items.iter().enumerate() {
        if index > 0 {
            spans.push(Span::raw("   "));
        }
        spans.push(Span::styled(format!(" {key} "), key_style));
        spans.push(Span::styled(format!(" {label}"), label_style));
    }

    Line::from(spans)
}