Skip to main content

atomcode_tuix/render/
qr.rs

1//! Render a URL as a terminal-friendly QR code for the OAuth login
2//! flow (`/login`, `/codingplan`).
3//!
4//! Two styles, both Unicode:
5//! * `Dense1x2` (default): 1×2 modules per char using `▀▄█`.
6//!   ≈ 45 cols × 23 rows. Block elements (U+2580–U+259F) are
7//!   Unicode-Neutral width, so no terminal renders them at double
8//!   width. QR aspect stays 1:1 and scanners decode reliably under
9//!   any configuration.
10//! * `Braille` (opt-in): packs 2×4 modules into one U+2800–U+28FF
11//!   char. ≈ 23 cols × 12 rows — about half the size. But Braille
12//!   is Unicode-Ambiguous width and gets stretched 2× horizontally
13//!   on terminals that default ambiguous-width to double (iTerm2's
14//!   "Treat ambiguous-width characters as double width", on by
15//!   default in many profiles). Use only when you know your
16//!   terminal renders braille at single cell width.
17//!
18//! There is no ASCII rendering: at typical 1:2 monospace cell
19//! ratios, an ASCII QR with `module_dimensions(2, 1)` exceeds 90
20//! columns for any usable URL — wider than every realistic terminal
21//! window. Callers (`compose_login_chrome`) must short-circuit to
22//! URL-only output when `TerminalCaps::unicode_symbols` is false.
23
24use qrcode::render::unicode::Dense1x2;
25use qrcode::{Color, QrCode};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum QrStyle {
29    Braille,
30    Dense1x2,
31}
32
33/// Render `url` as a multi-line QR string. Returns `None` only when QR
34/// encoding itself fails (URL exceeds version 40 capacity ~4 KB —
35/// never the case for OAuth URLs in practice).
36pub fn render_login_qr(url: &str, style: QrStyle) -> Option<String> {
37    let code = QrCode::new(url.as_bytes()).ok()?;
38    let s = match style {
39        QrStyle::Braille => render_braille(&code),
40        QrStyle::Dense1x2 => code
41            .render::<Dense1x2>()
42            .dark_color(Dense1x2::Dark)
43            .light_color(Dense1x2::Light)
44            .quiet_zone(true)
45            .build(),
46    };
47    Some(s)
48}
49
50/// Visible column count of the widest line in `rendered`. Both styles
51/// emit exactly one terminal cell per char, so `chars().count()` is
52/// the correct cell count.
53pub fn block_cols(rendered: &str) -> usize {
54    rendered
55        .lines()
56        .map(|l| l.chars().count())
57        .max()
58        .unwrap_or(0)
59}
60
61/// Pack a QR matrix into Braille glyphs (2 cols × 4 rows of modules
62/// per char). Standard 4-module quiet zone is added before packing —
63/// scanners need the white margin to find the corner finder patterns.
64///
65/// Braille bit layout (Unicode 6.0):
66/// ```text
67///   col 0 row 0 → 0x01     col 1 row 0 → 0x08
68///   col 0 row 1 → 0x02     col 1 row 1 → 0x10
69///   col 0 row 2 → 0x04     col 1 row 2 → 0x20
70///   col 0 row 3 → 0x40     col 1 row 3 → 0x80
71/// ```
72fn render_braille(code: &QrCode) -> String {
73    const BRAILLE_BITS: [[u32; 4]; 2] = [
74        [0x01, 0x02, 0x04, 0x40], // left column
75        [0x08, 0x10, 0x20, 0x80], // right column
76    ];
77    const QUIET: usize = 4;
78
79    let width = code.width();
80    let colors = code.to_colors();
81    let total = width + 2 * QUIET;
82
83    // Light (false) outside the data area, including the quiet zone.
84    let is_dark = |x: usize, y: usize| -> bool {
85        if x < QUIET || x >= QUIET + width || y < QUIET || y >= QUIET + width {
86            return false;
87        }
88        matches!(colors[(x - QUIET) + (y - QUIET) * width], Color::Dark)
89    };
90
91    let cols = total.div_ceil(2);
92    let rows = total.div_ceil(4);
93    let mut out = String::with_capacity(rows * (cols * 3 + 1));
94
95    for cy in 0..rows {
96        for cx in 0..cols {
97            let mut bits: u32 = 0;
98            for dx in 0..2usize {
99                for dy in 0..4usize {
100                    if is_dark(cx * 2 + dx, cy * 4 + dy) {
101                        bits |= BRAILLE_BITS[dx][dy];
102                    }
103                }
104            }
105            // 0x2800 + bits is always within U+2800..=U+28FF (8-bit
106            // payload), so unwrap is safe.
107            out.push(char::from_u32(0x2800 + bits).unwrap());
108        }
109        if cy + 1 < rows {
110            out.push('\n');
111        }
112    }
113    out
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    const SAMPLE_URL: &str =
121        "https://acs.atomgit.com/auth/login?state=abcd1234efgh5678ijkl9012mnop3456";
122
123    #[test]
124    fn braille_path_emits_braille_chars() {
125        let rendered = render_login_qr(SAMPLE_URL, QrStyle::Braille).expect("qr encodes");
126        assert!(
127            rendered
128                .chars()
129                .any(|c| (c as u32) >= 0x2800 && (c as u32) <= 0x28FF),
130            "expected at least one braille char in QR, got: {:?}",
131            rendered.chars().take(40).collect::<String>()
132        );
133    }
134
135    #[test]
136    fn dense1x2_path_emits_block_glyphs() {
137        let rendered = render_login_qr(SAMPLE_URL, QrStyle::Dense1x2).expect("qr encodes");
138        assert!(
139            rendered
140                .chars()
141                .any(|c| matches!(c, '\u{2588}' | '\u{2580}' | '\u{2584}')),
142            "expected at least one block glyph in dense1x2 QR, got: {:?}",
143            rendered.chars().take(40).collect::<String>()
144        );
145    }
146
147    #[test]
148    fn rendered_lines_have_uniform_width() {
149        for style in [QrStyle::Braille, QrStyle::Dense1x2] {
150            let rendered = render_login_qr(SAMPLE_URL, style).expect("qr encodes");
151            let widths: Vec<usize> = rendered.lines().map(|l| l.chars().count()).collect();
152            let first = widths[0];
153            assert!(
154                widths.iter().all(|w| *w == first),
155                "rendered QR ({:?}) has uneven line widths: {:?}",
156                style,
157                widths
158            );
159        }
160    }
161
162    #[test]
163    fn braille_is_about_half_size_of_dense1x2() {
164        // Braille packs 2×4 modules per char, Dense1x2 packs 1×2.
165        // So braille should be ~½ width and ~½ height of Dense1x2.
166        let braille = render_login_qr(SAMPLE_URL, QrStyle::Braille).unwrap();
167        let dense = render_login_qr(SAMPLE_URL, QrStyle::Dense1x2).unwrap();
168        let (bw, bh) = (block_cols(&braille), braille.lines().count());
169        let (dw, dh) = (block_cols(&dense), dense.lines().count());
170        // Allow ±1 cell slack from ceiling rounding.
171        assert!(
172            bw * 2 <= dw + 1,
173            "braille width {} should be ~½ of dense1x2 width {}",
174            bw,
175            dw
176        );
177        assert!(
178            bh * 2 <= dh + 1,
179            "braille height {} should be ~½ of dense1x2 height {}",
180            bh,
181            dh
182        );
183    }
184
185    #[test]
186    fn block_cols_matches_widest_line() {
187        let rendered = "###\n#####\n##";
188        assert_eq!(block_cols(rendered), 5);
189    }
190}