mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
60c562c956
commit
515653f556
7 files changed
+313
-32
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,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}");
|
||||
|
||||
Reference in new issue
Block a user