Misc · Codes and checks: a QR code scanner and a captcha

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-28 01:40:15 -04:00
1 parent dc4911a68f
commit 692fae6244
14 files changed
+785 -10

No files matched your search

+3 -1
View File
@@ -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.
Generated
+58 -1
View File
@@ -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"
+1
View File
@@ -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"] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+99
View File
@@ -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<RenderImage> {
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<bool> = 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,
))
}
+13 -2
View File
@@ -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()
}
+45
View File
@@ -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()
+193
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnRefresh = Rc<dyn Fn(&mut Window, &mut App)>;
/// 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<OnAnswer>,
on_refresh: Option<OnRefresh>,
}
impl Captcha {
/// `ratio` is the picture's width over its height.
pub fn new(id: impl Into<ElementId>, picture: impl Into<ImageSource>, 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."),
))
})
}
}
+4
View File
@@ -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;
+216
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// The text of the first QR code that decodes in a frame of gpui's BGRA pixels.
pub(crate) fn read(frame: &RenderImage) -> Option<String> {
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<ImageId>,
found: Option<SharedString>,
reading: Option<Task<()>>,
on_scan: Option<OnScan>,
}
/// 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<Arc<RenderImage>>,
on_scan: Option<OnScan>,
}
impl QrCodeScanner {
/// `ratio` is the camera's width over its height.
pub fn new(id: impl Into<ElementId>, 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<RenderImage>) -> 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<RenderImage> {
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
);
}
}
+135
View File
@@ -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<Option<Arc<RenderImage>>> = const { RefCell::new(None) };
static SAID: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
static STATE: Cell<CaptchaState> = const { Cell::new(CaptchaState::Asking) };
}
fn said() -> Vec<String> {
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");
}
+13 -1
View File
@@ -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<Option<FocusHandle>> = 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);
+5 -5
View File
@@ -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