1use std::sync::Arc;
4
5use crate::core::element::{Element, IntoElement};
6use crate::style::{Color, Length, Style};
7use crate::widgets::{Overflow, Spacer, Text};
8
9const MAX_QUIET_ZONE: u16 = 32;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum QrEcc {
22 Low,
24 #[default]
26 Medium,
27 Quartile,
29 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
51pub enum QrRender {
52 #[default]
58 HalfBlock,
59 Wide,
64}
65
66#[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 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 pub fn ecc(mut self, ecc: QrEcc) -> Self {
135 self.ecc = ecc;
136 self
137 }
138
139 pub fn render(mut self, render: QrRender) -> Self {
141 self.render = render;
142 self
143 }
144
145 pub fn quiet_zone(mut self, modules: u16) -> Self {
151 self.quiet_zone = modules.min(MAX_QUIET_ZONE);
152 self
153 }
154
155 pub fn dark(mut self, color: Color) -> Self {
157 self.dark = color;
158 self
159 }
160
161 pub fn light(mut self, color: Color) -> Self {
163 self.light = color;
164 self
165 }
166
167 pub fn invert(mut self) -> Self {
169 std::mem::swap(&mut self.dark, &mut self.light);
170 self
171 }
172
173 pub fn fallback(mut self, fallback: impl IntoElement) -> Self {
178 self.fallback = Some(fallback.into());
179 self
180 }
181
182 pub fn module_count(&self) -> Option<u16> {
186 encode(&self.data, self.ecc).map(|(modules, _)| modules)
187 }
188
189 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
212fn 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
224fn 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 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 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 .overflow(Overflow::Clip)
293 .into()
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 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 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}