Skip to main content

liora_components/
qr_code.rs

1use crate::gpui_compat::PixelsExt;
2use gpui::{
3    AnyElement, App, Hsla, IntoElement, Pixels, RenderImage, RenderOnce, Rgba, SharedString,
4    Window, div, img, prelude::*, px,
5};
6use image::{DynamicImage, ImageBuffer, Rgba as ImageRgba, RgbaImage};
7use liora_core::Config;
8use qrcode::{EcLevel, QrCode as QrEncoder, types::Color as QrModuleColor};
9use std::path::Path;
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum QrEcLevel {
14    Low,
15    Medium,
16    Quartile,
17    High,
18}
19
20impl QrEcLevel {
21    fn into_qrcode(self) -> EcLevel {
22        match self {
23            QrEcLevel::Low => EcLevel::L,
24            QrEcLevel::Medium => EcLevel::M,
25            QrEcLevel::Quartile => EcLevel::Q,
26            QrEcLevel::High => EcLevel::H,
27        }
28    }
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct QrDecoded {
33    pub content: SharedString,
34    pub ecc_level: u8,
35    pub version: i32,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum QrCodeError {
40    Encode(String),
41    Image(String),
42    NotFound,
43    Decode(String),
44}
45
46pub type QrCodeResult<T> = Result<T, QrCodeError>;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum QrModuleStyle {
50    #[default]
51    Square,
52    Rounded,
53    Dots,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum QrFinderStyle {
58    #[default]
59    Square,
60    Rounded,
61    Circle,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum QrGradientDirection {
66    ToTop,
67    ToTopRight,
68    ToRight,
69    ToBottomRight,
70    #[default]
71    ToBottom,
72    ToBottomLeft,
73    ToLeft,
74    ToTopLeft,
75}
76
77pub struct QrCode {
78    value: SharedString,
79    size: Pixels,
80    quiet_zone: u32,
81    module_radius: Pixels,
82    foreground: Option<Hsla>,
83    gradient_colors: Option<Vec<Hsla>>,
84    gradient_direction: QrGradientDirection,
85    background: Option<Hsla>,
86    ec_level: QrEcLevel,
87    show_text: bool,
88    module_style: QrModuleStyle,
89    finder_style: QrFinderStyle,
90    logo: Option<AnyElement>,
91    logo_text: Option<SharedString>,
92    logo_size_ratio: f32,
93    logo_background: Option<Hsla>,
94    logo_color: Option<Hsla>,
95    corner_logo: Option<AnyElement>,
96    corner_logo_text: Option<SharedString>,
97}
98
99impl QrCode {
100    pub fn new(value: impl Into<SharedString>) -> Self {
101        Self {
102            value: value.into(),
103            size: px(180.0),
104            quiet_zone: 4,
105            module_radius: px(0.0),
106            foreground: None,
107            gradient_colors: None,
108            gradient_direction: QrGradientDirection::ToBottom,
109            background: None,
110            ec_level: QrEcLevel::Medium,
111            show_text: false,
112            module_style: QrModuleStyle::Square,
113            finder_style: QrFinderStyle::Square,
114            logo: None,
115            logo_text: None,
116            logo_size_ratio: 0.24,
117            logo_background: None,
118            logo_color: None,
119            corner_logo: None,
120            corner_logo_text: None,
121        }
122    }
123
124    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
125        self.size = size.into();
126        self
127    }
128
129    pub fn quiet_zone(mut self, modules: u32) -> Self {
130        self.quiet_zone = modules;
131        self
132    }
133
134    pub fn module_radius(mut self, radius: impl Into<Pixels>) -> Self {
135        self.module_radius = radius.into();
136        self
137    }
138
139    pub fn foreground(mut self, color: Hsla) -> Self {
140        self.foreground = Some(color);
141        self.gradient_colors = None;
142        self
143    }
144
145    pub fn gradient(
146        mut self,
147        colors: impl IntoIterator<Item = Hsla>,
148        direction: QrGradientDirection,
149    ) -> Self {
150        let colors = colors.into_iter().collect::<Vec<_>>();
151        self.gradient_colors = (colors.len() >= 2).then_some(colors);
152        self.gradient_direction = direction;
153        self
154    }
155
156    pub fn foreground_gradient(
157        self,
158        colors: impl IntoIterator<Item = Hsla>,
159        direction: QrGradientDirection,
160    ) -> Self {
161        self.gradient(colors, direction)
162    }
163
164    pub fn gradient_colors(mut self, colors: impl IntoIterator<Item = Hsla>) -> Self {
165        let colors = colors.into_iter().collect::<Vec<_>>();
166        self.gradient_colors = (colors.len() >= 2).then_some(colors);
167        self
168    }
169
170    pub fn gradient_direction(mut self, direction: QrGradientDirection) -> Self {
171        self.gradient_direction = direction;
172        self
173    }
174
175    pub fn background(mut self, color: Hsla) -> Self {
176        self.background = Some(color);
177        self
178    }
179
180    pub fn colors(self, foreground: Hsla, background: Hsla) -> Self {
181        self.foreground(foreground).background(background)
182    }
183
184    pub fn ec_level(mut self, level: QrEcLevel) -> Self {
185        self.ec_level = level;
186        self
187    }
188
189    pub fn high_recovery(self) -> Self {
190        self.ec_level(QrEcLevel::High)
191    }
192
193    pub fn show_text(mut self, show: bool) -> Self {
194        self.show_text = show;
195        self
196    }
197
198    pub fn module_style(mut self, style: QrModuleStyle) -> Self {
199        self.module_style = style;
200        self
201    }
202
203    pub fn square_modules(self) -> Self {
204        self.module_style(QrModuleStyle::Square)
205    }
206
207    pub fn rounded_modules(self) -> Self {
208        self.module_style(QrModuleStyle::Rounded)
209    }
210
211    pub fn dot_modules(self) -> Self {
212        self.module_style(QrModuleStyle::Dots)
213    }
214
215    pub fn finder_style(mut self, style: QrFinderStyle) -> Self {
216        self.finder_style = style;
217        self
218    }
219
220    pub fn rounded_finders(self) -> Self {
221        self.finder_style(QrFinderStyle::Rounded)
222    }
223
224    pub fn circle_finders(self) -> Self {
225        self.finder_style(QrFinderStyle::Circle)
226    }
227
228    pub fn logo(mut self, logo: impl IntoElement) -> Self {
229        self.logo = Some(logo.into_any_element());
230        self.ec_level = QrEcLevel::High;
231        self
232    }
233
234    pub fn logo_text(mut self, text: impl Into<SharedString>) -> Self {
235        self.logo_text = Some(text.into());
236        self.ec_level = QrEcLevel::High;
237        self
238    }
239
240    pub fn logo_size_ratio(mut self, ratio: f32) -> Self {
241        self.logo_size_ratio = ratio.clamp(0.12, 0.36);
242        self
243    }
244
245    pub fn logo_background(mut self, color: Hsla) -> Self {
246        self.logo_background = Some(color);
247        self
248    }
249
250    pub fn logo_color(mut self, color: Hsla) -> Self {
251        self.logo_color = Some(color);
252        self
253    }
254
255    pub fn corner_logo(mut self, logo: impl IntoElement) -> Self {
256        self.corner_logo = Some(logo.into_any_element());
257        self.ec_level = QrEcLevel::High;
258        self
259    }
260
261    pub fn corner_logo_text(mut self, text: impl Into<SharedString>) -> Self {
262        self.corner_logo_text = Some(text.into());
263        self
264    }
265
266    pub fn encode_matrix(value: &str, ec_level: QrEcLevel) -> QrCodeResult<QrMatrix> {
267        let code = QrEncoder::with_error_correction_level(value.as_bytes(), ec_level.into_qrcode())
268            .map_err(|err| QrCodeError::Encode(err.to_string()))?;
269        let width = code.width();
270        let modules = code
271            .to_colors()
272            .into_iter()
273            .map(|color| color == QrModuleColor::Dark)
274            .collect();
275
276        Ok(QrMatrix { width, modules })
277    }
278
279    pub fn render_image(
280        value: &str,
281        size_px: u32,
282        quiet_zone: u32,
283        foreground: [u8; 4],
284        background: [u8; 4],
285        ec_level: QrEcLevel,
286    ) -> QrCodeResult<RgbaImage> {
287        let matrix = Self::encode_matrix(value, ec_level)?;
288        Ok(matrix.render_image(size_px, quiet_zone, foreground, background))
289    }
290
291    pub fn decode_image(image: DynamicImage) -> QrCodeResult<Vec<QrDecoded>> {
292        let luma = image.to_luma8();
293        let mut prepared = rqrr::PreparedImage::prepare(luma);
294        let grids = prepared.detect_grids();
295        if grids.is_empty() {
296            return Err(QrCodeError::NotFound);
297        }
298
299        let mut decoded = Vec::new();
300        for grid in grids {
301            let (meta, content) = grid
302                .decode()
303                .map_err(|err| QrCodeError::Decode(err.to_string()))?;
304            decoded.push(QrDecoded {
305                content: content.into(),
306                ecc_level: meta.ecc_level as u8,
307                version: meta.version.0 as i32,
308            });
309        }
310        Ok(decoded)
311    }
312
313    pub fn decode_bytes(bytes: &[u8]) -> QrCodeResult<Vec<QrDecoded>> {
314        let image =
315            image::load_from_memory(bytes).map_err(|err| QrCodeError::Image(err.to_string()))?;
316        Self::decode_image(image)
317    }
318
319    pub fn decode_file(path: impl AsRef<Path>) -> QrCodeResult<Vec<QrDecoded>> {
320        let image = image::open(path).map_err(|err| QrCodeError::Image(err.to_string()))?;
321        Self::decode_image(image)
322    }
323}
324
325pub struct QrMatrix {
326    pub width: usize,
327    pub modules: Vec<bool>,
328}
329
330impl QrMatrix {
331    pub fn is_dark(&self, x: usize, y: usize) -> bool {
332        self.modules[y * self.width + x]
333    }
334
335    pub fn render_image(
336        &self,
337        size_px: u32,
338        quiet_zone: u32,
339        foreground: [u8; 4],
340        background: [u8; 4],
341    ) -> RgbaImage {
342        self.render_styled_image(
343            size_px,
344            quiet_zone,
345            foreground,
346            background,
347            None,
348            QrModuleStyle::Square,
349            QrFinderStyle::Square,
350            None,
351        )
352    }
353
354    pub fn render_styled_image(
355        &self,
356        size_px: u32,
357        quiet_zone: u32,
358        foreground: [u8; 4],
359        background: [u8; 4],
360        gradient: Option<&QrGradientBytes>,
361        module_style: QrModuleStyle,
362        finder_style: QrFinderStyle,
363        logo_size_ratio: Option<f32>,
364    ) -> RgbaImage {
365        let total_modules = self.width as u32 + quiet_zone.saturating_mul(2);
366        let scale = (size_px / total_modules).max(1);
367        let actual = total_modules * scale;
368        let mut image = ImageBuffer::from_pixel(actual, actual, ImageRgba(background));
369
370        let logo_clear = logo_size_ratio.map(|ratio| {
371            let clear_modules = ((self.width as f32) * ratio.clamp(0.12, 0.36)).ceil() as usize;
372            let clear_modules = clear_modules.max(5);
373            let start = self.width.saturating_sub(clear_modules) / 2;
374            let end = (start + clear_modules).min(self.width);
375            (start, end)
376        });
377
378        for y in 0..self.width {
379            for x in 0..self.width {
380                if !self.is_dark(x, y) {
381                    continue;
382                }
383                if let Some((start, end)) = logo_clear {
384                    if x >= start && x < end && y >= start && y < end {
385                        continue;
386                    }
387                }
388                let start_x = (x as u32 + quiet_zone) * scale;
389                let start_y = (y as u32 + quiet_zone) * scale;
390                let is_finder = self.is_finder_module(x, y);
391                let style = if is_finder {
392                    match finder_style {
393                        QrFinderStyle::Square => QrModuleStyle::Square,
394                        QrFinderStyle::Rounded => QrModuleStyle::Rounded,
395                        QrFinderStyle::Circle => QrModuleStyle::Dots,
396                    }
397                } else {
398                    module_style
399                };
400                let module_color = gradient
401                    .map(|gradient| {
402                        gradient.color_at(start_x + scale / 2, start_y + scale / 2, actual)
403                    })
404                    .unwrap_or(foreground);
405                draw_module(&mut image, start_x, start_y, scale, module_color, style);
406            }
407        }
408
409        image
410    }
411
412    fn is_finder_module(&self, x: usize, y: usize) -> bool {
413        let w = self.width;
414        let in_top = y < 7;
415        let in_left = x < 7;
416        let in_right = x + 7 >= w;
417        let in_bottom = y + 7 >= w;
418        (in_top && (in_left || in_right)) || (in_bottom && in_left)
419    }
420}
421
422fn draw_module(
423    image: &mut RgbaImage,
424    start_x: u32,
425    start_y: u32,
426    scale: u32,
427    color: [u8; 4],
428    style: QrModuleStyle,
429) {
430    match style {
431        QrModuleStyle::Square => fill_rect(image, start_x, start_y, scale, color),
432        QrModuleStyle::Rounded => fill_rounded_rect(image, start_x, start_y, scale, color),
433        QrModuleStyle::Dots => fill_circle(image, start_x, start_y, scale, color),
434    }
435}
436
437fn fill_rect(image: &mut RgbaImage, start_x: u32, start_y: u32, scale: u32, color: [u8; 4]) {
438    for py in start_y..start_y + scale {
439        for px in start_x..start_x + scale {
440            image.put_pixel(px, py, ImageRgba(color));
441        }
442    }
443}
444
445fn fill_rounded_rect(
446    image: &mut RgbaImage,
447    start_x: u32,
448    start_y: u32,
449    scale: u32,
450    color: [u8; 4],
451) {
452    if scale <= 2 {
453        fill_rect(image, start_x, start_y, scale, color);
454        return;
455    }
456    let radius = scale as f32 * 0.32;
457    let max = scale as f32 - 1.0;
458    for y in 0..scale {
459        for x in 0..scale {
460            let xf = x as f32;
461            let yf = y as f32;
462            let cx = if xf < radius {
463                radius
464            } else if xf > max - radius {
465                max - radius
466            } else {
467                xf
468            };
469            let cy = if yf < radius {
470                radius
471            } else if yf > max - radius {
472                max - radius
473            } else {
474                yf
475            };
476            let dx = xf - cx;
477            let dy = yf - cy;
478            if dx * dx + dy * dy <= radius * radius + 0.75 {
479                image.put_pixel(start_x + x, start_y + y, ImageRgba(color));
480            }
481        }
482    }
483}
484
485fn fill_circle(image: &mut RgbaImage, start_x: u32, start_y: u32, scale: u32, color: [u8; 4]) {
486    if scale <= 2 {
487        fill_rect(image, start_x, start_y, scale, color);
488        return;
489    }
490    let center = (scale as f32 - 1.0) / 2.0;
491    let radius = scale as f32 * 0.43;
492    let radius_sq = radius * radius;
493    for y in 0..scale {
494        for x in 0..scale {
495            let dx = x as f32 - center;
496            let dy = y as f32 - center;
497            if dx * dx + dy * dy <= radius_sq {
498                image.put_pixel(start_x + x, start_y + y, ImageRgba(color));
499            }
500        }
501    }
502}
503
504fn hsla_to_rgba_bytes(color: Hsla) -> [u8; 4] {
505    let rgba = Rgba::from(color);
506    [
507        (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8,
508        (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8,
509        (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8,
510        (rgba.a.clamp(0.0, 1.0) * 255.0).round() as u8,
511    ]
512}
513
514#[derive(Clone)]
515pub struct QrGradientBytes {
516    colors: Vec<[u8; 4]>,
517    direction: QrGradientDirection,
518}
519
520impl QrGradientBytes {
521    fn new(colors: &[Hsla], direction: QrGradientDirection) -> Self {
522        Self {
523            colors: colors.iter().copied().map(hsla_to_rgba_bytes).collect(),
524            direction,
525        }
526    }
527
528    fn color_at(&self, x: u32, y: u32, size: u32) -> [u8; 4] {
529        if self.colors.is_empty() {
530            return [0, 0, 0, 255];
531        }
532        if self.colors.len() == 1 {
533            return self.colors[0];
534        }
535
536        let max = size.saturating_sub(1).max(1) as f32;
537        let nx = x as f32 / max;
538        let ny = y as f32 / max;
539        let t = match self.direction {
540            QrGradientDirection::ToTop => 1.0 - ny,
541            QrGradientDirection::ToTopRight => (nx + (1.0 - ny)) / 2.0,
542            QrGradientDirection::ToRight => nx,
543            QrGradientDirection::ToBottomRight => (nx + ny) / 2.0,
544            QrGradientDirection::ToBottom => ny,
545            QrGradientDirection::ToBottomLeft => ((1.0 - nx) + ny) / 2.0,
546            QrGradientDirection::ToLeft => 1.0 - nx,
547            QrGradientDirection::ToTopLeft => ((1.0 - nx) + (1.0 - ny)) / 2.0,
548        }
549        .clamp(0.0, 1.0);
550
551        let scaled = t * (self.colors.len() - 1) as f32;
552        let index = scaled.floor() as usize;
553        let next = (index + 1).min(self.colors.len() - 1);
554        let local_t = scaled - index as f32;
555        lerp_rgba(self.colors[index], self.colors[next], local_t)
556    }
557}
558
559fn lerp_rgba(from: [u8; 4], to: [u8; 4], t: f32) -> [u8; 4] {
560    [
561        lerp_u8(from[0], to[0], t),
562        lerp_u8(from[1], to[1], t),
563        lerp_u8(from[2], to[2], t),
564        lerp_u8(from[3], to[3], t),
565    ]
566}
567
568fn lerp_u8(from: u8, to: u8, t: f32) -> u8 {
569    (from as f32 + (to as f32 - from as f32) * t)
570        .round()
571        .clamp(0.0, 255.0) as u8
572}
573
574fn render_image_from_matrix(
575    matrix: &QrMatrix,
576    size_px: u32,
577    quiet_zone: u32,
578    foreground: Hsla,
579    background: Hsla,
580    gradient: Option<QrGradientBytes>,
581    module_style: QrModuleStyle,
582    finder_style: QrFinderStyle,
583    logo_size_ratio: Option<f32>,
584) -> Arc<RenderImage> {
585    let image = matrix.render_styled_image(
586        size_px,
587        quiet_zone,
588        hsla_to_rgba_bytes(foreground),
589        hsla_to_rgba_bytes(background),
590        gradient.as_ref(),
591        module_style,
592        finder_style,
593        logo_size_ratio,
594    );
595    Arc::new(RenderImage::new([image::Frame::new(image)]))
596}
597
598impl RenderOnce for QrCode {
599    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
600        let theme = cx.global::<Config>().theme.clone();
601        let foreground = self.foreground.unwrap_or(theme.neutral.text_1);
602        let gradient = self
603            .gradient_colors
604            .as_ref()
605            .map(|colors| QrGradientBytes::new(colors, self.gradient_direction));
606        let background = self.background.unwrap_or(theme.neutral.card);
607        let logo_bg = self.logo_background.unwrap_or(theme.neutral.card);
608        let logo_color = self.logo_color.unwrap_or(foreground);
609        let size_px = self.size.as_f32().max(24.0).round() as u32;
610        let logo_size = self.size * self.logo_size_ratio;
611        let corner_logo_size = self.size * 0.18;
612        let has_logo = self.logo.is_some() || self.logo_text.is_some();
613
614        let content = match Self::encode_matrix(self.value.as_ref(), self.ec_level) {
615            Ok(matrix) => {
616                let image = render_image_from_matrix(
617                    &matrix,
618                    size_px,
619                    self.quiet_zone,
620                    foreground,
621                    background,
622                    gradient,
623                    self.module_style,
624                    self.finder_style,
625                    has_logo.then_some(self.logo_size_ratio),
626                );
627                let mut qr = div()
628                    .relative()
629                    .size(self.size)
630                    .child(img(image).size(self.size));
631                if let Some(logo) = self.logo {
632                    qr = qr.child(
633                        div()
634                            .absolute()
635                            .top((self.size - logo_size) / 2.0)
636                            .left((self.size - logo_size) / 2.0)
637                            .size(logo_size)
638                            .rounded_full()
639                            .bg(logo_bg)
640                            .border_1()
641                            .border_color(background)
642                            .flex()
643                            .items_center()
644                            .justify_center()
645                            .child(logo),
646                    );
647                } else if let Some(logo_text) = self.logo_text.clone() {
648                    qr = qr.child(
649                        div()
650                            .absolute()
651                            .top((self.size - logo_size) / 2.0)
652                            .left((self.size - logo_size) / 2.0)
653                            .size(logo_size)
654                            .rounded_full()
655                            .bg(logo_bg)
656                            .border_1()
657                            .border_color(background)
658                            .flex()
659                            .items_center()
660                            .justify_center()
661                            .text_color(logo_color)
662                            .text_size(logo_size * 0.38)
663                            .font_weight(gpui::FontWeight::BOLD)
664                            .child(logo_text),
665                    );
666                }
667                if let Some(corner_logo) = self.corner_logo {
668                    qr = qr.child(
669                        div()
670                            .absolute()
671                            .right(px(8.0))
672                            .bottom(px(8.0))
673                            .size(corner_logo_size)
674                            .rounded_full()
675                            .bg(logo_color)
676                            .border_1()
677                            .border_color(background)
678                            .flex()
679                            .items_center()
680                            .justify_center()
681                            .child(corner_logo),
682                    );
683                } else if let Some(corner_logo_text) = self.corner_logo_text.clone() {
684                    qr = qr.child(
685                        div()
686                            .absolute()
687                            .right(px(8.0))
688                            .bottom(px(8.0))
689                            .size(corner_logo_size)
690                            .rounded_full()
691                            .bg(logo_color)
692                            .border_1()
693                            .border_color(background)
694                            .flex()
695                            .items_center()
696                            .justify_center()
697                            .text_color(background)
698                            .text_size(corner_logo_size * 0.42)
699                            .font_weight(gpui::FontWeight::BOLD)
700                            .child(corner_logo_text),
701                    );
702                }
703                qr.into_any_element()
704            }
705            Err(err) => div()
706                .flex()
707                .items_center()
708                .justify_center()
709                .size(self.size)
710                .rounded(px(theme.radius.md))
711                .border_1()
712                .border_color(theme.danger.base)
713                .text_color(theme.danger.base)
714                .text_size(px(theme.font_size.sm))
715                .child(format!("QR error: {err:?}"))
716                .into_any_element(),
717        };
718
719        div()
720            .flex()
721            .flex_col()
722            .items_center()
723            .gap_2()
724            .child(
725                div()
726                    .flex()
727                    .items_center()
728                    .justify_center()
729                    .p(px(6.0))
730                    .rounded(px(theme.radius.lg))
731                    .bg(background)
732                    .child(content),
733            )
734            .when(self.show_text, |s| {
735                s.child(
736                    div()
737                        .max_w(self.size)
738                        .text_xs()
739                        .text_color(theme.neutral.text_3)
740                        .child(self.value),
741                )
742            })
743    }
744}
745
746impl IntoElement for QrCode {
747    type Element = gpui::Component<Self>;
748
749    fn into_element(self) -> Self::Element {
750        gpui::Component::new(self)
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    #[test]
759    fn qr_matrix_encodes_content() {
760        let matrix = QrCode::encode_matrix("https://liora-ui.dev", QrEcLevel::Medium).unwrap();
761        assert!(matrix.width >= 21);
762        assert_eq!(matrix.modules.len(), matrix.width * matrix.width);
763        assert!(matrix.modules.iter().any(|dark| *dark));
764    }
765
766    #[test]
767    fn qr_matrix_renders_styled_image() {
768        let matrix = QrCode::encode_matrix("styled", QrEcLevel::High).unwrap();
769        let image = matrix.render_styled_image(
770            240,
771            4,
772            [20, 20, 20, 255],
773            [255, 255, 255, 255],
774            None,
775            QrModuleStyle::Dots,
776            QrFinderStyle::Circle,
777            Some(0.24),
778        );
779        assert!(image.width() >= 200);
780        assert_eq!(image.width(), image.height());
781    }
782
783    #[test]
784    fn qr_gradient_interpolates_in_all_directions() {
785        let gradient = QrGradientBytes {
786            colors: vec![[0, 0, 0, 255], [255, 255, 255, 255]],
787            direction: QrGradientDirection::ToRight,
788        };
789        assert_eq!(gradient.color_at(0, 5, 10)[0], 0);
790        assert_eq!(gradient.color_at(9, 5, 10)[0], 255);
791
792        for direction in [
793            QrGradientDirection::ToTop,
794            QrGradientDirection::ToTopRight,
795            QrGradientDirection::ToRight,
796            QrGradientDirection::ToBottomRight,
797            QrGradientDirection::ToBottom,
798            QrGradientDirection::ToBottomLeft,
799            QrGradientDirection::ToLeft,
800            QrGradientDirection::ToTopLeft,
801        ] {
802            let gradient = QrGradientBytes {
803                colors: vec![[0, 0, 0, 255], [255, 255, 255, 255]],
804                direction,
805            };
806            let color = gradient.color_at(4, 4, 10);
807            assert_eq!(color[3], 255);
808        }
809    }
810
811    #[test]
812    fn qr_decode_round_trips_generated_image() {
813        let content = "liora://component/qr-code";
814        let image = QrCode::render_image(
815            content,
816            256,
817            4,
818            [0, 0, 0, 255],
819            [255, 255, 255, 255],
820            QrEcLevel::High,
821        )
822        .unwrap();
823        let decoded = QrCode::decode_image(DynamicImage::ImageRgba8(image)).unwrap();
824        assert_eq!(decoded[0].content.as_ref(), content);
825    }
826}