Skip to main content

zest_widget/widget/
qr.rs

1//! Passive QR code widget. Encodes owned UTF-8 text or arbitrary bytes and
2//! renders a centered, integer-scaled symbol with a configurable quiet zone.
3//!
4//! The host rebuilds the widget each frame, so encoding happens when the
5//! widget is constructed or when [`Qr::ecc`] changes. Encoding failures never
6//! panic; query them via [`Qr::error`].
7
8use super::Widget;
9use alloc::{string::String, vec, vec::Vec};
10use core::marker::PhantomData;
11use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
12use qrcodegen_no_heap::{DataTooLong, QrCode, QrCodeEcc, Version};
13use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase};
14use zest_theme::Theme;
15
16/// Default quiet-zone width in modules, per the QR specification.
17const DEFAULT_QUIET_ZONE: u32 = 4;
18
19/// QR error correction level.
20#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
21pub enum EccLevel {
22    /// Roughly 7% error correction.
23    Low,
24    /// Roughly 15% error correction.
25    #[default]
26    Medium,
27    /// Roughly 25% error correction.
28    Quartile,
29    /// Roughly 30% error correction.
30    High,
31}
32
33impl EccLevel {
34    const fn into_qr(self) -> QrCodeEcc {
35        match self {
36            Self::Low => QrCodeEcc::Low,
37            Self::Medium => QrCodeEcc::Medium,
38            Self::Quartile => QrCodeEcc::Quartile,
39            Self::High => QrCodeEcc::High,
40        }
41    }
42}
43
44/// QR encode failure surfaced by [`Qr::error`].
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum QrError {
47    /// The payload is too large for a byte-mode segment.
48    SegmentTooLong,
49    /// The payload exceeds the QR capacity for the selected error correction.
50    DataOverCapacity {
51        /// Bits needed to encode the payload.
52        used_bits: usize,
53        /// Bits available in the chosen symbol version.
54        capacity_bits: usize,
55    },
56}
57
58impl From<DataTooLong> for QrError {
59    fn from(err: DataTooLong) -> Self {
60        match err {
61            DataTooLong::SegmentTooLong => Self::SegmentTooLong,
62            DataTooLong::DataOverCapacity(used_bits, capacity_bits) => Self::DataOverCapacity {
63                used_bits,
64                capacity_bits,
65            },
66        }
67    }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
71struct EncodedQr {
72    size: u32,
73    modules: Vec<u8>,
74}
75
76impl EncodedQr {
77    fn encode(data: &[u8], ecc: EccLevel) -> Result<Self, QrError> {
78        let mut temp = vec![0u8; Version::MAX.buffer_len()];
79        let mut out = vec![0u8; Version::MAX.buffer_len()];
80
81        if data.len() > temp.len() {
82            return Err(QrError::SegmentTooLong);
83        }
84        temp[..data.len()].copy_from_slice(data);
85
86        let qr = QrCode::encode_binary(
87            &mut temp,
88            data.len(),
89            &mut out,
90            ecc.into_qr(),
91            Version::MIN,
92            Version::MAX,
93            None,
94            true,
95        )
96        .map_err(QrError::from)?;
97
98        let size = qr.size() as u32;
99        let mut modules = vec![0u8; ((size * size) as usize).div_ceil(8)];
100        for y in 0..size {
101            for x in 0..size {
102                if qr.get_module(x as i32, y as i32) {
103                    let index = (y * size + x) as usize;
104                    modules[index >> 3] |= 1u8 << (index & 7);
105                }
106            }
107        }
108
109        Ok(Self { size, modules })
110    }
111
112    fn side_modules(&self, quiet_zone: u32) -> u32 {
113        self.size.saturating_add(quiet_zone.saturating_mul(2))
114    }
115
116    fn is_dark(&self, x: u32, y: u32) -> bool {
117        let index = (y * self.size + x) as usize;
118        (self.modules[index >> 3] >> (index & 7)) & 1 != 0
119    }
120}
121
122/// Passive QR code widget with builder-style sizing, ECC, quiet zone, and colors.
123pub struct Qr<C: PixelColor, M: Clone> {
124    rect: Rectangle,
125    data: Vec<u8>,
126    encoded: Result<EncodedQr, QrError>,
127    ecc: EccLevel,
128    quiet_zone: u32,
129    dark: Option<C>,
130    light: Option<C>,
131    width: Length,
132    height: Length,
133    _phantom: PhantomData<M>,
134}
135
136impl<C: PixelColor, M: Clone> Qr<C, M> {
137    /// New QR code from UTF-8 text. The payload is encoded in byte mode.
138    pub fn new(data: impl Into<String>) -> Self {
139        Self::from_bytes(data.into().into_bytes())
140    }
141
142    /// New QR code from arbitrary bytes. The payload is encoded in byte mode.
143    pub fn from_bytes(data: impl Into<Vec<u8>>) -> Self {
144        let data = data.into();
145        let ecc = EccLevel::default();
146        let encoded = EncodedQr::encode(&data, ecc);
147        Self {
148            rect: Rectangle::zero(),
149            data,
150            encoded,
151            ecc,
152            quiet_zone: DEFAULT_QUIET_ZONE,
153            dark: None,
154            light: None,
155            width: Length::Shrink,
156            height: Length::Shrink,
157            _phantom: PhantomData,
158        }
159    }
160
161    /// Width sizing intent.
162    #[must_use]
163    pub fn width(mut self, width: impl Into<Length>) -> Self {
164        self.width = width.into();
165        self
166    }
167
168    /// Height sizing intent.
169    #[must_use]
170    pub fn height(mut self, height: impl Into<Length>) -> Self {
171        self.height = height.into();
172        self
173    }
174
175    /// Error correction level (default: `Medium`).
176    #[must_use]
177    pub fn ecc(mut self, ecc: EccLevel) -> Self {
178        self.ecc = ecc;
179        self.reencode();
180        self
181    }
182
183    /// Quiet zone width in modules (default: 4).
184    #[must_use]
185    pub fn quiet_zone(mut self, quiet_zone: u32) -> Self {
186        self.quiet_zone = quiet_zone;
187        self
188    }
189
190    /// Override the dark-module color (default: `theme.background.on_base`).
191    #[must_use]
192    pub fn dark(mut self, color: C) -> Self {
193        self.dark = Some(color);
194        self
195    }
196
197    /// Override the light-module color (default: `theme.background.base`).
198    #[must_use]
199    pub fn light(mut self, color: C) -> Self {
200        self.light = Some(color);
201        self
202    }
203
204    /// Returns the current encode error, if any.
205    pub fn error(&self) -> Option<&QrError> {
206        self.encoded.as_ref().err()
207    }
208
209    fn reencode(&mut self) {
210        self.encoded = EncodedQr::encode(&self.data, self.ecc);
211    }
212
213    fn intrinsic_side(&self) -> u32 {
214        match self.encoded.as_ref() {
215            Ok(encoded) => encoded.side_modules(self.quiet_zone),
216            Err(_) => 0,
217        }
218    }
219}
220
221impl<C: PixelColor, M: Clone> Widget<C, M> for Qr<C, M> {
222    fn measure(&mut self, constraints: Constraints) -> Size {
223        let intrinsic = self.intrinsic_side();
224        let width = self.width.resolve(intrinsic, constraints.max.width);
225        let height = self.height.resolve(intrinsic, constraints.max.height);
226        constraints.clamp(Size::new(width, height))
227    }
228
229    fn preferred_size(&self) -> (Length, Length) {
230        (self.width, self.height)
231    }
232
233    fn arrange(&mut self, rect: Rectangle) {
234        self.rect = rect;
235    }
236
237    fn rect(&self) -> Rectangle {
238        self.rect
239    }
240
241    fn handle_touch(&mut self, _point: Point, _phase: TouchPhase) -> Option<M> {
242        None
243    }
244
245    fn draw<'t>(
246        &self,
247        renderer: &mut dyn Renderer<C>,
248        theme: &Theme<'t, C>,
249    ) -> Result<(), RenderError> {
250        let Ok(encoded) = self.encoded.as_ref() else {
251            return Ok(());
252        };
253
254        let side_modules = encoded.side_modules(self.quiet_zone);
255        if side_modules == 0 {
256            return Ok(());
257        }
258
259        let module_px =
260            (self.rect.size.width / side_modules).min(self.rect.size.height / side_modules);
261        if module_px == 0 {
262            return Ok(());
263        }
264
265        let qr_px = side_modules * module_px;
266        let dx = ((self.rect.size.width - qr_px) / 2) as i32;
267        let dy = ((self.rect.size.height - qr_px) / 2) as i32;
268        let origin = self.rect.top_left + Point::new(dx, dy);
269        let light = self.light.unwrap_or(theme.background.base);
270        let dark = self.dark.unwrap_or(theme.background.on_base);
271
272        renderer.fill_rect(Rectangle::new(origin, Size::new(qr_px, qr_px)), light)?;
273
274        for y in 0..encoded.size {
275            let y_px = origin.y + ((y + self.quiet_zone) * module_px) as i32;
276            let mut x = 0;
277            while x < encoded.size {
278                if !encoded.is_dark(x, y) {
279                    x += 1;
280                    continue;
281                }
282
283                let run_start = x;
284                x += 1;
285                while x < encoded.size && encoded.is_dark(x, y) {
286                    x += 1;
287                }
288
289                let x_px = origin.x + ((run_start + self.quiet_zone) * module_px) as i32;
290                let run_w = (x - run_start) * module_px;
291                renderer.fill_rect(
292                    Rectangle::new(Point::new(x_px, y_px), Size::new(run_w, module_px)),
293                    dark,
294                )?;
295            }
296        }
297
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use alloc::vec::Vec;
306    use embedded_graphics::{mono_font::MonoFont, pixelcolor::BinaryColor, text::Alignment};
307    use zest_core::Renderer;
308    use zest_theme::{Theme, convert_theme, theme::dark};
309
310    struct RecordingRenderer {
311        fills: Vec<Rectangle>,
312    }
313
314    impl RecordingRenderer {
315        fn new() -> Self {
316            Self { fills: Vec::new() }
317        }
318    }
319
320    impl Renderer<BinaryColor> for RecordingRenderer {
321        fn fill_rect(&mut self, rect: Rectangle, _color: BinaryColor) -> Result<(), RenderError> {
322            self.fills.push(rect);
323            Ok(())
324        }
325
326        fn stroke_rect(
327            &mut self,
328            _rect: Rectangle,
329            _color: BinaryColor,
330        ) -> Result<(), RenderError> {
331            Ok(())
332        }
333
334        fn fill_circle(
335            &mut self,
336            _center: Point,
337            _radius: u32,
338            _color: BinaryColor,
339        ) -> Result<(), RenderError> {
340            Ok(())
341        }
342
343        fn stroke_line(
344            &mut self,
345            _start: Point,
346            _end: Point,
347            _color: BinaryColor,
348            _width: u32,
349        ) -> Result<(), RenderError> {
350            Ok(())
351        }
352
353        fn draw_text(
354            &mut self,
355            _text: &str,
356            _position: Point,
357            _font: &MonoFont<'_>,
358            _color: BinaryColor,
359            _alignment: Alignment,
360        ) -> Result<(), RenderError> {
361            Ok(())
362        }
363    }
364
365    fn theme() -> Theme<'static, BinaryColor> {
366        convert_theme(&dark::THEME)
367    }
368
369    #[test]
370    fn oversize_payload_surfaces_error() {
371        let qr = Qr::<BinaryColor, ()>::from_bytes(vec![0x41; 4096]);
372        assert!(matches!(
373            qr.error(),
374            Some(QrError::SegmentTooLong | QrError::DataOverCapacity { .. })
375        ));
376    }
377
378    #[test]
379    fn draw_centers_a_square_integer_scaled_symbol() {
380        let mut qr = Qr::<BinaryColor, ()>::new("https://bhh32.com")
381            .quiet_zone(4)
382            .width(Length::Fixed(120))
383            .height(Length::Fixed(100));
384        qr.arrange(Rectangle::new(Point::new(10, 20), Size::new(120, 100)));
385
386        let mut renderer = RecordingRenderer::new();
387        qr.draw(&mut renderer, &theme()).unwrap();
388
389        let bg = renderer.fills.first().copied().unwrap();
390        assert_eq!(bg.size.width, bg.size.height);
391        assert_eq!(bg.top_left.y, 20);
392        assert!(bg.top_left.x > 10);
393        assert!(renderer.fills.len() > 1);
394    }
395}