From 42df20ca7f665ac9c6aa965810eb97ddf22841f8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 09:32:20 -0400 Subject: [PATCH] Panels to Illustrator parity (19 panels), ~70 menu commands, generic param dialogs, view rotation, property/model-based tests + MCP e2e; fix arrange overflow, compound grouping, double journaling, float round-trip, coordinate sanity, simplify growth, batch rollback of session state Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 26 + Cargo.toml | 2 +- apps/drawcraft-cli/Cargo.toml | 1 + apps/drawcraft-cli/tests/mcp_e2e.rs | 260 +++++ apps/drawcraft/src/native_menu.rs | 21 +- assets/icons/dc-al-bottom.svg | 1 + assets/icons/dc-al-hcenter.svg | 1 + assets/icons/dc-al-left.svg | 1 + assets/icons/dc-al-right.svg | 1 + assets/icons/dc-al-top.svg | 1 + assets/icons/dc-al-vcenter.svg | 1 + assets/icons/dc-alignto-artboard.svg | 1 + assets/icons/dc-alignto-key.svg | 1 + assets/icons/dc-alignto-selection.svg | 1 + assets/icons/dc-arrow-down.svg | 1 + assets/icons/dc-arrow-up.svg | 1 + assets/icons/dc-artboard-options.svg | 1 + assets/icons/dc-cap-butt.svg | 1 + assets/icons/dc-cap-round.svg | 1 + assets/icons/dc-cap-square.svg | 1 + assets/icons/dc-clear.svg | 1 + assets/icons/dc-cube.svg | 1 + assets/icons/dc-dash-align.svg | 1 + assets/icons/dc-dash-exact.svg | 1 + assets/icons/dc-dist-bottom.svg | 1 + assets/icons/dc-dist-hcenter.svg | 1 + assets/icons/dc-dist-hspace.svg | 1 + assets/icons/dc-dist-left.svg | 1 + assets/icons/dc-dist-right.svg | 1 + assets/icons/dc-dist-top.svg | 1 + assets/icons/dc-dist-vcenter.svg | 1 + assets/icons/dc-dist-vspace.svg | 1 + assets/icons/dc-folder.svg | 1 + assets/icons/dc-fx.svg | 1 + assets/icons/dc-gamut.svg | 1 + assets/icons/dc-grad-freeform.svg | 1 + assets/icons/dc-grad-linear.svg | 1 + assets/icons/dc-grad-radial.svg | 1 + assets/icons/dc-grid-view.svg | 1 + assets/icons/dc-join-bevel.svg | 1 + assets/icons/dc-join-miter.svg | 1 + assets/icons/dc-join-round.svg | 1 + assets/icons/dc-list-view.svg | 1 + assets/icons/dc-mask-none.svg | 1 + assets/icons/dc-new-fill.svg | 1 + assets/icons/dc-new-item.svg | 1 + assets/icons/dc-new-stroke.svg | 1 + assets/icons/dc-options.svg | 1 + assets/icons/dc-para-center.svg | 1 + assets/icons/dc-para-justify-all.svg | 1 + assets/icons/dc-para-justify-center.svg | 1 + assets/icons/dc-para-justify-left.svg | 1 + assets/icons/dc-para-justify-right.svg | 1 + assets/icons/dc-para-left.svg | 1 + assets/icons/dc-para-right.svg | 1 + assets/icons/dc-pf-crop.svg | 1 + assets/icons/dc-pf-divide.svg | 1 + assets/icons/dc-pf-exclude.svg | 1 + assets/icons/dc-pf-intersect.svg | 1 + assets/icons/dc-pf-merge.svg | 1 + assets/icons/dc-pf-minus-back.svg | 1 + assets/icons/dc-pf-minus-front.svg | 1 + assets/icons/dc-pf-outline.svg | 1 + assets/icons/dc-pf-trim.svg | 1 + assets/icons/dc-pf-unite.svg | 1 + assets/icons/dc-place-symbol.svg | 1 + assets/icons/dc-rearrange.svg | 1 + assets/icons/dc-remove-brush.svg | 1 + assets/icons/dc-reverse.svg | 1 + assets/icons/dc-stroke-center.svg | 1 + assets/icons/dc-stroke-inside.svg | 1 + assets/icons/dc-stroke-outside.svg | 1 + assets/icons/dc-swatch-kinds.svg | 1 + assets/icons/dc-width-profile.svg | 1 + assets/icons/dc-zoom-large.svg | 1 + assets/icons/dc-zoom-small.svg | 1 + crates/engine/Cargo.toml | 4 + crates/engine/src/cmd/colorcmds.rs | 333 ++++++ crates/engine/src/cmd/create.rs | 15 +- crates/engine/src/cmd/docmenu.rs | 499 +++++++++ crates/engine/src/cmd/edit.rs | 12 +- crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/menucmds.rs | 1048 +++++++++++++++++++ crates/engine/src/cmd/mod.rs | 10 + crates/engine/src/cmd/object.rs | 36 +- crates/engine/src/cmd/panelcmds.rs | 552 ++++++++++ crates/engine/src/cmd/typemenu.rs | 603 +++++++++++ crates/engine/src/lib.rs | 48 +- crates/engine/src/tests_menucmds.rs | 706 +++++++++++++ crates/engine/src/tests_panelcmds.rs | 166 +++ crates/engine/tests/command_sweep.rs | 328 ++++++ crates/engine/tests/known_bugs.rs | 57 + crates/engine/tests/model_based.rs | 163 +++ crates/format/Cargo.toml | 2 + crates/format/tests/prop_format.rs | 147 +++ crates/geom/Cargo.toml | 1 + crates/geom/tests/prop_geom.rs | 275 +++++ crates/mcp/Cargo.toml | 4 + crates/mcp/tests/protocol_props.rs | 271 +++++ crates/pathops/Cargo.toml | 1 + crates/pathops/src/edit.rs | 7 +- crates/pathops/tests/prop_pathops.rs | 254 +++++ crates/render/Cargo.toml | 2 + crates/render/examples/bench.rs | 6 +- crates/render/tests/golden.rs | 531 ++++++++++ crates/svg/Cargo.toml | 2 + crates/svg/tests/prop_svg.rs | 118 +++ crates/testkit/Cargo.toml | 12 +- crates/testkit/src/fixtures.rs | 211 ++++ crates/testkit/src/geom.rs | 93 ++ crates/testkit/src/invariants.rs | 204 ++++ crates/testkit/src/lib.rs | 54 +- crates/testkit/src/raster.rs | 174 +++ crates/testkit/src/strategies.rs | 518 +++++++++ crates/ui-egui/src/canvas.rs | 100 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs.rs | 11 +- crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/icon_data.rs | 71 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 273 +++-- crates/ui-egui/src/panels/align.rs | 153 +++ crates/ui-egui/src/panels/appearance.rs | 528 ++++++++++ crates/ui-egui/src/panels/artboards.rs | 126 +++ crates/ui-egui/src/panels/brushes.rs | 106 ++ crates/ui-egui/src/panels/character.rs | 215 ++++ crates/ui-egui/src/panels/color.rs | 489 +++++++++ crates/ui-egui/src/panels/color_guide.rs | 160 +++ crates/ui-egui/src/panels/gradient.rs | 475 +++++++++ crates/ui-egui/src/panels/graphic_styles.rs | 144 +++ crates/ui-egui/src/panels/history.rs | 76 ++ crates/ui-egui/src/panels/info.rs | 104 ++ crates/ui-egui/src/panels/mod.rs | 703 ++++--------- crates/ui-egui/src/panels/navigator.rs | 168 +++ crates/ui-egui/src/panels/paragraph.rs | 97 ++ crates/ui-egui/src/panels/pathfinder.rs | 77 ++ crates/ui-egui/src/panels/stroke.rs | 424 ++++++++ crates/ui-egui/src/panels/swatches.rs | 407 +++++++ crates/ui-egui/src/panels/symbols.rs | 99 ++ crates/ui-egui/src/panels/transform.rs | 172 +++ crates/ui-egui/src/panels/transparency.rs | 113 ++ crates/ui-egui/src/widgets.rs | 361 ++++++- xtask/src/bundle.rs | 61 +- 143 files changed, 12927 insertions(+), 642 deletions(-) create mode 100644 apps/drawcraft-cli/tests/mcp_e2e.rs create mode 100644 assets/icons/dc-al-bottom.svg create mode 100644 assets/icons/dc-al-hcenter.svg create mode 100644 assets/icons/dc-al-left.svg create mode 100644 assets/icons/dc-al-right.svg create mode 100644 assets/icons/dc-al-top.svg create mode 100644 assets/icons/dc-al-vcenter.svg create mode 100644 assets/icons/dc-alignto-artboard.svg create mode 100644 assets/icons/dc-alignto-key.svg create mode 100644 assets/icons/dc-alignto-selection.svg create mode 100644 assets/icons/dc-arrow-down.svg create mode 100644 assets/icons/dc-arrow-up.svg create mode 100644 assets/icons/dc-artboard-options.svg create mode 100644 assets/icons/dc-cap-butt.svg create mode 100644 assets/icons/dc-cap-round.svg create mode 100644 assets/icons/dc-cap-square.svg create mode 100644 assets/icons/dc-clear.svg create mode 100644 assets/icons/dc-cube.svg create mode 100644 assets/icons/dc-dash-align.svg create mode 100644 assets/icons/dc-dash-exact.svg create mode 100644 assets/icons/dc-dist-bottom.svg create mode 100644 assets/icons/dc-dist-hcenter.svg create mode 100644 assets/icons/dc-dist-hspace.svg create mode 100644 assets/icons/dc-dist-left.svg create mode 100644 assets/icons/dc-dist-right.svg create mode 100644 assets/icons/dc-dist-top.svg create mode 100644 assets/icons/dc-dist-vcenter.svg create mode 100644 assets/icons/dc-dist-vspace.svg create mode 100644 assets/icons/dc-folder.svg create mode 100644 assets/icons/dc-fx.svg create mode 100644 assets/icons/dc-gamut.svg create mode 100644 assets/icons/dc-grad-freeform.svg create mode 100644 assets/icons/dc-grad-linear.svg create mode 100644 assets/icons/dc-grad-radial.svg create mode 100644 assets/icons/dc-grid-view.svg create mode 100644 assets/icons/dc-join-bevel.svg create mode 100644 assets/icons/dc-join-miter.svg create mode 100644 assets/icons/dc-join-round.svg create mode 100644 assets/icons/dc-list-view.svg create mode 100644 assets/icons/dc-mask-none.svg create mode 100644 assets/icons/dc-new-fill.svg create mode 100644 assets/icons/dc-new-item.svg create mode 100644 assets/icons/dc-new-stroke.svg create mode 100644 assets/icons/dc-options.svg create mode 100644 assets/icons/dc-para-center.svg create mode 100644 assets/icons/dc-para-justify-all.svg create mode 100644 assets/icons/dc-para-justify-center.svg create mode 100644 assets/icons/dc-para-justify-left.svg create mode 100644 assets/icons/dc-para-justify-right.svg create mode 100644 assets/icons/dc-para-left.svg create mode 100644 assets/icons/dc-para-right.svg create mode 100644 assets/icons/dc-pf-crop.svg create mode 100644 assets/icons/dc-pf-divide.svg create mode 100644 assets/icons/dc-pf-exclude.svg create mode 100644 assets/icons/dc-pf-intersect.svg create mode 100644 assets/icons/dc-pf-merge.svg create mode 100644 assets/icons/dc-pf-minus-back.svg create mode 100644 assets/icons/dc-pf-minus-front.svg create mode 100644 assets/icons/dc-pf-outline.svg create mode 100644 assets/icons/dc-pf-trim.svg create mode 100644 assets/icons/dc-pf-unite.svg create mode 100644 assets/icons/dc-place-symbol.svg create mode 100644 assets/icons/dc-rearrange.svg create mode 100644 assets/icons/dc-remove-brush.svg create mode 100644 assets/icons/dc-reverse.svg create mode 100644 assets/icons/dc-stroke-center.svg create mode 100644 assets/icons/dc-stroke-inside.svg create mode 100644 assets/icons/dc-stroke-outside.svg create mode 100644 assets/icons/dc-swatch-kinds.svg create mode 100644 assets/icons/dc-width-profile.svg create mode 100644 assets/icons/dc-zoom-large.svg create mode 100644 assets/icons/dc-zoom-small.svg create mode 100644 crates/engine/src/cmd/colorcmds.rs create mode 100644 crates/engine/src/cmd/docmenu.rs create mode 100644 crates/engine/src/cmd/menucmds.rs create mode 100644 crates/engine/src/cmd/panelcmds.rs create mode 100644 crates/engine/src/cmd/typemenu.rs create mode 100644 crates/engine/src/tests_menucmds.rs create mode 100644 crates/engine/src/tests_panelcmds.rs create mode 100644 crates/engine/tests/command_sweep.rs create mode 100644 crates/engine/tests/known_bugs.rs create mode 100644 crates/engine/tests/model_based.rs create mode 100644 crates/format/tests/prop_format.rs create mode 100644 crates/geom/tests/prop_geom.rs create mode 100644 crates/mcp/tests/protocol_props.rs create mode 100644 crates/pathops/tests/prop_pathops.rs create mode 100644 crates/render/tests/golden.rs create mode 100644 crates/svg/tests/prop_svg.rs create mode 100644 crates/testkit/src/fixtures.rs create mode 100644 crates/testkit/src/geom.rs create mode 100644 crates/testkit/src/invariants.rs create mode 100644 crates/testkit/src/raster.rs create mode 100644 crates/testkit/src/strategies.rs create mode 100644 crates/ui-egui/src/panels/align.rs create mode 100644 crates/ui-egui/src/panels/appearance.rs create mode 100644 crates/ui-egui/src/panels/artboards.rs create mode 100644 crates/ui-egui/src/panels/brushes.rs create mode 100644 crates/ui-egui/src/panels/character.rs create mode 100644 crates/ui-egui/src/panels/color.rs create mode 100644 crates/ui-egui/src/panels/color_guide.rs create mode 100644 crates/ui-egui/src/panels/gradient.rs create mode 100644 crates/ui-egui/src/panels/graphic_styles.rs create mode 100644 crates/ui-egui/src/panels/history.rs create mode 100644 crates/ui-egui/src/panels/info.rs create mode 100644 crates/ui-egui/src/panels/navigator.rs create mode 100644 crates/ui-egui/src/panels/paragraph.rs create mode 100644 crates/ui-egui/src/panels/pathfinder.rs create mode 100644 crates/ui-egui/src/panels/stroke.rs create mode 100644 crates/ui-egui/src/panels/swatches.rs create mode 100644 crates/ui-egui/src/panels/symbols.rs create mode 100644 crates/ui-egui/src/panels/transform.rs create mode 100644 crates/ui-egui/src/panels/transparency.rs diff --git a/Cargo.lock b/Cargo.lock index 4bdc1ab5..264a38d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,7 @@ name = "drawcraft-cli" version = "0.1.0" dependencies = [ "drawcraft-mcp", + "drawcraft-testkit", "serde_json", ] @@ -892,10 +893,12 @@ dependencies = [ "drawcraft-pdf", "drawcraft-render", "drawcraft-svg", + "drawcraft-testkit", "drawcraft-text", "drawcraft-tools", "kurbo 0.13.1", "log", + "proptest", "serde", "serde_json", "thiserror 2.0.21", @@ -907,6 +910,8 @@ version = "0.1.0" dependencies = [ "drawcraft-doc", "drawcraft-geom", + "drawcraft-testkit", + "proptest", "serde", "serde_json", "thiserror 2.0.21", @@ -916,6 +921,7 @@ dependencies = [ name = "drawcraft-geom" version = "0.1.0" dependencies = [ + "drawcraft-testkit", "kurbo 0.13.1", "proptest", "serde", @@ -933,8 +939,10 @@ dependencies = [ "drawcraft-geom", "drawcraft-render", "drawcraft-svg", + "drawcraft-testkit", "drawcraft-tools", "log", + "proptest", "serde_json", ] @@ -943,6 +951,7 @@ name = "drawcraft-pathops" version = "0.1.0" dependencies = [ "drawcraft-geom", + "drawcraft-testkit", "kurbo 0.13.1", "linesweeper", "proptest", @@ -975,9 +984,11 @@ dependencies = [ "drawcraft-doc", "drawcraft-effects", "drawcraft-geom", + "drawcraft-testkit", "drawcraft-text", "image", "log", + "proptest", "serde_json", "vello_common 0.2.0", "vello_cpu 0.2.0", @@ -990,8 +1001,10 @@ dependencies = [ "drawcraft-color", "drawcraft-doc", "drawcraft-geom", + "drawcraft-testkit", "image", "kurbo 0.13.1", + "proptest", "svgtypes 0.16.1", "thiserror 2.0.21", "usvg 0.48.1", @@ -1001,8 +1014,17 @@ dependencies = [ name = "drawcraft-testkit" version = "0.1.0" dependencies = [ + "drawcraft-color", "drawcraft-doc", + "drawcraft-engine", + "drawcraft-format", "drawcraft-geom", + "drawcraft-render", + "drawcraft-svg", + "image", + "kurbo 0.13.1", + "proptest", + "serde_json", ] [[package]] @@ -5026,6 +5048,10 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" name = "xtask" version = "0.1.0" dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-geom", + "drawcraft-render", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index f4a8b85b..1bb949ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ kurbo = { version = "0.13", features = ["serde"] } peniko = "0.6" thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } -serde_json = "1" +serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" proptest = "1" diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml index 674965f5..09436fb4 100644 --- a/apps/drawcraft-cli/Cargo.toml +++ b/apps/drawcraft-cli/Cargo.toml @@ -18,4 +18,5 @@ drawcraft-mcp = { workspace = true } serde_json = { workspace = true } [dev-dependencies] +drawcraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/apps/drawcraft-cli/tests/mcp_e2e.rs b/apps/drawcraft-cli/tests/mcp_e2e.rs new file mode 100644 index 00000000..54bbc97b --- /dev/null +++ b/apps/drawcraft-cli/tests/mcp_e2e.rs @@ -0,0 +1,260 @@ +//! End-to-end MCP over stdio against the built `drawcraft-cli mcp --headless` binary. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use drawcraft_testkit::raster::Image; +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_drawcraft-cli"); + +struct Mcp { + child: Child, + stdin: Option, + stdout: BufReader, + next: u64, +} + +impl Mcp { + fn spawn() -> Self { + let mut child = Command::new(BIN) + .args(["mcp", "--headless"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn drawcraft-cli"); + let stdin = child.stdin.take(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Self { child, stdin, stdout, next: 1 } + } + fn send(&mut self, v: &Value) { + let w = self.stdin.as_mut().unwrap(); + writeln!(w, "{v}").unwrap(); + w.flush().unwrap(); + } + fn read(&mut self) -> Value { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).unwrap(); + assert!(n > 0, "server closed stdout"); + serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad JSON line {line}: {e}")) + } + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next; + self.next += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let v = self.read(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], id, "reply to the wrong request: {v}"); + v + } + fn notify(&mut self, method: &str) { + self.send(&json!({"jsonrpc": "2.0", "method": method})); + } + /// Call a tool; panics on protocol errors or tool errors. + fn tool(&mut self, name: &str, args: Value) -> Value { + let v = self.request("tools/call", json!({"name": name, "arguments": args})); + assert!(v.get("error").is_none(), "{name}: {v}"); + let r = v["result"].clone(); + assert_ne!(r["isError"], json!(true), "{name} {args}: {r}"); + r + } + fn tool_json(&mut self, name: &str, args: Value) -> Value { + let r = self.tool(name, args); + let text: String = + r["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect(); + serde_json::from_str(&text).unwrap_or(Value::String(text)) + } + fn run(&mut self, command: &str, params: Value) -> Value { + self.tool_json("run_command", json!({"command": command, "params": params})) + } + fn document(&mut self) -> Value { + let v = self.request("resources/read", json!({"uri": "drawcraft://document/json"})); + serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap() + } + fn art_count(&mut self) -> usize { + let d: drawcraft_testkit::doc::Document = serde_json::from_value(self.document()).unwrap(); + d.node_count() - d.layers.len() + } + fn initialize(&mut self) { + let v = + self.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}})); + let r = &v["result"]; + assert_eq!(r["serverInfo"]["name"], "drawcraft"); + assert!(r["protocolVersion"].is_string()); + assert!(r["capabilities"]["tools"].is_object()); + self.notify("notifications/initialized"); + } + fn close(mut self) { + drop(self.stdin.take()); + let status = self.child.wait().unwrap(); + assert!(status.success(), "server exited with {status}"); + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +fn tmp(name: &str) -> String { + drawcraft_testkit::temp_dir("cli-mcp-e2e").join(name).to_string_lossy().to_string() +} + +#[test] +fn initialize_and_list_tools_with_valid_schemas() { + let mut m = Mcp::spawn(); + m.initialize(); + let v = m.request("tools/list", json!({})); + let tools = v["result"]["tools"].as_array().unwrap().clone(); + assert!(tools.len() >= 15); + for t in &tools { + let name = t["name"].as_str().unwrap(); + assert!(!name.is_empty() && t["description"].as_str().is_some_and(|d| !d.is_empty()), "{t}"); + let s = &t["inputSchema"]; + assert_eq!(s["type"], "object", "{name}"); + let props = s["properties"].as_object().unwrap_or_else(|| panic!("{name}: no properties")); + for r in s.get("required").and_then(Value::as_array).into_iter().flatten() { + assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} missing"); + } + } + for want in ["run_command", "draw_shape", "draw_path", "pointer_gesture", "screenshot", "export", "open_file", "undo", "redo", "inspect_document"] + { + assert!(tools.iter().any(|t| t["name"] == want), "missing tool {want}"); + } + // ping and an unknown method. + assert_eq!(m.request("ping", json!({}))["result"], json!({})); + assert_eq!(m.request("no/such", json!({}))["error"]["code"], -32601); + m.close(); +} + +#[test] +fn draw_inspect_batch_undo_redo() { + let mut m = Mcp::spawn(); + m.initialize(); + let base = m.art_count(); + let r = + m.tool_json("draw_shape", json!({"shape": "rectangle", "x": 20, "y": 20, "width": 100, "height": 80, "fill": "#ff0000", "stroke": "none"})); + assert!(r["id"].is_u64(), "{r}"); + m.tool_json("draw_path", json!({"points": [[200, 50], [300, 50], [250, 150]], "closed": true, "fill": [0, 0, 1]})); + m.tool_json("draw_path", json!({"d": "M 50 300 C 100 250 150 350 200 300", "stroke": "#00aa00", "strokeWidth": 3})); + m.tool("pointer_gesture", json!({"tool": "ellipse", "events": [ + {"kind": "down", "x": 300, "y": 300}, {"kind": "drag", "x": 350, "y": 340}, {"kind": "drag", "x": 400, "y": 380}, {"kind": "up", "x": 400, "y": 380} + ]})); + assert_eq!(m.art_count(), base + 4); + let inspect = m.tool_json("inspect_document", json!({})); + let text = inspect.to_string(); + assert!(text.contains("Rectangle") || text.contains("rectangle"), "{text}"); + + // A batch is one undo step. + let before = m.document(); + m.run( + "command.batch", + json!({"commands": [ + {"command": "shape.star", "params": {"cx": 100, "cy": 500, "radius1": 40, "radius2": 20}}, + {"command": "shape.polygon", "params": {"cx": 200, "cy": 500, "radius": 30}}, + {"command": "select.all", "params": {}}, + {"command": "object.group", "params": {}}, + ]}), + ); + let after = m.document(); + assert_ne!(before, after); + m.tool("undo", json!({})); + assert!(drawcraft_testkit::invariants::json_approx_eq(&m.document(), &before, 1e-12)); + m.tool("redo", json!({})); + assert!(drawcraft_testkit::invariants::json_approx_eq(&m.document(), &after, 1e-12)); + + // Tool errors are reported in-band, and the server keeps working. + let v = m.request("tools/call", json!({"name": "run_command", "arguments": {"command": "no.such.command"}})); + assert_eq!(v["result"]["isError"], json!(true), "{v}"); + let v = m.request("tools/call", json!({"name": "draw_shape", "arguments": {"shape": "hexagram"}})); + assert_eq!(v["result"]["isError"], json!(true)); + m.tool("inspect_document", json!({})); + m.close(); +} + +#[test] +fn screenshot_is_a_decodable_png() { + let mut m = Mcp::spawn(); + m.initialize(); + m.tool("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100, "fill": "#ff0000", "stroke": "none"})); + let path = tmp("shot.png"); + let r = m.tool("screenshot", json!({"scale": 0.5, "path": path})); + let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content").clone(); + assert_eq!(img["mimeType"], "image/png"); + let png = drawcraft_testkit::format::base64_decode(img["data"].as_str().unwrap()).expect("base64"); + let decoded = Image::from_png(&png).expect("decodable PNG"); + let doc: drawcraft_testkit::doc::Document = serde_json::from_value(m.document()).unwrap(); + let ab = doc.artboards[0].rect; + assert_eq!(decoded.width, (ab.width() * 0.5).round() as u32); + assert_eq!(decoded.height, (ab.height() * 0.5).round() as u32); + // The red square covers the top-left 50×50 px. + let p = decoded.over_white(10, 10); + assert!(p[0] > 240 && p[1] < 20 && p[2] < 20, "{p:?}"); + let q = decoded.over_white(decoded.width - 5, decoded.height - 5); + assert_eq!(q, [255, 255, 255]); + // The saved file is the same PNG. + assert_eq!(Image::from_png(&std::fs::read(&path).unwrap()).unwrap(), decoded); + m.close(); +} + +#[test] +fn export_formats_and_reopen() { + let mut m = Mcp::spawn(); + m.initialize(); + m.tool("draw_shape", json!({"shape": "ellipse", "x": 50, "y": 50, "width": 200, "height": 120, "fill": "#3366ff"})); + m.tool("draw_shape", json!({"shape": "star", "cx": 300, "cy": 300, "radius1": 80, "radius2": 40, "fill": "#ffcc00"})); + let original = m.document(); + let n = m.art_count(); + + let dc = tmp("e2e.drawcraft"); + let svg = tmp("e2e.svg"); + let pdf = tmp("e2e.pdf"); + let png = tmp("e2e.png"); + m.tool("export", json!({"format": "drawcraft", "path": dc})); + m.tool("export", json!({"path": svg})); + m.tool("export", json!({"path": png, "scale": 0.25})); + m.run("document.export", json!({"format": "pdf", "path": pdf})); + assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF")); + assert!(std::fs::read_to_string(&svg).unwrap().contains("= n, "svg re-open lost art"); + m.run("document.open", json!({"path": pdf})); + assert!(m.art_count() >= n, "pdf re-open lost art"); + let docs = m.tool_json("inspect_document", json!({})); + assert!(docs.is_object()); + + // save_file writes the native format to a new path and it loads. + let saved = tmp("saved.drawcraft"); + m.tool("save_file", json!({"path": saved})); + let d = drawcraft_testkit::format::load(&std::fs::read(&saved).unwrap()).unwrap(); + drawcraft_testkit::invariants::check_document(&d).unwrap(); + m.close(); +} + +#[test] +fn many_requests_in_one_write_are_answered_in_order() { + let mut m = Mcp::spawn(); + m.initialize(); + let mut batch = String::new(); + for i in 0..50u64 { + batch.push_str(&json!({"jsonrpc": "2.0", "id": 1000 + i, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": i, "y": i, "width": 5, "height": 5}}}).to_string()); + batch.push('\n'); + } + let w = m.stdin.as_mut().unwrap(); + w.write_all(batch.as_bytes()).unwrap(); + w.flush().unwrap(); + for i in 0..50u64 { + let v = m.read(); + assert_eq!(v["id"], 1000 + i); + assert_ne!(v["result"]["isError"], json!(true)); + } + assert!(m.art_count() >= 50); + m.close(); +} diff --git a/apps/drawcraft/src/native_menu.rs b/apps/drawcraft/src/native_menu.rs index beb70965..09c40244 100644 --- a/apps/drawcraft/src/native_menu.rs +++ b/apps/drawcraft/src/native_menu.rs @@ -18,7 +18,7 @@ enum Handle { pub struct NativeMenu { _menu: Menu, - items: HashMap, + items: HashMap, last_refresh: f64, } @@ -43,15 +43,18 @@ impl NativeMenu { } menu.init_for_nsapp(); app.native_menu = true; - app.native_shortcuts = - items.values().filter(|(c, p, _)| p.is_null() && menus::shortcut_of(c).and_then(accel).is_some()).map(|(c, _, _)| c.clone()).collect(); + app.native_shortcuts = items + .values() + .filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some()) + .map(|(_, _, _, c)| c.clone()) + .collect(); Self { _menu: menu, items, last_refresh: 0.0 } } /// Dispatch clicked items and refresh state. pub fn poll(&mut self, app: &mut DrawcraftApp) { while let Ok(ev) = MenuEvent::receiver().try_recv() { - if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { + if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) { let p = if params.is_null() { serde_json::json!({}) } else { params.clone() }; menus::invoke(app, cmd, p); } @@ -61,7 +64,9 @@ impl NativeMenu { return; } self.last_refresh = now; - for (cmd, params, h) in self.items.values() { + for (run, rp, h, cmd) in self.items.values() { + let null = Value::Null; + let params = if run == cmd { rp } else { &null }; let en = menus::enabled(app, cmd); match h { Handle::Plain(i) => { @@ -91,7 +96,7 @@ impl NativeMenu { } } -fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap, counter: &mut usize) { +fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap, counter: &mut usize) { for e in entries { match e { Item::Sep => { @@ -121,7 +126,9 @@ fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut Has let _ = parent.append(&i); Handle::Plain(i) }; - items.insert(id, (cmd.to_string(), params.clone(), handle)); + let (run, run_params) = menus::click_target(label, cmd, params); + let _ = &run_params; + items.insert(id, (run, run_params, handle, cmd.to_string())); } } } diff --git a/assets/icons/dc-al-bottom.svg b/assets/icons/dc-al-bottom.svg new file mode 100644 index 00000000..73ca1c4b --- /dev/null +++ b/assets/icons/dc-al-bottom.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-al-hcenter.svg b/assets/icons/dc-al-hcenter.svg new file mode 100644 index 00000000..ae7c705f --- /dev/null +++ b/assets/icons/dc-al-hcenter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-al-left.svg b/assets/icons/dc-al-left.svg new file mode 100644 index 00000000..e8088d13 --- /dev/null +++ b/assets/icons/dc-al-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-al-right.svg b/assets/icons/dc-al-right.svg new file mode 100644 index 00000000..6f5407cc --- /dev/null +++ b/assets/icons/dc-al-right.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-al-top.svg b/assets/icons/dc-al-top.svg new file mode 100644 index 00000000..9a32bc30 --- /dev/null +++ b/assets/icons/dc-al-top.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-al-vcenter.svg b/assets/icons/dc-al-vcenter.svg new file mode 100644 index 00000000..29e42fb4 --- /dev/null +++ b/assets/icons/dc-al-vcenter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-alignto-artboard.svg b/assets/icons/dc-alignto-artboard.svg new file mode 100644 index 00000000..a04ffb99 --- /dev/null +++ b/assets/icons/dc-alignto-artboard.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-alignto-key.svg b/assets/icons/dc-alignto-key.svg new file mode 100644 index 00000000..5dd9f88b --- /dev/null +++ b/assets/icons/dc-alignto-key.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-alignto-selection.svg b/assets/icons/dc-alignto-selection.svg new file mode 100644 index 00000000..d145c91c --- /dev/null +++ b/assets/icons/dc-alignto-selection.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-arrow-down.svg b/assets/icons/dc-arrow-down.svg new file mode 100644 index 00000000..0b19b0de --- /dev/null +++ b/assets/icons/dc-arrow-down.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-arrow-up.svg b/assets/icons/dc-arrow-up.svg new file mode 100644 index 00000000..0e7c58f4 --- /dev/null +++ b/assets/icons/dc-arrow-up.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-artboard-options.svg b/assets/icons/dc-artboard-options.svg new file mode 100644 index 00000000..13dbc90d --- /dev/null +++ b/assets/icons/dc-artboard-options.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-cap-butt.svg b/assets/icons/dc-cap-butt.svg new file mode 100644 index 00000000..bf39a170 --- /dev/null +++ b/assets/icons/dc-cap-butt.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-cap-round.svg b/assets/icons/dc-cap-round.svg new file mode 100644 index 00000000..06af9d07 --- /dev/null +++ b/assets/icons/dc-cap-round.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-cap-square.svg b/assets/icons/dc-cap-square.svg new file mode 100644 index 00000000..4a82a10d --- /dev/null +++ b/assets/icons/dc-cap-square.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-clear.svg b/assets/icons/dc-clear.svg new file mode 100644 index 00000000..17ea9dcc --- /dev/null +++ b/assets/icons/dc-clear.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-cube.svg b/assets/icons/dc-cube.svg new file mode 100644 index 00000000..ba0767f5 --- /dev/null +++ b/assets/icons/dc-cube.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dash-align.svg b/assets/icons/dc-dash-align.svg new file mode 100644 index 00000000..736fae2a --- /dev/null +++ b/assets/icons/dc-dash-align.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dash-exact.svg b/assets/icons/dc-dash-exact.svg new file mode 100644 index 00000000..ce4dd40a --- /dev/null +++ b/assets/icons/dc-dash-exact.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-bottom.svg b/assets/icons/dc-dist-bottom.svg new file mode 100644 index 00000000..06f9e16c --- /dev/null +++ b/assets/icons/dc-dist-bottom.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-hcenter.svg b/assets/icons/dc-dist-hcenter.svg new file mode 100644 index 00000000..30cabd1a --- /dev/null +++ b/assets/icons/dc-dist-hcenter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-hspace.svg b/assets/icons/dc-dist-hspace.svg new file mode 100644 index 00000000..bc7a7494 --- /dev/null +++ b/assets/icons/dc-dist-hspace.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-left.svg b/assets/icons/dc-dist-left.svg new file mode 100644 index 00000000..efc53ab8 --- /dev/null +++ b/assets/icons/dc-dist-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-right.svg b/assets/icons/dc-dist-right.svg new file mode 100644 index 00000000..1dc55667 --- /dev/null +++ b/assets/icons/dc-dist-right.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-top.svg b/assets/icons/dc-dist-top.svg new file mode 100644 index 00000000..f0560524 --- /dev/null +++ b/assets/icons/dc-dist-top.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-vcenter.svg b/assets/icons/dc-dist-vcenter.svg new file mode 100644 index 00000000..4b0b5e76 --- /dev/null +++ b/assets/icons/dc-dist-vcenter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dist-vspace.svg b/assets/icons/dc-dist-vspace.svg new file mode 100644 index 00000000..8a2429cb --- /dev/null +++ b/assets/icons/dc-dist-vspace.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-folder.svg b/assets/icons/dc-folder.svg new file mode 100644 index 00000000..ef088e40 --- /dev/null +++ b/assets/icons/dc-folder.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-fx.svg b/assets/icons/dc-fx.svg new file mode 100644 index 00000000..e253dff3 --- /dev/null +++ b/assets/icons/dc-fx.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-gamut.svg b/assets/icons/dc-gamut.svg new file mode 100644 index 00000000..21bc0272 --- /dev/null +++ b/assets/icons/dc-gamut.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-freeform.svg b/assets/icons/dc-grad-freeform.svg new file mode 100644 index 00000000..3821e0d7 --- /dev/null +++ b/assets/icons/dc-grad-freeform.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-linear.svg b/assets/icons/dc-grad-linear.svg new file mode 100644 index 00000000..51688f54 --- /dev/null +++ b/assets/icons/dc-grad-linear.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-radial.svg b/assets/icons/dc-grad-radial.svg new file mode 100644 index 00000000..15d3230c --- /dev/null +++ b/assets/icons/dc-grad-radial.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grid-view.svg b/assets/icons/dc-grid-view.svg new file mode 100644 index 00000000..2412709e --- /dev/null +++ b/assets/icons/dc-grid-view.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-join-bevel.svg b/assets/icons/dc-join-bevel.svg new file mode 100644 index 00000000..b0bb6ba3 --- /dev/null +++ b/assets/icons/dc-join-bevel.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-join-miter.svg b/assets/icons/dc-join-miter.svg new file mode 100644 index 00000000..b4c68f0b --- /dev/null +++ b/assets/icons/dc-join-miter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-join-round.svg b/assets/icons/dc-join-round.svg new file mode 100644 index 00000000..37254de1 --- /dev/null +++ b/assets/icons/dc-join-round.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-list-view.svg b/assets/icons/dc-list-view.svg new file mode 100644 index 00000000..8a60b58f --- /dev/null +++ b/assets/icons/dc-list-view.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-mask-none.svg b/assets/icons/dc-mask-none.svg new file mode 100644 index 00000000..2e384e4f --- /dev/null +++ b/assets/icons/dc-mask-none.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-new-fill.svg b/assets/icons/dc-new-fill.svg new file mode 100644 index 00000000..478a0808 --- /dev/null +++ b/assets/icons/dc-new-fill.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-new-item.svg b/assets/icons/dc-new-item.svg new file mode 100644 index 00000000..4b815724 --- /dev/null +++ b/assets/icons/dc-new-item.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-new-stroke.svg b/assets/icons/dc-new-stroke.svg new file mode 100644 index 00000000..f8e43810 --- /dev/null +++ b/assets/icons/dc-new-stroke.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-options.svg b/assets/icons/dc-options.svg new file mode 100644 index 00000000..80efa07b --- /dev/null +++ b/assets/icons/dc-options.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-center.svg b/assets/icons/dc-para-center.svg new file mode 100644 index 00000000..df4c3d35 --- /dev/null +++ b/assets/icons/dc-para-center.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-justify-all.svg b/assets/icons/dc-para-justify-all.svg new file mode 100644 index 00000000..77fa93c2 --- /dev/null +++ b/assets/icons/dc-para-justify-all.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-justify-center.svg b/assets/icons/dc-para-justify-center.svg new file mode 100644 index 00000000..9c3180ff --- /dev/null +++ b/assets/icons/dc-para-justify-center.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-justify-left.svg b/assets/icons/dc-para-justify-left.svg new file mode 100644 index 00000000..1d8725db --- /dev/null +++ b/assets/icons/dc-para-justify-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-justify-right.svg b/assets/icons/dc-para-justify-right.svg new file mode 100644 index 00000000..967e47a2 --- /dev/null +++ b/assets/icons/dc-para-justify-right.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-left.svg b/assets/icons/dc-para-left.svg new file mode 100644 index 00000000..7a767a0e --- /dev/null +++ b/assets/icons/dc-para-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-right.svg b/assets/icons/dc-para-right.svg new file mode 100644 index 00000000..9f58e360 --- /dev/null +++ b/assets/icons/dc-para-right.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-crop.svg b/assets/icons/dc-pf-crop.svg new file mode 100644 index 00000000..70f9dfc9 --- /dev/null +++ b/assets/icons/dc-pf-crop.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-divide.svg b/assets/icons/dc-pf-divide.svg new file mode 100644 index 00000000..9c4299ad --- /dev/null +++ b/assets/icons/dc-pf-divide.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-exclude.svg b/assets/icons/dc-pf-exclude.svg new file mode 100644 index 00000000..c535d82d --- /dev/null +++ b/assets/icons/dc-pf-exclude.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-intersect.svg b/assets/icons/dc-pf-intersect.svg new file mode 100644 index 00000000..fec68213 --- /dev/null +++ b/assets/icons/dc-pf-intersect.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-merge.svg b/assets/icons/dc-pf-merge.svg new file mode 100644 index 00000000..bbf26415 --- /dev/null +++ b/assets/icons/dc-pf-merge.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-minus-back.svg b/assets/icons/dc-pf-minus-back.svg new file mode 100644 index 00000000..487a240c --- /dev/null +++ b/assets/icons/dc-pf-minus-back.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-minus-front.svg b/assets/icons/dc-pf-minus-front.svg new file mode 100644 index 00000000..6d077acc --- /dev/null +++ b/assets/icons/dc-pf-minus-front.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-outline.svg b/assets/icons/dc-pf-outline.svg new file mode 100644 index 00000000..cd0b691f --- /dev/null +++ b/assets/icons/dc-pf-outline.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-trim.svg b/assets/icons/dc-pf-trim.svg new file mode 100644 index 00000000..8b448fc4 --- /dev/null +++ b/assets/icons/dc-pf-trim.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pf-unite.svg b/assets/icons/dc-pf-unite.svg new file mode 100644 index 00000000..a3eefb76 --- /dev/null +++ b/assets/icons/dc-pf-unite.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-place-symbol.svg b/assets/icons/dc-place-symbol.svg new file mode 100644 index 00000000..eff266c5 --- /dev/null +++ b/assets/icons/dc-place-symbol.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rearrange.svg b/assets/icons/dc-rearrange.svg new file mode 100644 index 00000000..3881164b --- /dev/null +++ b/assets/icons/dc-rearrange.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-remove-brush.svg b/assets/icons/dc-remove-brush.svg new file mode 100644 index 00000000..e925ab23 --- /dev/null +++ b/assets/icons/dc-remove-brush.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-reverse.svg b/assets/icons/dc-reverse.svg new file mode 100644 index 00000000..6ff2ce94 --- /dev/null +++ b/assets/icons/dc-reverse.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-stroke-center.svg b/assets/icons/dc-stroke-center.svg new file mode 100644 index 00000000..72b3d029 --- /dev/null +++ b/assets/icons/dc-stroke-center.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-stroke-inside.svg b/assets/icons/dc-stroke-inside.svg new file mode 100644 index 00000000..ab69c93e --- /dev/null +++ b/assets/icons/dc-stroke-inside.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-stroke-outside.svg b/assets/icons/dc-stroke-outside.svg new file mode 100644 index 00000000..bf2ff4ac --- /dev/null +++ b/assets/icons/dc-stroke-outside.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-swatch-kinds.svg b/assets/icons/dc-swatch-kinds.svg new file mode 100644 index 00000000..becf7924 --- /dev/null +++ b/assets/icons/dc-swatch-kinds.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-width-profile.svg b/assets/icons/dc-width-profile.svg new file mode 100644 index 00000000..52479629 --- /dev/null +++ b/assets/icons/dc-width-profile.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-zoom-large.svg b/assets/icons/dc-zoom-large.svg new file mode 100644 index 00000000..1b8abae3 --- /dev/null +++ b/assets/icons/dc-zoom-large.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-zoom-small.svg b/assets/icons/dc-zoom-small.svg new file mode 100644 index 00000000..9f0a650e --- /dev/null +++ b/assets/icons/dc-zoom-small.svg @@ -0,0 +1 @@ + diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6a1ba86f..04debbe6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,3 +24,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } + +[dev-dependencies] +drawcraft-testkit = { workspace = true } +proptest = { workspace = true } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs new file mode 100644 index 00000000..534e3ef8 --- /dev/null +++ b/crates/engine/src/cmd/colorcmds.rs @@ -0,0 +1,333 @@ +//! Edit → Edit Colors: invert, convert to CMYK/Grayscale/RGB, saturate, adjust colour balance and the +//! three Blend commands. They recolour fills and strokes (solid colours, gradient stops and text +//! runs) of the selected objects and everything inside them. Colours linked to a global swatch are +//! unlinked when they change. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "edit.colors.invert", + "Invert Colors", + ["Edit", "Edit Colors"], + None, + "{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}", + has_selection, + |s, p| recolor(s, p, "Invert Colors", &|c| keep_model(c, c.invert())) + ), + cmd!( + "edit.colors.toCMYK", + "Convert to CMYK", + ["Edit", "Edit Colors"], + None, + "{fill?, stroke?, ids?} → {changed}", + has_selection, + |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) + ), + cmd!( + "edit.colors.toGrayscale", + "Convert to Grayscale", + ["Edit", "Edit Colors"], + None, + "{fill?, stroke?, ids?} → {changed}", + has_selection, + |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) + ), + cmd!("edit.colors.toRGB", "Convert to RGB", ["Edit", "Edit Colors"], None, "{fill?, stroke?, ids?} → {changed}", has_selection, |s, p| { + recolor(s, p, "Convert to RGB", &|c| { + let [r, g, b] = c.to_rgb(); + Color::rgb(r, g, b) + }) + }), + cmd!( + "edit.colors.saturate", + "Saturate…", + ["Edit", "Edit Colors"], + None, + "{intensity: -100..100 (%), fill?, stroke?, ids?} scale saturation by (1 + intensity/100) → {changed}", + has_selection, + saturate + ), + cmd!( + "edit.colors.adjustBalance", + "Adjust Color Balance…", + ["Edit", "Edit Colors"], + None, + "{r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel), convert?: false (keep the result in the adjusted model), fill?, stroke?, ids?} → {changed}", + has_selection, + adjust_balance + ), + cmd!( + "edit.colors.blendFrontToBack", + "Blend Front to Back", + ["Edit", "Edit Colors"], + None, + "{} ≥3 filled objects: intermediate fills graded between the frontmost and backmost fill → {changed}", + has_selection, + |s, _| blend(s, BlendOrder::Stack) + ), + cmd!( + "edit.colors.blendHorizontally", + "Blend Horizontally", + ["Edit", "Edit Colors"], + None, + "{} ≥3 filled objects: fills graded between the leftmost and rightmost → {changed}", + has_selection, + |s, _| blend(s, BlendOrder::Horizontal) + ), + cmd!( + "edit.colors.blendVertically", + "Blend Vertically", + ["Edit", "Edit Colors"], + None, + "{} ≥3 filled objects: fills graded between the topmost and bottommost → {changed}", + has_selection, + |s, _| blend(s, BlendOrder::Vertical) + ), + ] +} + +/// Express `new` in the colour model of `orig`. +fn keep_model(orig: Color, new: Color) -> Color { + match orig { + Color::Rgb { .. } => { + let [r, g, b] = new.to_rgb(); + Color::rgb(r, g, b) + } + Color::Cmyk { .. } => to_cmyk(new), + Color::Gray { .. } => to_gray(new), + } +} + +pub(crate) fn to_cmyk(c: Color) -> Color { + let [c, m, y, k] = c.to_cmyk(); + Color::cmyk(c, m, y, k) +} + +pub(crate) fn to_gray(c: Color) -> Color { + if let Color::Gray { .. } = c { + return c; + } + let [r, g, b] = c.to_rgb(); + Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) +} + +/// Apply `f` to a paint; returns whether anything changed. +fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { + match p { + Paint::Solid { color, swatch } => { + let n = f(*color); + if n != *color { + *color = n; + *swatch = None; + return true; + } + false + } + Paint::Gradient(g) => { + let mut ch = false; + for st in &mut g.gradient.stops { + let n = f(st.color); + if n != st.color { + st.color = n; + ch = true; + } + } + if ch { + g.swatch = None; + } + ch + } + _ => false, + } +} + +/// Recolour a node and its descendants. Returns the number of paints changed. +pub(crate) fn map_node_colors(n: &mut Node, f: &dyn Fn(Color) -> Color, fill: bool, stroke: bool) -> usize { + let mut count = 0; + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) if fill => count += map_paint(&mut l.paint, f) as usize, + AppearanceItem::Stroke(l) if stroke => count += map_paint(&mut l.paint, f) as usize, + _ => {} + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + if fill { + count += map_paint(&mut r.style.fill, f) as usize; + } + if stroke { + count += map_paint(&mut r.style.stroke, f) as usize; + } + } + } + _ => { + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + count += map_node_colors(Arc::make_mut(c), f, fill, stroke); + } + } + } + } + count +} + +fn recolor_ids(s: &mut Session, label: &str, ids: Vec, fill: bool, stroke: bool, f: &dyn Fn(Color) -> Color) -> Result { + let n = s.edit(label, |d, _| { + let mut n = 0; + for id in &ids { + if let Some(node) = d.node_mut(*id) { + n += map_node_colors(node, f, fill, stroke); + } + } + Ok(n) + })?; + Ok(json!({ "changed": n })) +} + +fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + recolor_ids(s, label, ids, bool_or(p, "fill", true), bool_or(p, "stroke", true), f) +} + +fn saturate(s: &mut Session, p: &Value) -> Result { + let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32; + recolor(s, p, "Saturate", &move |c| { + let [h, sat, v] = c.to_hsb(); + if sat <= 0.0 { + return c; + } + keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v)) + }) +} + +fn adjust_balance(s: &mut Session, p: &Value) -> Result { + let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32; + let convert = bool_or(p, "convert", false); + let cmyk = ["c", "m", "y", "k"].iter().any(|k| p.get(*k).is_some()); + let gray = p.get("gray").is_some(); + let rgb = ["r", "g", "b"].iter().any(|k| p.get(*k).is_some()); + if !(cmyk || gray || rgb) { + return Err(bad("edit.colors.adjustBalance", "give r/g/b, c/m/y/k or gray adjustments")); + } + let (dr, dg, db) = (g("r"), g("g"), g("b")); + let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k")); + let dgray = g("gray"); + let cl = |v: f32| v.clamp(0.0, 1.0); + recolor(s, p, "Adjust Colors", &move |c| { + let out = if cmyk { + let [cc, m, y, k] = c.to_cmyk(); + Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk)) + } else if gray { + let Color::Gray { k } = to_gray(c) else { unreachable!() }; + Color::gray(cl(k + dgray)) + } else { + let [r, gg, b] = c.to_rgb(); + Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db)) + }; + if convert { out } else { keep_model(c, out) } + }) +} + +enum BlendOrder { + Stack, + Horizontal, + Vertical, +} + +/// Leaf objects with a solid fill among the selection (paint order). +fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + visit(c, out); + } + } + NodeKind::Text(t) => { + if t.first_style().fill.color().is_some() { + out.push(n.id); + } + } + _ => { + if n.appearance.fill_paint().color().is_some() { + out.push(n.id); + } + } + } + } + let mut out = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + visit(n, &mut out); + } + } + out +} + +fn fill_color(n: &Node) -> Option { + match &n.kind { + NodeKind::Text(t) => t.first_style().fill.color(), + _ => n.appearance.fill_paint().color(), + } +} + +/// Interpolate within the endpoints' model when they share one. +pub(crate) fn lerp_model(a: Color, b: Color, t: f32) -> Color { + let l = |x: f32, y: f32| x + (y - x) * t; + match (a, b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), + _ => a.lerp(&b, t), + } +} + +fn blend(s: &mut Session, order: BlendOrder) -> Result { + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let mut ids = filled_leaves(d, &roots); + if ids.len() < 3 { + return Err(EngineError::Other("Blend colors: select at least three objects with filled colours".into())); + } + let center = |id: &NodeId| d.node(*id).and_then(|n| n.geometric_bounds()).map(|b| b.center()).unwrap_or_default(); + match order { + BlendOrder::Stack => { + // Front to back: frontmost first. + ids.reverse(); + } + BlendOrder::Horizontal => ids.sort_by(|a, b| center(a).x.total_cmp(¢er(b).x)), + BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(¢er(b).y)), + } + let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default(); + let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default(); + let n = ids.len(); + let label = match order { + BlendOrder::Stack => "Blend Front to Back", + BlendOrder::Horizontal => "Blend Horizontally", + BlendOrder::Vertical => "Blend Vertically", + }; + let changed = s.edit(label, |d, _| { + let mut changed = 0; + for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { + let c = lerp_model(first, last, i as f32 / (n - 1) as f32); + if let Some(node) = d.node_mut(*id) { + changed += map_node_colors(node, &|_| c, true, false); + } + } + Ok(changed) + })?; + Ok(json!({ "changed": changed })) +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6fb6edbd..f65962f2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -26,7 +26,15 @@ pub fn specs() -> Vec { has_doc, path_create ), - cmd!("view.drawMode", "Drawing Mode", [], Some("Shift+D"), "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", has_doc, draw_mode), + cmd!( + "view.drawMode", + "Drawing Mode", + [], + Some("Shift+D"), + "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", + has_doc, + draw_mode + ), cmd!( "text.create", "Create Text", @@ -108,7 +116,10 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result { }; if mode == crate::DrawMode::Inside { let st = s.doc()?; - let target = st.selection.objects.first().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)))); + let target = + st.selection.objects.first().copied().filter(|id| { + matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) + }); match target { Some(t) if st.selection.len() == 1 => s.draw_inside = Some(t), _ => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs new file mode 100644 index 00000000..4740cd04 --- /dev/null +++ b/crates/engine/src/cmd/docmenu.rs @@ -0,0 +1,499 @@ +//! Select menu long tail (Same → text/symbol attributes, Object → Direction Handles / text kinds / +//! brush strokes, Save/recall selections), View → Guides, and File → Close All / Document Color +//! Mode / File Info. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use drawcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind}; +use serde_json::{Value, json}; + +use super::colorcmds::{map_node_colors, to_cmyk}; +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!("select.same.symbolInstance", "Symbol Instance", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same( + s, + "select.same.symbolInstance", + |a, b| match (&a.kind, &b.kind) { + (NodeKind::SymbolInstance { symbol: x, .. }, NodeKind::SymbolInstance { symbol: y, .. }) => x == y, + _ => false, + } + )), + cmd!("select.same.fontFamily", "Font Family", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text( + s, + "select.same.fontFamily", + |a, b| a.font_family == b.font_family + )), + cmd!("select.same.fontFamilyStyle", "Font Family & Style", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text( + s, + "select.same.fontFamilyStyle", + |a, b| a.font_family == b.font_family && a.font_style == b.font_style + )), + cmd!("select.same.fontFamilyStyleSize", "Font Family, Style & Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| { + same_text(s, "select.same.fontFamilyStyleSize", |a, b| { + a.font_family == b.font_family && a.font_style == b.font_style && (a.size - b.size).abs() < 1e-6 + }) + }), + cmd!("select.same.fontSize", "Font Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text( + s, + "select.same.fontSize", + |a, b| (a.size - b.size).abs() < 1e-6 + )), + cmd!("select.same.textFillColor", "Text Fill Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text( + s, + "select.same.textFillColor", + |a, b| a.fill == b.fill + )), + cmd!("select.same.textStrokeColor", "Text Stroke Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text( + s, + "select.same.textStrokeColor", + |a, b| a.stroke == b.stroke + )), + cmd!( + "select.object.directionHandles", + "Direction Handles", + ["Select", "Object"], + None, + "{} direct-select every anchor (showing all handles) of the selected paths → {anchors}", + has_selection, + direction_handles + ), + cmd!("select.object.pointText", "Point Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind( + s, + |n| matches!( + &n.kind, + NodeKind::Text(t) if matches!(t.kind, TextKind::Point) + ) + )), + cmd!("select.object.areaText", "Area Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| matches!( + &n.kind, + NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) + ))), + cmd!("select.object.brushStrokes", "Brush Strokes", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| n + .appearance + .stroke() + .is_some_and(|st| st.brush.is_some()))), + cmd!( + "select.object.bristleBrushStrokes", + "Bristle Brush Strokes", + ["Select", "Object"], + None, + "{} objects whose brush name contains \"bristle\" → {count}", + has_doc, + |s, _| by_kind(s, |n| n + .appearance + .stroke() + .and_then(|st| st.brush.as_deref()) + .is_some_and(|b| b.to_ascii_lowercase().contains("bristle"))) + ), + cmd!( + "select.save", + "Save Selection…", + ["Select"], + None, + "{name?} remember the current selection for this session (default name \"Selection N\") → {name}", + has_selection, + save_selection + ), + cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection), + cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved), + cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| { + let t = s.doc()?.title(); + Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::>())) + }), + cmd!( + "view.guides.make", + "Make Guides", + ["View", "Guides"], + Some("Cmd+5"), + "{} turn the selected paths into guides → {count}", + has_selection, + make_guides + ), + cmd!( + "view.guides.release", + "Release Guides", + ["View", "Guides"], + Some("Cmd+Alt+5"), + "{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}", + has_doc, + release_guides + ), + cmd!( + query "view.guides.lock", + "Lock Guides", + ["View", "Guides"], + Some("Cmd+Alt+;"), + "{locked?: bool} toggle (or set) the session's guide lock → {locked}", + has_doc, + lock_guides + ), + cmd!( + "view.guides.clear", + "Clear Guides", + ["View", "Guides"], + None, + "{} delete all ruler guides and guide paths → {count}", + has_doc, + clear_guides + ), + cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add), + cmd!("guide.remove", "Remove Guide", [], None, "{index}", guides_unlocked, guide_remove), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", guides_unlocked, guide_move), + cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all), + cmd!( + "file.documentColorMode", + "Document Color Mode", + ["File", "Document Color Mode"], + None, + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}", + has_doc, + color_mode + ), + cmd!( + "file.info", + "File Info…", + ["File"], + Some("Cmd+Alt+Shift+I"), + "{title?} set the document title; always → {title, colorMode, units, artboards, objects}", + has_doc, + file_info + ), + ] +} + +impl Session { + /// View → Guides → Lock Guides state (canvas guide dragging should honour it). + pub fn guides_locked(&self) -> bool { + self.menu.guides_locked + } +} + +// ---------- Select ---------- + +fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result> { + let st = s.doc()?; + let mut ids = vec![]; + for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { + l.walk(&mut |n| { + if !n.is_container() && n.visible && !n.locked && f(n) { + ids.push(n.id); + } + }); + } + Ok(ids) +} + +fn finish_same(s: &mut Session, cmd: &str, ids: Vec) -> Result { + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}))); + Ok(json!({ "count": n })) +} + +fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { + let st = s.doc()?; + let r = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; + let ids = candidates(s, |n| eq(n, &r))?; + finish_same(s, cmd, ids) +} + +fn first_text_style(s: &Session) -> Option { + let st = s.active()?; + let mut out = None; + for id in &st.selection.objects { + st.doc.node(*id)?.walk(&mut |n| { + if out.is_none() + && let NodeKind::Text(t) = &n.kind + { + out = Some(t.first_style()); + } + }); + if out.is_some() { + break; + } + } + out +} + +fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) -> Result { + let r = first_text_style(s).ok_or_else(|| bad(cmd, "select a text object"))?; + let ids = candidates(s, |n| match &n.kind { + NodeKind::Text(t) => t.runs.iter().any(|run| eq(&run.style, &r)), + _ => false, + })?; + finish_same(s, cmd, ids) +} + +fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result { + let ids = candidates(s, f)?; + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": n })) +} + +fn direction_handles(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let mut items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = vec![]; + for r in &roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + items.push((c.id, p.anchors().map(|(si, ai, _)| (si, ai)).collect())); + } + }); + } + } + let total: usize = items.iter().map(|x| x.1.len()).sum(); + s.select(|_, sel| { + sel.clear(); + for (id, refs) in items { + sel.add(id); + sel.anchors.insert(id, refs); + } + })?; + Ok(json!({ "anchors": total })) +} + +fn save_selection(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let title = st.title(); + let ids = st.selection.objects.clone(); + let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) { + Some(n) => n.trim().to_string(), + None => { + let mut i = 1; + while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) { + i += 1; + } + format!("Selection {i}") + } + }; + s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name)); + s.menu.saved_selections.push((title, name.clone(), ids)); + Ok(json!({ "name": name })) +} + +fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?; + let t = s.doc()?.title(); + s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`"))) +} + +fn recall_selection(s: &mut Session, p: &Value) -> Result { + let i = saved_index(s, p, "select.recall")?; + let ids = s.menu.saved_selections[i].2.clone(); + s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?; + Ok(json!({ "count": s.doc()?.selection.len() })) +} + +fn edit_saved(s: &mut Session, p: &Value) -> Result { + let i = saved_index(s, p, "select.editSaved")?; + if bool_or(p, "delete", false) { + s.menu.saved_selections.remove(i); + } else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) { + s.menu.saved_selections[i].1 = n.trim().to_string(); + } + ok() +} + +// ---------- Guides ---------- + +fn guides_unlocked(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.menu.guides_locked { Err("guides are locked".into()) } else { Ok(()) } +} + +fn set_guide_flag(n: &mut Node, on: bool, count: &mut usize) { + match &mut n.kind { + NodeKind::Path { guide, clipping: false, .. } => { + if *guide != on { + *guide = on; + *count += 1; + } + } + _ => { + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + set_guide_flag(Arc::make_mut(c), on, count); + } + } + } + } +} + +fn make_guides(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let n = s.edit("Make Guides", |d, sel| { + let mut n = 0; + for r in &roots { + if let Some(node) = d.node_mut(*r) { + set_guide_flag(node, true, &mut n); + } + } + if n == 0 { + return Err(EngineError::Other("Make Guides: select paths".into())); + } + sel.clear(); + Ok(n) + })?; + Ok(json!({ "count": n })) +} + +fn guide_paths(d: &drawcraft_doc::Document) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) { + v.push(n.id); + } + }); + v +} + +fn release_guides(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let all = guide_paths(&st.doc); + let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); + let targets: Vec = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel }; + if targets.is_empty() { + return Err(EngineError::Other("Release Guides: no guides".into())); + } + let n = targets.len(); + s.edit("Release Guides", |d, sel| { + for id in &targets { + if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *guide = false; + } + } + sel.set(targets.iter().copied()); + Ok(()) + })?; + Ok(json!({ "count": n })) +} + +fn lock_guides(s: &mut Session, p: &Value) -> Result { + let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked); + s.menu.guides_locked = v; + Ok(json!({ "locked": v })) +} + +fn clear_guides(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let paths = guide_paths(&st.doc); + let n = paths.len() + st.doc.guides.len(); + if n == 0 { + return Ok(json!({ "count": 0 })); + } + s.edit("Clear Guides", |d, _| { + d.guides.clear(); + for id in &paths { + let _ = d.remove(*id); + } + Ok(()) + })?; + Ok(json!({ "count": n })) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + let pos = f64_req(p, "pos", "guide.add")?; + let vertical = bool_or(p, "vertical", false); + let i = s.edit("New Guide", |d, _| { + d.guides.push(Guide { vertical, pos }); + Ok(d.guides.len() - 1) + })?; + Ok(json!({ "index": i })) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_remove(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.remove")?; + s.edit("Delete Guide", |d, _| { + d.guides.remove(i); + Ok(()) + })?; + ok() +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let pos = f64_req(p, "pos", "guide.move")?; + s.edit("Move Guide", |d, _| { + d.guides[i].pos = pos; + Ok(()) + })?; + ok() +} + +// ---------- File ---------- + +fn close_all(s: &mut Session, _: &Value) -> Result { + let n = s.documents().len(); + while !s.documents().is_empty() { + s.close_document(s.documents().len() - 1); + } + Ok(json!({ "closed": n })) +} + +fn color_mode(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.documentColorMode"; + let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { + Some("cmyk") => ColorMode::Cmyk, + Some("rgb") => ColorMode::Rgb, + _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), + }; + let convert = bool_or(p, "convert", true); + if s.doc()?.doc.color_mode == mode { + return ok(); + } + let f = move |c: drawcraft_color::Color| match mode { + ColorMode::Cmyk => to_cmyk(c), + ColorMode::Rgb => { + let [r, g, b] = c.to_rgb(); + drawcraft_color::Color::rgb(r, g, b) + } + }; + s.edit("Document Color Mode", |d, _| { + d.color_mode = mode; + if convert { + for l in &mut d.layers { + map_node_colors(Arc::make_mut(l), &f, true, true); + } + for sw in &mut d.swatches { + if let drawcraft_color::Paint::Solid { color, .. } = &mut sw.paint { + *color = f(*color); + } + } + } + Ok(()) + })?; + ok() +} + +fn file_info(s: &mut Session, p: &Value) -> Result { + if let Some(t) = str_param(p, "title") { + let t = t.to_string(); + s.edit("File Info", |d, _| { + d.title = t; + Ok(()) + })?; + } + let d = &s.doc()?.doc; + Ok(json!({ + "title": d.title, + "colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" }, + "units": d.units.label(), + "artboards": d.artboards.len(), + "objects": d.node_count(), + })) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e9f43fe..5b728a41 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -146,7 +146,17 @@ fn redo(s: &mut Session, _: &Value) -> Result { /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. pub(crate) fn selected_roots(s: &Session) -> Result> { let st = s.doc()?; - let sel = st.selection.in_paint_order(&st.doc); + // A selected compound-path member acts as its compound (Illustrator treats compounds as one object). + let mut sel: Vec = st + .selection + .in_paint_order(&st.doc) + .into_iter() + .map(|id| match st.doc.parent_of(id).and_then(|p| st.doc.node(p).map(|n| (p, n))) { + Some((p, n)) if matches!(n.kind, drawcraft_doc::NodeKind::Compound { .. }) => p, + _ => id, + }) + .collect(); + sel.dedup(); Ok(sel .iter() .copied() diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ebb091e8..1225c7e8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -112,6 +112,8 @@ fn batch(s: &mut Session, p: &Value) -> Result { let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); let label = str_param(p, "label").unwrap_or("Batch").to_string(); s.begin_interaction(&label)?; + // Session-level state a step may change (paint defaults, drawing mode) rolls back too. + let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone()); let mut results = vec![]; for c in &cmds { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); @@ -124,6 +126,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { Ok(v) => results.push(v), Err(e) => { s.cancel_interaction()?; + (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved; return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len()))); } } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs new file mode 100644 index 00000000..47564103 --- /dev/null +++ b/crates/engine/src/cmd/menucmds.rs @@ -0,0 +1,1048 @@ +//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset +//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape, Blend +//! (expanded blends) and Artboards → Convert / Rearrange. +//! +//! Blends: the document model has no live blend object, so **Make** builds an *expanded* blend — +//! a group named `Blend` holding the key objects with generated intermediate steps (named +//! `Blend Step`) between them. Release / Blend Options / Reverse work on such groups by +//! recognising those names; Blend → Expand drops the names, leaving an ordinary group. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, AppearanceItem, ImageObject, LiveShape, Node, NodeId, NodeKind}; +use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, Vec2, shapes}; +use serde_json::{Value, json}; + +use super::colorcmds::lerp_model; +use super::edit::{duplicate_in, selected_roots}; +use super::*; + +pub const BLEND_NAME: &str = "Blend"; +pub const BLEND_STEP_NAME: &str = "Blend Step"; + +/// Session-level (not saved) state owned by the menu commands. +#[derive(Clone, Debug, Default)] +pub struct MenuState { + /// Saved selections: (document title, name, object ids). + pub saved_selections: Vec<(String, String, Vec)>, + /// View → Guides → Lock Guides. + pub guides_locked: bool, +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.lock.above", + "All Artwork Above", + ["Object", "Lock"], + None, + "{} lock objects in the same layer that are above and overlap the selection → {count}", + has_selection, + |s, _| above(s, true) + ), + cmd!( + "object.lock.otherLayers", + "Other Layers", + ["Object", "Lock"], + None, + "{} lock every layer that holds no selected object → {count}", + has_selection, + |s, _| other_layers(s, true) + ), + cmd!( + "object.hide.above", + "All Artwork Above", + ["Object", "Hide"], + None, + "{} hide objects in the same layer that are above and overlap the selection → {count}", + has_selection, + |s, _| above(s, false) + ), + cmd!( + "object.hide.otherLayers", + "Other Layers", + ["Object", "Hide"], + None, + "{} hide every layer that holds no selected object → {count}", + has_selection, + |s, _| other_layers(s, false) + ), + cmd!( + "object.transformEach", + "Transform Each…", + ["Object", "Transform"], + Some("Cmd+Alt+Shift+D"), + "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool} transform every selected object about its own reference point → {ids}", + has_selection, + transform_each + ), + cmd!( + "object.resetBoundingBox", + "Reset Bounding Box", + ["Object", "Transform"], + None, + "{} no-op: DrawCraft bounding boxes are always axis-aligned to the document → {changed: 0}", + has_selection, + |_, _| Ok(json!({ "changed": 0 })) + ), + cmd!( + "object.expand", + "Expand…", + ["Object"], + None, + "{object?: true (text → outlines, live shapes → paths, effects baked), fill?: true, stroke?: false (strokes → filled outlines)} one undo step → {ids}", + has_selection, + expand + ), + cmd!( + "object.rasterize", + "Rasterize…", + ["Object"], + None, + "{ppi?: (document raster effects resolution), background?: \"transparent\"|\"white\", padding?: pt} replace the selection with an embedded PNG image → {id, width, height}", + has_selection, + rasterize + ), + cmd!( + "object.cropImage", + "Crop Image", + ["Object"], + None, + "{rect?: [x, y, width, height]} crop the selected image (default: to the artboard it sits on) → {id, width, height}", + has_image, + crop_image + ), + cmd!( + "object.createTrimMarks", + "Create Trim Marks", + ["Object"], + None, + "{offset?: pt (9), length?: pt (18), weight?: pt (0.3)} trim marks around the selection (or the first artboard) as a group → {id}", + has_doc, + trim_marks + ), + cmd!( + "object.shape.convertToShape", + "Convert to Shape", + ["Object", "Shape"], + None, + "{} turn paths that are rectangles (any rotation) or axis-aligned ellipses into live shapes → {converted}", + has_selection, + convert_to_shape + ), + cmd!( + "object.blend.make", + "Make", + ["Object", "Blend"], + Some("Cmd+Alt+B"), + "{steps?: n (5)} blend the selected objects (paint order) into an expanded blend group → {id}", + has_multi, + blend_make + ), + cmd!( + "object.blend.release", + "Release", + ["Object", "Blend"], + Some("Cmd+Alt+Shift+B"), + "{} remove the generated steps of the selected blend groups, keeping the key objects → {ids}", + has_blend, + blend_release + ), + cmd!( + "object.blend.options", + "Blend Options…", + ["Object", "Blend"], + None, + "{steps: n} regenerate the steps of the selected blend groups", + has_blend, + blend_options + ), + cmd!("object.blend.expand", "Expand", ["Object", "Blend"], None, "{} turn blend groups into ordinary groups", has_blend, blend_expand), + cmd!( + "object.blend.reverseFrontToBack", + "Reverse Front to Back", + ["Object", "Blend"], + None, + "{} reverse the stacking order inside the selected blends", + has_blend, + blend_reverse_stack + ), + cmd!( + "object.blend.reverseSpine", + "Reverse Spine", + ["Object", "Blend"], + None, + "{} swap the positions of the key objects along the blend and regenerate", + has_blend, + blend_reverse_spine + ), + cmd!( + "artboard.convertToArtboards", + "Convert to Artboards", + ["Object", "Artboards"], + None, + "{} turn each selected object's bounds into a new artboard (the objects are removed) → {artboards}", + has_selection, + convert_to_artboards + ), + cmd!( + "artboard.rearrange", + "Rearrange All Artboards…", + ["Object", "Artboards"], + None, + "{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true} lay artboards out in a grid", + has_doc, + rearrange_artboards + ), + ] +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Run a command's implementation without the enablement check or journaling. +pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result { + let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (c.run)(s, p) +} + +/// Merge the undo entries pushed since the history had `from` entries into one labelled step. +pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { + if let Some(st) = s.active_mut() { + let h = &mut st.history.undo; + if h.len() > from { + h.truncate(from + 1); + h[from].label = label.to_string(); + } + } +} + +// ---------- Lock / Hide ---------- + +fn above(s: &mut Session, lock: bool) -> Result { + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let mut hits: Vec = vec![]; + for id in &roots { + let Some(layer) = d.layer_of(*id) else { continue }; + // The selected object's ancestor directly inside the layer. + let anc = d.ancestry(*id).unwrap_or_default(); + let Some(pos) = anc.iter().position(|a| *a == layer) else { continue }; + let top = anc.get(pos + 1).copied().unwrap_or(*id); + let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue }; + let Some((_, idx, _)) = d.position(top) else { continue }; + for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) { + if roots.contains(&n.id) || hits.contains(&n.id) { + continue; + } + if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) { + hits.push(n.id); + } + } + } + let label = if lock { "Lock" } else { "Hide" }; + let n = hits.len(); + s.edit(label, |d, _| { + for id in &hits { + if let Some(n) = d.node_mut(*id) { + if lock { + n.locked = true; + } else { + n.visible = false; + } + } + } + Ok(()) + })?; + Ok(json!({ "count": n })) +} + +fn other_layers(s: &mut Session, lock: bool) -> Result { + let st = s.doc()?; + let keep: Vec = st.selection.objects.iter().filter_map(|id| st.doc.ancestry(*id)).flatten().collect(); + let others: Vec = st.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect(); + let n = others.len(); + s.edit(if lock { "Lock Others" } else { "Hide Others" }, |d, _| { + for id in &others { + if let Some(n) = d.node_mut(*id) { + if lock { + n.locked = true; + } else { + n.visible = false; + } + } + } + Ok(()) + })?; + Ok(json!({ "count": n })) +} + +// ---------- Transform Each ---------- + +/// Small deterministic PRNG (xorshift64*) for Transform Each → Random. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + (self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } +} + +fn about(o: Point, a: Affine) -> Affine { + Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) +} + +fn transform_each(s: &mut Session, p: &Value) -> Result { + let roots = selected_roots(s)?; + // Bounded so coordinates stay finite (and the document stays serializable). + let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0); + let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0); + let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7); + let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7); + let rot = f64_or(p, "rotate", 0.0); + let rx = bool_or(p, "reflectX", false); + let ry = bool_or(p, "reflectY", false); + let random = bool_or(p, "random", false); + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; + let copy = bool_or(p, "copy", false); + if sh == 0.0 || sv == 0.0 { + return Err(bad("object.transformEach", "scale must be non-zero")); + } + let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1)); + let scale_strokes = s.prefs.scale_strokes; + let ids = s.edit("Transform Each", |d, sel| { + let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() }; + for id in &targets { + let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue }; + let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot); + if random { + k_sh = 100.0 + (sh - 100.0) * rng.next(); + k_sv = 100.0 + (sv - 100.0) * rng.next(); + k_mh = mh * (rng.next() * 2.0 - 1.0); + k_mv = mv * (rng.next() * 2.0 - 1.0); + k_rot = rot * (rng.next() * 2.0 - 1.0); + } + let o = drawcraft_geom::reference_point(b, refi); + let refl = Affine::scale_non_uniform(if ry { -1.0 } else { 1.0 }, if rx { -1.0 } else { 1.0 }); + let m = Affine::translate((k_mh, k_mv)) + * about(o, Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh / 100.0, k_sv / 100.0)); + if let Some(n) = d.node_mut(*id) { + n.transform(m, scale_strokes); + } + } + Ok(targets) + })?; + Ok(ids_json(&ids)) +} + +// ---------- Expand ---------- + +fn expand(s: &mut Session, p: &Value) -> Result { + let object = bool_or(p, "object", true); + let fill = bool_or(p, "fill", true); + let stroke = bool_or(p, "stroke", false); + let from = s.doc()?.history.undo.len(); + let rev0 = s.doc()?.revision; + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let (mut has_text, mut has_fx, mut has_live, mut has_stroke) = (false, false, false, false); + for r in &roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + has_text |= matches!(c.kind, NodeKind::Text(_)); + has_live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); + has_fx |= !c.appearance.effects.is_empty(); + has_stroke |= matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !c.appearance.stroke_paint().is_none(); + }); + } + } + if object && has_fx { + let _ = run_raw(s, "effect.expandAppearance", &json!({})); + } + if object && has_live { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| { + for r in &roots { + if let Some(n) = d.node_mut(*r) { + drop_live(n); + } + } + Ok(()) + })?; + } + if object && has_text { + let _ = run_raw(s, "type.createOutlines", &json!({})); + } + if stroke && has_stroke { + let _ = run_raw(s, "object.path.outlineStroke", &json!({})); + } + if !fill { + // Fill unchecked: strokes only survive (Illustrator expands just the selected attributes). + } + if s.doc()?.revision == rev0 { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } + squash(s, from, "Expand"); + let ids = s.doc()?.selection.objects.clone(); + Ok(ids_json(&ids)) +} + +fn drop_live(n: &mut Node) { + if let NodeKind::Path { live, .. } = &mut n.kind { + *live = None; + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + drop_live(Arc::make_mut(c)); + } + } +} + +// ---------- Rasterize / Crop ---------- + +const MAX_PIXELS: f64 = 64.0e6; + +/// A copy of `doc` whose only content is `nodes` (paint order), for offscreen rendering. +fn isolated_doc(doc: &drawcraft_doc::Document, nodes: Vec) -> drawcraft_doc::Document { + let mut tmp = doc.clone(); + let mut layer = Node::layer(NodeId(u64::MAX), "raster", drawcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = nodes.into_iter().map(Arc::new).collect(); + } + tmp.layers = vec![Arc::new(layer)]; + tmp +} + +fn unique_key(d: &drawcraft_doc::Document, stem: &str) -> String { + let mut i = d.images.len() + 1; + loop { + let k = format!("{stem}-{i}"); + if !d.images.contains_key(&k) { + return k; + } + i += 1; + } +} + +fn render_png(doc: &drawcraft_doc::Document, region: Rect, scale: f64, white: bool) -> Result<(Vec, u32, u32)> { + if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { + return Err(EngineError::Other("image would be too large; lower the resolution".into())); + } + let mut r = drawcraft_render::Renderer::new(); + let img = r.render_region(doc, region, scale, white); + Ok((img.to_png(), img.width, img.height)) +} + +fn rasterize(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.rasterize"; + let roots = selected_roots(s)?; + let st = s.doc()?; + let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi); + if !(1.0..=2400.0).contains(&ppi) { + return Err(bad(C, "ppi must be between 1 and 2400")); + } + let pad = f64_or(p, "padding", 0.0).max(0.0); + let white = str_param(p, "background") == Some("white"); + let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad); + let scale = ppi / 72.0; + // Snap to whole pixels. + let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); + let nodes: Vec = roots + .iter() + .filter_map(|id| st.doc.node(*id).cloned()) + .map(|mut n| { + n.visible = true; + n + }) + .collect(); + let tmp = isolated_doc(&st.doc, nodes); + let (png, w, h) = render_png(&tmp, region, scale, white)?; + let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?; + let id = s.edit("Rasterize", |d, sel| { + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let key = unique_key(d, "raster"); + d.images.insert(key.clone(), drawcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + let id = d.alloc_id(); + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + let node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); + d.insert(par, idx + 1, node)?; + for r in &roots { + d.remove(*r)?; + } + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0, "width": w, "height": h })) +} + +fn has_image(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().unwrap(); + if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + Ok(()) + } else { + Err("select an image".into()) + } +} + +fn crop_image(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.cropImage"; + let st = s.doc()?; + let id = st + .selection + .objects + .iter() + .copied() + .find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) + .ok_or_else(|| bad(C, "select an image"))?; + let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let NodeKind::Image(im) = &node.kind else { unreachable!() }; + let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; + let rect = match p.get("rect").and_then(Value::as_array) { + Some(a) if a.len() == 4 => { + let v: Vec = a.iter().filter_map(Value::as_f64).collect(); + if v.len() != 4 { + return Err(bad(C, "rect must be [x, y, width, height]")); + } + Rect::new(v[0], v[1], v[0] + v[2], v[1] + v[3]).abs() + } + Some(_) => return Err(bad(C, "rect must be [x, y, width, height]")), + None => { + let ab = st.doc.artboard_at(ib.center()).and_then(|i| st.doc.artboards.get(i)).map(|a| a.rect); + match ab { + Some(r) => r, + None => return Err(bad(C, "the image is not on an artboard; pass rect")), + } + } + }; + let r = rect.intersect(ib); + if r.width() <= 0.0 || r.height() <= 0.0 { + return Err(bad(C, "crop rectangle doesn't overlap the image")); + } + if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 { + return Err(bad(C, "nothing to crop")); + } + // Keep the image's pixel density. + let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9)); + let mut bare = node.clone(); + bare.opacity = 1.0; + bare.blend = Default::default(); + bare.appearance = Appearance::default(); + bare.visible = true; + let tmp = isolated_doc(&st.doc, vec![bare]); + let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale); + let (png, w, h) = render_png(&tmp, region, scale, false)?; + s.edit("Crop Image", |d, _| { + let key = unique_key(d, "crop"); + d.images.insert(key.clone(), drawcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Image(ImageObject { + key, + width: w, + height: h, + xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), + link: None, + }); + } + Ok(()) + })?; + Ok(json!({ "id": id.0, "width": w, "height": h })) +} + +// ---------- Trim marks ---------- + +fn trim_marks(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let off = f64_or(p, "offset", 9.0).max(0.0); + let len = f64_or(p, "length", 18.0).max(0.1); + let weight = f64_or(p, "weight", 0.3).max(0.01); + let r = if st.selection.is_empty() { + st.doc.artboards.first().map(|a| a.rect) + } else { + let roots = selected_roots(s)?; + s.doc()?.doc.bounds_of(&roots, true) + } + .ok_or_else(|| EngineError::Other("nothing to mark".into()))?; + let parent = s.doc()?.insertion_parent(); + let lines = [ + // Top-left. + (Point::new(r.x0 - off - len, r.y0), Point::new(r.x0 - off, r.y0)), + (Point::new(r.x0, r.y0 - off - len), Point::new(r.x0, r.y0 - off)), + // Top-right. + (Point::new(r.x1 + off, r.y0), Point::new(r.x1 + off + len, r.y0)), + (Point::new(r.x1, r.y0 - off - len), Point::new(r.x1, r.y0 - off)), + // Bottom-right. + (Point::new(r.x1 + off, r.y1), Point::new(r.x1 + off + len, r.y1)), + (Point::new(r.x1, r.y1 + off), Point::new(r.x1, r.y1 + off + len)), + // Bottom-left. + (Point::new(r.x0 - off - len, r.y1), Point::new(r.x0 - off, r.y1)), + (Point::new(r.x0, r.y1 + off), Point::new(r.x0, r.y1 + off + len)), + ]; + let id = s.edit("Create Trim Marks", |d, sel| { + let mut children = vec![]; + for (a, b) in lines { + let id = d.alloc_id(); + children.push(Arc::new(Node::path(id, shapes::line(a, b), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight)))); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some("Trim Marks".into()); + d.insert(parent, usize::MAX, g)?; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0 })) +} + +// ---------- Convert to Shape ---------- + +fn no_handles(a: &Anchor) -> bool { + !a.has_in() && !a.has_out() +} + +/// Recognise a rectangle (any rotation) or an axis-aligned ellipse. +pub(crate) fn detect_shape(path: &PathData) -> Option { + let [sp] = path.subpaths.as_slice() else { return None }; + if !sp.closed || sp.anchors.len() != 4 { + return None; + } + let a = &sp.anchors; + let size = path.bounds()?.size(); + let tol = 1e-3 * size.width.max(size.height).max(1.0); + if a.iter().all(no_handles) { + let e0 = a[1].p - a[0].p; + let e1 = a[2].p - a[1].p; + let e2 = a[3].p - a[2].p; + let (w, h) = (e0.hypot(), e1.hypot()); + if w < 1e-9 || h < 1e-9 { + return None; + } + if e0.dot(e1).abs() > tol * (w + h) || (e2 + e0).hypot() > tol { + return None; + } + let rot = Affine::rotate(e0.y.atan2(e0.x)); + let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY }; + return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], xf: Affine::translate(a[0].p.to_vec2()) * rot * flip }); + } + let b = path.bounds()?; + let c = b.center(); + let mids = [Point::new(c.x, b.y0), Point::new(b.x1, c.y), Point::new(c.x, b.y1), Point::new(b.x0, c.y)]; + let k = 0.5523; + for an in a { + if !mids.iter().any(|m| (an.p - *m).hypot() < tol) { + return None; + } + // Handles tangent to the bounds and ~κ·radius long. + let horizontal = (an.p.y - b.y0).abs() < tol || (an.p.y - b.y1).abs() < tol; + let want = if horizontal { k * b.width() / 2.0 } else { k * b.height() / 2.0 }; + for hnd in [an.h_in, an.h_out] { + let v = hnd - an.p; + let (along, across) = if horizontal { (v.x.abs(), v.y.abs()) } else { (v.y.abs(), v.x.abs()) }; + if across > tol * 10.0 || (along - want).abs() > want * 0.05 + tol { + return None; + } + } + } + Some(LiveShape::Ellipse { w: b.width(), h: b.height(), pie: (0.0, 360.0), xf: Affine::translate(b.origin().to_vec2()) }) +} + +fn convert_to_shape(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let n = s.edit("Convert to Shape", |d, _| { + let mut n = 0; + for id in &roots { + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if live.is_some() { + continue; + } + if let Some(l) = detect_shape(path) { + *path = l.to_path(); + *live = Some(l); + n += 1; + } + } + if n == 0 { + return Err(EngineError::Other("Convert to Shape: no rectangles or ellipses selected".into())); + } + Ok(n) + })?; + Ok(json!({ "converted": n })) +} + +// ---------- Blend ---------- + +fn is_blend(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some(BLEND_NAME) +} + +fn blend_groups(s: &Session) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let mut out = vec![]; + for id in &st.selection.objects { + // The blend itself or any ancestor that is a blend. + for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { + if st.doc.node(a).is_some_and(is_blend) { + if !out.contains(&a) { + out.push(a); + } + break; + } + } + } + out +} + +fn has_blend(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if blend_groups(s).is_empty() { Err("select a blend".into()) } else { Ok(()) } +} + +/// Bring two subpaths to the same anchor count by splitting the longest segments. +fn equalize(a: &mut SubPath, b: &mut SubPath) { + fn grow(sp: &mut SubPath, n: usize) { + let mut guard = 0; + while sp.anchors.len() < n && guard < 10_000 && sp.segment_count() > 0 { + let seg = (0..sp.segment_count()) + .max_by(|i, j| { + let li = { + let c = sp.segment(*i); + (c.p3 - c.p0).hypot() + }; + let lj = { + let c = sp.segment(*j); + (c.p3 - c.p0).hypot() + }; + li.total_cmp(&lj) + }) + .unwrap_or(0); + sp.insert_anchor(seg, 0.5); + guard += 1; + } + } + let n = a.anchors.len().max(b.anchors.len()); + grow(a, n); + grow(b, n); +} + +fn lerp_pt(a: Point, b: Point, t: f64) -> Point { + a.lerp(b, t) +} + +fn lerp_path(a: &PathData, b: &PathData, t: f64) -> Option { + if a.subpaths.len() != b.subpaths.len() || a.subpaths.is_empty() { + return None; + } + let mut out = vec![]; + for (sa, sb) in a.subpaths.iter().zip(&b.subpaths) { + let (mut sa, mut sb) = (sa.clone(), sb.clone()); + equalize(&mut sa, &mut sb); + if sa.anchors.len() != sb.anchors.len() { + return None; + } + let anchors = sa + .anchors + .iter() + .zip(&sb.anchors) + .map(|(x, y)| Anchor { p: lerp_pt(x.p, y.p, t), h_in: lerp_pt(x.h_in, y.h_in, t), h_out: lerp_pt(x.h_out, y.h_out, t), kind: x.kind }) + .collect(); + out.push(SubPath::new(anchors, sa.closed)); + } + Some(PathData::new(out)) +} + +fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { + match (a.color(), b.color()) { + (Some(x), Some(y)) => Paint::solid(lerp_model(x, y, t)), + _ => { + if t < 0.5 { + a.clone() + } else { + b.clone() + } + } + } +} + +fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { + let mut out = if t < 0.5 { a.clone() } else { b.clone() }; + out.set_fill(lerp_paint(&a.fill_paint(), &b.fill_paint(), t as f32)); + let (sa, sb) = (a.stroke_paint(), b.stroke_paint()); + if !(sa.is_none() && sb.is_none()) { + out.set_stroke(lerp_paint(&sa, &sb, t as f32)); + let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t; + for it in &mut out.items { + if let AppearanceItem::Stroke(s) = it { + s.width = w; + } + } + } + out +} + +/// One intermediate step between key objects `a` and `b`. +fn blend_step(d: &mut drawcraft_doc::Document, a: &Node, b: &Node, t: f64) -> Node { + let ap = a.path_data(); + let bp = b.path_data(); + let mut n = match (ap, bp.and_then(|bp| ap.and_then(|ap| lerp_path(ap, bp, t)))) { + (Some(_), Some(path)) => { + let mut n = Node::path(NodeId(0), path, Appearance::default()); + if let NodeKind::Path { rule, .. } = &a.kind + && let NodeKind::Path { rule: r, .. } = &mut n.kind + { + *r = *rule; + } + n + } + _ => { + // Different structure: move/scale a copy of `a` towards `b`'s bounds. + let mut n = a.clone(); + if let (Some(ba), Some(bb)) = (a.geometric_bounds(), b.geometric_bounds()) { + let w = ba.width() + (bb.width() - ba.width()) * t; + let h = ba.height() + (bb.height() - ba.height()) * t; + let c = lerp_pt(ba.center(), bb.center(), t); + let sx = if ba.width() > 1e-9 { w / ba.width() } else { 1.0 }; + let sy = if ba.height() > 1e-9 { h / ba.height() } else { 1.0 }; + n.transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ba.center().to_vec2()), false); + } + if let NodeKind::Path { live, .. } = &mut n.kind { + *live = None; + } + n + } + }; + let mut n = d.reid(&{ + n.id = NodeId(0); + n + }); + n.appearance = lerp_appearance(&a.appearance, &b.appearance, t); + n.opacity = a.opacity + (b.opacity - a.opacity) * t as f32; + n.name = Some(BLEND_STEP_NAME.into()); + n.visible = true; + n.locked = false; + n +} + +/// Key objects + generated steps, in paint order. +fn build_blend(d: &mut drawcraft_doc::Document, keys: &[Node], steps: usize) -> Vec> { + let mut out = vec![]; + for (i, k) in keys.iter().enumerate() { + out.push(Arc::new(k.clone())); + if let Some(next) = keys.get(i + 1) { + for j in 1..=steps { + let t = j as f64 / (steps + 1) as f64; + out.push(Arc::new(blend_step(d, k, next, t))); + } + } + } + out +} + +fn steps_param(p: &Value, cmd: &str) -> Result { + let n = f64_or(p, "steps", 5.0); + if !(1.0..=1000.0).contains(&n) { + return Err(bad(cmd, "steps must be 1..1000")); + } + Ok(n as usize) +} + +fn blend_make(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.make"; + let steps = steps_param(p, C)?; + let roots = selected_roots(s)?; + if roots.len() < 2 { + return Err(bad(C, "select at least two objects")); + } + let top = *roots.last().unwrap(); + let id = s.edit("Make Blend", |d, sel| { + let keys: Vec = roots.iter().filter_map(|id| d.node(*id).cloned()).collect(); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let children = build_blend(d, &keys, steps); + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some(BLEND_NAME.into()); + d.insert(par, idx + 1, g)?; + for r in &roots { + d.remove(*r)?; + } + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0 })) +} + +fn keys_of(n: &Node) -> Vec { + n.children().into_iter().flatten().filter(|c| c.name.as_deref() != Some(BLEND_STEP_NAME)).map(|c| (**c).clone()).collect() +} + +fn blend_release(s: &mut Session, _: &Value) -> Result { + let groups = blend_groups(s); + let ids = s.edit("Release Blend", |d, sel| { + let mut out = vec![]; + for g in &groups { + let Some(n) = d.node(*g).cloned() else { continue }; + let (par, idx, _) = d.position(*g).ok_or(EngineError::NoNode(*g))?; + d.remove(*g)?; + for (k, key) in keys_of(&n).into_iter().enumerate() { + out.push(key.id); + d.insert(par, idx + k, key)?; + } + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn regenerate(s: &mut Session, label: &str, steps: Option, f: impl Fn(&mut Vec)) -> Result { + let groups = blend_groups(s); + s.edit(label, |d, _| { + for g in &groups { + let Some(n) = d.node(*g).cloned() else { continue }; + let mut keys = keys_of(&n); + let old_steps = n.children().map(|c| c.len()).unwrap_or(0).saturating_sub(keys.len()) / keys.len().saturating_sub(1).max(1); + f(&mut keys); + let children = build_blend(d, &keys, steps.unwrap_or(old_steps.max(1))); + if let Some(ch) = d.node_mut(*g).and_then(|n| n.children_mut()) { + *ch = children; + } + } + Ok(()) + })?; + ok() +} + +fn blend_options(s: &mut Session, p: &Value) -> Result { + let steps = steps_param(p, "object.blend.options")?; + regenerate(s, "Blend Options", Some(steps), |_| {}) +} + +fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + regenerate(s, "Reverse Spine", None, |keys| { + let centers: Vec = keys.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); + let n = keys.len(); + for (i, k) in keys.iter_mut().enumerate() { + let d: Vec2 = centers[n - 1 - i] - centers[i]; + k.transform(Affine::translate(d), false); + } + }) +} + +fn blend_expand(s: &mut Session, _: &Value) -> Result { + let groups = blend_groups(s); + s.edit("Expand Blend", |d, _| { + for g in &groups { + let Some(n) = d.node_mut(*g) else { continue }; + n.name = None; + for c in n.children_mut().into_iter().flatten() { + if c.name.as_deref() == Some(BLEND_STEP_NAME) { + Arc::make_mut(c).name = None; + } + } + } + Ok(()) + })?; + ok() +} + +fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { + let groups = blend_groups(s); + s.edit("Reverse Front to Back", |d, _| { + for g in &groups { + if let Some(ch) = d.node_mut(*g).and_then(|n| n.children_mut()) { + ch.reverse(); + } + } + Ok(()) + })?; + ok() +} + +// ---------- Artboards ---------- + +fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let n = s.edit("Convert to Artboards", |d, sel| { + let mut n = 0; + for id in &roots { + let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue }; + if b.width() <= 0.0 || b.height() <= 0.0 { + continue; + } + let aid = d.next_artboard_id(); + d.artboards.push(drawcraft_doc::Artboard { + id: aid, + name: format!("Artboard {aid}"), + rect: b, + show_center_mark: false, + show_cross_hairs: false, + }); + d.remove(*id)?; + n += 1; + } + if n == 0 { + return Err(EngineError::Other("Convert to Artboards: selection has no area".into())); + } + sel.clear(); + Ok(n) + })?; + Ok(json!({ "artboards": n })) +} + +fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { + let n_ab = s.doc()?.doc.artboards.len().max(1); + let cols = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64)) as usize; + let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5); + let by_col = bool_or(p, "byColumn", false); + let move_art = bool_or(p, "moveArtwork", true); + let scale_strokes = false; + s.edit("Rearrange Artboards", |d, _| { + let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); + let Some(first) = rects.first().copied() else { return Ok(()) }; + let n = rects.len(); + let rows = n.div_ceil(cols); + // Grid cell (row, col) of artboard i. + let cell = |i: usize| if by_col { (i % rows, i / rows) } else { (i / cols, i % cols) }; + let mut col_w = vec![0.0f64; cols]; + let mut row_h = vec![0.0f64; rows]; + for (i, r) in rects.iter().enumerate() { + let (ro, co) = cell(i); + col_w[co] = col_w[co].max(r.width()); + row_h[ro] = row_h[ro].max(r.height()); + } + let x_of = |c: usize| first.x0 + col_w[..c].iter().map(|w| w + spacing).sum::(); + let y_of = |r: usize| first.y0 + row_h[..r].iter().map(|h| h + spacing).sum::(); + let deltas: Vec = rects + .iter() + .enumerate() + .map(|(i, r)| { + let (ro, co) = cell(i); + Point::new(x_of(co), y_of(ro)) - r.origin() + }) + .collect(); + if move_art { + let tops: Vec<(NodeId, Point)> = d + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .filter_map(|n| Some((n.id, n.geometric_bounds()?.center()))) + .collect(); + for (id, c) in tops { + if let Some(i) = rects.iter().position(|r| r.contains(c)) + && deltas[i] != Vec2::ZERO + && let Some(n) = d.node_mut(id) + { + n.transform(Affine::translate(deltas[i]), scale_strokes); + } + } + } + for (a, dl) in d.artboards.iter_mut().zip(&deltas) { + a.rect = a.rect + *dl; + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 25cadc68..259b193c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,17 +1,22 @@ //! The command registry. Ids follow Illustrator's menu structure. +mod colorcmds; mod create; +mod docmenu; mod draw2; mod edit; mod effectcmd; mod file; mod layer; +pub(crate) mod menucmds; mod object; mod paint; +mod panelcmds; mod path; mod pathops; mod select; mod typecmd; +mod typemenu; mod xform; use drawcraft_color::Color; @@ -123,6 +128,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(effectcmd::specs()); v.extend(pathops::specs()); v.extend(typecmd::specs()); + v.extend(menucmds::specs()); + v.extend(colorcmds::specs()); + v.extend(typemenu::specs()); + v.extend(docmenu::specs()); + v.extend(panelcmds::specs()); v }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 1937f1ff..4067b9c2 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -269,17 +269,31 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { Arrange::Back => "Send to Back", }; s.edit(label, |d, _| { - let n = ids.len(); - for (k, id) in ids.iter().enumerate() { - let Some((par, idx, len)) = d.position(*id) else { continue }; - let to = match how { - Arrange::Front => len - 1 - k, - Arrange::Back => k, - Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)), - Arrange::Backward => idx.saturating_sub(1).max(k.min(n)), - }; - if to != idx { - d.move_node(*id, par, to)?; + // Arrange within each parent independently (selections may span layers/groups). + let mut by_parent: Vec<(Option, Vec)> = vec![]; + for id in &ids { + let par = d.parent_of(*id); + match by_parent.iter_mut().find(|(p, _)| *p == par) { + Some((_, v)) => v.push(*id), + None => by_parent.push((par, vec![*id])), + } + } + for (par, group) in by_parent { + for (k, id) in group.iter().enumerate() { + let Some((_, idx, len)) = d.position(*id) else { continue }; + if len == 0 { + continue; + } + let last = len - 1; + let to = match how { + Arrange::Front => last.saturating_sub(k), + Arrange::Back => k.min(last), + Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx), + Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)), + }; + if to != idx { + d.move_node(*id, par, to)?; + } } } Ok(()) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs new file mode 100644 index 00000000..dde0509e --- /dev/null +++ b/crates/engine/src/cmd/panelcmds.rs @@ -0,0 +1,552 @@ +//! Commands the panels need beyond the core set: in-place gradient editing (Gradient panel), +//! appearance item duplicate/reorder, artboard reorder/duplicate, swatch groups, graphic style +//! management and the advanced Character/Paragraph attributes. + +use drawcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint, SwatchGroup}; +use drawcraft_doc::{Document, NodeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "paint.editGradient", + "Gradient", + ["Window", "Gradient"], + None, + "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + has_doc, + edit_gradient + ), + cmd!( + "stroke.setAdvanced", + "Stroke Options", + [], + None, + "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras", + has_doc, + stroke_advanced + ), + cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item), + cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item), + cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder), + cmd!( + "artboard.duplicate", + "Duplicate Artboard", + ["Window", "Artboards"], + None, + "{index} copy placed right of the last artboard", + has_doc, + artboard_duplicate + ), + cmd!( + "swatch.newGroup", + "New Color Group", + ["Window", "Swatches"], + None, + "{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}", + has_doc, + swatch_new_group + ), + cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate), + cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort), + cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), + cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), + cmd!( + "text.setFormat", + "Character / Paragraph", + [], + None, + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}", + has_doc, + set_format + ), + ] +} + +// ---------- gradient ---------- + +/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels. +fn leaf_targets(s: &Session, p: &Value) -> Result> { + let ids = targets(s, p)?; + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(id) else { continue }; + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { + if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { + out.push(c.id) + } + }), + _ => out.push(id), + } + } + let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); + out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); + Ok(out) +} + +/// Apply the gradient edits in `p` to `paint` (pure; unit-tested). +pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { + let mut gp = match paint { + Paint::Gradient(g) => (**g).clone(), + _ => GradientPaint::new(Gradient::default()), + }; + if let Some(k) = str_param(p, "kind") { + let kind = match k.to_ascii_lowercase().as_str() { + "linear" => GradientKind::Linear, + "radial" => GradientKind::Radial, + "freeform" => GradientKind::Freeform, + _ => return Err(format!("unknown gradient kind `{k}`")), + }; + if kind != gp.gradient.kind { + gp.gradient.kind = kind; + gp.geom = None; + } + } + if let Some(stops) = p.get("stops") { + let arr = stops.as_array().ok_or("`stops` must be an array")?; + let mut out = Vec::with_capacity(arr.len()); + for st in arr { + let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?; + let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?; + let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); + let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5); + out.push(GradientStop { + offset: offset.clamp(0.0, 1.0) as f32, + color, + opacity: opacity.clamp(0.0, 1.0) as f32, + midpoint: midpoint.clamp(0.13, 0.87) as f32, + }); + } + if out.len() < 2 { + return Err("a gradient needs at least two stops".into()); + } + gp.gradient.stops = out; + gp.gradient.sort(); + gp.swatch = None; + } + if bool_or(p, "reverse", false) { + gp.gradient.reverse(); + // Midpoints belong to the segment to the right; mirror them. + let n = gp.gradient.stops.len(); + let mids: Vec = gp.gradient.stops.iter().map(|s| s.midpoint).collect(); + for i in 0..n { + gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; + } + } + if let Some(a) = p.get("angle").and_then(Value::as_f64) { + let a = ((a + 180.0).rem_euclid(360.0)) - 180.0; + gp.angle = a; + if let Some(g) = &mut gp.geom { + let r = a.to_radians(); + let dir = drawcraft_geom::Vec2::new(r.cos(), -r.sin()); + if gp.gradient.kind == GradientKind::Radial { + g.end = g.start + dir * g.length(); + } else { + let c = g.start.midpoint(g.end); + let half = g.length() / 2.0; + g.start = c - dir * half; + g.end = c + dir * half; + } + } + } + if let Some(asp) = p.get("aspect").and_then(Value::as_f64) { + let asp = (asp / 100.0).clamp(0.005, 327.67); + if gp.geom.is_none() + && let Some(b) = bounds + { + gp.geom = Some(gp.resolve(b)); + } + if let Some(g) = &mut gp.geom { + g.aspect = asp; + } + } + Ok(Paint::Gradient(Box::new(gp))) +} + +fn edit_gradient(s: &mut Session, p: &Value) -> Result { + const C: &str = "paint.editGradient"; + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let ids = leaf_targets(s, p)?; + // Validate against the defaults first so bad params fail without touching the document. + let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; + let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; + if ids.is_empty() { + if stroke { + s.paint.stroke = new_default; + } else { + s.paint.fill = new_default; + } + return Ok(json!({"ids": []})); + } + let mut err = None; + s.edit("Gradient", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; + match apply_gradient_edit(cur, p, None) { + Ok(np) => *cur = np, + Err(e) => err = Some(e), + } + } + continue; + } + let b = n.geometric_bounds(); + let ap = &mut n.appearance; + let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() }; + match apply_gradient_edit(&cur, p, b) { + Ok(np) => { + if stroke { + ap.set_stroke(np) + } else { + ap.set_fill(np) + } + } + Err(e) => err = Some(e), + } + } + match err.take() { + Some(e) => Err(bad(C, e)), + None => Ok(()), + } + })?; + if stroke { + s.paint.stroke = new_default; + } else { + s.paint.fill = new_default; + } + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) +} + +// ---------- appearance ---------- + +fn duplicate_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize; + let ids = leaf_targets(s, p)?; + s.edit("Duplicate Item", |d, _| { + let mut any = false; + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if let Some(item) = n.appearance.items.get(idx).cloned() { + n.appearance.items.insert(idx + 1, item); + any = true; + } + } + if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } + })?; + ok() +} + +fn move_item(s: &mut Session, p: &Value) -> Result { + const C: &str = "appearance.moveItem"; + let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; + let ids = leaf_targets(s, p)?; + s.edit("Reorder Appearance", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let items = &mut n.appearance.items; + if from >= items.len() { + return Err(bad(C, format!("no item at index {from}"))); + } + let it = items.remove(from); + let to = to.min(items.len()); + items.insert(to, it); + } + Ok(()) + })?; + ok() +} + +// ---------- artboards ---------- + +fn artboard_reorder(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.reorder"; + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; + s.edit("Reorder Artboards", |d, _| { + if i >= d.artboards.len() { + return Err(EngineError::Other("no such artboard".into())); + } + let a = d.artboards.remove(i); + let to = to.min(d.artboards.len()); + d.artboards.insert(to, a); + Ok(()) + })?; + ok() +} + +fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = s.edit("Duplicate Artboard", |d, _| { + let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?; + let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max); + let mut a = src.clone(); + a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1; + a.name = format!("{} copy", src.name); + let dx = right + 20.0 - src.rect.x0; + a.rect = drawcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1); + d.artboards.push(a); + Ok(d.artboards.len() - 1) + })?; + Ok(json!({"index": index})) +} + +// ---------- swatches ---------- + +fn unique_name(d: &Document, base: &str) -> String { + let exists = |n: &str| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n); + if !exists(base) { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| !exists(n)).unwrap_or_else(|| base.to_string()) +} + +fn swatch_new_group(s: &mut Session, p: &Value) -> Result { + let names: Vec = + p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + let colors: Vec = + p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); + let requested = str_param(p, "name").map(str::to_string); + let name = s.edit("New Color Group", |d, _| { + let name = unique_name(d, requested.as_deref().unwrap_or("Color Group")); + let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; + for n in &names { + if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) { + group.swatches.push(d.swatches.remove(pos)); + } + } + for c in &colors { + let [r, g, b] = c.to_rgb(); + let nm = format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()); + group.swatches.push(drawcraft_color::Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); + } + d.swatch_groups.push(group); + Ok(name) + })?; + Ok(json!({"name": name})) +} + +fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); + let new = s.edit("Duplicate Swatch", |d, _| { + let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; + let nm = unique_name(d, &format!("{name} copy")); + let copy = drawcraft_color::Swatch { name: nm.clone(), ..src }; + if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) { + d.swatches.insert(pos + 1, copy); + } else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) { + let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0); + g.swatches.insert(pos + 1, copy); + } + Ok(nm) + })?; + Ok(json!({"name": new})) +} + +fn swatch_sort(s: &mut Session, _: &Value) -> Result { + s.edit("Sort Swatches", |d, _| { + // [None] and other bracketed specials stay first, like Illustrator. + let key = |sw: &drawcraft_color::Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase()); + d.swatches.sort_by_key(key); + for g in &mut d.swatch_groups { + g.swatches.sort_by_key(key); + } + Ok(()) + })?; + ok() +} + +// ---------- graphic styles ---------- + +fn style_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string(); + s.edit("Delete Graphic Style", |d, _| { + let before = d.graphic_styles.len(); + d.graphic_styles.retain(|g| g.name != name); + if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) } + })?; + ok() +} + +fn style_duplicate(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string(); + let new = s.edit("Duplicate Graphic Style", |d, _| { + let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; + let mut g = d.graphic_styles[pos].clone(); + let base = format!("{name} copy"); + let mut nm = base.clone(); + let mut i = 2; + while d.graphic_styles.iter().any(|x| x.name == nm) { + nm = format!("{base} {i}"); + i += 1; + } + g.name = nm.clone(); + d.graphic_styles.insert(pos + 1, g); + Ok(nm) + })?; + Ok(json!({"name": new})) +} + +// ---------- text ---------- + +fn set_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setFormat"; + let ids: Vec = { + let ids = targets(s, p)?; + let d = &s.doc()?.doc; + ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no text objects selected")); + } + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let kerning = match p.get("kerning") { + None | Some(Value::Null) => None, + Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), + Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))), + }; + let keys = [ + "kerning", + "baselineShift", + "hScale", + "vScale", + "rotation", + "underline", + "strikethrough", + "allCaps", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "hyphenate", + ]; + if !keys.iter().any(|k| p.get(*k).is_some()) { + return Err(bad(C, "nothing to change")); + } + s.edit("Character", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + for r in &mut t.runs { + let st = &mut r.style; + if let Some(k) = kerning { + st.kerning = k; + } + if let Some(v) = num("baselineShift") { + st.baseline_shift = v.clamp(-1296.0, 1296.0); + } + if let Some(v) = num("hScale") { + st.h_scale = v.clamp(1.0, 10000.0); + } + if let Some(v) = num("vScale") { + st.v_scale = v.clamp(1.0, 10000.0); + } + if let Some(v) = num("rotation") { + st.rotation = ((v + 180.0).rem_euclid(360.0)) - 180.0; + } + if let Some(v) = flag("underline") { + st.underline = v; + } + if let Some(v) = flag("strikethrough") { + st.strikethrough = v; + } + if let Some(v) = flag("allCaps") { + st.all_caps = v; + } + } + let para = &mut t.para; + if let Some(v) = num("leftIndent") { + para.left_indent = v; + } + if let Some(v) = num("rightIndent") { + para.right_indent = v; + } + if let Some(v) = num("firstLineIndent") { + para.first_line_indent = v; + } + if let Some(v) = num("spaceBefore") { + para.space_before = v; + } + if let Some(v) = num("spaceAfter") { + para.space_after = v; + } + if let Some(v) = flag("hyphenate") { + para.hyphenate = v; + } + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +// ---------- stroke extras ---------- + +/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). +pub(crate) fn flip_profile(p: &drawcraft_doc::WidthProfile, along: bool) -> drawcraft_doc::WidthProfile { + let mut pts: Vec<(f64, f64, f64)> = + if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + drawcraft_doc::WidthProfile { points: pts } +} + +fn stroke_advanced(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.setAdvanced"; + let scale = match p.get("arrowScale") { + None => None, + Some(v) => { + let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?; + let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0)); + Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?)) + } + }; + let flip = match str_param(p, "flipProfile") { + None => None, + Some("along") => Some(true), + Some("across") => Some(false), + Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))), + }; + let swap = bool_or(p, "swapArrows", false); + let brush = match p.get("brush") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(b)) => Some(Some(b.clone())), + Some(_) => return Err(bad(C, "brush must be a name or null")), + }; + if scale.is_none() && flip.is_none() && !swap && brush.is_none() { + return Err(bad(C, "nothing to change")); + } + let ids = leaf_targets(s, p)?; + if ids.is_empty() { + return ok(); + } + s.edit("Stroke", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(st) = n.appearance.stroke_mut() else { continue }; + if let Some(sc) = scale { + st.arrow_scale = sc; + } + if swap { + std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); + st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); + } + if let (Some(along), Some(pr)) = (flip, &st.profile) { + st.profile = Some(flip_profile(pr, along)); + } + if let Some(b) = &brush { + st.brush = b.clone(); + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs new file mode 100644 index 00000000..41a2d477 --- /dev/null +++ b/crates/engine/src/cmd/typemenu.rs @@ -0,0 +1,603 @@ +//! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path +//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text +//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). + +use drawcraft_doc::{CharStyle, Document, NodeId, NodeKind, TextKind, TextRun}; +use drawcraft_geom::{Affine, Rect, shapes}; +use serde_json::{Value, json}; + +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "type.changeCase", + "Change Case", + ["Type", "Change Case"], + None, + "{case: \"upper\"|\"lower\"|\"title\"|\"sentence\", ids?} → {ids}", + has_selection, + change_case + ), + cmd!( + "type.smartPunctuation", + "Smart Punctuation…", + ["Type"], + None, + "{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}", + has_doc, + smart_punctuation + ), + cmd!( + "type.convertToAreaType", + "Convert To Area Type", + ["Type"], + None, + "{ids?} point type → area type framed by its current layout bounds → {ids}", + has_selection, + to_area + ), + cmd!( + "type.convertToPointType", + "Convert To Point Type", + ["Type"], + None, + "{ids?} area type → point type; soft line wraps become line breaks → {ids}", + has_selection, + to_point + ), + cmd!( + "type.pathOptions", + "Type on a Path Options…", + ["Type", "Type on a Path"], + None, + "{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), ids?}", + has_selection, + path_options + ), + cmd!( + "type.fillPlaceholder", + "Fill with Placeholder Text", + ["Type"], + None, + "{ids?} replace selected text with placeholder text (area type is filled to its frame) → {ids}", + has_selection, + fill_placeholder + ), + cmd!( + "type.insert", + "Insert Character", + ["Type"], + None, + "{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string} append to the selected text objects", + has_selection, + insert_char + ), + cmd!( + "edit.findReplace", + "Find and Replace…", + ["Edit"], + None, + "{find, replace?: \"\", matchCase?: false, wholeWord?: false, ids?} replace in every text object (or ids) → {count}. Matches never span style runs.", + has_doc, + find_replace + ), + cmd!( + "edit.findNext", + "Find Next", + ["Edit"], + None, + "{find, matchCase?, wholeWord?} select the next text object (after the selected one, wrapping) containing `find` → {id|null}", + has_doc, + find_next + ), + cmd!( + "edit.pasteWithoutFormatting", + "Paste without Formatting", + ["Edit"], + Some("Cmd+Alt+V"), + "{dx?, dy?} paste; pasted text takes the default character style (one run)", + has_clipboard, + paste_plain + ), + ] +} + +/// Text objects among `ids` and their descendants. +pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + } + out +} + +fn texts(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = targets(s, p)?; + let t = text_ids(&s.doc()?.doc, &ids); + if t.is_empty() { + return Err(bad(cmd, "no text objects selected")); + } + Ok(t) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Rewrite every run of the given text objects with `f(run text, state)`. +fn edit_runs(s: &mut Session, label: &str, ids: &[NodeId], f: impl Fn(&str, &mut S) -> String) -> Result { + s.edit(label, |d, _| { + let mut changed = 0; + for id in ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let mut st = S::default(); + let mut any = false; + for r in &mut t.runs { + let n = f(&r.text, &mut st); + if n != r.text { + r.text = n; + any = true; + } + } + if any { + changed += 1; + refresh_bounds(t); + } + } + Ok(changed) + }) +} + +// ---------- Change Case ---------- + +#[derive(Default)] +struct CaseState { + /// Previous character (across runs). + prev: Option, + /// Sentence start pending (sentence case). + sentence: bool, + started: bool, +} + +fn change_case(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.changeCase"; + let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase(); + if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) { + return Err(bad(C, "case must be upper|lower|title|sentence")); + } + let ids = texts(s, p, C)?; + edit_runs::(s, "Change Case", &ids, |text, st| { + if !st.started { + st.started = true; + st.sentence = true; + } + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match case.as_str() { + "upper" | "uppercase" => out.extend(c.to_uppercase()), + "lower" | "lowercase" => out.extend(c.to_lowercase()), + "title" => { + if st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) { + out.extend(c.to_uppercase()) + } else { + out.extend(c.to_lowercase()) + } + } + _ => { + if c.is_alphabetic() && st.sentence { + out.extend(c.to_uppercase()); + st.sentence = false; + } else { + out.extend(c.to_lowercase()); + } + if matches!(c, '.' | '!' | '?' | '\n') { + st.sentence = true; + } + } + } + st.prev = Some(c); + } + out + })?; + Ok(ids_json(&ids)) +} + +// ---------- Smart Punctuation ---------- + +#[derive(Default)] +struct PunctState { + prev: Option, +} + +fn opens(prev: Option) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘')) +} + +pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: bool, dashes: bool, ellipsis: bool) -> String { + let mut s = text.to_string(); + if dashes { + s = s.replace("---", "—").replace("--", "–"); + } + if ellipsis { + s = s.replace("...", "…"); + } + if !quotes { + *prev = s.chars().last().or(*prev); + return s; + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let r = match c { + '"' => { + if opens(*prev) { + '“' + } else { + '”' + } + } + '\'' => { + if opens(*prev) { + '‘' + } else { + '’' + } + } + c => c, + }; + out.push(r); + *prev = Some(r); + } + out +} + +fn smart_punctuation(s: &mut Session, p: &Value) -> Result { + let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true)); + let st = s.doc()?; + let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() { + let mut v = vec![]; + st.doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + v.push(n.id) + } + }); + v + } else { + text_ids(&st.doc, &st.selection.objects) + }; + let n = edit_runs::(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?; + Ok(json!({ "changed": n })) +} + +// ---------- Area / Point conversion ---------- + +fn to_area(s: &mut Session, p: &Value) -> Result { + let ids = texts(s, p, "type.convertToAreaType")?; + s.edit("Convert To Area Type", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if !matches!(t.kind, TextKind::Point) { + continue; + } + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let size = t.first_style().size; + // A little slack on the right so the text doesn't rewrap. + let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); + let frame = Rect::new(b.x0, top, b.x1 + size * 0.5, b.y1.max(top + 1.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids_json(&ids)) +} + +/// (run, byte within the run) for a byte offset into the plain text. +fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> { + let mut off = 0; + for (i, r) in runs.iter().enumerate() { + if byte < off + r.text.len() { + return Some((i, byte - off)); + } + off += r.text.len(); + } + None +} + +fn to_point(s: &mut Session, p: &Value) -> Result { + let ids = texts(s, p, "type.convertToPointType")?; + s.edit("Convert To Point Type", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let TextKind::Area { frame } = &t.kind else { continue }; + let fb = frame.bounds().unwrap_or_default(); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let plain = t.plain_text(); + let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); + // Soft wraps → explicit breaks (replace the space before the wrap when there is one). + let mut breaks: Vec = + lay.lines.iter().skip(1).map(|l| l.start).filter(|&b| b > 0 && plain.as_bytes().get(b - 1) != Some(&b'\n')).collect(); + breaks.sort_unstable(); + breaks.dedup(); + for b in breaks.into_iter().rev() { + if plain.as_bytes().get(b - 1) == Some(&b' ') { + if let Some((ri, bi)) = locate(&t.runs, b - 1) { + t.runs[ri].text.replace_range(bi..bi + 1, "\n"); + } + } else if let Some((ri, bi)) = locate(&t.runs, b) { + t.runs[ri].text.insert(bi, '\n'); + } + } + t.kind = TextKind::Point; + t.xf *= Affine::translate((fb.x0, baseline)); + refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids_json(&ids)) +} + +fn path_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.pathOptions"; + let ids = texts(s, p, C)?; + let start = p.get("start").and_then(Value::as_f64); + let flip = bool_or(p, "flip", false); + let n = s.edit("Type on a Path Options", |d, _| { + let mut n = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let TextKind::OnPath { path, start: st } = &mut t.kind else { continue }; + if let Some(v) = start { + *st = v.clamp(0.0, 1.0); + } + if flip { + path.reverse(); + } + n += 1; + refresh_bounds(t); + } + if n == 0 { + return Err(bad(C, "select type on a path")); + } + Ok(n) + })?; + Ok(json!({ "changed": n })) +} + +// ---------- placeholder / insert ---------- + +/// DrawCraft's own placeholder copy (not the classical Latin text). +pub(crate) const PLACEHOLDER: &str = "Sample copy flows here while the layout takes shape. Swap these words for real text once the \ +design is settled. Every line is only a stand-in that shows size, rhythm and colour. Headlines, captions and body text all \ +start as rough drafts like this one. Keep going until the frame is full and the page feels balanced."; + +fn fill_placeholder(s: &mut Session, p: &Value) -> Result { + let ids = texts(s, p, "type.fillPlaceholder")?; + s.edit("Fill with Placeholder Text", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let style = t.first_style(); + let text = match &t.kind { + TextKind::Area { frame } => { + let b = frame.bounds().unwrap_or_default(); + let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0); + let lines = (b.height() / style.effective_leading()).max(1.0); + let want = (chars_per_line * lines) as usize; + let mut out = String::new(); + let words: Vec<&str> = PLACEHOLDER.split(' ').collect(); + let mut i = 0; + while out.len() < want && i < 10_000 { + if !out.is_empty() { + out.push(' '); + } + out.push_str(words[i % words.len()]); + i += 1; + } + out + } + _ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".", + }; + t.runs = vec![TextRun { text, style }]; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids_json(&ids)) +} + +pub(crate) fn special_char(name: &str) -> Option<&'static str> { + Some(match name { + "bullet" => "•", + "copyright" => "©", + "ellipsis" => "…", + "paragraph" => "¶", + "registered" => "®", + "section" => "§", + "trademark" => "™", + "emDash" => "—", + "enDash" => "–", + "discretionaryHyphen" => "\u{00AD}", + "nonBreakingHyphen" => "\u{2011}", + "doubleLeftQuote" => "“", + "doubleRightQuote" => "”", + "singleLeftQuote" => "‘", + "singleRightQuote" => "’", + "emSpace" => "\u{2003}", + "enSpace" => "\u{2002}", + "hairSpace" => "\u{200A}", + "sixthSpace" => "\u{2006}", + "thinSpace" => "\u{2009}", + "nonBreakingSpace" => "\u{00A0}", + "figureSpace" => "\u{2007}", + "punctuationSpace" => "\u{2008}", + "thirdSpace" => "\u{2004}", + "quarterSpace" => "\u{2005}", + "tab" => "\t", + // Our text model breaks lines at paragraph returns only. + "forcedLineBreak" | "paragraphReturn" => "\n", + _ => return None, + }) +} + +fn insert_char(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.insert"; + let text = match (str_param(p, "char"), str_param(p, "text")) { + (Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), + (None, Some(t)) => t.to_string(), + _ => return Err(bad(C, "give `char` or `text`")), + }; + let ids = texts(s, p, C)?; + s.edit("Typing", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + match t.runs.last_mut() { + Some(r) => r.text.push_str(&text), + None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }), + } + refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids_json(&ids)) +} + +// ---------- Find and Replace ---------- + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// Byte ranges of `needle` in `hay`. +pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> { + let nc: Vec = needle.chars().collect(); + if nc.is_empty() { + return vec![]; + } + let hc: Vec<(usize, char)> = hay.char_indices().collect(); + let eq = |a: char, b: char| if match_case { a == b } else { a == b || a.to_lowercase().eq(b.to_lowercase()) }; + let mut out = vec![]; + let mut i = 0; + while i + nc.len() <= hc.len() { + if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) { + let before = i.checked_sub(1).map(|j| hc[j].1); + let after = hc.get(i + nc.len()).map(|x| x.1); + if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) { + let start = hc[i].0; + let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len()); + out.push((start, end)); + i += nc.len(); + continue; + } + } + i += 1; + } + out +} + +fn all_texts(d: &Document) -> Vec { + let mut v = vec![]; + for l in d.layers.iter().filter(|l| l.visible && !l.locked) { + l.walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked { + v.push(n.id); + } + }); + } + v +} + +fn find_replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.findReplace"; + let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let rep = str_param(p, "replace").unwrap_or("").to_string(); + let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); + let ids = match ids_param(p, "ids") { + Some(v) => text_ids(&s.doc()?.doc, &v), + None => all_texts(&s.doc()?.doc), + }; + let st = s.doc()?; + let total: usize = ids + .iter() + .filter_map(|id| match &st.doc.node(*id)?.kind { + NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::()), + _ => None, + }) + .sum(); + if total == 0 { + return Ok(json!({ "count": 0 })); + } + edit_runs::<()>(s, "Replace", &ids, |text, _| { + let mut out = String::with_capacity(text.len()); + let mut last = 0; + for (a, b) in find_all(text, &find, mc, ww) { + out.push_str(&text[last..a]); + out.push_str(&rep); + last = b; + } + out.push_str(&text[last..]); + out + })?; + Ok(json!({ "count": total })) +} + +fn find_next(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.findNext"; + let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); + let st = s.doc()?; + let all = all_texts(&st.doc); + let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id)); + let n = all.len(); + let start = cur.map(|c| c + 1).unwrap_or(0); + let hit = (0..n).map(|k| all[(start + k) % n.max(1)]).find(|id| match st.doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => !find_all(&t.plain_text(), &find, mc, ww).is_empty(), + _ => false, + }); + match hit { + Some(id) => { + s.select(|_, sel| sel.set([id]))?; + Ok(json!({ "id": id.0 })) + } + None => Ok(json!({ "id": null })), + } +} + +// ---------- Paste without Formatting ---------- + +fn strip_formatting(n: &mut drawcraft_doc::Node) { + if let NodeKind::Text(t) = &mut n.kind { + let text = t.plain_text(); + t.runs = vec![TextRun { text, style: CharStyle::default() }]; + t.para = Default::default(); + refresh_bounds(t); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + strip_formatting(std::sync::Arc::make_mut(c)); + } + } +} + +fn paste_plain(s: &mut Session, p: &Value) -> Result { + let saved = s.clipboard.clone(); + for n in &mut s.clipboard { + strip_formatting(n); + } + let paste = find_command("edit.paste").map(|c| c.run).ok_or_else(|| EngineError::Other("paste unavailable".into()))?; + let r = paste(s, p); + s.clipboard = saved; + if r.is_ok() + && let Some(e) = s.active_mut().and_then(|d| d.history.undo.last_mut()) + { + e.label = "Paste without Formatting".into(); + } + r +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff88eb64..1be7d4c9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,9 @@ pub struct Interaction { pub doc: Arc, pub selection: Selection, pub preview: Option<(String, Value)>, + /// Per-document state restored on cancel (current layer, isolation). + pub active_layer: Option, + pub isolation: Option, } /// Per-document editing state. @@ -131,6 +134,16 @@ impl DocState { } } +/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. +pub const MAX_COORD: f64 = 4.0e6; + +/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must +/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. +fn doc_sane(d: &Document, sel: &Selection) -> bool { + let ok = |r: drawcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); + d.artboards.iter().all(|a| ok(a.rect)) && sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok)) +} + /// Where new art goes (Illustrator's drawing modes, Shift+D cycles). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -173,6 +186,7 @@ pub struct Session { pub journal: Vec<(String, Value)>, pub(crate) tool: Box, pub(crate) last_view: ViewInfo, + depth: u32, /// Draw Normal / Behind / Inside (toolbar drawing modes). pub draw_mode: DrawMode, /// The path new art is drawn inside (Draw Inside). @@ -200,6 +214,7 @@ impl Session { journal: vec![], tool: drawcraft_tools::create("selection"), last_view: ViewInfo::default(), + depth: 0, draw_mode: DrawMode::Normal, draw_inside: None, untitled_counter: 0, @@ -259,8 +274,13 @@ impl Session { if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } - let r = (spec.run)(self, params)?; - if spec.journal && self.active().is_none_or(|d| d.interaction.is_none()) { + // Only top-level commands are journaled (commands that call other commands would otherwise + // be recorded twice and replay differently). + self.depth += 1; + let r = (spec.run)(self, params); + self.depth -= 1; + let r = r?; + if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { self.journal.push((id.to_string(), params.clone())); } Ok(r) @@ -273,7 +293,14 @@ impl Session { let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); - match f(doc, &mut st.selection) { + let result = f(doc, &mut st.selection).and_then(|v| { + if doc_sane(&st.doc, &st.selection) { + Ok(v) + } else { + Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) + } + }); + match result { Ok(v) => { st.selection.prune(&st.doc); st.revision += 1; @@ -310,7 +337,14 @@ impl Session { if st.interaction.is_some() { return Ok(()); } - st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None }); + st.interaction = Some(Interaction { + label: label.to_string(), + doc: st.doc.clone(), + selection: st.selection.clone(), + preview: None, + active_layer: st.active_layer, + isolation: st.isolation, + }); Ok(()) } @@ -355,6 +389,8 @@ impl Session { if let Some(it) = st.interaction.take() { st.doc = it.doc; st.selection = it.selection; + st.active_layer = it.active_layer; + st.isolation = it.isolation; st.revision += 1; } Ok(()) @@ -379,8 +415,8 @@ mod tests_file; #[cfg(test)] mod tests_menucmds; #[cfg(test)] -mod tests_pathops; -#[cfg(test)] mod tests_panelcmds; #[cfg(test)] +mod tests_pathops; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs new file mode 100644 index 00000000..7e23345e --- /dev/null +++ b/crates/engine/src/tests_menucmds.rs @@ -0,0 +1,706 @@ +//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`. + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind}; +use drawcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn text(s: &mut Session, t: &str) -> NodeId { + id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": t})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn fill(s: &mut Session, id: NodeId, hex: &str) { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap(); +} + +fn fill_of(s: &Session, id: NodeId) -> Color { + node(s, id).appearance.fill_paint().color().unwrap() +} + +fn plain(s: &Session, id: NodeId) -> String { + match &node(s, id).kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.objects.clone() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +// ---------- Object → Lock / Hide ---------- + +#[test] +fn lock_all_artwork_above_only_overlapping_objects_above() { + let mut s = session(); + let below = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0); + let far = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + sel(&mut s, &[a]); + let n = undo_len(&s); + let r = s.execute("object.lock.above", &json!({})).unwrap(); + assert_eq!(r["count"], 1); + assert!(node(&s, over).locked); + assert!(!node(&s, below).locked && !node(&s, far).locked && !node(&s, a).locked); + assert_eq!(undo_len(&s), n + 1); +} + +#[test] +fn hide_all_artwork_above() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0); + sel(&mut s, &[a]); + s.execute("object.hide.above", &json!({})).unwrap(); + assert!(!node(&s, over).visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, over).visible); +} + +#[test] +fn lock_and_hide_other_layers() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap_or(json!({"id": 0}))); + let l1 = s.doc().unwrap().doc.layers[0].id; + assert_ne!(l1, l2); + sel(&mut s, &[a]); + s.execute("object.lock.otherLayers", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(l2).unwrap().locked); + assert!(!d.node(l1).unwrap().locked); + s.execute("object.hide.otherLayers", &json!({})).unwrap(); + assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible); +} + +// ---------- Transform Each ---------- + +#[test] +fn transform_each_scales_each_about_its_own_center() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a, b]); + let n = undo_len(&s); + s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(25.0, 25.0, 75.0, 75.0)); + assert_eq!(bounds(&s, b), Rect::new(225.0, 25.0, 275.0, 75.0)); + assert_eq!(undo_len(&s), n + 1); +} + +#[test] +fn transform_each_move_reference_and_copy() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + // Top-left reference: scaling keeps the top-left corner. + let r = s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": 5, "reference": 0, "copy": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert_ne!(copy, a); + assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(bounds(&s, copy), Rect::new(10.0, 5.0, 60.0, 55.0)); +} + +#[test] +fn transform_each_random_is_deterministic_with_seed() { + let run = || { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.transformEach", &json!({"moveH": 50, "random": true, "seed": 7})).unwrap(); + bounds(&s, a) + }; + let (x, y) = (run(), run()); + assert_eq!(x, y); + assert!(x.x0.abs() <= 50.0 && x.width() == 100.0); +} + +#[test] +fn reset_bounding_box_is_a_noop() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + let n = undo_len(&s); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0); + assert_eq!(undo_len(&s), n); +} + +// ---------- Expand / Rasterize / Crop / Trim marks ---------- + +#[test] +fn expand_live_shape_and_text_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let t = text(&mut s, "Hi"); + sel(&mut s, &[a, t]); + let n = undo_len(&s); + s.execute("object.expand", &json!({})).unwrap(); + assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. })); + assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines"); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(_), .. })); + assert!(s.doc().unwrap().doc.node(t).is_some()); +} + +#[test] +fn expand_nothing_errors() { + let mut s = session(); + let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]})).unwrap()); + sel(&mut s, &[p]); + // A plain path: nothing to expand for object/fill. + let r = s.execute("object.expand", &json!({"stroke": false})); + assert!(r.is_err()); +} + +#[test] +fn rasterize_replaces_selection_with_image() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0); + fill(&mut s, a, "#ff0000"); + sel(&mut s, &[a]); + let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap(); + let r = s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap(); + let img = id_of(&r); + assert!(s.doc().unwrap().doc.node(a).is_none()); + let n = node(&s, img); + let NodeKind::Image(im) = &n.kind else { panic!() }; + // Visual bounds (stroke included) at 2 px/pt. + assert_eq!((im.width as f64, im.height as f64), ((vb.width() * 2.0).ceil(), (vb.height() * 2.0).ceil())); + assert!(s.doc().unwrap().doc.images.contains_key(&im.key)); + let b = n.geometric_bounds().unwrap(); + assert!(close(b.x0, vb.x0) && close(b.y0, vb.y0) && b.width() >= vb.width()); + assert_eq!(selected(&s), vec![img]); +} + +#[test] +fn crop_image_to_rect() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap()); + let r = s.execute("object.cropImage", &json!({"rect": [10, 10, 50, 40]})).unwrap(); + assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (50, 40)); + let b = bounds(&s, img); + assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}"); + // Image fully inside the artboard: default crop has nothing to do. + assert!(s.execute("object.cropImage", &json!({})).is_err()); +} + +#[test] +fn crop_image_disabled_without_image() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + assert!(matches!(s.execute("object.cropImage", &json!({})), Err(EngineError::Disabled(..)))); +} + +#[test] +fn trim_marks_around_selection() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + fill(&mut s, a, "#00ff00"); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 0})).unwrap(); + sel(&mut s, &[a]); + let g = id_of(&s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20})).unwrap()); + let n = node(&s, g); + assert_eq!(n.children().unwrap().len(), 8); + assert_eq!(n.name.as_deref(), Some("Trim Marks")); + let b = n.geometric_bounds().unwrap(); + let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap(); + assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}"); +} + +// ---------- Convert to Shape ---------- + +#[test] +fn convert_rectangle_path_to_live_shape() { + let mut s = session(); + let p = id_of( + &s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 40, "y": 20}, {"x": 0, "y": 20}], "closed": true})) + .unwrap(), + ); + sel(&mut s, &[p]); + assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1); + let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() }; + assert!(close(*w, 40.0) && close(*h, 20.0)); + assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0))); +} + +#[test] +fn convert_rotated_rectangle_and_ellipse() { + let mut s = session(); + let p = id_of( + &s.execute( + "path.create", + &json!({"anchors": [{"x": 50, "y": 0}, {"x": 100, "y": 50}, {"x": 50, "y": 100}, {"x": 0, "y": 50}], "closed": true}), + ) + .unwrap(), + ); + let e = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap()); + sel(&mut s, &[e]); + s.execute("object.expandShape", &json!({})).unwrap(); + sel(&mut s, &[p, e]); + assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2); + let b = bounds(&s, p); + assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}"); + assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); +} + +#[test] +fn convert_to_shape_rejects_other_paths() { + let mut s = session(); + let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 10, "y": 20}], "closed": true})).unwrap()); + sel(&mut s, &[p]); + assert!(s.execute("object.shape.convertToShape", &json!({})).is_err()); +} + +// ---------- Blend ---------- + +#[test] +fn blend_make_release_options() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 30.0, 30.0); + fill(&mut s, a, "#000000"); + fill(&mut s, b, "#ffffff"); + sel(&mut s, &[a, b]); + let n = undo_len(&s); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + assert_eq!(undo_len(&s), n + 1); + let gn = node(&s, g); + assert_eq!(gn.name.as_deref(), Some("Blend")); + let ch = gn.children().unwrap().clone(); + assert_eq!(ch.len(), 5); + // Middle step: halfway in position, size and colour. + let mid = &ch[2]; + let mb = mid.geometric_bounds().unwrap(); + assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}"); + let c = mid.appearance.fill_paint().color().unwrap().to_rgb(); + assert!((c[0] - 0.5).abs() < 1e-3); + // Options: 1 step. + s.execute("object.blend.options", &json!({"steps": 1})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().len(), 3); + // Release: keys only, back in the layer. + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 2); + assert!(s.doc().unwrap().doc.node(g).is_none()); + assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some()); +} + +#[test] +fn blend_expand_and_reverse() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); + assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + assert!(close(bounds(&s, a).x0, 100.0)); + s.execute("object.blend.expand", &json!({})).unwrap(); + assert!(node(&s, g).name.is_none()); + assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend"); +} + +// ---------- Artboards ---------- + +#[test] +fn convert_to_artboards_and_rearrange() { + let mut s = session(); + let a = rect(&mut s, 900.0, 0.0, 200.0, 100.0); + sel(&mut s, &[a]); + assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 1); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 2); + assert_eq!(d.artboards[1].rect, Rect::new(900.0, 0.0, 1100.0, 100.0)); + assert!(d.node(a).is_none()); + // Art on artboard 2 moves with it. + let b = rect(&mut s, 950.0, 25.0, 10.0, 10.0); + s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards[1].rect, Rect::new(0.0, 650.0, 200.0, 750.0)); + assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0)); +} + +// ---------- Edit Colors ---------- + +#[test] +fn invert_and_convert_colors() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + fill(&mut s, a, "#ff0000"); + sel(&mut s, &[a]); + s.execute("edit.colors.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(fill_of(&s, a).to_hex(), "#00ffff"); + s.execute("edit.colors.toCMYK", &json!({})).unwrap(); + assert!(matches!(fill_of(&s, a), Color::Cmyk { .. })); + s.execute("edit.colors.toGrayscale", &json!({})).unwrap(); + assert!(matches!(fill_of(&s, a), Color::Gray { .. })); + s.execute("edit.colors.toRGB", &json!({})).unwrap(); + assert!(matches!(fill_of(&s, a), Color::Rgb { .. })); +} + +#[test] +fn saturate_and_adjust_balance() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": [0.8, 0.4, 0.4]})).unwrap(); + sel(&mut s, &[a]); + s.execute("edit.colors.saturate", &json!({"intensity": -100})).unwrap(); + let [_, sat, _] = fill_of(&s, a).to_hsb(); + assert!(sat < 1e-4); + s.execute("edit.colors.adjustBalance", &json!({"r": 10, "g": -10})).unwrap(); + let [r, g, _] = fill_of(&s, a).to_rgb(); + assert!((r - 0.9).abs() < 1e-3 && (g - 0.7).abs() < 1e-3, "{r} {g}"); + assert!(s.execute("edit.colors.adjustBalance", &json!({})).is_err()); +} + +#[test] +fn blend_colors_horizontally() { + let mut s = session(); + let ids: Vec = (0..3).map(|i| rect(&mut s, 200.0 - i as f64 * 100.0, 0.0, 10.0, 10.0)).collect(); + fill(&mut s, ids[2], "#000000"); // leftmost + fill(&mut s, ids[0], "#ffffff"); // rightmost + fill(&mut s, ids[1], "#ff0000"); + sel(&mut s, &ids); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080"); + sel(&mut s, &ids[..2]); + assert!(s.execute("edit.colors.blendVertically", &json!({})).is_err(), "needs three"); +} + +#[test] +fn blend_front_to_back_uses_stacking() { + let mut s = session(); + let ids: Vec = (0..3).map(|_| rect(&mut s, 0.0, 0.0, 10.0, 10.0)).collect(); + fill(&mut s, ids[0], "#000000"); + fill(&mut s, ids[2], "#ffffff"); + sel(&mut s, &ids); + s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap(); + assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080"); +} + +// ---------- Paste without formatting / Find ---------- + +#[test] +fn paste_without_formatting_resets_text_style() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Bold", "size": 40})).unwrap()); + sel(&mut s, &[t]); + s.execute("edit.copy", &json!({})).unwrap(); + let n = undo_len(&s); + let r = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); + let p = NodeId(r["ids"][0].as_u64().unwrap()); + let NodeKind::Text(tt) = &node(&s, p).kind else { panic!() }; + assert_eq!(tt.plain_text(), "Bold"); + assert_eq!(tt.first_style().size, 12.0); + assert_eq!(undo_len(&s), n + 1); + // Clipboard keeps its formatting. + let NodeKind::Text(ct) = &s.clipboard[0].kind else { panic!() }; + assert_eq!(ct.first_style().size, 40.0); +} + +#[test] +fn find_and_replace_options() { + let mut s = session(); + let a = text(&mut s, "Cat cat catalog"); + let b = text(&mut s, "the CAT"); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "wholeWord": true})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!(plain(&s, a), "dog dog catalog"); + assert_eq!(plain(&s, b), "the dog"); + let r = s.execute("edit.findReplace", &json!({"find": "Dog", "replace": "x", "matchCase": true})).unwrap(); + assert_eq!(r["count"], 0); + assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err()); +} + +#[test] +fn find_next_cycles_through_text_objects() { + let mut s = session(); + let a = text(&mut s, "apple"); + let _ = text(&mut s, "banana"); + let c = text(&mut s, "pineapple"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "APPLE"})).unwrap()), a); + assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), c); + assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), a); + assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null()); +} + +// ---------- Select ---------- + +#[test] +fn select_same_font_size_and_fill() { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 20, "text": "a", "size": 20})).unwrap()); + let b = id_of(&s.execute("text.create", &json!({"x": 0, "y": 60, "text": "b", "size": 20})).unwrap()); + let c = id_of(&s.execute("text.create", &json!({"x": 0, "y": 90, "text": "c", "size": 30, "font": "Inter"})).unwrap()); + sel(&mut s, &[a]); + assert_eq!(s.execute("select.same.fontSize", &json!({})).unwrap()["count"], 2); + assert_eq!(selected(&s), vec![a, b]); + sel(&mut s, &[c]); + assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 1); + sel(&mut s, &[a]); + assert_eq!(s.execute("select.same.textFillColor", &json!({})).unwrap()["count"], 3); + assert_eq!(s.execute("select.same.fontFamilyStyle", &json!({})).unwrap()["count"], 2); + let r = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + sel(&mut s, &[r]); + assert!(s.execute("select.same.fontSize", &json!({})).is_err()); +} + +#[test] +fn select_direction_handles_selects_all_anchors() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + assert_eq!(s.execute("select.object.directionHandles", &json!({})).unwrap()["anchors"], 4); + assert_eq!(s.doc().unwrap().selection.anchors.get(&a).unwrap().len(), 4); +} + +#[test] +fn select_point_and_area_text() { + let mut s = session(); + let p = text(&mut s, "point"); + let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "area", "area": {"width": 100, "height": 50}})).unwrap()); + let _ = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + assert_eq!(s.execute("select.object.pointText", &json!({})).unwrap()["count"], 1); + assert_eq!(selected(&s), vec![p]); + s.execute("select.object.areaText", &json!({})).unwrap(); + assert_eq!(selected(&s), vec![a]); + assert_eq!(s.execute("select.object.brushStrokes", &json!({})).unwrap()["count"], 0); +} + +#[test] +fn save_and_recall_selection() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("select.recall", &json!({"name": "Selection 1"})).unwrap()["count"], 2); + s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "Pair"})).unwrap(); + assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Pair"])); + s.execute("select.editSaved", &json!({"name": "Pair", "delete": true})).unwrap(); + assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err()); +} + +// ---------- Type ---------- + +#[test] +fn change_case_variants() { + let mut s = session(); + let t = text(&mut s, "hello wORLD. again here"); + sel(&mut s, &[t]); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(plain(&s, t), "HELLO WORLD. AGAIN HERE"); + s.execute("type.changeCase", &json!({"case": "lower"})).unwrap(); + assert_eq!(plain(&s, t), "hello world. again here"); + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(plain(&s, t), "Hello World. Again Here"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(plain(&s, t), "Hello world. Again here"); + assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err()); +} + +#[test] +fn smart_punctuation_quotes_dashes_ellipsis() { + let mut s = session(); + let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4..."); + sel(&mut s, &[t]); + assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1); + assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…"); +} + +#[test] +fn convert_point_area_roundtrip() { + let mut s = session(); + let t = text(&mut s, "Some words"); + sel(&mut s, &[t]); + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() }; + assert!(matches!(tt.kind, TextKind::Area { .. })); + assert_eq!(tt.plain_text(), "Some words"); + s.execute("type.convertToPointType", &json!({})).unwrap(); + let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() }; + assert!(matches!(tt.kind, TextKind::Point)); + assert_eq!(tt.plain_text(), "Some words"); +} + +#[test] +fn area_to_point_turns_wraps_into_breaks() { + let mut s = session(); + let a = id_of( + &s.execute("text.create", &json!({"x": 0, "y": 0, "text": "one two three four five six", "area": {"width": 40, "height": 200}})).unwrap(), + ); + sel(&mut s, &[a]); + s.execute("type.convertToPointType", &json!({})).unwrap(); + let txt = plain(&s, a); + assert!(txt.contains('\n'), "{txt:?}"); + assert_eq!(txt.replace('\n', " "), "one two three four five six"); +} + +#[test] +fn placeholder_and_insert_characters() { + let mut s = session(); + let t = text(&mut s, "x"); + sel(&mut s, &[t]); + s.execute("type.fillPlaceholder", &json!({})).unwrap(); + assert!(plain(&s, t).len() > 10); + s.execute("text.setText", &json!({"text": "A"})).unwrap(); + s.execute("type.insert", &json!({"char": "emDash"})).unwrap(); + s.execute("type.insert", &json!({"char": "copyright"})).unwrap(); + s.execute("type.insert", &json!({"text": "z"})).unwrap(); + assert_eq!(plain(&s, t), "A—©z"); + assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err()); +} + +#[test] +fn area_placeholder_fills_frame() { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "", "area": {"width": 300, "height": 300}})).unwrap()); + sel(&mut s, &[a]); + s.execute("type.fillPlaceholder", &json!({})).unwrap(); + assert!(plain(&s, a).len() > 400); +} + +#[test] +fn font_and_size_menu_items_use_set_style() { + let mut s = session(); + let t = text(&mut s, "x"); + sel(&mut s, &[t]); + s.execute("text.setStyle", &json!({"size": 36})).unwrap(); + s.execute("text.setStyle", &json!({"font": "Inter"})).unwrap(); + let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() }; + assert_eq!(tt.first_style().size, 36.0); + assert_eq!(tt.first_style().font_family, "Inter"); +} + +// ---------- View → Guides ---------- + +#[test] +fn make_release_clear_guides() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + assert_eq!(s.execute("view.guides.make", &json!({})).unwrap()["count"], 1); + assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: true, .. })); + assert!(selected(&s).is_empty()); + assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1); + assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: false, .. })); + sel(&mut s, &[a]); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); + let n = undo_len(&s); + assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2); + assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.node(a).is_none()); + assert_eq!(undo_len(&s), n + 1); +} + +#[test] +fn ruler_guides_and_lock() { + let mut s = session(); + assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0); + s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0); + assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true); + assert!(s.guides_locked()); + assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..)))); + s.execute("view.guides.lock", &json!({})).unwrap(); + s.execute("guide.remove", &json!({"index": 0})).unwrap(); + assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); +} + +// ---------- File ---------- + +#[test] +fn close_all_documents() { + let mut s = session(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.execute("file.closeAll", &json!({})).unwrap()["closed"], 2); + assert!(s.active().is_none()); + assert!(s.execute("file.closeAll", &json!({})).is_err()); +} + +#[test] +fn document_color_mode_converts_colours() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + fill(&mut s, a, "#ff0000"); + s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb); + assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err()); +} + +#[test] +fn file_info_sets_title() { + let mut s = session(); + let r = s.execute("file.info", &json!({"title": "Poster"})).unwrap(); + assert_eq!(r["title"], "Poster"); + assert_eq!(s.doc().unwrap().doc.title, "Poster"); + assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "rgb"); +} + +#[test] +fn every_new_command_has_params_doc_and_menu() { + for id in [ + "object.lock.above", + "object.transformEach", + "object.rasterize", + "object.blend.make", + "edit.colors.invert", + "edit.findReplace", + "select.same.fontSize", + "type.changeCase", + "view.guides.make", + "file.closeAll", + ] { + let c = find_command(id).unwrap_or_else(|| panic!("{id}")); + assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}"); + } +} diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs new file mode 100644 index 00000000..6fe2b412 --- /dev/null +++ b/crates/engine/src/tests_panelcmds.rs @@ -0,0 +1,166 @@ +//! Panel-support commands (`cmd/panelcmds.rs`). + +use drawcraft_color::{GradientKind, Paint}; +use drawcraft_doc::{AppearanceItem, NodeKind}; +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +#[test] +fn edit_gradient_converts_and_keeps_stops() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute( + "paint.editGradient", + &json!({"stroke": false, "kind": "radial", "stops": [ + {"offset": 1.0, "color": "#0000ff"}, + {"offset": 0.0, "color": "#ff0000", "opacity": 50, "midpoint": 0.3}, + {"offset": 0.5, "color": "#00ff00"}]}), + ) + .unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + assert_eq!(g.gradient.stops.len(), 3); + assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000"); + assert!((g.gradient.stops[0].opacity - 0.5).abs() < 1e-6); + assert!((g.gradient.stops[0].midpoint - 0.3).abs() < 1e-6); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.fill_paint().color().is_some()); +} + +#[test] +fn edit_gradient_angle_reverse_and_errors() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.editGradient", &json!({"stroke": false, "angle": 450})).unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; + assert!((g.angle - 90.0).abs() < 1e-9); + s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() }; + assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000"); + assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err()); + assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err()); +} + +#[test] +fn appearance_duplicate_and_move() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let n0 = node(&s, id).appearance.items.len(); + s.execute("appearance.duplicateItem", &json!({"index": 0})).unwrap(); + assert_eq!(node(&s, id).appearance.items.len(), n0 + 1); + let last = node(&s, id).appearance.items.len() - 1; + s.execute("appearance.moveItem", &json!({"from": last, "to": 0})).unwrap(); + assert!(matches!(node(&s, id).appearance.items[0], AppearanceItem::Stroke(_))); + assert!(s.execute("appearance.duplicateItem", &json!({"index": 99})).is_err()); +} + +#[test] +fn artboards_reorder_and_duplicate() { + let mut s = session(); + s.execute("artboard.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap(); + assert_eq!(r["index"], 2); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 3); + assert!(d.artboards[2].rect.x0 >= d.artboards[1].rect.x1); + s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); +} + +#[test] +fn swatch_groups_duplicate_sort() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Zed", "color": "#123456"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Alpha", "color": "#654321"})).unwrap(); + let r = s.execute("swatch.newGroup", &json!({"name": "Mine", "swatches": ["Zed"], "colors": ["#ff0000"]})).unwrap(); + assert_eq!(r["name"], "Mine"); + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Mine").unwrap(); + assert_eq!(g.swatches.len(), 2); + assert!(!d.swatches.iter().any(|x| x.name == "Zed")); + let r = s.execute("swatch.duplicate", &json!({"name": "Alpha"})).unwrap(); + assert_eq!(r["name"], "Alpha copy"); + s.execute("swatch.sortByName", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.swatches[0].name.starts_with('[')); + let names: Vec<_> = d.swatches.iter().skip(1).map(|x| x.name.to_lowercase()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); +} + +#[test] +fn graphic_style_delete_duplicate() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "S1"})).unwrap(); + assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "S1"})).unwrap()["name"], "S1 copy"); + s.execute("graphicStyle.delete", &json!({"name": "S1"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.graphic_styles.iter().all(|g| g.name != "S1")); + assert!(s.execute("graphicStyle.delete", &json!({"name": "nope"})).is_err()); +} + +#[test] +fn text_set_format() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hello"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute( + "text.setFormat", + &json!({"ids": [id.0], "kerning": 20, "baselineShift": 3, "hScale": 120, "rotation": 370, "underline": true, "spaceBefore": 6, "hyphenate": true}), + ) + .unwrap(); + let NodeKind::Text(t) = node(&s, id).kind else { panic!() }; + let st = t.first_style(); + assert_eq!(st.kerning, Some(20.0)); + assert_eq!(st.baseline_shift, 3.0); + assert_eq!(st.h_scale, 120.0); + assert!((st.rotation - 10.0).abs() < 1e-9); + assert!(st.underline); + assert_eq!(t.para.space_before, 6.0); + assert!(t.para.hyphenate); + s.execute("text.setFormat", &json!({"ids": [id.0], "kerning": "auto"})).unwrap(); + let NodeKind::Text(t) = node(&s, id).kind else { panic!() }; + assert_eq!(t.first_style().kerning, None); + assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err()); +} + +#[test] +fn stroke_advanced_scale_swap_flip() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("stroke.set", &json!({"startArrow": "Triangle", "profile": "taperEnd"})).unwrap(); + s.execute("stroke.setAdvanced", &json!({"arrowScale": [50, 200]})).unwrap(); + s.execute("stroke.setAdvanced", &json!({"swapArrows": true, "flipProfile": "along"})).unwrap(); + let n = node(&s, id); + let st = n.appearance.stroke().unwrap(); + assert_eq!(st.start_arrow, None); + assert_eq!(st.end_arrow, Some(drawcraft_doc::Arrowhead::Triangle)); + assert_eq!(st.arrow_scale, (200.0, 50.0)); + assert_eq!(st.profile.as_ref().unwrap().points, drawcraft_doc::WidthProfile::taper_start().points); + assert!(s.execute("stroke.setAdvanced", &json!({})).is_err()); + assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs new file mode 100644 index 00000000..e76e181c --- /dev/null +++ b/crates/engine/tests/command_sweep.rs @@ -0,0 +1,328 @@ +//! Every-command sweep: each registered command, in three fixture states, with `{}` and with fuzzed +//! junk params, must return Ok or Err (never panic), never leave an interaction open, and never +//! corrupt the tree. + +use drawcraft_engine::{Session, command_specs}; +use drawcraft_testkit::catch_quiet; +use drawcraft_testkit::fixtures::Fixture; +use drawcraft_testkit::invariants::{check_all, check_session, doc_json}; +use drawcraft_testkit::strategies::{junk_params, junk_values, param_keys}; +use serde_json::{Value, json}; + +/// Commands that replace/close the active document or touch the filesystem are fine to call, but a +/// few need their paths redirected into a temp dir (handled by `junk_params`). +fn safe_path() -> String { + drawcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string() +} + +/// Run one call; returns a failure description or None. +fn probe(fx: Fixture, id: &str, params: &Value) -> Option { + let mut s = fx.session(); + let r = catch_quiet(|| s.execute(id, params)); + match r { + Err(msg) => Some(format!("PANIC {id} {params} [{fx:?}]: {msg}")), + Ok(_) => { + if s.in_interaction() { + return Some(format!("{id} {params} [{fx:?}]: left an interaction open")); + } + check_session(&s).err().map(|e| format!("{id} {params} [{fx:?}]: {e}")) + } + } +} + +#[test] +fn registry_is_well_formed() { + let specs = command_specs(); + assert!(specs.len() > 150, "only {} commands", specs.len()); + let mut ids = std::collections::HashSet::new(); + for c in specs { + assert!(ids.insert(c.id), "duplicate command id {}", c.id); + assert!(!c.label.is_empty(), "{} has no label", c.id); + assert!(c.params.starts_with('{') || c.params.starts_with("same as"), "{}: params doc `{}`", c.id, c.params); + assert!(c.id.contains('.'), "{}: ids are namespaced", c.id); + } + // Every command reports enablement without a document. + let s = Session::new(); + for info in s.commands() { + assert_eq!(info.enabled, info.disabled_reason.is_none(), "{}", info.id); + } +} + +#[test] +fn every_command_with_empty_params_in_every_fixture() { + let mut failures = vec![]; + for fx in Fixture::ALL { + for c in command_specs() { + if let Some(f) = probe(fx, c.id, &json!({})) { + failures.push(f); + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + +#[test] +fn every_command_without_a_document() { + let mut failures = vec![]; + for c in command_specs() { + let mut s = Session::new(); + match catch_quiet(|| s.execute(c.id, &json!({}))) { + Err(p) => failures.push(format!("PANIC {} with no document: {p}", c.id)), + // Anything that needs a document must say so via enablement, not fail later. + Ok(Err(drawcraft_engine::EngineError::NoDocument)) => failures.push(format!("{}: enabled without a document but needs one", c.id)), + Ok(_) => {} + } + if s.in_interaction() { + failures.push(format!("{}: interaction left open", c.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the +/// junk sweep so the rest of the sweep stays meaningful. +const KNOWN_BUGS: &[(&str, &str)] = &[ + ("object.scale", "sx"), + ("object.transformEach", "scaleH"), + ("object.transformEach", "scaleV"), + ("object.distributeSpacing", "spacing"), + ("artboard.setProps", "x"), + ("artboard.setProps", "y"), + ("artboard.setProps", "width"), + ("artboard.setProps", "height"), +]; + +fn known_bug(id: &str, p: &Value) -> bool { + KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some()) +} + +/// Regression: huge column counts used to panic with `capacity overflow`. +#[test] +fn artboard_rearrange_huge_columns() { + for fx in Fixture::ALL { + for cols in [json!(u64::MAX), json!(1e308)] { + let p = json!({ "columns": cols }); + assert_eq!(probe(fx, "artboard.rearrange", &p), None); + } + } +} + +#[test] +fn bug_huge_transform_makes_document_unloadable() { + for (id, p) in [ + ("object.scale", json!({"sx": 1e308})), + ("object.transformEach", json!({"scaleH": 1e308})), + ("object.distributeSpacing", json!({"spacing": 1e308})), + ] { + let mut s = Fixture::Multi.session(); + if s.execute(id, &p).is_ok() { + drawcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + } + } +} + +#[test] +fn bug_artboard_huge_props_break_svg() { + for key in ["x", "y", "width", "height"] { + let mut s = Fixture::Single.session(); + let p = json!({"index": 0, key: 1e308}); + if s.execute("artboard.setProps", &p).is_ok() { + drawcraft_testkit::invariants::check_svg_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{p}: {}", &e[..e.len().min(200)])); + } + } +} + +/// Commands too slow to fuzz exhaustively get a reduced junk set (they still run). +fn heavy(id: &str) -> bool { + id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open" +} + +#[test] +fn every_command_with_junk_params() { + let path = safe_path(); + let mut failures = vec![]; + let mut calls = 0usize; + for fx in Fixture::ALL { + for c in command_specs() { + let mut cases = junk_params(c.params, &path); + if heavy(c.id) { + cases.truncate(12); + } + for p in &cases { + if known_bug(c.id, p) { + continue; + } + calls += 1; + if let Some(f) = probe(fx, c.id, p) { + failures.push(f); + } + } + } + } + assert!(calls > 5_000, "only {calls} calls"); + failures.sort(); + failures.dedup(); + assert!( + failures.is_empty(), + "{} failures (of {calls} calls):\n{}", + failures.len(), + failures.iter().take(60).cloned().collect::>().join("\n") + ); +} + +/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops. +#[test] +fn structured_junk() { + let cases: Vec<(&str, Value)> = vec![ + ("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})), + ("select.add", json!({"ids": [[1], {"a": 1}]})), + ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), + ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), + ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), + ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), + ("object.transform", json!({"matrix": [1, 2, 3]})), + ("object.scale", json!({"sx": 0, "sy": 0})), + ("object.scale", json!({"sx": -100, "sy": 1e308})), + ("object.rotate", json!({"angle": 1e308, "origin": [1e308, -1e308]})), + ("object.distort", json!({"corners": [[0, 0], [0, 0], [0, 0], [0, 0]]})), + ("object.distort", json!({"corners": [[0, 0]], "from": [0, 0, 0, 0]})), + ("path.create", json!({"anchors": []})), + ("path.create", json!({"anchors": [{"x": 1e308, "y": -1e308}, {"x": 0, "y": 0, "in": [1e308, 1e308]}], "closed": true})), + ("path.create", json!({"d": "M 0 0 L"})), + ("path.create", json!({"d": "M0 0 C 1e308 1e308 -1e308 -1e308 0 0 Z"})), + ("path.create", json!({"d": "Z Z Z m 1 1 z"})), + ("path.setAnchors", json!({"id": 2, "subpaths": [{"anchors": [], "closed": true}]})), + ("path.setAnchors", json!({"id": 1, "subpaths": [{"anchors": [{"x": 0, "y": 0}], "closed": true}]})), + ("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})), + ("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})), + ("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})), + ("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})), + ("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})), + ("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})), + ("path.freehand", json!({"points": [[0, 0]]})), + ("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})), + ("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})), + ("path.curvature", json!({"points": [{"x": 0, "y": 0}]})), + ("path.knife", json!({"points": [[0, 0], [1e9, 1e9]]})), + ("path.eraseRegion", json!({"points": [[100, 80]], "size": 0})), + ("path.eraseRegion", json!({"points": [[100, 80], [101, 80]], "size": 1e9})), + ("path.blob", json!({"points": [[0, 0], [0, 0]], "size": -5})), + ("path.smoothRegion", json!({"points": [[100, 80], [120, 90]], "radius": 1e308})), + ("paint.setFill", json!({"gradient": {"stops": []}})), + ("paint.setFill", json!({"gradient": {"stops": [{"offset": 0.5, "color": "#ff0000"}]}})), + ( + "paint.setFill", + json!({"gradient": {"kind": "radial", "stops": [{"offset": -5, "color": [1, 0, 0]}, {"offset": 99, "color": {"gray": 50}}], "angle": 1e308}}), + ), + ("paint.setFill", json!({"color": [1e308, -1e308, 2]})), + ("paint.setFill", json!({"color": {"c": 200, "m": -1, "y": 0, "k": 1e9}})), + ("paint.setGradientGeom", json!({"start": [0, 0], "end": [0, 0]})), + ("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})), + ("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})), + ("stroke.set", json!({"dash": [1e-12]})), + ("transparency.set", json!({"opacity": -50})), + ("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})), + ("object.setProps", json!({"opacity": -1, "blend": 5})), + ("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})), + ("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})), + ("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})), + ("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})), + ("effect.apply", json!({"effect": "no.such.effect"})), + ("effect.remove", json!({"index": 99})), + ("effect.setParams", json!({"index": 0, "params": null})), + ("object.path.offsetPath", json!({"offset": 1e308})), + ("object.path.offsetPath", json!({"offset": -1e308})), + ("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})), + ("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})), + ("object.path.splitIntoGrid", json!({"rows": 0.4})), + ("object.setBounds", json!({"width": 0, "height": 0})), + ("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})), + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})), + ("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})), + ("artboard.new", json!({"width": 0, "height": -1})), + ("artboard.delete", json!({"index": 0})), + ("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})), + ("node.move", json!({"id": 1, "parent": 2, "index": 0})), + ("node.move", json!({"id": 2, "parent": 2, "index": 0})), + ("node.move", json!({"id": 1, "parent": null, "index": 1000})), + ("layer.setProps", json!({"id": 1, "color": 1000})), + ("layer.newSublayer", json!({"parent": 2})), + ("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})), + ("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})), + ("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})), + ("document.setUnits", json!({"units": "Parsecs"})), + ("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})), + ("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})), + ("document.open", json!({"name": "x.pdf", "dataBase64": "JVBERi0xLjQK"})), + ("document.open", json!({"name": "x.drawcraft", "dataBase64": "e30="})), + ("document.activate", json!({"index": 99})), + ("file.close", json!({"index": 99})), + ("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})), + ("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})), + ("command.batch", json!({"commands": "nope"})), + ]; + let mut failures = vec![]; + for fx in Fixture::ALL { + for (id, p) in &cases { + if let Some(f) = probe(fx, id, p) { + failures.push(f); + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + +/// After a successful fuzzed call, the document still round-trips and exports. +#[test] +fn fuzzed_calls_keep_documents_serializable() { + let path = safe_path(); + let mut failures = vec![]; + for c in command_specs() { + if heavy(c.id) || c.id.starts_with("file.") { + continue; + } + for v in junk_values().iter().take(8) { + for k in param_keys(c.params) { + let k = if k == "path" { continue } else { k }; + let mut s = Fixture::Multi.session(); + let p = json!({ k.clone(): v }); + if known_bug(c.id, &p) { + continue; + } + let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); + if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { + failures.push(format!("{} {p}: {e}", c.id)); + } + } + } + } + let _ = path; + failures.sort(); + failures.dedup(); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::>().join("\n")); +} + +/// Undo after any successful command in the Multi fixture restores the exact document. +#[test] +fn every_command_undoes_exactly() { + let mut failures = vec![]; + for c in command_specs() { + if matches!(c.id, "edit.undo" | "edit.redo") || c.id.starts_with("file.") || c.id.starts_with("document.") { + continue; + } + let mut s = Fixture::Multi.session(); + let before = doc_json(&s.doc().unwrap().doc); + let depth = s.doc().unwrap().history.undo.len(); + let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue }; + if s.doc().unwrap().history.undo.len() > depth { + let _ = s.execute("edit.undo", &json!({})); + let after = doc_json(&s.doc().unwrap().doc); + if after != before { + failures.push(format!("{}: {}", c.id, drawcraft_testkit::invariants::first_diff(&before, &after, "$"))); + } + } else if doc_json(&s.doc().unwrap().doc) != before { + failures.push(format!("{}: changed the document without an undo step", c.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/engine/tests/known_bugs.rs b/crates/engine/tests/known_bugs.rs new file mode 100644 index 00000000..4d5a5bbe --- /dev/null +++ b/crates/engine/tests/known_bugs.rs @@ -0,0 +1,57 @@ +//! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a +//! `BUG:` reason until fixed; remove the attribute when the fix lands. + +use drawcraft_engine::Session; +use drawcraft_testkit::fixtures::{exec, rect, select, session}; +use drawcraft_testkit::invariants::{check_native_roundtrip_exact, check_session, doc_json, first_diff}; +use serde_json::json; + +#[test] +fn bug_group_inside_compound() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + select(&mut s, &[a, b]); + exec(&mut s, "object.compoundPath.make", json!({})); + select(&mut s, &[a]); + let _ = s.execute("object.group", &json!({})); + check_session(&s).unwrap(); +} + +#[test] +fn bug_nested_commands_double_journal() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0); + exec(&mut s, "transparency.set", json!({"opacity": 0})); + exec(&mut s, "edit.undo", json!({})); + let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.new", "shape.rectangle", "transparency.set", "edit.undo"], "journal: {ids:?}"); + let mut r = Session::new(); + for (id, p) in &s.journal.clone() { + let _ = r.execute(id, p); + } + let (want, got) = (doc_json(&s.doc().unwrap().doc), doc_json(&r.doc().unwrap().doc)); + assert_eq!(want, got, "{}", first_diff(&want, &got, "$")); +} + +#[test] +fn bug_native_roundtrip_not_bit_exact() { + let mut s = session(); + exec(&mut s, "shape.ellipse", json!({"x": 100.1, "y": 60.3, "width": 120.7, "height": 120.9})); + exec(&mut s, "object.rotate", json!({"angle": 33.3})); + check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap(); +} + +#[test] +fn bug_bring_to_front_across_layers_underflows() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + exec(&mut s, "layer.new", json!({})); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + for cmd in ["object.arrange.bringToFront", "object.arrange.bringForward"] { + select(&mut s, &[a, b]); + let r = drawcraft_testkit::catch_quiet(|| s.execute(cmd, &json!({}))); + assert!(r.is_ok(), "{cmd} panicked: {:?}", r.err()); + check_session(&s).unwrap(); + } +} diff --git a/crates/engine/tests/model_based.rs b/crates/engine/tests/model_based.rs new file mode 100644 index 00000000..00f43385 --- /dev/null +++ b/crates/engine/tests/model_based.rs @@ -0,0 +1,163 @@ +//! Model-based / stateful property tests: random command sequences drawn from the registry, with +//! structural invariants checked after every step and history/serialization invariants at the end. + +use drawcraft_testkit::fixtures::{self, exec}; +use drawcraft_testkit::invariants::{self, check_all, check_session, doc_json, first_diff, redo_all, undo_all}; +use drawcraft_testkit::strategies::{Op, arb_ops}; +use proptest::prelude::*; +use serde_json::json; + +fn run_sequence(ops: &[Op], full_checks_every: usize) -> Result<(), TestCaseError> { + let mut s = fixtures::session(); + let initial = doc_json(&s.doc().unwrap().doc); + for (i, op) in ops.iter().enumerate() { + let (id, params) = op.command(&s); + // Any result is fine; panics are not. + let _ = s.execute(&id, ¶ms); + let check = if i % full_checks_every == 0 || i + 1 == ops.len() { check_all(&mut s) } else { check_session(&s) }; + if let Err(e) = check { + return Err(TestCaseError::fail(format!("after step {i} `{id}` {params}: {e}"))); + } + } + // Reach the fully redone state first (the sequence may end with undos). + redo_all(&mut s); + let fin = doc_json(&s.doc().unwrap().doc); + let n = undo_all(&mut s); + let back = doc_json(&s.doc().unwrap().doc); + prop_assert!(back == initial, "undo all ({n} steps) != initial: {}", first_diff(&initial, &back, "$")); + check_session(&s).map_err(TestCaseError::fail)?; + let m = redo_all(&mut s); + prop_assert_eq!(n, m, "redo count differs from undo count"); + let again = doc_json(&s.doc().unwrap().doc); + prop_assert!(again == fin, "redo all != final: {}", first_diff(&fin, &again, "$")); + check_all(&mut s).map_err(TestCaseError::fail)?; + Ok(()) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 96, failure_persistence: None, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// ~60 random commands; cheap invariants every step, round trips every 10 steps. + #[test] + fn random_command_sequences(ops in arb_ops(40..80)) { + run_sequence(&ops, 10)?; + } + + /// Shorter sequences with the full check (save/load, SVG, inspect) after every step. + #[test] + fn short_sequences_full_checks(ops in arb_ops(5..20)) { + run_sequence(&ops, 1)?; + } + + /// Replaying the journal of a random session into a fresh session reproduces the document. + #[test] + fn journal_replay_reproduces_document(ops in arb_ops(10..40)) { + let mut s = fixtures::session(); + for op in &ops { + let _ = op.apply(&mut s); + } + let want = doc_json(&s.doc().unwrap().doc); + let journal = s.journal.clone(); + let mut r = drawcraft_engine::Session::new(); + for (id, p) in &journal { + let _ = r.execute(id, p); + } + // The journal includes clipboard state implicitly (copy/cut are journaled), so the replay + // must land on the same document. + let got = doc_json(&r.doc().unwrap().doc); + prop_assert!(got == want, "replay differs: {}", first_diff(&want, &got, "$")); + } +} + +#[test] +fn rich_fixture_satisfies_invariants_and_undoes_to_blank() { + let mut s = fixtures::rich_session(); + check_all(&mut s).unwrap(); + let fin = doc_json(&s.doc().unwrap().doc); + let n = undo_all(&mut s); + assert!(n >= 10, "expected a real history, got {n} steps"); + let blank = doc_json(&fixtures::session_with(500.0, 400.0).doc().unwrap().doc); + // Titles differ ("Untitled-1" in both sessions since each has its own counter). + assert_eq!(doc_json(&s.doc().unwrap().doc), blank, "{}", first_diff(&blank, &doc_json(&s.doc().unwrap().doc), "$")); + redo_all(&mut s); + assert_eq!(doc_json(&s.doc().unwrap().doc), fin); +} + +#[test] +fn batch_is_one_undo_step_and_rolls_back_on_error() { + let mut s = fixtures::session(); + let before = s.doc().unwrap().history.undo.len(); + exec( + &mut s, + "command.batch", + json!({"commands": [ + {"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}}, + {"command": "shape.ellipse", "params": {"x": 20, "y": 0, "width": 10, "height": 10}}, + {"command": "select.all", "params": {}}, + {"command": "object.group", "params": {}}, + ]}), + ); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().doc.node_count(), 4); + let snapshot = doc_json(&s.doc().unwrap().doc); + // A failing step rolls the whole batch back and leaves no interaction open. + let r = s.execute( + "command.batch", + &json!({"commands": [ + {"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}}, + {"command": "no.such.command", "params": {}}, + ]}), + ); + assert!(r.is_err()); + assert_eq!(doc_json(&s.doc().unwrap().doc), snapshot); + assert!(!s.in_interaction()); + // Nested batches are rejected, also without leaking an interaction. + let r = s.execute("command.batch", &json!({"commands": [{"command": "command.batch", "params": {"commands": []}}]})); + assert!(r.is_err()); + assert!(!s.in_interaction()); + exec(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.node_count(), 1); +} + +#[test] +fn empty_batch_is_harmless() { + let mut s = fixtures::session(); + let before = doc_json(&s.doc().unwrap().doc); + let _ = s.execute("command.batch", &json!({"commands": []})); + assert!(!s.in_interaction()); + assert_eq!(doc_json(&s.doc().unwrap().doc), before); + check_all(&mut s).unwrap(); +} + +#[test] +fn save_open_via_commands_roundtrips() { + let mut s = fixtures::rich_session(); + let dir = drawcraft_testkit::temp_dir("engine-save"); + let path = dir.join("rich.drawcraft"); + exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()})); + let want = doc_json(&s.doc().unwrap().doc); + exec(&mut s, "document.open", json!({"path": path.to_str().unwrap()})); + assert_eq!(s.documents().len(), 2); + let got = doc_json(&s.doc().unwrap().doc); + // Numbers to 1e-12 (bit-exactness is a known bug: known_bugs.rs::bug_native_roundtrip_not_bit_exact). + assert!(invariants::json_approx_eq(&got, &want, 1e-12), "{}", first_diff(&want, &got, "$")); + check_all(&mut s).unwrap(); +} + +#[test] +fn serialize_all_formats_on_rich_doc() { + let mut s = fixtures::rich_session(); + for f in ["drawcraft", "svg", "pdf", "png"] { + let v = exec(&mut s, "document.serialize", json!({"format": f})); + assert!(v.get("dataBase64").or(v.get("text")).is_some(), "{f}: {v}"); + } + // The SVG re-imports with the same number of visible art objects or more (groups may nest). + let svg = exec(&mut s, "document.serialize", json!({"format": "svg"})); + let text = svg["text"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| String::from_utf8(drawcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap()); + let back = drawcraft_svg::import(&text).unwrap(); + invariants::check_document(&back).unwrap(); + assert!(back.node_count() >= 5); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index ebe36679..75527364 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -15,4 +15,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +drawcraft-testkit = { workspace = true } +proptest = { workspace = true } drawcraft-geom = { workspace = true } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs new file mode 100644 index 00000000..7820f6c7 --- /dev/null +++ b/crates/format/tests/prop_format.rs @@ -0,0 +1,147 @@ +//! `.drawcraft` format: round trips, robustness against garbage, versioning, unknown fields. + +use std::sync::Arc; + +use drawcraft_doc::{Document, ImageBlob, NodeId}; +use drawcraft_format::{base64_decode, base64_encode, load, save, sniff}; +use drawcraft_testkit::fixtures; +use drawcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq}; +use drawcraft_testkit::strategies::arb_ops; +use proptest::prelude::*; +use serde_json::{Value, json}; + +fn rich_doc() -> Document { + (*fixtures::rich_session().doc().unwrap().doc).clone() +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 128, failure_persistence: None, ..ProptestConfig::default() })] + + #[test] + fn base64_roundtrip(data in prop::collection::vec(any::(), 0..300)) { + let s = base64_encode(&data); + prop_assert_eq!(s.len() % 4, 0); + prop_assert_eq!(base64_decode(&s), Some(data.clone())); + // Whitespace is ignored. + let spaced: String = s.chars().flat_map(|c| [c, '\n']).collect(); + prop_assert_eq!(base64_decode(&spaced), Some(data)); + } + + #[test] + fn base64_decode_never_panics(s in ".{0,64}") { + let _ = base64_decode(&s); + } + + /// Arbitrary bytes are rejected (or, if they happen to parse, yield a valid document) — never a panic. + #[test] + fn load_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..512)) { + if let Ok(d) = load(&bytes) { + check_document(&d).map_err(TestCaseError::fail)?; + } + } + + /// Mutating a valid file (byte flips / truncation) never panics the loader. + #[test] + fn load_mutated_file_never_panics(cut in 0usize..4000, flips in prop::collection::vec((0usize..4000, any::()), 0..8)) { + let mut bytes = save(&Document::new(100.0, 100.0), false); + for (i, b) in flips { + let n = bytes.len(); + bytes[i % n] = b; + } + bytes.truncate(cut.min(bytes.len())); + let _ = load(&bytes); + } + + /// Documents produced by random editing survive save → load (numbers to 1e-12). + #[test] + fn random_documents_roundtrip(ops in arb_ops(5..40)) { + let mut s = fixtures::session(); + for op in &ops { + let _ = op.apply(&mut s); + } + let d = s.doc().unwrap().doc.clone(); + check_native_roundtrip(&d).map_err(TestCaseError::fail)?; + // Pretty and compact encodings load to the same document. + let a = doc_json(&load(&save(&d, true)).unwrap()); + let b = doc_json(&load(&save(&d, false)).unwrap()); + prop_assert_eq!(a, b); + } + + /// Embedded image bytes survive the base64 round trip exactly. + #[test] + fn images_roundtrip(data in prop::collection::vec(any::(), 0..2000), key in "[a-z]{1,8}") { + let mut d = Document::new(10.0, 10.0); + d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) }); + let back = load(&save(&d, false)).unwrap(); + prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data)); + } +} + +#[test] +fn rich_document_roundtrips() { + let d = rich_doc(); + check_native_roundtrip(&d).unwrap(); + let bytes = save(&d, true); + assert!(sniff(&bytes)); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], "drawcraft"); + assert_eq!(v["version"], drawcraft_format::VERSION); +} + +#[test] +fn sniff_distinguishes_formats() { + assert!(sniff(&save(&Document::new(1.0, 1.0), false))); + assert!(!sniff(b"")); + assert!(!sniff(b"%PDF-1.7")); + assert!(!sniff(b"")); + assert!(!sniff(&[0xff, 0xfe, 0x00])); +} + +#[test] +fn rejects_newer_versions_and_other_formats() { + let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap(); + v["version"] = json!(drawcraft_format::VERSION + 1); + assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::TooNew(_)))); + v["version"] = json!(drawcraft_format::VERSION); + v["format"] = json!("other"); + assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::NotDrawcraft(_)))); + v["format"] = json!("drawcraft"); + v["images"] = json!({"x": {"mime": "image/png", "data": "not base64!"}}); + assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::BadImage(_)))); +} + +#[test] +fn unknown_fields_are_preserved() { + let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap(); + v["document"]["unknown"] = json!({"futureFeature": {"a": [1, 2, 3]}}); + let d = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let again: Value = serde_json::from_slice(&save(&d, false)).unwrap(); + assert_eq!(again["document"]["unknown"]["futureFeature"]["a"], json!([1, 2, 3])); +} + +#[test] +fn load_repairs_next_id() { + let d = rich_doc(); + let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["document"]["next_id"] = json!(1); + let mut back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + check_document(&back).unwrap(); + let fresh = back.alloc_id(); + assert!(back.node(fresh).is_none(), "allocated id {fresh} collides"); + let max = fixtures::all_ids(&back).into_iter().map(|NodeId(i)| i).max().unwrap(); + assert!(fresh.0 > max); +} + +#[test] +fn approx_equal_documents_compare_equal() { + let d = rich_doc(); + assert!(json_approx_eq(&doc_json(&d), &doc_json(&d), 0.0)); +} + +#[test] +fn bug_f64_bit_exact_roundtrip() { + check_native_roundtrip_exact(&rich_doc()).unwrap(); + let mut d = Document::new(126.86291501015239, 214.69463130731182); + d.raster_effects_ppi = 0.1 + 0.2; + check_native_roundtrip_exact(&d).unwrap(); +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml index 271a63ce..23776ecc 100644 --- a/crates/geom/Cargo.toml +++ b/crates/geom/Cargo.toml @@ -14,5 +14,6 @@ serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +drawcraft-testkit = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/geom/tests/prop_geom.rs b/crates/geom/tests/prop_geom.rs new file mode 100644 index 00000000..d771a70f --- /dev/null +++ b/crates/geom/tests/prop_geom.rs @@ -0,0 +1,275 @@ +//! Geometry property tests: PathData ↔ BezPath, bounds, hit testing, anchors and shapes. + +use drawcraft_geom::hit::{distance_to_outline, fill_contains, stroke_contains}; +use drawcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes}; +use drawcraft_testkit::geom::{grid, hausdorff, polygon_area, rect_contains, sample, sample_path}; +use drawcraft_testkit::strategies::{arb_closed_shape, arb_convex_polygon, arb_path_data, arb_point, arb_rect, arb_star_polygon, polygon_path}; +use proptest::prelude::*; + +fn els_close(a: &BezPath, b: &BezPath, eps: f64) -> bool { + let (a, b) = (a.elements(), b.elements()); + if a.len() != b.len() { + return false; + } + let pc = |p: Point, q: Point| p.distance(q) <= eps; + a.iter().zip(b).all(|(x, y)| match (x, y) { + (PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => pc(*p, *q), + (PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => pc(*p1, *q1) && pc(*p2, *q2) && pc(*p3, *q3), + (PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => pc(*p1, *q1) && pc(*p2, *q2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }) +} + +/// Winding number computed independently: ray-cast crossings of a flattened polyline. +fn crossing_winding(bp: &BezPath, p: Point) -> i32 { + let mut w = 0; + let mut pts: Vec> = vec![]; + kurbo::flatten(bp, 1e-4, |el| match el { + PathEl::MoveTo(q) => pts.push(vec![q]), + PathEl::LineTo(q) => pts.last_mut().unwrap().push(q), + PathEl::ClosePath => {} + _ => unreachable!(), + }); + for poly in pts { + for i in 0..poly.len() { + let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); + if a.y <= p.y { + if b.y > p.y && (b - a).cross(p - a) > 0.0 { + w += 1; + } + } else if b.y <= p.y && (b - a).cross(p - a) < 0.0 { + w -= 1; + } + } + } + w +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, ..ProptestConfig::default() })] + + /// PathData → BezPath → PathData → BezPath is the identity on the Bézier geometry. + #[test] + fn pathdata_bezpath_roundtrip(p in arb_path_data()) { + let bp = p.to_bezpath(); + let back = PathData::from_bezpath(&bp); + prop_assert!(els_close(&bp, &back.to_bezpath(), 1e-9), "{bp:?}\nvs\n{:?}", back.to_bezpath()); + prop_assert_eq!(back.subpaths.len(), p.subpaths.len()); + for (a, b) in p.subpaths.iter().zip(&back.subpaths) { + prop_assert_eq!(a.closed, b.closed); + prop_assert_eq!(a.anchors.len(), b.anchors.len()); + } + } + + /// kurbo shapes survive BezPath → PathData → BezPath. + #[test] + fn kurbo_shapes_roundtrip(r in arb_rect(), rad in 0.0..20.0f64) { + for bp in [r.to_path(1e-6), kurbo::RoundedRect::from_rect(r, rad).to_path(1e-6), kurbo::Ellipse::from_rect(r).to_path(1e-6)] { + let pd = PathData::from_bezpath(&bp); + prop_assert!(hausdorff(&bp, &pd.to_bezpath(), 8) < 1e-6); + prop_assert!((pd.to_bezpath().area() - bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0)); + } + } + + /// Tight bounds contain every sampled curve point and are attained (within a sampling slack). + #[test] + fn bounds_contain_samples(p in arb_path_data()) { + let b = p.bounds().unwrap(); + let pts = sample_path(&p, 64); + for q in &pts { + prop_assert!(rect_contains(b, Rect::from_points(*q, *q), 1e-9), "{q:?} outside {b:?}"); + } + let sb = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, q| r.union_pt(*q)); + prop_assert!(rect_contains(sb, b, 0.5), "bounds {b:?} not tight vs samples {sb:?}"); + // Control bounds contain the tight bounds. + prop_assert!(rect_contains(p.control_bounds().unwrap(), b, 1e-9)); + } + + /// Bounds commute with translation and uniform scale. + #[test] + fn bounds_transform_covariant(p in arb_path_data(), d in arb_point(-100.0, 100.0), s in 0.1..5.0f64) { + let a = Affine::translate(d.to_vec2()) * Affine::scale(s); + let b1 = p.transformed(a).bounds().unwrap(); + let b0 = p.bounds().unwrap(); + let want = Rect::new(b0.x0 * s + d.x, b0.y0 * s + d.y, b0.x1 * s + d.x, b0.y1 * s + d.y); + let eps = 1e-7 * (1.0 + want.width().abs() + want.height().abs() + d.x.abs() + d.y.abs()); + prop_assert!(rect_contains(b1, want, eps) && rect_contains(want, b1, eps), "{b1:?} vs {want:?}"); + } + + /// fill_contains agrees with an independent crossing-number winding on random points. + #[test] + fn fill_contains_matches_winding(p in arb_closed_shape(), q in arb_point(0.0, 220.0)) { + let bp = p.to_bezpath(); + // Skip points on (or extremely near) the outline, where flattening decides. + prop_assume!(distance_to_outline(&bp, q) > 1e-2); + let w = crossing_winding(&bp, q); + prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), w != 0); + prop_assert_eq!(fill_contains(&bp, FillRule::EvenOdd, q), w % 2 != 0); + } + + /// Even-odd ⊆ non-zero; reversing a path never changes containment. + #[test] + fn fill_rules_and_reverse(p in arb_path_data(), q in arb_point(0.0, 200.0)) { + let bp = p.to_bezpath(); + prop_assume!(distance_to_outline(&bp, q) > 1e-3); + if fill_contains(&bp, FillRule::EvenOdd, q) { + prop_assert!(fill_contains(&bp, FillRule::NonZero, q)); + } + let mut r = p.clone(); + r.reverse(); + let rb = r.to_bezpath(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + prop_assert_eq!(fill_contains(&bp, rule, q), fill_contains(&rb, rule, q)); + } + // Reversal flips signed area, and reversing twice is the identity. + prop_assert!((rb.area() + bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0)); + r.reverse(); + prop_assert_eq!(r, p); + } + + /// Convex polygon containment equals the half-plane test. + #[test] + fn convex_polygon_hit(pts in arb_convex_polygon(), q in arb_point(0.0, 200.0)) { + let bp = polygon_path(&pts).to_bezpath(); + prop_assume!(distance_to_outline(&bp, q) > 1e-6); + let n = pts.len(); + let sign = (pts[1] - pts[0]).cross(pts[2] - pts[1]).signum(); + let inside = (0..n).all(|i| (pts[(i + 1) % n] - pts[i]).cross(q - pts[i]) * sign > 0.0); + prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), inside); + } + + /// Points sampled on the outline are within any positive stroke; distance is ~0 there. + #[test] + fn stroke_contains_outline(p in arb_path_data(), w in 0.1..10.0f64) { + let bp = p.to_bezpath(); + for q in sample(&bp, 5) { + prop_assert!(distance_to_outline(&bp, q) < 1e-6); + prop_assert!(stroke_contains(&bp, w, 0.0, q)); + } + } + + /// A point offset along the normal by more than half the width is outside the stroke of an + /// isolated line. + #[test] + fn stroke_excludes_far_points(a in arb_point(0.0, 100.0), d in arb_point(10.0, 100.0), w in 0.5..10.0f64, t in 0.1..0.9f64) { + let b = a + d.to_vec2(); + let line = shapes::line(a, b).to_bezpath(); + let dir = (b - a).normalize(); + let n = Vec2::new(-dir.y, dir.x); + let m = a.lerp(b, t); + prop_assert!(stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 0.9))); + prop_assert!(!stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 1.1 + 1e-6))); + } + + /// Inserting an anchor never changes the curve, adds exactly one anchor and keeps closure. + #[test] + fn insert_anchor_preserves_shape(p in arb_path_data(), t in 0.05..0.95f64, pick in 0usize..100) { + let si = pick % p.subpaths.len(); + let seg_count = p.subpaths[si].segment_count(); + prop_assume!(seg_count > 0); + let seg = pick % seg_count; + let mut q = p.clone(); + q.subpaths[si].insert_anchor(seg, t); + prop_assert_eq!(q.anchor_count(), p.anchor_count() + 1); + prop_assert_eq!(q.subpaths[si].closed, p.subpaths[si].closed); + prop_assert!(hausdorff(&p.to_bezpath(), &q.to_bezpath(), 16) < 1e-6); + prop_assert!((q.length() - p.length()).abs() < 1e-3 * p.length().max(1.0)); + } + + /// Polygon area via the shoelace formula equals the path's signed area magnitude and + /// SubPath::area. + #[test] + fn polygon_area_agrees(pts in arb_star_polygon()) { + let p = polygon_path(&pts); + let a = polygon_area(&pts); + prop_assert!((p.to_bezpath().area().abs() - a).abs() < 1e-6 * a.max(1.0)); + prop_assert!((p.subpaths[0].area().abs() - a).abs() < 1e-6 * a.max(1.0)); + } + + /// Serde round trip of PathData is lossless in-memory (JSON Value). + #[test] + fn pathdata_serde_roundtrip(p in arb_path_data()) { + let v = serde_json::to_value(&p).unwrap(); + let back: PathData = serde_json::from_value(v).unwrap(); + prop_assert_eq!(back, p); + } + + /// Nearest point really is on the path and no sampled point is closer. + #[test] + fn nearest_is_nearest(p in arb_path_data(), q in arb_point(-20.0, 220.0)) { + let (_, _, _, pt, d) = p.nearest(q).unwrap(); + prop_assert!((pt.distance(q) - d).abs() < 1e-6); + let best_sample = sample_path(&p, 32).into_iter().map(|s| s.distance(q)).fold(f64::INFINITY, f64::min); + prop_assert!(d <= best_sample + 1e-6, "nearest {d} > sampled {best_sample}"); + } + + /// Regular polygons and stars: anchor counts, all anchors on their radius, closed. + #[test] + fn polygon_and_star_shapes(c in arb_point(0.0, 100.0), r in 1.0..100.0f64, n in 3u32..20, rot in -360.0..360.0f64, k in 0.1..0.9f64) { + let p = shapes::polygon(c, r, n, rot); + prop_assert!(p.is_closed()); + prop_assert_eq!(p.anchor_count(), n as usize); + for (_, _, a) in p.anchors() { + prop_assert!((a.p.distance(c) - r).abs() < 1e-9 * r.max(1.0)); + } + let s = shapes::star(c, r, r * k, n, rot); + prop_assert_eq!(s.anchor_count(), 2 * n as usize); + let ds: Vec = s.anchors().map(|(_, _, a)| a.p.distance(c)).collect(); + prop_assert!(ds.iter().all(|d| (d - r).abs() < 1e-9 * r || (d - r * k).abs() < 1e-9 * r)); + } + + /// Rectangles and ellipses: bounds equal the defining rect; area matches. + #[test] + fn rect_and_ellipse_shapes(r in arb_rect(), rad in 0.0..30.0f64) { + let p = shapes::rectangle(r); + prop_assert_eq!(p.bounds().unwrap(), r); + prop_assert!((p.to_bezpath().area().abs() - r.area()).abs() < 1e-9 * r.area()); + let e = shapes::ellipse(r); + let eb = e.bounds().unwrap(); + prop_assert!(rect_contains(eb, r, 1e-9) && rect_contains(r, eb, 1e-9)); + let ea = std::f64::consts::PI * r.width() * r.height() / 4.0; + prop_assert!((e.to_bezpath().area().abs() - ea).abs() < 1e-3 * ea, "{} vs {ea}", e.to_bezpath().area()); + let rr = shapes::rounded_rectangle(r, rad); + prop_assert!(rect_contains(r, rr.bounds().unwrap(), 1e-9)); + prop_assert!(rr.to_bezpath().area().abs() <= r.area() + 1e-9); + } + + /// Length of a rectangle is its perimeter; of a line, its distance. + #[test] + fn lengths(r in arb_rect(), a in arb_point(0.0, 100.0), b in arb_point(0.0, 100.0)) { + prop_assert!((shapes::rectangle(r).length() - 2.0 * (r.width() + r.height())).abs() < 1e-6); + prop_assert!((shapes::line(a, b).length() - a.distance(b)).abs() < 1e-6); + } +} + +#[test] +fn from_bezpath_merges_duplicate_closing_point() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.line_to((10.0, 10.0)); + bp.line_to((0.0, 0.0)); + bp.close_path(); + let p = PathData::from_bezpath(&bp); + assert_eq!(p.anchor_count(), 3); + assert!(p.is_closed()); +} + +#[test] +fn empty_and_degenerate_paths() { + let e = PathData::default(); + assert!(e.is_empty()); + assert!(e.bounds().is_none()); + assert_eq!(e.length(), 0.0); + assert!(e.nearest(Point::ZERO).is_none()); + let one = PathData::from_bezpath(&{ + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b + }); + assert_eq!(one.anchor_count(), 1); + assert_eq!(one.bounds(), Some(Rect::new(5.0, 5.0, 5.0, 5.0))); + assert!(!fill_contains(&one.to_bezpath(), FillRule::NonZero, Point::new(5.0, 5.0))); + let _ = grid(Rect::new(0.0, 0.0, 1.0, 1.0), 2); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 32042410..571ed6da 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -18,3 +18,7 @@ drawcraft-geom = { workspace = true } drawcraft-doc = { workspace = true } serde_json = { workspace = true } log = { workspace = true } + +[dev-dependencies] +drawcraft-testkit = { workspace = true } +proptest = { workspace = true } diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs new file mode 100644 index 00000000..612e5a37 --- /dev/null +++ b/crates/mcp/tests/protocol_props.rs @@ -0,0 +1,271 @@ +//! MCP server properties (in-process, headless backend): tool schemas are valid, every tool +//! survives junk arguments, JSON-RPC framing always answers correctly, and random editing through +//! MCP keeps the document valid. + +use drawcraft_doc::Document; +use drawcraft_mcp::{Headless, Server, tool_definitions}; +use drawcraft_testkit::catch_quiet; +use drawcraft_testkit::invariants::{check_document, doc_json}; +use drawcraft_testkit::strategies::{arb_ops, junk_values}; +use proptest::prelude::*; +use serde_json::{Value, json}; + +fn server() -> Server { + Server::new(Box::new(Headless::with_document())) +} + +fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply = s.handle_line(&line).expect("requests get a reply"); + let v: Value = serde_json::from_str(&reply).expect("reply is JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], id); + assert!(v.get("result").is_some() != v.get("error").is_some(), "exactly one of result/error: {v}"); + v +} + +fn call(s: &mut Server, name: &str, args: Value) -> Value { + rpc(s, 1, "tools/call", json!({"name": name, "arguments": args})) +} + +fn document(s: &mut Server) -> Document { + let v = rpc(s, 2, "resources/read", json!({"uri": "drawcraft://document/json"})); + let text = v["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{v}")); + serde_json::from_str(text).expect("document JSON") +} + +fn doc_uri_json(s: &mut Server) -> String { + let list = rpc(s, 3, "resources/list", json!({})); + list["result"]["resources"].as_array().unwrap().iter().find(|r| r["name"] == "document-json").unwrap()["uri"].as_str().unwrap().to_string() +} + +/// Minimal JSON-schema sanity: an object schema with typed properties and consistent `required`. +fn validate_schema(name: &str, schema: &Value) { + assert_eq!(schema["type"], "object", "{name}: inputSchema.type"); + let props = schema.get("properties").and_then(Value::as_object).unwrap_or_else(|| panic!("{name}: properties must be an object")); + for (k, p) in props { + let p = p.as_object().unwrap_or_else(|| panic!("{name}.{k}: property schema must be an object")); + assert!( + p.contains_key("type") || p.contains_key("anyOf") || p.contains_key("oneOf") || p.contains_key("enum") || p.contains_key("$ref"), + "{name}.{k}: property has no type" + ); + if let Some(t) = p.get("type") { + let ok = |t: &str| ["object", "array", "string", "number", "integer", "boolean", "null"].contains(&t); + match t { + Value::String(t) => assert!(ok(t), "{name}.{k}: bad type {t}"), + Value::Array(ts) => assert!(ts.iter().all(|t| t.as_str().is_some_and(ok)), "{name}.{k}: bad types"), + _ => panic!("{name}.{k}: type must be a string"), + } + if t == "array" { + assert!(p.get("items").is_none_or(Value::is_object), "{name}.{k}: items must be a schema"); + } + if t == "object" { + assert!(p.get("properties").is_none_or(Value::is_object), "{name}.{k}: nested properties"); + } + } + if let Some(e) = p.get("enum") { + assert!(e.as_array().is_some_and(|a| !a.is_empty()), "{name}.{k}: empty enum"); + } + } + if let Some(req) = schema.get("required") { + for r in req.as_array().unwrap_or_else(|| panic!("{name}: required must be an array")) { + let r = r.as_str().unwrap(); + assert!(props.contains_key(r), "{name}: required `{r}` is not a property"); + } + } +} + +#[test] +fn tool_definitions_are_valid() { + let tools = tool_definitions(); + assert!(tools.len() >= 15); + let mut names = std::collections::HashSet::new(); + for t in &tools { + let name = t["name"].as_str().expect("name"); + assert!(names.insert(name.to_string()), "duplicate tool {name}"); + assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "tool name {name}"); + assert!(t["description"].as_str().is_some_and(|d| d.len() >= 10), "{name}: description"); + assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{name}: readOnlyHint"); + validate_schema(name, &t["inputSchema"]); + } + // tools/list over the protocol returns the same list. + let mut s = server(); + let v = rpc(&mut s, 1, "tools/list", json!({})); + assert_eq!(v["result"]["tools"].as_array().unwrap().len(), tools.len()); +} + +#[test] +fn every_tool_survives_empty_and_junk_arguments() { + let dir = drawcraft_testkit::temp_dir("mcp-junk"); + let safe = dir.join("junk.drawcraft").to_string_lossy().to_string(); + let mut failures = vec![]; + for t in tool_definitions() { + let name = t["name"].as_str().unwrap().to_string(); + let props: Vec = t["inputSchema"]["properties"].as_object().unwrap().keys().cloned().collect(); + let mut cases = vec![json!({}), Value::Null, json!([]), json!("x"), json!(3)]; + for k in &props { + for v in junk_values() { + // Never let fuzzed strings name files; keep renders small. + let v = if k == "path" { + json!(safe) + } else if k == "scale" && v.as_f64().is_some_and(|x| x.abs() > 4.0) { + json!(4) + } else { + v + }; + cases.push(json!({ k.clone(): v })); + } + } + for args in cases { + let mut s = server(); + let r = catch_quiet(|| { + let v = call(&mut s, &name, args.clone()); + if let Some(res) = v.get("result") { + assert!(res["content"].is_array(), "{name}: result has no content array: {v}"); + } + check_document(&document(&mut s)) + }); + match r { + Err(p) => failures.push(format!("PANIC {name} {args}: {p}")), + Ok(Err(e)) => failures.push(format!("{name} {args}: {e}")), + Ok(Ok(())) => {} + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + +#[test] +fn framing_never_panics_on_malformed_messages() { + let mut s = server(); + for line in [ + "", + "null", + "[]", + "[1,2,3]", + "{}", + "{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"ping\"}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":5}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"nope\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"run_command\",\"arguments\":[]}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\",\"params\":{\"uri\":\"nope://x\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"1900-01-01\"}}", + "\u{0}\u{1}", + "{\"jsonrpc\":\"2.0\",\"id\":1e400,\"method\":\"ping\"}", + ] { + let r = catch_quiet(|| s.handle_line(line)); + assert!(r.is_ok(), "panic on {line:?}"); + if let Ok(Some(reply)) = r { + let v: Value = serde_json::from_str(&reply).unwrap_or_else(|e| panic!("{line:?} → invalid JSON reply {reply}: {e}")); + assert!(v.is_object() || v.is_array()); + } + } + // Still alive afterwards. + rpc(&mut s, 7, "ping", json!({})); +} + +#[test] +fn resources_are_json() { + let mut s = server(); + let uri = doc_uri_json(&mut s); + let v = rpc(&mut s, 1, "resources/read", json!({"uri": uri})); + let d: Document = serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); + check_document(&d).unwrap(); + let list = rpc(&mut s, 2, "resources/list", json!({})); + for r in list["result"]["resources"].as_array().unwrap() { + let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]})); + let text = v["result"]["contents"][0]["text"].as_str().unwrap(); + serde_json::from_str::(text).unwrap(); + } +} + +#[test] +fn undo_redo_tools_restore_documents() { + let mut s = server(); + let d0 = doc_json(&document(&mut s)); + let v = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#ff0000"})); + assert!(v["result"]["isError"] != json!(true), "{v}"); + call(&mut s, "draw_shape", json!({"shape": "star", "cx": 100, "cy": 100, "radius1": 40, "radius2": 20})); + let d2 = doc_json(&document(&mut s)); + // draw + paint are separate undo steps; undo until the original document is back. + let mut n = 0; + while doc_json(&document(&mut s)) != d0 { + let v = call(&mut s, "undo", json!({})); + assert!(v["result"]["isError"] != json!(true), "undo failed early: {v}"); + n += 1; + assert!(n < 20); + } + for _ in 0..n { + call(&mut s, "redo", json!({})); + } + assert_eq!(doc_json(&document(&mut s)), d2); +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 24, failure_persistence: None, ..ProptestConfig::default() })] + + /// Random engine command sequences through `run_command` keep the document valid and the + /// server responsive; the result equals running the same commands on a local session. + #[test] + fn run_command_sequences_match_local_session(ops in arb_ops(5..40)) { + let mut srv = server(); + let mut local = drawcraft_testkit::fixtures::session_with(612.0, 792.0); + // Match the headless default document. + let d_remote = doc_json(&document(&mut srv)); + let d_local = doc_json(&local.doc().unwrap().doc); + prop_assume!(d_remote == d_local); + for op in &ops { + let (id, params) = op.command(&local); + let a = local.execute(&id, ¶ms).is_ok(); + let v = call(&mut srv, "run_command", json!({"command": id, "params": params})); + let b = v["result"]["isError"] != json!(true); + prop_assert_eq!(a, b, "`{}` {}: local ok={} mcp ok={} ({})", id, params, a, b, v); + } + let got = document(&mut srv); + check_document(&got).map_err(TestCaseError::fail)?; + { let (g, l) = (doc_json(&got), doc_json(&local.doc().unwrap().doc)); // 1e-12: JSON text round trips aren't bit-exact (serde_json without float_roundtrip; see + // crates/format/tests/prop_format.rs::bug_f64_bit_exact_roundtrip). + prop_assert!(drawcraft_testkit::invariants::json_approx_eq(&g, &l, 1e-12), "MCP and local sessions diverged: {}", drawcraft_testkit::invariants::first_diff(&l, &g, "$")); } + } +} + +/// Tools driven directly against a Headless backend never leave an interaction open or corrupt +/// the session, whatever the arguments. (A gesture that stops after `drag` legitimately stays open +/// so an agent can continue it in the next call; that case is not listed.) +#[test] +fn tools_never_leave_interactions_open() { + use drawcraft_mcp::call_tool; + use drawcraft_testkit::invariants::check_session; + let cases = [ + ("pointer_gesture", json!({"tool": "rectangle", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "bogus", "x": 50, "y": 50}]})), + ("pointer_gesture", json!({"tool": "pen", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": "a", "y": 50}]})), + ( + "pointer_gesture", + json!({"tool": "selection", "events": [{"kind": "down", "x": 1e308, "y": -1e308}, {"kind": "drag", "x": 1e308, "y": 0}, {"kind": "up", "x": 0, "y": 0}]}), + ), + ("pointer_gesture", json!({"tool": "ellipse", "events": [{"kind": "up", "x": 0, "y": 0}]})), + ("press_key", json!({"key": "Escape"})), + ( + "run_command", + json!({"command": "command.batch", "params": {"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 5, "height": 5}}, {"command": "nope"}]}}), + ), + ]; + let mut failures = vec![]; + for (name, args) in cases { + let mut h = Headless::with_document(); + let r = catch_quiet(|| call_tool(&mut h, name, &args)); + if r.is_err() { + failures.push(format!("PANIC {name} {args}")); + continue; + } + // A finished gesture sequence must not leave a drag half-applied. + if h.session.in_interaction() { + failures.push(format!("{name} {args}: interaction left open")); + } + if let Err(e) = check_session(&h.session) { + failures.push(format!("{name} {args}: {e}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/pathops/Cargo.toml b/crates/pathops/Cargo.toml index 97dc8da1..3eaca6f6 100644 --- a/crates/pathops/Cargo.toml +++ b/crates/pathops/Cargo.toml @@ -15,4 +15,5 @@ thiserror = { workspace = true } linesweeper = { workspace = true } [dev-dependencies] +drawcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 067277c0..7a03cffc 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -29,6 +29,11 @@ pub fn simplify(path: &PathData, tolerance: f64) -> PathData { simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) } +/// Simplify never adds anchors: keep whichever subpath is smaller. +fn no_worse(original: &SubPath, fitted: SubPath) -> SubPath { + if fitted.anchors.len() > original.anchors.len() { original.clone() } else { fitted } +} + fn subpath_segs(sp: &SubPath) -> Vec { (0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect() } @@ -92,7 +97,7 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { } } if let Some(s) = segs_to_subpath(&result, sp.closed) { - out.push(s); + out.push(if opts.straight_lines { s } else { no_worse(sp, s) }); } } PathData::new(out) diff --git a/crates/pathops/tests/prop_pathops.rs b/crates/pathops/tests/prop_pathops.rs new file mode 100644 index 00000000..c9c3c9e0 --- /dev/null +++ b/crates/pathops/tests/prop_pathops.rs @@ -0,0 +1,254 @@ +//! Path-operation properties on concave polygons, ellipses and random smooth curves (complements +//! `props.rs`, which uses rectangles and circles). + +use drawcraft_geom::{FillRule, PathData, Point, Rect}; +use drawcraft_pathops::*; +use drawcraft_testkit::geom::{directed_hausdorff, grid, hausdorff, polygon_area, rel_close}; +use drawcraft_testkit::strategies::{arb_closed_shape, arb_convex_polygon, arb_ellipse, arb_rect, arb_smooth_curve, arb_star_polygon, polygon_path}; +use proptest::prelude::*; + +const NZ: FillRule = FillRule::NonZero; + +fn inside(p: &PathData, q: Point) -> bool { + drawcraft_geom::hit::fill_contains(&p.to_bezpath(), NZ, q) +} +fn near_edge(p: &PathData, q: Point, eps: f64) -> bool { + drawcraft_geom::hit::distance_to_outline(&p.to_bezpath(), q) < eps +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 128, failure_persistence: None, ..ProptestConfig::default() })] + + /// Area of a simple polygon equals its shoelace area; normalize keeps it. + #[test] + fn polygon_area_matches_shoelace(pts in arb_star_polygon()) { + let p = polygon_path(&pts); + let a = polygon_area(&pts); + prop_assert!(rel_close(area(&p, NZ), a, 1e-6), "{} vs {a}", area(&p, NZ)); + prop_assert!(rel_close(area(&normalize(&p, NZ), NZ), a, 1e-4)); + } + + /// A = (A − B) + (A ∩ B); union/intersection commute; results never exceed their bounds. + #[test] + fn area_identities(a in arb_closed_shape(), b in arb_closed_shape()) { + let (aa, ab) = (area(&a, NZ), area(&b, NZ)); + let d = area(&boolean(&a, NZ, &b, NZ, BoolOp::Difference), NZ); + let i = area(&boolean(&a, NZ, &b, NZ, BoolOp::Intersect), NZ); + let i2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Intersect), NZ); + let u = area(&boolean(&a, NZ, &b, NZ, BoolOp::Union), NZ); + let u2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Union), NZ); + let x = area(&boolean(&a, NZ, &b, NZ, BoolOp::Xor), NZ); + let tol = 2e-3; + prop_assert!(rel_close(d + i, aa, tol), "d {d} + i {i} != a {aa}"); + prop_assert!(rel_close(i, i2, tol) && rel_close(u, u2, tol)); + prop_assert!(rel_close(x, u - i, tol), "xor {x} != u-i {}", u - i); + prop_assert!(i <= aa.min(ab) * (1.0 + tol) + 1e-6); + prop_assert!(u + 1e-6 >= aa.max(ab) * (1.0 - tol)); + prop_assert!(u <= (aa + ab) * (1.0 + tol) + 1e-6); + } + + /// A ∪ A = A ∩ A = A, A − A = ∅. + #[test] + fn self_ops(a in arb_closed_shape()) { + let aa = area(&a, NZ); + prop_assert!(rel_close(area(&boolean(&a, NZ, &a, NZ, BoolOp::Union), NZ), aa, 1e-3)); + prop_assert!(rel_close(area(&boolean(&a, NZ, &a, NZ, BoolOp::Intersect), NZ), aa, 1e-3)); + prop_assert!(area(&boolean(&a, NZ, &a, NZ, BoolOp::Difference), NZ) < 1e-3 * aa.max(1.0)); + } + + /// Point membership of boolean results agrees with the set operation on probe points away + /// from any edge. + #[test] + fn boolean_membership(a in arb_closed_shape(), b in arb_closed_shape()) { + let ops = [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor]; + let results: Vec = ops.iter().map(|op| boolean(&a, NZ, &b, NZ, *op)).collect(); + for q in grid(Rect::new(0.0, 0.0, 230.0, 230.0), 23) { + if near_edge(&a, q, 0.5) || near_edge(&b, q, 0.5) { + continue; + } + let (ia, ib) = (inside(&a, q), inside(&b, q)); + let want = [ia || ib, ia && ib, ia && !ib, ia != ib]; + for (k, r) in results.iter().enumerate() { + prop_assert_eq!(inside(r, q), want[k], "{:?} at {:?}", ops[k], q); + } + } + } + + /// Divide faces partition the union: areas sum to the union area, and every probe point in + /// the union lies in exactly one face; each face's sources are exactly the shapes covering it. + #[test] + fn divide_partitions_union(v in prop::collection::vec(arb_closed_shape(), 2..5)) { + let shapes: Vec = v.iter().enumerate().map(|(i, p)| Shape::new(p.clone(), NZ, i as u64)).collect(); + let faces = pathfinder(PathfinderOp::Divide, &shapes); + let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum(); + let u = pathfinder(PathfinderOp::Unite, &shapes).first().map(|s| area(&s.path, NZ)).unwrap_or(0.0); + prop_assert!(rel_close(sum, u, 3e-3), "faces {sum} vs union {u}"); + let regs = regions(&shapes); + for q in grid(Rect::new(0.0, 0.0, 230.0, 230.0), 17) { + if v.iter().any(|p| near_edge(p, q, 0.5)) { + continue; + } + let covering: Vec = v.iter().enumerate().filter(|(_, p)| inside(p, q)).map(|(i, _)| i).collect(); + let hits: Vec<&Region> = regs.iter().filter(|r| r.contains(q)).collect(); + if covering.is_empty() { + prop_assert!(hits.is_empty(), "uncovered point {q:?} in a region"); + } else { + prop_assert_eq!(hits.len(), 1, "point {:?} in {} regions", q, hits.len()); + prop_assert_eq!(&hits[0].sources, &covering); + } + } + } + + /// Offset monotonicity: a larger offset contains a smaller one (area of smaller − larger ≈ 0) + /// and areas are ordered. + #[test] + fn offset_monotone(p in prop_oneof![arb_ellipse(), arb_convex_polygon().prop_map(|v| polygon_path(&v)), arb_star_polygon().prop_map(|v| polygon_path(&v))], + d1 in -3.0..8.0f64, dd in 0.5..6.0f64) { + let d2 = d1 + dd; + // Large offsets of small curved shapes are a known bug (bug_offset_large_delta_loses_area). + let b = p.bounds().unwrap(); + prop_assume!(b.width().min(b.height()) >= 2.0 * d2.abs()); + for join in [Join::Round, Join::Miter, Join::Bevel] { + let small = offset_path(&p, d1, join, 4.0); + let big = offset_path(&p, d2, join, 4.0); + let (sa, ba) = (area(&small, NZ), area(&big, NZ)); + prop_assert!(ba + 1e-6 >= sa, "{join:?}: area({d2}) {ba} < area({d1}) {sa}"); + let leak = area(&boolean(&small, NZ, &big, NZ, BoolOp::Difference), NZ); + prop_assert!(leak <= 2e-3 * sa.max(1.0), "{join:?}: offset {d1} leaks {leak} outside offset {d2}"); + } + // A positive offset contains the original. + if d2 > 0.0 { + let big = offset_path(&p, d2, Join::Round, 4.0); + let leak = area(&boolean(&p, NZ, &big, NZ, BoolOp::Difference), NZ); + prop_assert!(leak <= 2e-3 * area(&p, NZ).max(1.0)); + } + } + + /// Round-join offset of a convex polygon follows Steiner's formula A + P·d + π·d². + #[test] + fn offset_convex_steiner(pts in arb_convex_polygon(), d in 0.5..10.0f64) { + let p = polygon_path(&pts); + let perim: f64 = (0..pts.len()).map(|i| pts[i].distance(pts[(i + 1) % pts.len()])).sum(); + let want = polygon_area(&pts) + perim * d + std::f64::consts::PI * d * d; + let got = area(&offset_path(&p, d, Join::Round, 4.0), NZ); + prop_assert!(rel_close(got, want, 5e-3), "{got} vs {want}"); + } + + /// Offsetting by 0 keeps the shape. + #[test] + fn offset_zero_identity(p in arb_closed_shape()) { + let o = offset_path(&p, 0.0, Join::Miter, 4.0); + prop_assert!(rel_close(area(&o, NZ), area(&p, NZ), 2e-3)); + } + + /// Simplify stays within tolerance (plus a small fitting slack). (It should also never add + /// anchors — see `bug_simplify_adds_anchors`.) + #[test] + fn simplify_error_within_tolerance(c in arb_smooth_curve(), tol in 0.2..5.0f64) { + let s = simplify(&c, tol); + let (a, b) = (c.to_bezpath(), s.to_bezpath()); + let err = hausdorff(&a, &b, 24); + prop_assert!(err <= tol * 1.1 + 1e-6, "simplify error {err} > tol {tol}"); + // Endpoints are preserved on open paths. + let (c0, s0) = (&c.subpaths[0], &s.subpaths[0]); + prop_assert!(c0.anchors[0].p.distance(s0.anchors[0].p) < 1e-9); + prop_assert!(c0.anchors.last().unwrap().p.distance(s0.anchors.last().unwrap().p) < 1e-9); + } + + /// Straight-line simplify produces only line segments within tolerance. + #[test] + fn simplify_straight_lines(c in arb_smooth_curve(), tol in 0.5..5.0f64) { + let s = simplify_with(&c, &SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() }); + for sp in &s.subpaths { + for i in 0..sp.segment_count() { + prop_assert!(sp.segment_is_line(i)); + } + } + prop_assert!(directed_hausdorff(&s.to_bezpath(), &c.to_bezpath(), 16) <= tol * 1.1 + 1e-6); + } + + /// add_anchor_points doubles segment count without changing geometry. + #[test] + fn add_anchor_points_preserves(p in arb_closed_shape()) { + let q = add_anchor_points(&p); + let segs = |p: &PathData| p.subpaths.iter().map(|s| s.segment_count()).sum::(); + prop_assert_eq!(segs(&q), 2 * segs(&p)); + prop_assert!(hausdorff(&p.to_bezpath(), &q.to_bezpath(), 8) < 1e-6); + } + + /// Split into grid: rows×cols cells whose total area is the rect minus gutters. + #[test] + fn split_into_grid_areas(r in arb_rect(), rows in 1usize..6, cols in 1usize..6, g in 0.0..2.0f64) { + let gutter_w = g * (cols - 1) as f64; + let gutter_h = g * (rows - 1) as f64; + prop_assume!(r.width() > gutter_w + 1.0 && r.height() > gutter_h + 1.0); + let cells = split_into_grid(r, rows, cols, g); + prop_assert_eq!(cells.len(), rows * cols); + let total: f64 = cells.iter().map(|c| area(c, NZ)).sum(); + let want = (r.width() - gutter_w) * (r.height() - gutter_h); + prop_assert!(rel_close(total, want, 1e-6), "{total} vs {want}"); + } + + /// Outline stroke of a closed convex polygon (round joins) has area ≈ perimeter × width + π(w/2)²·0 + /// (ring area = offset(+w/2) − offset(−w/2)). + #[test] + fn outline_stroke_ring_area(pts in arb_convex_polygon(), w in 0.5..4.0f64) { + let p = polygon_path(&pts); + let ring = area(&outline_stroke(&p, w, Cap::Butt, Join::Round, 4.0), NZ); + let outer = area(&offset_path(&p, w / 2.0, Join::Round, 4.0), NZ); + let inner = area(&offset_path(&p, -w / 2.0, Join::Round, 4.0), NZ); + prop_assert!(rel_close(ring, outer - inner, 1e-2), "ring {ring} vs {}", outer - inner); + } +} + +#[test] +fn non_finite_input_is_an_error() { + let mut p = drawcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + p.subpaths[0].anchors[1].p.x = f64::NAN; + let q = drawcraft_geom::shapes::rectangle(Rect::new(5.0, 5.0, 15.0, 15.0)); + assert_eq!(try_boolean(&p, NZ, &q, NZ, BoolOp::Union, DEFAULT_PRECISION), Err(PathOpsError::NonFinite)); + assert!(boolean(&p, NZ, &q, NZ, BoolOp::Union).is_empty()); + assert!(offset_path(&q, f64::NAN, Join::Round, 4.0).is_empty()); + assert!(outline_stroke(&q, f64::INFINITY, Cap::Butt, Join::Round, 4.0).is_empty()); +} + +#[test] +fn empty_inputs() { + let e = PathData::default(); + let q = drawcraft_geom::shapes::rectangle(Rect::new(5.0, 5.0, 15.0, 15.0)); + assert!(rel_close(area(&boolean(&e, NZ, &q, NZ, BoolOp::Union), NZ), 100.0, 1e-9)); + assert!(boolean(&e, NZ, &q, NZ, BoolOp::Intersect).is_empty()); + assert!(pathfinder(PathfinderOp::Divide, &[]).is_empty()); + assert!(regions(&[]).is_empty()); + assert!(simplify(&e, 1.0).is_empty()); +} + +#[test] +fn bug_simplify_adds_anchors() { + use drawcraft_geom::{Anchor, SubPath}; + let a = |p: (f64, f64), i: (f64, f64), o: (f64, f64)| Anchor::with_handles(p.into(), i.into(), o.into()); + let c = PathData::single(SubPath::new( + vec![ + a((0.0, 100.0), (-4.964818950742777, 102.85147018094767), (4.964818950742777, 97.14852981905233)), + a((29.788913704456665, 82.89117891431397), (19.859275802971112, 82.89117891431397), (39.71855160594222, 82.89117891431397)), + a((59.57782740891333, 100.0), (49.64818950742777, 94.34401856927909), (69.50746531039889, 105.65598143072091)), + a((89.36674111337, 116.82706749863948), (79.43710321188445, 116.82706749863948), (99.29637901485555, 116.82706749863948)), + a((119.15565481782666, 100.0), (109.22601691634111, 102.80451124977324), (129.0852927193122, 97.19548875022676)), + a((148.94456852228333, 100.0), (143.97974957154057, 100.0), (153.9093874730261, 100.0)), + ], + false, + )); + let s = simplify(&c, 0.2); + assert!(s.anchor_count() <= c.anchor_count(), "{} anchors in, {} out", c.anchor_count(), s.anchor_count()); +} + +#[test] +#[ignore = "BUG (open): offset by more than ~the shape's diameter loses area (inner stroke loop cancels winding)"] +fn bug_offset_large_delta_loses_area() { + for (r, d) in [(1.0, 5.0), (1.0, 3.0), (10.0, 30.0)] { + let p = drawcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r)); + let got = area(&offset_path(&p, d, Join::Round, 4.0), NZ); + let want = std::f64::consts::PI * (r + d) * (r + d); + assert!(rel_close(got, want, 1e-2), "r={r} d={d}: {got} vs {want}"); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 088847ce..68671e8d 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -24,4 +24,6 @@ log = { workspace = true } vello_cpu = { workspace = true, features = ["multithreading"] } [dev-dependencies] +drawcraft-testkit = { workspace = true } +proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/examples/bench.rs b/crates/render/examples/bench.rs index d5451738..9b64c097 100644 --- a/crates/render/examples/bench.rs +++ b/crates/render/examples/bench.rs @@ -42,7 +42,11 @@ fn main() { let x = rng(&mut seed) * 1500.0; let y = rng(&mut seed) * 1150.0 + 20.0; let id = d.alloc_id(); - let t = drawcraft_doc::TextObject::point(Point::new(x, y), &format!("Label {i} — DrawCraft"), drawcraft_doc::CharStyle { size: 10.0 + (i % 5) as f64 * 4.0, ..Default::default() }); + let t = drawcraft_doc::TextObject::point( + Point::new(x, y), + &format!("Label {i} — DrawCraft"), + drawcraft_doc::CharStyle { size: 10.0 + (i % 5) as f64 * 4.0, ..Default::default() }, + ); d.insert(Some(l), usize::MAX, Node::new(id, drawcraft_doc::NodeKind::Text(Box::new(t)))).unwrap(); } let mut r = Renderer::new(); diff --git a/crates/render/tests/golden.rs b/crates/render/tests/golden.rs new file mode 100644 index 00000000..8a39d64e --- /dev/null +++ b/crates/render/tests/golden.rs @@ -0,0 +1,531 @@ +//! Pixel-sample "golden" tests on small fixtures: gradients, blend modes, clip groups, stroke +//! alignment/dashes/arrowheads, effects, text, and opacity folding vs layer compositing. + +use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use drawcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, Document, Effect, FillLayer, LineCap, Node, StrokeAlign, StrokeLayer}; +use drawcraft_geom::{Point, Rect, shapes}; +use drawcraft_testkit::fixtures::{DocBuilder, exec, session_with}; +use drawcraft_testkit::raster::{Image, assert_rgb, assert_similar, diff, render_view}; +use serde_json::json; + +const W: u32 = 100; + +fn render(d: &Document) -> Image { + render_view(d, W, W) +} + +fn stops(a: Color, b: Color) -> Vec { + vec![GradientStop { offset: 0.0, color: a, opacity: 1.0, midpoint: 0.5 }, GradientStop { offset: 1.0, color: b, opacity: 1.0, midpoint: 0.5 }] +} + +fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint { + let mut gp = GradientPaint::new(Gradient { kind, stops: stops(a, b) }); + gp.angle = angle; + Paint::Gradient(Box::new(gp)) +} + +fn fill_only(p: Paint) -> Appearance { + Appearance::basic(p, Paint::None, 0.0) +} + +fn stroke_only(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + st.cap = LineCap::Butt; + f(&mut st); + Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } +} + +// ---------------------------------------------------------------- gradients + +#[test] +fn linear_gradient_left_to_right() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path( + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + fill_only(gradient(GradientKind::Linear, Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), 0.0)), + |_| {}, + ); + let img = render(&b.build()); + assert_rgb(&img, 1, 50, [255, 0, 0], 12); + assert_rgb(&img, 98, 50, [0, 0, 255], 12); + let mid = img.over_white(50, 50); + assert!(mid[0].abs_diff(128) < 20 && mid[2].abs_diff(128) < 20 && mid[1] < 10, "mid {mid:?}"); + // Red decreases monotonically along x; rows are identical (horizontal gradient). + let reds: Vec = (0..100).step_by(5).map(|x| img.over_white(x, 50)[0]).collect(); + assert!(reds.windows(2).all(|w| w[1] <= w[0]), "{reds:?}"); + for x in [10, 40, 70] { + assert_eq!(img.over_white(x, 10), img.over_white(x, 90)); + } +} + +#[test] +fn linear_gradient_angle_90_is_vertical() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), fill_only(gradient(GradientKind::Linear, Color::WHITE, Color::BLACK, 90.0)), |_| {}); + let img = render(&b.build()); + // Constant along rows, varying along columns. + for y in [10, 50, 90] { + let a = img.over_white(10, y); + let c = img.over_white(90, y); + assert!(a[0].abs_diff(c[0]) <= 2, "row {y}: {a:?} vs {c:?}"); + } + assert!(img.over_white(50, 5)[0].abs_diff(img.over_white(50, 95)[0]) > 180); +} + +#[test] +fn radial_gradient_centre_to_edge() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path( + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + fill_only(gradient(GradientKind::Radial, Color::rgb(0.0, 1.0, 0.0), Color::BLACK, 0.0)), + |_| {}, + ); + let img = render(&b.build()); + assert_rgb(&img, 50, 50, [0, 255, 0], 15); + // Symmetric about the centre and darker towards the edge. + let g = |x, y| img.over_white(x, y)[1]; + assert!(g(50, 50) > g(70, 50) && g(70, 50) > g(95, 50)); + // Pixel (x) has its centre at x + 0.5, so x=30 mirrors x=69 about the centre 50. + assert!(g(30, 50).abs_diff(g(69, 50)) <= 2 && g(50, 30).abs_diff(g(50, 69)) <= 2, "{} {} {} {}", g(30, 50), g(69, 50), g(50, 30), g(50, 69)); + assert!(g(30, 50).abs_diff(g(50, 30)) <= 2, "not circular"); +} + +// ---------------------------------------------------------------- blend modes + +fn blend_ref(mode: BlendMode, b: f64, s: f64) -> f64 { + let hard = |b: f64, s: f64| { + if s <= 0.5 { + b * 2.0 * s + } else { + let t = 2.0 * s - 1.0; + b + t - b * t + } + }; + match mode { + BlendMode::Normal => s, + BlendMode::Multiply => b * s, + BlendMode::Screen => b + s - b * s, + BlendMode::Darken => b.min(s), + BlendMode::Lighten => b.max(s), + BlendMode::Difference => (b - s).abs(), + BlendMode::Exclusion => b + s - 2.0 * b * s, + BlendMode::HardLight => hard(b, s), + BlendMode::Overlay => hard(s, b), + BlendMode::ColorDodge => { + if b == 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + } + BlendMode::ColorBurn => { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + } + BlendMode::SoftLight => { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + } + _ => unreachable!(), + } +} + +#[test] +fn separable_blend_modes_match_formulas() { + let back = [0.8, 0.4, 0.2]; + let src = [0.3, 0.6, 0.9]; + for mode in [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Darken, + BlendMode::Lighten, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::HardLight, + BlendMode::Overlay, + BlendMode::ColorDodge, + BlendMode::ColorBurn, + BlendMode::SoftLight, + ] { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(back[0] as f32, back[1] as f32, back[2] as f32), |_| {}); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(src[0] as f32, src[1] as f32, src[2] as f32), |n| n.blend = mode); + let img = render(&b.build()); + let want: Vec = (0..3).map(|c| (blend_ref(mode, back[c], src[c]) * 255.0).round() as u8).collect(); + assert_rgb(&img, 50, 50, [want[0], want[1], want[2]], 3); + } +} + +fn lum(c: [f64; 3]) -> f64 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} +fn clip_color(c: [f64; 3]) -> [f64; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + let mut c = c; + if n < 0.0 { + c = c.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 { + c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + c +} +fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} +fn sat(c: [f64; 3]) -> f64 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} +fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { + let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); + if mx > mn { c.map(|v| (v - mn) * s / (mx - mn)) } else { [0.0; 3] } +} + +#[test] +fn non_separable_blend_modes_match_formulas() { + let b = [0.8, 0.4, 0.2]; + let s = [0.3, 0.6, 0.9]; + for (mode, want) in [ + (BlendMode::Hue, set_lum(set_sat(s, sat(b)), lum(b))), + (BlendMode::Saturation, set_lum(set_sat(b, sat(s)), lum(b))), + (BlendMode::Color, set_lum(s, lum(b))), + (BlendMode::Luminosity, set_lum(b, lum(s))), + ] { + let mut d = DocBuilder::new(100.0, 100.0); + d.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(b[0] as f32, b[1] as f32, b[2] as f32), |_| {}); + d.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(s[0] as f32, s[1] as f32, s[2] as f32), |n| n.blend = mode); + let img = render(&d.build()); + let w = want.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let got = img.over_white(50, 50); + assert!((0..3).all(|c| got[c].abs_diff(w[c]) <= 4), "{mode:?}: got {got:?}, want {w:?}"); + } +} + +// ---------------------------------------------------------------- clipping + +#[test] +fn clip_group_masks_content() { + let mut b = DocBuilder::new(100.0, 100.0); + let clip = b.path_node(shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), fill_only(Paint::None)); + let red = b.rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0)); + b.group(vec![clip, red], true); + let img = render(&b.build()); + assert_rgb(&img, 50, 50, [255, 0, 0], 2); + assert_rgb(&img, 25, 25, [255, 255, 255], 2); // inside the rect, outside the ellipse + assert_rgb(&img, 5, 50, [255, 255, 255], 2); + // Area of red ≈ circle area (π·30²). + let red_px = img.count(|p| p[0] > 200 && p[1] < 60 && p[3] > 200); + let want = std::f64::consts::PI * 900.0; + assert!((red_px as f64 - want).abs() < want * 0.05, "{red_px} vs {want}"); +} + +#[test] +fn unclipped_group_draws_everything() { + let mut b = DocBuilder::new(100.0, 100.0); + let e = b.path_node(shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), fill_only(Paint::None)); + let red = b.rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0)); + b.group(vec![e, red], false); + let img = render(&b.build()); + assert_rgb(&img, 25, 25, [255, 0, 0], 2); +} + +// ---------------------------------------------------------------- strokes + +#[test] +fn center_stroke_straddles_the_edge() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), stroke_only(10.0, |s| s.align = StrokeAlign::Center), |_| {}); + let img = render(&b.build()); + assert_rgb(&img, 17, 50, [0, 0, 0], 2); + assert_rgb(&img, 23, 50, [0, 0, 0], 2); + assert_rgb(&img, 13, 50, [255, 255, 255], 2); + assert_rgb(&img, 27, 50, [255, 255, 255], 2); +} + +#[test] +fn dashed_line_alternates() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path( + shapes::line(Point::new(10.0, 50.0), Point::new(90.0, 50.0)), + stroke_only(4.0, |s| s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false })), + |_| {}, + ); + let img = render(&b.build()); + for (x, on) in [(15, true), (25, false), (35, true), (45, false), (55, true), (65, false)] { + let px = img.over_white(x, 50); + assert_eq!(px[0] < 60, on, "x={x}: {px:?}"); + } + // Solid line for comparison: all on. + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::line(Point::new(10.0, 50.0), Point::new(90.0, 50.0)), stroke_only(4.0, |_| {}), |_| {}); + let solid = render(&b.build()); + assert!(solid.ink() > img.ink() * 3 / 2, "solid {} dashed {}", solid.ink(), img.ink()); +} + +#[test] +fn arrowheads_add_ink_at_the_ends() { + let line = || shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(line(), stroke_only(2.0, |_| {}), |_| {}); + let plain = render(&b.build()); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(Arrowhead::Triangle)), |_| {}); + let end = render(&b.build()); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(line(), stroke_only(2.0, |s| s.start_arrow = Some(Arrowhead::Circle)), |_| {}); + let start = render(&b.build()); + assert!(end.ink() > plain.ink() + 20, "end arrow adds no ink: {} vs {}", end.ink(), plain.ink()); + assert!(start.ink() > plain.ink() + 20); + // The ink is at the right end for the end arrow and at the left for the start arrow. + let col_ink = + |img: &Image, x0: u32, x1: u32| (x0..x1).flat_map(|x| (40..60).map(move |y| (x, y))).filter(|&(x, y)| img.over_white(x, y)[0] < 128).count(); + assert!(col_ink(&end, 60, 100) > col_ink(&plain, 60, 100) + 10); + assert_eq!(col_ink(&end, 0, 30), col_ink(&plain, 0, 30)); + assert!(col_ink(&start, 0, 30) > col_ink(&plain, 0, 30) + 10); + // Every arrowhead kind renders. + for a in Arrowhead::ALL { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); + } +} + +// ---------------------------------------------------------------- effects + +fn with_effect(id: &str, params: serde_json::Value) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(20.0, 20.0, 60.0, 60.0), Color::rgb(0.0, 0.0, 1.0), |n| { + n.appearance.effects.push(Effect { id: id.into(), params, visible: true }); + }); + b.build() +} + +#[test] +fn drop_shadow_darkens_offset_region() { + let plain = render(&with_effect("stylize.dropShadow", json!({})).clone()); + let mut no = with_effect("stylize.dropShadow", json!({})); + // Same document without the effect. + let l = no.layers[0].id; + let id = no.children(Some(l)).unwrap()[0].id; + no.node_mut(id).unwrap().appearance.effects.clear(); + let none = render(&no); + let _ = l; + // Shadow at +7,+7 with blur: below-right of the rect is darker than white; the rect itself is unchanged. + let p = plain.over_white(64, 64); + assert!(p[0] < 230, "no shadow at (64,64): {p:?}"); + assert_eq!(none.over_white(64, 64), [255, 255, 255]); + assert_rgb(&plain, 40, 40, [0, 0, 255], 2); + assert_rgb(&plain, 10, 10, [255, 255, 255], 2); + // A shadow with zero opacity renders like no effect. + let zero = render(&with_effect("stylize.dropShadow", json!({"opacity": 0}))); + assert_similar(&zero, &none, 2.0, 0.0); +} + +#[test] +fn roughen_is_deterministic_and_seeded() { + let p = json!({"size": 10, "detail": 20, "relative": true, "seed": 3}); + let a = render(&with_effect("distort.roughen", p.clone())); + let b = render(&with_effect("distort.roughen", p)); + assert_eq!(a, b, "roughen is not deterministic"); + let c = render(&with_effect("distort.roughen", json!({"size": 10, "detail": 20, "relative": true, "seed": 4}))); + assert!(diff(&a, &c).max > 100, "seed has no effect"); + let plain = render(&with_effect("distort.roughen", json!({"size": 0, "detail": 20}))); + assert!(diff(&a, &plain).max > 100, "roughen does nothing"); +} + +#[test] +fn geometry_effects_render_without_panicking() { + for (id, p) in [ + ("distort.zigZag", json!({"size": 5, "ridges": 4})), + ("distort.puckerBloat", json!({"amount": 50})), + ("distort.twist", json!({"angle": 45})), + ("distort.tweak", json!({"h": 10, "v": 10, "seed": 1})), + ("stylize.roundCorners", json!({"radius": 8})), + ("stylize.innerGlow", json!({})), + ("stylize.outerGlow", json!({})), + ("stylize.feather", json!({"radius": 4})), + ("blur.gaussian", json!({"radius": 3})), + ("path.offsetPath", json!({"offset": 5})), + ("warp.arc", json!({})), + ] { + let img = render(&with_effect(id, p)); + assert!(img.ink() > 500, "{id} rendered almost nothing ({} px)", img.ink()); + } +} + +#[test] +fn hidden_effect_is_ignored() { + let mut d = with_effect("distort.roughen", json!({"size": 20, "seed": 1})); + let l = d.layers[0].id; + let id = d.children(Some(l)).unwrap()[0].id; + d.node_mut(id).unwrap().appearance.effects[0].visible = false; + let hidden = render(&d); + d.node_mut(id).unwrap().appearance.effects.clear(); + assert_eq!(hidden, render(&d)); +} + +// ---------------------------------------------------------------- text + +#[test] +fn text_renders_ink_inside_its_bounds() { + let mut s = session_with(200.0, 100.0); + exec(&mut s, "text.create", json!({"x": 10, "y": 60, "text": "Hello", "size": 40})); + exec(&mut s, "paint.setFill", json!({"color": "#000000"})); + let doc = s.doc().unwrap().doc.clone(); + let img = render_view(&doc, 200, 100); + let ink = img.ink(); + assert!(ink > 300, "text rendered {ink} px"); + // Nothing far away from the text. + let far = (150..200).flat_map(|x| (0..100).map(move |y| (x, y))).filter(|&(x, y)| img.over_white(x, y) != [255, 255, 255]).count(); + assert_eq!(far, 0); + // Outlined text renders the same pixels (within anti-aliasing). + exec(&mut s, "select.all", json!({})); + exec(&mut s, "type.createOutlines", json!({})); + let outlined = render_view(&s.doc().unwrap().doc, 200, 100); + assert_similar(&img, &outlined, 24.0, 0.01); +} + +// ---------------------------------------------------------------- opacity folding + +/// Opacity on a 1-item appearance (folded into the paint alpha) must match opacity on a 2-item +/// appearance (which forces a compositing layer) when the second item doesn't change the result. +#[test] +fn opacity_folding_equals_layer_compositing() { + for (opacity, blend) in [(0.5, BlendMode::Normal), (0.25, BlendMode::Normal), (0.8, BlendMode::Multiply), (0.6, BlendMode::Screen)] { + let red = Color::rgb(0.9, 0.1, 0.2); + let one = { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.1, 0.5, 0.9), |_| {}); + b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), fill_only(Paint::solid(red)), |n| { + n.opacity = opacity; + n.blend = blend; + n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + }); + render(&b.build()) + }; + let two = { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.1, 0.5, 0.9), |_| {}); + b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), fill_only(Paint::solid(red)), |n| { + n.opacity = opacity; + n.blend = blend; + // An opaque green fill fully covered by the same red on top: the composite is red. + n.appearance.items = vec![ + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)))), + AppearanceItem::Fill(FillLayer::new(Paint::solid(red))), + ]; + }); + render(&b.build()) + }; + let st = diff(&one, &two); + // Interior pixels within 2 levels; edges get a little slack for AA of two coverage passes. + for (x, y) in [(50, 50), (30, 50), (50, 20), (70, 70)] { + let (a, b) = (one.over_white(x, y), two.over_white(x, y)); + assert!((0..3).all(|c| a[c].abs_diff(b[c]) <= 2), "opacity {opacity} {blend:?} at ({x},{y}): {a:?} vs {b:?}"); + } + assert!(st.mean < 0.5, "opacity {opacity} {blend:?}: {st:?}"); + } +} + +/// Group opacity over a single child equals the child's own opacity. +#[test] +fn group_opacity_equals_child_opacity() { + let child_op = { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::WHITE, |_| {}); + b.rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.0, 0.0, 0.0), |n| n.opacity = 0.4); + render(&b.build()) + }; + let group_op = { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::WHITE, |_| {}); + let r = b.rect_node(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.0, 0.0, 0.0)); + let g = b.group(vec![r], false); + b.doc.node_mut(g).unwrap().opacity = 0.4; + render(&b.build()) + }; + assert_similar(&child_op, &group_op, 2.0, 0.0); + assert_rgb(&group_op, 50, 50, [153, 153, 153], 3); +} + +// ---------------------------------------------------------------- misc + +#[test] +fn invisible_and_hidden_nodes_are_not_drawn() { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK, |n| n.visible = false); + assert_eq!(render(&b.build()).ink(), 0); + let mut b = DocBuilder::new(100.0, 100.0); + let id = b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK, |_| {}); + let d = b.build(); + let opts = drawcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), hidden: vec![id], ..Default::default() }; + let r = drawcraft_render::Renderer::new().render(&d, 100, 100, drawcraft_geom::Affine::IDENTITY, &opts); + assert_eq!(Image::from_rendered(&r).ink(), 0); +} + +#[test] +fn outline_mode_draws_thin_black_edges_only() { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(1.0, 0.0, 0.0), |_| {}); + let d = b.build(); + let opts = drawcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() }; + let img = Image::from_rendered(&drawcraft_render::Renderer::new().render(&d, 100, 100, drawcraft_geom::Affine::IDENTITY, &opts)); + assert_rgb(&img, 50, 50, [255, 255, 255], 2); + assert_eq!(img.count(|p| p[0] > 200 && p[1] < 50), 0, "red paint visible in outline mode"); + assert!(img.ink() > 150 && img.ink() < 600, "outline ink {}", img.ink()); +} + +#[test] +fn png_encoding_roundtrips_pixels() { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(10.0, 10.0, 60.0, 60.0), Color::rgb(0.2, 0.4, 0.6), |n| n.opacity = 0.5); + let d = b.build(); + let r = drawcraft_render::Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, false); + let decoded = Image::from_png(&r.to_png()).unwrap(); + assert_eq!(decoded, Image::from_rendered(&r)); +} + +#[test] +fn renderer_reuse_matches_fresh_renderer() { + let mut b = DocBuilder::new(100.0, 100.0); + b.rect(Rect::new(10.0, 10.0, 60.0, 60.0), Color::rgb(0.2, 0.4, 0.6), |_| {}); + let d1 = b.build(); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::ellipse(Rect::new(30.0, 30.0, 90.0, 90.0)), fill_only(Paint::solid(Color::rgb(0.9, 0.2, 0.1))), |_| {}); + let d2 = b.build(); + let opts = drawcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = drawcraft_render::Renderer::new(); + let id = drawcraft_geom::Affine::IDENTITY; + let _ = r.render(&d1, 100, 100, id, &opts); + let reused = Image::from_rendered(&r.render(&d2, 100, 100, id, &opts)); + assert_eq!(reused, render(&d2)); +} + +#[test] +fn scaled_render_matches_upscaled_geometry() { + // Rendering at 2 px/pt equals rendering geometry scaled ×2 at 1 px/pt. + let mut b = DocBuilder::new(50.0, 50.0); + b.path(shapes::ellipse(Rect::new(5.0, 5.0, 45.0, 40.0)), fill_only(Paint::solid(Color::rgb(0.1, 0.6, 0.3))), |_| {}); + let d = b.build(); + let a = Image::from_rendered(&drawcraft_render::Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 50.0, 50.0), 2.0, true)); + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 80.0)), fill_only(Paint::solid(Color::rgb(0.1, 0.6, 0.3))), |_| {}); + let c = Image::from_rendered(&drawcraft_render::Renderer::new().render_region(&b.build(), Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true)); + assert_similar(&a, &c, 2.0, 0.0); + let _: Option = None; +} diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 18c86afb..d23f50da 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -20,4 +20,6 @@ image = { workspace = true } svgtypes = "0.16" [dev-dependencies] +drawcraft-testkit = { workspace = true } +proptest = { workspace = true } image = { workspace = true } diff --git a/crates/svg/tests/prop_svg.rs b/crates/svg/tests/prop_svg.rs new file mode 100644 index 00000000..ef6f576f --- /dev/null +++ b/crates/svg/tests/prop_svg.rs @@ -0,0 +1,118 @@ +//! SVG export/import properties: never panics, always re-imports, and plain art survives the round +//! trip visually (render comparison with a perceptual tolerance). + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::Appearance; +use drawcraft_geom::{PathData, Rect, shapes}; +use drawcraft_svg::{ExportOptions, Styling, export, import}; +use drawcraft_testkit::fixtures::{self, DocBuilder, art_nodes}; +use drawcraft_testkit::invariants::check_document; +use drawcraft_testkit::raster::{assert_similar, render_artboard}; +use drawcraft_testkit::strategies::{arb_closed_shape, arb_ops, arb_path_data}; +use proptest::prelude::*; + +fn all_options() -> Vec { + let mut v = vec![]; + for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] { + for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] { + v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive }); + } + } + v.push(ExportOptions { artboard: None, ..Default::default() }); + v +} + +fn arb_color() -> impl Strategy { + (0u8..=255, 0u8..=255, 0u8..=255).prop_map(|(r, g, b)| Color::rgb8(r, g, b)) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, failure_persistence: None, ..ProptestConfig::default() })] + + /// Documents from random editing export (all option combos) and re-import to valid documents. + #[test] + fn random_documents_export_and_reimport(ops in arb_ops(5..40)) { + let mut s = fixtures::session(); + for op in &ops { + let _ = op.apply(&mut s); + } + let d = s.doc().unwrap().doc.clone(); + for o in all_options() { + let svg = export(&d, &o); + let back = import(&svg).map_err(|e| TestCaseError::fail(format!("{o:?}: {e}\n{svg}")))?; + check_document(&back).map_err(TestCaseError::fail)?; + } + } + + /// Solid-filled shapes look the same after export → import. + #[test] + fn plain_art_survives_visually(shapes_ in prop::collection::vec((arb_closed_shape(), arb_color(), 0.2f32..1.0), 1..6)) { + let mut b = DocBuilder::new(200.0, 200.0); + for (p, c, o) in &shapes_ { + b.path(p.clone(), Appearance::basic(Paint::solid(*c), Paint::None, 0.0), |n| n.opacity = *o); + } + let d = b.build(); + let back = import(&export(&d, &ExportOptions::default())).unwrap(); + assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.002); + } + + /// Path geometry survives within the export precision. + #[test] + fn path_bounds_survive(p in arb_path_data(), decimals in 2u8..6) { + let mut b = DocBuilder::new(200.0, 200.0); + b.path(p.clone(), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0), |_| {}); + let d = b.build(); + let back = import(&export(&d, &ExportOptions { decimals, ..Default::default() })).unwrap(); + let nodes = art_nodes(&back); + let paths: Vec<&PathData> = nodes.iter().filter_map(|n| n.path_data()).collect(); + prop_assert!(!paths.is_empty(), "no path back"); + // Multi-subpath paths may come back split into several objects; compare the union. + let bb = paths.iter().filter_map(|q| q.bounds()).reduce(|x, y| x.union(y)).unwrap(); + let a = p.bounds().unwrap(); + let eps = 2.0 * 10f64.powi(-(decimals as i32)); + prop_assert!((a.x0 - bb.x0).abs() <= eps && (a.y0 - bb.y0).abs() <= eps && (a.x1 - bb.x1).abs() <= eps && (a.y1 - bb.y1).abs() <= eps, "{a:?} vs {bb:?}"); + } + + /// Importing arbitrary text never panics. + #[test] + fn import_garbage_never_panics(s in ".{0,200}") { + let _ = import(&s); + let _ = import(&format!("{s}")); + } + + /// Importing random path data never panics and gives a valid document. + #[test] + fn import_random_path_data(d in "[MLCQZHVAmlcqzhva0-9 .,eE+-]{0,80}") { + let svg = format!(""); + if let Ok(doc) = import(&svg) { + check_document(&doc).map_err(TestCaseError::fail)?; + } + } +} + +#[test] +fn rich_fixture_exports_with_all_options() { + let s = fixtures::rich_session(); + let d = s.doc().unwrap().doc.clone(); + for o in all_options() { + let svg = export(&d, &o); + let back = import(&svg).unwrap_or_else(|e| panic!("{o:?}: {e}")); + check_document(&back).unwrap(); + if o.minify { + assert!(!svg.contains("\n "), "minified output is indented"); + } + } +} + +#[test] +fn rect_survives_visually_with_stroke() { + let mut b = DocBuilder::new(100.0, 100.0); + b.path( + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb8(30, 120, 200)), Paint::solid(Color::BLACK), 3.0), + |_| {}, + ); + let d = b.build(); + let back = import(&export(&d, &ExportOptions::default())).unwrap(); + assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.001); +} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index a18729db..4182eb83 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -3,11 +3,21 @@ name = "drawcraft-testkit" version.workspace = true edition.workspace = true license.workspace = true -description = "DrawCraft test helpers" +description = "DrawCraft test helpers: fixtures, generators, render comparison, geometry assertions" +publish = false [lints] workspace = true [dependencies] drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } drawcraft-doc = { workspace = true } +drawcraft-engine = { workspace = true } +drawcraft-render = { workspace = true } +drawcraft-format = { workspace = true } +drawcraft-svg = { workspace = true } +kurbo = { workspace = true } +serde_json = { workspace = true } +proptest = { workspace = true } +image = { workspace = true } diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs new file mode 100644 index 00000000..68a9a2f4 --- /dev/null +++ b/crates/testkit/src/fixtures.rs @@ -0,0 +1,211 @@ +//! Sessions and documents in known states. + +use std::sync::Arc; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use drawcraft_engine::Session; +use drawcraft_geom::{PathData, Rect, shapes}; +use serde_json::{Value, json}; + +/// Execute a command and panic with context on error. +#[track_caller] +pub fn exec(s: &mut Session, id: &str, params: Value) -> Value { + match s.execute(id, ¶ms) { + Ok(v) => v, + Err(e) => panic!("`{id}` {params} failed: {e}"), + } +} + +/// The `id` field of a creation command's result. +#[track_caller] +pub fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))) +} + +/// A session with one new `w`×`h` document. +pub fn session_with(w: f64, h: f64) -> Session { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"width": w, "height": h})); + s +} + +/// A session with one new 400×300 document. +pub fn session() -> Session { + session_with(400.0, 300.0) +} + +pub fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&exec(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))) +} +pub fn ellipse(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&exec(s, "shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h}))) +} +pub fn star(s: &mut Session, cx: f64, cy: f64, r1: f64, r2: f64) -> NodeId { + id_of(&exec(s, "shape.star", json!({"cx": cx, "cy": cy, "radius1": r1, "radius2": r2, "points": 5}))) +} +pub fn select(s: &mut Session, ids: &[NodeId]) { + exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::>()})); +} + +/// The three standard fixture states for sweeping commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fixture { + /// A new document with nothing in it. + Empty, + /// One rectangle, selected. + Single, + /// Several overlapping objects of different kinds (paths, live shapes, text, a group), three selected. + Multi, +} + +impl Fixture { + pub const ALL: [Fixture; 3] = [Fixture::Empty, Fixture::Single, Fixture::Multi]; + + pub fn session(self) -> Session { + let mut s = session(); + match self { + Fixture::Empty => {} + Fixture::Single => { + rect(&mut s, 50.0, 50.0, 100.0, 60.0); + } + Fixture::Multi => { + let a = rect(&mut s, 40.0, 40.0, 120.0, 80.0); + let b = ellipse(&mut s, 100.0, 60.0, 90.0, 90.0); + let c = star(&mut s, 200.0, 150.0, 50.0, 25.0); + let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 250, "text": "Hi", "size": 24}))); + let p = id_of(&exec( + &mut s, + "path.create", + json!({"anchors": [{"x": 250, "y": 30}, {"x": 330, "y": 60, "in": [300, 20], "out": [360, 100]}, {"x": 300, "y": 140}], "closed": false}), + )); + let g1 = rect(&mut s, 300.0, 200.0, 40.0, 40.0); + let g2 = ellipse(&mut s, 320.0, 220.0, 40.0, 40.0); + select(&mut s, &[g1, g2]); + exec(&mut s, "object.group", json!({})); + let _ = (t, p); + select(&mut s, &[a, b, c]); + } + } + s + } +} + +/// A document touching most node kinds and appearance features, built through commands (so its +/// history is non-trivial). Selection is left empty. +pub fn rich_session() -> Session { + let mut s = session_with(500.0, 400.0); + let a = rect(&mut s, 20.0, 20.0, 150.0, 100.0); + exec( + &mut s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}), + ); + let b = ellipse(&mut s, 100.0, 60.0, 120.0, 120.0); + exec(&mut s, "transparency.set", json!({"opacity": 60, "blend": "Multiply"})); + exec(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 3], "cap": "round"})); + let c = star(&mut s, 300.0, 100.0, 60.0, 30.0); + exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}})); + let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350}))); + exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"})); + let _ = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "DrawCraft", "size": 30}))); + exec(&mut s, "layer.new", json!({"name": "Top"})); + let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0); + let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0); + select(&mut s, &[d, e]); + exec(&mut s, "object.compoundPath.make", json!({})); + let clip = ellipse(&mut s, 30.0, 150.0, 80.0, 80.0); + let inner = rect(&mut s, 10.0, 170.0, 140.0, 30.0); + exec(&mut s, "paint.setFill", json!({"color": "#22aa44"})); + select(&mut s, &[inner, clip]); + exec(&mut s, "object.clippingMask.make", json!({})); + let _ = (a, b, c, l); + exec(&mut s, "select.none", json!({})); + s +} + +/// Build documents directly (without the engine), e.g. for render tests. +pub struct DocBuilder { + pub doc: Document, + pub layer: NodeId, +} + +impl DocBuilder { + pub fn new(w: f64, h: f64) -> Self { + let doc = Document::new(w, h); + let layer = doc.layers[0].id; + Self { doc, layer } + } + pub fn alloc(&mut self) -> NodeId { + self.doc.alloc_id() + } + /// A path node (not inserted). + pub fn path_node(&mut self, path: PathData, appearance: Appearance) -> Node { + let id = self.alloc(); + Node::path(id, path, appearance) + } + /// A filled, unstroked rectangle node (not inserted). + pub fn rect_node(&mut self, r: Rect, fill: Color) -> Node { + self.path_node(shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)) + } + /// Append `node` at the top of the current layer. + pub fn add(&mut self, node: Node) -> NodeId { + let l = self.layer; + self.doc.insert(Some(l), usize::MAX, node).expect("insert") + } + /// Add a filled rectangle; `f` may tweak the node first. + pub fn rect(&mut self, r: Rect, fill: Color, f: impl FnOnce(&mut Node)) -> NodeId { + let mut n = self.rect_node(r, fill); + f(&mut n); + self.add(n) + } + /// Add any path with an appearance; `f` may tweak the node first. + pub fn path(&mut self, path: PathData, appearance: Appearance, f: impl FnOnce(&mut Node)) -> NodeId { + let mut n = self.path_node(path, appearance); + f(&mut n); + self.add(n) + } + /// Add a group of `children` (bottom first). With `clip`, the first child is the clipping path. + pub fn group(&mut self, mut children: Vec, clip: bool) -> NodeId { + if clip && let Some(NodeKind::Path { clipping, .. }) = children.first_mut().map(|n| &mut n.kind) { + *clipping = true; + } + let id = self.alloc(); + let mut g = Node::group(id, children.into_iter().map(Arc::new).collect()); + if let NodeKind::Group { clip: c, .. } = &mut g.kind { + *c = clip; + } + self.add(g) + } + /// Add a new layer and make it current. + pub fn layer(&mut self, name: &str) -> NodeId { + self.layer = self.doc.add_layer(Some(name)); + self.layer + } + pub fn build(self) -> Document { + self.doc + } +} + +/// All non-layer nodes of a document in paint order. +pub fn art_nodes(doc: &Document) -> Vec<&Node> { + let mut v = Vec::new(); + doc.walk(|n| { + if !n.is_layer() { + v.push(n) + } + }); + v +} + +/// Ids of the top-level art (direct children of layers), bottom first. +pub fn top_level_ids(doc: &Document) -> Vec { + doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect() +} + +/// Every node id (layers included), paint order. +pub fn all_ids(doc: &Document) -> Vec { + let mut v = Vec::new(); + doc.walk(|n| v.push(n.id)); + v +} diff --git a/crates/testkit/src/geom.rs b/crates/testkit/src/geom.rs new file mode 100644 index 00000000..a25bb8cc --- /dev/null +++ b/crates/testkit/src/geom.rs @@ -0,0 +1,93 @@ +//! Geometry assertions. + +use drawcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect}; +use kurbo::{ParamCurveNearest, Shape}; + +pub fn approx(a: f64, b: f64, eps: f64) -> bool { + (a - b).abs() <= eps +} + +/// Relative closeness with an absolute floor of 1. +pub fn rel_close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0) +} + +#[track_caller] +pub fn assert_approx(a: f64, b: f64, eps: f64) { + assert!(approx(a, b, eps), "{a} != {b} (±{eps})"); +} + +#[track_caller] +pub fn assert_rect_approx(a: Rect, b: Rect, eps: f64) { + assert!(approx(a.x0, b.x0, eps) && approx(a.y0, b.y0, eps) && approx(a.x1, b.x1, eps) && approx(a.y1, b.y1, eps), "{a:?} != {b:?} (±{eps})"); +} + +/// Does `outer` contain `inner` (with slack `eps`)? +pub fn rect_contains(outer: Rect, inner: Rect, eps: f64) -> bool { + inner.x0 >= outer.x0 - eps && inner.y0 >= outer.y0 - eps && inner.x1 <= outer.x1 + eps && inner.y1 <= outer.y1 + eps +} + +/// `n` evenly spaced parameter samples on every segment (endpoints included). +pub fn sample(bp: &BezPath, n: usize) -> Vec { + let mut v = vec![]; + for seg in bp.segments() { + for i in 0..=n { + v.push(seg.eval(i as f64 / n as f64)); + } + } + v +} + +/// Sample a `PathData`. +pub fn sample_path(p: &PathData, n: usize) -> Vec { + sample(&p.to_bezpath(), n) +} + +/// Distance from `p` to the nearest point of `bp`'s outline. +pub fn distance_to(bp: &BezPath, p: Point) -> f64 { + bp.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt() +} + +/// One-sided Hausdorff distance: the largest distance from a sample of `a` to the outline of `b`. +pub fn directed_hausdorff(a: &BezPath, b: &BezPath, n: usize) -> f64 { + sample(a, n).into_iter().map(|p| distance_to(b, p)).fold(0.0, f64::max) +} + +/// Symmetric Hausdorff distance (sampled). +pub fn hausdorff(a: &BezPath, b: &BezPath, n: usize) -> f64 { + directed_hausdorff(a, b, n).max(directed_hausdorff(b, a, n)) +} + +/// Assert that two paths trace the same outline within `tol`. +#[track_caller] +pub fn assert_paths_close(a: &PathData, b: &PathData, tol: f64) { + let d = hausdorff(&a.to_bezpath(), &b.to_bezpath(), 16); + assert!(d <= tol, "paths differ by {d} > {tol}"); +} + +/// Shoelace area of a polygon (absolute). +pub fn polygon_area(pts: &[Point]) -> f64 { + let n = pts.len(); + (0..n).map(|i| pts[i].to_vec2().cross(pts[(i + 1) % n].to_vec2())).sum::().abs() / 2.0 +} + +/// Signed area enclosed by a path (kurbo's Green's theorem area, all subpaths). +pub fn signed_area(p: &PathData) -> f64 { + p.to_bezpath().area() +} + +/// Is every point finite? +pub fn all_finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|s| &s.anchors).all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite())) +} + +/// A grid of probe points over `r` (`n`×`n`, cell centres). +pub fn grid(r: Rect, n: usize) -> Vec { + let mut v = vec![]; + for i in 0..n { + for j in 0..n { + v.push(Point::new(r.x0 + (i as f64 + 0.5) / n as f64 * r.width(), r.y0 + (j as f64 + 0.5) / n as f64 * r.height())); + } + } + v +} diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs new file mode 100644 index 00000000..add9efc5 --- /dev/null +++ b/crates/testkit/src/invariants.rs @@ -0,0 +1,204 @@ +//! Structural invariants for documents and sessions, and round-trip checks. + +use std::collections::HashSet; + +use drawcraft_doc::{Document, NodeKind}; +use drawcraft_engine::Session; +use serde_json::{Value, json}; + +/// Serialize a document to JSON (the canonical form for equality checks). +pub fn doc_json(doc: &Document) -> Value { + serde_json::to_value(doc).expect("document serializes") +} + +/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above +/// every id, top level made of layers, layers only at the top or inside layers, clip groups have +/// a first child, compound children are paths. +pub fn check_document(doc: &Document) -> Result<(), String> { + let mut seen = HashSet::new(); + let mut walked = 0usize; + let mut max = 0u64; + let mut err = None; + doc.walk(|n| { + walked += 1; + max = max.max(n.id.0); + if !seen.insert(n.id) && err.is_none() { + err = Some(format!("duplicate id {}", n.id)); + } + match &n.kind { + NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => { + err = Some(format!("group {} contains a layer", n.id)); + } + NodeKind::Compound { children, .. } if children.iter().any(|c| !matches!(c.kind, NodeKind::Path { .. })) && err.is_none() => { + err = Some(format!("compound {} has a non-path child", n.id)); + } + _ => {} + } + if !(n.opacity.is_finite() && (0.0..=1.0).contains(&n.opacity)) && err.is_none() { + err = Some(format!("node {} opacity {}", n.id, n.opacity)); + } + }); + if let Some(e) = err { + return Err(e); + } + if walked != doc.node_count() { + return Err(format!("node_count {} != walked {walked}", doc.node_count())); + } + if doc.peek_next_id() <= max { + return Err(format!("next_id {} <= max id {max}", doc.peek_next_id())); + } + if let Some(l) = doc.layers.iter().find(|l| !l.is_layer()) { + return Err(format!("top-level node {} is not a layer", l.id)); + } + for id in seen { + if doc.node(id).is_none() { + return Err(format!("walked id {id} not found by lookup")); + } + } + Ok(()) +} + +/// Session invariants for the active document: document invariants, every selected id exists, +/// the key object (if any) exists, no interaction left open, history snapshots valid. +pub fn check_session(s: &Session) -> Result<(), String> { + let Some(st) = s.active() else { return Ok(()) }; + check_document(&st.doc)?; + for id in &st.selection.objects { + if st.doc.node(*id).is_none() { + return Err(format!("selected id {id} does not exist")); + } + } + let mut uniq = HashSet::new(); + if let Some(d) = st.selection.objects.iter().find(|i| !uniq.insert(**i)) { + return Err(format!("selection lists {d} twice")); + } + for id in st.selection.anchors.keys() { + if st.doc.node(*id).is_none() { + return Err(format!("anchor selection on missing id {id}")); + } + } + if let Some(k) = st.selection.key + && st.doc.node(k).is_none() + { + return Err(format!("key object {k} does not exist")); + } + if st.interaction.is_some() { + return Err("interaction left open".into()); + } + Ok(()) +} + +/// `.drawcraft` save → load must reproduce the document (numbers compared to 1e-12 relative — +/// see [`check_native_roundtrip_exact`] for the bit-exact version). +pub fn check_native_roundtrip(doc: &Document) -> Result<(), String> { + let bytes = drawcraft_format::save(doc, false); + let back = drawcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?; + let (a, b) = (doc_json(doc), doc_json(&back)); + if !json_approx_eq(&a, &b, 1e-12) { + return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$"))); + } + Ok(()) +} + +/// Bit-exact `.drawcraft` round trip (every f64 must survive). +pub fn check_native_roundtrip_exact(doc: &Document) -> Result<(), String> { + let bytes = drawcraft_format::save(doc, false); + let back = drawcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?; + let (a, b) = (doc_json(doc), doc_json(&back)); + if a != b { + return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$"))); + } + Ok(()) +} + +/// Structural JSON equality with numbers compared to `rel` relative tolerance. +pub fn json_approx_eq(a: &Value, b: &Value, rel: f64) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => x == y || (x - y).abs() <= rel * x.abs().max(y.abs()), + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| json_approx_eq(p, q, rel)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| json_approx_eq(v, w, rel))), + _ => a == b, + } +} + +/// SVG export must not panic and must re-import. +pub fn check_svg_roundtrip(doc: &Document) -> Result { + let svg = drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions::default()); + drawcraft_svg::import(&svg).map_err(|e| format!("svg re-import: {e}\n{svg}")) +} + +/// `document.inspect` must succeed and serialize. +pub fn check_inspect(s: &mut Session) -> Result<(), String> { + if s.active().is_none() { + return Ok(()); + } + let v = s.execute("document.inspect", &json!({})).map_err(|e| format!("inspect: {e}"))?; + serde_json::to_string(&v).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Everything above. +pub fn check_all(s: &mut Session) -> Result<(), String> { + check_session(s)?; + check_inspect(s)?; + if let Some(st) = s.active() { + let doc = st.doc.clone(); + check_native_roundtrip(&doc)?; + check_svg_roundtrip(&doc)?; + } + Ok(()) +} + +/// A short description of the first difference between two JSON values. +pub fn first_diff(a: &Value, b: &Value, path: &str) -> String { + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + for (k, v) in x { + match y.get(k) { + None => return format!("{path}.{k}: missing on right"), + Some(w) if w != v => return first_diff(v, w, &format!("{path}.{k}")), + _ => {} + } + } + for k in y.keys() { + if !x.contains_key(k) { + return format!("{path}.{k}: missing on left"); + } + } + format!("{path}: equal") + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + return format!("{path}: array length {} vs {}", x.len(), y.len()); + } + for (i, (v, w)) in x.iter().zip(y).enumerate() { + if v != w { + return first_diff(v, w, &format!("{path}[{i}]")); + } + } + format!("{path}: equal") + } + _ => format!("{path}: {a} vs {b}"), + } +} + +/// Undo everything, then redo everything, returning (initial, final) JSON snapshots. +pub fn undo_all(s: &mut Session) -> usize { + let mut n = 0; + while s.execute("edit.undo", &json!({})).is_ok() { + n += 1; + assert!(n < 10_000, "undo does not terminate"); + } + n +} +pub fn redo_all(s: &mut Session) -> usize { + let mut n = 0; + while s.execute("edit.redo", &json!({})).is_ok() { + n += 1; + assert!(n < 10_000, "redo does not terminate"); + } + n +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index ef6f1b1c..9e0dadf5 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -1,2 +1,54 @@ -//! DrawCraft test helpers +//! DrawCraft test helpers. +//! +//! - [`fixtures`]: sessions and documents in known states (empty, single/multi selection, a "rich" +//! document touching most node kinds), plus a [`fixtures::DocBuilder`] for building documents +//! directly (render tests). +//! - [`strategies`]: proptest strategies for geometry and for engine command sequences ([`strategies::Op`]), and +//! junk-parameter generators for fuzzing the command registry. +//! - [`invariants`]: structural checks for documents and sessions, and round-trip checks +//! (`.drawcraft`, SVG, `document.inspect`). +//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance. +//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance). +//! +//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`). #![forbid(unsafe_code)] + +pub mod fixtures; +pub mod geom; +pub mod invariants; +pub mod raster; +pub mod strategies; + +pub use drawcraft_engine::{Session, doc::NodeId}; +pub use serde_json::{Value, json}; +/// Re-exports so dependents without direct dependencies (e.g. app test crates) can use them. +pub use {drawcraft_doc as doc, drawcraft_format as format, drawcraft_render as render, drawcraft_svg as svg}; + +/// A per-process temporary directory for test output (created on first use). +pub fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("drawcraft-testkit-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +thread_local! { + static QUIET: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `f`, catching a panic without printing it (other threads' panics still print). Returns the +/// panic message on panic. +pub fn catch_quiet(f: impl FnOnce() -> T) -> Result { + static HOOK: std::sync::Once = std::sync::Once::new(); + HOOK.call_once(|| { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if !QUIET.with(|q| q.get()) { + prev(info); + } + })); + }); + QUIET.with(|q| q.set(true)); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + QUIET.with(|q| q.set(false)); + r.map_err(|p| p.downcast_ref::().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "panic".into())) +} diff --git a/crates/testkit/src/raster.rs b/crates/testkit/src/raster.rs new file mode 100644 index 00000000..57751587 --- /dev/null +++ b/crates/testkit/src/raster.rs @@ -0,0 +1,174 @@ +//! Rendering helpers and image comparison with a perceptual tolerance. + +use drawcraft_doc::Document; +use drawcraft_geom::{Affine, Rect}; +use drawcraft_render::{RenderOptions, Rendered, Renderer}; + +/// A straight-alpha RGBA8 image. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Image { + pub fn from_rendered(r: &Rendered) -> Self { + Self { width: r.width, height: r.height, rgba: r.to_straight() } + } + /// Decode a PNG. + pub fn from_png(bytes: &[u8]) -> Result { + let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8(); + Ok(Self { width: img.width(), height: img.height(), rgba: img.into_raw() }) + } + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + [self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]] + } + /// The pixel composited over opaque white (what a viewer sees). + pub fn over_white(&self, x: u32, y: u32) -> [u8; 3] { + let [r, g, b, a] = self.pixel(x, y); + let f = |c: u8| ((c as u32 * a as u32 + 255 * (255 - a as u32) + 127) / 255) as u8; + [f(r), f(g), f(b)] + } + /// Number of pixels for which `pred(rgba)` holds. + pub fn count(&self, pred: impl Fn([u8; 4]) -> bool) -> usize { + self.rgba.chunks_exact(4).filter(|p| pred([p[0], p[1], p[2], p[3]])).count() + } + /// Pixels that differ from opaque white and are not fully transparent. + pub fn ink(&self) -> usize { + self.count(|p| p[3] > 0 && !(p[0] > 250 && p[1] > 250 && p[2] > 250)) + } + /// Save as PNG (for debugging failed comparisons). + pub fn save_png(&self, path: &std::path::Path) { + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.save(path); + } + } + /// Luminance over white, 0..255 as f64, per pixel. + fn luma_over_white(&self) -> Vec<[f64; 3]> { + (0..self.height).flat_map(|y| (0..self.width).map(move |x| (x, y))).map(|(x, y)| self.over_white(x, y).map(|c| c as f64)).collect() + } +} + +/// Render `doc` at `scale` px/pt over the region `r` on a white background. +pub fn render_region(doc: &Document, r: Rect, scale: f64) -> Image { + Image::from_rendered(&Renderer::new().render_region(doc, r, scale, true)) +} + +/// Render the first artboard at 1 px/pt on white. +pub fn render_artboard(doc: &Document) -> Image { + render_region(doc, doc.artboards[0].rect, 1.0) +} + +/// Render `w`×`h` pixels with an identity view (doc points = pixels) on white. +pub fn render_view(doc: &Document, w: u32, h: u32) -> Image { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + Image::from_rendered(&Renderer::new().render(doc, w, h, Affine::IDENTITY, &opts)) +} + +/// Max per-channel difference between two pixels. +pub fn channel_diff(a: [u8; 4], b: [u8; 4]) -> u8 { + (0..4).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0) +} + +/// Assert a pixel is within `tol` (per channel) of `want`. +#[track_caller] +pub fn assert_pixel(img: &Image, x: u32, y: u32, want: [u8; 4], tol: u8) { + let got = img.pixel(x, y); + assert!(channel_diff(got, want) <= tol, "pixel ({x},{y}) = {got:?}, want {want:?} ±{tol}"); +} + +/// Assert a pixel, composited over white, is within `tol` of `want` RGB. +#[track_caller] +pub fn assert_rgb(img: &Image, x: u32, y: u32, want: [u8; 3], tol: u8) { + let got = img.over_white(x, y); + let d = (0..3).map(|i| got[i].abs_diff(want[i])).max().unwrap(); + assert!(d <= tol, "pixel ({x},{y}) = {got:?}, want {want:?} ±{tol}"); +} + +/// Statistics from comparing two same-sized images. +#[derive(Clone, Copy, Debug, Default)] +pub struct DiffStats { + /// Largest per-channel difference over white. + pub max: u8, + /// Mean absolute difference over all channels (0..255). + pub mean: f64, + /// Pixels whose (blurred, perceptual) difference exceeds the tolerance. + pub bad_pixels: usize, + pub pixels: usize, +} + +/// Exact per-pixel comparison (composited over white). +pub fn diff(a: &Image, b: &Image) -> DiffStats { + assert_eq!((a.width, a.height), (b.width, b.height), "image sizes differ"); + let (la, lb) = (a.luma_over_white(), b.luma_over_white()); + let mut st = DiffStats { pixels: la.len(), ..Default::default() }; + let mut sum = 0.0; + for (p, q) in la.iter().zip(&lb) { + for c in 0..3 { + let d = (p[c] - q[c]).abs(); + sum += d; + st.max = st.max.max(d as u8); + } + } + st.mean = sum / (la.len() * 3).max(1) as f64; + st +} + +/// Perceptual comparison: both images are box-blurred (radius 1) to forgive sub-pixel +/// anti-aliasing shifts, then compared with a luminance-weighted colour distance. A pixel is "bad" +/// when that distance exceeds `tol` (0..255 scale). +pub fn perceptual_diff(a: &Image, b: &Image, tol: f64) -> DiffStats { + assert_eq!((a.width, a.height), (b.width, b.height), "image sizes differ"); + let (w, h) = (a.width as i64, a.height as i64); + let blur = |img: &Image| { + let l = img.luma_over_white(); + let mut out = vec![[0.0; 3]; l.len()]; + for y in 0..h { + for x in 0..w { + let mut acc = [0.0; 3]; + let mut n = 0.0; + for dy in -1..=1 { + for dx in -1..=1 { + let (xx, yy) = (x + dx, y + dy); + if xx >= 0 && yy >= 0 && xx < w && yy < h { + let p = l[(yy * w + xx) as usize]; + for c in 0..3 { + acc[c] += p[c]; + } + n += 1.0; + } + } + } + out[(y * w + x) as usize] = acc.map(|v| v / n); + } + } + out + }; + let (ba, bb) = (blur(a), blur(b)); + let mut st = diff(a, b); + st.bad_pixels = 0; + for (p, q) in ba.iter().zip(&bb) { + // Rec. 601 luma weights for the perceptual distance; chroma counts at half weight. + let dy = 0.299 * (p[0] - q[0]) + 0.587 * (p[1] - q[1]) + 0.114 * (p[2] - q[2]); + let dc = ((p[0] - q[0]).abs() + (p[1] - q[1]).abs() + (p[2] - q[2]).abs()) / 3.0; + if dy.abs().max(dc * 0.5) > tol { + st.bad_pixels += 1; + } + } + st +} + +/// Assert two images are perceptually equal: at most `max_bad_fraction` of pixels exceed `tol`. +#[track_caller] +pub fn assert_similar(a: &Image, b: &Image, tol: f64, max_bad_fraction: f64) { + let st = perceptual_diff(a, b, tol); + let frac = st.bad_pixels as f64 / st.pixels.max(1) as f64; + if frac > max_bad_fraction { + let dir = crate::temp_dir("raster"); + a.save_png(&dir.join("a.png")); + b.save_png(&dir.join("b.png")); + panic!("images differ: {st:?} ({:.3}% bad > {:.3}%), saved to {}", frac * 100.0, max_bad_fraction * 100.0, dir.display()); + } +} diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs new file mode 100644 index 00000000..33539873 --- /dev/null +++ b/crates/testkit/src/strategies.rs @@ -0,0 +1,518 @@ +//! Proptest strategies: geometry, engine command sequences, junk parameters. + +use drawcraft_engine::{Session, command_specs}; +use drawcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; +use proptest::prelude::*; +use serde_json::{Value, json}; + +use crate::fixtures::{art_nodes, top_level_ids}; + +// ---------------------------------------------------------------- geometry + +pub fn arb_point(lo: f64, hi: f64) -> impl Strategy { + (lo..hi, lo..hi).prop_map(|(x, y)| Point::new(x, y)) +} + +/// A non-degenerate rectangle inside `[0, 200]²`. +pub fn arb_rect() -> impl Strategy { + (0.0..150.0f64, 0.0..150.0f64, 2.0..80.0f64, 2.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h)) +} + +/// A simple (non self-intersecting) star-shaped polygon: points at increasing angles around a +/// centre with random radii. +pub fn arb_star_polygon() -> impl Strategy> { + (arb_point(40.0, 160.0), prop::collection::vec((0.2f64..1.0, 5.0f64..40.0), 3..12)).prop_map(|(c, v)| { + let total: f64 = v.iter().map(|(a, _)| a).sum(); + let mut ang = 0.0; + v.iter() + .map(|&(a, r)| { + ang += a / total * std::f64::consts::TAU; + Point::new(c.x + r * ang.cos(), c.y + r * ang.sin()) + }) + .collect() + }) +} + +/// A convex polygon (regular polygon with jittered radius kept convex by using a single radius per +/// polygon and jittered angles only). +pub fn arb_convex_polygon() -> impl Strategy> { + (arb_point(40.0, 160.0), 5.0f64..40.0, prop::collection::vec(0.3f64..1.0, 3..10)).prop_map(|(c, r, v)| { + let total: f64 = v.iter().sum(); + let mut ang = 0.0; + v.iter() + .map(|a| { + ang += a / total * std::f64::consts::TAU; + Point::new(c.x + r * ang.cos(), c.y + r * ang.sin()) + }) + .collect() + }) +} + +pub fn polygon_path(pts: &[Point]) -> PathData { + PathData::single(SubPath::polyline(pts, true)) +} + +/// An axis-aligned ellipse as a path. +pub fn arb_ellipse() -> impl Strategy { + arb_rect().prop_map(drawcraft_geom::shapes::ellipse) +} + +/// Rectangles, ellipses and star-shaped polygons. +pub fn arb_closed_shape() -> impl Strategy { + prop_oneof![arb_rect().prop_map(drawcraft_geom::shapes::rectangle), arb_ellipse(), arb_star_polygon().prop_map(|p| polygon_path(&p)),] +} + +/// A random cubic Bézier path (open or closed, possibly several subpaths, arbitrary handles). +pub fn arb_path_data() -> impl Strategy { + let anchor = (arb_point(0.0, 200.0), arb_point(-30.0, 30.0), arb_point(-30.0, 30.0), any::()) + .prop_map(|(p, i, o, corner)| if corner { Anchor::corner(p) } else { Anchor::with_handles(p, p + i.to_vec2(), p + o.to_vec2()) }); + let sub = (prop::collection::vec(anchor, 2..8), any::()).prop_map(|(a, closed)| SubPath::new(a, closed)); + prop::collection::vec(sub, 1..3).prop_map(PathData::new) +} + +/// A smooth open curve through points on a gently varying function (for simplify tests): densely +/// sampled polyline-like cubic chain with C1 handles. +pub fn arb_smooth_curve() -> impl Strategy { + (prop::collection::vec(-20.0f64..20.0, 4..9), 10.0f64..30.0).prop_map(|(ys, step)| { + // Catmull-Rom through (i*step, y_i), converted to cubic handles. + let pts: Vec = ys.iter().enumerate().map(|(i, y)| Point::new(i as f64 * step, 100.0 + y)).collect(); + let n = pts.len(); + let tangent = |i: usize| { + let a = pts[i.saturating_sub(1)]; + let b = pts[(i + 1).min(n - 1)]; + (b - a) / 6.0 + }; + let anchors = (0..n).map(|i| Anchor::with_handles(pts[i], pts[i] - tangent(i), pts[i] + tangent(i))).collect(); + PathData::single(SubPath::new(anchors, false)) + }) +} + +// ---------------------------------------------------------------- engine ops + +/// One abstract editing step. Object references are indices resolved against the live document +/// when the op is applied (so generated sequences stay meaningful as ids change). +#[derive(Clone, Debug)] +pub enum Op { + Rect(f64, f64, f64, f64), + Ellipse(f64, f64, f64, f64), + Polygon(f64, f64, f64, u32), + Star(f64, f64, f64, f64), + Line(f64, f64, f64, f64), + Path(Vec<(f64, f64)>, bool), + Text(f64, f64), + SelectSet(Vec), + SelectAll, + SelectNone, + SelectInverse, + Move(f64, f64, bool), + Rotate(f64, bool), + Scale(f64, f64), + Reflect(bool), + Shear(f64), + TransformAgain, + Nudge(i8, i8), + Group, + Ungroup, + Arrange(u8), + Pathfinder(u8), + Fill(u8), + Gradient(f64), + Stroke(u8, f64, bool), + Transparency(f64, u8), + Align(u8), + Distribute, + LayerNew, + LayerDelete, + LayerCurrent(usize), + Collect, + Copy, + Cut, + Paste(u8), + Duplicate, + Clear, + Undo, + Redo, + Clip, + ClipRelease, + Compound, + CompoundRelease, + Lock, + UnlockAll, + Hide, + ShowAll, + Offset(f64), + Simplify, + OutlineStroke, + ExpandShape, + Effect(u8), + ExpandAppearance, + Batch(Vec), +} + +pub const PATHFINDER: [&str; 10] = [ + "object.pathfinder.unite", + "object.pathfinder.minusFront", + "object.pathfinder.intersect", + "object.pathfinder.exclude", + "object.pathfinder.divide", + "object.pathfinder.trim", + "object.pathfinder.merge", + "object.pathfinder.crop", + "object.pathfinder.outline", + "object.pathfinder.minusBack", +]; +const ARRANGE: [&str; 5] = [ + "object.arrange.bringToFront", + "object.arrange.bringForward", + "object.arrange.sendBackward", + "object.arrange.sendToBack", + "object.arrange.sendToCurrentLayer", +]; +const COLORS: [&str; 6] = ["#ff0000", "#00ff00", "#0000ff", "#222222", "#ffffff", "#ffcc00"]; +const BLENDS: [&str; 4] = ["Normal", "Multiply", "Screen", "Difference"]; +const PASTE: [&str; 4] = ["edit.paste", "edit.pasteInFront", "edit.pasteInBack", "edit.pasteInPlace"]; + +fn coord() -> impl Strategy { + -20.0..380.0f64 +} +fn size() -> impl Strategy { + 1.0..150.0f64 +} + +fn arb_leaf_op() -> impl Strategy { + prop_oneof![ + // creation (weighted up so documents have content) + 4 => (coord(), coord(), size(), size()).prop_map(|(a, b, c, d)| Op::Rect(a, b, c, d)), + 3 => (coord(), coord(), size(), size()).prop_map(|(a, b, c, d)| Op::Ellipse(a, b, c, d)), + 1 => (coord(), coord(), 5.0..80.0f64, 3u32..9).prop_map(|(a, b, c, d)| Op::Polygon(a, b, c, d)), + 1 => (coord(), coord(), 10.0..80.0f64, 3.0..40.0f64).prop_map(|(a, b, c, d)| Op::Star(a, b, c, d)), + 1 => (coord(), coord(), coord(), coord()).prop_map(|(a, b, c, d)| Op::Line(a, b, c, d)), + 1 => (prop::collection::vec((coord(), coord()), 2..6), any::()).prop_map(|(v, c)| Op::Path(v, c)), + 1 => (coord(), coord()).prop_map(|(a, b)| Op::Text(a, b)), + // selection + 5 => prop::collection::vec(0usize..64, 1..4).prop_map(Op::SelectSet), + 1 => Just(Op::SelectAll), + 1 => Just(Op::SelectNone), + 1 => Just(Op::SelectInverse), + // transforms + 2 => (-50.0..50.0f64, -50.0..50.0f64, any::()).prop_map(|(a, b, c)| Op::Move(a, b, c)), + 1 => (-180.0..180.0f64, any::()).prop_map(|(a, c)| Op::Rotate(a, c)), + 1 => (20.0..300.0f64, 20.0..300.0f64).prop_map(|(a, b)| Op::Scale(a, b)), + 1 => any::().prop_map(Op::Reflect), + 1 => (-45.0..45.0f64).prop_map(Op::Shear), + 1 => Just(Op::TransformAgain), + 1 => (-1i8..=1, -1i8..=1).prop_map(|(a, b)| Op::Nudge(a, b)), + // structure + 2 => Just(Op::Group), + 2 => Just(Op::Ungroup), + 2 => (0u8..5).prop_map(Op::Arrange), + 3 => (0u8..10).prop_map(Op::Pathfinder), + // paint + 2 => (0u8..6).prop_map(Op::Fill), + 1 => (0.0..360.0f64).prop_map(Op::Gradient), + 1 => (0u8..6, 0.0..10.0f64, any::()).prop_map(|(a, b, c)| Op::Stroke(a, b, c)), + 1 => (0.0..100.0f64, 0u8..4).prop_map(|(a, b)| Op::Transparency(a, b)), + 1 => (0u8..6).prop_map(Op::Align), + 1 => Just(Op::Distribute), + // layers + 1 => Just(Op::LayerNew), + 1 => Just(Op::LayerDelete), + 1 => (0usize..4).prop_map(Op::LayerCurrent), + 1 => Just(Op::Collect), + // clipboard + 1 => Just(Op::Copy), + 1 => Just(Op::Cut), + 2 => (0u8..4).prop_map(Op::Paste), + 1 => Just(Op::Duplicate), + 1 => Just(Op::Clear), + // history + 3 => Just(Op::Undo), + 2 => Just(Op::Redo), + // object menu + 1 => Just(Op::Clip), + 1 => Just(Op::ClipRelease), + 1 => Just(Op::Compound), + 1 => Just(Op::CompoundRelease), + 1 => Just(Op::Lock), + 1 => Just(Op::UnlockAll), + 1 => Just(Op::Hide), + 1 => Just(Op::ShowAll), + 1 => (-10.0..10.0f64).prop_map(Op::Offset), + 1 => Just(Op::Simplify), + 1 => Just(Op::OutlineStroke), + 1 => Just(Op::ExpandShape), + 1 => (0u8..4).prop_map(Op::Effect), + 1 => Just(Op::ExpandAppearance), + ] +} + +/// Any op, including (non-nested) batches. +pub fn arb_op() -> impl Strategy { + prop_oneof![ + 30 => arb_leaf_op(), + 1 => prop::collection::vec(arb_leaf_op().prop_filter("no history ops in batches", |o| !matches!(o, Op::Undo | Op::Redo)), 1..5).prop_map(Op::Batch), + ] +} + +/// A sequence of `len` ops. +pub fn arb_ops(len: std::ops::Range) -> impl Strategy> { + prop::collection::vec(arb_op(), len) +} + +fn pick(ids: &[drawcraft_doc::NodeId], i: usize) -> Option { + if ids.is_empty() { None } else { Some(ids[i % ids.len()].0) } +} + +impl Op { + /// The command id and params this op maps to in the current session state. + pub fn command(&self, s: &Session) -> (String, Value) { + let doc = s.active().map(|d| d.doc.clone()); + // Children of compound paths are excluded: grouping one of them nests a group inside the + // compound (known bug, see crates/engine/tests/known_bugs.rs). + let art: Vec<_> = doc + .as_ref() + .map(|d| { + art_nodes(d) + .iter() + .filter(|n| { + !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, drawcraft_doc::NodeKind::Compound { .. })) + }) + .map(|n| n.id) + .collect() + }) + .unwrap_or_default(); + let layers: Vec<_> = doc.as_ref().map(|d| d.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default(); + let c = |id: &str, p: Value| (id.to_string(), p); + match self { + Op::Rect(x, y, w, h) => c("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})), + Op::Ellipse(x, y, w, h) => c("shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})), + Op::Polygon(x, y, r, n) => c("shape.polygon", json!({"cx": x, "cy": y, "radius": r, "sides": n})), + Op::Star(x, y, r1, r2) => c("shape.star", json!({"cx": x, "cy": y, "radius1": r1, "radius2": r2})), + Op::Line(a, b, cc, d) => c("shape.line", json!({"x1": a, "y1": b, "x2": cc, "y2": d})), + Op::Path(pts, closed) => { + c("path.create", json!({"anchors": pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect::>(), "closed": closed})) + } + Op::Text(x, y) => c("text.create", json!({"x": x, "y": y, "text": "Ab", "size": 18})), + Op::SelectSet(ix) => c("select.set", json!({"ids": ix.iter().filter_map(|i| pick(&art, *i)).collect::>()})), + Op::SelectAll => c("select.all", json!({})), + Op::SelectNone => c("select.none", json!({})), + Op::SelectInverse => c("select.inverse", json!({})), + Op::Move(dx, dy, copy) => c("object.move", json!({"dx": dx, "dy": dy, "copy": copy})), + Op::Rotate(a, copy) => c("object.rotate", json!({"angle": a, "copy": copy})), + Op::Scale(sx, sy) => c("object.scale", json!({"sx": sx, "sy": sy})), + Op::Reflect(v) => c("object.reflect", json!({"axis": if *v { "vertical" } else { "horizontal" }})), + Op::Shear(a) => c("object.shear", json!({"angle": a})), + Op::TransformAgain => c("object.transformAgain", json!({})), + Op::Nudge(x, y) => c("object.nudge", json!({"dx": x, "dy": y})), + Op::Group => c("object.group", json!({})), + Op::Ungroup => c("object.ungroup", json!({})), + Op::Arrange(i) => { + let i = *i as usize % ARRANGE.len(); + // Known bug (crates/engine/tests/known_bugs.rs): Bring to Front/Forward panics when + // the selection spans several parents. Avoid generating that case. + let multi_parent = doc.as_ref().zip(s.active()).is_some_and(|(d, st)| { + let mut parents: Vec<_> = st.selection.objects.iter().map(|id| d.parent_of(*id)).collect(); + parents.dedup(); + parents.len() > 1 + }); + if i < 2 && multi_parent { c("document.inspect", json!({})) } else { c(ARRANGE[i], json!({})) } + } + Op::Pathfinder(i) => c(PATHFINDER[*i as usize % PATHFINDER.len()], json!({})), + Op::Fill(i) => c("paint.setFill", json!({"color": COLORS[*i as usize % COLORS.len()]})), + Op::Gradient(a) => c( + "paint.setFill", + json!({"gradient": {"kind": if *a > 180.0 { "radial" } else { "linear" }, "angle": a, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ), + Op::Stroke(i, w, dash) => { + let mut p = json!({"weight": w}); + if *dash { + p["dash"] = json!([4, 2]); + } + let _ = s; + // Also set the colour through setStroke in a separate step would need two commands; + // use the colour index to pick an arrowhead instead to cover more of stroke.set. + if i % 2 == 0 { + p["endArrow"] = json!("Triangle"); + } + c("stroke.set", p) + } + Op::Transparency(o, b) => c("transparency.set", json!({"opacity": o, "blend": BLENDS[*b as usize % BLENDS.len()]})), + Op::Align(i) => { + let p = match i % 6 { + 0 => json!({"horizontal": "left"}), + 1 => json!({"horizontal": "center"}), + 2 => json!({"horizontal": "right"}), + 3 => json!({"vertical": "top"}), + 4 => json!({"vertical": "center", "to": "artboard"}), + _ => json!({"vertical": "bottom", "to": "key"}), + }; + c("object.align", p) + } + Op::Distribute => c("object.distribute", json!({"horizontal": "center"})), + Op::LayerNew => c("layer.new", json!({})), + Op::LayerDelete => c("layer.delete", json!({})), + Op::LayerCurrent(i) => c("layer.setCurrent", json!({"id": if layers.is_empty() { 0 } else { layers[i % layers.len()] }})), + Op::Collect => c("layer.collectInNew", json!({})), + Op::Copy => c("edit.copy", json!({})), + Op::Cut => c("edit.cut", json!({})), + Op::Paste(i) => c(PASTE[*i as usize % PASTE.len()], json!({})), + Op::Duplicate => c("edit.duplicate", json!({"dx": 5, "dy": 5})), + Op::Clear => c("edit.clear", json!({})), + Op::Undo => c("edit.undo", json!({})), + Op::Redo => c("edit.redo", json!({})), + Op::Clip => c("object.clippingMask.make", json!({})), + Op::ClipRelease => c("object.clippingMask.release", json!({})), + Op::Compound => c("object.compoundPath.make", json!({})), + Op::CompoundRelease => c("object.compoundPath.release", json!({})), + Op::Lock => c("object.lock", json!({})), + Op::UnlockAll => c("object.unlockAll", json!({})), + Op::Hide => c("object.hide", json!({})), + Op::ShowAll => c("object.showAll", json!({})), + Op::Offset(d) => c("object.path.offsetPath", json!({"offset": d})), + Op::Simplify => c("object.path.simplify", json!({"tolerance": 2})), + Op::OutlineStroke => c("object.path.outlineStroke", json!({})), + Op::ExpandShape => c("object.expandShape", json!({})), + Op::Effect(i) => { + let (id, p) = match i % 4 { + 0 => ("distort.roughen", json!({"size": 3, "detail": 4, "seed": 1})), + 1 => ("stylize.dropShadow", json!({})), + 2 => ("distort.zigZag", json!({"size": 3, "ridges": 2})), + _ => ("stylize.roundCorners", json!({"radius": 5})), + }; + c("effect.apply", json!({"effect": id, "params": p})) + } + Op::ExpandAppearance => c("effect.expandAppearance", json!({})), + Op::Batch(ops) => { + // Batches are resolved against the state *before* the batch; index-based + // references may therefore point at objects later steps removed (then that step + // errors and the whole batch rolls back — also worth exercising). + let cmds: Vec = ops + .iter() + // See the Arrange note above: selection inside a batch isn't known up front. + .map(|o| if let Op::Arrange(i) = o { Op::Arrange(2 + i % 3) } else { o.clone() }) + .map(|o| { + let (id, p) = o.command(s); + json!({"command": id, "params": p}) + }) + .collect(); + c("command.batch", json!({"label": "Gen batch", "commands": cmds})) + } + } + } + + /// Apply this op to the session. + pub fn apply(&self, s: &mut Session) -> drawcraft_engine::Result { + let (id, p) = self.command(s); + s.execute(&id, &p) + } +} + +/// Keep the unused-import lint quiet when only some helpers are used. +#[doc(hidden)] +pub fn _top_level(s: &Session) -> Vec { + s.active().map(|d| top_level_ids(&d.doc)).unwrap_or_default() +} + +// ---------------------------------------------------------------- junk params + +/// Hostile JSON values: wrong types, negative/huge/non-finite-as-null numbers, deep/empty containers. +pub fn junk_values() -> Vec { + vec![ + Value::Null, + json!(true), + json!(-1), + json!(0), + json!(-1e9), + json!(1e12), + json!(1e308), + json!(-1e308), + json!(f64::MIN_POSITIVE), + json!(u64::MAX), + json!(i64::MIN), + json!(0.5), + json!(""), + json!("junk"), + json!("#zzzzzz"), + json!([]), + json!([1]), + json!([f64::MAX, -f64::MAX]), + json!([[1, 2], [3]]), + json!({}), + json!({"x": "y"}), + json!([[[[[]]]]]), + ] +} + +/// Every parameter key mentioned in any command's params doc (plus a few common ones). +pub fn all_param_keys() -> Vec { + let mut keys: std::collections::BTreeSet = + ["ids", "id", "index", "x", "y", "width", "height", "points", "anchors", "params", "effect", "commands"] + .iter() + .map(|s| s.to_string()) + .collect(); + for spec in command_specs() { + keys.extend(param_keys(spec.params)); + } + keys.into_iter().collect() +} + +/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`). +pub fn param_keys(doc: &str) -> Vec { + let doc = doc.split('→').next().unwrap_or(""); + let chars: Vec = doc.chars().collect(); + let mut out = vec![]; + let mut i = 0; + while i < chars.len() { + if chars[i].is_ascii_lowercase() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + // Previous non-space char must open a field; next char must close/type it. + let before = chars[..start].iter().rev().find(|c| **c != ' ').copied(); + let after = chars.get(i).copied(); + if matches!(before, Some('{') | Some(',') | Some('|')) + && matches!(after, Some('?') | Some(':') | Some(',') | Some('}') | Some('=') | Some('|')) + { + out.push(chars[start..i].iter().collect()); + } + } else { + i += 1; + } + } + out.sort(); + out.dedup(); + out +} + +/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could +/// write files anywhere, so callers substitute a temp path. +pub fn is_path_key(k: &str) -> bool { + k == "path" +} + +/// A deterministic list of junk param objects for `doc` (the command's params doc): each +/// documented key set to each junk value, all keys junk at once, wrong top-level types. +pub fn junk_params(doc: &str, safe_path: &str) -> Vec { + let keys = param_keys(doc); + let junk = junk_values(); + let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; + for k in &keys { + for (i, v) in junk.iter().enumerate() { + let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() }; + // Sparse: one key at a time with every junk value, plus a few combos. + let mut o = serde_json::Map::new(); + o.insert(k.clone(), v.clone()); + out.push(Value::Object(o)); + if i % 5 == 0 { + let mut all = serde_json::Map::new(); + for k2 in &keys { + let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() }; + all.insert(k2.clone(), v2); + } + out.push(Value::Object(all)); + } + } + } + out +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index befb64a8..2af343c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -19,16 +19,36 @@ pub struct Xf { pub rect: egui::Rect, pub zoom: f64, pub center: Point, + /// View rotation in radians (Rotate View), clockwise on screen. + pub rot: f64, } impl Xf { + pub fn new(rect: egui::Rect, v: &View) -> Self { + Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() } + } pub fn to_screen(&self, p: Point) -> Pos2 { let c = self.rect.center(); - pos2(c.x + ((p.x - self.center.x) * self.zoom) as f32, c.y + ((p.y - self.center.y) * self.zoom) as f32) + let (dx, dy) = ((p.x - self.center.x) * self.zoom, (p.y - self.center.y) * self.zoom); + let (sn, cs) = self.rot.sin_cos(); + pos2(c.x + (dx * cs - dy * sn) as f32, c.y + (dx * sn + dy * cs) as f32) } pub fn to_doc(&self, p: Pos2) -> Point { let c = self.rect.center(); - Point::new(self.center.x + (p.x - c.x) as f64 / self.zoom, self.center.y + (p.y - c.y) as f64 / self.zoom) + let (dx, dy) = ((p.x - c.x) as f64, (p.y - c.y) as f64); + let (sn, cs) = self.rot.sin_cos(); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + Point::new(self.center.x + ux / self.zoom, self.center.y + uy / self.zoom) + } + /// Screen-space corners of a document rect (a rotated quad when the view is rotated). + pub fn quad(&self, r: Rect) -> Vec { + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].iter().map(|p| self.to_screen(*p)).collect() + } + /// Convert a screen-space delta to a document delta. + pub fn delta_to_doc(&self, d: egui::Vec2) -> drawcraft_geom::Vec2 { + let (sn, cs) = self.rot.sin_cos(); + let (dx, dy) = (d.x as f64, d.y as f64); + drawcraft_geom::Vec2::new((dx * cs + dy * sn) / self.zoom, (-dx * sn + dy * cs) / self.zoom) } pub fn rect_to_screen(&self, r: Rect) -> egui::Rect { egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1))) @@ -36,7 +56,7 @@ impl Xf { /// Affine mapping document points to screen points. pub fn affine(&self) -> Affine { let c = self.rect.center(); - Affine::translate((c.x as f64, c.y as f64)) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2()) + Affine::translate((c.x as f64, c.y as f64)) * Affine::rotate(self.rot) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2()) } } @@ -50,6 +70,10 @@ enum Drag { ZoomBox { start: Pos2, }, + RotateView { + start_angle: f64, + start_rot: f64, + }, /// Cmd held: temporary selection tool; restore this tool on release. TempSelect, } @@ -109,7 +133,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); handle_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf { rect, zoom: v.zoom, center: v.center }; + let xf = Xf::new(rect, &v); let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -119,18 +143,20 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { if app.ui.view.artboards && !app.ui.view.outline { for ab in &doc.artboards { let r = xf.rect_to_screen(ab.rect); + let q = xf.quad(ab.rect); // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). - painter.rect_filled(r.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(26)); - painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(52)); - if app.ui.view.transparency_grid { + let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); + painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); + painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); + if app.ui.view.transparency_grid && xf.rot == 0.0 { checker(&painter, r); } else { - painter.rect_filled(r, 0.0, Color32::WHITE); + painter.add(Shape::convex_polygon(q, Color32::WHITE, Stroke::NONE)); } } } else if app.ui.view.outline { for ab in &doc.artboards { - painter.rect_filled(xf.rect_to_screen(ab.rect), 0.0, Color32::WHITE); + painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); } } if app.ui.view.grid { @@ -151,6 +177,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { outline: app.ui.view.outline, ppp, hidden: vec![], + rot: v.rotation, }; if !app.canvas.worker_started { app.canvas.worker_started = true; @@ -166,7 +193,10 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { app.canvas.last_ms = done.ms; } if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { - let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2()); + let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) + * Affine::rotate(v.rotation.to_radians()) + * Affine::scale(v.zoom * ppp as f64) + * Affine::translate(-v.center.to_vec2()); let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); @@ -182,9 +212,12 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } } } - if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) { + if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) + && (k.rot - v.rotation).abs() < 1e-9 + { // Reproject the last frame if it was rendered for a different view. - let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy) }; + let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot }; + let _ = k.rot; let a = xf.to_screen(old.to_doc(rect.min)); let b = xf.to_screen(old.to_doc(rect.max)); painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); @@ -194,7 +227,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { for (i, ab) in doc.artboards.iter().enumerate() { let r = xf.rect_to_screen(ab.rect); let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; - painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside); + painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c))); if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 { painter.text( r.left_top() - vec2(0.0, 4.0), @@ -284,7 +317,7 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta())); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf { rect, zoom: v.zoom, center: v.center }; + let xf = Xf::new(rect, &v); let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); app.hover_doc = hover.map(|p| xf.to_doc(p)); let view = app.view_info(); @@ -300,7 +333,7 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg if let (Some(p), Some(vm)) = (hover, app.view_mut()) { let before = xf.to_doc(p); vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0); - let nx = Xf { rect, zoom: vm.zoom, center: vm.center }; + let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }; let after = nx.to_doc(p); vm.center += before - after; } @@ -308,8 +341,8 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg && !m.alt && let Some(vm) = app.view_mut() { - vm.center.x -= scroll.x as f64 / vm.zoom; - vm.center.y -= scroll.y as f64 / vm.zoom; + let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll); + vm.center -= d; } } @@ -323,6 +356,9 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg Drag::Pan { start: p, center: v.center } } else if tool == "zoom" { Drag::ZoomBox { start: p } + } else if tool == "rotateView" { + let c = rect.center(); + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } } else { let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection"); let mut kind = Drag::Tool; @@ -341,8 +377,20 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg if pointer.primary_down() { match d { Drag::Pan { start, center } => { + let d = xf.delta_to_doc(p - start); if let Some(vm) = app.view_mut() { - vm.center = center - (p - start).to_vec2_f64() / vm.zoom; + vm.center = center - d; + } + } + Drag::RotateView { start_angle, start_rot } => { + let c = rect.center(); + let a = (p.y - c.y).atan2(p.x - c.x) as f64; + let mut deg = start_rot + (a - start_angle).to_degrees(); + if m.shift { + deg = (deg / 15.0).round() * 15.0; + } + if let Some(vm) = app.view_mut() { + vm.rotation = drawcraft_geom::normalize_deg(deg); } } Drag::ZoomBox { .. } => {} @@ -367,7 +415,7 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg } else { let before = xf.to_doc(p); vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt); - let nx = Xf { rect, zoom: vm.zoom, center: vm.center }; + let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }; vm.center += before - nx.to_doc(p); } } @@ -381,7 +429,7 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg app.select_tool(&prev); } } - Drag::Pan { .. } => {} + Drag::Pan { .. } | Drag::RotateView { .. } => {} } } } else if let Some(p) = hover @@ -401,15 +449,6 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg } } -trait ToVec2F64 { - fn to_vec2_f64(self) -> drawcraft_geom::Vec2; -} -impl ToVec2F64 for egui::Vec2 { - fn to_vec2_f64(self) -> drawcraft_geom::Vec2 { - drawcraft_geom::Vec2::new(self.x as f64, self.y as f64) - } -} - /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut DrawcraftApp, ev: &PointerEvent, view: drawcraft_engine::ViewInfo) { match app.session.pointer(ev, view) { @@ -693,8 +732,7 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return }; let color = c32(st.doc.layer_color(st.selection.objects[0])); - let r = xf.rect_to_screen(b); - p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + p.add(Shape::closed_line(xf.quad(b), Stroke::new(1.0, color))); for h in drawcraft_tools::bbox::Handle::ALL { let c = xf.to_screen(h.pos(b)); let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 50349851..a5c634ad 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -117,7 +117,7 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf { rect, zoom: v.zoom, center: v.center }); + let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); for e in events { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0c0bb071..72140290 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -120,6 +120,12 @@ fn title(kind: &str) -> &'static str { pub fn confirm(app: &mut DrawcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; let copy = d.bool("copy"); + if d.kind == "command" { + let cmd = d.str("__command"); + let params = effect_params(&d); + app.ui.dialog = None; + return app.run(&cmd, params); + } if d.kind == "effect" { let effect = d.str("__effect"); let params = effect_params(&d); @@ -240,10 +246,13 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) .show(ctx, |ui| { ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 }); - let heading = if d.kind == "effect" { d.str("__label") } else { title(&d.kind).to_string() }; + let heading = if d.kind == "effect" || d.kind == "command" { d.str("__label") } else { title(&d.kind).to_string() }; ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); match d.kind.as_str() { + "command" => { + effect_fields(ui, &mut d); + } "effect" => { let changed = effect_fields(ui, &mut d); ui.add_space(6.0); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 045f4eb9..67b081b7 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -98,6 +98,8 @@ pub fn floating_panel(app: &mut DrawcraftApp, ctx: &egui::Context) { if cr.clicked() { open = false; } + let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0)); + panels::panel_menu(app, ui, &id, menu_r); egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(236.0); panels::show_icon_panel(app, ui, &id); diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f48ba503..09e3034a 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -29,45 +29,109 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("cloud", include_bytes!("../../../assets/icons/cloud.svg")), ("combine", include_bytes!("../../../assets/icons/combine.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), + ("dc-al-bottom", include_bytes!("../../../assets/icons/dc-al-bottom.svg")), + ("dc-al-hcenter", include_bytes!("../../../assets/icons/dc-al-hcenter.svg")), + ("dc-al-left", include_bytes!("../../../assets/icons/dc-al-left.svg")), + ("dc-al-right", include_bytes!("../../../assets/icons/dc-al-right.svg")), + ("dc-al-top", include_bytes!("../../../assets/icons/dc-al-top.svg")), + ("dc-al-vcenter", include_bytes!("../../../assets/icons/dc-al-vcenter.svg")), ("dc-align", include_bytes!("../../../assets/icons/dc-align.svg")), + ("dc-alignto-artboard", include_bytes!("../../../assets/icons/dc-alignto-artboard.svg")), + ("dc-alignto-key", include_bytes!("../../../assets/icons/dc-alignto-key.svg")), + ("dc-alignto-selection", include_bytes!("../../../assets/icons/dc-alignto-selection.svg")), ("dc-anchor", include_bytes!("../../../assets/icons/dc-anchor.svg")), ("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")), ("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")), + ("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")), + ("dc-arrow-up", include_bytes!("../../../assets/icons/dc-arrow-up.svg")), + ("dc-artboard-options", include_bytes!("../../../assets/icons/dc-artboard-options.svg")), ("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")), ("dc-blend", include_bytes!("../../../assets/icons/dc-blend.svg")), ("dc-bloat", include_bytes!("../../../assets/icons/dc-bloat.svg")), + ("dc-cap-butt", include_bytes!("../../../assets/icons/dc-cap-butt.svg")), + ("dc-cap-round", include_bytes!("../../../assets/icons/dc-cap-round.svg")), + ("dc-cap-square", include_bytes!("../../../assets/icons/dc-cap-square.svg")), + ("dc-clear", include_bytes!("../../../assets/icons/dc-clear.svg")), ("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")), ("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")), + ("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")), + ("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")), + ("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")), ("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")), + ("dc-dist-bottom", include_bytes!("../../../assets/icons/dc-dist-bottom.svg")), + ("dc-dist-hcenter", include_bytes!("../../../assets/icons/dc-dist-hcenter.svg")), + ("dc-dist-hspace", include_bytes!("../../../assets/icons/dc-dist-hspace.svg")), + ("dc-dist-left", include_bytes!("../../../assets/icons/dc-dist-left.svg")), + ("dc-dist-right", include_bytes!("../../../assets/icons/dc-dist-right.svg")), + ("dc-dist-top", include_bytes!("../../../assets/icons/dc-dist-top.svg")), + ("dc-dist-vcenter", include_bytes!("../../../assets/icons/dc-dist-vcenter.svg")), + ("dc-dist-vspace", include_bytes!("../../../assets/icons/dc-dist-vspace.svg")), ("dc-draw-behind", include_bytes!("../../../assets/icons/dc-draw-behind.svg")), ("dc-draw-inside", include_bytes!("../../../assets/icons/dc-draw-inside.svg")), ("dc-draw-normal", include_bytes!("../../../assets/icons/dc-draw-normal.svg")), ("dc-ellipse", include_bytes!("../../../assets/icons/dc-ellipse.svg")), ("dc-fill-none", include_bytes!("../../../assets/icons/dc-fill-none.svg")), ("dc-flare", include_bytes!("../../../assets/icons/dc-flare.svg")), + ("dc-folder", include_bytes!("../../../assets/icons/dc-folder.svg")), ("dc-free-transform", include_bytes!("../../../assets/icons/dc-free-transform.svg")), + ("dc-fx", include_bytes!("../../../assets/icons/dc-fx.svg")), + ("dc-gamut", include_bytes!("../../../assets/icons/dc-gamut.svg")), + ("dc-grad-freeform", include_bytes!("../../../assets/icons/dc-grad-freeform.svg")), + ("dc-grad-linear", include_bytes!("../../../assets/icons/dc-grad-linear.svg")), + ("dc-grad-radial", include_bytes!("../../../assets/icons/dc-grad-radial.svg")), ("dc-gradient", include_bytes!("../../../assets/icons/dc-gradient.svg")), ("dc-graphic-styles", include_bytes!("../../../assets/icons/dc-graphic-styles.svg")), + ("dc-grid-view", include_bytes!("../../../assets/icons/dc-grid-view.svg")), ("dc-group-select", include_bytes!("../../../assets/icons/dc-group-select.svg")), ("dc-join", include_bytes!("../../../assets/icons/dc-join.svg")), + ("dc-join-bevel", include_bytes!("../../../assets/icons/dc-join-bevel.svg")), + ("dc-join-miter", include_bytes!("../../../assets/icons/dc-join-miter.svg")), + ("dc-join-round", include_bytes!("../../../assets/icons/dc-join-round.svg")), ("dc-knife", include_bytes!("../../../assets/icons/dc-knife.svg")), ("dc-line", include_bytes!("../../../assets/icons/dc-line.svg")), + ("dc-list-view", include_bytes!("../../../assets/icons/dc-list-view.svg")), ("dc-live-bucket", include_bytes!("../../../assets/icons/dc-live-bucket.svg")), ("dc-live-select", include_bytes!("../../../assets/icons/dc-live-select.svg")), + ("dc-mask-none", include_bytes!("../../../assets/icons/dc-mask-none.svg")), ("dc-measure", include_bytes!("../../../assets/icons/dc-measure.svg")), ("dc-mesh", include_bytes!("../../../assets/icons/dc-mesh.svg")), + ("dc-new-fill", include_bytes!("../../../assets/icons/dc-new-fill.svg")), + ("dc-new-item", include_bytes!("../../../assets/icons/dc-new-item.svg")), + ("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")), + ("dc-options", include_bytes!("../../../assets/icons/dc-options.svg")), + ("dc-para-center", include_bytes!("../../../assets/icons/dc-para-center.svg")), + ("dc-para-justify-all", include_bytes!("../../../assets/icons/dc-para-justify-all.svg")), + ("dc-para-justify-center", include_bytes!("../../../assets/icons/dc-para-justify-center.svg")), + ("dc-para-justify-left", include_bytes!("../../../assets/icons/dc-para-justify-left.svg")), + ("dc-para-justify-right", include_bytes!("../../../assets/icons/dc-para-justify-right.svg")), + ("dc-para-left", include_bytes!("../../../assets/icons/dc-para-left.svg")), + ("dc-para-right", include_bytes!("../../../assets/icons/dc-para-right.svg")), ("dc-path-eraser", include_bytes!("../../../assets/icons/dc-path-eraser.svg")), ("dc-pathfinder", include_bytes!("../../../assets/icons/dc-pathfinder.svg")), ("dc-pen-add", include_bytes!("../../../assets/icons/dc-pen-add.svg")), ("dc-pen-delete", include_bytes!("../../../assets/icons/dc-pen-delete.svg")), ("dc-perspective", include_bytes!("../../../assets/icons/dc-perspective.svg")), + ("dc-pf-crop", include_bytes!("../../../assets/icons/dc-pf-crop.svg")), + ("dc-pf-divide", include_bytes!("../../../assets/icons/dc-pf-divide.svg")), + ("dc-pf-exclude", include_bytes!("../../../assets/icons/dc-pf-exclude.svg")), + ("dc-pf-intersect", include_bytes!("../../../assets/icons/dc-pf-intersect.svg")), + ("dc-pf-merge", include_bytes!("../../../assets/icons/dc-pf-merge.svg")), + ("dc-pf-minus-back", include_bytes!("../../../assets/icons/dc-pf-minus-back.svg")), + ("dc-pf-minus-front", include_bytes!("../../../assets/icons/dc-pf-minus-front.svg")), + ("dc-pf-outline", include_bytes!("../../../assets/icons/dc-pf-outline.svg")), + ("dc-pf-trim", include_bytes!("../../../assets/icons/dc-pf-trim.svg")), + ("dc-pf-unite", include_bytes!("../../../assets/icons/dc-pf-unite.svg")), + ("dc-place-symbol", include_bytes!("../../../assets/icons/dc-place-symbol.svg")), ("dc-polar-grid", include_bytes!("../../../assets/icons/dc-polar-grid.svg")), ("dc-polygon", include_bytes!("../../../assets/icons/dc-polygon.svg")), ("dc-pucker", include_bytes!("../../../assets/icons/dc-pucker.svg")), ("dc-puppet", include_bytes!("../../../assets/icons/dc-puppet.svg")), + ("dc-rearrange", include_bytes!("../../../assets/icons/dc-rearrange.svg")), ("dc-rect-grid", include_bytes!("../../../assets/icons/dc-rect-grid.svg")), ("dc-reference-point", include_bytes!("../../../assets/icons/dc-reference-point.svg")), + ("dc-remove-brush", include_bytes!("../../../assets/icons/dc-remove-brush.svg")), ("dc-reshape", include_bytes!("../../../assets/icons/dc-reshape.svg")), + ("dc-reverse", include_bytes!("../../../assets/icons/dc-reverse.svg")), ("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")), ("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")), ("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")), @@ -77,7 +141,11 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-shear", include_bytes!("../../../assets/icons/dc-shear.svg")), ("dc-smooth", include_bytes!("../../../assets/icons/dc-smooth.svg")), ("dc-stroke", include_bytes!("../../../assets/icons/dc-stroke.svg")), + ("dc-stroke-center", include_bytes!("../../../assets/icons/dc-stroke-center.svg")), + ("dc-stroke-inside", include_bytes!("../../../assets/icons/dc-stroke-inside.svg")), + ("dc-stroke-outside", include_bytes!("../../../assets/icons/dc-stroke-outside.svg")), ("dc-swap", include_bytes!("../../../assets/icons/dc-swap.svg")), + ("dc-swatch-kinds", include_bytes!("../../../assets/icons/dc-swatch-kinds.svg")), ("dc-symbol-sprayer", include_bytes!("../../../assets/icons/dc-symbol-sprayer.svg")), ("dc-touch-type", include_bytes!("../../../assets/icons/dc-touch-type.svg")), ("dc-transform-panel", include_bytes!("../../../assets/icons/dc-transform-panel.svg")), @@ -87,7 +155,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-type-path", include_bytes!("../../../assets/icons/dc-type-path.svg")), ("dc-type-vertical", include_bytes!("../../../assets/icons/dc-type-vertical.svg")), ("dc-width", include_bytes!("../../../assets/icons/dc-width.svg")), + ("dc-width-profile", include_bytes!("../../../assets/icons/dc-width-profile.svg")), ("dc-wrinkle", include_bytes!("../../../assets/icons/dc-wrinkle.svg")), + ("dc-zoom-large", include_bytes!("../../../assets/icons/dc-zoom-large.svg")), + ("dc-zoom-small", include_bytes!("../../../assets/icons/dc-zoom-small.svg")), ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), ("eye", include_bytes!("../../../assets/icons/eye.svg")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3425db75..97723b6c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,7 @@ pub struct CacheKey { pub outline: bool, pub ppp: f32, pub hidden: Vec, + pub rot: f64, } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 511efe0c..2422b815 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,6 +89,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ("tool.setOption", "Tool Option", "", "{key, value}"), ("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"), + ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ("file.export.pdf", "Save as PDF…", "", "{path?}"), ("help.about", "About DrawCraft", "", "{}"), @@ -293,6 +294,14 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option Err(format!("unknown effect `{id}`")), } } + "ui.paramDialog" => { + let cmd = s("command").unwrap_or_default(); + let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); + fields.insert("__command".into(), json!(cmd)); + fields.insert("__label".into(), json!(s("label").unwrap_or(cmd.clone()))); + app.ui.dialog = Some(crate::state::Dialog { kind: "command".into(), fields }); + Ok(Value::Null) + } "effect.applyLast" => match app.last_effect.clone() { Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), None => Err("no effect applied yet".into()), @@ -341,6 +350,10 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option { } } "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), + "file.documentColorMode" => { + let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == drawcraft_engine::doc::ColorMode::Cmyk); + p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) + } _ => return None, }) } @@ -357,6 +370,7 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String { "view.transparencyGrid" => if v.transparency_grid { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into(), "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), + "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), _ => label.into(), @@ -421,7 +435,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Open Recent Files"), Sep, c("Close", "file.close"), - todos("Close All", "Cmd+Alt+W"), + c("Close All", "file.closeAll"), c("Save", "file.save"), c("Save As…", "file.saveAs"), c("Save a Copy…", "file.saveCopy"), @@ -445,8 +459,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]), Sep, c("Document Setup…", "file.documentSetup"), - sub("Document Color Mode", vec![todo("CMYK Color"), todo("RGB Color")]), - todos("File Info…", "Cmd+Alt+Shift+I"), + sub( + "Document Color Mode", + vec![ + cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), + cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), + ], + ), + c("File Info…", "file.info"), Sep, todos("Print…", "Cmd+P"), ], @@ -464,27 +484,27 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Paste in Back", "edit.pasteInBack"), c("Paste in Place", "edit.pasteInPlace"), c("Paste on All Artboards", "edit.pasteOnAllArtboards"), - todos("Paste without Formatting", "Cmd+Alt+V"), + c("Paste without Formatting", "edit.pasteWithoutFormatting"), c("Clear", "edit.clear"), Sep, - todo("Find and Replace…"), - todo("Find Next"), + cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), + cp("Find Next", "edit.findNext", json!({"find": ""})), sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), Sep, sub( "Edit Colors", vec![ todo("Recolor Artwork…"), - todo("Adjust Color Balance…"), - todo("Blend Front to Back"), - todo("Blend Horizontally"), - todo("Blend Vertically"), - todo("Convert to CMYK"), - todo("Convert to Grayscale"), - todo("Convert to RGB"), - todo("Invert Colors"), + cp("Adjust Color Balance…", "edit.colors.adjustBalance", json!({"r": 0, "g": 0, "b": 0})), + c("Blend Front to Back", "edit.colors.blendFrontToBack"), + c("Blend Horizontally", "edit.colors.blendHorizontally"), + c("Blend Vertically", "edit.colors.blendVertically"), + c("Convert to CMYK", "edit.colors.toCMYK"), + c("Convert to Grayscale", "edit.colors.toGrayscale"), + c("Convert to RGB", "edit.colors.toRGB"), + c("Invert Colors", "edit.colors.invert"), todo("Overprint Black…"), - todo("Saturate…"), + cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})), ], ), todo("Edit Original"), @@ -515,9 +535,13 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Scale…", "object.scale"), c("Shear…", "object.shear"), Sep, - todos("Transform Each…", "Cmd+Alt+Shift+D"), + cp( + "Transform Each…", + "object.transformEach", + json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4}), + ), Sep, - todo("Reset Bounding Box"), + c("Reset Bounding Box", "object.resetBoundingBox"), ], ), sub( @@ -545,18 +569,24 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Group", "object.group"), c("Ungroup", "object.ungroup"), - sub("Lock", vec![c("Selection", "object.lock"), todo("All Artwork Above"), todo("Other Layers")]), + sub( + "Lock", + vec![c("Selection", "object.lock"), c("All Artwork Above", "object.lock.above"), c("Other Layers", "object.lock.otherLayers")], + ), c("Unlock All", "object.unlockAll"), - sub("Hide", vec![c("Selection", "object.hide"), todo("All Artwork Above"), todo("Other Layers")]), + sub( + "Hide", + vec![c("Selection", "object.hide"), c("All Artwork Above", "object.hide.above"), c("Other Layers", "object.hide.otherLayers")], + ), c("Show All", "object.showAll"), Sep, - todo("Expand…"), + cp("Expand…", "object.expand", json!({"object": true, "fill": true, "stroke": true})), c("Expand Appearance", "effect.expandAppearance"), - todo("Crop Image"), - todo("Rasterize…"), + c("Crop Image", "object.cropImage"), + cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})), todo("Create Gradient Mesh…"), todo("Create Object Mosaic…"), - todo("Create Trim Marks"), + c("Create Trim Marks", "object.createTrimMarks"), todo("Flatten Transparency…"), Sep, todo("Make Pixel Perfect"), @@ -576,28 +606,28 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Simplify…", "object.path.simplify"), c("Add Anchor Points", "object.path.addAnchorPoints"), c("Remove Anchor Points", "path.deleteAnchors"), - todo("Divide Objects Below"), + c("Divide Objects Below", "object.path.divideObjectsBelow"), c("Split Into Grid…", "object.path.splitIntoGrid"), Sep, - todo("Clean Up…"), + cp("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true, "unpaintedObjects": true, "emptyTextPaths": true})), ], ), - sub("Shape", vec![todo("Convert to Shape"), c("Expand Shape", "object.expandShape")]), + sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]), sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]), sub( "Blend", vec![ - todos("Make", "Cmd+Alt+B"), - todos("Release", "Cmd+Alt+Shift+B"), + c("Make", "object.blend.make"), + c("Release", "object.blend.release"), Sep, - todo("Blend Options…"), + cp("Blend Options…", "object.blend.options", json!({"steps": 5})), Sep, - todo("Expand"), + c("Expand", "object.blend.expand"), Sep, todo("Replace Spine"), - todo("Reverse Spine"), - todo("Reverse Front to Back"), + c("Reverse Spine", "object.blend.reverseSpine"), + c("Reverse Front to Back", "object.blend.reverseFrontToBack"), ], ), sub( @@ -622,8 +652,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Artboards", vec![ - todo("Convert to Artboards"), - todo("Rearrange All Artboards…"), + c("Convert to Artboards", "artboard.convertToArtboards"), + cp("Rearrange All Artboards…", "artboard.rearrange", json!({"columns": 2, "spacing": 20, "moveArtwork": true})), Sep, c("Fit to Artwork Bounds", "artboard.fitToArt"), c("Fit to Selected Art", "artboard.fitToSelection"), @@ -635,46 +665,49 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ( "Type", vec![ - sub( - "Font", - drawcraft_text::FontDb::global() - .families() - .into_iter() - .take(0) - .map(|_| todo("")) - .chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]) - .collect(), - ), + sub("Font", font_items()), todo("Recent Fonts"), - sub( - "Size", - [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72] - .iter() - .map(|_| todo("pt")) - .take(0) - .chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]) - .collect(), - ), + sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), Sep, todo("Glyphs"), + sub("Insert Special Character", insert_items(INSERT_SPECIAL)), + sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), + sub("Insert Break Character", insert_items(INSERT_BREAK)), Sep, - todo("Convert To Area Type"), + c("Convert To Area Type", "type.convertToAreaType"), + c("Convert To Point Type", "type.convertToPointType"), todo("Area Type Options…"), sub( "Type on a Path", - vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")], + vec![ + todo("Rainbow"), + todo("Skew"), + todo("3D Ribbon"), + todo("Stair Step"), + todo("Gravity"), + Sep, + cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})), + ], ), sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]), todo("Fit Headline"), Sep, todo("Find Font…"), - sub("Change Case", vec![todo("UPPERCASE"), todo("lowercase"), todo("Title Case"), todo("Sentence case")]), - todo("Smart Punctuation…"), + sub( + "Change Case", + vec![ + cp("UPPERCASE", "type.changeCase", json!({"case": "upper"})), + cp("lowercase", "type.changeCase", json!({"case": "lower"})), + cp("Title Case", "type.changeCase", json!({"case": "title"})), + cp("Sentence case", "type.changeCase", json!({"case": "sentence"})), + ], + ), + cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), Sep, c("Create Outlines", "type.createOutlines"), todo("Optical Margin Alignment"), Sep, - todo("Fill with Placeholder Text"), + c("Fill with Placeholder Text", "type.fillPlaceholder"), Sep, todos("Show Hidden Characters", "Cmd+Alt+I"), sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]), @@ -706,30 +739,36 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Stroke Weight", "select.same.strokeWeight"), todo("Graphic Style"), c("Shape", "select.same.shapeType"), - todo("Symbol Instance"), + c("Symbol Instance", "select.same.symbolInstance"), Sep, - todo("Font Family"), - todo("Font Size"), - todo("Text Fill Color"), + c("Font Family", "select.same.fontFamily"), + c("Font Family & Style", "select.same.fontFamilyStyle"), + c("Font Family, Style & Size", "select.same.fontFamilyStyleSize"), + c("Font Size", "select.same.fontSize"), + c("Text Fill Color", "select.same.textFillColor"), + c("Text Stroke Color", "select.same.textStrokeColor"), ], ), sub( "Object", vec![ c("All on Same Layers", "select.object.allOnSameLayers"), - todo("Direction Handles"), + c("Direction Handles", "select.object.directionHandles"), Sep, - todo("Brush Strokes"), + c("Brush Strokes", "select.object.brushStrokes"), + c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), c("Clipping Masks", "select.object.clippingMasks"), c("Stray Points", "select.object.strayPoints"), c("Open Paths", "select.object.openPaths"), Sep, c("All Text Objects", "select.object.textObjects"), + c("Point Text Objects", "select.object.pointText"), + c("Area Text Objects", "select.object.areaText"), ], ), todo("Start Global Edit"), Sep, - todo("Save Selection…"), + c("Save Selection…", "select.save"), todo("Edit Selection…"), ], ), @@ -785,10 +824,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Guides", vec![ c("Hide Guides", "view.guides"), - todos("Lock Guides", "Cmd+Alt+;"), - todos("Make Guides", "Cmd+5"), - todos("Release Guides", "Cmd+Alt+5"), - todo("Clear Guides"), + c("Lock Guides", "view.guides.lock"), + c("Make Guides", "view.guides.make"), + c("Release Guides", "view.guides.release"), + c("Clear Guides", "view.guides.clear"), ], ), c("Smart Guides", "view.smartGuides"), @@ -937,7 +976,7 @@ fn render_items(app: &DrawcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: }; let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc).min_size(egui::vec2(ui.available_width(), 0.0))); if r.clicked() { - *clicked = Some((id.to_string(), if p.is_null() { json!({}) } else { p.clone() })); + *clicked = Some(click_target(label_of(it), id, p)); ui.close(); } } @@ -945,6 +984,25 @@ fn render_items(app: &DrawcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: } } +fn label_of(it: &Item) -> &'static str { + match it { + Item::Cmd(l, _, _) => l, + _ => "", + } +} + +/// What a menu click runs: items whose label ends with "…" and that carry default params open a +/// generic parameter dialog (fields = the defaults) instead of running immediately. +pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { + let dialog = label.ends_with('…') + && p.as_object().is_some_and(|o| !o.is_empty()) + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode"); + if dialog { + return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); + } + (id.to_string(), if p.is_null() { json!({}) } else { p.clone() }) +} + /// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) { // Commands whose menu item opens a dialog in Illustrator. @@ -1043,6 +1101,80 @@ pub fn menu_entries(app: &DrawcraftApp) -> Vec { out } +/// Type → Size presets. +const TYPE_SIZES: [(&str, u32); 14] = [ + ("6 pt", 6), + ("8 pt", 8), + ("9 pt", 9), + ("10 pt", 10), + ("11 pt", 11), + ("12 pt", 12), + ("14 pt", 14), + ("18 pt", 18), + ("24 pt", 24), + ("30 pt", 30), + ("36 pt", 36), + ("48 pt", 48), + ("60 pt", 60), + ("72 pt", 72), +]; + +const INSERT_SPECIAL: &[(&str, &str)] = &[ + ("Bullet", "bullet"), + ("Copyright Symbol", "copyright"), + ("Ellipsis", "ellipsis"), + ("Paragraph Symbol", "paragraph"), + ("Registered Trademark Symbol", "registered"), + ("Section Symbol", "section"), + ("Trademark Symbol", "trademark"), + ("Em Dash", "emDash"), + ("En Dash", "enDash"), + ("Discretionary Hyphen", "discretionaryHyphen"), + ("Nonbreaking Hyphen", "nonBreakingHyphen"), + ("Double Left Quotation Marks", "doubleLeftQuote"), + ("Double Right Quotation Marks", "doubleRightQuote"), + ("Single Left Quotation Mark", "singleLeftQuote"), + ("Single Right Quotation Mark", "singleRightQuote"), +]; +const INSERT_WHITESPACE: &[(&str, &str)] = &[ + ("Em Space", "emSpace"), + ("En Space", "enSpace"), + ("Hair Space", "hairSpace"), + ("Sixth Space", "sixthSpace"), + ("Thin Space", "thinSpace"), + ("Nonbreaking Space", "nonBreakingSpace"), + ("Figure Space", "figureSpace"), + ("Punctuation Space", "punctuationSpace"), + ("Third Space", "thirdSpace"), + ("Quarter Space", "quarterSpace"), +]; +const INSERT_BREAK: &[(&str, &str)] = &[("Forced Line Break", "forcedLineBreak"), ("Tab", "tab")]; + +fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { + list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() +} + +/// Type → Font: one item per installed family (labels interned once so the per-frame menu build +/// doesn't allocate forever). +fn font_items() -> Vec { + static NAMES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let fams = drawcraft_text::FontDb::global().families(); + let Ok(mut names) = NAMES.lock() else { return vec![] }; + fams.iter() + .map(|f| { + let label: &'static str = match names.iter().find(|n| **n == f.as_str()) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(f.clone().into_boxed_str()); + names.push(n); + n + } + }; + cp(label, "text.setStyle", json!({ "font": label })) + }) + .collect() +} + /// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects. fn effect_menu() -> Vec { let cat = drawcraft_effects::effect_catalog(); @@ -1130,4 +1262,3 @@ mod tests { assert_eq!(pretty_shortcut(""), ""); } } - diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs new file mode 100644 index 00000000..a6ed0089 --- /dev/null +++ b/crates/ui-egui/src/panels/align.rs @@ -0,0 +1,153 @@ +//! Align panel: Align Objects, Distribute Objects, Distribute Spacing (with a spacing value when +//! aligning to a key object) and Align To (selection / key object / artboard). + +use drawcraft_doc::Unit; +use egui::Ui; +use serde_json::{Value, json}; + +use super::{pstate, selection_len, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AlignTo { + #[default] + Selection, + Key, + Artboard, +} + +impl AlignTo { + pub fn param(self) -> &'static str { + match self { + AlignTo::Selection => "selection", + AlignTo::Key => "key", + AlignTo::Artboard => "artboard", + } + } +} + +/// `object.align` params: a single object aligns to the artboard (Illustrator does the same). +pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value { + let mut p = base; + let to = if n_selected == 1 && to == AlignTo::Selection { AlignTo::Artboard } else { to }; + p["to"] = json!(to.param()); + p +} + +pub const ALIGN: [(&str, &str, &str, &str); 6] = [ + ("dc-al-left", "Horizontal Align Left", "horizontal", "left"), + ("dc-al-hcenter", "Horizontal Align Center", "horizontal", "center"), + ("dc-al-right", "Horizontal Align Right", "horizontal", "right"), + ("dc-al-top", "Vertical Align Top", "vertical", "top"), + ("dc-al-vcenter", "Vertical Align Center", "vertical", "center"), + ("dc-al-bottom", "Vertical Align Bottom", "vertical", "bottom"), +]; + +pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [ + ("dc-dist-top", "Vertical Distribute Top", "vertical", "top"), + ("dc-dist-vcenter", "Vertical Distribute Center", "vertical", "center"), + ("dc-dist-bottom", "Vertical Distribute Bottom", "vertical", "bottom"), + ("dc-dist-left", "Horizontal Distribute Left", "horizontal", "left"), + ("dc-dist-hcenter", "Horizontal Distribute Center", "horizontal", "center"), + ("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"), +]; + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = selection_len(app); + let to: AlignTo = pstate(ui.ctx(), "align-to"); + widgets::subheader(ui, "Align Objects:"); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (i, (icon, tip, axis, v)) in ALIGN.iter().enumerate() { + if i == 3 { + ui.add_space(6.0); + } + if widgets::icon_button_enabled(ui, icon, tip, false, n >= 1, 32.0).clicked() { + app.run("object.align", align_params(json!({*axis: v}), to, n)).ok(); + } + } + }); + widgets::divider(ui); + widgets::subheader(ui, "Distribute Objects:"); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (i, (icon, tip, axis, v)) in DISTRIBUTE.iter().enumerate() { + if i == 3 { + ui.add_space(6.0); + } + if widgets::icon_button_enabled(ui, icon, tip, false, n >= 2, 32.0).clicked() { + app.run("object.distribute", json!({*axis: v})).ok(); + } + } + }); + if pstate::(ui.ctx(), "align-hide-options") { + return; + } + widgets::divider(ui); + ui.horizontal(|ui| { + ui.vertical(|ui| { + widgets::subheader(ui, "Distribute Spacing:"); + let spacing: f64 = pstate::>(ui.ctx(), "align-spacing").unwrap_or(0.0); + let key = to == AlignTo::Key; + ui.horizontal(|ui| { + let on = n >= 2; + let sp = if key { Some(spacing) } else { None }; + if widgets::icon_button_enabled(ui, "dc-dist-vspace", "Vertical Distribute Space", false, on, 28.0).clicked() { + app.run("object.distributeSpacing", json!({"axis": "vertical", "spacing": sp})).ok(); + } + if widgets::icon_button_enabled(ui, "dc-dist-hspace", "Horizontal Distribute Space", false, on, 28.0).clicked() { + app.run("object.distributeSpacing", json!({"axis": "horizontal", "spacing": sp})).ok(); + } + ui.add_enabled_ui(key, |ui| { + if let Some(v) = widgets::spin_field(ui, "align-spacing", Some(spacing), Unit::Points, 70.0, 1.0, 0.0, &[]) { + set_pstate(ui.ctx(), "align-spacing", Some(v)); + } + }); + }); + }); + ui.separator(); + ui.vertical(|ui| { + widgets::subheader(ui, "Align To:"); + ui.horizontal(|ui| { + for (v, icon, tip) in [ + (AlignTo::Selection, "dc-alignto-selection", "Align to Selection"), + (AlignTo::Key, "dc-alignto-key", "Align to Key Object"), + (AlignTo::Artboard, "dc-alignto-artboard", "Align to Artboard"), + ] { + if widgets::icon_button(ui, icon, tip, to == v, 28.0).clicked() { + set_pstate(ui.ctx(), "align-to", v); + } + } + }); + }); + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let hidden: bool = pstate(ui.ctx(), "align-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "align-hide-options", !hidden); + } + menu_item(ui, "Use Preview Bounds", false, false); + let to: AlignTo = pstate(ui.ctx(), "align-to"); + if menu_item(ui, "Cancel Key Object", to == AlignTo::Key, false) { + set_pstate(ui.ctx(), "align-to", AlignTo::Selection); + app.run("select.key", json!({})).ok(); + } + menu_item(ui, "Align to Glyph Bounds", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_object_aligns_to_artboard() { + let p = align_params(json!({"horizontal": "left"}), AlignTo::Selection, 1); + assert_eq!(p["to"], "artboard"); + let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3); + assert_eq!(p["to"], "selection"); + assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); + } +} diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs new file mode 100644 index 00000000..818c19a7 --- /dev/null +++ b/crates/ui-egui/src/panels/appearance.rs @@ -0,0 +1,528 @@ +//! Appearance panel: the full stack like Illustrator — the object row, each stroke/fill row (eye, +//! disclosure, link label, swatch, weight) with "Opacity: Default" sub-rows, fx rows with inline +//! parameter editors (effect.setParams), the object's Opacity row and the bottom bar. + +use std::sync::OnceLock; + +use drawcraft_color::{BlendMode, Paint}; +use drawcraft_doc::{AppearanceItem, Effect, Node}; +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use super::{first_selected, pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{DrawcraftApp, icons}; + +const ROW: f32 = 30.0; +const EYE_W: f32 = 26.0; + +/// What is selected in the stack (for Duplicate / Delete). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Sel { + #[default] + None, + Item(usize), + Effect(usize), +} + +fn catalog() -> &'static [(String, String, Vec)] { + static C: OnceLock)>> = OnceLock::new(); + C.get_or_init(|| { + drawcraft_effects::effect_catalog() + .into_iter() + .map(|e| (e.id.to_string(), e.label.trim_end_matches('…').to_string(), e.menu.iter().map(|s| s.to_string()).collect())) + .collect() + }) +} + +/// Display label of an effect id ("stylize.dropShadow" → "Drop Shadow"). +pub fn effect_label(id: &str) -> String { + catalog().iter().find(|c| c.0 == id).map(|c| c.1.clone()).unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) +} + +/// "Opacity: Default" or "Opacity: 50% Multiply". +pub fn opacity_text(opacity: f32, blend: BlendMode) -> String { + if (opacity - 1.0).abs() < 1e-4 && blend == BlendMode::Normal { + "Default".into() + } else if blend == BlendMode::Normal { + format!("{:.0}%", opacity * 100.0) + } else { + format!("{:.0}% {}", opacity * 100.0, blend.label()) + } +} + +fn row(ui: &mut Ui, selected: bool) -> (Rect, egui::Response) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag()); + if selected { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + ui.painter().line_segment([pos2(r.left() + EYE_W, r.top()), pos2(r.left() + EYE_W, r.bottom())], Stroke::new(1.0, t.border)); + (r, resp) +} + +fn eye(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, on: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let er = Rect::from_center_size(pos2(r.left() + EYE_W / 2.0, r.center().y), vec2(16.0, 16.0)); + let resp = ui.interact(er, ui.id().with(id), if enabled { Sense::click() } else { Sense::hover() }); + let col = if !enabled { + t.text_disabled + } else if resp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, if on { "eye" } else { "eye-off" }, er, col); + enabled && resp.on_hover_text("Click to toggle visibility").clicked() +} + +fn chevron(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, open: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let cr = Rect::from_center_size(pos2(r.left() + EYE_W + 12.0, r.center().y), vec2(12.0, 12.0)); + let resp = ui.interact(cr, ui.id().with(id), Sense::click()); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, cr, if resp.hovered() { t.text_strong } else { t.icon }); + resp.clicked() +} + +fn text(ui: &Ui, pos: egui::Pos2, s: &str, strong: bool) { + let t = Tokens::get(ui.ctx()); + ui.painter().text(pos, egui::Align2::LEFT_CENTER, s, egui::FontId::proportional(12.5), if strong { t.text_strong } else { t.text }); +} + +fn chip(ui: &Ui, r: Rect, p: &Paint) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r.expand(1.0), 0.0, egui::Color32::BLACK); + widgets::paint_chip(ui, r, p); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, egui::Color32::WHITE), StrokeKind::Inside); + let _ = t; +} + +/// A dotted-underline link drawn at `pos`; returns clicked. +fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug, s: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(s.to_string(), egui::FontId::proportional(12.5), t.text_strong); + let r = Rect::from_min_size(pos2(pos.x, pos.y - galley.size().y / 2.0), galley.size()); + let resp = ui.interact(r, ui.id().with(id), Sense::click()); + ui.painter().galley(r.min, galley, t.text_strong); + let mut x = r.left(); + while x < r.right() { + ui.painter().line_segment([pos2(x, r.bottom()), pos2((x + 1.0).min(r.right()), r.bottom())], Stroke::new(1.0, t.text_dim)); + x += 2.5; + } + resp.clicked() +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let node = first_selected(app); + let sel: Sel = pstate(ui.ctx(), "ap-sel"); + let hide_thumb: bool = pstate(ui.ctx(), "ap-hide-thumb"); + widgets::list_box(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + // Object row. + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW + 4.0), Sense::hover()); + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + let label = match (&node, super::selection_len(app)) { + (None, _) => "No Selection".to_string(), + (Some(_), n) if n > 1 => "Mixed Objects".to_string(), + (Some(n), _) => n.kind_label().to_string(), + }; + if !hide_thumb { + let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); + let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); + chip(ui, th, &fill); + } + text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true); + match &node { + Some(n) => stack(app, ui, n, sel), + None => default_stack(app, ui), + } + // Spacer row like Illustrator's empty tail. + ui.allocate_exact_size(vec2(ui.available_width(), 10.0), Sense::hover()); + }); + bottom(app, ui, node.as_ref(), sel); +} + +/// The defaults for new art when nothing is selected (read-only rows). +fn default_stack(app: &mut DrawcraftApp, ui: &mut Ui) { + let p = app.session.paint.clone(); + for (lbl, paint, w) in [("Stroke:", p.stroke, Some(p.stroke_width)), ("Fill:", p.fill, None)] { + let (r, _) = row(ui, false); + eye(ui, r, ("ap-def-eye", lbl), true, false); + text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), lbl, false); + chip(ui, Rect::from_min_size(r.left_center() + vec2(EYE_W + 76.0, -9.0), vec2(18.0, 18.0)), &paint); + if let Some(w) = w { + text(ui, r.left_center() + vec2(EYE_W + 106.0, 0.0), &format!("{w} pt"), false); + } + } + let (r, _) = row(ui, false); + eye(ui, r, "ap-def-op", true, false); + text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false); +} + +fn stack(app: &mut DrawcraftApp, ui: &mut Ui, n: &Node, sel: Sel) { + let t = Tokens::get(ui.ctx()); + let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect(); + let mut drop: Option<(usize, usize)> = None; + let mut stopped = false; + let dragging: Option = pstate(ui.ctx(), "ap-drag"); + let mut row_rects = vec![]; + for (i, it) in items { + let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); + let (r, resp) = row(ui, sel == Sel::Item(i)); + row_rects.push((i, r)); + let (visible, paint, is_stroke, width, opacity, blend, effects) = match &it { + AppearanceItem::Fill(f) => (f.visible, f.paint.clone(), false, 0.0, f.opacity, f.blend, f.effects.clone()), + AppearanceItem::Stroke(s) => (s.visible, s.paint.clone(), true, s.width, s.opacity, s.blend, s.effects.clone()), + }; + if eye(ui, r, ("ap-eye", i), visible, true) { + app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok(); + } + if chevron(ui, r, ("ap-chev", i), open) { + set_pstate(ui.ctx(), &format!("ap-open-{i}"), !open); + } + let lx = r.left() + EYE_W + 24.0; + if is_stroke { + if link(ui, pos2(lx, r.center().y), ("ap-link", i), "Stroke:") { + set_pstate(ui.ctx(), "ap-sel", Sel::Item(i)); + app.ui.open_panel = Some("stroke".into()); + } + } else { + text(ui, pos2(lx, r.center().y), "Fill:", false); + } + // Swatch with a swatches popup that sets this item's paint. + let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); + chip(ui, cr, &paint); + let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch"); + egui::Popup::menu(&cresp).show(|ui| { + swatch_picker(app, ui, i); + }); + if is_stroke { + let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); + if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(width), drawcraft_doc::Unit::Points, 56.0) { + app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); + } + } + if resp.clicked() { + set_pstate(ui.ctx(), "ap-sel", Sel::Item(i)); + } + if resp.double_clicked() { + app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); + } + if resp.drag_started() { + set_pstate(ui.ctx(), "ap-drag", Some(i)); + } + if resp.drag_stopped() { + stopped = true; + } + if open { + sub_row(app, ui, ("ap-sub-op", i), &format!("Opacity: {}", opacity_text(opacity, blend)), true, 1); + for (k, e) in effects.iter().enumerate() { + sub_row(app, ui, ("ap-sub-fx", i, k), &effect_label(&e.id), e.visible, 1); + } + } + } + if stopped + && let Some(from) = dragging + && let Some(p) = ui.ctx().pointer_interact_pos() + { + drop = Some((from, target_index(&row_rects, p.y))); + } + if dragging.is_some() && !ui.ctx().input(|i| i.pointer.any_down()) { + set_pstate::>(ui.ctx(), "ap-drag", None); + } + if let Some(p) = ui.ctx().pointer_interact_pos() + && dragging.is_some() + { + // Insertion marker. + if let Some((_, r)) = row_rects.iter().find(|(_, r)| r.y_range().contains(p.y)) { + let y = if p.y < r.center().y { r.top() } else { r.bottom() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } + } + // Object-level effects. + for (k, e) in n.appearance.effects.iter().enumerate() { + effect_row(app, ui, k, e, sel == Sel::Effect(k)); + } + // Object opacity row. + let (r, resp) = row(ui, false); + eye(ui, r, "ap-op-eye", true, false); + let lx = r.left() + EYE_W + 24.0; + if link(ui, pos2(lx, r.center().y), "ap-op-link", "Opacity:") || resp.double_clicked() { + app.ui.open_panel = Some("transparency".into()); + } + text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(n.opacity, n.blend), false); + if let Some((from, to_row)) = drop + && from != to_row + { + app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok(); + set_pstate(ui.ctx(), "ap-sel", Sel::Item(to_row)); + } +} + +/// Paint-order index a drop at `y` lands on (rows are listed top = last item). +fn target_index(rows: &[(usize, Rect)], y: f32) -> usize { + rows.iter().find(|(_, r)| r.y_range().contains(y)).map(|(i, _)| *i).unwrap_or_else(|| { + if rows.first().is_some_and(|(_, r)| y < r.top()) { rows.first().map(|r| r.0).unwrap_or(0) } else { rows.last().map(|r| r.0).unwrap_or(0) } + }) +} + +fn sub_row(_app: &mut DrawcraftApp, ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, label: &str, visible: bool, depth: usize) { + let (r, _) = row(ui, false); + eye(ui, r, (id, "eye"), visible, false); + text(ui, r.left_center() + vec2(EYE_W + 24.0 + depth as f32 * 12.0, 0.0), label, false); +} + +fn effect_row(app: &mut DrawcraftApp, ui: &mut Ui, k: usize, e: &Effect, selected: bool) { + let t = Tokens::get(ui.ctx()); + let open: bool = pstate(ui.ctx(), &format!("ap-fx-open-{k}")); + let (r, resp) = row(ui, selected); + if eye(ui, r, ("ap-fx-eye", k), e.visible, true) { + app.run("effect.setParams", json!({"index": k, "visible": !e.visible})).ok(); + } + if chevron(ui, r, ("ap-fx-chev", k), open) { + set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); + } + let lx = r.left() + EYE_W + 24.0; + if link(ui, pos2(lx, r.center().y), ("ap-fx-link", k), &effect_label(&e.id)) { + set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); + set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); + } + icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); + if resp.clicked() { + set_pstate(ui.ctx(), "ap-sel", Sel::Effect(k)); + } + if resp.double_clicked() { + set_pstate(ui.ctx(), &format!("ap-fx-open-{k}"), !open); + } + if open { + effect_editor(app, ui, k, e); + } +} + +/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); +/// edits go through `effect.setParams`. +fn effect_editor(app: &mut DrawcraftApp, ui: &mut Ui, k: usize, e: &Effect) { + let t = Tokens::get(ui.ctx()); + let defaults = drawcraft_effects::effect_catalog().into_iter().find(|c| c.id == e.id).map(|c| c.defaults).unwrap_or(Value::Null); + let mut params = defaults.as_object().cloned().unwrap_or_default(); + if let Some(cur) = e.params.as_object() { + for (key, v) in cur { + params.insert(key.clone(), v.clone()); + } + } + let mut change: Option<(String, Value)> = None; + egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 3.0; + if params.is_empty() { + widgets::dim_label(ui, "No options"); + } + for (key, v) in ¶ms { + ui.horizontal(|ui| { + let label = humanize(key); + ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate()); + match v { + Value::Bool(b) => { + if widgets::check(ui, "", *b, true) { + change = Some((key.clone(), json!(!b))); + } + } + Value::Number(n) => { + let x = n.as_f64().unwrap_or(0.0); + if let Some(nx) = widgets::plain_field(ui, ("fx", k, key.as_str()), x, "", 2, 70.0) { + change = Some((key.clone(), json!(nx))); + } + } + Value::String(s) => { + let mut buf = s.clone(); + let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0)); + if r.lost_focus() && buf != *s { + change = Some((key.clone(), json!(buf))); + } + } + other => { + widgets::dim_label(ui, &other.to_string()); + } + } + }); + } + }); + if let Some((key, v)) = change { + app.run("effect.setParams", json!({"index": k, "params": {key: v}})).ok(); + } +} + +/// "offsetX" → "Offset X". +pub fn humanize(key: &str) -> String { + let mut out = String::new(); + for (i, ch) in key.chars().enumerate() { + if i == 0 { + out.extend(ch.to_uppercase()); + } else if ch.is_uppercase() { + out.push(' '); + out.push(ch); + } else { + out.push(ch); + } + } + out +} + +/// Small swatch grid inside the appearance chip popup. +fn swatch_picker(app: &mut DrawcraftApp, ui: &mut Ui, index: usize) { + let Some(st) = app.session.active() else { return }; + let mut all: Vec<(String, Paint)> = st.doc.swatches.iter().map(|s| (s.name.clone(), s.paint.clone())).collect(); + for g in &st.doc.swatch_groups { + all.extend(g.swatches.iter().map(|s| (s.name.clone(), s.paint.clone()))); + } + ui.set_max_width(12.0 * 17.0 + 8.0); + let mut chosen = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(1.5, 1.5); + for (name, p) in &all { + let (r, resp) = ui.allocate_exact_size(vec2(15.5, 15.5), Sense::click()); + widgets::swatch_tile(ui, r, p, false, resp.hovered()); + if resp.on_hover_text(name).clicked() { + chosen = Some((name.clone(), p.is_none())); + } + } + }); + if let Some((name, none)) = chosen { + let params = if none { json!({"index": index, "none": true}) } else { json!({"index": index, "swatch": name}) }; + app.run("appearance.setItem", params).ok(); + ui.close(); + } +} + +fn bottom(app: &mut DrawcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) { + let has = node.is_some(); + widgets::bottom_bar(ui, |ui| { + if widgets::icon_button_enabled(ui, "dc-new-stroke", "Add New Stroke", false, has, 24.0).clicked() { + app.run("appearance.addStroke", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "dc-new-fill", "Add New Fill", false, has, 24.0).clicked() { + app.run("appearance.addFill", json!({})).ok(); + } + let fx = widgets::icon_button_enabled(ui, "dc-fx", "Add New Effect", false, has, 24.0); + egui::Popup::menu(&fx).show(|ui| { + fx_menu(app, ui); + }); + ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); + let basic = node.is_none_or(|n| n.appearance.is_basic()); + if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() { + app.run("appearance.clear", json!({})).ok(); + } + let can_dup = has && matches!(sel, Sel::Item(_)); + if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() + && let Sel::Item(i) = sel + { + app.run("appearance.duplicateItem", json!({"index": i})).ok(); + } + let can_del = has && sel != Sel::None; + if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() { + delete_selected(app, ui, sel); + } + let _ = basic; + }); +} + +fn delete_selected(app: &mut DrawcraftApp, ui: &Ui, sel: Sel) { + let ok = match sel { + Sel::Item(i) => app.run("appearance.removeItem", json!({"index": i})).is_ok(), + Sel::Effect(k) => app.run("effect.remove", json!({"index": k})).is_ok(), + Sel::None => false, + }; + if ok { + set_pstate(ui.ctx(), "ap-sel", Sel::None); + } +} + +/// The fx menu: effects grouped by their Effect-menu submenu; opens the effect dialog. +fn fx_menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; + for (id, label, menu) in catalog() { + let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); + match groups.iter_mut().find(|(n, _)| *n == g) { + Some((_, v)) => v.push((id.clone(), label.clone())), + None => groups.push((g, vec![(id.clone(), label.clone())])), + } + } + for (g, items) in groups { + ui.menu_button(g, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } + } + }); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let node = first_selected(app); + let has = node.is_some(); + let sel: Sel = pstate(ui.ctx(), "ap-sel"); + if menu_item(ui, "Add New Fill", has, false) { + app.run("appearance.addFill", json!({})).ok(); + } + if menu_item(ui, "Add New Stroke", has, false) { + app.run("appearance.addStroke", json!({})).ok(); + } + ui.separator(); + if menu_item(ui, "Duplicate Item", has && matches!(sel, Sel::Item(_)), false) + && let Sel::Item(i) = sel + { + app.run("appearance.duplicateItem", json!({"index": i})).ok(); + } + if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { + delete_selected(app, ui, sel); + } + if menu_item(ui, "Clear Appearance", has, false) { + app.run("appearance.clear", json!({})).ok(); + } + if menu_item(ui, "Reduce to Basic Appearance", has && !node.as_ref().is_some_and(|n| n.appearance.is_basic()), false) { + app.run("appearance.reduceToBasic", json!({})).ok(); + } + ui.separator(); + menu_item(ui, "New Art Has Basic Appearance", false, true); + let hide: bool = pstate(ui.ctx(), "ap-hide-thumb"); + if menu_item(ui, if hide { "Show Thumbnail" } else { "Hide Thumbnail" }, true, false) { + set_pstate(ui.ctx(), "ap-hide-thumb", !hide); + } + ui.separator(); + menu_item(ui, "Redefine Graphic Style", false, false); + menu_item(ui, "Show All Hidden Attributes", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opacity_labels() { + assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default"); + assert_eq!(opacity_text(0.5, BlendMode::Normal), "50%"); + assert_eq!(opacity_text(0.5, BlendMode::Multiply), "50% Multiply"); + } + + #[test] + fn labels() { + assert_eq!(humanize("offsetX"), "Offset X"); + assert_eq!(humanize("blur"), "Blur"); + assert!(!effect_label("stylize.dropShadow").contains('…')); + assert_eq!(effect_label("x.unknownThing"), "unknownThing"); + } + + #[test] + fn drop_targets() { + let r = |y: f32| Rect::from_min_size(pos2(0.0, y), vec2(100.0, ROW)); + let rows = vec![(2, r(0.0)), (1, r(30.0)), (0, r(60.0))]; + assert_eq!(target_index(&rows, 45.0), 1); + assert_eq!(target_index(&rows, -10.0), 2); + assert_eq!(target_index(&rows, 500.0), 0); + } +} diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs new file mode 100644 index 00000000..0089a7ae --- /dev/null +++ b/crates/ui-egui/src/panels/artboards.rs @@ -0,0 +1,126 @@ +//! Artboards panel: numbered list with inline rename (double-click), move up / down, new, delete. + +use egui::{Sense, Ui, pos2, vec2}; +use serde_json::json; + +use super::{pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{DrawcraftApp, icons}; + +fn selected(ui: &Ui, n: usize) -> usize { + pstate::(ui.ctx(), "ab-sel").min(n.saturating_sub(1)) +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let abs: Vec = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + if abs.is_empty() { + super::empty_state(ui, "dc-artboards", "No document", "Open a document to see its artboards."); + return; + } + let sel = selected(ui, abs.len()); + let editing: Option = pstate(ui.ctx(), "ab-edit"); + widgets::list_box(ui, |ui| { + ui.set_min_height(110.0); + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::vertical().id_salt("ab-scroll").max_height(220.0).show(ui, |ui| { + for (i, name) in abs.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if i == sel { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + ui.painter().text( + pos2(r.left() + 22.0, r.center().y), + egui::Align2::RIGHT_CENTER, + format!("{}", i + 1), + egui::FontId::proportional(12.0), + t.text_dim, + ); + let name_rect = egui::Rect::from_min_max(pos2(r.left() + 32.0, r.top() + 2.0), pos2(r.right() - 28.0, r.bottom() - 2.0)); + if editing == Some(i) { + let id = ui.id().with(("ab-name", i)); + let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); + let te = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(id).font(egui::FontId::proportional(12.0))); + if !te.has_focus() && !te.lost_focus() { + te.request_focus(); + } + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if te.lost_focus() { + set_pstate::>(ui.ctx(), "ab-edit", None); + ui.data_mut(|d| d.remove::(id)); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name { + app.run("artboard.setProps", json!({"index": i, "name": buf.trim()})).ok(); + } + } + } else { + ui.painter().text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text); + } + let opt = egui::Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)); + let oresp = ui.interact(opt, ui.id().with(("ab-opt", i)), Sense::click()); + icons::paint(ui, "dc-artboard-options", opt, if oresp.hovered() { t.text_strong } else { t.icon }); + if oresp.on_hover_text("Artboard Options: edit with the Artboard tool").clicked() { + set_pstate(ui.ctx(), "ab-sel", i); + app.select_tool("artboard"); + } + if resp.clicked() { + set_pstate(ui.ctx(), "ab-sel", i); + } + if resp.double_clicked() { + set_pstate(ui.ctx(), "ab-edit", Some(i)); + } + } + }); + }); + let n = abs.len(); + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "dc-rearrange", "Rearrange All Artboards (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 4.0 * 28.0).max(0.0)); + if widgets::icon_button_enabled(ui, "dc-arrow-up", "Move Up", false, sel > 0, 24.0).clicked() + && app.run("artboard.reorder", json!({"index": sel, "to": sel - 1})).is_ok() + { + set_pstate(ui.ctx(), "ab-sel", sel - 1); + } + if widgets::icon_button_enabled(ui, "dc-arrow-down", "Move Down", false, sel + 1 < n, 24.0).clicked() + && app.run("artboard.reorder", json!({"index": sel, "to": sel + 1})).is_ok() + { + set_pstate(ui.ctx(), "ab-sel", sel + 1); + } + if widgets::icon_button(ui, "dc-new-item", "New Artboard", false, 24.0).clicked() && app.run("artboard.new", json!({})).is_ok() { + set_pstate(ui.ctx(), "ab-sel", n); + } + if widgets::icon_button_enabled(ui, "trash-2", "Delete Artboard", false, n > 1, 24.0).clicked() { + app.run("artboard.delete", json!({"index": sel})).ok(); + } + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); + let sel = selected(ui, n); + if menu_item(ui, "New Artboard", n > 0, false) { + app.run("artboard.new", json!({})).ok(); + } + if menu_item(ui, "Duplicate Artboards", n > 0, false) { + app.run("artboard.duplicate", json!({"index": sel})).ok(); + } + if menu_item(ui, "Delete Artboards", n > 1, false) { + app.run("artboard.delete", json!({"index": sel})).ok(); + } + if menu_item(ui, "Rename", n > 0, false) { + set_pstate(ui.ctx(), "ab-edit", Some(sel)); + } + menu_item(ui, "Delete Empty Artboards", false, false); + ui.separator(); + menu_item(ui, "Convert to Artboards", false, false); + if menu_item(ui, "Artboard Options…", n > 0, false) { + app.select_tool("artboard"); + } + menu_item(ui, "Rearrange All Artboards…", false, false); + ui.separator(); + if menu_item(ui, "Fit to Artwork Bounds", n > 0, false) { + app.run("artboard.fitToArt", json!({"index": sel})).ok(); + } +} diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs new file mode 100644 index 00000000..5ec0a66b --- /dev/null +++ b/crates/ui-egui/src/panels/brushes.rs @@ -0,0 +1,106 @@ +//! Brushes panel: the brushes referenced by this document's strokes (list or thumbnails), with an +//! honest empty state until brush definitions land. + +use std::collections::BTreeSet; + +use drawcraft_doc::{AppearanceItem, Node}; +use egui::{Sense, Ui, vec2}; +use serde_json::json; + +use super::{first_selected, pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +fn collect(n: &Node, out: &mut BTreeSet) { + n.walk(&mut |c| { + for it in &c.appearance.items { + if let AppearanceItem::Stroke(s) = it + && let Some(b) = &s.brush + { + out.insert(b.clone()); + } + } + }); +} + +/// Brush names used anywhere in the document. +pub fn used_brushes(app: &DrawcraftApp) -> Vec { + let mut set = BTreeSet::new(); + if let Some(st) = app.session.active() { + for l in &st.doc.layers { + collect(l, &mut set); + } + } + set.into_iter().collect() +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let brushes = used_brushes(app); + let sel_brush = first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())); + widgets::list_box(ui, |ui| { + ui.set_min_height(110.0); + ui.set_width(ui.available_width()); + if brushes.is_empty() { + super::empty_state( + ui, + "paintbrush", + "No brushes in this document", + "Brush definitions (calligraphic, art, scatter, pattern) are on the roadmap.", + ); + return; + } + for b in &brushes { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + if sel_brush.as_deref() == Some(b.as_str()) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let stroke_r = egui::Rect::from_min_size(r.left_center() + vec2(6.0, -5.0), vec2(60.0, 10.0)); + super::stroke::paint_profile(ui, stroke_r, Some(&drawcraft_doc::WidthProfile::lens()), t.text_strong); + ui.painter().text(r.left_center() + vec2(76.0, 0.0), egui::Align2::LEFT_CENTER, b, egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + app.run("stroke.setAdvanced", json!({"brush": b})).ok(); + } + } + }); + let has_brush = sel_brush.is_some(); + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "library", "Brush Libraries (on the roadmap)", false, false, 24.0); + if widgets::icon_button_enabled(ui, "dc-remove-brush", "Remove Brush Stroke", false, has_brush, 24.0).clicked() { + app.run("stroke.setAdvanced", json!({"brush": null})).ok(); + } + widgets::icon_button_enabled(ui, "dc-options", "Options of Selected Object (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + widgets::icon_button_enabled(ui, "dc-new-item", "New Brush (on the roadmap)", false, false, 24.0); + widgets::icon_button_enabled(ui, "trash-2", "Delete Brush (on the roadmap)", false, false, 24.0); + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let has_brush = first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())).is_some(); + for l in ["New Brush…", "Duplicate Brush", "Delete Brush"] { + menu_item(ui, l, false, false); + } + if menu_item(ui, "Remove Brush Stroke", has_brush, false) { + app.run("stroke.setAdvanced", json!({"brush": null})).ok(); + } + menu_item(ui, "Select All Unused", false, false); + ui.separator(); + for l in ["Show Calligraphic Brushes", "Show Scatter Brushes", "Show Art Brushes", "Show Bristle Brushes", "Show Pattern Brushes"] { + menu_item(ui, l, false, true); + } + ui.separator(); + let list: bool = pstate(ui.ctx(), "br-list"); + if menu_item(ui, "Thumbnail View", true, !list) { + set_pstate(ui.ctx(), "br-list", false); + } + if menu_item(ui, "List View", true, list) { + set_pstate(ui.ctx(), "br-list", true); + } + ui.separator(); + menu_item(ui, "Brush Options…", false, false); + menu_item(ui, "Open Brush Library", false, false); +} diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs new file mode 100644 index 00000000..54ae1f08 --- /dev/null +++ b/crates/ui-egui/src/panels/character.rs @@ -0,0 +1,215 @@ +//! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal +//! scale, baseline shift, character rotation and the caps / underline / strikethrough toggles. + +use drawcraft_doc::{CharStyle, NodeKind, Unit}; +use egui::{Ui, vec2}; +use serde_json::{Value, json}; + +use super::{first_selected, pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +pub const SIZE_PRESETS: [f64; 16] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 36.0, 48.0, 60.0, 72.0, 96.0]; +pub const TRACKING_PRESETS: [f64; 13] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0]; +pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; + +pub(crate) fn text_style(app: &DrawcraftApp) -> Option<(CharStyle, drawcraft_doc::ParaStyle)> { + let n = first_selected(app)?; + match &n.kind { + NodeKind::Text(t) => Some((t.first_style(), t.para.clone())), + _ => None, + } +} + +fn style(app: &mut DrawcraftApp, p: Value) { + app.run("text.setStyle", p).ok(); +} +fn format(app: &mut DrawcraftApp, p: Value) { + app.run("text.setFormat", p).ok(); +} + +/// A compact labelled cell: short glyph label + spinner field. +fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tip); + add(ui); + }); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let Some((s, _)) = text_style(app) else { + super::empty_state(ui, "type", "No text selected", "Select a text object to edit its character attributes."); + return; + }; + let fams = drawcraft_text::FontDb::global().families(); + let names: Vec<&str> = fams.iter().map(String::as_str).collect(); + let w = ui.available_width(); + if let Some(i) = widgets::dropdown(ui, "ch-font", &s.font_family, &names, w - 4.0) { + style(app, json!({"font": names[i]})); + } + let styles = drawcraft_text::FontDb::global().styles(&s.font_family); + let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "ch-style", &s.font_style, &snames, w - 4.0) { + style(app, json!({"style": snames[i]})); + } + ui.add_space(2.0); + let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); + egui::Grid::new("ch-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { + cell(ui, "T", "Font Size", |ui| { + if let Some(v) = widgets::spin_field(ui, "ch-size", Some(s.size), Unit::Points, fw, 1.0, 0.1, &SIZE_PRESETS) { + style(app, json!({"size": v})); + } + }); + cell(ui, "A↕", "Leading", |ui| { + let presets: Vec = SIZE_PRESETS.iter().map(|v| v * 1.2).collect(); + if let Some(v) = widgets::spin_field(ui, "ch-lead", Some(s.effective_leading()), Unit::Points, fw, 1.0, 0.1, &presets) { + style(app, json!({"leading": v})); + } + }); + ui.end_row(); + cell(ui, "VA", "Kerning (0 = Auto)", |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-kern", s.kerning.unwrap_or(0.0), "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { + format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) }); + } + }); + cell(ui, "VA↔", "Tracking", |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-track", s.tracking, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { + style(app, json!({"tracking": v})); + } + }); + ui.end_row(); + if !pstate::(ui.ctx(), "ch-hide-options") { + cell(ui, "IT", "Vertical Scale %", |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + format(app, json!({"vScale": v})); + } + }); + cell(ui, "T↔", "Horizontal Scale %", |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + format(app, json!({"hScale": v})); + } + }); + ui.end_row(); + cell(ui, "Aª", "Baseline Shift", |ui| { + if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), Unit::Points, fw, 1.0, -1296.0, &[]) { + format(app, json!({"baselineShift": v})); + } + }); + cell(ui, "⟲T", "Character Rotation", |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-rot", s.rotation, "°", 1, fw, 15.0, -360.0, &super::transform::ANGLE_PRESETS) { + format(app, json!({"rotation": v})); + } + }); + ui.end_row(); + } + }); + if pstate::(ui.ctx(), "ch-hide-options") { + return; + } + ui.add_space(4.0); + ui.horizontal(|ui| { + for (kind, tip, key, on, enabled) in [ + (Glyph::AllCaps, "All Caps", "allCaps", s.all_caps, true), + (Glyph::SmallCaps, "Small Caps (on the roadmap)", "", false, false), + (Glyph::Super, "Superscript (on the roadmap)", "", false, false), + (Glyph::Sub, "Subscript (on the roadmap)", "", false, false), + (Glyph::Underline, "Underline", "underline", s.underline, true), + (Glyph::Strike, "Strikethrough", "strikethrough", s.strikethrough, true), + ] { + if style_toggle(ui, kind, tip, on, enabled) && !key.is_empty() { + format(app, json!({key: !on})); + } + } + }); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Glyph { + AllCaps, + SmallCaps, + Super, + Sub, + Underline, + Strike, +} + +/// A drawn "T" style toggle (All Caps, Small Caps, Superscript, Subscript, Underline, Strikethrough). +fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), if enabled { egui::Sense::click() } else { egui::Sense::hover() }); + if on { + ui.painter().rect_filled(r, 3, t.tool_active); + } else if resp.hovered() && enabled { + ui.painter().rect_filled(r, 3, t.hover); + } + let col = if enabled { t.text_strong } else { t.text_disabled }; + let big = egui::FontId::proportional(15.0); + let small = egui::FontId::proportional(10.5); + let c = r.center(); + let p = ui.painter(); + match g { + Glyph::AllCaps => { + p.text(c - vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big.clone(), col); + p.text(c + vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col); + } + Glyph::SmallCaps => { + p.text(c - vec2(4.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col); + p.text(c + vec2(6.0, 2.0), egui::Align2::CENTER_CENTER, "T", small, col); + } + Glyph::Super => { + p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col); + p.text(c + vec2(6.0, -5.0), egui::Align2::CENTER_CENTER, "1", small, col); + } + Glyph::Sub => { + p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col); + p.text(c + vec2(6.0, 5.0), egui::Align2::CENTER_CENTER, "1", small, col); + } + Glyph::Underline => { + p.text(c - vec2(0.0, 1.0), egui::Align2::CENTER_CENTER, "T", big, col); + p.line_segment([c + vec2(-5.0, 7.0), c + vec2(5.0, 7.0)], egui::Stroke::new(1.2, col)); + } + Glyph::Strike => { + p.text(c, egui::Align2::CENTER_CENTER, "T", big, col); + p.line_segment([c + vec2(-6.0, 1.0), c + vec2(6.0, 1.0)], egui::Stroke::new(1.2, col)); + } + } + let resp = resp.on_hover_text(tip); + enabled && resp.clicked() +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let st = text_style(app); + let has = st.is_some(); + let hidden: bool = pstate(ui.ctx(), "ch-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "ch-hide-options", !hidden); + } + ui.separator(); + let s = st.map(|x| x.0); + for (label, key, on) in [ + ("All Caps", "allCaps", s.as_ref().is_some_and(|s| s.all_caps)), + ("Underline", "underline", s.as_ref().is_some_and(|s| s.underline)), + ("Strikethrough", "strikethrough", s.as_ref().is_some_and(|s| s.strikethrough)), + ] { + if menu_item(ui, label, has, on) { + format(app, json!({key: !on})); + } + } + for l in ["Small Caps", "Superscript", "Subscript"] { + menu_item(ui, l, false, false); + } + ui.separator(); + for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] { + menu_item(ui, l, false, l == "Fractional Widths"); + } + ui.separator(); + if menu_item(ui, "Reset Panel", has, false) { + style(app, json!({"tracking": 0, "leading": "auto"})); + format( + app, + json!({"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false}), + ); + } +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs new file mode 100644 index 00000000..0f37bf0c --- /dev/null +++ b/crates/ui-egui/src/panels/color.rs @@ -0,0 +1,489 @@ +//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks, +//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy. +//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop. + +use drawcraft_color::{Color, Paint}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use super::{active_paint, color_json, live_run, paint_target, pstate, push_recent, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, Live, menu_item}; +use crate::{DrawcraftApp, icons}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Mode { + Grayscale, + #[default] + Rgb, + Hsb, + Cmyk, + WebSafe, +} + +impl Mode { + pub const ALL: [(Mode, &'static str); 5] = + [(Mode::Grayscale, "Grayscale"), (Mode::Rgb, "RGB"), (Mode::Hsb, "HSB"), (Mode::Cmyk, "CMYK"), (Mode::WebSafe, "Web Safe RGB")]; + + pub fn labels(self) -> &'static [&'static str] { + match self { + Mode::Grayscale => &["K"], + Mode::Rgb | Mode::WebSafe => &["R", "G", "B"], + Mode::Hsb => &["H", "S", "B"], + Mode::Cmyk => &["C", "M", "Y", "K"], + } + } + /// Maximum of each displayed component (minimum is 0). + pub fn max(self, i: usize) -> f32 { + match self { + Mode::Rgb | Mode::WebSafe => 255.0, + Mode::Hsb if i == 0 => 360.0, + _ => 100.0, + } + } + pub fn suffix(self, i: usize) -> &'static str { + match self { + Mode::Rgb | Mode::WebSafe => "", + Mode::Hsb if i == 0 => "°", + _ => "%", + } + } + /// The mode a colour was authored in. + pub fn of(c: &Color) -> Mode { + match c { + Color::Rgb { .. } => Mode::Rgb, + Color::Cmyk { .. } => Mode::Cmyk, + Color::Gray { .. } => Mode::Grayscale, + } + } +} + +/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %). +pub fn components(mode: Mode, c: &Color) -> Vec { + match mode { + Mode::Grayscale => { + let k = match *c { + Color::Gray { k } => k, + _ => { + let [r, g, b] = c.to_rgb(); + 1.0 - (0.3 * r + 0.59 * g + 0.11 * b) + } + }; + vec![k * 100.0] + } + Mode::Rgb | Mode::WebSafe => c.to_rgb().iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(), + Mode::Hsb => { + let [h, s, b] = c.to_hsb(); + vec![h, s * 100.0, b * 100.0] + } + Mode::Cmyk => c.to_cmyk().iter().map(|v| v * 100.0).collect(), + } +} + +/// Build a colour from displayed components (inverse of [`components`]). +pub fn from_components(mode: Mode, v: &[f32]) -> Color { + let g = |i: usize| v.get(i).copied().unwrap_or(0.0); + match mode { + Mode::Grayscale => Color::gray((g(0) / 100.0).clamp(0.0, 1.0)), + Mode::Rgb => Color::rgb((g(0) / 255.0).clamp(0.0, 1.0), (g(1) / 255.0).clamp(0.0, 1.0), (g(2) / 255.0).clamp(0.0, 1.0)), + Mode::WebSafe => web_safe(&Color::rgb(g(0) / 255.0, g(1) / 255.0, g(2) / 255.0)), + Mode::Hsb => Color::from_hsb(g(0).rem_euclid(360.0), (g(1) / 100.0).clamp(0.0, 1.0), (g(2) / 100.0).clamp(0.0, 1.0)), + Mode::Cmyk => Color::cmyk( + (g(0) / 100.0).clamp(0.0, 1.0), + (g(1) / 100.0).clamp(0.0, 1.0), + (g(2) / 100.0).clamp(0.0, 1.0), + (g(3) / 100.0).clamp(0.0, 1.0), + ), + } +} + +/// The nearest web-safe colour (each channel a multiple of 0x33). +pub fn web_safe(c: &Color) -> Color { + let q = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0; + let [r, g, b] = c.to_rgb(); + Color::rgb(q(r), q(g), q(b)) +} + +pub fn is_web_safe(c: &Color) -> bool { + web_safe(c).to_hex() == c.to_hex() +} + +/// Track colour of slider `i` at position `t` (0..1) with the other components fixed — +/// Illustrator's dynamic colour sliders. +pub fn track_color(mode: Mode, comps: &[f32], i: usize, t: f32) -> Color { + let mut v = comps.to_vec(); + if i < v.len() { + v[i] = t * mode.max(i); + } + // HSB hue track at full saturation/brightness reads better when S or B are 0. + let m = if mode == Mode::WebSafe { Mode::Rgb } else { mode }; + from_components(m, &v) +} + +/// Colour at a point of the spectrum ramp: hue across; white at the top, full colour in the +/// middle, black at the bottom. In Grayscale mode the ramp is a grey ramp. +pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { + let x = x.clamp(0.0, 1.0); + let y = y.clamp(0.0, 1.0); + if mode == Mode::Grayscale { + return Color::gray(x); + } + let h = x * 360.0; + let c = if y < 0.5 { Color::from_hsb(h, y * 2.0, 1.0) } else { Color::from_hsb(h, 1.0, 1.0 - (y - 0.5) * 2.0) }; + match mode { + Mode::Cmyk => { + let [c0, m, yy, k] = c.to_cmyk(); + Color::cmyk(c0, m, yy, k) + } + Mode::WebSafe => web_safe(&c), + _ => c, + } +} + +/// Parse a hex field (`E67828`, `#e67828`, `fff`). +pub fn parse_hex(s: &str) -> Option { + let s = s.trim().trim_start_matches('#'); + if !(s.len() == 3 || s.len() == 6) || !s.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + Color::from_hex(s) +} + +fn to32(c: &Color) -> Color32 { + super::c32(c) +} + +/// What the panel is editing: the active proxy's solid colour, or a selected gradient stop. +enum Target { + Paint(Option), + Stop { paint: Paint, index: usize, color: Color }, +} + +fn target(app: &DrawcraftApp, ui: &Ui) -> Target { + let p = active_paint(app); + match &p { + Paint::Gradient(g) => { + let i = super::gradient::selected_stop(ui.ctx()).unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); + let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK); + Target::Stop { paint: p.clone(), index: i, color } + } + Paint::Solid { color, .. } => Target::Paint(Some(*color)), + _ => Target::Paint(None), + } +} + +fn apply(app: &mut DrawcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live) { + match tgt { + Target::Paint(_) => { + let cmd = paint_target(app); + live_run(app, "Color", cmd, json!({"color": color_json(&c)}), phase); + } + Target::Stop { paint, index, .. } => { + if let Paint::Gradient(g) = paint { + let mut stops = g.gradient.stops.clone(); + if let Some(s) = stops.get_mut(*index) { + s.color = c; + } + let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)}); + live_run(app, "Gradient", "paint.editGradient", params, phase); + } + } + } + if phase == Live::Released { + push_recent(ui.ctx(), c); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let tgt = target(app, ui); + let color = match &tgt { + Target::Paint(c) => *c, + Target::Stop { color, .. } => Some(*color), + }; + let stored: Option = pstate(ui.ctx(), "color-mode"); + let mode = stored.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default()); + let show_options: bool = !pstate::(ui.ctx(), "color-hide-options"); + // Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.). + let key = color.map(|c| (c.to_hex(), mode as u8)); + let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { + (Some(_), Some((k, v))) if Some(&k) == key.as_ref() => v, + (Some(c), _) => components(mode, &c), + (None, _) => vec![0.0; mode.labels().len()], + }; + if show_options { + super::recent_colors_row(app, ui); + widgets::divider(ui); + } + let mut new: Option<(Color, Live, Vec)> = None; + ui.horizontal(|ui| { + // Left column: proxy + web-safe warning. + ui.vertical(|ui| { + ui.set_width(44.0); + super::proxy(app, ui, 40.0); + if let Some(c) = color + && mode != Mode::WebSafe + && mode != Mode::Grayscale + && !is_web_safe(&c) + { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + icons::icon(ui, "dc-cube", 16.0, t.icon).on_hover_text("Out of Web Color Warning"); + let ws = web_safe(&c); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(ws), false, resp.hovered()); + if resp.on_hover_text(format!("Click to correct to the closest web color ({})", ws.to_hex())).clicked() { + new = Some((ws, Live::Released, components(mode, &ws))); + } + }); + } + }); + // Sliders. + ui.vertical(|ui| { + let labels = mode.labels(); + let field_w = 48.0; + let slider_w = (ui.available_width() - field_w - 24.0).max(60.0); + for (i, lbl) in labels.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); + let max = mode.max(i); + let v = comps.get(i).copied().unwrap_or(0.0); + let enabled = color.is_some(); + let track = |x: f32| to32(&track_color(mode, &comps, i, x)); + let grey = |_x: f32| t.input; + let (nv, phase) = if enabled { + widgets::color_slider(ui, ("color-slider", i), v / max, slider_w, &track) + } else { + widgets::color_slider(ui, ("color-slider", i), 0.0, slider_w, &grey) + }; + if let Some(nv) = nv + && enabled + { + let mut c2 = comps.clone(); + c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + new = Some((from_components(mode, &c2), phase, c2)); + } + let dec = 0; + if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), dec, field_w) + && enabled + { + let mut c2 = comps.clone(); + c2[i] = (fv as f32).clamp(0.0, max); + new = Some((from_components(mode, &c2), Live::Released, c2)); + } + }); + } + }); + }); + ui.add_space(4.0); + // None / Black / White chips and the hex field. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (p, tip) in [(Paint::None, "None"), (Paint::solid(Color::BLACK), "Black"), (Paint::solid(Color::WHITE), "White")] { + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + widgets::swatch_tile(ui, r, &p, false, resp.hovered()); + if resp.on_hover_text(tip).clicked() { + match p.color() { + Some(c) => new = Some((c, Live::Released, components(mode, &c))), + None => { + app.run(paint_target(app), json!({"none": true})).ok(); + } + } + } + } + ui.spacing_mut().item_spacing.x = 6.0; + if matches!(mode, Mode::Rgb | Mode::WebSafe | Mode::Hsb) { + ui.add_space((ui.available_width() - 96.0).max(4.0)); + ui.label(egui::RichText::new("#").size(15.0).color(t.text)); + let hex = color.map(|c| c.to_hex().trim_start_matches('#').to_uppercase()).unwrap_or_default(); + let id = ui.id().with("hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.clone()) } else { hex.clone() }; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(62.0) + .char_limit(7) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if resp.lost_focus() + && buf != hex + && let Some(c) = parse_hex(&buf) + { + new = Some((c, Live::Released, components(mode, &c))); + } + } + }); + ui.add_space(4.0); + // Spectrum ramp. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click_and_drag()); + let cols = 72; + let rows = 12; + let (cw, rh) = (r.width() / cols as f32, r.height() / rows as f32); + let mut mesh = egui::Mesh::default(); + for j in 0..=rows { + for i in 0..=cols { + let c = spectrum_at(mode, i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(pos2(r.left() + i as f32 * cw, r.top() + j as f32 * rh), to32(&c)); + } + } + for j in 0..rows { + for i in 0..cols { + let a = (j * (cols + 1) + i) as u32; + let b = a + 1; + let c = a + (cols + 1) as u32; + let d = c + 1; + mesh.add_triangle(a, b, c); + mesh.add_triangle(b, d, c); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + if (resp.dragged() || resp.clicked() || resp.drag_stopped()) + && let Some(p) = resp.interact_pointer_pos() + { + let c = spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()); + let phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }; + new = Some((c, phase, components(mode, &c))); + } + if resp.hovered() + && let Some(p) = resp.hover_pos() + { + ui.painter().rect_stroke(Rect::from_center_size(p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + } + if let Some((c, phase, comps2)) = new { + let key = (c.to_hex(), mode as u8); + set_pstate(ui.ctx(), "color-comps", Some((key, comps2))); + apply(app, ui, &tgt, c, phase); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let hidden: bool = pstate(ui.ctx(), "color-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "color-hide-options", !hidden); + } + ui.separator(); + let tgt = target(app, ui); + let color = match &tgt { + Target::Paint(c) => *c, + Target::Stop { color, .. } => Some(*color), + }; + let cur: Option = pstate(ui.ctx(), "color-mode"); + let cur = cur.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default()); + for (m, label) in Mode::ALL { + if menu_item(ui, label, true, m == cur) { + set_pstate(ui.ctx(), "color-mode", Some(m)); + // Converting the colour's model follows Illustrator (picking CMYK converts the colour). + if let Some(c) = color { + let conv = from_components(if m == Mode::Hsb { Mode::Rgb } else { m }, &components(if m == Mode::Hsb { Mode::Rgb } else { m }, &c)); + if conv != c { + apply(app, ui, &tgt, conv, Live::Released); + } + } + } + } + ui.separator(); + if menu_item(ui, "Invert", color.is_some(), false) + && let Some(c) = color + { + apply(app, ui, &tgt, c.invert(), Live::Released); + } + if menu_item(ui, "Complement", color.is_some(), false) + && let Some(c) = color + { + apply(app, ui, &tgt, c.complement(), Live::Released); + } + ui.separator(); + if menu_item(ui, "Create New Swatch…", color.is_some(), false) + && let Some(c) = color + { + app.run("swatch.new", json!({"color": color_json(&c)})).ok(); + } + if menu_item(ui, "Copy Color Value (Hex)", color.is_some(), false) + && let Some(c) = color + { + ui.ctx().copy_text(c.to_hex()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_roundtrip() { + let c = Color::rgb8(230, 120, 40); + let v = components(Mode::Rgb, &c); + assert_eq!(v.iter().map(|x| x.round() as i32).collect::>(), vec![230, 120, 40]); + assert_eq!(from_components(Mode::Rgb, &v).to_hex(), c.to_hex()); + } + + #[test] + fn hsb_roundtrip_and_ranges() { + let c = Color::rgb8(230, 120, 40); + let v = components(Mode::Hsb, &c); + assert!(v[0] > 0.0 && v[0] < 360.0 && v[1] <= 100.0 && v[2] <= 100.0); + assert_eq!(from_components(Mode::Hsb, &v).to_hex(), c.to_hex()); + assert_eq!(Mode::Hsb.max(0), 360.0); + assert_eq!(Mode::Hsb.suffix(0), "°"); + } + + #[test] + fn cmyk_and_gray_keep_model() { + let c = from_components(Mode::Cmyk, &[10.0, 20.0, 30.0, 40.0]); + assert!(matches!(c, Color::Cmyk { .. })); + let v = components(Mode::Cmyk, &c); + assert!((v[3] - 40.0).abs() < 1e-3); + let g = from_components(Mode::Grayscale, &[25.0]); + assert_eq!(g, Color::gray(0.25)); + assert!((components(Mode::Grayscale, &g)[0] - 25.0).abs() < 1e-4); + // Grayscale view of an RGB colour uses luminance. + assert!((components(Mode::Grayscale, &Color::WHITE)[0]).abs() < 1e-4); + } + + #[test] + fn web_safe_snaps() { + let c = Color::rgb8(230, 120, 40); + let w = web_safe(&c); + assert_eq!(w.to_hex(), "#ff6633"); + assert!(is_web_safe(&w)); + assert!(!is_web_safe(&c)); + assert_eq!(from_components(Mode::WebSafe, &[230.0, 120.0, 40.0]).to_hex(), "#ff6633"); + } + + #[test] + fn slider_tracks_vary_one_component() { + let comps = [230.0, 120.0, 40.0]; + let a = track_color(Mode::Rgb, &comps, 0, 0.0).to_rgba8(1.0); + let b = track_color(Mode::Rgb, &comps, 0, 1.0).to_rgba8(1.0); + assert_eq!((a[0], a[1], a[2]), (0, 120, 40)); + assert_eq!((b[0], b[1], b[2]), (255, 120, 40)); + let h = track_color(Mode::Hsb, &[0.0, 100.0, 100.0], 0, 1.0 / 3.0).to_hex(); + assert_eq!(h, "#00ff00"); + } + + #[test] + fn spectrum_and_hex() { + assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.0).to_hex(), "#ffffff"); + assert_eq!(spectrum_at(Mode::Rgb, 0.0, 0.5).to_hex(), "#ff0000"); + assert_eq!(spectrum_at(Mode::Rgb, 0.3, 1.0).to_hex(), "#000000"); + assert_eq!(spectrum_at(Mode::Grayscale, 1.0, 0.3), Color::gray(1.0)); + assert_eq!(parse_hex("E67828").unwrap().to_hex(), "#e67828"); + assert_eq!(parse_hex("#fff").unwrap().to_hex(), "#ffffff"); + assert!(parse_hex("zz").is_none()); + assert!(parse_hex("12345").is_none()); + } +} diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs new file mode 100644 index 00000000..38a7a271 --- /dev/null +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -0,0 +1,160 @@ +//! Color Guide panel: base colour, harmony-rule dropdown with the harmony strip, and a variation +//! grid (tints/shades, warm/cool or vivid/muted) whose cells apply to the active proxy. + +use drawcraft_color::harmony::Harmony; +use drawcraft_color::{Color, Paint}; +use egui::{Rect, Sense, Ui, vec2}; +use serde_json::json; + +use super::{active_paint, color_json, paint_target, pstate, push_recent, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Variation { + #[default] + TintsShades, + WarmCool, + VividMuted, +} + +/// Rows above/below the base row in the variation grid. +pub const STEPS: i32 = 3; + +/// Variation of `c` at `step` (−STEPS..=STEPS; 0 = the colour itself). +pub fn variation(c: &Color, mode: Variation, step: i32) -> Color { + if step == 0 { + return *c; + } + let f = step.unsigned_abs() as f32 / (STEPS as f32 + 1.0); + match mode { + Variation::TintsShades => { + if step < 0 { + c.lerp(&Color::BLACK, f) + } else { + c.lerp(&Color::WHITE, f) + } + } + Variation::WarmCool => { + let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) }; + c.lerp(&target, f * 0.6) + } + Variation::VividMuted => { + let [h, s, b] = c.to_hsb(); + let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f }; + Color::from_hsb(h, s2.clamp(0.0, 1.0), b) + } + } +} + +fn harmony(ui: &Ui) -> Harmony { + let i: usize = pstate(ui.ctx(), "cg-harmony"); + Harmony::ALL[i.min(Harmony::ALL.len() - 1)] +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let base = match active_paint(app) { + Paint::Solid { color, .. } => color, + _ => pstate::>(ui.ctx(), "cg-base").unwrap_or(Color::rgb8(230, 120, 40)), + }; + let h = harmony(ui); + let colors = h.apply(base); + let mode: Variation = pstate(ui.ctx(), "cg-mode"); + let mut chosen: Option = None; + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(base), false, resp.hovered()); + if resp.on_hover_text("Set base color to the current color").clicked() { + set_pstate(ui.ctx(), "cg-base", Some(base)); + } + let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "cg-rule", h.label(), &labels, ui.available_width() - 4.0) { + set_pstate(ui.ctx(), "cg-harmony", i); + } + }); + // Harmony strip. + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); + let w = strip.width() / colors.len() as f32; + for (i, c) in colors.iter().enumerate() { + let r = Rect::from_min_size(strip.min + vec2(i as f32 * w, 0.0), vec2(w, strip.height())); + let resp = ui.interact(r, ui.id().with(("cg-h", i)), Sense::click()); + ui.painter().rect_filled(r, 0.0, super::c32(c)); + if resp.on_hover_text(c.to_hex()).clicked() { + chosen = Some(*c); + } + } + ui.add_space(6.0); + // Variation grid: columns = harmony colours, rows = variations. + let cols = colors.len().max(1) as f32; + let cell_w = (ui.available_width() / cols).floor(); + for step in -STEPS..=STEPS { + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + for (i, c) in colors.iter().enumerate() { + let v = variation(c, mode, step); + let r = Rect::from_min_size(row.min + vec2(i as f32 * cell_w, 0.0), vec2(cell_w - 1.0, 17.0)); + let resp = ui.interact(r, ui.id().with(("cg-v", step, i)), Sense::click()); + ui.painter().rect_filled(r, 0.0, super::c32(&v)); + if step == 0 { + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.text_strong), egui::StrokeKind::Inside); + } + if resp.on_hover_text(v.to_hex()).clicked() { + chosen = Some(v); + } + } + } + let label = match mode { + Variation::TintsShades => ("Shades", "Tints"), + Variation::WarmCool => ("Cool", "Warm"), + Variation::VividMuted => ("Muted", "Vivid"), + }; + ui.horizontal(|ui| { + widgets::dim_label(ui, label.0); + ui.add_space((ui.available_width() - 40.0).max(0.0)); + widgets::dim_label(ui, label.1); + }); + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "library", "Limit to Swatch Library (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (Recolor Artwork is on the roadmap)", false, false, 24.0); + if widgets::icon_button(ui, "dc-folder", "Save color group to Swatch panel", false, 24.0).clicked() { + let cs: Vec<_> = colors.iter().map(color_json).collect(); + app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs})).ok(); + } + }); + if let Some(c) = chosen + && app.run(paint_target(app), json!({"color": color_json(&c)})).is_ok() + { + push_recent(ui.ctx(), c); + } +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + let mode: Variation = pstate(ui.ctx(), "cg-mode"); + for (m, l) in + [(Variation::TintsShades, "Show Tints/Shades"), (Variation::WarmCool, "Show Warm/Cool"), (Variation::VividMuted, "Show Vivid/Muted")] + { + if menu_item(ui, l, true, m == mode) { + set_pstate(ui.ctx(), "cg-mode", m); + } + } + ui.separator(); + menu_item(ui, "Color Guide Options…", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variations() { + let c = Color::rgb(0.5, 0.5, 0.5); + assert_eq!(variation(&c, Variation::TintsShades, 0), c); + assert!(variation(&c, Variation::TintsShades, STEPS).to_rgb()[0] > 0.5); + assert!(variation(&c, Variation::TintsShades, -STEPS).to_rgb()[0] < 0.5); + let red = Color::rgb(1.0, 0.2, 0.2); + assert!(variation(&red, Variation::VividMuted, -2).to_hsb()[1] < red.to_hsb()[1]); + let warm = variation(&c, Variation::WarmCool, 2).to_rgb(); + assert!(warm[0] > warm[2]); + } +} diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs new file mode 100644 index 00000000..a4801bc2 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient.rs @@ -0,0 +1,475 @@ +//! Gradient panel: type buttons, angle / aspect ratio / reverse, the gradient slider with draggable +//! stops and midpoint diamonds (click below the ramp adds a stop, drag a stop off to remove it), +//! and stop Opacity / Location fields. Stop colours are edited with the Color panel. + +use drawcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use super::{active_paint, color_json, live_run, pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, Live, menu_item}; +use crate::{DrawcraftApp, icons}; + +/// Minimum number of stops (Illustrator won't delete below two). +pub const MIN_STOPS: usize = 2; +/// Dragging a stop this far below the ramp removes it. +pub const REMOVE_DISTANCE: f32 = 28.0; + +/// The Gradient panel's selected stop (shared with the Color panel). +pub fn selected_stop(ctx: &egui::Context) -> Option { + pstate::>(ctx, "grad-stop") +} +fn select_stop(ctx: &egui::Context, i: Option) { + set_pstate(ctx, "grad-stop", i); +} + +// ---------- pure stop math (unit-tested) ---------- + +/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and +/// the new stop's index. +pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec, usize) { + let offset = offset.clamp(0.0, 1.0); + let (color, opacity) = g.sample(offset); + let mut stops = g.stops.clone(); + let idx = stops.iter().position(|s| s.offset > offset).unwrap_or(stops.len()); + stops.insert(idx, GradientStop { offset, color, opacity, midpoint: 0.5 }); + (stops, idx) +} + +/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`]. +pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option> { + if stops.len() <= MIN_STOPS || i >= stops.len() { + return None; + } + let mut v = stops.to_vec(); + v.remove(i); + Some(v) +} + +/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index. +pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec, usize) { + let mut v = stops.to_vec(); + if i >= v.len() { + return (v, i); + } + let mut s = v.remove(i); + s.offset = offset.clamp(0.0, 1.0); + let idx = v.iter().position(|o| o.offset > s.offset).unwrap_or(v.len()); + v.insert(idx, s); + (v, idx) +} + +/// Set the midpoint between stop `i` and `i + 1` (clamped to Illustrator's 13–87 %). +pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec { + let mut v = stops.to_vec(); + if let Some(s) = v.get_mut(i) { + s.midpoint = m.clamp(0.13, 0.87); + } + v +} + +/// Offset (0..1) of an x position on a ramp spanning `left..left + width`. +pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 { + ((x - left) / width.max(1.0)).clamp(0.0, 1.0) +} + +/// Absolute position (0..1) of the midpoint diamond after stop `i`. +pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option { + let (a, b) = (stops.get(i)?, stops.get(i + 1)?); + Some(a.offset + (b.offset - a.offset) * a.midpoint) +} + +/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position. +pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option { + let (a, b) = (stops.get(i)?, stops.get(i + 1)?); + let span = (b.offset - a.offset).max(1e-6); + Some(((pos - a.offset) / span).clamp(0.13, 0.87)) +} + +/// Stops as `paint.editGradient` JSON. +pub fn stops_json(stops: &[GradientStop]) -> Value { + Value::Array( + stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(), + ) +} + +// ---------- UI ---------- + +fn current(app: &DrawcraftApp) -> Option { + match active_paint(app) { + Paint::Gradient(g) => Some(*g), + _ => None, + } +} + +fn edit(app: &mut DrawcraftApp, params: Value, phase: Live) { + let mut p = params; + p["stroke"] = json!(!app.session.fill_active); + live_run(app, "Gradient", "paint.editGradient", p, phase); +} + +/// Drag state of the ramp: which handle is being dragged. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Drag { + #[default] + None, + Stop(usize), + Mid(usize), +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let gp = current(app); + let fallback = GradientPaint::new(Gradient::default()); + let g = gp.clone().unwrap_or(fallback); + let is_grad = gp.is_some(); + let kind = g.gradient.kind; + // Header: gradient swatch + type buttons + Edit Gradient. + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); + widgets::paint_chip(ui, r, &Paint::Gradient(Box::new(g.clone()))); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + if resp.on_hover_text("Gradient Fill — click to apply").clicked() && !is_grad { + edit(app, json!({}), Live::Released); + } + ui.vertical(|ui| { + ui.horizontal(|ui| { + widgets::dim_label(ui, "Type:"); + for (k, icon, tip) in [ + (GradientKind::Linear, "dc-grad-linear", "Linear Gradient"), + (GradientKind::Radial, "dc-grad-radial", "Radial Gradient"), + (GradientKind::Freeform, "dc-grad-freeform", "Freeform Gradient"), + ] { + if widgets::icon_button(ui, icon, tip, is_grad && kind == k, 24.0).clicked() { + edit(app, json!({"kind": k.label().to_lowercase()}), Live::Released); + } + } + }); + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() { + app.select_tool("gradient"); + } + }); + }); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Stroke:"); + for (icon, tip) in [ + ("dc-stroke-center", "Apply gradient within stroke"), + ("dc-stroke-outside", "Apply gradient along stroke (on the roadmap)"), + ("dc-stroke-inside", "Apply gradient across stroke (on the roadmap)"), + ] { + widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); + } + }); + ui.horizontal(|ui| { + icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle"); + if let Some(a) = widgets::plain_field(ui, "grad-angle", g.geom.map(|x| x.angle_deg()).unwrap_or(g.angle), "°", 1, 64.0) { + edit(app, json!({"angle": a}), Live::Released); + } + ui.add_space(4.0); + let radial = kind == GradientKind::Radial; + icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio"); + let asp = g.geom.map(|x| x.aspect * 100.0).unwrap_or(100.0); + if radial { + if let Some(a) = widgets::plain_field(ui, "grad-aspect", asp, "%", 1, 60.0) { + edit(app, json!({"aspect": a}), Live::Released); + } + } else { + ui.add_enabled(false, egui::Label::new(egui::RichText::new(format!("{asp:.0}%")).color(t.text_disabled))); + } + if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() { + edit(app, json!({"reverse": true}), Live::Released); + } + }); + ui.add_space(6.0); + ramp(app, ui, &g.gradient, is_grad); + ui.add_space(4.0); + // Stop fields. + let sel = selected_stop(ui.ctx()).filter(|i| *i < g.gradient.stops.len()); + ui.horizontal(|ui| { + let enabled = is_grad && sel.is_some(); + let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied(); + widgets::dim_label(ui, "Opacity:"); + let op = stop.map(|s| s.opacity as f64 * 100.0).unwrap_or(100.0); + if let Some(v) = widgets::plain_field(ui, "grad-op", op, "%", 0, 54.0) + && enabled + && let Some(i) = sel + { + let mut stops = g.gradient.stops.clone(); + stops[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; + edit(app, json!({"stops": stops_json(&stops)}), Live::Released); + } + widgets::dim_label(ui, "Location:"); + let loc = stop.map(|s| s.offset as f64 * 100.0).unwrap_or(0.0); + if let Some(v) = widgets::plain_field(ui, "grad-loc", loc, "%", 1, 54.0) + && enabled + && let Some(i) = sel + { + let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32); + select_stop(ui.ctx(), Some(ni)); + edit(app, json!({"stops": stops_json(&stops)}), Live::Released); + } + let can_del = enabled && g.gradient.stops.len() > MIN_STOPS; + if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked() + && let Some(i) = sel + && let Some(stops) = remove_stop(&g.gradient.stops, i) + { + select_stop(ui.ctx(), Some(i.min(stops.len() - 1))); + edit(app, json!({"stops": stops_json(&stops)}), Live::Released); + } + }); + if let Some(i) = sel + && is_grad + { + let c = g.gradient.stops[i].color; + ui.horizontal(|ui| { + widgets::dim_label(ui, "Stop color:"); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(c), false, resp.hovered()); + widgets::dim_label(ui, &c.to_hex().to_uppercase()); + if resp.on_hover_text("Edit the stop in the Color panel").clicked() { + app.ui.open_panel = Some("color".into()); + } + }); + } + if !is_grad { + widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient."); + } +} + +/// The gradient slider: ramp, stops below, midpoint diamonds above. +fn ramp(app: &mut DrawcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (area, _) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover()); + let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0)); + // Checkerboard under the ramp (opacity). + let cell = 6.0; + let mut y = bar.top(); + let mut row = 0; + while y < bar.bottom() { + let mut x = bar.left(); + let mut col = row % 2; + while x < bar.right() { + let r = Rect::from_min_max(pos2(x, y), pos2((x + cell).min(bar.right()), (y + cell).min(bar.bottom()))); + ui.painter().rect_filled(r, 0.0, if col % 2 == 0 { Color32::WHITE } else { Color32::from_gray(204) }); + x += cell; + col += 1; + } + y += cell; + row += 1; + } + let n = (bar.width() / 2.0) as usize; + for i in 0..n { + let tt = (i as f32 + 0.5) / n as f32; + let (c, o) = g.sample(tt); + let [r, gg, b, a] = c.to_rgba8(o); + let rr = + Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height())); + ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a)); + } + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let x_of = |o: f32| bar.left() + o * bar.width(); + let sel = selected_stop(ui.ctx()); + let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); + let stops = &g.stops; + // While dragging, edits are computed against the stops as they were when the drag began (the + // document shows the preview). + let origin: Vec = if drag == Drag::None { stops.clone() } else { pstate::>(ui.ctx(), "grad-origin") }; + let origin = if origin.len() == stops.len() { origin } else { stops.clone() }; + let mut changed: Option<(Vec, Live)> = None; + // Midpoint diamonds. + for i in 0..stops.len().saturating_sub(1) { + let Some(p) = midpoint_pos(stops, i) else { continue }; + let c = pos2(x_of(p), bar.top() - 5.0); + let rect = Rect::from_center_size(c, vec2(10.0, 10.0)); + let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag()); + let active = drag == Drag::Mid(i) || resp.hovered(); + let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; + ui.painter().add(egui::Shape::convex_polygon(pts, if active { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); + if !is_grad { + continue; + } + if resp.drag_started() { + drag = Drag::Mid(i); + set_pstate(ui.ctx(), "grad-origin", stops.clone()); + } + if (resp.dragged() || resp.drag_stopped()) + && let Some(pp) = resp.interact_pointer_pos() + && let Some(m) = midpoint_from_pos(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())) + { + let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging }; + changed = Some((set_midpoint(&origin, i, m), phase)); + if resp.drag_stopped() { + drag = Drag::None; + } + } + } + // Stops (house-shaped markers under the ramp). + let mut hit_stop = false; + for (i, s) in stops.iter().enumerate() { + let x = x_of(s.offset); + let top = bar.bottom() + 2.0; + let marker = Rect::from_min_size(pos2(x - 6.0, top), vec2(12.0, 16.0)); + let resp = ui.interact(marker, ui.id().with(("grad-stop", i)), Sense::click_and_drag()); + hit_stop |= resp.hovered() || resp.dragged(); + let dragging_this = drag == Drag::Stop(i); + let off = dragging_this && resp.interact_pointer_pos().is_some_and(|p| p.y > bar.bottom() + REMOVE_DISTANCE) && origin.len() > MIN_STOPS; + let outline = if sel == Some(i) { t.accent } else { t.border }; + let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)]; + if !off { + ui.painter().add(egui::Shape::convex_polygon(body, if sel == Some(i) { t.text_strong } else { t.icon }, Stroke::new(1.0, outline))); + let chip = Rect::from_min_size(pos2(x - 4.0, top + 6.0), vec2(8.0, 7.0)); + ui.painter().rect_filled(chip, 0.0, super::c32(&s.color)); + } + if !is_grad { + continue; + } + if resp.clicked() || resp.drag_started() { + select_stop(ui.ctx(), Some(i)); + } + if resp.drag_started() { + drag = Drag::Stop(i); + set_pstate(ui.ctx(), "grad-origin", stops.clone()); + } + if (resp.dragged() || resp.drag_stopped()) + && dragging_this + && let Some(pp) = resp.interact_pointer_pos() + { + let removing = pp.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS; + if resp.drag_stopped() { + drag = Drag::None; + if removing { + if let Some(v) = remove_stop(&origin, i) { + select_stop(ui.ctx(), Some(i.min(v.len() - 1))); + changed = Some((v, Live::Released)); + } + } else { + let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); + select_stop(ui.ctx(), Some(ni)); + changed = Some((v, Live::Released)); + } + } else if removing { + // Dragged off: show the gradient without moving the stop until release. + changed = Some((origin.clone(), Live::Dragging)); + } else { + let (v, _) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); + changed = Some((v, Live::Dragging)); + } + } + } + // Click below the ramp (not on a stop) adds a stop; clicking the ramp applies a gradient. + let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom())); + let resp = ui.interact(below, ui.id().with("grad-add"), Sense::click()); + if resp.hovered() && !hit_stop && is_grad { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); + } + if resp.clicked() + && !hit_stop + && let Some(p) = resp.interact_pointer_pos() + { + if is_grad { + let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width())); + select_stop(ui.ctx(), Some(i)); + changed = Some((v, Live::Released)); + } else { + edit(app, json!({}), Live::Released); + } + } + let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click()); + if bar_resp.clicked() && !is_grad { + edit(app, json!({}), Live::Released); + } + set_pstate(ui.ctx(), "grad-drag", drag); + if let Some((v, phase)) = changed { + edit(app, json!({"stops": stops_json(&v)}), phase); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let g = current(app); + menu_item(ui, "Hide Options", false, false); + if menu_item(ui, "Add to Swatches", g.is_some(), false) + && let Some(g) = g + { + app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g)))).ok(); + } + if menu_item(ui, "Reverse Gradient", current(app).is_some(), false) { + edit(app, json!({"reverse": true}), Live::Released); + } + if menu_item(ui, "Reset to White, Black", true, false) { + let d = Gradient::default(); + edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released); + } + let _ = Color::BLACK; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn g3() -> Gradient { + let mut g = Gradient::default(); + g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }); + g + } + + #[test] + fn insert_samples_color_and_sorts() { + let g = Gradient::default(); + let (stops, i) = insert_stop(&g, 0.25); + assert_eq!(stops.len(), 3); + assert_eq!(i, 1); + let r = stops[1].color.to_rgb()[0]; + assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); + let (stops, i) = insert_stop(&g, 2.0); + assert_eq!(i, 2); + assert_eq!(stops[2].offset, 1.0); + } + + #[test] + fn remove_keeps_two() { + let g = g3(); + let v = remove_stop(&g.stops, 1).unwrap(); + assert_eq!(v.len(), 2); + assert!(remove_stop(&v, 0).is_none()); + assert!(remove_stop(&g.stops, 9).is_none()); + } + + #[test] + fn move_reorders_and_tracks_index() { + let g = g3(); + let (v, i) = move_stop(&g.stops, 0, 0.8); + assert_eq!(i, 1); + assert_eq!(v[1].color.to_hex(), "#ffffff"); + assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); + let (v, i) = move_stop(&g.stops, 1, -3.0); + // Clamped to 0 and placed after the existing stop at 0. + assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); + } + + #[test] + fn midpoint_math() { + let g = g3(); + assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); + assert_eq!(midpoint_pos(&g.stops, 2), None); + let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); + assert!((m - 0.2).abs() < 1e-5); + assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); + let v = set_midpoint(&g.stops, 0, 0.99); + assert_eq!(v[0].midpoint, 0.87); + } + + #[test] + fn offsets_and_json() { + assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); + assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0); + assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0); + let j = stops_json(&g3().stops); + assert_eq!(j.as_array().unwrap().len(), 3); + assert_eq!(j[1]["midpoint"], json!(0.5)); + } +} diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs new file mode 100644 index 00000000..ea530bd5 --- /dev/null +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -0,0 +1,144 @@ +//! Graphic Styles panel: thumbnail grid or list of the document's styles. + +use drawcraft_doc::Appearance; +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use super::{pstate, selection_len, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +fn preview(ui: &Ui, r: Rect, ap: &Appearance) { + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + let inner = r.shrink(r.width() * 0.18); + widgets::paint_chip(ui, inner, &ap.fill_paint()); + if let Some(s) = ap.stroke() + && let Some(c) = s.paint.color() + { + ui.painter().rect_stroke(inner, 0.0, Stroke::new((s.width as f32).clamp(1.0, 4.0), super::c32(&c)), StrokeKind::Middle); + } + if !ap.effects.is_empty() { + ui.painter().text( + r.right_bottom() - vec2(3.0, 2.0), + egui::Align2::RIGHT_BOTTOM, + "fx", + egui::FontId::proportional(9.0), + egui::Color32::DARK_GRAY, + ); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let styles: Vec<(String, Appearance)> = + app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); + let sel: Option = pstate(ui.ctx(), "gs-sel"); + let list: bool = pstate(ui.ctx(), "gs-list"); + let mut clicked = None; + widgets::list_box(ui, |ui| { + ui.set_min_height(120.0); + ui.set_width(ui.available_width()); + if styles.is_empty() { + super::empty_state(ui, "dc-graphic-styles", "No graphic styles", "Select styled art and click New Graphic Style."); + return; + } + if list { + for (name, ap) in &styles { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if sel.as_deref() == Some(name) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), ap); + ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); + if resp.clicked() { + clicked = Some(name.clone()); + } + } + } else { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (name, ap) in &styles { + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); + preview(ui, r, ap); + let on = sel.as_deref() == Some(name.as_str()); + ui.painter().rect_stroke( + r, + 0.0, + Stroke::new( + if on { 2.0 } else { 1.0 }, + if on { + t.accent + } else if resp.hovered() { + t.text + } else { + t.border + }, + ), + StrokeKind::Inside, + ); + if resp.on_hover_text(name).clicked() { + clicked = Some(name.clone()); + } + } + }); + } + }); + if let Some(name) = clicked { + set_pstate(ui.ctx(), "gs-sel", Some(name.clone())); + if selection_len(app) > 0 { + app.run("graphicStyle.apply", json!({"name": name})).ok(); + } + } + let sel: Option = pstate(ui.ctx(), "gs-sel"); + let has_sel = selection_len(app) > 0; + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "library", "Graphic Style Libraries (on the roadmap)", false, false, 24.0); + widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style", false, has_sel, 24.0).clicked() { + app.run("graphicStyle.new", json!({})).ok(); + } + if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, sel.is_some(), 24.0).clicked() + && let Some(n) = &sel + && app.run("graphicStyle.delete", json!({"name": n})).is_ok() + { + set_pstate::>(ui.ctx(), "gs-sel", None); + } + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let sel: Option = pstate(ui.ctx(), "gs-sel"); + let list: bool = pstate(ui.ctx(), "gs-list"); + if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) { + app.run("graphicStyle.new", json!({})).ok(); + } + if menu_item(ui, "Duplicate Graphic Style", sel.is_some(), false) + && let Some(n) = &sel + { + app.run("graphicStyle.duplicate", json!({"name": n})).ok(); + } + menu_item(ui, "Merge Graphic Styles", false, false); + if menu_item(ui, "Delete Graphic Style", sel.is_some(), false) + && let Some(n) = &sel + { + app.run("graphicStyle.delete", json!({"name": n})).ok(); + } + menu_item(ui, "Break Link to Graphic Style", false, false); + ui.separator(); + menu_item(ui, "Select All Unused", false, false); + menu_item(ui, "Sort by Name", false, false); + ui.separator(); + if menu_item(ui, "Thumbnail View", true, !list) { + set_pstate(ui.ctx(), "gs-list", false); + } + if menu_item(ui, "Large List View", true, list) { + set_pstate(ui.ctx(), "gs-list", true); + } + ui.separator(); + menu_item(ui, "Graphic Style Options…", false, false); + menu_item(ui, "Open Graphic Style Library", false, false); +} diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs new file mode 100644 index 00000000..4829a1ef --- /dev/null +++ b/crates/ui-egui/src/panels/history.rs @@ -0,0 +1,76 @@ +//! History panel: the document's states; click one to go back (or forward) to it. + +use egui::{Sense, Ui, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::widgets::menu_item; +use crate::{DrawcraftApp, icons}; + +/// Undo (negative) / redo (positive) steps to reach row `target` when `current` is the current +/// row (row 0 = the document as opened). +pub fn steps_to(current: usize, target: usize) -> i64 { + target as i64 - current as i64 +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + super::empty_state(ui, "history", "No document", "Open a document to see its history."); + return; + }; + let mut rows: Vec<(String, bool)> = vec![("Open".to_string(), false)]; + rows.extend(st.history.undo.iter().map(|h| (h.label.clone(), false))); + let current = rows.len() - 1; + rows.extend(st.history.redo.iter().rev().map(|h| (h.label.clone(), true))); + let mut steps: Option = None; + crate::widgets::list_box(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::vertical().id_salt("hist").max_height(260.0).stick_to_bottom(true).show(ui, |ui| { + for (i, (label, future)) in rows.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if i == current { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let icon = if i == 0 { "file-plus" } else { "history" }; + icons::paint( + ui, + icon, + egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(14.0, 14.0)), + if *future { t.text_disabled } else { t.icon }, + ); + let col = if *future { t.text_disabled } else { t.text }; + ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), col); + if resp.clicked() && i != current { + steps = Some(steps_to(current, i)); + } + } + }); + }); + if let Some(s) = steps { + let (cmd, n) = if s < 0 { ("edit.undo", -s) } else { ("edit.redo", s) }; + for _ in 0..n { + if app.run(cmd, json!({})).is_err() { + break; + } + } + } +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + menu_item(ui, "History Options…", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps() { + assert_eq!(steps_to(5, 2), -3); + assert_eq!(steps_to(2, 4), 2); + assert_eq!(steps_to(3, 3), 0); + } +} diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs new file mode 100644 index 00000000..7884f7b2 --- /dev/null +++ b/crates/ui-egui/src/panels/info.rs @@ -0,0 +1,104 @@ +//! Info panel: cursor X/Y, selection W/H, the pointer's distance/angle from the selection, and the +//! selection's fill and stroke colour values. + +use drawcraft_color::{Color, Paint}; +use egui::{Sense, Ui, vec2}; + +use super::{current_paints, first_selected, pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +/// Colour values in the colour's own model, like Illustrator's Info panel. +pub fn color_values(c: &Color) -> Vec<(&'static str, String)> { + match *c { + Color::Rgb { r, g, b } => vec![("R", format!("{:.0}", r * 255.0)), ("G", format!("{:.0}", g * 255.0)), ("B", format!("{:.0}", b * 255.0))], + Color::Cmyk { c, m, y, k } => { + vec![ + ("C", format!("{:.0}%", c * 100.0)), + ("M", format!("{:.0}%", m * 100.0)), + ("Y", format!("{:.0}%", y * 100.0)), + ("K", format!("{:.0}%", k * 100.0)), + ] + } + Color::Gray { k } => vec![("K", format!("{:.0}%", k * 100.0))], + } +} + +fn paint_block(ui: &mut Ui, p: &Paint) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + widgets::swatch_tile(ui, r, p, false, false); + match p { + Paint::Solid { color, .. } => { + for (k, v) in color_values(color) { + ui.label(egui::RichText::new(format!("{k}: {v}")).monospace().color(t.text)); + } + } + other => { + ui.label(egui::RichText::new(other.label()).color(t.text_dim)); + } + } + }); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); + let p = app.hover_doc.unwrap_or_default(); + let bounds = app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)); + let mono = |ui: &mut Ui, s: String| { + ui.label(egui::RichText::new(s).monospace().color(t.text)); + }; + ui.columns(2, |cols| { + mono(&mut cols[0], format!("X: {}", units.format(p.x))); + mono(&mut cols[0], format!("Y: {}", units.format(p.y))); + let (w, h) = bounds.map(|b| (units.format(b.width()), units.format(b.height()))).unwrap_or_default(); + mono(&mut cols[1], format!("W: {w}")); + mono(&mut cols[1], format!("H: {h}")); + }); + if !pstate::(ui.ctx(), "info-hide-options") + && let Some(b) = bounds + { + let c = b.center(); + let d = ((p.x - c.x).powi(2) + (p.y - c.y).powi(2)).sqrt(); + let a = (-(p.y - c.y)).atan2(p.x - c.x).to_degrees(); + ui.columns(2, |cols| { + mono(&mut cols[0], format!("D: {}", units.format(d))); + mono(&mut cols[1], format!("A: {a:.1}°")); + }); + } + widgets::divider(ui); + let (f, s) = current_paints(app); + paint_block(ui, &f); + paint_block(ui, &s); + if let Some(n) = first_selected(app) + && let drawcraft_doc::NodeKind::Text(tx) = &n.kind + { + widgets::divider(ui); + let st = tx.first_style(); + mono(ui, format!("{} {}", st.font_family, st.font_style)); + mono(ui, format!("Size: {} Tracking: {:.0}", units.format(st.size), st.tracking)); + } +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + let hidden: bool = pstate(ui.ctx(), "info-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "info-hide-options", !hidden); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_follow_model() { + let v = color_values(&Color::rgb8(230, 120, 40)); + assert_eq!(v[0], ("R", "230".to_string())); + assert_eq!(color_values(&Color::cmyk(0.1, 0.0, 0.0, 0.5))[3].1, "50%"); + assert_eq!(color_values(&Color::gray(0.25)).len(), 1); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 642b6db4..7d05123e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,15 +1,37 @@ -//! Panels. Each panel reads engine state and acts only through `app.run(...)`. +//! Panels. Each panel reads engine state and acts only through `app.run(...)` (slider drags +//! preview through the session's interaction so one drag is one undo step). +//! +//! Every icon panel has a body (`show_icon_panel`) and a panel (≡) menu (`panel_menu`). +pub mod align; +pub mod appearance; +pub mod artboards; +pub mod brushes; +pub mod character; +pub mod color; +pub mod color_guide; +pub mod gradient; +pub mod graphic_styles; +pub mod history; +pub mod info; pub mod layers; +pub mod navigator; +pub mod paragraph; +pub mod pathfinder; pub mod properties; +pub mod stroke; +pub mod swatches; +pub mod symbols; +pub mod transform; +pub mod transparency; -use drawcraft_color::{BlendMode, Color, Paint}; -use drawcraft_doc::{AppearanceItem, Node}; -use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; -use serde_json::json; +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Node, NodeKind}; +use egui::{Rect, Sense, Ui, vec2}; +use serde_json::{Value, json}; use crate::theme::Tokens; -use crate::widgets::{self, dim_label, paint_chip}; +use crate::widgets::{Live, dim_label}; use crate::{DrawcraftApp, icons}; /// The first selected node (cloned), if any. @@ -18,30 +40,78 @@ pub fn first_selected(app: &DrawcraftApp) -> Option { st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned() } +/// Number of selected objects. +pub(crate) fn selection_len(app: &DrawcraftApp) -> usize { + app.session.active().map(|d| d.selection.len()).unwrap_or(0) +} + pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { match id { - "swatches" => swatches(app, ui), - "color" => color(app, ui), - "colorGuide" => color_guide(app, ui), - "stroke" => stroke(app, ui), - "transparency" => transparency(app, ui), - "appearance" => appearance(app, ui), - "graphicStyles" => graphic_styles(app, ui), - "align" => align(app, ui), - "pathfinder" => pathfinder(app, ui), - "transform" => properties::transform_section(app, ui), - "history" => history(app, ui), - "info" => info(app, ui), - "artboards" => artboards(app, ui), - "gradient" => gradient(app, ui), - "character" | "paragraph" => properties::type_sections(app, ui), - "navigator" => navigator(app, ui), + "swatches" => swatches::show(app, ui), + "color" => color::show(app, ui), + "colorGuide" => color_guide::show(app, ui), + "stroke" => stroke::show(app, ui), + "transparency" => transparency::show(app, ui), + "appearance" => appearance::show(app, ui), + "graphicStyles" => graphic_styles::show(app, ui), + "align" => align::show(app, ui), + "pathfinder" => pathfinder::show(app, ui), + "transform" => transform::show(app, ui), + "history" => history::show(app, ui), + "info" => info::show(app, ui), + "artboards" => artboards::show(app, ui), + "gradient" => gradient::show(app, ui), + "character" => character::show(app, ui), + "paragraph" => paragraph::show(app, ui), + "navigator" => navigator::show(app, ui), + "brushes" => brushes::show(app, ui), + "symbols" => symbols::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } } } +/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled. +pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { + match id { + "swatches" => swatches::menu(app, ui), + "color" => color::menu(app, ui), + "colorGuide" => color_guide::menu(app, ui), + "stroke" => stroke::menu(app, ui), + "transparency" => transparency::menu(app, ui), + "appearance" => appearance::menu(app, ui), + "graphicStyles" => graphic_styles::menu(app, ui), + "align" => align::menu(app, ui), + "pathfinder" => pathfinder::menu(app, ui), + "transform" => transform::menu(app, ui), + "history" => history::menu(app, ui), + "info" => info::menu(app, ui), + "artboards" => artboards::menu(app, ui), + "gradient" => gradient::menu(app, ui), + "character" => character::menu(app, ui), + "paragraph" => paragraph::menu(app, ui), + "navigator" => navigator::menu(app, ui), + "brushes" => brushes::menu(app, ui), + "symbols" => symbols::menu(app, ui), + _ => { + ui.add_enabled(false, egui::Button::new("No options").frame(false)); + } + } +} + +/// The ≡ panel-menu button drawn into `rect` (the right end of a panel's title/tab strip). +pub fn panel_menu(app: &mut DrawcraftApp, ui: &mut Ui, id: &str, rect: Rect) { + let t = Tokens::get(ui.ctx()); + let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click()); + icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); + let resp = resp.on_hover_text("Panel menu"); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(220.0); + panel_menu_items(app, ui, id); + }); +} + pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.add_space(20.0); @@ -56,480 +126,169 @@ pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) { }); } -fn paint_target(app: &DrawcraftApp) -> &'static str { +// ---------- shared helpers ---------- + +/// The paint command for the active proxy (Fill or Stroke). +pub(crate) fn paint_target(app: &DrawcraftApp) -> &'static str { if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" } } -pub fn swatches(app: &mut DrawcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { - dim_label(ui, "Open a document to see its swatches."); - return; - }; - let mut all: Vec<(String, Paint)> = st.doc.swatches.iter().map(|s| (s.name.clone(), s.paint.clone())).collect(); - for g in &st.doc.swatch_groups { - for s in &g.swatches { - all.push((s.name.clone(), s.paint.clone())); +/// Fill and stroke as the proxies show them: the first selected object's (text uses its first +/// run's style), else the defaults for new art. +pub(crate) fn current_paints(app: &DrawcraftApp) -> (Paint, Paint) { + match first_selected(app) { + Some(n) => match &n.kind { + NodeKind::Text(t) => { + let s = t.first_style(); + (s.fill, s.stroke) + } + _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), + }, + None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), + } +} + +/// The paint behind the active proxy. +pub(crate) fn active_paint(app: &DrawcraftApp) -> Paint { + let (f, s) = current_paints(app); + if app.session.fill_active { f } else { s } +} + +/// Draw the Fill/Stroke proxy and handle its clicks through commands. +pub(crate) fn proxy(app: &mut DrawcraftApp, ui: &mut Ui, size: f32) { + let (f, s) = current_paints(app); + let (a, b, swap, def) = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); + if (a && !app.session.fill_active) || (b && app.session.fill_active) { + app.run("paint.toggleActive", json!({})).ok(); + } + if swap { + app.run("paint.swap", json!({})).ok(); + } + if def { + app.run("paint.default", json!({})).ok(); + } +} + +/// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit +/// it as one undo step. Falls back to a plain run when no document is open. +pub(crate) fn live_run(app: &mut DrawcraftApp, label: &str, cmd: &str, params: Value, phase: Live) { + match phase { + Live::Idle => {} + Live::Dragging | Live::Released => { + if app.session.begin_interaction(label).is_ok() { + if let Err(e) = app.session.preview(cmd, ¶ms) { + app.ui.status = e.to_string(); + } + if phase == Live::Released { + app.session.commit_interaction().ok(); + } + app.sync_views(); + } else if phase == Live::Released { + app.run(cmd, params).ok(); + } } } - let target = paint_target(app); +} + +/// Per-panel UI state kept in egui memory (not document state). +pub(crate) fn pstate(ctx: &egui::Context, key: &str) -> T { + ctx.data(|d| d.get_temp::(egui::Id::new(("panel-state", key)))).unwrap_or_default() +} +pub(crate) fn set_pstate(ctx: &egui::Context, key: &str, v: T) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v)); +} + +/// Recently applied colours (Swatches / Color panels "Recent Colors" row), newest first. +pub(crate) fn recent_colors(ctx: &egui::Context) -> Vec { + pstate::>(ctx, "recent-colors") +} +pub(crate) fn push_recent(ctx: &egui::Context, c: Color) { + let mut v = recent_colors(ctx); + v.retain(|x| x.to_hex() != c.to_hex()); + v.insert(0, c); + v.truncate(10); + set_pstate(ctx, "recent-colors", v); +} + +/// "Recent Colors" header + a row of chips; clicking one applies it to the active proxy. +pub(crate) fn recent_colors_row(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::subheader(ui, "Recent Colors"); + let recent = recent_colors(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); let mut chosen = None; - ui.horizontal(|ui| { - let (f, s) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone()); - let (a, b, _, _) = widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, 34.0); - if a { - app.session.fill_active = true; + for (i, c) in recent.iter().enumerate() { + let cell = Rect::from_min_size(r.min + vec2(3.0 + i as f32 * 19.0, 3.0), vec2(16.0, 16.0)); + if cell.right() > r.right() - 2.0 { + break; } - if b { - app.session.fill_active = false; - } - }); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - for (name, paint) in &all { - let (r, resp) = ui.allocate_exact_size(vec2(19.0, 19.0), Sense::click()); - paint_chip(ui, r, paint); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside); - if resp.on_hover_text(name).clicked() { - chosen = Some(name.clone()); - } - } - }); - if let Some(name) = chosen { - app.run(target, json!({"swatch": name})).ok(); - } - widgets::divider(ui); - ui.horizontal(|ui| { - if widgets::icon_button(ui, "plus", "New Swatch", false, 22.0).clicked() { - app.run("swatch.new", json!({})).ok(); - } - }); -} - -pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) { - let current = match first_selected(app) { - Some(n) => { - if app.session.fill_active { - n.appearance.fill_paint() - } else { - n.appearance.stroke_paint() - } - } - None => { - if app.session.fill_active { - app.session.paint.fill.clone() - } else { - app.session.paint.stroke.clone() - } - } - }; - let c = current.color().unwrap_or(Color::BLACK); - let mut rgb = c.to_rgba8(1.0); - let mut changed = false; - for (i, lbl) in ["R", "G", "B"].iter().enumerate() { - ui.horizontal(|ui| { - ui.label(*lbl); - let mut v = rgb[i] as i32; - if ui.add(egui::Slider::new(&mut v, 0..=255)).changed() { - rgb[i] = v as u8; - changed = true; - } - }); - } - let mut hex = c.to_hex(); - ui.horizontal(|ui| { - ui.label("#"); - if ui.add(egui::TextEdit::singleline(&mut hex).desired_width(80.0)).lost_focus() - && let Some(nc) = Color::from_hex(&hex) - { - rgb = nc.to_rgba8(1.0); - changed = true; - } - }); - // Spectrum ramp. - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click_and_drag()); - let n = 64; - for i in 0..n { - for j in 0..4 { - let h = i as f32 / n as f32 * 360.0; - let v = 1.0 - j as f32 / 4.0 * 0.8; - let [cr, cg, cb, _] = Color::from_hsb(h, 1.0, v).to_rgba8(1.0); - let cell = - egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0)); - ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb(cr, cg, cb)); + let resp = ui.interact(cell, ui.id().with(("recent", i)), Sense::click()); + crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); + if resp.on_hover_text(c.to_hex()).clicked() { + chosen = Some(*c); } } - if (resp.clicked() || resp.dragged()) - && let Some(p) = resp.interact_pointer_pos() - { - let h = ((p.x - r.left()) / r.width()).clamp(0.0, 1.0) * 360.0; - let v = 1.0 - ((p.y - r.top()) / r.height()).clamp(0.0, 1.0) * 0.8; - rgb = Color::from_hsb(h, 1.0, v).to_rgba8(1.0); - changed = true; - } - if changed { - let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]); - app.run(paint_target(app), json!({"color": hex})).ok(); + if let Some(c) = chosen { + app.run(paint_target(app), json!({"color": color_json(&c)})).ok(); } } -fn color_guide(app: &mut DrawcraftApp, ui: &mut Ui) { - let base = - first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1)); - for h in drawcraft_color::harmony::Harmony::ALL { - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(h.label()).size(11.0))); - for c in h.apply(base) { - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); - paint_chip(ui, r, &Paint::solid(c)); - if resp.clicked() { - app.run(paint_target(app), json!({"color": c.to_hex()})).ok(); - } - } - }); +/// A colour as command JSON, keeping its model. +pub(crate) fn color_json(c: &Color) -> Value { + match *c { + Color::Rgb { r, g, b } => json!([r, g, b]), + Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}), + Color::Gray { k } => json!({"gray": k}), } } -pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) { - let n = first_selected(app); - let st = n.as_ref().and_then(|n| n.appearance.stroke().cloned()); - let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); - ui.horizontal(|ui| { - dim_label(ui, "Weight:"); - if let Some(w) = widgets::num_field(ui, "stroke-w", Some(weight), drawcraft_doc::Unit::Points, 80.0) { - app.run("stroke.set", json!({"weight": w})).ok(); - } - }); - let cap = st.as_ref().map(|s| s.cap).unwrap_or_default(); - let join = st.as_ref().map(|s| s.join).unwrap_or_default(); - let align = st.as_ref().map(|s| s.align).unwrap_or_default(); - ui.horizontal(|ui| { - dim_label(ui, "Cap:"); - for (v, lbl, name) in [ - (drawcraft_doc::LineCap::Butt, "Butt", "butt"), - (drawcraft_doc::LineCap::Round, "Round", "round"), - (drawcraft_doc::LineCap::Square, "Projecting", "square"), - ] { - if ui.selectable_label(cap == v, lbl).clicked() { - app.run("stroke.set", json!({"cap": name})).ok(); - } - } - }); - ui.horizontal(|ui| { - dim_label(ui, "Corner:"); - for (v, lbl, name) in [ - (drawcraft_doc::LineJoin::Miter, "Miter", "miter"), - (drawcraft_doc::LineJoin::Round, "Round", "round"), - (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel"), - ] { - if ui.selectable_label(join == v, lbl).clicked() { - app.run("stroke.set", json!({"join": name})).ok(); - } - } - }); - ui.horizontal(|ui| { - dim_label(ui, "Align:"); - for (v, lbl, name) in [ - (drawcraft_doc::StrokeAlign::Center, "Center", "center"), - (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), - (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside"), - ] { - if ui.selectable_label(align == v, lbl).clicked() { - app.run("stroke.set", json!({"align": name})).ok(); - } - } - }); - let mut dashed = st.as_ref().is_some_and(|s| s.dash.is_some()); - if ui.checkbox(&mut dashed, "Dashed Line").changed() { - app.run("stroke.set", if dashed { json!({"dash": [12, 6]}) } else { json!({"dash": null}) }).ok(); - } - ui.horizontal(|ui| { - dim_label(ui, "Arrowheads:"); - let end = st.as_ref().and_then(|s| s.end_arrow).map(|a| format!("{a:?}")).unwrap_or("None".into()); - let opts = ["None", "Arrow", "Triangle", "Circle", "Square", "Diamond", "Bar"]; - if let Some(i) = widgets::dropdown(ui, "arrow-end", &end, &opts, 110.0) { - let v = if i == 0 { json!(null) } else { json!(opts[i]) }; - app.run("stroke.set", json!({"endArrow": v})).ok(); - } - }); - ui.horizontal(|ui| { - dim_label(ui, "Profile:"); - let opts = ["Uniform", "Width Profile 1 (lens)", "Taper Start", "Taper End"]; - let ids = ["uniform", "lens", "taperStart", "taperEnd"]; - if let Some(i) = widgets::dropdown(ui, "profile", "Uniform", &opts, 150.0) { - app.run("stroke.set", json!({"profile": ids[i]})).ok(); - } - }); -} - -fn blend_dropdown(app: &mut DrawcraftApp, ui: &mut Ui, current: BlendMode) { - let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); - if let Some(i) = widgets::dropdown(ui, "blend", current.label(), &labels, 130.0) { - app.run("object.setProps", json!({"blend": labels[i]})).ok(); +/// Paint as command params (`{color}`, `{none}` or `{gradient}`). +pub(crate) fn paint_params(p: &Paint) -> Value { + match p { + Paint::None => json!({"none": true}), + Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}), + Paint::Solid { color, .. } => json!({"color": color_json(color)}), + Paint::Gradient(g) => json!({"gradient": { + "kind": g.gradient.kind.label().to_lowercase(), + "angle": g.angle, + "stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::>() + }}), + Paint::Pattern { .. } => json!({"none": true}), } } -pub fn transparency(app: &mut DrawcraftApp, ui: &mut Ui) { - let n = first_selected(app); - let (blend, op) = n.as_ref().map(|n| (n.blend, n.opacity)).unwrap_or((BlendMode::Normal, 1.0)); - ui.horizontal(|ui| { - blend_dropdown(app, ui, blend); - dim_label(ui, "Opacity:"); - if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 60.0) { - app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); - } - }); - let mut o = op * 100.0; - if ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)).drag_stopped() { - app.run("object.setProps", json!({"opacity": o / 100.0})).ok(); - } +/// Egui colour of a document colour. +pub(crate) fn c32(c: &Color) -> egui::Color32 { + let [r, g, b, _] = c.to_rgba8(1.0); + egui::Color32::from_rgb(r, g, b) } -pub fn appearance(app: &mut DrawcraftApp, ui: &mut Ui) { +/// A labelled empty-state message used by list panels. +pub(crate) fn empty_state(ui: &mut Ui, icon: &str, title: &str, body: &str) { let t = Tokens::get(ui.ctx()); - let Some(n) = first_selected(app) else { - dim_label(ui, "No Selection"); - return; - }; - ui.label(egui::RichText::new(n.kind_label()).strong()); - let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect(); - for (i, it) in items { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.divider)); - let (label, paint, extra) = match &it { - AppearanceItem::Fill(f) => ("Fill:", f.paint.clone(), String::new()), - AppearanceItem::Stroke(s) => ("Stroke:", s.paint.clone(), format!("{} pt", s.width)), - }; - ui.painter().text(r.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); - let chip = egui::Rect::from_min_size(r.left_center() + vec2(74.0, -8.0), vec2(16.0, 16.0)); - paint_chip(ui, chip, &paint); - ui.painter().text(r.left_center() + vec2(98.0, 0.0), egui::Align2::LEFT_CENTER, extra, egui::FontId::proportional(12.0), t.text_dim); - let del = egui::Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(14.0, 14.0)); - let dr = ui.interact(del, ui.id().with(("ap-del", i)), Sense::click()); - icons::paint(ui, "trash-2", del, if dr.hovered() { t.text } else { t.text_disabled }); - if dr.clicked() { - app.run("appearance.removeItem", json!({"index": i})).ok(); - } - icons::paint(ui, "eye", egui::Rect::from_center_size(r.left_center() + vec2(9.0, 0.0), vec2(13.0, 13.0)), t.icon); - } - ui.horizontal(|ui| { - dim_label(ui, "Opacity:"); - dim_label(ui, &format!("{:.0}% {}", n.opacity * 100.0, n.blend.label())); - }); - widgets::divider(ui); - ui.horizontal(|ui| { - if widgets::flat_button(ui, "+ Stroke", 70.0).clicked() { - app.run("appearance.addStroke", json!({})).ok(); - } - if widgets::flat_button(ui, "+ Fill", 60.0).clicked() { - app.run("appearance.addFill", json!({})).ok(); - } - if widgets::flat_button(ui, "Clear", 50.0).clicked() { - app.run("appearance.clear", json!({})).ok(); - } + ui.add_space(12.0); + ui.vertical_centered(|ui| { + icons::icon(ui, icon, 28.0, t.text_disabled); + ui.add_space(4.0); + ui.label(egui::RichText::new(title).size(12.5).color(t.text)); + ui.label(egui::RichText::new(body).size(11.5).color(t.text_dim)); }); + ui.add_space(12.0); } -fn graphic_styles(app: &mut DrawcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let styles: Vec<(String, drawcraft_doc::Appearance)> = - app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); - ui.horizontal_wrapped(|ui| { - for (name, ap) in styles { - let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - let inner = r.shrink(8.0); - paint_chip(ui, inner, &ap.fill_paint()); - if let Some(s) = ap.stroke() { - let col = s.paint.color().map(|c| { - let [a, b, cc, _] = c.to_rgba8(1.0); - egui::Color32::from_rgb(a, b, cc) - }); - if let Some(col) = col { - ui.painter().rect_stroke(inner, 0.0, Stroke::new((s.width as f32).clamp(1.0, 4.0), col), StrokeKind::Middle); - } - } - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside); - if resp.on_hover_text(&name).clicked() { - app.run("graphicStyle.apply", json!({"name": name})).ok(); - } - } - }); -} +#[cfg(test)] +mod tests { + use super::*; -pub fn align(app: &mut DrawcraftApp, ui: &mut Ui) { - dim_label(ui, "Align Objects:"); - ui.horizontal(|ui| { - for (icon, tip, p) in [ - ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), - ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), - ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), - ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), - ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), - ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), - ] { - if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { - app.run("object.align", p).ok(); - } - } - }); - dim_label(ui, "Distribute Objects:"); - ui.horizontal(|ui| { - for (icon, tip, p) in [ - ("align-vertical-justify-start", "Vertical Distribute Top", json!({"vertical": "top"})), - ("align-vertical-justify-center", "Vertical Distribute Center", json!({"vertical": "center"})), - ("align-vertical-justify-end", "Vertical Distribute Bottom", json!({"vertical": "bottom"})), - ("align-horizontal-justify-start", "Horizontal Distribute Left", json!({"horizontal": "left"})), - ("align-horizontal-justify-center", "Horizontal Distribute Center", json!({"horizontal": "center"})), - ("align-horizontal-justify-end", "Horizontal Distribute Right", json!({"horizontal": "right"})), - ] { - if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { - app.run("object.distribute", p).ok(); - } - } - }); - dim_label(ui, "Distribute Spacing:"); - ui.horizontal(|ui| { - if widgets::icon_button(ui, "arrow-up-down", "Vertical Distribute Space", false, 28.0).clicked() { - app.run("object.distributeSpacing", json!({"axis": "vertical"})).ok(); - } - if widgets::icon_button(ui, "arrow-left-right", "Horizontal Distribute Space", false, 28.0).clicked() { - app.run("object.distributeSpacing", json!({"axis": "horizontal"})).ok(); - } - }); -} - -pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) { - dim_label(ui, "Shape Modes:"); - ui.horizontal(|ui| { - for (icon, tip, op) in [ - ("squares-unite", "Unite", "unite"), - ("squares-subtract", "Minus Front", "minusFront"), - ("squares-intersect", "Intersect", "intersect"), - ("squares-exclude", "Exclude", "exclude"), - ] { - if widgets::icon_button(ui, icon, tip, false, 30.0).clicked() { - app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); - } - } - }); - dim_label(ui, "Pathfinders:"); - ui.horizontal_wrapped(|ui| { - for (tip, op) in - [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] - { - if widgets::flat_button(ui, tip, 72.0).clicked() { - app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); - } - } - }); -} - -fn history(app: &mut DrawcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let undo: Vec = st.history.undo.iter().map(|h| h.label.clone()).collect(); - let redo: Vec = st.history.redo.iter().rev().map(|h| h.label.clone()).collect(); - let n_undo = undo.len(); - let mut steps: Option = None; - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "Open", egui::FontId::proportional(12.0), t.text_dim); - if resp.clicked() { - steps = Some(-(n_undo as i64)); - } - for (i, l) in undo.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if i + 1 == n_undo { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } - ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text); - if resp.clicked() { - steps = Some(i as i64 + 1 - n_undo as i64); - } - } - for (i, l) in redo.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text_disabled); - if resp.clicked() { - steps = Some(i as i64 + 1); - } - } - }); - if let Some(s) = steps { - let (cmd, n) = if s < 0 { ("edit.undo", -s) } else { ("edit.redo", s) }; - for _ in 0..n { - app.run(cmd, json!({})).ok(); - } + #[test] + fn paint_params_roundtrip_shapes() { + assert_eq!(paint_params(&Paint::None), json!({"none": true})); + let p = paint_params(&Paint::solid(Color::cmyk(0.1, 0.2, 0.3, 0.4))); + assert!((p["color"]["k"].as_f64().unwrap() - 0.4).abs() < 1e-6); + let g = Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))); + let p = paint_params(&g); + assert_eq!(p["gradient"]["kind"], "linear"); + assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2); } } - -fn info(app: &mut DrawcraftApp, ui: &mut Ui) { - let p = app.hover_doc.unwrap_or_default(); - ui.monospace(format!("X: {:>9.2} pt Y: {:>9.2} pt", p.x, p.y)); - if let Some(st) = app.session.active() - && let Some(b) = st.doc.bounds_of(&st.selection.objects, false) - { - ui.monospace(format!("W: {:>9.2} pt H: {:>9.2} pt", b.width(), b.height())); - } - if let Some(n) = first_selected(app) { - ui.label(format!("Fill: {}", n.appearance.fill_paint().label())); - ui.label(format!("Stroke: {}", n.appearance.stroke_paint().label())); - } -} - -fn artboards(app: &mut DrawcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let abs: Vec<(usize, String)> = - app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default(); - for (i, name) in abs { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - ui.painter().text( - r.left_center() + vec2(6.0, 0.0), - egui::Align2::LEFT_CENTER, - format!("{}", i + 1), - egui::FontId::proportional(12.0), - t.text_dim, - ); - ui.painter().text(r.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); - } - ui.horizontal(|ui| { - if widgets::icon_button(ui, "plus", "New Artboard", false, 22.0).clicked() { - app.run("artboard.new", json!({})).ok(); - } - }); -} - -fn gradient(app: &mut DrawcraftApp, ui: &mut Ui) { - let n = first_selected(app); - let paint = n.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or(Paint::None); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); - let preview = if matches!(paint, Paint::Gradient(_)) { - paint.clone() - } else { - Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) - }; - paint_chip(ui, r, &preview); - ui.horizontal(|ui| { - dim_label(ui, "Type:"); - if widgets::flat_button(ui, "Linear", 60.0).clicked() { - app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).ok(); - } - if widgets::flat_button(ui, "Radial", 60.0).clicked() { - app.run("paint.setFill", json!({"gradient": {"kind": "radial"}})).ok(); - } - }); -} - -fn navigator(app: &mut DrawcraftApp, ui: &mut Ui) { - let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); - let mut z = zoom * 100.0; - if ui.add(egui::Slider::new(&mut z, 3.13..=6400.0).logarithmic(true).suffix("%")).changed() { - app.run("view.setZoom", json!({"zoom": z})).ok(); - } - let _ = CornerRadius::ZERO; -} diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs new file mode 100644 index 00000000..dacf1ebe --- /dev/null +++ b/crates/ui-egui/src/panels/navigator.rs @@ -0,0 +1,168 @@ +//! Navigator panel: a thumbnail of the document (rendered with `Renderer::render_region`, cached by +//! document revision) with the red view box you drag to pan, zoom out/in buttons, a zoom slider +//! and a zoom field. + +use std::cell::RefCell; + +use drawcraft_geom::{Point, Rect as DRect}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +struct Cache { + renderer: Option, + key: Option<(usize, u64, u32, u32, bool)>, + tex: Option, +} + +thread_local! { + static CACHE: RefCell = const { RefCell::new(Cache { renderer: None, key: None, tex: None }) }; +} + +/// Map a document point into the thumbnail rect (region → rect, aspect preserved). +pub fn doc_to_thumb(region: DRect, thumb: Rect, p: Point) -> egui::Pos2 { + let s = (thumb.width() as f64 / region.width()).min(thumb.height() as f64 / region.height()); + let ox = thumb.left() as f64 + (thumb.width() as f64 - region.width() * s) / 2.0; + let oy = thumb.top() as f64 + (thumb.height() as f64 - region.height() * s) / 2.0; + pos2((ox + (p.x - region.x0) * s) as f32, (oy + (p.y - region.y0) * s) as f32) +} + +/// Inverse of [`doc_to_thumb`]. +pub fn thumb_to_doc(region: DRect, thumb: Rect, p: egui::Pos2) -> Point { + let s = (thumb.width() as f64 / region.width()).min(thumb.height() as f64 / region.height()); + let ox = thumb.left() as f64 + (thumb.width() as f64 - region.width() * s) / 2.0; + let oy = thumb.top() as f64 + (thumb.height() as f64 - region.height() * s) / 2.0; + Point::new(region.x0 + (p.x as f64 - ox) / s, region.y0 + (p.y as f64 - oy) / s) +} + +/// Slider position (0..1) ↔ zoom percent on Illustrator's log range 3.13 %–64 000 %. +pub fn zoom_to_slider(z: f64) -> f64 { + ((z.clamp(3.13, 64000.0)).ln() - 3.13f64.ln()) / (64000f64.ln() - 3.13f64.ln()) +} +pub fn slider_to_zoom(t: f64) -> f64 { + (3.13f64.ln() + t.clamp(0.0, 1.0) * (64000f64.ln() - 3.13f64.ln())).exp() +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + super::empty_state(ui, "map", "No document", "Open a document to navigate it."); + return; + }; + let artboard_only: bool = pstate(ui.ctx(), "nav-ab-only"); + let doc = st.doc.clone(); + let rev = st.revision; + let doc_idx = app.session.documents().iter().position(|d| std::sync::Arc::ptr_eq(&d.doc, &doc)).unwrap_or(0); + let mut region = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0)); + if !artboard_only && let Some(b) = doc.art_bounds() { + region = region.union(b); + } + let region = region.inflate(region.width() * 0.04 + 4.0, region.height() * 0.04 + 4.0); + let w = ui.available_width(); + let h = (w * 0.72).clamp(100.0, 200.0); + let (thumb, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click_and_drag()); + ui.painter().rect_filled(thumb, 0.0, t.pasteboard); + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = ((w * ppp) as u32, (h * ppp) as u32); + let key = (doc_idx, rev, pw, ph, artboard_only); + let tex = CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.key != Some(key) || c.tex.is_none() { + let s = (pw as f64 / region.width()).min(ph as f64 / region.height()); + let r = c.renderer.get_or_insert_with(drawcraft_render::Renderer::new).render_region(&doc, region, s, false); + let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.pixels); + match &mut c.tex { + Some(tx) => tx.set(img, egui::TextureOptions::LINEAR), + None => c.tex = Some(ui.ctx().load_texture("navigator", img, egui::TextureOptions::LINEAR)), + } + c.key = Some(key); + } + c.tex.clone() + }); + // Artboards (white) under the art thumbnail, like the canvas. + for a in &doc.artboards { + let r = Rect::from_min_max(doc_to_thumb(region, thumb, a.rect.origin()), doc_to_thumb(region, thumb, Point::new(a.rect.x1, a.rect.y1))); + ui.painter().rect_filled(r, 0.0, Color32::WHITE); + } + if let Some(tex) = tex { + let tl = doc_to_thumb(region, thumb, region.origin()); + let br = doc_to_thumb(region, thumb, Point::new(region.x1, region.y1)); + ui.painter().image(tex.id(), Rect::from_min_max(tl, br), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + // The view box. + let view = app.view().copied(); + if let (Some(v), Some(cr)) = (view, app.canvas_rect) { + let hw = cr.width() as f64 / 2.0 / v.zoom; + let hh = cr.height() as f64 / 2.0 / v.zoom; + let a = doc_to_thumb(region, thumb, Point::new(v.center.x - hw, v.center.y - hh)); + let b = doc_to_thumb(region, thumb, Point::new(v.center.x + hw, v.center.y + hh)); + let vr = Rect::from_min_max(a, b).intersect(thumb.expand(1.0)); + ui.painter().with_clip_rect(thumb).rect_stroke(vr, 0.0, Stroke::new(1.5, Color32::from_rgb(0xff, 0x30, 0x30)), StrokeKind::Middle); + if (resp.dragged() || resp.clicked()) + && let Some(p) = resp.interact_pointer_pos() + { + let c = thumb_to_doc(region, thumb, p); + app.run("view.setZoom", json!({"zoom": v.zoom * 100.0, "center": [c.x, c.y]})).ok(); + } + } + ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + // Zoom controls. + let zoom = view.map(|v| v.zoom * 100.0).unwrap_or(100.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let mut z = None; + if let Some(v) = widgets::plain_field(ui, "nav-zoom", zoom, "%", 2, 64.0) { + z = Some(v); + } + if widgets::icon_button(ui, "dc-zoom-small", "Zoom Out", false, 22.0).clicked() { + app.run("view.zoomOut", json!({})).ok(); + } + let sw = (ui.available_width() - 30.0).max(40.0); + let track = |_: f32| t.input_border; + if let (Some(v), phase) = widgets::color_slider(ui, "nav-slider", zoom_to_slider(zoom) as f32, sw, &track) + && phase != widgets::Live::Idle + { + z = Some(slider_to_zoom(v as f64)); + } + if widgets::icon_button(ui, "dc-zoom-large", "Zoom In", false, 22.0).clicked() { + app.run("view.zoomIn", json!({})).ok(); + } + if let Some(z) = z { + app.run("view.setZoom", json!({"zoom": z.clamp(3.13, 64000.0)})).ok(); + } + }); +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + let ab: bool = pstate(ui.ctx(), "nav-ab-only"); + if menu_item(ui, "View Artboard Only", true, ab) { + set_pstate(ui.ctx(), "nav-ab-only", !ab); + } + menu_item(ui, "Panel Options…", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thumb_mapping_roundtrip() { + let region = DRect::new(0.0, 0.0, 200.0, 100.0); + let thumb = Rect::from_min_size(pos2(10.0, 10.0), vec2(100.0, 100.0)); + let p = doc_to_thumb(region, thumb, Point::new(100.0, 50.0)); + assert!((p.x - 60.0).abs() < 1e-3 && (p.y - 60.0).abs() < 1e-3); + let q = thumb_to_doc(region, thumb, p); + assert!((q.x - 100.0).abs() < 1e-6 && (q.y - 50.0).abs() < 1e-6); + } + + #[test] + fn zoom_slider_roundtrip() { + assert!((zoom_to_slider(3.13)).abs() < 1e-9); + assert!((zoom_to_slider(64000.0) - 1.0).abs() < 1e-9); + assert!((slider_to_zoom(zoom_to_slider(100.0)) - 100.0).abs() < 1e-6); + } +} diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs new file mode 100644 index 00000000..191231ec --- /dev/null +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -0,0 +1,97 @@ +//! Paragraph panel: seven alignment buttons, indents, space before/after and Hyphenate. + +use drawcraft_doc::{Justify, Unit}; +use egui::{Ui, vec2}; +use serde_json::{Value, json}; + +use super::character::text_style; +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [ + (Justify::Left, "dc-para-left", "Align left", "left"), + (Justify::Center, "dc-para-center", "Align center", "center"), + (Justify::Right, "dc-para-right", "Align right", "right"), + (Justify::JustifyLeft, "dc-para-justify-left", "Justify with last line aligned left", "justifyLeft"), + (Justify::JustifyCenter, "dc-para-justify-center", "Justify with last line aligned center", "justifyCenter"), + (Justify::JustifyRight, "dc-para-justify-right", "Justify with last line aligned right", "justifyRight"), + (Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"), +]; + +fn format(app: &mut DrawcraftApp, p: Value) { + app.run("text.setFormat", p).ok(); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some((_, para)) = text_style(app) else { + super::empty_state(ui, "pilcrow", "No text selected", "Select a text object to edit its paragraph attributes."); + return; + }; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + for (j, icon, tip, id) in ALIGNMENTS { + if widgets::icon_button(ui, icon, tip, para.justify == j, 28.0).clicked() { + app.run("text.setStyle", json!({"justify": id})).ok(); + } + } + }); + ui.add_space(4.0); + let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0); + let label = |ui: &mut Ui, s: &str, tip: &str| { + ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip); + }; + egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| { + label(ui, "→|", "Left Indent"); + if let Some(v) = widgets::spin_field(ui, "pa-li", Some(para.left_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + format(app, json!({"leftIndent": v})); + } + label(ui, "|←", "Right Indent"); + if let Some(v) = widgets::spin_field(ui, "pa-ri", Some(para.right_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + format(app, json!({"rightIndent": v})); + } + ui.end_row(); + label(ui, "1→", "First-line Left Indent"); + if let Some(v) = widgets::spin_field(ui, "pa-fi", Some(para.first_line_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + format(app, json!({"firstLineIndent": v})); + } + ui.label(""); + ui.label(""); + ui.end_row(); + label(ui, "↑¶", "Space Before Paragraph"); + if let Some(v) = widgets::spin_field(ui, "pa-sb", Some(para.space_before), Unit::Points, fw, 1.0, 0.0, &[]) { + format(app, json!({"spaceBefore": v})); + } + label(ui, "↓¶", "Space After Paragraph"); + if let Some(v) = widgets::spin_field(ui, "pa-sa", Some(para.space_after), Unit::Points, fw, 1.0, 0.0, &[]) { + format(app, json!({"spaceAfter": v})); + } + ui.end_row(); + }); + ui.add_space(4.0); + if !pstate::(ui.ctx(), "pa-hide-options") && widgets::check(ui, "Hyphenate", para.hyphenate, true) { + format(app, json!({"hyphenate": !para.hyphenate})); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let has = text_style(app).is_some(); + let hidden: bool = pstate(ui.ctx(), "pa-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "pa-hide-options", !hidden); + } + ui.separator(); + for l in ["Roman Hanging Punctuation", "Justification…", "Hyphenation…"] { + menu_item(ui, l, false, false); + } + ui.separator(); + menu_item(ui, "Single-line Composer", false, false); + menu_item(ui, "Every-line Composer", false, true); + ui.separator(); + if menu_item(ui, "Reset Panel", has, false) { + app.run("text.setStyle", json!({"justify": "left"})).ok(); + format(app, json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false})); + } +} diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs new file mode 100644 index 00000000..af5e8594 --- /dev/null +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -0,0 +1,77 @@ +//! Pathfinder panel: Shape Modes (+ Expand) and Pathfinders, with icons drawn for DrawCraft. + +use egui::Ui; +use serde_json::json; + +use super::{pstate, selection_len, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +pub const SHAPE_MODES: [(&str, &str, &str); 4] = [ + ("dc-pf-unite", "Unite", "unite"), + ("dc-pf-minus-front", "Minus Front", "minusFront"), + ("dc-pf-intersect", "Intersect", "intersect"), + ("dc-pf-exclude", "Exclude", "exclude"), +]; + +pub const PATHFINDERS: [(&str, &str, &str); 6] = [ + ("dc-pf-divide", "Divide", "divide"), + ("dc-pf-trim", "Trim", "trim"), + ("dc-pf-merge", "Merge", "merge"), + ("dc-pf-crop", "Crop", "crop"), + ("dc-pf-outline", "Outline", "outline"), + ("dc-pf-minus-back", "Minus Back", "minusBack"), +]; + +fn run(app: &mut DrawcraftApp, ui: &Ui, op: &str, label: &str) { + if app.run(&format!("object.pathfinder.{op}"), json!({})).is_ok() { + set_pstate(ui.ctx(), "pf-last", Some((op.to_string(), label.to_string()))); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = selection_len(app); + let t = crate::theme::Tokens::get(ui.ctx()); + widgets::subheader(ui, "Shape Modes:"); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (icon, tip, op) in SHAPE_MODES { + if widgets::icon_button_enabled(ui, icon, tip, false, n >= 2, 34.0).clicked() { + run(app, ui, op, tip); + } + } + ui.add_enabled_ui(false, |ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(ui.available_width().min(90.0), 26.0), egui::Sense::hover()); + ui.painter().rect_filled(r, 2, t.hover.gamma_multiply(0.5)); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "Expand", egui::FontId::proportional(12.5), t.text_disabled); + }) + .response + .on_disabled_hover_text("Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap"); + }); + widgets::subheader(ui, "Pathfinders:"); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + for (icon, tip, op) in PATHFINDERS { + let min = if op == "outline" || op == "divide" { 1 } else { 2 }; + if widgets::icon_button_enabled(ui, icon, tip, false, n >= min, 34.0).clicked() { + run(app, ui, op, tip); + } + } + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + menu_item(ui, "Trap…", false, false); + let last: Option<(String, String)> = pstate(ui.ctx(), "pf-last"); + let label = last.as_ref().map(|(_, l)| format!("Repeat {l}")).unwrap_or_else(|| "Repeat Pathfinder".into()); + if menu_item(ui, &label, last.is_some() && selection_len(app) >= 1, false) + && let Some((op, l)) = last + { + run(app, ui, &op, &l); + } + menu_item(ui, "Pathfinder Options…", false, false); + ui.separator(); + menu_item(ui, "Make Compound Shape", false, false); + menu_item(ui, "Release Compound Shape", false, false); + menu_item(ui, "Expand Compound Shape", false, false); +} diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs new file mode 100644 index 00000000..b5926bbc --- /dev/null +++ b/crates/ui-egui/src/panels/stroke.rs @@ -0,0 +1,424 @@ +//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line +//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles. + +use drawcraft_doc::{Arrowhead, LineCap, LineJoin, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use super::{first_selected, pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{DrawcraftApp, icons}; + +pub const WEIGHT_PRESETS: [f64; 22] = + [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; + +/// Width profile presets: (id for `stroke.set`, label). +pub const PROFILES: [(&str, &str); 4] = + [("uniform", "Uniform"), ("lens", "Width Profile 1"), ("taperStart", "Width Profile 4"), ("taperEnd", "Width Profile 5")]; + +/// Which preset a stroke's profile is (None profile = uniform). +pub fn profile_id(p: Option<&WidthProfile>) -> &'static str { + let Some(p) = p else { return "uniform" }; + for (id, prof) in [("lens", WidthProfile::lens()), ("taperStart", WidthProfile::taper_start()), ("taperEnd", WidthProfile::taper_end())] { + if prof.points == p.points { + return id; + } + } + "custom" +} + +fn profile_of(id: &str) -> Option { + match id { + "lens" => Some(WidthProfile::lens()), + "taperStart" => Some(WidthProfile::taper_start()), + "taperEnd" => Some(WidthProfile::taper_end()), + _ => None, + } +} + +/// Dash pattern → the panel's six dash/gap fields (None = empty field). +pub fn dash_fields(pattern: &[f64]) -> [Option; 6] { + let mut out = [None; 6]; + for (i, v) in pattern.iter().take(6).enumerate() { + out[i] = Some(*v); + } + out +} + +/// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap +/// repeats its length as the gap (Illustrator's behaviour). +pub fn dash_pattern(fields: &[Option; 6]) -> Vec { + let mut out = vec![]; + for pair in 0..3 { + let Some(d) = fields[pair * 2] else { break }; + let g = fields[pair * 2 + 1].unwrap_or(d); + out.push(d.max(0.0)); + out.push(g.max(0.0)); + } + if out.iter().all(|v| *v == 0.0) { + out.clear(); + } + out +} + +fn arrow_label(a: Option) -> String { + match a { + None => "None".into(), + Some(a) => { + let s = format!("{a:?}"); + s.replace("Open", " (open)") + } + } +} + +fn stroke_now(app: &DrawcraftApp) -> Option { + first_selected(app).and_then(|n| n.appearance.stroke().cloned()) +} + +fn set(app: &mut DrawcraftApp, p: Value) { + app.run("stroke.set", p).ok(); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let st = stroke_now(app); + let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); + let hidden: bool = pstate(ui.ctx(), "stroke-hide-options"); + let label_w = 64.0; + let row_label = |ui: &mut Ui, s: &str| { + ui.add_sized(vec2(label_w, 24.0), egui::Label::new(egui::RichText::new(s).size(12.5).color(t.text)).halign(egui::Align::RIGHT)); + }; + ui.horizontal(|ui| { + row_label(ui, "Weight:"); + if let Some(w) = widgets::spin_field(ui, "stroke-weight", Some(weight), Unit::Points, 120.0, 1.0, 0.0, &WEIGHT_PRESETS) { + set(app, json!({"weight": w})); + } + }); + if hidden { + return; + } + let cap = st.as_ref().map(|s| s.cap).unwrap_or_default(); + let join = st.as_ref().map(|s| s.join).unwrap_or_default(); + let align = st.as_ref().map(|s| s.align).unwrap_or_default(); + ui.horizontal(|ui| { + row_label(ui, "Cap:"); + for (v, icon, tip, name) in [ + (LineCap::Butt, "dc-cap-butt", "Butt Cap", "butt"), + (LineCap::Round, "dc-cap-round", "Round Cap", "round"), + (LineCap::Square, "dc-cap-square", "Projecting Cap", "square"), + ] { + if widgets::icon_button(ui, icon, tip, cap == v, 24.0).clicked() { + set(app, json!({"cap": name})); + } + } + }); + ui.horizontal(|ui| { + row_label(ui, "Corner:"); + for (v, icon, tip, name) in [ + (LineJoin::Miter, "dc-join-miter", "Miter Join", "miter"), + (LineJoin::Round, "dc-join-round", "Round Join", "round"), + (LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"), + ] { + if widgets::icon_button(ui, icon, tip, join == v, 24.0).clicked() { + set(app, json!({"join": name})); + } + } + widgets::dim_label(ui, "Limit:"); + let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0); + if join == LineJoin::Miter { + if let Some(v) = widgets::plain_field(ui, "stroke-limit", lim, " x", 0, 50.0) { + set(app, json!({"miterLimit": v.clamp(1.0, 500.0)})); + } + } else { + ui.label(egui::RichText::new(format!("{lim:.0} x")).color(t.text_disabled)); + } + }); + ui.horizontal(|ui| { + row_label(ui, "Align Stroke:"); + for (v, icon, tip, name) in [ + (StrokeAlign::Center, "dc-stroke-center", "Align Stroke to Center", "center"), + (StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"), + (StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"), + ] { + if widgets::icon_button(ui, icon, tip, align == v, 24.0).clicked() { + set(app, json!({"align": name})); + } + } + }); + widgets::divider(ui); + // Dashed line. + let dash = st.as_ref().and_then(|s| s.dash.clone()); + let fields: [Option; 6] = match &dash { + Some(d) => dash_fields(&d.pattern), + None => pstate::; 6]>>(ui.ctx(), "stroke-dash-last").unwrap_or([Some(12.0), None, None, None, None, None]), + }; + let align_corners = dash.as_ref().is_some_and(|d| d.align_corners); + ui.horizontal(|ui| { + if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) { + if dash.is_some() { + set_pstate(ui.ctx(), "stroke-dash-last", Some(fields)); + set(app, json!({"dash": null})); + } else { + set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners})); + } + } + ui.add_space((ui.available_width() - 56.0).max(0.0)); + let on = dash.is_some(); + if widgets::icon_button_enabled(ui, "dc-dash-exact", "Preserves exact dash and gap lengths", on && !align_corners, on, 24.0).clicked() { + set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false})); + } + if widgets::icon_button_enabled( + ui, + "dc-dash-align", + "Aligns dashes to corners and path ends, adjusting lengths to fit", + on && align_corners, + on, + 24.0, + ) + .clicked() + { + set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true})); + } + }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0); + let mut nf = fields; + let mut changed = false; + for (i, f) in fields.iter().enumerate() { + let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, fw)).inner; + if let Some(x) = r { + nf[i] = x.map(|v| v.max(0.0)); + changed = true; + } + } + if changed && dash.is_some() { + set(app, json!({"dash": dash_pattern(&nf), "alignDashes": align_corners})); + } + }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0); + for lbl in ["dash", "gap", "dash", "gap", "dash", "gap"] { + ui.add_sized(vec2(fw, 12.0), egui::Label::new(egui::RichText::new(lbl).size(10.5).color(t.text_dim))); + } + }); + widgets::divider(ui); + // Arrowheads. + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + ui.horizontal(|ui| { + row_label(ui, "Arrowheads:"); + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { + set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + } + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { + set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + } + if widgets::icon_button_enabled(ui, "arrow-left-right", "Swap start and end arrowheads", false, st.is_some(), 22.0).clicked() { + app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); + } + }); + let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0)); + let linked: bool = pstate(ui.ctx(), "arrow-scale-link"); + ui.horizontal(|ui| { + row_label(ui, "Scale:"); + let on = st.is_some() && (sa.is_some() || ea.is_some()); + let mut ns = None; + ui.add_enabled_ui(on, |ui| { + if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) { + ns = Some((v, if linked { v } else { scale.1 })); + } + if let Some(v) = widgets::plain_field(ui, "arrow-scale-e", scale.1, "%", 0, 56.0) { + ns = Some((if linked { v } else { scale.0 }, v)); + } + }); + if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, "Link start and end arrowhead scales", linked, 22.0).clicked() { + set_pstate(ui.ctx(), "arrow-scale-link", !linked); + } + if let Some((a, b)) = ns { + app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok(); + } + }); + ui.horizontal(|ui| { + row_label(ui, "Align:"); + widgets::icon_button_enabled(ui, "dc-cap-square", "Extend arrow tip beyond end of path (on the roadmap)", true, false, 22.0); + widgets::icon_button_enabled(ui, "dc-cap-butt", "Place arrow tip at end of path (on the roadmap)", false, false, 22.0); + }); + widgets::divider(ui); + // Profile. + let pid = profile_id(st.as_ref().and_then(|s| s.profile.as_ref())); + ui.horizontal(|ui| { + row_label(ui, "Profile:"); + if let Some(id) = profile_dropdown(ui, pid) { + set(app, json!({"profile": id})); + } + let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some()); + if widgets::icon_button_enabled(ui, "flip-horizontal-2", "Flip Along", false, can_flip, 22.0).clicked() { + app.run("stroke.setAdvanced", json!({"flipProfile": "along"})).ok(); + } + if widgets::icon_button_enabled(ui, "flip-vertical-2", "Flip Across", false, can_flip, 22.0).clicked() { + app.run("stroke.setAdvanced", json!({"flipProfile": "across"})).ok(); + } + }); +} + +/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end. +fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { + let y = r.center().y; + let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) }; + let s = Stroke::new(1.5, color); + let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x }; + let other = if start { r.right() - 4.0 } else { r.left() + 4.0 }; + ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s); + let Some(a) = a else { return }; + let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) }; + let solid = |pts: Vec| egui::Shape::convex_polygon(pts, color, Stroke::NONE); + let open = |pts: Vec| egui::Shape::closed_line(pts, s); + let shape = match a { + Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]), + Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]), + Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]), + Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s), + Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color), + Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s), + Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color), + Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle), + Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]), + Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)), + }; + ui.painter().add(shape); +} + +/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, 2, t.input); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); + let body = Rect::from_min_max(r.min + vec2(3.0, 0.0), pos2(r.right() - 16.0, r.bottom())); + paint_arrow(ui, body, cur, start, t.text_strong); + icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(10.0, 10.0)), t.icon); + let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" }); + let mut out = None; + egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| { + ui.set_min_width(140.0); + let opts: Vec> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect(); + for a in opts { + let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click()); + if a == cur { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if rr.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + ui.painter().text( + row.left_center() + vec2(66.0, 0.0), + egui::Align2::LEFT_CENTER, + arrow_label(a), + egui::FontId::proportional(11.5), + t.text, + ); + if rr.clicked() { + out = Some(a); + ui.close(); + } + } + }); + out +} + +/// Draw a width profile's silhouette into `r`. +pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) { + let n = 32; + let half = r.height() / 2.0 - 1.0; + let mut top = vec![]; + let mut bot = vec![]; + for i in 0..=n { + let tt = i as f64 / n as f64; + let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35)); + let x = r.left() + tt as f32 * r.width(); + top.push(pos2(x, r.center().y - l as f32 * half)); + bot.push(pos2(x, r.center().y + rr as f32 * half)); + } + let mut mesh = egui::Mesh::default(); + for i in 0..=n { + mesh.colored_vertex(top[i], color); + mesh.colored_vertex(bot[i], color); + } + for i in 0..n as u32 { + let a = i * 2; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, 2, t.input); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); + let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0)); + paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong); + icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon); + let resp = resp.on_hover_text("Variable Width Profile"); + let mut out = None; + egui::Popup::menu(&resp).show(|ui| { + for (id, label) in PROFILES { + let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); + if id == cur { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if rr.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong); + ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text); + if rr.clicked() { + out = Some(id); + ui.close(); + } + } + }); + out +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let hidden: bool = pstate(ui.ctx(), "stroke-hide-options"); + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "stroke-hide-options", !hidden); + } + menu_item(ui, "Add to Profiles", false, false); + menu_item(ui, "Delete Profile", false, false); + if menu_item(ui, "Reset Profile", stroke_now(app).is_some_and(|s| s.profile.is_some()), false) { + set(app, json!({"profile": "uniform"})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dash_fields_roundtrip() { + let f = dash_fields(&[12.0, 6.0, 2.0, 3.0]); + assert_eq!(f, [Some(12.0), Some(6.0), Some(2.0), Some(3.0), None, None]); + assert_eq!(dash_pattern(&f), vec![12.0, 6.0, 2.0, 3.0]); + } + + #[test] + fn dash_without_gap_repeats_dash() { + assert_eq!(dash_pattern(&[Some(5.0), None, None, Some(9.0), None, None]), vec![5.0, 5.0]); + assert!(dash_pattern(&[None; 6]).is_empty()); + assert!(dash_pattern(&[Some(0.0), Some(0.0), None, None, None, None]).is_empty()); + } + + #[test] + fn profile_ids() { + assert_eq!(profile_id(None), "uniform"); + assert_eq!(profile_id(Some(&WidthProfile::lens())), "lens"); + assert_eq!(profile_id(Some(&WidthProfile::taper_end())), "taperEnd"); + assert_eq!(profile_id(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); + assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs new file mode 100644 index 00000000..36054f0a --- /dev/null +++ b/crates/ui-egui/src/panels/swatches.rs @@ -0,0 +1,407 @@ +//! Swatches panel: proxy, Recent Colors, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, +//! colour groups as folders, None/Registration first, bottom bar and panel menu. + +use drawcraft_color::Paint; +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use super::{active_paint, paint_target, pstate, push_recent, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item, swatch_tile}; +use crate::{DrawcraftApp, icons}; + +/// View modes of the swatch list. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum View { + SmallThumb, + #[default] + MediumThumb, + LargeThumb, + SmallList, + LargeList, +} + +impl View { + const ALL: [(View, &'static str); 5] = [ + (View::SmallThumb, "Small Thumbnail View"), + (View::MediumThumb, "Medium Thumbnail View"), + (View::LargeThumb, "Large Thumbnail View"), + (View::SmallList, "Small List View"), + (View::LargeList, "Large List View"), + ]; + fn is_list(self) -> bool { + matches!(self, View::SmallList | View::LargeList) + } + /// (tile, pitch) in points. Medium is the measured 15.5 / 17 pt. + fn tile(self) -> (f32, f32) { + match self { + View::SmallThumb => (11.0, 12.5), + View::MediumThumb => (15.5, 17.0), + View::LargeThumb => (30.0, 32.0), + View::SmallList => (12.0, 17.0), + View::LargeList => (18.0, 25.0), + } + } +} + +/// Swatch-kind filter (Show Swatch Kinds menu). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + #[default] + All, + Color, + Gradient, + Pattern, + Groups, +} + +impl Kind { + const ALL: [(Kind, &'static str); 5] = [ + (Kind::All, "Show All Swatches"), + (Kind::Color, "Show Color Swatches"), + (Kind::Gradient, "Show Gradient Swatches"), + (Kind::Pattern, "Show Pattern Swatches"), + (Kind::Groups, "Show Color Groups"), + ]; + /// Does a swatch with `paint` (in a group or not) pass the filter? + pub fn accepts(self, paint: &Paint, in_group: bool) -> bool { + match self { + Kind::All => true, + Kind::Color => matches!(paint, Paint::Solid { .. } | Paint::None), + Kind::Gradient => matches!(paint, Paint::Gradient(_)), + Kind::Pattern => matches!(paint, Paint::Pattern { .. }), + Kind::Groups => in_group, + } + } +} + +/// A row of the swatch list: special tiles, plain swatches and group folders. +#[derive(Clone, Debug)] +enum Entry { + Registration, + Swatch { name: String, paint: Paint, global: bool, spot: bool }, + Folder(String), +} + +const REGISTRATION: &str = "[Registration]"; + +fn entries(app: &DrawcraftApp, kind: Kind) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + let mut out = vec![]; + let sw = |s: &drawcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot }; + // None first, then Registration, then the rest (Illustrator's order). + let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); + for s in specials.iter().filter(|s| kind.accepts(&s.paint, false)) { + out.push(sw(s)); + } + if matches!(kind, Kind::All | Kind::Color) { + out.push(Entry::Registration); + } + for s in rest.iter().filter(|s| kind.accepts(&s.paint, false)) { + out.push(sw(s)); + } + for g in &d.swatch_groups { + let items: Vec<_> = g.swatches.iter().filter(|s| kind.accepts(&s.paint, true)).collect(); + if items.is_empty() && kind != Kind::Groups && kind != Kind::All { + continue; + } + out.push(Entry::Folder(g.name.clone())); + out.extend(items.into_iter().map(sw)); + } + out +} + +fn apply(app: &mut DrawcraftApp, ui: &Ui, e: &Entry) { + let target = paint_target(app); + match e { + Entry::Registration => { + app.run(target, json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})).ok(); + } + Entry::Swatch { name, paint, .. } => { + let r = if paint.is_none() { app.run(target, json!({"none": true})) } else { app.run(target, json!({"swatch": name})) }; + if r.is_ok() + && let Some(c) = paint.color() + { + push_recent(ui.ctx(), c); + } + } + Entry::Folder(_) => {} + } +} + +fn draw_registration(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + let c = r.center(); + let rad = r.width() * 0.28; + let s = Stroke::new(1.0, egui::Color32::BLACK); + ui.painter().circle_stroke(c, rad, s); + ui.painter().line_segment([pos2(r.left() + 2.0, c.y), pos2(r.right() - 2.0, c.y)], s); + ui.painter().line_segment([pos2(c.x, r.top() + 2.0), pos2(c.x, r.bottom() - 2.0)], s); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); +} + +fn draw_folder(ui: &Ui, r: Rect) { + let t = Tokens::get(ui.ctx()); + icons::paint(ui, "dc-folder", r.expand(1.0), t.icon); +} + +fn selected_name(ui: &Ui) -> Option { + pstate::>(ui.ctx(), "swatch-selected") +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if app.session.active().is_none() { + super::empty_state(ui, "swatch-book", "No document", "Open a document to see its swatches."); + return; + } + let view: View = pstate(ui.ctx(), "swatch-view"); + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + // Top row: proxy at left, list / grid view toggles at right. + ui.horizontal(|ui| { + super::proxy(app, ui, 34.0); + ui.add_space(ui.available_width() - 58.0); + if widgets::icon_button(ui, "dc-list-view", "Show List View", view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::SmallList); + } + if widgets::icon_button(ui, "dc-grid-view", "Show Thumbnail View", !view.is_list(), 26.0).clicked() { + set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); + } + }); + ui.add_space(4.0); + super::recent_colors_row(app, ui); + widgets::divider(ui); + widgets::subheader(ui, "Swatch Tiles"); + let items = entries(app, kind); + let active = active_paint(app); + let active_swatch = match &active { + Paint::Solid { swatch: Some(n), .. } => Some(n.clone()), + Paint::None => Some("[None]".to_string()), + _ => None, + }; + let sel = selected_name(ui).or(active_swatch); + let (tile, pitch) = view.tile(); + let mut clicked: Option = None; + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + if view.is_list() { + for e in &items { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click()); + let name = match e { + Entry::Registration => REGISTRATION.to_string(), + Entry::Swatch { name, .. } => name.clone(), + Entry::Folder(n) => n.clone(), + }; + let is_sel = sel.as_deref() == Some(name.as_str()); + if is_sel { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); + match e { + Entry::Registration => draw_registration(ui, chip), + Entry::Swatch { paint, .. } => swatch_tile(ui, chip, paint, false, false), + Entry::Folder(_) => draw_folder(ui, chip), + } + ui.painter().text( + pos2(chip.right() + 8.0, r.center().y), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(12.0), + t.text, + ); + // Kind markers at the right: global (white corner) / spot (dot) / process. + if let Entry::Swatch { global, spot, paint, .. } = e { + let mk = Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(10.0, 10.0)); + if *spot { + ui.painter().circle_filled(mk.center(), 3.5, t.icon); + } else if matches!(paint, Paint::Gradient(_)) { + icons::paint(ui, "dc-grad-linear", mk, t.icon); + } else if *global { + ui.painter().add(egui::Shape::convex_polygon( + vec![mk.left_bottom(), mk.right_bottom(), mk.right_top()], + t.icon, + Stroke::NONE, + )); + } + } + if resp.on_hover_text(&name).clicked() && !matches!(e, Entry::Folder(_)) { + clicked = Some(e.clone()); + } + } + } else { + let w = ui.available_width(); + let per_row = ((w - 2.0) / pitch).floor().max(1.0) as usize; + let mut col = 0usize; + let mut rows: Vec> = vec![vec![]]; + for (i, e) in items.iter().enumerate() { + // A group folder starts a new row. + if matches!(e, Entry::Folder(_)) && col > 0 { + rows.push(vec![]); + col = 0; + } + if col == per_row { + rows.push(vec![]); + col = 0; + } + rows.last_mut().unwrap().push((i, e)); + col += 1; + } + for row in rows { + let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover()); + for (c, (i, e)) in row.into_iter().enumerate() { + let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile)); + let resp = ui.interact(cell, ui.id().with(("sw", i)), Sense::click()); + let (name, tip) = match e { + Entry::Registration => (REGISTRATION.to_string(), "[Registration]".to_string()), + Entry::Swatch { name, .. } => (name.clone(), name.clone()), + Entry::Folder(n) => (n.clone(), format!("Color Group: {n}")), + }; + match e { + Entry::Registration => draw_registration(ui, cell), + Entry::Swatch { paint, global, .. } => { + swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered()); + if *global { + let k = cell.shrink(1.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![k.right_bottom(), k.right_bottom() - vec2(5.0, 0.0), k.right_bottom() - vec2(0.0, 5.0)], + egui::Color32::WHITE, + Stroke::NONE, + )); + } + } + Entry::Folder(_) => draw_folder(ui, cell), + } + if resp.on_hover_text(tip).clicked() && !matches!(e, Entry::Folder(_)) { + clicked = Some(e.clone()); + } + } + } + } + }); + }); + if let Some(e) = clicked { + let name = match &e { + Entry::Registration => REGISTRATION.to_string(), + Entry::Swatch { name, .. } => name.clone(), + Entry::Folder(n) => n.clone(), + }; + set_pstate(ui.ctx(), "swatch-selected", Some(name)); + apply(app, ui, &e); + } + bottom(app, ui); +} + +fn deletable(ui: &Ui) -> Option { + selected_name(ui).filter(|n| !n.starts_with('[')) +} + +fn bottom(app: &mut DrawcraftApp, ui: &mut Ui) { + let kind: Kind = pstate(ui.ctx(), "swatch-kind"); + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "library", "Swatch Libraries (on the roadmap)", false, false, 24.0); + let kr = widgets::icon_button(ui, "dc-swatch-kinds", "Show Swatch Kinds", kind != Kind::All, 24.0); + egui::Popup::menu(&kr).show(|ui| { + for (k, label) in Kind::ALL { + if menu_item(ui, label, true, k == kind) { + set_pstate(ui.ctx(), "swatch-kind", k); + } + } + }); + widgets::icon_button_enabled(ui, "dc-options", "Swatch Options (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); + if widgets::icon_button(ui, "dc-folder", "New Color Group", false, 24.0).clicked() { + let names: Vec = deletable(ui).into_iter().collect(); + app.run("swatch.newGroup", json!({"swatches": names})).ok(); + } + if widgets::icon_button(ui, "dc-new-item", "New Swatch", false, 24.0).clicked() { + new_swatch(app); + } + let del = deletable(ui); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Swatch", false, del.is_some(), 24.0).clicked() + && let Some(n) = del + && app.run("swatch.delete", json!({"name": n})).is_ok() + { + set_pstate::>(ui.ctx(), "swatch-selected", None); + } + }); +} + +fn new_swatch(app: &mut DrawcraftApp) { + let p = active_paint(app); + let params = match &p { + Paint::None => json!({}), + other => super::paint_params(other), + }; + app.run("swatch.new", params).ok(); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let view: View = pstate(ui.ctx(), "swatch-view"); + let sel = deletable(ui); + if menu_item(ui, "New Swatch…", true, false) { + new_swatch(app); + } + if menu_item(ui, "New Color Group…", true, false) { + app.run("swatch.newGroup", json!({"swatches": sel.clone().into_iter().collect::>()})).ok(); + } + if menu_item(ui, "Duplicate Swatch", sel.is_some(), false) + && let Some(n) = &sel + { + app.run("swatch.duplicate", json!({"name": n})).ok(); + } + menu_item(ui, "Merge Swatches", false, false); + if menu_item(ui, "Delete Swatch", sel.is_some(), false) + && let Some(n) = &sel + { + app.run("swatch.delete", json!({"name": n})).ok(); + } + menu_item(ui, "Ungroup Color Group", false, false); + menu_item(ui, "Select All Unused", false, false); + menu_item(ui, "Add Used Colors", false, false); + ui.separator(); + if menu_item(ui, "Sort by Name", true, false) { + app.run("swatch.sortByName", json!({})).ok(); + } + menu_item(ui, "Sort by Kind", false, false); + ui.separator(); + for (v, label) in View::ALL { + if menu_item(ui, label, true, v == view) { + set_pstate(ui.ctx(), "swatch-view", v); + } + } + ui.separator(); + menu_item(ui, "Swatch Options…", false, false); + menu_item(ui, "Spot Colors…", false, false); + ui.separator(); + menu_item(ui, "Open Swatch Library", false, false); + menu_item(ui, "Export Swatches Library as ASE…", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + use drawcraft_color::Color; + + #[test] + fn kind_filter() { + let solid = Paint::solid(Color::BLACK); + let grad = Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))); + assert!(Kind::All.accepts(&grad, false)); + assert!(Kind::Color.accepts(&solid, false)); + assert!(!Kind::Color.accepts(&grad, false)); + assert!(Kind::Gradient.accepts(&grad, true)); + assert!(Kind::Groups.accepts(&solid, true)); + assert!(!Kind::Groups.accepts(&solid, false)); + } + + #[test] + fn medium_tiles_match_measured_metrics() { + assert_eq!(View::MediumThumb.tile(), (15.5, 17.0)); + assert!(View::SmallList.is_list() && !View::LargeThumb.is_list()); + } +} diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs new file mode 100644 index 00000000..25e4eb86 --- /dev/null +++ b/crates/ui-egui/src/panels/symbols.rs @@ -0,0 +1,99 @@ +//! Symbols panel: the document's symbols as thumbnails or a list, with an honest empty state. + +use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; + +use super::{pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{DrawcraftApp, icons}; + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let names: Vec = app.session.active().map(|d| d.doc.symbols.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let list: bool = pstate(ui.ctx(), "sym-list"); + let sel: Option = pstate(ui.ctx(), "sym-sel"); + widgets::list_box(ui, |ui| { + ui.set_min_height(110.0); + ui.set_width(ui.available_width()); + if names.is_empty() { + super::empty_state( + ui, + "spray-can", + "No symbols in this document", + "Symbols come in through SVG/PDF import; creating them is on the roadmap.", + ); + return; + } + if list { + for n in &names { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if sel.as_deref() == Some(n.as_str()) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + icons::paint(ui, "shapes", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); + ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + set_pstate(ui.ctx(), "sym-sel", Some(n.clone())); + } + } + } else { + ui.horizontal_wrapped(|ui| { + for n in &names { + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + icons::paint(ui, "shapes", r.shrink(8.0), egui::Color32::DARK_GRAY); + let on = sel.as_deref() == Some(n.as_str()); + ui.painter().rect_stroke( + r, + 0.0, + Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), + StrokeKind::Inside, + ); + if resp.on_hover_text(n).clicked() { + set_pstate(ui.ctx(), "sym-sel", Some(n.clone())); + } + } + }); + } + }); + widgets::bottom_bar(ui, |ui| { + widgets::icon_button_enabled(ui, "library", "Symbol Libraries (on the roadmap)", false, false, 24.0); + widgets::icon_button_enabled(ui, "dc-place-symbol", "Place Symbol Instance (on the roadmap)", false, false, 24.0); + widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Symbol (on the roadmap)", false, false, 24.0); + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + widgets::icon_button_enabled(ui, "dc-new-item", "New Symbol (on the roadmap)", false, false, 24.0); + widgets::icon_button_enabled(ui, "trash-2", "Delete Symbol (on the roadmap)", false, false, 24.0); + }); +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + for l in [ + "New Symbol…", + "Redefine Symbol", + "Duplicate Symbol", + "Delete Symbol", + "Edit Symbol", + "Place Symbol Instance", + "Replace Symbol", + "Break Link to Symbol", + ] { + menu_item(ui, l, false, false); + } + ui.separator(); + menu_item(ui, "Select All Unused", false, false); + menu_item(ui, "Select All Instances", false, false); + menu_item(ui, "Sort by Name", false, false); + ui.separator(); + let list: bool = pstate(ui.ctx(), "sym-list"); + if menu_item(ui, "Thumbnail View", true, !list) { + set_pstate(ui.ctx(), "sym-list", false); + } + if menu_item(ui, "List View", true, list) { + set_pstate(ui.ctx(), "sym-list", true); + } + ui.separator(); + menu_item(ui, "Symbol Options…", false, false); + menu_item(ui, "Open Symbol Library", false, false); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs new file mode 100644 index 00000000..982c98ec --- /dev/null +++ b/crates/ui-egui/src/panels/transform.rs @@ -0,0 +1,172 @@ +//! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips, +//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. + +use drawcraft_doc::NodeKind; +use egui::Ui; +use serde_json::json; + +use super::{first_selected, pstate, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; +use crate::{DrawcraftApp, icons}; + +pub const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0]; + +/// Proportional size: the other dimension when one changes with the link on. +pub fn constrained(w: f64, h: f64, new_w: Option, new_h: Option) -> (f64, f64) { + match (new_w, new_h) { + (Some(nw), _) if w.abs() > 1e-9 => (nw, h * nw / w), + (_, Some(nh)) if h.abs() > 1e-9 => (w * nh / h, nh), + (a, b) => (a.unwrap_or(w), b.unwrap_or(h)), + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "No document"); + return; + }; + let units = st.doc.units; + let bounds = st.doc.bounds_of(&st.selection.objects, false); + let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); + let link: bool = pstate(ui.ctx(), "xf-link"); + let has = bounds.is_some(); + let rp = bounds.map(|b| drawcraft_geom::reference_point(b, refi)); + ui.horizontal(|ui| { + if let Some(i) = widgets::reference_point(ui, refi) { + ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); + } + ui.add_space(4.0); + ui.add_enabled_ui(has, |ui| { + egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { + widgets::dim_label(ui, "X:"); + if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x), units, 74.0) { + app.run("object.setBounds", json!({"x": v, "reference": refi})).ok(); + } + widgets::dim_label(ui, "W:"); + if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 74.0) { + app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); + } + ui.end_row(); + widgets::dim_label(ui, "Y:"); + if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y), units, 74.0) { + app.run("object.setBounds", json!({"y": v, "reference": refi})).ok(); + } + widgets::dim_label(ui, "H:"); + if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 74.0) { + app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); + } + ui.end_row(); + }); + }); + if widgets::icon_button(ui, if link { "link" } else { "link-2-off" }, "Constrain Width and Height Proportions", link, 22.0).clicked() { + set_pstate(ui.ctx(), "xf-link", !link); + } + }); + ui.add_space(4.0); + let origin = rp.map(|p| json!([p.x, p.y])); + ui.horizontal(|ui| { + ui.add_enabled_ui(has, |ui| { + icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text("Rotate"); + // Rotation is applied relative to the current orientation (0° shown after each apply). + if let Some(a) = widgets::spin_plain(ui, "xfp-rot", 0.0, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) + && a != 0.0 + { + app.run("object.rotate", json!({"angle": a, "origin": origin})).ok(); + } + ui.add_space(4.0); + icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text("Shear"); + if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0) + && a != 0.0 + { + app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin})).ok(); + } + }); + }); + ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "flip-horizontal-2", "Flip Horizontal", false, has, 24.0).clicked() { + app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok(); + } + if widgets::icon_button_enabled(ui, "flip-vertical-2", "Flip Vertical", false, has, 24.0).clicked() { + app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok(); + } + }); + // Live shape properties. + if let Some(n) = first_selected(app) + && let NodeKind::Path { live: Some(live), .. } = &n.kind + { + widgets::divider(ui); + match live { + drawcraft_doc::LiveShape::Rectangle { radii, .. } => { + widgets::subheader(ui, "Rectangle Properties:"); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Corner Radius:"); + if let Some(r) = widgets::num_field(ui, "xfp-radius", Some(radii[0]), units, 80.0) { + app.run("object.setLiveShape", json!({"radius": r})).ok(); + } + }); + } + drawcraft_doc::LiveShape::Polygon { sides, .. } => { + widgets::subheader(ui, "Polygon Properties:"); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Sides:"); + if let Some(s) = widgets::plain_field(ui, "xfp-sides", *sides as f64, "", 0, 60.0) { + app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok(); + } + }); + } + _ => { + widgets::subheader(ui, "Shape Properties:"); + widgets::dim_label(ui, n.kind_label()); + } + } + } + if pstate::(ui.ctx(), "xf-hide-options") { + return; + } + widgets::divider(ui); + widgets::check(ui, "Scale Corners", false, false); + let ss = app.session.prefs.scale_strokes; + if widgets::check(ui, "Scale Strokes & Effects", ss, true) { + app.session.prefs.scale_strokes = !ss; + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let hidden: bool = pstate(ui.ctx(), "xf-hide-options"); + let has = super::selection_len(app) > 0; + if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "xf-hide-options", !hidden); + } + ui.separator(); + if menu_item(ui, "Flip Horizontal", has, false) { + app.run("object.reflect", json!({"axis": "vertical"})).ok(); + } + if menu_item(ui, "Flip Vertical", has, false) { + app.run("object.reflect", json!({"axis": "horizontal"})).ok(); + } + ui.separator(); + let ss = app.session.prefs.scale_strokes; + if menu_item(ui, "Scale Strokes & Effects", true, ss) { + app.session.prefs.scale_strokes = !ss; + } + ui.separator(); + menu_item(ui, "Transform Object Only", false, true); + menu_item(ui, "Transform Pattern Only", false, false); + menu_item(ui, "Transform Both", false, false); + ui.separator(); + menu_item(ui, "Use Registration Point for Symbol", false, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrain_proportions() { + assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); + assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0)); + assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0)); + } +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs new file mode 100644 index 00000000..75d71c73 --- /dev/null +++ b/crates/ui-egui/src/panels/transparency.rs @@ -0,0 +1,113 @@ +//! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the +//! opacity-mask controls (on the roadmap), Isolate Blending and Knockout Group. + +use drawcraft_color::{BlendMode, Paint}; +use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use super::{current_paints, first_selected, live_run, pstate, selection_len, set_pstate}; +use crate::theme::Tokens; +use crate::widgets::{self, Live, menu_item}; +use crate::{DrawcraftApp, icons}; + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let n = first_selected(app); + let has = n.is_some(); + let (blend, op, isolate, knockout) = + n.as_ref().map(|n| (n.blend, n.opacity, n.isolate, n.knockout)).unwrap_or((BlendMode::Normal, 1.0, false, false)); + ui.horizontal(|ui| { + let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); + ui.add_enabled_ui(has, |ui| { + if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) { + app.run("transparency.set", json!({"blend": labels[i]})).ok(); + } + }); + widgets::dim_label(ui, "Opacity:"); + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_enabled_ui(has, |ui| { + if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) { + app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); + } + }); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if has { Sense::click() } else { Sense::hover() }); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if has { t.icon } else { t.text_disabled }); + egui::Popup::menu(&resp).show(|ui| { + let mut o = op * 100.0; + let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); + let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { + Live::Released + } else if r.changed() { + Live::Dragging + } else { + Live::Idle + }; + live_run(app, "Opacity", "transparency.set", json!({"opacity": o.round()}), phase); + }); + }); + widgets::divider(ui); + // Thumbnails and the opacity-mask controls. + let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); + ui.horizontal(|ui| { + if !hide_thumbs { + let (r, _) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.border), StrokeKind::Outside); + if has { + let (f, s) = current_paints(app); + let inner = r.shrink2(vec2(6.0, 10.0)); + widgets::paint_chip(ui, inner, &f); + if let Some(c) = s.color() + && !matches!(s, Paint::None) + { + ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, super::c32(&c)), StrokeKind::Middle); + } + } + let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover()); + ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled); + } + ui.vertical(|ui| { + ui.add_enabled_ui(false, |ui| widgets::flat_button(ui, "Make Mask", 96.0)) + .inner + .on_disabled_hover_text("Opacity masks are on the roadmap"); + widgets::check(ui, "Clip", true, false); + widgets::check(ui, "Invert Mask", false, false); + }); + }); + if !pstate::(ui.ctx(), "tr-hide-options") { + widgets::divider(ui); + if widgets::check(ui, "Isolate Blending", isolate, has) { + app.run("transparency.set", json!({"isolate": !isolate})).ok(); + } + if widgets::check(ui, "Knockout Group", knockout, has) { + app.run("transparency.set", json!({"knockout": !knockout})).ok(); + } + widgets::check(ui, "Opacity & Mask Define Knockout Shape", false, false); + } + if !has { + widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" }); + } +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs"); + let hide_opts: bool = pstate(ui.ctx(), "tr-hide-options"); + if menu_item(ui, if hide_thumbs { "Show Thumbnails" } else { "Hide Thumbnails" }, true, false) { + set_pstate(ui.ctx(), "tr-hide-thumbs", !hide_thumbs); + } + if menu_item(ui, if hide_opts { "Show Options" } else { "Hide Options" }, true, false) { + set_pstate(ui.ctx(), "tr-hide-options", !hide_opts); + } + ui.separator(); + for l in ["Make Opacity Mask", "Release Opacity Mask", "Disable Opacity Mask", "Unlink Opacity Mask"] { + menu_item(ui, l, false, false); + } + ui.separator(); + menu_item(ui, "New Opacity Masks Are Clipping", false, true); + menu_item(ui, "New Opacity Masks Are Inverted", false, false); + ui.separator(); + menu_item(ui, "Page Isolated Blending", false, false); + menu_item(ui, "Page Knockout Group", false, false); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0951ccce..f0d48e48 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -103,20 +103,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if !editing { buf = shown.clone(); } - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(width - 14.0) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) + let resp = ui + .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(width); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 14.0) + .min_size(vec2(width - 14.0, 0.0)) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner }) .inner; let commit = resp.lost_focus() && buf != shown; @@ -135,20 +141,26 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu }; let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(width - 14.0) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) + let resp = ui + .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(width); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 14.0) + .min_size(vec2(width - 14.0, 0.0)) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner }) .inner; ui.data_mut(|d| d.insert_temp(id, buf.clone())); @@ -172,6 +184,12 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } Paint::Gradient(gp) => { + if gp.gradient.kind == drawcraft_color::GradientKind::Radial { + // Outer colour fills the corners beyond the largest circle. + let (c, _) = gp.gradient.sample(1.0); + let [r, g, b, _] = c.to_rgba8(1.0); + p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); + } let n = 24; let w = rect.width() / n as f32; for i in 0..n { @@ -302,3 +320,290 @@ pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) - } resp.on_hover_text(tip).clicked() } + +// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ---------- + +/// Icon button that can be disabled (greyed, no hover, never clicked). +pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response { + if enabled { + return icon_button(ui, icon, tip, selected, size); + } + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); + if tip.is_empty() { resp } else { resp.on_hover_text(tip) } +} + +/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:"). +pub fn subheader(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).size(12.5).color(t.text)); +} + +/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:"). +pub fn link_label(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), t.text_strong); + let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click()); + let y = rect.top() + galley.size().y + 1.0; + ui.painter().galley(rect.min, galley, t.text_strong); + let mut x = rect.left(); + while x < rect.right() - 1.0 { + ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); + x += 2.5; + } + resp +} + +/// Illustrator-style checkbox with a disabled state. Returns true when toggled. +pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); + let (rect, resp) = + ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); + let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); + ui.painter().rect_filled(bx, CornerRadius::same(2), if value && enabled { t.accent_strong } else { t.input }); + ui.painter().rect_stroke( + bx, + CornerRadius::same(2), + Stroke::new( + 1.0, + if !enabled { + t.divider + } else if resp.hovered() { + t.text + } else { + t.button_border + }, + ), + StrokeKind::Inside, + ); + if value { + let c = if enabled { Color32::WHITE } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); + ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); + } + ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + enabled && resp.clicked() +} + +/// Numeric field with an up/down spinner on the left and a preset dropdown on the right +/// (Stroke weight, font size, leading…). `presets` empty = no dropdown. Returns the committed value. +#[allow(clippy::too_many_arguments)] +pub fn spin_field( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug + Copy, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_generic(ui, value, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw)) +} + +/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug + Copy, + value: f64, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let fmt = |v: f64| format!("{v}{suffix}"); + spin_generic(ui, Some(value), width, step, min, presets, &fmt, &mut |ui, fw| plain_field(ui, id, value, suffix, decimals, fw)) +} + +#[allow(clippy::too_many_arguments)] +fn spin_generic( + ui: &mut Ui, + value: Option, + width: f32, + step: f64, + min: f64, + presets: &[f64], + fmt: &dyn Fn(f64) -> String, + field: &mut dyn FnMut(&mut Ui, f32) -> Option, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let h = 26.0; + let enabled = ui.is_enabled(); + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); + ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); + ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); + let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max); + let hover = sresp.hover_pos(); + for (r, is_up) in [(up, true), (down, false)] { + let c = if !enabled { + t.text_disabled + } else if hover.is_some_and(|p| r.contains(p)) { + t.text_strong + } else { + t.icon + }; + let m = r.center(); + let d = if is_up { -1.5 } else { 1.5 }; + ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); + ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); + } + if sresp.clicked() + && let Some(p) = sresp.interact_pointer_pos() + { + let v = value.unwrap_or(0.0); + let nv = if up.contains(p) { v + step } else { v - step }; + out = Some(nv.max(min)); + } + let fw = if presets.is_empty() { width - 16.0 } else { width - 36.0 }; + if let Some(v) = field(ui, fw) { + out = Some(v.max(min)); + } + if !presets.is_empty() { + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); + ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let c = if !enabled { + t.text_disabled + } else if dresp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + egui::Popup::menu(&dresp).show(|ui| { + ui.set_min_width(width - 10.0); + for p in presets { + if ui.selectable_label(value.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { + out = Some(*p); + } + } + }); + } + }); + out +} + +/// Drag phase of a live slider/handle: previews while dragging, commits on release. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Live { + Idle, + Dragging, + Released, +} + +/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular +/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. +pub fn color_slider( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); + let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let n = (bar.width() / 2.0).ceil().max(2.0) as usize; + let seg = bar.width() / n as f32; + for i in 0..n { + let r = Rect::from_min_size(pos2(bar.left() + i as f32 * seg, bar.top()), vec2(seg + 0.6, bar.height())); + ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); + } + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let v = value.clamp(0.0, 1.0); + let x = bar.left() + v * bar.width(); + let tip = pos2(x, bar.bottom() - 1.0); + let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; + let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; + ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); + let mut out = None; + let mut phase = Live::Idle; + if (resp.dragged() || resp.clicked() || resp.drag_stopped()) + && let Some(p) = resp.interact_pointer_pos() + { + out = Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)); + phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }; + } + (out, phase) +} + +/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). +pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) { + let t = Tokens::get(ui.ctx()); + paint_chip(ui, rect, paint); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + if selected || hovered { + ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside); + } +} + +/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). +pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(1)).show(ui, add).inner +} + +/// A bottom button bar separated from the content by a divider (panel footers). +pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.divider); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + add(ui) + }); +} + +/// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. +pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { + let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; + ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked() +} + +/// A number field that may be empty (Stroke dash/gap). Returns `Some(new)` on commit, where +/// `new` is `None` when the field was cleared. +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, width: f32) -> Option> { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with(id); + let shown = value.map(|v| format!("{v}")).unwrap_or_default(); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; + let enabled = ui.is_enabled(); + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(4, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 10.0) + .font(egui::FontId::proportional(12.5)) + .text_color(if enabled { t.text_strong } else { t.text_disabled }), + ) + }) + .inner; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if resp.lost_focus() && buf != shown { + let s = buf.trim().trim_end_matches("pt").trim(); + if s.is_empty() { Some(None) } else { s.parse::().ok().map(Some) } + } else { + None + } +} diff --git a/xtask/src/bundle.rs b/xtask/src/bundle.rs index f07cdb48..3b48117b 100644 --- a/xtask/src/bundle.rs +++ b/xtask/src/bundle.rs @@ -24,7 +24,16 @@ pub fn icon_png(size: u32) -> Vec { 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(); + 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( @@ -40,15 +49,37 @@ pub fn icon_png(size: u32) -> Vec { 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.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(); + 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())?; + 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()); } @@ -63,11 +94,29 @@ pub fn run(root: &Path) -> Result<(), String> { 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")] { + 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); + 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"); }