zinc-wallet-cli 0.4.0

Agent-first Bitcoin + Ordinals CLI wallet with account-based taproot ordinals + native segwit payment addresses (optional human mode)
use crate::ui::widgets::shared::centered_rect;
use crate::ui::ZincTheme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};

pub struct PasswordModal<'a> {
    pub input: &'a str,
    pub theme: &'a ZincTheme,
    pub error: Option<&'a str>,
}

impl Widget for PasswordModal<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        Block::default()
            .style(Style::default().bg(self.theme.surface_base))
            .render(area, buf);

        let modal_area = centered_rect(44, 14, area);
        let block = Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Plain)
            .style(Style::default().bg(self.theme.surface_glass))
            .border_style(Style::default().fg(self.theme.border));

        let inner_area = block.inner(modal_area);
        block.render(modal_area, buf);

        let title = Line::from(vec![
            Span::styled(
                " ZINC ",
                Style::default()
                    .fg(self.theme.charcoal)
                    .bg(self.theme.accent)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  ·  "),
            Span::styled(
                "UNLOCK WALLET",
                Style::default()
                    .fg(self.theme.text_primary)
                    .add_modifier(Modifier::BOLD),
            ),
        ]);
        Paragraph::new(title).alignment(Alignment::Center).render(
            Rect::new(modal_area.x + 1, modal_area.y + 1, modal_area.width - 2, 1),
            buf,
        );

        let hint = "Enter your wallet password";
        Paragraph::new(Span::styled(
            hint,
            Style::default()
                .fg(self.theme.text_muted)
                .add_modifier(Modifier::ITALIC),
        ))
        .alignment(Alignment::Center)
        .render(
            Rect::new(modal_area.x + 1, modal_area.y + 4, modal_area.width - 2, 1),
            buf,
        );

        let input_width = 24.min(inner_area.width.saturating_sub(4));
        let input_area = Rect::new(
            inner_area.x + (inner_area.width - input_width) / 2,
            inner_area.y + 6,
            input_width,
            3,
        );

        let input_block = Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Plain)
            .style(Style::default().bg(self.theme.charcoal))
            .border_style(Style::default().fg(self.theme.border));

        let input_inner = input_block.inner(input_area);
        input_block.render(input_area, buf);

        if self.input.is_empty() {
            let cursor_hint = "·";
            Paragraph::new(Span::styled(
                cursor_hint,
                Style::default()
                    .fg(self.theme.text_muted)
                    .add_modifier(Modifier::DIM),
            ))
            .alignment(Alignment::Center)
            .render(input_inner, buf);
        } else {
            let masked = "*".repeat(self.input.len());
            let display_text = format!("{:^width$}", masked, width = input_inner.width as usize);
            Paragraph::new(Span::styled(
                display_text,
                Style::default()
                    .fg(self.theme.cream)
                    .bg(self.theme.charcoal)
                    .add_modifier(Modifier::BOLD),
            ))
            .alignment(Alignment::Center)
            .render(input_inner, buf);
        }

        let key_hint = Line::from(vec![
            Span::styled("[", Style::default().fg(self.theme.border)),
            Span::styled(
                "ENTER",
                Style::default()
                    .fg(self.theme.cream)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled("]", Style::default().fg(self.theme.border)),
            Span::styled(" to unlock   ", Style::default().fg(self.theme.text_muted)),
            Span::styled("[", Style::default().fg(self.theme.border)),
            Span::styled(
                "ESC",
                Style::default()
                    .fg(self.theme.cream)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled("]", Style::default().fg(self.theme.border)),
            Span::styled(" to quit", Style::default().fg(self.theme.text_muted)),
        ]);
        Paragraph::new(key_hint)
            .alignment(Alignment::Center)
            .render(
                Rect::new(modal_area.x + 1, modal_area.y + 11, modal_area.width - 2, 1),
                buf,
            );

        if let Some(err) = self.error {
            let err_style = Style::default()
                .fg(self.theme.danger)
                .add_modifier(Modifier::BOLD);
            Paragraph::new(Line::from(vec![
                Span::styled("", err_style),
                Span::styled(err, err_style),
            ]))
            .alignment(Alignment::Center)
            .render(
                Rect::new(modal_area.x + 1, modal_area.y + 13, modal_area.width - 2, 1),
                buf,
            );
        }
    }
}

pub struct ExitOverlay<'a> {
    pub theme: &'a ZincTheme,
    pub tick: u64,
}

impl Widget for ExitOverlay<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        Block::default()
            .style(Style::default().bg(self.theme.charcoal))
            .render(area, buf);

        let area = centered_rect(42, 22, area);
        let block = Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Plain)
            .border_style(Style::default().fg(self.theme.border))
            .style(Style::default().bg(self.theme.surface_elevated));

        let inner_area = block.inner(area);
        block.render(area, buf);

        let spinner = create_loading_spinner(self.tick, self.theme);
        Paragraph::new(spinner).alignment(Alignment::Center).render(
            Rect::new(
                inner_area.x,
                inner_area.y + (inner_area.height / 2),
                inner_area.width,
                1,
            ),
            buf,
        );

        let subtext = "Cleaning up sessions and tasks";
        Paragraph::new(subtext)
            .alignment(Alignment::Center)
            .style(
                Style::default()
                    .fg(self.theme.text_muted)
                    .bg(self.theme.surface_elevated)
                    .add_modifier(Modifier::DIM),
            )
            .render(
                Rect::new(
                    inner_area.x,
                    inner_area.y + (inner_area.height / 2) + 1,
                    inner_area.width,
                    1,
                ),
                buf,
            );
    }
}

fn create_loading_spinner(tick: u64, theme: &ZincTheme) -> Line<'_> {
    let frames = ["", "", "", "", "", "", "", "", "", ""];
    let frame = frames[(tick as usize) % frames.len()];

    let track_width: usize = 12;
    let head_pos = (tick % (track_width as u64 * 2)) as usize;
    let pos = if head_pos < track_width {
        head_pos
    } else {
        track_width * 2 - head_pos - 1
    };

    let mut spans = vec![Span::styled(
        frame,
        Style::default()
            .fg(theme.accent)
            .add_modifier(Modifier::BOLD),
    )];

    spans.push(Span::raw("  "));

    for i in 0..track_width {
        let ch = if i == pos { "" } else { "" };
        let color = if i == pos {
            theme.selection
        } else if i > pos && i <= pos + 3 {
            Color::Rgb(245, 158 - ((i - pos) as u8 * 30), 11)
        } else {
            theme.border
        };
        spans.push(Span::styled(ch, Style::default().fg(color)));
    }

    spans.push(Span::styled(
        "  exiting",
        Style::default()
            .fg(theme.text_muted)
            .add_modifier(Modifier::ITALIC),
    ));

    Line::from(spans)
}