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-actionsjobs 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
qrcodecrate 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.

Top comments (0)