//! Terminal QR rendering for the onboarding wizard.
//!
//! Thin wrapper over `qrcode` 0.14's `Dense1x2` (half-block) renderer
//! gated on the project's existing terminal-capability flag. Each
//! Unicode half-block packs two QR "modules" (rows) into one terminal
//! cell, so the rendered QR is half as tall as the underlying matrix —
//! critical for fitting a 33x33 QR inside the wizard panel without
//! overflowing typical terminal heights.
//!
//! Why ASCII fallback returns `None` rather than a degraded
//! pseudo-QR: terminals that fail the `unicode_symbols` check
//! (Windows legacy conhost / `LANG=C` / `TERM=dumb`) render half-block
//! glyphs as `□` tofu, and a tofu QR is silently unscannable. Better
//! to show the URL as text and let the user paste it into a browser.
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
/// Render `data` as a Unicode QR code suitable for terminal display.
/// Each returned row is one terminal line; callers can pad / centre
/// each row inside the wizard panel without re-splitting the string.
///
/// Returns `None` when:
/// - `unicode_symbols == false` — caller MUST fall back to text URL,
/// - the QR encoder rejects the input (data too long for v40 / L).
///
/// Errors are coarse-grained on purpose: the only failure mode the
/// onboarding flow cares about is "show URL instead of QR." The exact
/// reason is irrelevant to the user and would just clutter the UI.
pub(super) fn render_for_terminal(data: &str, unicode_symbols: bool) -> Option<Vec<String>> {
if !unicode_symbols {
return None;
}
let code = QrCode::new(data.as_bytes()).ok()?;
let rendered = code
.render::<Dense1x2>()
.module_dimensions(1, 1)
.dark_color(Dense1x2::Dark)
.light_color(Dense1x2::Light)
.build();
Some(rendered.lines().map(|l| l.to_string()).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_returns_none_when_unicode_disabled() {
// ASCII-only / dumb terminals get None so the caller falls
// back to text URL rendering. Half-block glyphs (▀ / ▄ / █)
// render as `□` tofu on Windows conhost / `TERM=dumb` /
// `LANG=C` — a tofu QR would silently break the only thing
// this screen exists to do (let the user scan and log in).
assert!(render_for_terminal("https://example.com", false).is_none());
}
#[test]
fn render_produces_non_empty_block_for_short_url() {
// Smoke test with the shape of an actual atomgit short link.
// 32-char URL encodes to roughly a 25x25-module QR; Dense1x2
// packs two rows per cell so ~13 terminal rows. Use 8 as a
// safe floor — any non-trivial input should clear it.
let lines = render_for_terminal("https://acs.atomgit.com/s/AbC123", true)
.expect("Unicode-capable render must succeed for a short URL");
assert!(
lines.len() >= 8,
"expected at least 8 rows, got {}: {:#?}",
lines.len(),
lines
);
for (i, row) in lines.iter().enumerate() {
assert!(!row.is_empty(), "row {i} must not be empty");
}
}
#[test]
fn render_rows_have_uniform_char_width() {
// The wizard panel centres each row by computing its display
// width once and reusing the value across rows. Non-uniform
// widths (e.g. trimmed trailing whitespace) would render the
// QR as a parallelogram and break phone scanning. Pin
// uniform width so any future renderer swap can't silently
// regress this.
let lines =
render_for_terminal("https://example.com", true).expect("render must succeed");
let first = lines[0].chars().count();
for (i, row) in lines.iter().enumerate() {
assert_eq!(
row.chars().count(),
first,
"row {i} char-width differs from row 0 ({first}): {row:?}"
);
}
}
}