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::format_sats;
use crate::ui::ZincTheme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Span;
use tui_big_text::{BigText, PixelSize};

pub struct BalanceDisplay {
    pub hero_btc: String,
    pub _precise_btc: String,
    pub _sats: String,
}

impl BalanceDisplay {
    pub(crate) fn from_sats(confirmed: u64) -> Self {
        let btc = confirmed as f64 / 100_000_000.0;
        Self {
            hero_btc: format!("{btc:.8}"),
            _precise_btc: format!("{btc:.8}"),
            _sats: format!("{} sats", format_sats(confirmed)),
        }
    }
}

pub struct BalanceWidget<'a> {
    pub confirmed: u64,
    pub theme: &'a ZincTheme,
    pub ascii_mode: bool,
    pub is_hovered: bool,
    pub ordinals_address: Option<String>,
    pub payment_address: Option<String>,
}

impl Widget for BalanceWidget<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let display = BalanceDisplay::from_sats(self.confirmed);

        let panel = crate::ui::widgets::GlassPanel::new(self.theme).title(Span::styled(
            " BALANCE ",
            Style::default()
                .fg(if self.is_hovered {
                    self.theme.accent
                } else {
                    self.theme.text_primary
                })
                .add_modifier(Modifier::BOLD),
        ));

        let inner = panel.render(area, buf);

        if inner.height < 2 || inner.width < 8 {
            return;
        }

        let balance_height = if self.ascii_mode { 4 } else { 5 };
        let address_height = if self.ordinals_address.is_some() || self.payment_address.is_some() {
            2
        } else {
            0
        };
        let total_content = balance_height + address_height;

        if inner.height < total_content {
            self.render_balance_only(area, buf);
            return;
        }

        let vertical_margin = inner.height.saturating_sub(total_content) / 2;
        let balance_area = Rect {
            x: inner.x,
            y: inner.y + vertical_margin,
            width: inner.width,
            height: balance_height,
        };
        let address_area = Rect {
            x: inner.x,
            y: balance_area.y + balance_area.height,
            width: inner.width,
            height: address_height,
        };

        self.render_balance(display, balance_area, buf);

        if address_height > 0 {
            self.render_addresses(address_area, buf);
        }
    }
}

impl BalanceWidget<'_> {
    fn render_balance(&self, display: BalanceDisplay, area: Rect, buf: &mut Buffer) {
        if self.ascii_mode {
            let amount_str = format!("{} BTC", display.hero_btc);

            let mut hero_builder = BigText::builder();
            hero_builder
                .pixel_size(PixelSize::Quadrant)
                .centered()
                .style(Style::default().fg(self.theme.cream))
                .lines(vec![amount_str.into()]);

            hero_builder.build().render(area, buf);
        } else {
            let pixel_size = if area.width >= 120 {
                PixelSize::Quadrant
            } else {
                PixelSize::Sextant
            };

            let content_height = if pixel_size == PixelSize::Quadrant {
                4
            } else {
                5
            };
            let mut render_area = area;
            render_area.height = content_height.min(area.height);

            let mut hero_builder = BigText::builder();
            hero_builder
                .pixel_size(pixel_size)
                .centered()
                .style(
                    Style::default()
                        .fg(self.theme.cream)
                        .add_modifier(Modifier::BOLD),
                )
                .lines(vec![format!("{} BTC", display.hero_btc).into()]);
            hero_builder.build().render(render_area, buf);
        }
    }

    fn render_balance_only(&self, area: Rect, buf: &mut Buffer) {
        let display = BalanceDisplay::from_sats(self.confirmed);
        if self.ascii_mode {
            let mut hero_builder = BigText::builder();
            hero_builder
                .pixel_size(PixelSize::Quadrant)
                .centered()
                .style(Style::default().fg(self.theme.cream))
                .lines(vec![format!("{} BTC", display.hero_btc).into()]);
            hero_builder.build().render(area, buf);
        } else {
            let mut hero_builder = BigText::builder();
            hero_builder
                .pixel_size(PixelSize::Sextant)
                .centered()
                .style(
                    Style::default()
                        .fg(self.theme.cream)
                        .add_modifier(Modifier::BOLD),
                )
                .lines(vec![format!("{} BTC", display.hero_btc).into()]);
            hero_builder.build().render(area, buf);
        }
    }

    fn render_addresses(&self, area: Rect, buf: &mut Buffer) {
        let taproot_addr = self.ordinals_address.as_ref().map(|a| shorten_address(a));
        let payment_addr = self.payment_address.as_ref().map(|a| shorten_address(a));

        let taproot_str = taproot_addr.map(|a| format!("Taproot: {}", a));
        let payment_str = payment_addr.map(|a| format!("Payment: {}", a));

        let (line1, line2) = match (taproot_str, payment_str) {
            (Some(t), Some(p)) => {
                if area.width < 80 {
                    (Some(t), Some(p))
                } else {
                    (Some(format!("{}   ยท   {}", t, p)), None)
                }
            }
            (Some(t), None) => (Some(t), None),
            (None, Some(p)) => (Some(p), None),
            (None, None) => return,
        };

        let y_center = area.y + area.height / 2;

        if let Some(line) = line1 {
            let x = area.x + (area.width.saturating_sub(line.len() as u16)) / 2;
            buf.set_string(
                x,
                y_center.saturating_sub(u16::from(line2.is_none())),
                &line,
                Style::default()
                    .fg(self.theme.text_muted)
                    .add_modifier(Modifier::DIM),
            );
        }

        if let Some(line) = line2 {
            let x = area.x + (area.width.saturating_sub(line.len() as u16)) / 2;
            buf.set_string(
                x,
                y_center + 1,
                &line,
                Style::default()
                    .fg(self.theme.text_muted)
                    .add_modifier(Modifier::DIM),
            );
        }
    }
}

fn shorten_address(addr: &str) -> String {
    if addr.len() <= 16 {
        addr.to_string()
    } else {
        format!("{}...{}", &addr[..6], &addr[addr.len() - 4..])
    }
}