stabiron 0.1.0

Beautiful terminal QR code generator — optimized for scanning crypto wallet addresses from iPhone
use arboard::Clipboard;
use chrono::Local;
use clap::Parser;
use colored::*;
use image::{ImageBuffer, Rgb, RgbImage};
use qrcode::{EcLevel, QrCode};
use std::process;

#[derive(Parser, Debug)]
#[command(
    name = "stabiron",
    about = "Beautiful terminal QR code generator — optimized for scanning crypto wallet addresses from iPhone",
    version,
    author
)]
struct Args {
    /// Text or address to encode (positional)
    input: Option<String>,

    /// Text or address to encode (flag form)
    #[arg(short = 'm', long = "message")]
    message: Option<String>,

    /// Read input from clipboard instead of argument
    #[arg(long = "clip")]
    clip: bool,

    /// Color theme: white (default), red, green, blue, gold
    #[arg(long = "color", default_value = "white")]
    color: String,

    /// Size: small / medium / large
    #[arg(long = "size", default_value = "medium")]
    size: String,

    /// Custom label shown above the QR code
    #[arg(long = "label")]
    label: Option<String>,

    /// Hide the label entirely
    #[arg(long = "no-label")]
    no_label: bool,

    /// Export QR code as PNG to current directory
    #[arg(long = "save")]
    save: bool,
}

// ── colour helpers ────────────────────────────────────────────────────────────

fn colored_block(s: &str, color: &str) -> ColoredString {
    match color {
        "red" => s.truecolor(220, 50, 50),
        "green" => s.truecolor(50, 205, 50),
        "blue" => s.truecolor(80, 160, 255),
        "gold" => s.truecolor(255, 200, 0),
        _ => s.truecolor(255, 255, 255),
    }
}

fn rgb_for_color(color: &str) -> [u8; 3] {
    match color {
        "red" => [220, 50, 50],
        "green" => [50, 205, 50],
        "blue" => [80, 160, 255],
        "gold" => [255, 200, 0],
        _ => [255, 255, 255],
    }
}

// ── size helpers ──────────────────────────────────────────────────────────────

/// Returns the module scale (pixels per QR module) for the terminal render.
/// Each terminal "cell" is 1 column wide, so we use half-block trick:
///   scale=1 → each module = 1 col, 1 row of ▀/▄ pairs
///   scale=2 → each module = 2 cols, 2 rows   (large)
fn scale_for_size(size: &str) -> usize {
    match size {
        "small" => 1,
        "large" => 2,
        _ => 1, // medium also uses 1 — difference is quiet-zone chars added
    }
}

/// Extra quiet-zone padding (in modules) added around the QR for each size.
fn padding_for_size(size: &str) -> usize {
    match size {
        "small" => 1,
        "large" => 4,
        _ => 2,
    }
}

// ── QR rendering ─────────────────────────────────────────────────────────────

/// Renders the QR matrix to terminal using unicode half-block characters.
/// ▀ = top dark / bottom light
/// ▄ = top light / bottom dark
/// █ = both dark
///   = both light (space)
/// This doubles vertical resolution and gives the camera crisp high-contrast blocks.
fn render_qr(matrix: &[Vec<bool>], size: &str, color: &str, term_width: usize) {
    let scale = scale_for_size(size);
    let pad = padding_for_size(size);

    // Build a padded grid (true = dark module)
    let qr_h = matrix.len();
    let qr_w = if qr_h > 0 { matrix[0].len() } else { 0 };
    let total_w = qr_w * scale + pad * 2;
    let total_h = qr_h * scale + pad * 2;

    if total_w > term_width.saturating_sub(2) && size != "small" {
        eprintln!(
            "{}",
            "  Warning: terminal may be too narrow — try --size small".yellow()
        );
    }

    // Helper: is pixel (col, row) in the padded grid dark?
    let is_dark = |col: usize, row: usize| -> bool {
        if col < pad || row < pad {
            return false;
        }
        let c = (col - pad) / scale;
        let r = (row - pad) / scale;
        if r >= qr_h || c >= qr_w {
            return false;
        }
        matrix[r][c]
    };

    // Print two rows per output line using half-block characters.
    // Iterate rows in steps of 2.
    let mut lines: Vec<String> = Vec::new();
    let mut row = 0usize;
    while row < total_h {
        let mut line = String::new();
        for col in 0..total_w {
            let top = is_dark(col, row);
            let bot = if row + 1 < total_h {
                is_dark(col, row + 1)
            } else {
                false
            };
            let ch = match (top, bot) {
                (true, true) => "",
                (true, false) => "",
                (false, true) => "",
                (false, false) => " ",
            };
            if top || bot {
                line.push_str(&colored_block(ch, color).to_string());
            } else {
                line.push(' ');
            }
        }
        lines.push(line);
        row += 2;
    }

    // Print with left indent of 2 spaces
    for line in &lines {
        println!("  {}", line);
    }
}

// ── PNG export ────────────────────────────────────────────────────────────────

fn save_png(matrix: &[Vec<bool>], color: &str, input: &str) -> Result<String, String> {
    let module_px: u32 = 12;
    let quiet: u32 = 4 * module_px;

    let qr_h = matrix.len() as u32;
    let qr_w = if qr_h > 0 { matrix[0].len() as u32 } else { 0 };
    let img_w = qr_w * module_px + quiet * 2;
    let img_h = qr_h * module_px + quiet * 2;

    let fg = rgb_for_color(color);
    let mut img: RgbImage = ImageBuffer::new(img_w, img_h);

    // Fill white background
    for px in img.pixels_mut() {
        *px = Rgb([255u8, 255u8, 255u8]);
    }

    // Draw dark modules
    for (r, row) in matrix.iter().enumerate() {
        for (c, &dark) in row.iter().enumerate() {
            if dark {
                let x0 = quiet + c as u32 * module_px;
                let y0 = quiet + r as u32 * module_px;
                for dy in 0..module_px {
                    for dx in 0..module_px {
                        img.put_pixel(x0 + dx, y0 + dy, Rgb(fg));
                    }
                }
            }
        }
    }

    // Add label strip at bottom (simple — no font rendering needed)
    // Just encode address hint in filename instead.
    let ts = Local::now().format("%Y%m%d_%H%M%S");
    let filename = format!("stabiron_{}.png", ts);

    // Also save a small Luma version for compatibility (some scanners prefer grayscale)
    let _ = input; // silence unused warning
    img.save(&filename)
        .map_err(|e| format!("Failed to save PNG: {}", e))?;

    Ok(filename)
}

// ── matrix extraction ─────────────────────────────────────────────────────────

fn qr_to_matrix(code: &QrCode) -> Vec<Vec<bool>> {
    let width = code.width();
    let colors = code.to_colors();
    let mut matrix = vec![vec![false; width]; width];
    for row in 0..width {
        for col in 0..width {
            matrix[row][col] = colors[row * width + col] == qrcode::Color::Dark;
        }
    }
    matrix
}

// ── terminal width ────────────────────────────────────────────────────────────

fn get_term_width() -> usize {
    if let Ok(val) = std::env::var("COLUMNS") {
        if let Ok(n) = val.parse::<usize>() {
            return n;
        }
    }
    // Default safe width
    120
}

// ── main ──────────────────────────────────────────────────────────────────────

fn main() {
    let args = Args::parse();

    // ── Resolve input ──────────────────────────────────────────────────────
    let input: String = if args.clip {
        match Clipboard::new() {
            Ok(mut cb) => match cb.get_text() {
                Ok(text) if !text.trim().is_empty() => text.trim().to_string(),
                Ok(_) => {
                    eprintln!("{}", "  Clipboard is empty. Copy something first.".red());
                    process::exit(1);
                }
                Err(e) => {
                    eprintln!("{}", format!("  Failed to read clipboard: {}", e).red());
                    process::exit(1);
                }
            },
            Err(e) => {
                eprintln!("{}", format!("  Clipboard unavailable: {}", e).red());
                process::exit(1);
            }
        }
    } else if let Some(ref m) = args.message {
        m.trim().to_string()
    } else if let Some(ref i) = args.input {
        i.trim().to_string()
    } else {
        eprintln!(
            "{}",
            "\n  stabiron — terminal QR code generator\n".bright_white().bold()
        );
        eprintln!("  Usage:");
        eprintln!("    stabiron \"<text or address>\"");
        eprintln!("    stabiron -m \"<text>\"");
        eprintln!("    stabiron --clip          (reads from clipboard)");
        eprintln!("    stabiron --help          (full options)\n");
        process::exit(0);
    };

    if input.is_empty() {
        eprintln!("{}", "  Input is empty. Nothing to encode.".red());
        process::exit(1);
    }

    // ── Validate color ─────────────────────────────────────────────────────
    let color = args.color.to_lowercase();
    let valid_colors = ["white", "red", "green", "blue", "gold"];
    if !valid_colors.contains(&color.as_str()) {
        eprintln!(
            "{}",
            format!(
                "  Unknown color '{}'. Choose: white, red, green, blue, gold",
                color
            )
            .red()
        );
        process::exit(1);
    }

    // ── Determine size with auto-upgrade ───────────────────────────────────
    let mut size = args.size.to_lowercase();
    if !["small", "medium", "large"].contains(&size.as_str()) {
        eprintln!(
            "{}",
            format!(
                "  Unknown size '{}'. Choose: small, medium, large",
                size
            )
            .red()
        );
        process::exit(1);
    }

    // Auto-upgrade if text is long and size is small/medium
    let len = input.len();
    if len > 200 && size != "large" {
        eprintln!(
            "{}",
            "  Input is long — auto-upgrading to --size large for reliability.".yellow()
        );
        size = "large".to_string();
    }

    // ── Generate QR code ───────────────────────────────────────────────────
    let code = match QrCode::with_error_correction_level(input.as_bytes(), EcLevel::H) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("{}", format!("  Failed to generate QR code: {}", e).red());
            process::exit(1);
        }
    };

    let matrix = qr_to_matrix(&code);
    let tw = get_term_width();

    // ── Label above ────────────────────────────────────────────────────────
    if !args.no_label {
        println!();
        if let Some(ref lbl) = args.label {
            println!("  {}", lbl.bright_white().bold());
        }
    } else {
        println!();
    }

    // ── Render QR ──────────────────────────────────────────────────────────
    render_qr(&matrix, &size, &color, tw);

    // ── Label / preview below ──────────────────────────────────────────────
    if !args.no_label {
        let preview = if input.len() > 48 {
            format!("{}", &input[..47])
        } else {
            input.clone()
        };
        println!();
        println!("  {}", preview.bright_black());
        println!();
    } else {
        println!();
    }

    // ── PNG export ─────────────────────────────────────────────────────────
    if args.save {
        match save_png(&matrix, &color, &input) {
            Ok(path) => println!("  {} {}", "Saved:".bright_green().bold(), path),
            Err(e) => eprintln!("{}", format!("  {}", e).red()),
        }
        println!();
    }
}