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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 07:15:23 -04:00
1 parent 60c562c956
commit 515653f556
7 files changed
+313 -32

No files matched your search

+6 -25
View File
@@ -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 {
+43
View File
@@ -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();
+141
View File
@@ -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<Pos2>, 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<Pos2> {
// 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<Pos2> = (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));
}
}
+16 -7
View File
@@ -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.
+4
View File
@@ -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 }
+100
View File
@@ -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<u8> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>DrawCraft</string>
<key>CFBundleDisplayName</key><string>DrawCraft</string>
<key>CFBundleIdentifier</key><string>ai.storyteller.drawcraft</string>
<key>CFBundleExecutable</key><string>DrawCraft</string>
<key>CFBundleIconFile</key><string>DrawCraft</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>NSHighResolutionCapable</key><true/>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>CFBundleDocumentTypes</key><array>
<dict><key>CFBundleTypeName</key><string>DrawCraft Document</string><key>CFBundleTypeExtensions</key><array><string>drawcraft</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>SVG</string><key>CFBundleTypeExtensions</key><array><string>svg</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>PDF / Illustrator</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
</array>
</dict></plist>
"#
);
std::fs::write(app.join("Info.plist"), plist).map_err(|e| e.to_string())?;
println!("built {}", root.join("dist/DrawCraft.app").display());
Ok(())
}
+3
View File
@@ -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}");