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 crossterm::event::{Event, KeyCode, MouseEvent};
use ratatui::layout::Rect;
use std::sync::Arc;
use tokio::sync::mpsc;

use zinc_core::ZincWallet;

use crate::cli::Cli;
use crate::ui::{INSCRIPTION_TILE_HEIGHT, INSCRIPTION_TILE_WIDTH};

use super::state::{DashboardEvent, DashboardState, SyncType};

pub fn handle_dashboard_event(
    event: DashboardEvent,
    state: &mut DashboardState,
    cli: &Cli,
    wallet_mutex: Option<&Arc<tokio::sync::Mutex<ZincWallet>>>,
    pending_session: &mut Option<crate::wallet_service::WalletSession>,
    event_tx: &mpsc::Sender<DashboardEvent>,
) {
    match event {
        DashboardEvent::Input(Event::Key(key)) => {
            if state.is_locked {
                match key.code {
                    KeyCode::Enter => {
                        let mut cli_auth = cli.clone();
                        cli_auth.password = Some(state.password_input.clone());
                        if let Ok(s) = crate::load_wallet_session(&cli_auth) {
                            *pending_session = Some(s);
                        } else {
                            state.auth_error = Some("Invalid password. Try again.".to_string());
                            state.password_input.clear();
                        }
                    }
                    KeyCode::Char(c) => state.password_input.push(c),
                    KeyCode::Backspace => {
                        state.password_input.pop();
                    }
                    KeyCode::Esc => {
                        state.is_quitting = true;
                    }
                    _ => {}
                }
                return;
            }

            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => {
                    state.is_quitting = true;
                }
                KeyCode::Char('s') => {
                    let _ = state.sync_tx.send(());
                }
                KeyCode::Tab => {
                    switch_account(state, wallet_mutex, (state.account_index + 1) % 5);
                }
                KeyCode::BackTab => {
                    let prev_account = if state.account_index == 0 {
                        4
                    } else {
                        state.account_index - 1
                    };
                    switch_account(state, wallet_mutex, prev_account);
                    let _ = state.sync_tx.send(());
                }
                KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
                    navigate_inscriptions(state, key.code);
                }
                _ => {}
            }
        }
        DashboardEvent::Input(Event::Mouse(mouse_event)) => {
            handle_mouse_event(mouse_event, state);
        }
        DashboardEvent::Tick => {
            state.tick_count += 1;
        }
        DashboardEvent::BalanceUpdated { confirmed, pending } => {
            state.confirmed_balance = confirmed;
            state.pending_balance = pending;

            if state.ordinals_address.is_none() || state.payment_address.is_none() {
                if let Some(ref w_mutex) = wallet_mutex {
                    let w_clone = Arc::clone(w_mutex);
                    let event_tx_clone = event_tx.clone();
                    tokio::spawn(async move {
                        let w = w_clone.lock().await;
                        let ordinals = w.peek_taproot_address(0).to_string();
                        let payment = w.peek_payment_address(0).map(|s| s.to_string());
                        let _ = event_tx_clone
                            .send(DashboardEvent::AddressesUpdated { ordinals, payment })
                            .await;
                    });
                }
            }
        }
        DashboardEvent::AddressesUpdated { ordinals, payment } => {
            state.ordinals_address = Some(ordinals);
            state.payment_address = payment;
        }
        DashboardEvent::InscriptionsUpdated(inscriptions) => {
            state.inscriptions = inscriptions;
        }
        DashboardEvent::SyncStarted(stype) => match stype {
            SyncType::Balance => state.is_syncing_balance = true,
            SyncType::Inscriptions => state.is_syncing_inscriptions = true,
        },
        DashboardEvent::SyncFinished(stype) => match stype {
            SyncType::Balance => state.is_syncing_balance = false,
            SyncType::Inscriptions => state.is_syncing_inscriptions = false,
        },
        _ => {}
    }
}

fn switch_account(
    state: &mut DashboardState,
    wallet_mutex: Option<&Arc<tokio::sync::Mutex<ZincWallet>>>,
    new_account: u32,
) {
    state.account_index = new_account;
    state.confirmed_balance = 0;
    state.pending_balance = 0;
    state.inscriptions.clear();
    state.image_cache.clear();
    state.failed_images.clear();
    state.ordinals_address = None;
    state.payment_address = None;

    if let Some(ref w_mutex) = wallet_mutex {
        let w_switch: Arc<tokio::sync::Mutex<zinc_core::ZincWallet>> = Arc::clone(w_mutex);
        let sync_tx = state.sync_tx.clone();
        tokio::spawn(async move {
            let mut w = w_switch.lock().await;
            let _ = w.set_active_account(new_account);
            let _ = sync_tx.send(());
        });
    }
}

fn navigate_inscriptions(state: &mut DashboardState, key_code: KeyCode) {
    if state.inscriptions.is_empty() {
        return;
    }

    let cols = state.gallery_cols.max(1);
    let current_idx = state.inscription_index;
    let (row, col) = (current_idx / cols, current_idx % cols);
    let total_rows = state.inscriptions.len().div_ceil(cols);

    let (new_row, new_col) = match key_code {
        KeyCode::Left if col > 0 => (row, col - 1),
        KeyCode::Right if col < cols - 1 && current_idx + 1 < state.inscriptions.len() => {
            (row, col + 1)
        }
        KeyCode::Up if row > 0 => (row - 1, col),
        KeyCode::Down if row < total_rows - 1 && current_idx + cols < state.inscriptions.len() => {
            (row + 1, col)
        }
        _ => (row, col),
    };

    let new_idx = new_row * cols + new_col;
    if new_idx < state.inscriptions.len() {
        state.inscription_index = new_idx;
    }
}

fn handle_mouse_event(mouse_event: MouseEvent, state: &mut DashboardState) {
    if state.is_locked {
        return;
    }

    let (x, y) = (mouse_event.column, mouse_event.row);
    state.mouse_pos = Some((x, y));

    let layout = super::state::DashboardLayout::new(Rect::new(0, 0, 120, 30));

    state.hover_balance = y >= layout.hero.y && y < layout.hero.y + layout.hero.height;

    state.hover_inscription_index = None;
    if y >= layout.main.y && y < layout.main.y + layout.main.height {
        let cols = state.gallery_cols.max(1);
        let tile_width = INSCRIPTION_TILE_WIDTH;
        let tile_height = INSCRIPTION_TILE_HEIGHT;

        let rel_y = y.saturating_sub(layout.main.y + 1);
        let rel_x = x.saturating_sub(layout.main.x + 1);

        let col = rel_x / tile_width;
        let row = rel_y / tile_height;

        let idx = (row as usize * cols) + col as usize;
        if idx < state.inscriptions.len() {
            state.hover_inscription_index = Some(idx);
        }
    }
}