From 692fae624414af256f2b5bd70d77bc0319d2cf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 01:40:15 -0400 Subject: [PATCH] =?UTF-8?q?Misc=20=C2=B7=20Codes=20and=20checks:=20a=20QR?= =?UTF-8?q?=20code=20scanner=20and=20a=20captcha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit misc::QrCodeScanner reads QR codes with rqrr 0.11 from the frames the host hands in, one at a time off the main thread, and hands each code to the owner once. misc::Captcha shows the owner's picture over an answer field that empties and keeps focus with each answer; the owner says how it stood. BarcodeDisplay, Signature and Rating Widget point to their homes. --- AGENTS.md | 4 +- Cargo.lock | 59 ++++++- Cargo.toml | 1 + examples/gallery/assets/captcha/q3np.png | Bin 0 -> 20116 bytes examples/gallery/assets/captcha/w7xk.png | Bin 0 -> 20506 bytes examples/gallery/pages/misc/codes.rs | 99 +++++++++++ examples/gallery/pages/misc/mod.rs | 15 +- scripts/captcha.py | 45 +++++ src/misc/captcha.rs | 193 ++++++++++++++++++++ src/misc/mod.rs | 4 + src/misc/scanner.rs | 216 +++++++++++++++++++++++ src/misc/tests/codes.rs | 135 ++++++++++++++ src/misc/tests/mod.rs | 14 +- tasks/ch31-43.md | 10 +- 14 files changed, 785 insertions(+), 10 deletions(-) create mode 100644 examples/gallery/assets/captcha/q3np.png create mode 100644 examples/gallery/assets/captcha/w7xk.png create mode 100644 examples/gallery/pages/misc/codes.rs create mode 100644 scripts/captcha.py create mode 100644 src/misc/captcha.rs create mode 100644 src/misc/scanner.rs create mode 100644 src/misc/tests/codes.rs diff --git a/AGENTS.md b/AGENTS.md index 7f327e1..ce2a6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui 0.2.2 from crates.io. Feature `runtime_shaders` is on by default: machines without Xcode lack the Metal compiler. - Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 (regular, medium, semibold, italic) and JetBrains Mono 2.304 (OFL). - Catalog data: `emojis` (Unicode emoji), `isolang` (ISO 639, native names), `isocountry` (ISO 3166), `iso_currency` (ISO 4217, also minor units for money). -- Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off. +- Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off. `rqrr` 0.11 ((MIT OR Apache-2.0) AND ISC, default features off) reads QR codes from the host's frames. - macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. @@ -199,6 +199,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). - A wall clock reads `Timestamp::now` and wakes its view at each whole second through a keyed task, only while the owner passes no moment; a stopwatch reads the executor's clock, so tests advance it (`misc::Clock`, `misc::Stopwatch`). - A number shown to so many significant digits goes through `typography::format::significant`, which rounds through Rust's exponent form, so no binary noise shows at any size (`misc::Calculator`, `misc::UnitConverter`). +- A component that reads the host's frames reads one at a time off the main thread and passes over frames that come meanwhile; when a read ends it redraws, so the newest frame is read next (`misc::QrCodeScanner`). - A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map. ## Decisions @@ -214,3 +215,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 2026-09-25: `unexpected_cfgs` declares `feature = "cargo-clippy"`, which the objc 0.2 macros test. - 2026-09-26: Terminal proven with `alacritty_terminal` 0.26: its grid, parser and pseudo-terminal, drawn by Ely. A replayed grid serves recorded output and tests. - 2026-09-27: ThemeSwitcher is `settings::ThemeSelector`: `src/theme` sits under every chapter and cannot draw with buttons. +- 2026-09-28: QRCodeScanner proven with `rqrr` 0.11: the host hands in frames as `RenderImage`s, read off the main thread; gpui 0.2.2 has no camera. diff --git a/Cargo.lock b/Cargo.lock index 9d66f08..dd93330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ "qrcode", "raw-window-handle", "regex", + "rqrr", "rust-embed", "rustix 1.1.5", "serde", @@ -1916,6 +1917,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.12.5" @@ -2135,6 +2142,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -2608,7 +2643,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2616,6 +2651,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3345,6 +3383,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -4819,6 +4866,16 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rqrr" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" +dependencies = [ + "g2p", + "lru", +] + [[package]] name = "rust-embed" version = "8.12.0" diff --git a/Cargo.toml b/Cargo.toml index 9b3c4c8..cda09e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ log = "0.4" pulldown-cmark = { version = "0.13", default-features = false } qrcode = { version = "0.14", default-features = false } regex = "1" +rqrr = { version = "0.11", default-features = false } rust-embed = "8.12" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } diff --git a/examples/gallery/assets/captcha/q3np.png b/examples/gallery/assets/captcha/q3np.png new file mode 100644 index 0000000000000000000000000000000000000000..a2496187bb570feb5216d3b21a2f6a1be737d3b7 GIT binary patch literal 20116 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 literal 0 HcmV?d00001 diff --git a/examples/gallery/pages/misc/codes.rs b/examples/gallery/pages/misc/codes.rs new file mode 100644 index 0000000..1962c71 --- /dev/null +++ b/examples/gallery/pages/misc/codes.rs @@ -0,0 +1,99 @@ +use std::{path::PathBuf, sync::Arc}; + +use ely_gpui_component::misc::{Captcha, CaptchaState, QrCodeScanner}; +use gpui::{App, IntoElement, ParentElement, RenderImage, Styled, Window, div, px}; + +use crate::probe::probe; +use crate::ui::section; + +const ANSWERS: [&str; 2] = ["W7XK", "Q3NP"]; +const PICTURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/captcha" +); + +/// The frame a camera would hand in: a QR code on a card over a dim scene, in gpui's BGRA pixels. +fn scene() -> Arc { + let code = qrcode::QrCode::new("https://example.com/ely").expect("the link fits a QR code"); + let (modules, scale, quiet) = (code.width() as u32, 4, 4); + let (width, height, side) = (360, 240, (modules + quiet * 2) * scale); + let (left, top) = ((width - side) / 2, (height - side) / 2); + let mut pixels = image::RgbaImage::from_fn(width, height, |x, y| { + let shade = (60 + (x + y) / 10) as u8; + image::Rgba([shade + 12, shade + 6, shade, 255]) + }); + let dark: Vec = code + .to_colors() + .into_iter() + .map(|color| color == qrcode::Color::Dark) + .collect(); + for (x, y) in (0..side).flat_map(|x| (0..side).map(move |y| (x, y))) { + let module = (x / scale) + .checked_sub(quiet) + .zip((y / scale).checked_sub(quiet)); + let ink = module + .filter(|(mx, my)| *mx < modules && *my < modules) + .is_some_and(|(mx, my)| dark[(my * modules + mx) as usize]); + let shade = if ink { 20 } else { 245 }; + pixels.put_pixel(left + x, top + y, image::Rgba([shade, shade, shade, 255])); + } + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])) +} + +pub fn render(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let frame = window.use_keyed_state("scanner-scene", cx, |_, _| scene()); + let desk = window.use_keyed_state("captcha-desk", cx, |_, _| (0, CaptchaState::Asking)); + let (ix, state) = *desk.read(cx); + let answer = ANSWERS[ix]; + let (checked, refreshed) = (desk.clone(), desk); + let picture = PathBuf::from(format!("{PICTURES}/{}.png", answer.to_lowercase())); + div() + .child( + section( + "QRCodeScanner", + "A camera's view with a square to aim at, reading QR codes off the main thread from frames the host hands in. gpui has no camera, so the gallery hands in one frame of its own.", + cx, + ) + .child( + div().w(px(280.0)).child( + QrCodeScanner::new("scanner", 1.5) + .frame(frame.read(cx).clone()) + .on_scan(|text, _, _| log::info!("gallery: scanned {text}")), + ), + ), + ) + .child( + section( + "Captcha", + "A picture of characters to copy, from the owner, who checks the answer and says how it stood; the refresh asks for another picture.", + cx, + ) + .child(probe( + "captcha", + div().w(px(280.0)).child( + Captcha::new("captcha", picture, 3.0) + .state(state) + .on_answer(move |text, _, cx| { + checked.update(cx, |desk, cx| { + desk.1 = match text.eq_ignore_ascii_case(answer) { + true => CaptchaState::Passed, + false => CaptchaState::Wrong, + }; + cx.notify(); + }) + }) + .on_refresh(move |_, cx| { + refreshed.update(cx, |desk, cx| { + *desk = ((desk.0 + 1) % ANSWERS.len(), CaptchaState::Asking); + cx.notify(); + }) + }), + ), + )), + ) + .child(section( + "BarcodeDisplay · Signature · Rating Widget", + "Each has one home, shown on its own chapter's page: BarcodeDisplay is data_display::Barcode, Signature is forms::SignaturePad, and Rating Widget is forms::Rating.", + cx, + )) +} diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs index 8404dd9..f410dbd 100644 --- a/examples/gallery/pages/misc/mod.rs +++ b/examples/gallery/pages/misc/mod.rs @@ -7,11 +7,13 @@ use crate::probe::probe; use crate::script::Step; use crate::ui::{section, specimen, specimens}; +mod codes; + pub const PAGE: Page = Page { number: 42, slug: "misc", title: "Misc", - summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter.", + summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha.", render, script: &[ Step::DownAt("stopwatch-start", 30.0, 62.0), @@ -32,6 +34,14 @@ pub const PAGE: Page = Page { Step::Key("cmd-a"), Step::Type("1000000000000000"), Step::Shot("converter-long"), + Step::DownAt("captcha", 24.0, 104.0), + Step::UpAt("captcha", 24.0, 104.0), + Step::Type("abcd"), + Step::Key("enter"), + Step::Shot("captcha-wrong"), + Step::Type("w7xk"), + Step::Key("enter"), + Step::Shot("captcha-passed"), ], }; @@ -39,12 +49,13 @@ fn zone(name: &str) -> TimeZone { TimeZone::get(name).unwrap_or_else(|error| panic!("the zone {name}: {error}")) } -fn render(_: &mut Window, cx: &mut App) -> AnyElement { +fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(clocks(cx)) .child(stopwatch(cx)) .child(calculator(cx)) .child(converter(cx)) + .child(codes::render(window, cx)) .into_any_element() } diff --git a/scripts/captcha.py b/scripts/captcha.py new file mode 100644 index 0000000..80cba6f --- /dev/null +++ b/scripts/captcha.py @@ -0,0 +1,45 @@ +"""Writes the gallery's captcha pictures: each answer's letters turned and set off, over noise. + +Usage: python3 scripts/captcha.py +The seed is fixed, so the pictures come out the same each run. +""" + +import random +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "examples/gallery/assets/captcha" +FONT = ImageFont.truetype(str(ROOT / "assets/fonts/Inter-SemiBold.ttf"), 76) +ANSWERS = ["W7XK", "Q3NP"] +SIZE = (480, 160) + + +def picture(answer, rng): + image = Image.new("RGB", SIZE, (236, 238, 242)) + draw = ImageDraw.Draw(image) + for _ in range(900): + x, y = rng.randrange(SIZE[0]), rng.randrange(SIZE[1]) + draw.point((x, y), fill=tuple(rng.randrange(150, 210) for _ in range(3))) + step = SIZE[0] // (len(answer) + 1) + for ix, letter in enumerate(answer): + tile = Image.new("RGBA", (120, 120), (0, 0, 0, 0)) + ImageDraw.Draw(tile).text((60, 60), letter, font=FONT, anchor="mm", fill=(40, 48, 70, 255)) + tile = tile.rotate(rng.uniform(-28, 28), resample=Image.BICUBIC) + image.paste(tile, (step * (ix + 1) - 60, 20 + rng.randrange(-14, 14)), tile) + for _ in range(5): + points = [(x, rng.randrange(20, SIZE[1] - 20)) for x in range(0, SIZE[0] + 60, 60)] + draw.line(points, fill=(90, 100, 130), width=3) + return image + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + rng = random.Random(42) + for answer in ANSWERS: + picture(answer, rng).save(OUT / f"{answer.lower()}.png") + print(f"wrote {answer.lower()}.png") + + +main() diff --git a/src/misc/captcha.rs b/src/misc/captcha.rs new file mode 100644 index 0000000..61a1ff4 --- /dev/null +++ b/src/misc/captcha.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + feedback::InlineMessage, + forms::{Enter, Input, TextInput}, + primitives::{FocusRing, Icon, IconName, Image, Severity, checked_ratio, framed, tab_stop}, + theme::{ActiveTheme, ControlSize, IconSize, Radius}, +}; + +type OnAnswer = Rc; +type OnRefresh = Rc; + +/// Where a captcha stands, as its owner says. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CaptchaState { + #[default] + Asking, + /// The owner checks the answer: Verify spins and takes no press. + Checking, + /// The answer did not match. + Wrong, + /// The field rests, and Verify becomes a mark that keeps its focus. + Passed, +} + +/// A picture of characters to copy, from the owner, over a field to type them. Verify or Enter hands the answer to the owner, who checks it and says how it stood; the field empties with each answer and keeps focus. With `on_refresh`, a button asks the owner for a new picture. +#[derive(IntoElement)] +pub struct Captcha { + id: ElementId, + picture: ImageSource, + ratio: f32, + state: CaptchaState, + on_answer: Option, + on_refresh: Option, +} + +impl Captcha { + /// `ratio` is the picture's width over its height. + pub fn new(id: impl Into, picture: impl Into, ratio: f32) -> Self { + Self { + id: id.into(), + picture: picture.into(), + ratio: checked_ratio(ratio), + state: CaptchaState::default(), + on_answer: None, + on_refresh: None, + } + } + + pub fn state(mut self, state: CaptchaState) -> Self { + self.state = state; + self + } + + /// Runs with the typed answer, trimmed. + pub fn on_answer( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_answer = Some(Rc::new(handler)); + self + } + + /// Asks for a new picture. + pub fn on_refresh(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_refresh = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Captcha { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_answer = self + .on_answer + .unwrap_or_else(|| panic!("captcha {id:?} has no on_answer")); + let field = window.use_keyed_state((id.clone(), "field"), cx, |window, cx| { + TextInput::new(window, cx).placeholder("Type the characters") + }); + let passed = self.state == CaptchaState::Passed; + if field.read(cx).is_disabled() != passed { + field.update(cx, |field, cx| field.set_disabled(passed, cx)); + } + let verify = tab_stop((id.clone(), "verify").into(), !passed, window, cx); + let empty = field.read(cx).text().trim().is_empty(); + let answer = { + let field = field.clone(); + Rc::new(move |window: &mut Window, cx: &mut App| { + let text = SharedString::from(field.read(cx).text().trim().to_string()); + if text.is_empty() { + return; + } + log::info!("captcha: answered"); + on_answer(&text, window, cx); + field.update(cx, |field, cx| field.set_text("", cx)); + window.focus(field.read(cx).focus()); + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let radius = theme.radius(Radius::Md); + let action = match passed { + true => div() + .id((id.clone(), "verified")) + .debug_selector(|| "captcha-verified".into()) + .track_focus(&verify) + .flex() + .flex_none() + .items_center() + .gap_1() + .h(theme.control_height(ControlSize::Md)) + .px(theme.control_padding(ControlSize::Md)) + .rounded(radius) + .border_1() + .border_color(colors.border.alpha(0.0)) + .focus_ring(cx) + .text_color(colors.success) + .child( + Icon::new(IconName::Check) + .size(IconSize::Sm) + .color(colors.success), + ) + .child("Verified") + .into_any_element(), + false => { + let answer = answer.clone(); + Button::new((id.clone(), "verify-button"), "Verify") + .variant(ButtonVariant::Primary) + .focus_handle(&verify) + .loading(self.state == CaptchaState::Checking) + .disabled(empty && self.state != CaptchaState::Checking) + .on_click(move |_, window, cx| answer(window, cx)) + .into_any_element() + } + }; + let refresh = self.on_refresh.map(|on_refresh| { + IconButton::new((id.clone(), "refresh"), IconName::RefreshCw) + .tooltip("New picture") + .on_click(move |_, window, cx| { + log::info!("captcha: asked for a new picture"); + on_refresh(window, cx) + }) + }); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div().flex_1().min_w_0().child( + framed(self.ratio, cx).rounded(radius).child( + Image::new((id.clone(), "picture"), self.picture) + .size_full() + .rounded(radius), + ), + ), + ) + .children(refresh), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + answer(window, cx); + }) + .child(Input::new(&field)), + ) + .child(action), + ) + .when(self.state == CaptchaState::Wrong, |captcha| { + captcha.child(div().debug_selector(|| "captcha-wrong".into()).child( + InlineMessage::new(Severity::Danger, "That did not match; try again."), + )) + }) + } +} diff --git a/src/misc/mod.rs b/src/misc/mod.rs index 287533f..9fe22e4 100644 --- a/src/misc/mod.rs +++ b/src/misc/mod.rs @@ -1,11 +1,15 @@ mod calculator; +mod captcha; mod clock; +mod scanner; mod stopwatch; #[cfg(all(test, feature = "test-support"))] mod tests; mod units; pub use calculator::Calculator; +pub use captcha::{Captcha, CaptchaState}; pub use clock::{Clock, WorldClock}; +pub use scanner::QrCodeScanner; pub use stopwatch::Stopwatch; pub use units::UnitConverter; diff --git a/src/misc/scanner.rs b/src/misc/scanner.rs new file mode 100644 index 0000000..3bf6dcb --- /dev/null +++ b/src/misc/scanner.rs @@ -0,0 +1,216 @@ +use std::{rc::Rc, sync::Arc}; + +use gpui::{ + App, ElementId, ImageId, IntoElement, ParentElement, RenderImage, RenderOnce, SharedString, + Styled, Task, Window, div, prelude::*, relative, +}; + +use crate::{ + media::CameraPreview, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::literal, +}; + +type OnScan = Rc; + +/// The text of the first QR code that decodes in a frame of gpui's BGRA pixels. +pub(crate) fn read(frame: &RenderImage) -> Option { + let pixels = frame.as_bytes(0).expect("a frame has pixels"); + let size = frame.size(0); + let (width, height) = (size.width.0 as usize, size.height.0 as usize); + let mut image = rqrr::PreparedImage::prepare_from_greyscale(width, height, |x, y| { + let at = (y * width + x) * 4; + let [blue, green, red] = [pixels[at], pixels[at + 1], pixels[at + 2]].map(u32::from); + ((red * 299 + green * 587 + blue * 114) / 1000) as u8 + }); + image + .detect_grids() + .into_iter() + .find_map(|grid| grid.decode().ok().map(|(_, text)| text)) +} + +/// The scanner's own: the frame last read, the text last found, the read under way, and the owner's handler. +#[derive(Default)] +struct Scan { + seen: Option, + found: Option, + reading: Option>, + on_scan: Option, +} + +/// A camera's view with a square to aim at, reading QR codes off the main thread from the frames the host hands in; frames that come while one is read are passed over. Each code goes to the owner once, when it differs from the last, and shows under the view. +#[derive(IntoElement)] +pub struct QrCodeScanner { + id: ElementId, + ratio: f32, + frame: Option>, + on_scan: Option, +} + +impl QrCodeScanner { + /// `ratio` is the camera's width over its height. + pub fn new(id: impl Into, ratio: f32) -> Self { + Self { + id: id.into(), + ratio, + frame: None, + on_scan: None, + } + } + + /// The frame to show and read now, in gpui's BGRA pixels. + pub fn frame(mut self, frame: Arc) -> Self { + self.frame = Some(frame); + self + } + + /// Runs with the text of each code read. + pub fn on_scan( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_scan = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for QrCodeScanner { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_scan = self + .on_scan + .unwrap_or_else(|| panic!("qr code scanner {id:?} has no on_scan")); + let scan = window.use_keyed_state((id.clone(), "scan"), cx, |_, _| Scan::default()); + scan.update(cx, |scan, _| scan.on_scan = Some(on_scan)); + let fresh = self.frame.clone().filter(|frame| { + let scan = scan.read(cx); + scan.reading.is_none() && scan.seen != Some(frame.id) + }); + if let Some(frame) = fresh { + let (weak, seen) = (scan.downgrade(), frame.id); + let task = window.spawn(cx, async move |cx| { + let text = cx + .background_executor() + .spawn(async move { read(&frame) }) + .await; + let _ = cx.update(|window, cx| { + let Ok(Some((text, on_scan))) = weak.update(cx, |scan, cx| { + scan.reading = None; + cx.notify(); + let text = SharedString::from(text?); + (scan.found.as_ref() != Some(&text)).then(|| { + scan.found = Some(text.clone()); + (text, scan.on_scan.clone()) + }) + }) else { + return; + }; + log::info!("qr code scanner: read {text}"); + if let Some(on_scan) = on_scan { + on_scan(&text, window, cx); + } + }); + }); + scan.update(cx, |scan, _| { + scan.seen = Some(seen); + scan.reading = Some(task); + }); + } + let theme = cx.theme(); + let colors = &theme.colors; + let found = scan.read(cx).found.clone(); + let live = self.frame.is_some(); + let mut camera = CameraPreview::new((id, "camera"), self.ratio); + if let Some(frame) = self.frame { + camera = camera.frame(frame); + } + let mut aim = div() + .h(relative(0.62)) + .border_2() + .border_color(colors.on_media.alpha(0.9)) + .rounded(theme.radius(Radius::Md)); + aim.style().aspect_ratio = Some(1.0); + let status = match found { + Some(text) => div() + .flex() + .items_start() + .gap_2() + .child( + Icon::new(IconName::CircleCheck) + .size(IconSize::Sm) + .color(colors.success), + ) + .child( + literal(div()) + .flex_1() + .min_w_0() + .debug_selector(|| format!("scanned-{text}")) + .text_color(colors.fg) + .child(text), + ), + None => div() + .text_color(colors.fg_muted) + .child("Point the camera at a QR code"), + }; + div() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child(div().relative().child(camera).when(live, |view| { + view.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child(aim), + ) + })) + .child(status) + } +} + +/// A frame of gpui's BGRA pixels showing `text` as a QR code, four pixels a module, inside the quiet margin. +#[cfg(test)] +pub(crate) fn frame_of(text: &str) -> Arc { + let code = qrcode::QrCode::new(text).expect("the text fits a QR code"); + let (width, scale, quiet) = (code.width(), 4, 4); + let side = ((width + quiet * 2) * scale) as u32; + let mut pixels = image::RgbaImage::from_pixel(side, side, image::Rgba([255; 4])); + for (ix, color) in code.to_colors().into_iter().enumerate() { + if color == qrcode::Color::Dark { + let (x, y) = ((ix % width + quiet) * scale, (ix / width + quiet) * scale); + for (dx, dy) in (0..scale).flat_map(|dx| (0..scale).map(move |dy| (dx, dy))) { + pixels.put_pixel( + (x + dx) as u32, + (y + dy) as u32, + image::Rgba([0, 0, 0, 255]), + ); + } + } + } + Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])) +} + +#[cfg(test)] +mod tests { + use gpui::RenderImage; + + use super::{frame_of, read}; + + #[test] + fn a_frame_reads_as_the_code_it_shows() { + assert_eq!( + read(&frame_of("https://example.com/ely")).as_deref(), + Some("https://example.com/ely") + ); + let blank = image::RgbaImage::from_pixel(64, 64, image::Rgba([255; 4])); + assert_eq!( + read(&RenderImage::new(vec![image::Frame::new(blank)])), + None + ); + } +} diff --git a/src/misc/tests/codes.rs b/src/misc/tests/codes.rs new file mode 100644 index 0000000..8caaff4 --- /dev/null +++ b/src/misc/tests/codes.rs @@ -0,0 +1,135 @@ +use std::{ + cell::{Cell, RefCell}, + sync::Arc, +}; + +use gpui::{AnyElement, IntoElement, RenderImage, TestAppContext, VisualTestContext}; + +use super::{at_root, press, settle, shown, stage}; +use crate::misc::{Captcha, CaptchaState, QrCodeScanner, scanner::frame_of}; + +thread_local! { + static FRAME: RefCell>> = const { RefCell::new(None) }; + static SAID: RefCell> = const { RefCell::new(Vec::new()) }; + static STATE: Cell = const { Cell::new(CaptchaState::Asking) }; +} + +fn said() -> Vec { + SAID.with(|said| said.borrow().clone()) +} + +fn say(text: &str) { + SAID.with(|said| said.borrow_mut().push(text.to_string())); +} + +/// Hands the stage a new frame, or state, and lets it draw and read. +fn show(cx: &mut VisualTestContext) { + cx.update(|window, _| window.refresh()); + settle(cx); +} + +fn scanner() -> AnyElement { + let scanner = QrCodeScanner::new("scanner", 1.0).on_scan(|text, _, _| say(text)); + match FRAME.with(|frame| frame.borrow().clone()) { + Some(frame) => scanner.frame(frame), + None => scanner, + } + .into_any_element() +} + +/// Frames read off the main thread; a code goes to the owner once, and a new one after it. +#[gpui::test] +fn a_scanner_reads_each_code_once(cx: &mut TestAppContext) { + FRAME.with(|frame| frame.replace(Some(frame_of("first")))); + let (_, cx) = stage(scanner, cx); + assert_eq!(said(), ["first"]); + assert!(shown("scanned-first", cx)); + FRAME.with(|frame| frame.replace(Some(frame_of("first")))); + show(cx); + assert_eq!( + said(), + ["first"], + "a new frame of the same code says nothing" + ); + FRAME.with(|frame| frame.replace(Some(frame_of("second")))); + show(cx); + assert_eq!(said(), ["first", "second"]); +} + +/// A frame that comes while one is read waits: the read's end redraws, and the newest frame is read next. +#[gpui::test] +fn a_frame_that_comes_mid_read_is_read_next(cx: &mut TestAppContext) { + let (_, cx) = stage(scanner, cx); + for text in ["first", "second"] { + FRAME.with(|frame| frame.replace(Some(frame_of(text)))); + cx.update(|window, _| window.refresh()); + } + assert!(said().is_empty(), "both frames drew before any read ended"); + cx.run_until_parked(); + assert_eq!(said(), ["first", "second"], "only the read's end redrew"); +} + +fn captcha() -> AnyElement { + Captcha::new("captcha", frame_of("W7XK"), 1.0) + .state(STATE.get()) + .on_answer(|text, _, _| say(text)) + .into_any_element() +} + +/// Enter hands the answer over and empties the field, which keeps focus through the owner's verdict. +#[gpui::test] +fn a_captcha_hands_over_answers_and_keeps_focus(cx: &mut TestAppContext) { + let (_, cx) = stage(captcha, cx); + press("tab", cx); + press("enter", cx); + assert!(said().is_empty(), "nothing typed, nothing handed over"); + cx.simulate_input(" w7xk "); + press("enter", cx); + assert_eq!(said(), ["w7xk"]); + STATE.set(CaptchaState::Wrong); + show(cx); + assert!(shown("captcha-wrong", cx)); + cx.simulate_input("W7XK"); + press("enter", cx); + assert_eq!( + said(), + ["w7xk", "W7XK"], + "the field emptied for the next try" + ); + STATE.set(CaptchaState::Passed); + show(cx); + assert!( + shown("captcha-verified", cx) && !at_root(cx), + "the field kept focus" + ); + cx.simulate_input("again"); + press("enter", cx); + assert_eq!(said().len(), 2, "a passed captcha takes no more answers"); +} + +/// Enter on Verify hands the answer over and focus to the emptied field, as Verify rests. +#[gpui::test] +fn verify_hands_focus_to_the_field(cx: &mut TestAppContext) { + let (_, cx) = stage(captcha, cx); + press("tab", cx); + cx.simulate_input("W7XK"); + press("tab", cx); + press("enter", cx); + assert_eq!(said(), ["W7XK"]); + assert!(!at_root(cx), "focus went to the field, not the root"); +} + +/// Verify spins while the owner checks, and its mark keeps its focus once passed. +#[gpui::test] +fn verify_keeps_its_focus_through_the_check(cx: &mut TestAppContext) { + let (_, cx) = stage(captcha, cx); + press("tab", cx); + cx.simulate_input("W7XK"); + press("tab", cx); + STATE.set(CaptchaState::Checking); + show(cx); + assert!(!at_root(cx), "a spinning Verify stays a stop"); + STATE.set(CaptchaState::Passed); + show(cx); + assert!(!at_root(cx), "the Verified mark took Verify's focus"); +} diff --git a/src/misc/tests/mod.rs b/src/misc/tests/mod.rs index 08ad7c8..b1d6256 100644 --- a/src/misc/tests/mod.rs +++ b/src/misc/tests/mod.rs @@ -13,6 +13,7 @@ use crate::{ theme::Theme, }; +mod codes; mod numbers; struct Stage { @@ -29,7 +30,7 @@ impl Render for Stage { } } -fn settle(cx: &mut VisualTestContext) { +pub(super) fn settle(cx: &mut VisualTestContext) { for _ in 0..3 { cx.run_until_parked(); cx.update(|window, _| window.refresh()); @@ -55,6 +56,7 @@ pub(super) fn stage( part, }); let root = view.read_with(cx, |stage, _| stage.root.clone()); + ROOT.with(|kept| kept.replace(Some(root.clone()))); let wakes = Rc::new(Cell::new(0)); let count = wakes.clone(); cx.update(|window, cx| { @@ -66,6 +68,16 @@ pub(super) fn stage( (wakes, cx) } +thread_local! { + static ROOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Whether focus fell to the stage's root scope. +pub(super) fn at_root(cx: &mut VisualTestContext) -> bool { + let root = ROOT.with(|root| root.borrow().clone().expect("a staged root")); + cx.update(|window, _| root.is_focused(window)) +} + pub(super) fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); settle(cx); diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 48f97cb..6138add 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -320,11 +320,11 @@ ## T42c Misc · Codes and checks -- [ ] QRCodeScanner — prove: host frames, `rqrr` decode -- [ ] BarcodeDisplay → `data_display::Barcode` -- [ ] Captcha -- [ ] Signature → `forms::SignaturePad` -- [ ] Rating Widget → `forms::Rating` +- [x] QRCodeScanner — proven: `rqrr` 0.11 reads the host's frames off the main thread → `misc::QrCodeScanner`, a camera's view with a square to aim at; each code goes to the owner once +- [x] BarcodeDisplay → `data_display::Barcode` +- [x] Captcha → `misc::Captcha`: the owner's picture and verdict over an answer field that empties and keeps focus with each answer, with a refresh +- [x] Signature → `forms::SignaturePad` +- [x] Rating Widget → `forms::Rating` ## T42d Misc · Asking and learning