Skip to main content

tui_lipan/widgets/
qr_code.rs

1//! QR code widget.
2
3use std::sync::Arc;
4
5use crate::core::element::{Element, IntoElement};
6use crate::style::{Color, Length, Style};
7use crate::widgets::{Overflow, Spacer, Text};
8
9/// Largest quiet zone accepted by [`QrCode::quiet_zone`], in modules.
10///
11/// The ISO/IEC 18004 minimum is 4; anything past a handful of modules only
12/// wastes cells, so the setter saturates here rather than letting a stray
13/// value inflate the symbol past the terminal.
14const MAX_QUIET_ZONE: u16 = 32;
15
16/// Error correction level for a [`QrCode`].
17///
18/// Higher levels survive more damage (and more terminal rendering artifacts)
19/// but need a larger symbol for the same payload.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum QrEcc {
22    /// Recovers roughly 7% of the symbol. Smallest output.
23    Low,
24    /// Recovers roughly 15% of the symbol.
25    #[default]
26    Medium,
27    /// Recovers roughly 25% of the symbol.
28    Quartile,
29    /// Recovers roughly 30% of the symbol. Largest output.
30    High,
31}
32
33impl QrEcc {
34    fn to_ec_level(self) -> qrcode::EcLevel {
35        match self {
36            Self::Low => qrcode::EcLevel::L,
37            Self::Medium => qrcode::EcLevel::M,
38            Self::Quartile => qrcode::EcLevel::Q,
39            Self::High => qrcode::EcLevel::H,
40        }
41    }
42}
43
44/// How QR modules are mapped onto terminal cells.
45///
46/// Terminal cells are roughly twice as tall as they are wide, so a naive
47/// one-cell-per-module symbol comes out at a 1:2 aspect ratio that most
48/// scanners reject. Both variants here correct for that; they differ only in
49/// how much space they trade for module size.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
51pub enum QrRender {
52    /// One cell per module wide, two module rows per cell row, drawn with
53    /// half-block glyphs (`▀`, `▄`, `█`).
54    ///
55    /// The compact option: a symbol of `n` modules occupies `n` columns and
56    /// `n / 2` rows.
57    #[default]
58    HalfBlock,
59    /// Two cells per module wide, one module row per cell row.
60    ///
61    /// Physically twice the size of [`HalfBlock`](Self::HalfBlock), which reads
62    /// more reliably on low-resolution cameras, at the cost of `2 * n` columns.
63    Wide,
64}
65
66/// A scannable QR code rendered as terminal cells.
67///
68/// Encodes `data` into a QR symbol and paints it with block glyphs. The common
69/// uses are handing something off to a phone: device-flow login URLs, WiFi
70/// credentials, TOTP enrollment secrets, or a link the user wants on another
71/// screen.
72///
73/// # Sizing
74///
75/// Unlike every other widget, a QR symbol cannot reflow: its size is fixed by
76/// the payload length and error correction level. A symbol that gets clipped
77/// still *looks* like a QR code but will not scan, so check [`QrCode::size`]
78/// against the viewport and substitute a fallback when the terminal is too
79/// small:
80///
81/// ```no_run
82/// # use tui_lipan::prelude::*;
83/// # fn example(ctx: &Context<impl Component>) -> Element {
84/// let qr = QrCode::new("https://tui-lipan.dev");
85/// let viewport = ctx.viewport();
86///
87/// match qr.size() {
88///     Some((w, h)) if w <= viewport.w && h <= viewport.h => qr.into(),
89///     _ => Text::new("https://tui-lipan.dev").into(),
90/// }
91/// # }
92/// ```
93///
94/// # Contrast
95///
96/// Scanners expect dark modules on a light background, so the default styling
97/// paints explicit [`Color::Black`] on [`Color::White`] rather than inheriting
98/// the terminal palette — a symbol rendered on a dark background is inverted
99/// and many readers will not decode it. Use [`QrCode::invert`] only when you
100/// know the target scanner handles it.
101///
102/// ```
103/// # use tui_lipan::prelude::*;
104/// QrCode::new("https://tui-lipan.dev")
105///     .ecc(QrEcc::Quartile)
106///     .render(QrRender::Wide);
107/// ```
108#[derive(Clone)]
109pub struct QrCode {
110    data: Arc<str>,
111    ecc: QrEcc,
112    render: QrRender,
113    quiet_zone: u16,
114    dark: Color,
115    light: Color,
116    fallback: Option<Element>,
117}
118
119impl QrCode {
120    /// Create a QR code for the given payload.
121    pub fn new(data: impl Into<Arc<str>>) -> Self {
122        Self {
123            data: data.into(),
124            ecc: QrEcc::default(),
125            render: QrRender::default(),
126            quiet_zone: 4,
127            dark: Color::Black,
128            light: Color::White,
129            fallback: None,
130        }
131    }
132
133    /// Set the error correction level.
134    pub fn ecc(mut self, ecc: QrEcc) -> Self {
135        self.ecc = ecc;
136        self
137    }
138
139    /// Set how modules map onto terminal cells.
140    pub fn render(mut self, render: QrRender) -> Self {
141        self.render = render;
142        self
143    }
144
145    /// Set the light margin around the symbol, in modules.
146    ///
147    /// Defaults to the spec-mandated 4. Scanners need this border to locate the
148    /// symbol; dropping it below 4 trades reliability for space. Values are
149    /// capped at 32.
150    pub fn quiet_zone(mut self, modules: u16) -> Self {
151        self.quiet_zone = modules.min(MAX_QUIET_ZONE);
152        self
153    }
154
155    /// Set the color of dark modules.
156    pub fn dark(mut self, color: Color) -> Self {
157        self.dark = color;
158        self
159    }
160
161    /// Set the color of light modules and the quiet zone.
162    pub fn light(mut self, color: Color) -> Self {
163        self.light = color;
164        self
165    }
166
167    /// Swap the dark and light colors.
168    pub fn invert(mut self) -> Self {
169        std::mem::swap(&mut self.dark, &mut self.light);
170        self
171    }
172
173    /// Set the element rendered when the payload is too long to encode.
174    ///
175    /// Without one, a payload that exceeds the largest QR version renders
176    /// nothing.
177    pub fn fallback(mut self, fallback: impl IntoElement) -> Self {
178        self.fallback = Some(fallback.into());
179        self
180    }
181
182    /// Symbol width in modules, excluding the quiet zone.
183    ///
184    /// Returns `None` when the payload is too long to encode.
185    pub fn module_count(&self) -> Option<u16> {
186        encode(&self.data, self.ecc).map(|(modules, _)| modules)
187    }
188
189    /// Cell footprint as `(width, height)`, including the quiet zone.
190    ///
191    /// Returns `None` when the payload is too long to encode. Compare this
192    /// against the available space before rendering — a clipped symbol does not
193    /// scan.
194    pub fn size(&self) -> Option<(u16, u16)> {
195        let total = self.module_count()?.saturating_add(self.quiet_zone * 2);
196        Some(match self.render {
197            QrRender::HalfBlock => (total, total.div_ceil(2)),
198            QrRender::Wide => (total.saturating_mul(2), total),
199        })
200    }
201
202    fn fallback_element(self) -> Element {
203        self.fallback.unwrap_or_else(|| {
204            Spacer::new()
205                .width(Length::Px(0))
206                .height(Length::Px(0))
207                .into()
208        })
209    }
210}
211
212/// Encode `data` into `(module_count, dark_flags)`, row-major.
213fn encode(data: &str, ecc: QrEcc) -> Option<(u16, Vec<bool>)> {
214    let code = qrcode::QrCode::with_error_correction_level(data, ecc.to_ec_level()).ok()?;
215    let modules = u16::try_from(code.width()).ok()?;
216    let dark = code
217        .to_colors()
218        .into_iter()
219        .map(|color| color == qrcode::Color::Dark)
220        .collect();
221    Some((modules, dark))
222}
223
224/// Paint the symbol as newline-separated rows of block glyphs.
225///
226/// Every glyph draws dark on light, so a single foreground/background pair
227/// styles the whole symbol: `█` is two dark modules, `▀` and `▄` are one of
228/// each, and a space is two light modules.
229fn paint(modules: u16, dark: &[bool], quiet_zone: u16, render: QrRender) -> String {
230    let n = modules as usize;
231    let quiet = quiet_zone as usize;
232    let total = n + quiet * 2;
233
234    // Coordinates outside the symbol land in the quiet zone, which is light.
235    let is_dark = |x: usize, y: usize| -> bool {
236        if x < quiet || y < quiet || x >= quiet + n || y >= quiet + n {
237            return false;
238        }
239        dark[(y - quiet) * n + (x - quiet)]
240    };
241
242    match render {
243        QrRender::HalfBlock => {
244            let rows = total.div_ceil(2);
245            let mut out = String::with_capacity(rows * (total + 1));
246            for row in 0..rows {
247                if row > 0 {
248                    out.push('\n');
249                }
250                let (top_y, bottom_y) = (row * 2, row * 2 + 1);
251                for x in 0..total {
252                    // An odd `total` leaves the final bottom row unpaired; it
253                    // reads as light, which only widens the quiet zone.
254                    let top = is_dark(x, top_y);
255                    let bottom = bottom_y < total && is_dark(x, bottom_y);
256                    out.push(match (top, bottom) {
257                        (true, true) => '█',
258                        (true, false) => '▀',
259                        (false, true) => '▄',
260                        (false, false) => ' ',
261                    });
262                }
263            }
264            out
265        }
266        QrRender::Wide => {
267            let mut out = String::with_capacity(total * (total * 2 + 1));
268            for y in 0..total {
269                if y > 0 {
270                    out.push('\n');
271                }
272                for x in 0..total {
273                    out.push_str(if is_dark(x, y) { "██" } else { "  " });
274                }
275            }
276            out
277        }
278    }
279}
280
281impl From<QrCode> for Element {
282    fn from(qr: QrCode) -> Self {
283        let Some((modules, dark)) = encode(&qr.data, qr.ecc) else {
284            return qr.fallback_element();
285        };
286
287        let content = paint(modules, &dark, qr.quiet_zone, qr.render);
288
289        Text::new(content)
290            .style(Style::new().fg(qr.dark).bg(qr.light))
291            // Wrapping would shear the symbol into unscannable fragments.
292            .overflow(Overflow::Clip)
293            .into()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// Rebuild the module grid from painted half-block output.
302    fn grid_from_half_block(painted: &str, total: usize) -> Vec<Vec<bool>> {
303        let mut grid = vec![vec![false; total]; total];
304        for (row, line) in painted.lines().enumerate() {
305            for (x, glyph) in line.chars().enumerate() {
306                let (top, bottom) = match glyph {
307                    '█' => (true, true),
308                    '▀' => (true, false),
309                    '▄' => (false, true),
310                    ' ' => (false, false),
311                    other => panic!("unexpected glyph {other:?}"),
312                };
313                if let Some(cell) = grid.get_mut(row * 2).and_then(|r| r.get_mut(x)) {
314                    *cell = top;
315                }
316                if let Some(cell) = grid.get_mut(row * 2 + 1).and_then(|r| r.get_mut(x)) {
317                    *cell = bottom;
318                }
319            }
320        }
321        grid
322    }
323
324    #[test]
325    fn half_block_size_is_half_as_tall_as_wide() {
326        let qr = QrCode::new("https://tui-lipan.dev");
327        let modules = qr.module_count().expect("encodes");
328        let total = modules + 8;
329
330        assert_eq!(qr.size(), Some((total, total.div_ceil(2))));
331    }
332
333    #[test]
334    fn wide_size_is_twice_as_wide_as_tall() {
335        let qr = QrCode::new("https://tui-lipan.dev").render(QrRender::Wide);
336        let modules = qr.module_count().expect("encodes");
337        let total = modules + 8;
338
339        assert_eq!(qr.size(), Some((total * 2, total)));
340    }
341
342    #[test]
343    fn painted_output_matches_reported_size() {
344        for render in [QrRender::HalfBlock, QrRender::Wide] {
345            let qr = QrCode::new("https://tui-lipan.dev").render(render);
346            let (modules, dark) = encode(&qr.data, qr.ecc).expect("encodes");
347            let painted = paint(modules, &dark, qr.quiet_zone, render);
348            let (w, h) = qr.size().expect("encodes");
349
350            assert_eq!(painted.lines().count(), h as usize, "{render:?} height");
351            for line in painted.lines() {
352                assert_eq!(line.chars().count(), w as usize, "{render:?} width");
353            }
354        }
355    }
356
357    #[test]
358    fn painted_modules_round_trip_through_half_blocks() {
359        let qr = QrCode::new("https://tui-lipan.dev");
360        let (modules, dark) = encode(&qr.data, qr.ecc).expect("encodes");
361        let quiet = qr.quiet_zone as usize;
362        let n = modules as usize;
363        let total = n + quiet * 2;
364
365        let grid = grid_from_half_block(&paint(modules, &dark, qr.quiet_zone, qr.render), total);
366
367        for y in 0..n {
368            for x in 0..n {
369                assert_eq!(
370                    grid[y + quiet][x + quiet],
371                    dark[y * n + x],
372                    "module ({x}, {y})"
373                );
374            }
375        }
376    }
377
378    #[test]
379    fn quiet_zone_stays_light_on_every_edge() {
380        let qr = QrCode::new("https://tui-lipan.dev");
381        let (modules, dark) = encode(&qr.data, qr.ecc).expect("encodes");
382        let quiet = qr.quiet_zone as usize;
383        let total = modules as usize + quiet * 2;
384
385        let grid = grid_from_half_block(&paint(modules, &dark, qr.quiet_zone, qr.render), total);
386
387        for (y, row) in grid.iter().enumerate() {
388            for (x, &cell) in row.iter().enumerate() {
389                let inside = x >= quiet && y >= quiet && x < total - quiet && y < total - quiet;
390                assert!(inside || !cell, "quiet zone dark at ({x}, {y})");
391            }
392        }
393    }
394
395    #[test]
396    fn odd_total_keeps_the_unpaired_row_light() {
397        // A quiet zone of 3 makes `total` odd for any odd module count.
398        let qr = QrCode::new("https://tui-lipan.dev").quiet_zone(3);
399        let (modules, dark) = encode(&qr.data, qr.ecc).expect("encodes");
400        let total = modules as usize + 6;
401        assert_eq!(total % 2, 1, "expected an odd total for this fixture");
402
403        let painted = paint(modules, &dark, qr.quiet_zone, qr.render);
404        let last = painted.lines().next_back().expect("has rows");
405
406        assert!(
407            last.chars().all(|glyph| matches!(glyph, '▀' | ' ')),
408            "unpaired bottom row painted dark: {last:?}"
409        );
410    }
411
412    #[test]
413    fn higher_error_correction_grows_the_symbol() {
414        let low = QrCode::new("https://tui-lipan.dev").ecc(QrEcc::Low);
415        let high = QrCode::new("https://tui-lipan.dev").ecc(QrEcc::High);
416
417        assert!(high.module_count() > low.module_count());
418    }
419
420    #[test]
421    fn oversized_payload_reports_no_size() {
422        let qr = QrCode::new("x".repeat(8000));
423
424        assert_eq!(qr.module_count(), None);
425        assert_eq!(qr.size(), None);
426    }
427
428    #[test]
429    fn quiet_zone_saturates() {
430        let qr = QrCode::new("https://tui-lipan.dev").quiet_zone(u16::MAX);
431
432        assert_eq!(qr.quiet_zone, MAX_QUIET_ZONE);
433    }
434
435    #[test]
436    fn invert_swaps_colors() {
437        let qr = QrCode::new("https://tui-lipan.dev").invert();
438
439        assert_eq!(qr.dark, Color::White);
440        assert_eq!(qr.light, Color::Black);
441    }
442}