A 150-Line Rust CLI That Renders QR Codes in Your Terminal — Half-Block Characters Pack Two Modules per Cell
Tech

A 150-Line Rust CLI That Renders QR Codes in Your Terminal — Half-Block Characters Pack Two Modules per Cell

The "open this URL on your phone" problem: your laptop is showing a deploy preview, your phone is on the desk, and copying-pasting a long URL between them is awkward. ascii-qr "https://..." prints a QR code right in the terminal — your phone scans it from across the desk, you're done. 150 lines of Rust, single static binary, works over SSH and inside CI containers. Here's the trick that makes the rendered QR scannable: packing two QR modules into one terminal character with half-block Unicode. 🦀 Source on GitHub: https://github.com/sen-ltd/ascii-qr 📦 Build & run: see below Where the terminal beats a PNG "open this on my phone" — typing a URL on a phone is a pain, copy-paste between machines is awkward, and emailing yourself feels heavy. A terminal QR is one command and you scan it. SSH / CI boxes — no GUI, no clipboard, but you need to surface a one-time token URL or a deploy preview. Print the QR, read it. Deploy notifications — print preview URLs from gh-actions jobs without having to upload artifacts. The log is the artifact. The trick: half-block characters Terminal cells are roughly twice as tall as they are wide (~7×14 px in most fonts). Render one QR module per character cell and the result is rectangular — not square — and most phones struggle to scan it without rotation. Solution: pack two vertically-stacked QR modules into a single character cell using the half-block Unicode: Top module Bottom module Character Codepoint dark dark █ U+2588 FULL BLOCK dark light ▀ U+2580 UPPER HALF BLOCK light dark ▄ U+2584 LOWER HALF BLOCK light light (space) Each output line covers two QR-module rows. The result reads as a square in any reasonable monospace font, and phone scanners pick it up cleanly: let mut y = 0_i64; while y < total as i64 { for x in 0..total as i64 { let top = dark_at(x, y); let bot = dark_at(x, y + 1); out.push(match (top, bot) { (true, true) => '\u{2588}', // █ (true, false) => '\u{2580}', // ▀ (false, true) => '\u{2584}', // ▄ (false, false) => ' ', }); } out.push('\n'); y += 2; } y += 2 is the only thing the terminal-vs-pixel distinction asks of you. The qrcode crate does the hard math The Rust qrcode crate handles encoding (Reed-Solomon error correction, mask pattern selection, version sizing) in one line: use qrcode::{QrCode, EcLevel}; let code = QrCode::with_error_correction_level(b"hello", EcLevel::M)?; let width = code.width(); // module-grid side length let dark = code[(x, y)] == qrcode::Color::Dark; // (x, y) module Error correction levels trade off recovery vs. size: Level Max recoverable error Use case L 7% Clean displays M 15% Default, suitable for print Q 25% Could be partially obscured H 30% Logos overlaid, dirt, damage CLI exposes them as --ec l|m|q|h. Higher EC means more modules → larger rendered output: #[test] fn ec_high_makes_a_larger_qr_than_ec_low_for_same_input() { let lo = run("--ec l https://sen.ltd/portfolio/"); let hi = run("--ec h https://sen.ltd/portfolio/"); assert!(hi.lines().count() >= lo.lines().count()); } The quiet zone trade-off The QR spec recommends a 4-module-wide white border ("quiet zone") around every code so scanners can distinguish the code from the surrounding background. Without it, recognition rate drops. In a terminal that's expensive — a 4-module border around a v3 QR code adds 8 modules of empty space top and bottom. Compromise: #[arg(long, default_value_t = 2)] border: usize, Default 2 is enough for any reasonable scanner; perfectionists pass --border 4 and get spec-conformance. Pure rendering for surgical unit tests The renderer takes a closure for the dark-pixel lookup, so you can exercise it with synthetic grids without going through the qrcode encoder. That keeps unit tests fast and failure messages narrow: pub fn render<F>(width: usize, border: usize, theme: Theme, is_dark: F) -> String where F: Fn(usize, usize) -> bool, { /* ... */ } #[test] fn alternating_pattern_renders_expected_blocks() { // 2×2 checkerboard: // (0,0) D (1,0) L // (0,1) L (1,1) D // Pair 1: top=D, bot=L → ▀ ; top=L, bot=D → ▄ let s = render(2, 0, Theme::Dark, |x, y| (x + y) % 2 == 0); assert_eq!(s, "▀▄\n"); } A 1×1 grid with the single module dark → ▀\n. Edge cases drop out. The CLI side gets 7 black-box tests via assert_cmd: empty input → exit 2, stdin/positional both work, --invert flips the population of █ and spaces, --border changes the line count, etc. Tight release profile CLI binaries get judged by their distribution size. Same size-optimised profile that #137 hexview and #216 whentime use: [profile.release] strip = true lto = true codegen-units = 1 opt-level = "z" panic = "abort" qrcode + clap together come in at ~1.2 MB as an Alpine static binary — small enough to copy into any base image without a second thought. Takeaways Half-block characters (▀▄█) pack two QR modules into one terminal cell, fixing the rectangular-vs-square aspect mismatch and making the result phone-scannable. The qrcode crate hides Reed-Solomon, mask selection, and version sizing behind a one-line constructor. A closure-based pure renderer keeps the half-block translation testable without going through the encoder. Quiet-zone default 2 (vs spec-mandated 4) saves vertical space without breaking any modern scanner. Size-optimised release profile gets the binary to ~1.2 MB. Full source on GitHub — src/render.rs (the pure block-packer), src/main.rs (clap + qrcode wiring), tests/cli.rs (assert_cmd black-box). MIT. Third entry in the Rust CLI series: hexview (hex dump) → whentime (timezone CLI) → ascii-qr.

Read full story →

Comments

Loading comments…

Related