From 515653f556a52cebcc1e5b3ab0dab7d2d1916a88 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 07:15:23 -0400 Subject: [PATCH] Vector tool cursors, real egui pointer injection (ui.move/click/drag), xtask bundle (DrawCraft.app with rendered icon) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 31 ++------ crates/ui-egui/src/control.rs | 43 +++++++++++ crates/ui-egui/src/cursors.rs | 141 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 23 ++++-- xtask/Cargo.toml | 4 + xtask/src/bundle.rs | 100 ++++++++++++++++++++++++ xtask/src/main.rs | 3 + 7 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/cursors.rs create mode 100644 xtask/src/bundle.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8370665d..befb64a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -243,12 +243,13 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } } else if let Some(p) = app.hover_doc { let c = app.session.cursor(p, mods(m, space), view_info); - if matches!(c, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue) - && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) - { - pen_cursor_badge(ui, hp, c, &t); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); + // Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors. + let custom = std::env::var_os("DRAWCRAFT_SYSTEM_CURSORS").is_none(); + match ui.input(|i| i.pointer.hover_pos()) { + Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None, + _ => cursor_icon(c), } - cursor_icon(c) } else { egui::CursorIcon::Default }; @@ -256,26 +257,6 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } } -fn pen_cursor_badge(ui: &Ui, p: Pos2, c: Cursor, t: &Tokens) { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pen-cursor"))); - let r = egui::Rect::from_min_size(p + vec2(6.0, 4.0), vec2(16.0, 16.0)); - // Pen nib glyph (black with white outline, like a system cursor). - let tip = r.left_top(); - let nib = vec![tip, tip + vec2(11.0, 4.0), tip + vec2(13.0, 13.0), tip + vec2(4.0, 11.0)]; - painter.add(Shape::convex_polygon(nib, Color32::BLACK, Stroke::new(1.0, Color32::WHITE))); - let _ = t; - let badge = match c { - Cursor::PenClose => "o", - Cursor::PenContinue => "/", - Cursor::PenAdd => "+", - Cursor::PenDelete => "–", - _ => "", - }; - if !badge.is_empty() { - painter.text(r.right_bottom() + vec2(1.0, -2.0), egui::Align2::LEFT_BOTTOM, badge, egui::FontId::proportional(11.0), Color32::BLACK); - } -} - fn cursor_icon(c: Cursor) -> egui::CursorIcon { use egui::CursorIcon as C; match c { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dfdb8165..50349851 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,6 +9,8 @@ //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: //! drive the active tool through the same path as the mouse //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input +//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: +//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` @@ -159,6 +161,47 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) ctx.request_repaint(); ok(Value::Null) } + "ui.move" => { + let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); + app.synthetic.push(egui::Event::PointerMoved(pos)); + ctx.request_repaint(); + ok(Value::Null) + } + "ui.click" | "ui.drag" => { + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let button = match s("button") { + Some("right") | Some("secondary") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + let modifiers = egui::Modifiers { + alt: b("alt"), + ctrl: b("ctrl"), + shift: b("shift"), + mac_cmd: b("cmd") && cfg!(target_os = "macos"), + command: b("cmd"), + }; + let a = egui::pos2(f("x"), f("y")); + let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + if req.method == "ui.drag" { + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).max(1); + for i in 1..=steps { + let t = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + } + } + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + for _ in 1..count { + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + } + ctx.request_repaint(); + ok(Value::Null) + } "ui.text" => { app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ctx.request_repaint(); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs new file mode 100644 index 00000000..2f9db8d6 --- /dev/null +++ b/crates/ui-egui/src/cursors.rs @@ -0,0 +1,141 @@ +//! Tool cursors drawn as vector glyphs (egui only offers system cursors). Black shapes with a white +//! halo, hotspot at `p`, Illustrator's visual grammar: solid arrow (Selection), hollow arrow (Direct +//! Selection), pen nib with state badges, crosshair for drawing tools, curved arrows for rotate. + +use drawcraft_tools::Cursor; +use egui::{Color32, Painter, Pos2, Shape, Stroke, pos2, vec2}; + +const INK: Color32 = Color32::BLACK; +const HALO: Color32 = Color32::WHITE; + +fn poly(p: &Painter, pts: Vec, fill: Color32, stroke: Color32) { + // Halo first (thicker white outline), then the glyph. + p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + p.add(Shape::closed_line(pts, Stroke::new(1.0, stroke))); +} + +fn line(p: &Painter, a: Pos2, b: Pos2) { + p.line_segment([a, b], Stroke::new(3.0, HALO)); + p.line_segment([a, b], Stroke::new(1.2, INK)); +} + +fn arrow_points(o: Pos2) -> Vec { + // Classic pointer, tip at o. + [(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)].iter().map(|(x, y)| o + vec2(*x, *y)).collect() +} + +fn arrow(p: &Painter, o: Pos2, hollow: bool) { + let pts = arrow_points(o); + // The arrow is concave: draw as a filled mesh of two convex parts, then outline. + p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + let fill = if hollow { HALO } else { INK }; + p.add(Shape::convex_polygon(vec![pts[0], pts[1], pts[2], pts[5], pts[6]], fill, Stroke::NONE)); + p.add(Shape::convex_polygon(vec![pts[2], pts[3], pts[4], pts[5]], fill, Stroke::NONE)); + p.add(Shape::closed_line(pts, Stroke::new(1.0, INK))); +} + +fn crosshair(p: &Painter, o: Pos2) { + for (a, b) in + [(vec2(-9.0, 0.0), vec2(-2.0, 0.0)), (vec2(2.0, 0.0), vec2(9.0, 0.0)), (vec2(0.0, -9.0), vec2(0.0, -2.0)), (vec2(0.0, 2.0), vec2(0.0, 9.0))] + { + line(p, o + a, o + b); + } +} + +fn pen(p: &Painter, o: Pos2, badge: &str) { + // Nib pointing to the top-left hotspot. + let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)]; + poly(p, pts, INK, INK); + p.circle_filled(o + vec2(6.0, 6.0), 1.4, HALO); + line(p, o + vec2(10.0, 14.0), o + vec2(14.0, 18.0)); + let b = o + vec2(16.0, 14.0); + match badge { + "o" => { + p.circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(3.0, HALO)); + p.circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(1.2, INK)); + } + "+" => { + line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)); + line(p, b + vec2(3.0, 0.0), b + vec2(3.0, 6.0)); + } + "-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)), + "/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)), + "*" => { + line(p, b + vec2(0.0, 0.0), b + vec2(6.0, 6.0)); + line(p, b + vec2(6.0, 0.0), b + vec2(0.0, 6.0)); + line(p, b + vec2(3.0, -1.0), b + vec2(3.0, 7.0)); + } + _ => {} + } +} + +fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) { + let d = dir.normalized() * 8.0; + let n = vec2(-d.y, d.x) * 0.45; + line(p, o - d, o + d); + for (tip, back) in [(o + d, o + d * 0.45), (o - d, o - d * 0.45)] { + poly(p, vec![tip, back + n, back - n], INK, INK); + } +} + +fn rotate(p: &Painter, o: Pos2) { + let pts: Vec = (0..=10) + .map(|i| { + let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0); + o + vec2(a.cos() * 9.0, -a.sin() * 9.0) + }) + .collect(); + p.add(Shape::line(pts.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::line(pts.clone(), Stroke::new(1.2, INK))); + for end in [pts[0], pts[pts.len() - 1]] { + poly(p, vec![end + vec2(-3.0, -1.0), end + vec2(3.0, -1.0), end + vec2(0.0, 4.0)], INK, INK); + } +} + +fn ibeam(p: &Painter, o: Pos2) { + line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0)); + line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0)); + line(p, o + vec2(-3.0, 8.0), o + vec2(3.0, 8.0)); + line(p, o + vec2(-2.0, 3.0), o + vec2(2.0, 3.0)); +} + +/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should +/// stay system cursors (hand, zoom, busy states). +pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { + match c { + Cursor::Arrow => arrow(painter, p, false), + Cursor::ArrowHollow => arrow(painter, p, true), + Cursor::Move => { + arrow(painter, p, false); + double_arrow(painter, p + vec2(15.0, 18.0), vec2(1.0, 0.0)); + } + Cursor::Crosshair | Cursor::Eyedropper => crosshair(painter, p), + Cursor::ResizeH => double_arrow(painter, p, vec2(1.0, 0.0)), + Cursor::ResizeV => double_arrow(painter, p, vec2(0.0, 1.0)), + Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)), + Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)), + Cursor::Rotate => rotate(painter, p), + Cursor::Pen => pen(painter, p, "*"), + Cursor::PenAdd => pen(painter, p, "+"), + Cursor::PenDelete => pen(painter, p, "-"), + Cursor::PenClose => pen(painter, p, "o"), + Cursor::PenContinue => pen(painter, p, "/"), + Cursor::Text => ibeam(painter, p), + _ => return false, + } + let _ = pos2; + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arrow_hotspot_is_tip() { + let pts = arrow_points(pos2(10.0, 20.0)); + assert_eq!(pts[0], pos2(10.0, 20.0)); + assert!(pts.iter().all(|q| q.x >= 10.0 && q.y >= 20.0)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cad3f223..3425db75 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod canvas; pub mod chrome; pub mod control; +pub mod cursors; pub mod dialogs; pub mod dock; pub mod icon_data; @@ -329,6 +330,9 @@ impl DrawcraftApp { self.last_time = now; self.sync_views(); self.drain_control(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } self.collect_screenshots(ctx); self.issue_screenshots(ctx); self.drain_inbox(); @@ -354,14 +358,19 @@ impl DrawcraftApp { /// Inject synthetic events (one press/release step per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if !self.synthetic.is_empty() { - let n = self - .synthetic - .iter() - .position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })) - .map_or(self.synthetic.len(), |i| i + 1); - raw.events.extend(self.synthetic.drain(..n)); + if self.synthetic.is_empty() { + return; } + // Pointer events go one per frame so egui sees presses, drags and releases as real input; + // keyboard sequences go up to the key release. + let n = match self.synthetic[0] { + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + raw.events.extend(self.synthetic.drain(..n)); } /// Lay out the whole window. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..2a3c9d86 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,7 @@ publish = false [dependencies] serde_json = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-render = { workspace = true } diff --git a/xtask/src/bundle.rs b/xtask/src/bundle.rs new file mode 100644 index 00000000..f07cdb48 --- /dev/null +++ b/xtask/src/bundle.rs @@ -0,0 +1,100 @@ +//! `cargo xtask bundle`: build the release app and wrap it as `dist/DrawCraft.app` (macOS), with an +//! icon rendered by DrawCraft's own renderer. + +use std::path::Path; +use std::process::Command; + +use drawcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use drawcraft_doc::{Appearance, Document, Node}; +use drawcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes}; +use drawcraft_render::Renderer; + +/// The app icon: a warm-gradient rounded square with a white pen nib (our own artwork). +pub fn icon_png(size: u32) -> Vec { + let s = 1024.0; + let mut d = Document::new(s, s); + let l = d.layers[0].id; + let grad = GradientPaint::new(Gradient { + kind: GradientKind::Linear, + stops: vec![ + GradientStop { offset: 0.0, color: Color::from_hex("#ff9a3c").unwrap(), opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::from_hex("#d4145a").unwrap(), opacity: 1.0, midpoint: 0.5 }, + ], + }); + let mut gp = grad; + gp.angle = -45.0; + let bg = d.alloc_id(); + d.insert(Some(l), 9, Node::path(bg, shapes::rounded_rectangle(Rect::new(100.0, 100.0, 924.0, 924.0), 185.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0))).unwrap(); + // Pen nib. + let c = Point::new(512.0, 520.0); + let nib = PathData::single(SubPath::new( + vec![ + Anchor::corner(Point::new(c.x, c.y + 250.0)), + Anchor::with_handles(Point::new(c.x - 170.0, c.y - 20.0), Point::new(c.x - 150.0, c.y + 80.0), Point::new(c.x - 170.0, c.y - 90.0)), + Anchor::corner(Point::new(c.x - 95.0, c.y - 230.0)), + Anchor::corner(Point::new(c.x + 95.0, c.y - 230.0)), + Anchor::with_handles(Point::new(c.x + 170.0, c.y - 20.0), Point::new(c.x + 170.0, c.y - 90.0), Point::new(c.x + 150.0, c.y + 80.0)), + ], + true, + )); + let n = d.alloc_id(); + d.insert(Some(l), 9, Node::path(n, nib, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0))).unwrap(); + let hole = d.alloc_id(); + d.insert(Some(l), 9, Node::path(hole, shapes::ellipse(Rect::from_center_size(Point::new(c.x, c.y - 30.0), (74.0, 74.0))), Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0))).unwrap(); + d.node_mut(hole).unwrap().appearance = Appearance::basic(Paint::solid(Color::from_hex("#e8573f").unwrap()), Paint::None, 0.0); + let slit = d.alloc_id(); + d.insert(Some(l), 9, Node::path(slit, shapes::rectangle(Rect::new(c.x - 9.0, c.y + 5.0, c.x + 9.0, c.y + 245.0)), Appearance::basic(Paint::solid(Color::from_hex("#e0457a").unwrap()), Paint::None, 0.0))).unwrap(); + Renderer::new().render_region(&d, Rect::new(0.0, 0.0, s, s), size as f64 / s, false).to_png() +} + +pub fn run(root: &Path) -> Result<(), String> { + let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())).current_dir(root).args(["build", "--release", "-p", "drawcraft", "-p", "drawcraft-cli"]).status().map_err(|e| e.to_string())?; + if !status.success() { + return Err("release build failed".into()); + } + let target = std::env::var("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|_| root.join("target")); + let app = root.join("dist/DrawCraft.app/Contents"); + let _ = std::fs::remove_dir_all(root.join("dist/DrawCraft.app")); + std::fs::create_dir_all(app.join("MacOS")).map_err(|e| e.to_string())?; + std::fs::create_dir_all(app.join("Resources")).map_err(|e| e.to_string())?; + std::fs::copy(target.join("release/drawcraft"), app.join("MacOS/DrawCraft")).map_err(|e| format!("copy app: {e}"))?; + std::fs::copy(target.join("release/drawcraft-cli"), app.join("MacOS/drawcraft-cli")).map_err(|e| format!("copy cli: {e}"))?; + // Icon set → .icns via iconutil (macOS build tool). + let iconset = root.join("dist/DrawCraft.iconset"); + let _ = std::fs::remove_dir_all(&iconset); + std::fs::create_dir_all(&iconset).map_err(|e| e.to_string())?; + for (sz, name) in [(16, "16x16"), (32, "16x16@2x"), (32, "32x32"), (64, "32x32@2x"), (128, "128x128"), (256, "128x128@2x"), (256, "256x256"), (512, "256x256@2x"), (512, "512x512"), (1024, "512x512@2x")] { + std::fs::write(iconset.join(format!("icon_{name}.png")), icon_png(sz)).map_err(|e| e.to_string())?; + } + std::fs::write(root.join("dist/DrawCraft-icon.png"), icon_png(1024)).map_err(|e| e.to_string())?; + let ok = Command::new("iconutil").args(["-c", "icns"]).arg(&iconset).arg("-o").arg(app.join("Resources/DrawCraft.icns")).status().map(|s| s.success()).unwrap_or(false); + if !ok { + eprintln!("warning: iconutil failed or missing; bundle has no icon"); + } + let version = env!("CARGO_PKG_VERSION"); + let plist = format!( + r#" + + +CFBundleNameDrawCraft +CFBundleDisplayNameDrawCraft +CFBundleIdentifierai.storyteller.drawcraft +CFBundleExecutableDrawCraft +CFBundleIconFileDrawCraft +CFBundlePackageTypeAPPL +CFBundleShortVersionString{version} +CFBundleVersion{version} +NSHighResolutionCapable +LSMinimumSystemVersion11.0 +CFBundleDocumentTypes + CFBundleTypeNameDrawCraft DocumentCFBundleTypeExtensionsdrawcraftCFBundleTypeRoleEditor + CFBundleTypeNameSVGCFBundleTypeExtensionssvgCFBundleTypeRoleEditor + CFBundleTypeNamePDF / IllustratorCFBundleTypeExtensionspdfaiCFBundleTypeRoleViewer + + +"# + ); + std::fs::write(app.join("Info.plist"), plist).map_err(|e| e.to_string())?; + println!("built {}", root.join("dist/DrawCraft.app").display()); + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b94a0d15..90dde793 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,6 +3,7 @@ //! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are //! invoked through `std::process::Command`. +mod bundle; mod layers; mod stats; @@ -18,6 +19,7 @@ commands: ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite + bundle build dist/DrawCraft.app (macOS) with an icon rendered by DrawCraft stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) "; @@ -29,6 +31,7 @@ fn main() -> ExitCode { Some("wasm") => cmd_wasm(), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), + Some("bundle") => bundle::run(&root()), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("-h" | "--help" | "help") | None => { print!("{USAGE}");